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 8ae524d801 fix(pairing): every device pending approval was called "This device"
ci / bun-nix (pull_request) Successful in 1m32s
ci / rust-arm64 (pull_request) Successful in 1m49s
apple / swift (pull_request) Successful in 2m8s
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 3m29s
ci / web (pull_request) Successful in 4m1s
ci / docs-site (pull_request) Successful in 4m6s
ci / rust (pull_request) Successful in 7m8s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m44s
android / android (pull_request) Successful in 15m39s
The web console shows exactly what the host was told, and the host was told
"This device" by every Apple client — so the outstanding-pairings view and the
approve dialog listed identical rows for an iPad, an Apple TV and a Mac.

The name rides `Hello::name`, which embedders fill from `client::device_name()`.
That resolves `COMPUTERNAME` (Windows-only) then `HOSTNAME` (a shell variable
never exported into a launchd-started process), and its last resort was the
literal "This device". No Apple GUI app has either variable, and the C ABI had
no device-name parameter for one to pass a better answer through, so every
Apple device fell through to the placeholder. Linux (/etc/hostname) and Windows
were unaffected; Android sent `Build.MODEL`, which names the product rather than
the unit — two of the same tablet were still indistinguishable.

- core: `punktfunk_connect_ex10` = `ex9` + `device_name` (C ABI v21, no wire
  change — `ex9` keeps its signature and passes a null name for the old
  default). Truncated to `HELLO_NAME_MAX` on a character boundary, since
  slicing a multi-byte name mid-scalar panics.
- core: `device_name()` falls back to `gethostname()` before the placeholder,
  so an embedder that passes nothing still gets a real name.
- apple: `DeviceName.current` (`Host.localizedName` / `UIDevice.current.name`,
  falling back to the hostname when 16+ answers with the bare model) is sent on
  connect, and the two pairing sheets plus the ceremony now read that one source
  instead of three separate literals.
- android: `Settings.Global.DEVICE_NAME` — the name the user typed in Settings —
  ahead of `Build.MODEL`. The "approve this device" prompt quotes the same
  string the connect knocks with, so it can't send the user looking for a row
  the console does not show.
- web: the approve dialog names the device and its fingerprint. A pre-filled
  field is editable text, not a statement of which knock is being approved.
2026-08-15 01:05:15 +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
enricobuehler 8c6099da2a Merge pull request 'The mgmt-port learn landed without its Windows half — restore the three missing pieces' (#241) from worktree-windows-client-mgmt-port-fix into main
ci / docs-site (push) Successful in 1m22s
ci / bun-nix (push) Successful in 24s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m4s
android / android (push) Successful in 8m32s
deb / build-publish-gamescope (push) Successful in 29s
deb / build-publish-client-arm64 (push) Successful in 3m24s
deb / build-publish (push) Successful in 8m24s
ci / rust-arm64 (push) Successful in 11m45s
deb / build-publish-host (push) Successful in 5m6s
ci / web (push) Successful in 11m58s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 6m40s
arch / build-publish (push) Successful in 17m15s
flatpak / build-publish (push) Successful in 8m43s
deb / smoke-install (push) Successful in 3m45s
ci / rust (push) Successful in 26m55s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m50s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 9s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
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 5s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / builders-arm64cross (push) Successful in 8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 49s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m18s
docker / deploy-docs (push) Successful in 1m9s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 29m36s
Reviewed-on: #241
2026-08-14 22:23:57 +00:00
enricobuehler 2b066b3e11 fix(clients): the mgmt-port learn landed without its Windows half — restore the three missing pieces
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m50s
ci / rust-arm64 (pull_request) Successful in 2m40s
ci / bun-nix (pull_request) Successful in 3m13s
ci / docs-site (pull_request) Successful in 4m3s
ci / web (pull_request) Successful in 4m5s
android / android (pull_request) Successful in 5m48s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m21s
ci / rust (pull_request) Successful in 11m34s
99eb679c wired the learned management port through every client, and its own commit message
records the hole: 'NOT verified: the Windows client (192.168.1.133 unreachable)'. The Windows
shell's usage sites landed, but the three definitions they lean on did not exist anywhere, so
main's windows-client build has been red since the merge of #230:

* clients/windows/src/trust.rs re-exports core's trust surface — learn_mgmt_port was added to
  core but never joined the re-export list (hosts.rs:729, E0425).
* The Windows shell's own mDNS browser (discovery.rs, 'ported verbatim from the GTK client')
  never learned the mgmt TXT that pf_client_core::discovery already parses — DiscoveredHost
  gains the field, parsed the same way (hosts.rs:725/727/1062, E0609).
* HostTarget ('the host a plan dials') never carried the port, so the Target the shell builds
  from a ConnectPlan had nothing to read (mod.rs:414, E0609). Wired From<&KnownHost> like mac;
  the two spawn-path literals stay None — a spawn plan never fetches the library, and each
  shell resolves the port itself at its fetch site.

cargo fmt --all --check clean. pf-client-core/linux/session compile via the rust CI job; the
windows-client job on this PR is the verification 99eb679c could not run.
2026-08-15 00:13:43 +02:00
enricobuehler c3c24b5855 Merge pull request 'Hyprland streaming worked for exactly one session, and the cursor was never on the streamed head — six bugs, all ours' (#240) from worktree-capture-bgra-dmabuf-pod into main
ci / rust-arm64 (push) Failing after 2m40s
android / android (push) Successful in 8m39s
ci / bun-nix (push) Successful in 18s
arch / build-publish (push) Successful in 9m22s
windows-host / package (push) Successful in 12m24s
windows-host / winget-source (push) Skipped
deb / build-publish (push) Successful in 3m58s
ci / web (push) Successful in 5m46s
deb / build-publish-gamescope (push) Successful in 29s
ci / rust (push) Canceled after 8m19s
ci / docs-site (push) Canceled after 5m6s
deb / build-publish-host (push) Canceled after 3m11s
deb / build-publish-client-arm64 (push) Canceled after 26s
deb / smoke-install (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 17s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-host / canary-manifest (push) Successful in 2m8s
Reviewed-on: #240
2026-08-14 22:07:17 +00:00
enricobuehler 0d4f878f32 Merge pull request 'The store's library shelves carry real cover art on every platform' (#239) from worktree-shot-cover-art into main
android / android (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
apple / swift (push) Successful in 2m5s
apple / distribute (push) Successful in 11m8s
apple / screenshots (push) Successful in 9m30s
Reviewed-on: #239
2026-08-14 22:06:30 +00:00
enricobuehler d886cd0124 Merge pull request 'The Windows signing docs still told users to import a certificate we no longer publish' (#238) from worktree-docs-azure-signing-cert-import into main
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 1m48s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 1m56s
Reviewed-on: #238
2026-08-14 22:05:53 +00:00
enricobuehler 2832b5d0f6 fix(host): the park schedule read a missing cursor overlay as a lost pointer, but an Embedded portal never sends one
ci / rust-arm64 (pull_request) Successful in 3m19s
ci / web (pull_request) Successful in 2m19s
ci / bun-nix (pull_request) Successful in 1m8s
ci / rust (pull_request) Successful in 9m12s
ci / docs-site (pull_request) Successful in 8m38s
android / android (pull_request) Successful in 20m26s
Field report, working Hyprland stream: the pointer jumps to the screen
centre once a second for ~10 s at the start of every session, fighting
every mouse movement, then settles.

That is `park_pointer`'s schedule running its full cap. Parking exists
for a good reason — a pointer-locked client sends only RELATIVE deltas,
so nothing would ever move the seat pointer onto a freshly created
virtual output — and past its two unconditional attempts it keeps going
only while a host-composite session STILL has no live cursor overlay.
"No overlay ⇒ the pointer has not reached the streamed output" is sound
on Mutter, which suppresses `SPA_META_Cursor` while the pointer is off
the recorded view. It is meaningless on the whole wlr family: xdph and
xdpw advertise `AvailableCursorModes = 3` (Hidden|Embedded), so a session
that asks for metadata is served EMBEDDED — the compositor paints the
pointer into the frames and sends no cursor metadata, ever, wherever the
pointer is. The heuristic was reading noise and warping the user's
pointer over it.

Distinct from — and complementary to — 5a5397ca, which fixed WHERE the
warp landed (the wlr virtual pointer was bound to the operator's head,
so the park drove a screen nobody was streaming). That one makes the
park work; this one stops it repeating on evidence that does not exist.
Both are needed: with only 5a5397ca the pointer would be re-centred on
the *streamed* output once a second instead, which is the field report's
symptom exactly.

The same fact broke a second thing next to it. `metadata_composite` had
the host plan a metadata cursor composite on a backend that can never
deliver metadata: the stream logged "host-composite active but the
capture has no live cursor overlay" for its whole life and drew no host
pointer, which is why an earlier session on this box looked cursorless.
Under Embedded the compositor's burnt-in pointer IS the cursor, and the
host must not plan a composite at all.

So surface what the portal actually negotiated instead of inferring it:
`portal_cursor::negotiate` now returns our own `Mode` (re-exported as
`pf_vdisplay::PortalCursorMode`), the hyprland/wlroots portal threads
carry it back beside the fd and node id — alongside, not instead of, the
`closed_tx` teardown handshake and inside the same `HANDSHAKE_BUDGET`
bound — and the backends, plus the monitor mirror that delegates to
them, report it per session as `VirtualDisplay::last_portal_cursor_mode`.
`None` is the default and what every non-portal backend reports (KWin
`zkde_screencast`, Mutter `RecordVirtual`, gamescope, Windows all get
the mode they ask for), so nothing about the GNOME behaviour this was
built for changes.

The host settles both consequences from that one fact in
`settle_portal_cursor`, at bring-up and again after every capture-loss
rebuild (the retarget arm has to recompute `metadata_composite` from the
compositor alone, because it runs before the rebuild to set `hw_cursor`).
`plan.cursor_blend` is deliberately left alone: it is resolved before any
display exists, and pre-judging it would mean re-asserting what the wlr
portals advertise — the exact hardcode `portal_cursor` exists to have
deleted. It costs a colour conversion, not correctness.

Also cuts the park RETRY for a client that steers the seat pointer
itself. The doc claimed a desktop-model client "overrides it with its
first absolute move, so the jump is invisible in practice" — one park at
bring-up is, a repeat is not: such a client sends absolute positions,
the very same event the park synthesizes, only aimed where the user is
actually pointing. It keeps the single bring-up park, so the session's
first click cannot land on whatever monitor the seat pointer was left
on, and loses the retry that fights the user. A cold EIS connection
swallows the client's own moves too, and those keep coming.

On the reported session this is 2 parks in the first second instead of
11 over ten, and no phantom composite.

Verified: `cargo fmt --all --check`; `scripts/xcheck.sh linux clippy`
and `windows clippy`; `cargo clippy -p punktfunk-host -p pf-vdisplay
-p pf-inject --all-targets --locked -- -D warnings` and `cargo test` for
the three, run for Linux in the ci/rust-ci.Dockerfile image (this crate
does not build on macOS at all — opus, zerocopy and the Linux-only
vdisplay entry points are cfg'd out there, so the container is the only
way to compile it). pf-vdisplay 231 tests, pf-inject 130+7, and both new
tests pass. punktfunk-host's suite has two pre-existing failures under
that emulated container — `gamestream::stream::tests::sender_delivers_
batches` (EINTR on a socket recv) and one of the two `mgmt` local-summary
tests, which share process-global session state — and the SAME two fail
on this branch's parent without this commit; each passes in isolation.
Not verified on glass: the .138 Hyprland box is read-only and in use.
2026-08-14 23:56:36 +02:00
enricobuehler 6863f8141a fix(input): the wlr injector aimed absolute motion at the operator's head, never the streamed one
`WlrootsInjector::open` created its virtual pointer with `globals.output` — whatever
`wl_output` the registry roundtrip had bound, which was `state.output.is_none()`, i.e. the
FIRST one advertised. Registry globals arrive in creation order, so "first" is the
compositor's oldest output: the operator's physical head, never the per-session headless one
the client is looking at. The wlr protocol maps `motion_absolute` onto the output the pointer
was CREATED with ("if the output argument is set, the compositor should map the input device
to the requested output"), so every absolute sample from every session drove a screen nobody
was streaming. On the EXTEND backends — Hyprland and wlroots/sway, where the streamed head
sits beside the operator's — that is the field report "no cursor was visible in the session",
and it is also why `park_pointer`'s opening warp put the seat cursor on the operator's
desktop once a second instead of on the stream.

Not a startup race, though it looks like one. The host journal on the Hyprland box shows
BOTH orderings across sessions of the same build — the injector opening 3.7 s before the
headless output in one, 24 ms after it in another — and the bug in both, because
"first advertised" is the oldest global either way. `hyprctl monitors` on that box:
`HDMI-A-1` (ID 0) at +0+0, `PF-87756-3` (ID 1) at +1920+0. ID 0 is always first.

Three parts.

1. pf-vdisplay carries the head's compositor name out on `VirtualOutput::output_name`, the
   Linux counterpart of what `win_capture` already carries on Windows. Set by hyprland and
   wlroots (the two EXTEND backends) and by the monitor mirror; `None` on KWin/Mutter (they
   inject through libei, which selects by region) and gamescope (it owns its whole seat).
   Threaded through the registry pool so a keep-alive reuse answers with the same name a
   fresh create would — no poolable backend sets it today, and this is so that stops being a
   silent trap the day one does.

2. The host publishes it at capture bring-up, `pf_inject::set_stream_output`, in the same
   place and shape as the Windows arm's existing `set_stream_target`.

3. The wlr injector binds EVERY `wl_output` at v4 (for the `name` event), matches the
   published name, and re-creates its virtual pointer bound to that output whenever the
   target changes — releasing any held button on the old device first, because nothing else
   would and a virtual pointer destroyed mid-press leaves the host with a stuck button.

Matching is by NAME, with NO fallback, and the absence of the fallback is the fix: the old
"first output" behaviour WAS the fallback. Size could not stand in for it either —
`MouseMoveAbs`'s extent is the client's letterboxed video rect in its own window, not the
streamed mode, so no size ladder can identify the head. An unresolved target binds NO output,
which maps absolute coordinates over the whole layout: on a single-output compositor that is
identical to binding that output, and on a multi-head one it at least keeps the streamed head
reachable, unlike a pin to the wrong one.

`inject` now also READS the Wayland socket. It only ever called `dispatch_pending`, which is
documented to "not perform reads on the Wayland socket", so the queue held nothing but what
`open`'s roundtrips put there. Without this the retarget above would have been dead in
exactly the sessions that need it most — the injector could never learn about a `wl_output`
created after it opened — and, separately, everything the compositor sent had been piling up
unread in the socket buffer for the host's lifetime, including the protocol errors the
comment there claimed to be surfacing.

Concurrency, stated plainly: ONE slot per process. The injector is host-lifetime (in fact
there are two `InjectorService`s — the native plane's and one per GameStream control
listener) and `InputEvent` is an 18-byte `#[repr(C)]` ABI struct with no session field, so
with parallel sessions (up to `max_concurrent`, default 4) the LAST capture bring-up wins for
everyone's absolute input. That is the same trade `stream_target` already documents on
Windows, and it is strictly better than what it replaces, where every session aimed at a head
NO session was streaming. Making injection genuinely session-aware is the real fix and a much
larger one — it needs source-tagged input events through both control planes.
`set_absolute_anchor`'s warning is amended rather than quietly violated: it still must not be
called from a session path, and it now says which mechanism took the per-session trade, why
that is a separate slot (this one is the operator's host-wide capture pin, recomputed from
policy whenever the console writes it — which would wipe a per-session value), and where the
trade is written down.

Gates: `cargo clippy --all-targets -p pf-inject -- -D warnings`, `cargo build -p pf-inject`
and `cargo test -p pf-inject` (137 tests, incl. 3 new) on x86_64-unknown-linux-gnu in
`punktfunk-rust-ci`; `cargo check -p punktfunk-host` likewise; `scripts/xcheck.sh linux
clippy` plus 230 `pf-vdisplay` tests; `cargo fmt --all --check`. The two new `wlr` tests pin
the regression directly — an unknown target must bind NOTHING rather than fall back to the
first advertised output.

Not verified here: no on-glass run. The box at .138 is in live use and read-only to me, so
the change is unproven against a real Hyprland seat. Two smaller things also rest on reading
rather than observation — that Hyprland's `hyprctl` monitor name is byte-identical to its
`wl_output.name` (the protocol says the name is "the same for all clients", and xdph already
resolves our `hyprctl`-minted name to the same output for screencast, which is field-proven),
and the exact on-screen arithmetic of the "moves to mid-screen then jumps back" symptom,
which does not follow from the protocol's normalize-by-extent mapping and would need
Hyprland's own source to pin down.
2026-08-14 23:56:36 +02:00
enricobuehler cf4c12ea52 fix(vdisplay): a per-cast tokio runtime orphaned ashpd's process-global connection after one cast
THE reason the first stream of a host process worked and every later one was
black. Not xdph, not the compositor, not the formats — ours, and a lifetime
mistake.

ashpd caches its D-Bus connection process-globally:

    static SESSION: OnceLock<zbus::Connection>     // ashpd 0.13.13, src/proxy.rs:27

The first `Screencast::new()` in the process creates that connection, and zbus
spawns its background reader as a task on whichever tokio runtime is current at
that moment. Both wlr backends built their OWN multi-thread runtime per cast and
dropped it at teardown — so the first cast created the cached connection on a
runtime that was then destroyed with it, and the OnceLock went on handing the
same executor-less connection to every later `Screencast::new()`, which awaited
a reply nothing was alive to read.

Measured 2026-08-14 (Hyprland 0.55.4, xdph 1.3.12): first cast of a host process
streamed, every cast after it hung, and the surviving cast thread sat in
futex_do_wait inside runtime shutdown. The discriminator that pins it on us: a
freshly spawned process completed the identical handshake against the identical
xdph, repeatedly, while the long-lived host completed none — with xdph itself
idle at 28 ms of CPU, so it was never the one wedged. Teardown was already
correct by then: the log shows `hyprland headless output removed` in the right
order.

One shared runtime (`portal_rt`), built once, never dropped, `block_on(&self)`
from every cast thread. It outlives the cached connection because it must.

Also bound `Screencast::new()` itself, not just the handshake after it: with the
connection orphaned that call is exactly where the thread hung, so the earlier
bound started one step too late and the failure still surfaced as the caller's
generic 20 s timeout.

Verified: cargo fmt --all --check, scripts/xcheck.sh linux clippy (-D warnings),
cargo test -p pf-vdisplay 122 passed. Not verified: on-glass.
2026-08-14 23:56:36 +02:00
enricobuehler 5e5d6904d3 fix(vdisplay): a hung portal handshake leaked its thread, and one leak poisoned every later cast
MEASURED 2026-08-14 on the Hyprland box, with the ordered-teardown fix already
in place. The first cast of a host process streamed:

    hyprland headless output ready … output=PF-44694-1 w=5120 h=1440 hz=240
    pipewire stream state old=Paused new=Streaming

and every cast after it timed out in select_and_cast. The host had NINE live
`punktfunk-hypr-cast` threads and 28 tokio workers at that point.

`select_sources`/`start` await a D-Bus reply that a wedged portal never sends.
That await cannot be cancelled by the `stop` flag, because the flag is only
read by the park loop further down — a thread stuck in the handshake never
reaches it. So every timed-out attempt left a thread parked forever on a
half-created portal session, holding this process's shared D-Bus connection,
and from the first hang onwards every later request from the SAME process hung
too.

The discriminator that proves it is the process, not the portal: a freshly
spawned process (`punktfunk-host spike --source portal`, driven through the
same custom picker) completed the identical handshake against the very same
xdph — repeatedly — while the long-lived host could not complete any. xdph
itself was idle, 28 ms of CPU since start, so it was not spinning.

Bound the handshake at 15 s, under select_and_cast's 20 s wait so the failure
is reported by the thread that owns it, with a reason, and — the point — so
that thread EXITS instead of leaking. Same change in the wlroots/sway backend:
xdpw carries the identical unbounded node-id spin (screencast.c), so it can
wedge the same way.

Verified: cargo fmt --all --check, scripts/xcheck.sh linux clippy (-D warnings),
cargo test -p pf-vdisplay 122 passed. Not verified: on-glass — needs the box.
2026-08-14 23:56:36 +02:00
enricobuehler 9ce347e4c0 fix(vdisplay): we removed the captured output before closing the cast, and xdph spun on the wreckage
Only the FIRST stream after a portal start ever worked on Hyprland; every
one after it died in `select_and_cast` with

    create virtual output: timed out waiting for the ScreenCast portal on PF-…

The mitigation on `worktree-capture-bgra-dmabuf-pod` chased the symptom. This
is the mechanism, read out of xdph 1.3.12's source and the box's own journal.

WE YANK THE OUTPUT OUT FROM UNDER A LIVE CAST. `Keepalive` drops `StopGuard`
then `OutputGuard`, and `StopGuard::drop` only SET an atomic and returned. The
portal thread noticed 200 ms later and merely dropped its zbus connection. So
`hyprctl output remove` ran — synchronously, microseconds later — on an output
xdph was still capturing, every single teardown.

Nothing closed the session either. xdph destroys one on exactly one event, an
explicit `org.freedesktop.impl.portal.Session.Close` (`Session.cpp:37`); it has
no peer-vanished watcher. The frontend does (`xdg-desktop-portal.c:230`
`peer_died_cb` → `close_sessions_for_sender`), but only once our bus name goes
away — after the poll, asynchronously, on a GTask thread. Long after the output
is gone. Proof from the box: xdph's toplevel lock stayed at 2 for 4.5 minutes
after our stream ended and its output was removed, and that session's
`Session destroyed` never came.

XDPH THEN SPINS AT 100% CPU, FOREVER. Handed that wreckage, `startSharing`
falls into `Screencopy.cpp:307-313`

    while (pSession->sharingData.nodeID == SPA_ID_INVALID) {
        int ret = pw_loop_iterate(g_pPortalManager->m_sPipewire.loop, 0);

— timeout 0, i.e. NON-blocking, i.e. an unbounded hot spin on xdph's only
event-loop thread, inside the `Start` handler, holding its `m_mEventLock`. From
there it answers no D-Bus, no Wayland, no PipeWire, ever again. MEASURED: the
wedged instance's unit reported `Consumed 3min 51.971s CPU time over 23min
41.092s wall clock`, and there were 232.70 s of wall clock between its last log
flush and its restart — 231.971 s of CPU against 232.70 s of wall, one core
pinned solid for precisely the wedged interval.

Everything after that is queueing. Our next handshake gets nothing, times out at
20 s, and `SelectionFile` deletes the per-session selection file on its way out;
if xdph is restarted mid-queue it finally runs the picker for that stale request
and reads an empty file — the `SHAREDATA returned selection -1` in the log.

THE FIX IS THE ORDER. `StopGuard::drop` now signals and then WAITS for the
portal thread to have closed the ScreenCast session, and only then does
`OutputGuard` remove the output. The close is answered synchronously by the
frontend (`xdp-session.c:217` `handle_close` → `xdp_dbus_impl_session_call_close_sync`),
so when it returns xdph has already run `destroyStream`. The output we remove
next is one nobody is capturing. Bounded at 3 s on each side — an already-wedged
portal must not be able to wedge our teardown with it — and the park poll drops
to 20 ms now that teardown waits on it.

wlroots gets the same change, and NOT on an assumption of symmetry: xdpw was
read to confirm both preconditions. `src/core/session.c` gives its session
object exactly one method, `Close`; and `src/screencast/screencast.c:599-605` is
the identical unbounded `while (cast->node_id == SPA_ID_INVALID)` spin — xdph's
copy is that code. sway's `output unplug` yanks a captured output exactly the
way Hyprland's `output remove` did. Not observed on glass; no sway box.

THE PICKER LINE WAS ALSO MALFORMED, AND IS A RED HERRING FOR THE STALL. xdph
splits the picker's line on the first `/` into flags and selection
(`ScreencopyShared.cpp:86-87`) and we never sent one. `find_first_of` then
returns npos, so FLAGS became the whole payload — and SEL became the whole
payload too, purely because `npos + 1` wraps to 0, which is why the output name
still parsed and why this hid. What did not hide is the flag loop walking
`screen:<name>` one character at a time (`unknown flag from share-picker: s`,
`c`, `e`, …) and setting `allowToken` on the `r` of `sc*r*een`, so xdph answered
every Start with a `restore_data` + `persist_mode: 2` we never asked for. The
reference picker prints the separator unconditionally
(`hyprland-share-picker/main.cpp:133-136`), so empty flags are a bare leading
`/`. Fixed to `[SELECTION]/screen:<NAME>`.

It is NOT what stalled anything: the sessions that streamed fine logged the
identical flag spam and the identical restore token, so it never discriminated.

The format moves to `portal_picker.rs`, declared unconditionally like
`portal_config` and `portal_cursor`, with xdph's parser transcribed into the
tests — including the npos arithmetic. A wire format with no schema and no error
report is invisible from the string alone: the old line's one assertion passed
the entire time it was wrong. Those tests now run on every platform's CI rather
than only the leg that compiles `mod hyprland`.

Verified: cargo fmt --all --check, scripts/xcheck.sh linux clippy AND windows
clippy (-D warnings), cargo test -p pf-vdisplay 122 passed (5 new). Not verified:
on-glass behaviour — the box is in use for live testing and read-only to me.

Upstream bugs worth filing, both wlr-family: the unbounded node-id spin
(hyprwm/xdg-desktop-portal-hyprland Screencopy.cpp:307,
emersion/xdg-desktop-portal-wlr screencast.c:599) should be bounded and fail the
request rather than pinning a core forever; and xdph's picker parse should reject
a line with no `/` instead of reading the whole selection as flags.
2026-08-14 23:56:36 +02:00
enricobuehler dea6395772 fix(capture): xdph offers BGRA on its dmabuf pod, and we offered BGRx, so the link never negotiated
A Hyprland/sway client went black with no error of ours: PipeWire failed the
link itself with

    pw.link: (73.0.0 -> 81.0.0) negotiating -> error no more input formats (-22)

Measured on Hyprland 0.55.4 + xdph 1.3.12 by dumping both EnumFormat pods from
the PipeWire DAEMON (`PIPEWIRE_DEBUG=*:1,pw.link:5` — the pods are not in our
own process's log, which is why this hid for so long):

  ours   BGRx only | 12 NVIDIA tiled + 0 (LINEAR)      | MANDATORY
  xdph   BGRA only | the same 12 + MOD_INVALID         | MANDATORY|DONT_FIXATE
  xdph   BGRA or BGRx, no modifier                     | (the SHM pod)

The modifiers intersect perfectly. Only the fourcc never does, which is exactly
why the failure reads as a GPU/modifier problem and is not one — the host's own
message ("the compositor never accepted the dmabuf-only offer (EGL->CUDA GPU
import)") points at the GPU, and our advert line prints only the first 6 of 13
modifiers so LINEAR is invisible. Both misled a full session of debugging.
Since our offer is dmabuf-only, xdph's mixed SHM pod could not rescue it.

Offer a BGRA dmabuf pod beside the BGRx one. BGRA and BGRx are the same 32-bit
layout, the alpha byte is ignored all the way to the encoder (`vk_util` maps
both to B8G8R8A8_UNORM, VAAPI both to Pixel::BGRA), and the import is driven by
the NEGOTIATED format's fourcc, so an AR24 frame imports as AR24.

Vendor-neutral by construction: the two modifier lists are enumerated PER FOURCC
(`XR24` and `AR24` asked separately), because EGL and libva answer per format and
nothing entitles us to assume a driver importing one imports the other. On the
VAAPI passthrough path there is no importer, so both lists are LINEAR (plus the
PyroWave Vulkan set when armed) — AMD and Intel get the BGRA pod on the same
terms as NVIDIA rather than an NVIDIA-shaped guess.

The BGRA pod is listed AFTER BGRx, so a producer offering both still lands on
the pre-existing path — first compatible consumer pod wins, so this is purely
additive. Both pods are now guarded on a non-empty list (`build_dmabuf_format`
indexes `modifiers[0]`).

Also name `linear_offered` and both counts in the advert log, so the truncated
`sample` can no longer be misread as the whole offer.
2026-08-14 23:56:35 +02:00
enricobuehler 55dbb14cf4 docs(windows): the signing pages still told users to import a certificate we no longer publish
ci / bun-nix (pull_request) Successful in 20s
ci / web (pull_request) Successful in 1m2s
ci / rust-arm64 (pull_request) Successful in 1m22s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Failing after 1m47s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Failing after 1m53s
ci / docs-site (pull_request) Successful in 8m3s
ci / rust (pull_request) Successful in 27m30s
Azure Artifact Signing chains to a public root, so neither the host installer nor the client MSIX ships a .cer any more and users import nothing. install.md, install-client.md and windows-host.md still walked through importing one — and because the pre-Azure files are still sitting in the package registry those URLs return 200 rather than 404, so following the docs didn't fail loudly, it quietly planted a retired self-signed cert in machine Root and TrustedPublisher.

Verified on glass against the 0.29 canary while checking the release: both artifacts verify Valid, timestamped, and publicly trusted, and the MSIX installs with nothing imported.

Also corrected while here: the MSIX publisher change makes a different package identity, so installs from 0.28.1 or earlier need an uninstall rather than an upgrade (and a packaged app's settings go with it, so the client pairs again); a silent UPGRADE reuses the task selection the previous install recorded instead of the wizard defaults, so a once-declined installgamepad silently keeps stale gamepad drivers; installaudiocable stopped being a task name in 4a621de6; and Add-AppxPackage from a non-interactive session can fail 0x80070005 when the Windows App Runtime it depends on is in use.
2026-08-14 23:50:00 +02:00
enricobuehler f0b35de92a feat(screenshots): the library shelf carries real cover art on every platform
apple / swift (pull_request) Successful in 2m5s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 3m15s
ci / web (pull_request) Successful in 1m34s
ci / docs-site (pull_request) Successful in 1m27s
ci / bun-nix (pull_request) Successful in 39s
android / android (pull_request) Successful in 7m21s
ci / rust (pull_request) Successful in 7m50s
The Apple scenes passed artLoader nil, so the store's library frames shipped
empty frosted cards next to Android's populated shelf. LibraryArtSource (a
two-method protocol the production LibraryArtLoader already satisfies) lets the
harness answer art from a canned source, and the four posters are drawn
procedurally at capture time — CoreGraphics on Apple, Canvas on Android, same
designs, same seeds — replacing Android's gradient-plus-monogram tiles. Nothing
is bundled; release builds ship none of it.
2026-08-14 23:39:41 +02:00
enricobuehler 588962f696 Merge pull request 'A lost merge run left an Android fix unpublished, and no re-run could ship it' (#237) from worktree-android-dispatch-publish-hatch into main
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 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m13s
docker / builders-arm64cross (push) Successful in 7s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m27s
docker / deploy-docs (push) Successful in 32s
android / android (push) Successful in 7m4s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
Reviewed-on: #237
2026-08-14 21:03:12 +00:00
enricobuehler 91b8f1a939 ci(android): a manual dispatch can publish, so a lost merge run is not a dead end
ci / web (pull_request) Successful in 1m1s
ci / docs-site (pull_request) Successful in 1m18s
ci / bun-nix (pull_request) Successful in 1m37s
android / android (pull_request) Successful in 4m33s
ci / rust-arm64 (pull_request) Successful in 12m3s
ci / rust (pull_request) Canceled after 29m33s
Merging two PRs seconds apart can leave the older merge sha with no run at all -
Gitea attributes the window's runs to the newer head. 1e5dca4c (PR #235, the pad
Select/KEYCODE_BACK fix) lost its run to b5cace3a 12 s later and reached main
having never been built, so no canary carries it.

Re-running the PR run cannot recover that: a re-run replays the original
pull_request event, and all four publish steps are gated on a push, so they stay
skipped. Until now the only cure was inventing a filler push touching one of
android.yml's paths.

Add a workflow_dispatch 'publish' input, default false, and widen the four gates
to accept a dispatch that opts in. A plain manual run stays build-only, so a
stray click still cannot reach testers. String-typed and compared against 'true'
to match apple.yml's testflight input, the form proven on this Gitea.
2026-08-14 22:55:13 +02:00
150 changed files with 11038 additions and 893 deletions
+26 -4
View File
@@ -56,7 +56,21 @@ on:
- 'rust-toolchain.toml'
- 'scripts/ci/**'
- '.gitea/workflows/android.yml'
# Manual runs are BUILD-ONLY by default. The escape hatch below exists because a push run can
# go missing entirely: merge two PRs seconds apart and Gitea attributes the window's runs to the
# newer head, so the older merge sha gets no run at all — its android change then sits on main
# having never been built, let alone published (2026-08-14: `1e5dca4c`, PR #235, lost its run to
# `b5cace3a` 12 s later). Re-running the PR run does NOT recover it: a re-run replays the original
# `pull_request` event, so every gate below stays false. Only a dispatch with publish=true can
# ship that commit without inventing a filler push.
workflow_dispatch:
inputs:
publish:
# String, not a boolean: matches apple.yml's `testflight` input, which is the form proven
# to evaluate correctly on this Gitea. Compared as `inputs.publish == 'true'` below.
description: "Also publish this build (registry + Google Play). main -> beta+alpha, vX.Y.Z tag -> production at 100%. Default false: a stray click must not reach testers."
required: false
default: "false"
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io, LAN-pinned via ci-core's
# unbound). The NDK clang targets get their own key universes automatically (keys embed
@@ -228,7 +242,9 @@ jobs:
# Single source of the version name + the Play track for the release steps below. versionCode
# stays github.run_number (monotonic across both tracks; Play rejects a regressed code).
- name: Version + channel
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
if: >-
(github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish == 'true'))
&& (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
run: |
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag)
case "$GITHUB_REF" in
@@ -250,7 +266,9 @@ jobs:
echo "android version $VN -> Play track '$TRACK'${ALSO:+ (+ '$ALSO')}"
- name: Build Release (signed AAB + universal APK)
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
if: >-
(github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish == 'true'))
&& (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
working-directory: clients/android
env:
VERSION_CODE: ${{ github.run_number }} # VERSION_NAME comes from the Version+channel step (GITHUB_ENV)
@@ -285,7 +303,9 @@ jobs:
# main = canary store + `canary/` sideload alias; a `vX.Y.Z` tag = `latest/` alias + attached
# to the unified Gitea Release.
- name: Publish to generic registry + attach to Gitea release
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
if: >-
(github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish == 'true'))
&& (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
env:
REGISTRY: git.unom.io
OWNER: unom
@@ -328,7 +348,9 @@ jobs:
# `--status inProgress --user-fraction 0.2`; to undo a bad one, halt or roll back from the
# Console (or `android-promote.yml`, which can re-point production at an older versionCode).
- name: Upload to Google Play
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
if: >-
(github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish == 'true'))
&& (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
env:
SERVICE_ACCOUNT_JSON: ${{ secrets.SERVICE_ACCOUNT_JSON }}
run: |
+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
+11 -5
View File
@@ -70,12 +70,18 @@
# latest stable tag via scripts/ci/pf-version.ps1, run number climbs monotonically).
# Both arches share the version; artifacts are arch-suffixed (..._x64.msix / ..._arm64.msix).
#
# Signing (clients/windows/packaging/pack-msix.ps1): if the MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD
# Actions secrets are set (a real or shared code-signing .pfx whose subject DN == Publisher), the
# package is signed with them. Otherwise an ephemeral self-signed cert is generated and its public
# .cer is published next to the .msix (users import it to Trusted People before install).
# Signing (clients/windows/packaging/pack-msix.ps1), first match wins:
# 1. Azure Artifact Signing — what this workflow always takes, since the AZURE_CODESIGNING_*
# endpoint/account/profile are literals below and only the AZURE_TENANT_ID / AZURE_CLIENT_ID /
# AZURE_CLIENT_SECRET secrets are needed. Publicly trusted, so NO .cer is emitted or published
# and users import nothing. NOTE the Publisher DN is the Azure profile's verified subject, and
# MSIX identity is name + publisher: moving to it changed the package identity, so installs
# predating it need an uninstall, not an upgrade.
# 2. MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD — the older self-signed .pfx, kept as a fallback.
# 3. an ephemeral self-signed cert. Modes 2 and 3 DO emit a .cer next to the .msix, which users
# would have to import into Trusted People before Windows will install the package.
#
# That fallback is for canary/CI ONLY. On a v* tag the pack script FAILS CLOSED — a missing secret
# Modes 2 and 3 are for canary/CI ONLY. On a v* tag the pack script FAILS CLOSED — a missing secret
# aborts the build instead of quietly shipping a release signed by a per-build throwaway cert that
# no one can pin. Nothing to opt into here: the script reads GITHUB_REF itself.
name: windows-client
+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).",
@@ -1,6 +1,5 @@
package io.unom.punktfunk
import android.os.Build
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
@@ -14,6 +13,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
@@ -206,7 +206,9 @@ fun AwaitingApprovalPrompt(gamepadUi: Boolean, hostLabel: String, onCancel: () -
actions = listOf(DialogAction("Cancel", primary = true, onClick = onCancel)),
dismissOnOutsideTap = false,
) {
val deviceName = Build.MODEL ?: "this device"
// MUST be the name the connect actually knocked with (`HostConnect`), or this sends the
// user looking for a row the console does not show.
val label = deviceName(LocalContext.current)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
@@ -222,7 +224,7 @@ fun AwaitingApprovalPrompt(gamepadUi: Boolean, hostLabel: String, onCancel: () -
)
}
PromptText(
"Open the host's console (or web UI) and approve “$deviceName”. It connects " +
"Open the host's console (or web UI) and approve “$label”. It connects " +
"automatically once you approve — no PIN needed.",
gamepadUi,
)
@@ -1,6 +1,5 @@
package io.unom.punktfunk
import android.os.Build
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -31,6 +30,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import io.unom.punktfunk.kit.NativeBridge
@@ -137,7 +137,8 @@ internal fun PairPinDialog(
) {
val scope = rememberCoroutineScope()
var pin by remember(pt) { mutableStateOf("") }
var name by remember(pt) { mutableStateOf(Build.MODEL ?: "Android") }
val context = LocalContext.current
var name by remember(pt) { mutableStateOf(deviceName(context)) }
var pairing by remember(pt) { mutableStateOf(false) }
var err by remember(pt) { mutableStateOf<String?>(null) }
AlertDialog(
@@ -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
}
@@ -0,0 +1,25 @@
package io.unom.punktfunk
import android.content.Context
import android.os.Build
import android.provider.Settings
/**
* The name the user knows this device by what a host shows in its pending-approval list (the web
* console's outstanding-pairings view and the dialog that approves a knock) and files the device
* under once approved.
*
* `Settings.Global.DEVICE_NAME` is the name the user typed in Settings ("Enrico's Pixel", "TV im
* Wohnzimmer"); it is what every other protocol on the network already calls this device. Only when
* it is unset does this fall back to [Build.MODEL], which names the *product* and so reads
* identically on every unit of it two of the same tablet pending approval are indistinguishable.
* Available unconditionally here: `DEVICE_NAME` landed in API 25 and this app's floor is 28.
*/
internal fun deviceName(context: Context): String {
val userNamed = runCatching {
Settings.Global.getString(context.contentResolver, Settings.Global.DEVICE_NAME)
}.getOrNull()
return userNamed?.trim()?.takeIf { it.isNotEmpty() }
?: Build.MODEL?.trim()?.takeIf { it.isNotEmpty() }
?: "Android"
}
@@ -1,6 +1,5 @@
package io.unom.punktfunk
import android.os.Build
import androidx.activity.compose.BackHandler
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
@@ -44,6 +43,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -396,7 +396,8 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired:
var slot by remember(pt) { mutableIntStateOf(0) } // 0..3 = digit slots, 4 = Pair button
var pairing by remember(pt) { mutableStateOf(false) }
var err by remember(pt) { mutableStateOf<String?>(null) }
val name = remember { Build.MODEL ?: "Android" }
val context = LocalContext.current
val name = remember(context) { deviceName(context) }
fun pair() {
val id = identity ?: return
@@ -1,7 +1,6 @@
package io.unom.punktfunk
import android.content.Context
import android.os.Build
import android.util.Log
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.NativeBridge
@@ -82,8 +81,8 @@ suspend fun connectToHost(
codecBits, preferredCodec, timeoutMs,
launch,
// The host's approval-list / trust-store label for this device — the same
// Build.MODEL convention the pairing dialogs use for nativePair.
Build.MODEL ?: "Android",
// user-set device name the pairing dialogs offer for nativePair.
deviceName(context),
// Tier-A pad audio: ask for the 0xD1 plane only when a setting would render it, so a
// user with it off does not make the host provision endpoints it will never feed.
settings.padHaptics || settings.padSpeaker,
@@ -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
@@ -3,9 +3,12 @@ package io.unom.punktfunk.screenshots
import android.content.Context
import android.content.res.Configuration
import android.graphics.Bitmap
import android.graphics.BlendMode
import android.graphics.Canvas
import android.graphics.LinearGradient
import android.graphics.Paint
import android.graphics.Path
import android.graphics.RadialGradient
import android.graphics.Shader
import android.graphics.Typeface
import android.graphics.drawable.BitmapDrawable
@@ -708,39 +711,250 @@ private fun shotGames() = listOf(
private fun shotLibraryLoader(context: Context): ImageLoader {
val engine = FakeImageLoaderEngine.Builder()
.intercept("shot://art/aurora", cover(context, 0xFF6656F2, 0xFF141040, "A"))
.intercept("shot://art/starfall", cover(context, 0xFFE86FA8, 0xFF3A1030, "S"))
.intercept("shot://art/neon", cover(context, 0xFF35D0C5, 0xFF0A2A33, "N"))
.intercept("shot://art/ember", cover(context, 0xFFEF8F4B, 0xFF3A1608, "E"))
.intercept("shot://art/aurora", poster(context, "AURORA DRIFT", ::drawAurora))
.intercept("shot://art/starfall", poster(context, "STARFALL VALE", ::drawStarfall))
.intercept("shot://art/neon", poster(context, "NEON CIRCUIT", ::drawNeon))
.intercept("shot://art/ember", poster(context, "EMBER PEAKS", ::drawEmber))
.default(ColorDrawable(0xFF221E44.toInt()))
.build()
return ImageLoader.Builder(context).components { add(engine) }.build()
}
/** A generated 2:3 poster: vertical brand-adjacent gradient + a big monogram. */
private fun cover(context: Context, top: Long, bottom: Long, mark: String): Drawable {
val w = 600
val h = 900
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
// The four shelf posters, drawn procedurally at capture time — the same designs the Apple
// harness draws with CoreGraphics (`ShotPosterArt.swift`), so both listings show the same shelf.
// All geometry below is in a 600×900, y-UP space (matching the CG source); `posterY()` flips it.
private const val POSTER_W = 600
private const val POSTER_H = 900
private fun posterY(v: Float) = POSTER_H - v
/** Deterministic LCG (same constants and seeds as the Swift twin) so every capture is identical. */
private class ShotRand(var state: ULong) {
fun next(): Float {
state = state * 6364136223846793005UL + 1442695040888963407UL
return (state shr 33).toFloat() / (1L shl 31).toFloat()
}
fun range(lo: Float, hi: Float) = lo + next() * (hi - lo)
}
private fun poster(context: Context, title: String, draw: (Canvas) -> Unit): Drawable {
val bmp = Bitmap.createBitmap(POSTER_W, POSTER_H, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bmp)
draw(canvas)
posterTitle(canvas, title)
return BitmapDrawable(context.resources, bmp)
}
/** Vertical gradient over the full canvas; stops bottom-to-top as (location, color). */
private fun sky(canvas: Canvas, stops: List<Pair<Float, Int>>) {
canvas.drawRect(
0f, 0f, w.toFloat(), h.toFloat(),
0f, 0f, POSTER_W.toFloat(), POSTER_H.toFloat(),
Paint(Paint.ANTI_ALIAS_FLAG).apply {
shader = LinearGradient(
0f, 0f, 0f, h.toFloat(), top.toInt(), bottom.toInt(), Shader.TileMode.CLAMP,
0f, POSTER_H.toFloat(), 0f, 0f,
stops.map { it.second }.toIntArray(),
stops.map { it.first }.toFloatArray(),
Shader.TileMode.CLAMP,
)
},
)
canvas.drawText(
mark, w / 2f, h / 2f + 110f,
}
private fun glowDot(canvas: Canvas, x: Float, y: Float, radius: Float, color: Int) {
canvas.drawCircle(
x, posterY(y), radius,
Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = 0xD9FFFFFF.toInt()
textSize = 320f
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
textAlign = Paint.Align.CENTER
shader = RadialGradient(
x, posterY(y), radius, color, color and 0x00FFFFFF, Shader.TileMode.CLAMP,
)
},
)
return BitmapDrawable(context.resources, bmp)
}
private fun shotAlpha(color: Int, a: Float) = (color and 0x00FFFFFF) or ((a * 255).toInt() shl 24)
/** Three strokes, wide-and-faint to thin-and-bright, in screen blend — the cheap neon glow. */
private fun glowStroke(canvas: Canvas, path: Path, width: Float, color: Int) {
for ((mult, a) in listOf(2.6f to 0.12f, 1.3f to 0.28f, 0.55f to 0.85f)) {
canvas.drawPath(
path,
Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
strokeWidth = width * mult
this.color = shotAlpha(color, a)
blendMode = BlendMode.SCREEN
},
)
}
}
private fun posterTitle(canvas: Canvas, title: String) {
sky(canvas, listOf(0f to shotAlpha(0x000000, 0.55f), 0.22f to shotAlpha(0x000000, 0f)))
canvas.drawText(
title, POSTER_W / 2f, posterY(72f),
Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = shotAlpha(0xFFFFFF, 0.94f)
textSize = 46f
letterSpacing = 5f / 46f
typeface = Typeface.create("sans-serif-condensed", Typeface.BOLD)
textAlign = Paint.Align.CENTER
setShadowLayer(8f, 0f, 2f, shotAlpha(0x000000, 0.6f))
},
)
}
private fun drawAurora(canvas: Canvas) {
sky(canvas, listOf(0f to 0xFF221E5C.toInt(), 0.45f to 0xFF141040.toInt(), 1f to 0xFF0B0830.toInt()))
val rng = ShotRand(11UL)
repeat(48) {
val x = rng.range(0f, 600f)
val y = rng.range(300f, 890f)
val r = rng.range(1.4f, 3.2f)
glowDot(canvas, x, y, r, shotAlpha(0xFFFFFF, rng.range(0.25f, 0.8f)))
}
data class Ribbon(
val base: Float, val amp: Float, val freq: Float,
val phase: Float, val w: Float, val c: Int,
)
for (r in listOf(
Ribbon(700f, 55f, 1.15f, 0.4f, 30f, 0xFF6656F2.toInt()),
Ribbon(615f, 70f, 1.4f, 2.2f, 24f, 0xFF8F7BFF.toInt()),
Ribbon(530f, 45f, 0.95f, 4.1f, 18f, 0xFF35D0C5.toInt()),
)) {
val path = Path()
for (i in 0..60) {
val t = i / 60f
val x = t * 600f
val y = r.base + r.amp * kotlin.math.sin(t * Math.PI.toFloat() * r.freq + r.phase) + 40f * t
if (i == 0) path.moveTo(x, posterY(y)) else path.lineTo(x, posterY(y))
}
glowStroke(canvas, path, r.w, r.c)
}
// A low ridge grounds the scene — without it the poster's bottom half is bare sky.
for ((fill, baseline, rough) in listOf(
Triple(0xFF191345.toInt(), 212f, 30f),
Triple(0xFF0E0A2E.toInt(), 148f, 38f),
)) {
val path = Path()
path.moveTo(0f, posterY(0f))
path.lineTo(0f, posterY(baseline + rng.range(-rough, rough)))
for (i in 1..9) {
val x = i / 9f * 600f
path.lineTo(x, posterY(baseline + rng.range(-rough, rough)))
}
path.lineTo(600f, posterY(0f))
path.close()
canvas.drawPath(path, Paint(Paint.ANTI_ALIAS_FLAG).apply { color = fill })
}
}
private fun drawStarfall(canvas: Canvas) {
sky(
canvas,
listOf(
0f to 0xFF2A0C24.toInt(), 0.35f to 0xFF7A2B58.toInt(),
0.8f to 0xFFE86FA8.toInt(), 1f to 0xFFF7A8C8.toInt(),
),
)
val rng = ShotRand(23UL)
repeat(6) {
val hx = rng.range(60f, 560f)
val hy = rng.range(420f, 840f)
val len = rng.range(90f, 170f)
val dx = kotlin.math.cos(2.15f)
val dy = kotlin.math.sin(2.15f)
val path = Path()
path.moveTo(hx, posterY(hy))
path.lineTo(hx + dx * len, posterY(hy + dy * len))
glowStroke(canvas, path, 4f, 0xFFFFE3EF.toInt())
glowDot(canvas, hx, hy, 11f, shotAlpha(0xFFFFFF, 0.9f))
}
for ((fill, baseline, rough) in listOf(
Triple(0xFF3A1430.toInt(), 300f, 26f),
Triple(0xFF1D0818.toInt(), 216f, 34f),
)) {
val path = Path()
path.moveTo(0f, posterY(0f))
path.lineTo(0f, posterY(baseline))
for (i in 1..8) {
val x = i / 8f * 600f
path.lineTo(x, posterY(baseline + rng.range(-rough, rough)))
}
path.lineTo(600f, posterY(0f))
path.close()
canvas.drawPath(path, Paint(Paint.ANTI_ALIAS_FLAG).apply { color = fill })
}
}
private fun drawNeon(canvas: Canvas) {
sky(canvas, listOf(0f to 0xFF0A2A33.toInt(), 1f to 0xFF04161C.toInt()))
val rng = ShotRand(7UL)
val ring = Path().apply {
addOval(300f - 105f, posterY(560f) - 105f, 300f + 105f, posterY(560f) + 105f, Path.Direction.CW)
}
glowStroke(canvas, ring, 10f, 0xFF35D0C5.toInt())
val gateX = listOf(-105f, 105f, 0f, 0f)
val gateY = listOf(0f, 0f, -105f, 105f)
for (i in 0 until 9) {
var px: Float
var py: Float
if (i < 4) {
px = 300f + gateX[i]
py = 560f + gateY[i]
} else {
px = 40f * kotlin.math.round(rng.range(1f, 14f))
py = 40f * kotlin.math.round(rng.range(1f, 21f))
}
val path = Path()
path.moveTo(px, posterY(py))
var horizontal = rng.next() > 0.5f
repeat(rng.range(3f, 6f).toInt()) {
val step = 40f * kotlin.math.round(rng.range(1f, 4f)) * (if (rng.next() > 0.5f) 1f else -1f)
if (horizontal) px = (px + step).coerceIn(20f, 580f) else py = (py + step).coerceIn(20f, 880f)
path.lineTo(px, posterY(py))
horizontal = !horizontal
}
val color = if (rng.next() > 0.6f) 0xFF7FE8DE.toInt() else 0xFF35D0C5.toInt()
glowStroke(canvas, path, 5f, color)
glowDot(canvas, px, py, 12f, shotAlpha(color, 0.9f))
}
}
private fun drawEmber(canvas: Canvas) {
sky(
canvas,
listOf(
0f to 0xFF200A04.toInt(), 0.3f to 0xFF7A2E12.toInt(),
0.42f to 0xFFEF8F4B.toInt(), 1f to 0xFF2A0E06.toInt(),
),
)
glowDot(canvas, 300f, 385f, 160f, shotAlpha(0xFFC37A, 0.85f))
val rng = ShotRand(41UL)
for ((fill, baseline, rough) in listOf(
Triple(0xFF5A2410.toInt(), 340f, 42f),
Triple(0xFF401708.toInt(), 255f, 56f),
Triple(0xFF200A04.toInt(), 165f, 48f),
)) {
val path = Path()
path.moveTo(0f, posterY(0f))
path.lineTo(0f, posterY(baseline + rng.range(-rough, rough)))
for (i in 1..10) {
val x = i / 10f * 600f
path.lineTo(x, posterY(baseline + rng.range(-rough, rough)))
}
path.lineTo(600f, posterY(0f))
path.close()
canvas.drawPath(path, Paint(Paint.ANTI_ALIAS_FLAG).apply { color = fill })
}
repeat(20) {
val x = rng.range(30f, 570f)
val y = rng.range(180f, 620f)
val r = rng.range(2.5f, 6f)
glowDot(canvas, x, y, r, shotAlpha(0xFFB067, rng.range(0.35f, 0.9f)))
}
}
@Composable
@@ -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(
@@ -24,7 +24,7 @@ struct LibraryCoverflowView: View {
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
private var ink: GamepadInk { .stored(paletteID) }
let games: [GameEntry]
let artLoader: LibraryArtLoader?
let artLoader: (any LibraryArtSource)?
var onLaunch: ((String) -> Void)?
/// Button B (back) dismisses the library screen. No touch equivalent needed here (the toolbar
/// Close button already covers that); this is what makes gamepad-only exit possible.
@@ -74,7 +74,7 @@ struct LibraryView: View {
@State private var errorText: String?
/// Cover-art loader (the same paired identity + host pinning as the list fetch, reused across
/// every poster in the grid). Built alongside `games` in `load()`; dropped on disappear.
@State private var artLoader: LibraryArtLoader?
@State private var artLoader: (any LibraryArtSource)?
#if os(iOS) || os(macOS)
/// The plain grid's hardware-keyboard cursor (a game id), and the grid width the column count
/// is derived from. nil until the first arrow press, so a touch user never sees a selection
@@ -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.
@@ -409,7 +420,7 @@ private struct LibraryBackCatcher: View {
/// (portrait header hero) and finally a text placeholder.
private struct GameCard: View {
let game: GameEntry
let artLoader: LibraryArtLoader?
let artLoader: (any LibraryArtSource)?
/// The hardware-keyboard cursor is on this tile drawn as an accent ring, since the plain
/// grid has no other way to say "Return launches THIS one".
var selected = false
@@ -70,7 +70,7 @@ private extension Image {
struct PosterImage: View {
let candidates: [URL]
let title: String
let loader: LibraryArtLoader?
let loader: (any LibraryArtSource)?
/// The entry's brand-mark token (`GameEntry.iconToken`), when it has one. A launcher tile ships
/// no cover art by design, so for those the mark IS the poster see `placeholder`.
var icon: String?
@@ -207,15 +207,20 @@ enum ShotMock {
/// A believable shelf for the library coverflow. Decoded rather than constructed:
/// `GameEntry`'s memberwise init is internal to PunktfunkKit, and Codable is its public
/// construction surface. No art URLs the posters render their deterministic fallback
/// (title tiles, the Steam entry its brand mark), which is also what keeps the shot offline.
/// construction surface. The `shot://art/` posters are answered by [`ShotPosterArt.source`]
/// (drawn at capture time), so the shot stays offline; the Steam launcher entry stays artless
/// by design and renders its brand mark.
static let games: [GameEntry] = {
let json = """
[
{"id": "custom:aurora", "store": "custom", "title": "Aurora Drift", "art": {}},
{"id": "steam:starfall", "store": "steam", "title": "Starfall Vale", "art": {}},
{"id": "heroic:neon", "store": "heroic", "title": "Neon Circuit", "art": {}},
{"id": "gog:ember", "store": "gog", "title": "Ember Peaks", "art": {}},
{"id": "custom:aurora", "store": "custom", "title": "Aurora Drift",
"art": {"portrait": "shot://art/aurora"}},
{"id": "steam:starfall", "store": "steam", "title": "Starfall Vale",
"art": {"portrait": "shot://art/starfall"}},
{"id": "heroic:neon", "store": "heroic", "title": "Neon Circuit",
"art": {"portrait": "shot://art/neon"}},
{"id": "gog:ember", "store": "gog", "title": "Ember Peaks",
"art": {"portrait": "shot://art/ember"}},
{"id": "steam:launcher", "store": "steam", "title": "Steam", "art": {},
"role": "launcher", "icon": "steam"}
]
@@ -263,12 +268,12 @@ private struct ShotHome: View {
// MARK: - Library
/// The library coverflow with the mock shelf the store listing's PICK & PLAY frame. The real
/// `LibraryCoverflowView`, no network: artless entries settle to their deterministic fallback
/// posters, and the entrance's 700 ms backstop has long fired by the time the driver captures.
/// `LibraryCoverflowView`, no network: `ShotPosterArt` answers the mock entries' art immediately,
/// so the cards swing in already carrying posters (the entrance waits on art settling).
private struct ShotLibrary: View {
var body: some View {
LibraryCoverflowView(
games: ShotMock.games, artLoader: nil,
games: ShotMock.games, artLoader: ShotPosterArt.source,
onLaunch: { _ in }, onDismiss: {}, controllerActive: false)
}
}
@@ -0,0 +1,264 @@
// Procedural cover art for the screenshot shelf. The store's library frames used to render the
// deterministic text-placeholder posters (`artLoader: nil`), which read as an empty library next
// to the Android listing's populated one. These four posters are drawn with CoreGraphics at
// capture time no bundled assets, nothing in a release build, and the same designs the Android
// harness draws in Canvas, so the two listings show the same shelf.
#if DEBUG
import CoreText
import Foundation
import ImageIO
import PunktfunkKit
import UniformTypeIdentifiers
/// A canned `LibraryArtSource`: poster bytes by URL, no network. What the screenshot shelf hands
/// the real coverflow in place of the paired-host loader.
struct ShotArtSource: LibraryArtSource {
let fixtures: [String: Data]
func data(for url: URL) async throws -> Data {
guard let data = fixtures[url.absoluteString] else {
throw CocoaError(.fileNoSuchFile)
}
return data
}
func close() async {}
}
enum ShotPosterArt {
/// Art for `ShotMock.games` keyed by the `shot://art/` URLs those entries carry.
static let source = ShotArtSource(fixtures: [
"shot://art/aurora": poster("AURORA DRIFT", draw: drawAurora),
"shot://art/starfall": poster("STARFALL VALE", draw: drawStarfall),
"shot://art/neon": poster("NEON CIRCUIT", draw: drawNeon),
"shot://art/ember": poster("EMBER PEAKS", draw: drawEmber),
])
private static let W = 600
private static let H = 900
// MARK: - Canvas plumbing
private static func poster(_ title: String, draw: (CGContext) -> Void) -> Data {
let space = CGColorSpace(name: CGColorSpace.sRGB)!
let ctx = CGContext(
data: nil, width: W, height: H, bitsPerComponent: 8, bytesPerRow: 0,
space: space, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!
draw(ctx)
drawTitle(ctx, title)
let image = ctx.makeImage()!
let out = NSMutableData()
let dest = CGImageDestinationCreateWithData(
out, UTType.png.identifier as CFString, 1, nil)!
CGImageDestinationAddImage(dest, image, nil)
CGImageDestinationFinalize(dest)
return out as Data
}
private static func rgb(_ hex: UInt32, _ alpha: CGFloat = 1) -> CGColor {
CGColor(
srgbRed: CGFloat((hex >> 16) & 0xff) / 255,
green: CGFloat((hex >> 8) & 0xff) / 255,
blue: CGFloat(hex & 0xff) / 255, alpha: alpha)
}
/// Vertical gradient over the full canvas; `stops` bottom-to-top as (location, color).
private static func sky(_ ctx: CGContext, _ stops: [(CGFloat, CGColor)]) {
let gradient = CGGradient(
colorsSpace: CGColorSpace(name: CGColorSpace.sRGB)!,
colors: stops.map(\.1) as CFArray,
locations: stops.map(\.0))!
ctx.drawLinearGradient(
gradient, start: .zero, end: CGPoint(x: 0, y: CGFloat(H)), options: [])
}
private static func glowDot(
_ ctx: CGContext, at center: CGPoint, radius: CGFloat, color: CGColor
) {
let clear = color.copy(alpha: 0)!
let gradient = CGGradient(
colorsSpace: CGColorSpace(name: CGColorSpace.sRGB)!,
colors: [color, clear] as CFArray, locations: [0, 1])!
ctx.drawRadialGradient(
gradient, startCenter: center, startRadius: 0,
endCenter: center, endRadius: radius, options: [])
}
/// Stroke `path` three times, wide-and-faint to thin-and-bright, in screen blend the cheap
/// neon-glow trick every one of these posters leans on.
private static func glowStroke(
_ ctx: CGContext, _ path: CGPath, width: CGFloat, color: CGColor
) {
ctx.saveGState()
ctx.setBlendMode(.screen)
ctx.setLineCap(.round)
ctx.setLineJoin(.round)
for (mult, alpha) in [(2.6, 0.12), (1.3, 0.28), (0.55, 0.85)] {
ctx.addPath(path)
ctx.setLineWidth(width * mult)
ctx.setStrokeColor(color.copy(alpha: alpha)!)
ctx.strokePath()
}
ctx.restoreGState()
}
private static func drawTitle(_ ctx: CGContext, _ title: String) {
// A soft floor behind the caption keeps it legible over any art.
sky(ctx, [(0, rgb(0x000000, 0.55)), (0.22, rgb(0x000000, 0))])
let font = CTFontCreateWithName("HelveticaNeue-CondensedBold" as CFString, 46, nil)
let text = NSAttributedString(string: title, attributes: [
.font: font, .kern: 5, .foregroundColor: rgb(0xFFFFFF, 0.94),
] as [NSAttributedString.Key: Any])
let line = CTLineCreateWithAttributedString(text)
let bounds = CTLineGetBoundsWithOptions(line, [])
ctx.saveGState()
ctx.setShadow(offset: CGSize(width: 0, height: -2), blur: 8, color: rgb(0x000000, 0.6))
ctx.textPosition = CGPoint(x: (CGFloat(W) - bounds.width) / 2, y: 72)
CTLineDraw(line, ctx)
ctx.restoreGState()
}
/// Deterministic LCG so every capture draws the identical poster.
private struct Rand {
var state: UInt64
mutating func next() -> CGFloat {
state = state &* 6364136223846793005 &+ 1442695040888963407
return CGFloat(state >> 33) / CGFloat(UInt64(1) << 31)
}
mutating func in_(_ lo: CGFloat, _ hi: CGFloat) -> CGFloat { lo + next() * (hi - lo) }
}
// MARK: - The four posters
private static func drawAurora(_ ctx: CGContext) {
sky(ctx, [(0, rgb(0x221E5C)), (0.45, rgb(0x141040)), (1, rgb(0x0B0830))])
var rng = Rand(state: 11)
for _ in 0..<48 {
let p = CGPoint(x: rng.in_(0, 600), y: rng.in_(300, 890))
glowDot(ctx, at: p, radius: rng.in_(1.4, 3.2), color: rgb(0xFFFFFF, rng.in_(0.25, 0.8)))
}
let ribbons: [(base: CGFloat, amp: CGFloat, freq: CGFloat, phase: CGFloat, w: CGFloat, c: UInt32)] = [
(700, 55, 1.15, 0.4, 30, 0x6656F2),
(615, 70, 1.4, 2.2, 24, 0x8F7BFF),
(530, 45, 0.95, 4.1, 18, 0x35D0C5),
]
for r in ribbons {
let path = CGMutablePath()
for i in 0...60 {
let t = CGFloat(i) / 60
let p = CGPoint(
x: t * 600,
y: r.base + r.amp * sin(t * .pi * r.freq + r.phase) + 40 * t)
if i == 0 { path.move(to: p) } else { path.addLine(to: p) }
}
glowStroke(ctx, path, width: r.w, color: rgb(r.c))
}
// A low ridge grounds the scene without it the poster's bottom half is bare sky.
for (fill, baseline, rough) in [
(rgb(0x191345), CGFloat(212), CGFloat(30)),
(rgb(0x0E0A2E), CGFloat(148), CGFloat(38)),
] {
let path = CGMutablePath()
path.move(to: CGPoint(x: 0, y: 0))
path.addLine(to: CGPoint(x: 0, y: baseline + rng.in_(-rough, rough)))
for i in 1...9 {
let x = CGFloat(i) / 9 * 600
path.addLine(to: CGPoint(x: x, y: baseline + rng.in_(-rough, rough)))
}
path.addLine(to: CGPoint(x: 600, y: 0))
path.closeSubpath()
ctx.setFillColor(fill)
ctx.addPath(path)
ctx.fillPath()
}
}
private static func drawStarfall(_ ctx: CGContext) {
sky(ctx, [(0, rgb(0x2A0C24)), (0.35, rgb(0x7A2B58)), (0.8, rgb(0xE86FA8)), (1, rgb(0xF7A8C8))])
var rng = Rand(state: 23)
for _ in 0..<6 {
let head = CGPoint(x: rng.in_(60, 560), y: rng.in_(420, 840))
let len = rng.in_(90, 170)
let dir = CGVector(dx: cos(2.15), dy: sin(2.15)) // ~123° up-left tails
let path = CGMutablePath()
path.move(to: head)
path.addLine(to: CGPoint(x: head.x + dir.dx * len, y: head.y + dir.dy * len))
glowStroke(ctx, path, width: 4, color: rgb(0xFFE3EF))
glowDot(ctx, at: head, radius: 11, color: rgb(0xFFFFFF, 0.9))
}
for (fill, baseline, rough) in [
(rgb(0x3A1430), CGFloat(300), CGFloat(26)),
(rgb(0x1D0818), CGFloat(216), CGFloat(34)),
] {
let path = CGMutablePath()
path.move(to: CGPoint(x: 0, y: 0))
path.addLine(to: CGPoint(x: 0, y: baseline))
for i in 1...8 {
let x = CGFloat(i) / 8 * 600
path.addLine(to: CGPoint(x: x, y: baseline + rng.in_(-rough, rough)))
}
path.addLine(to: CGPoint(x: 600, y: 0))
path.closeSubpath()
ctx.setFillColor(fill)
ctx.addPath(path)
ctx.fillPath()
}
}
private static func drawNeon(_ ctx: CGContext) {
sky(ctx, [(0, rgb(0x0A2A33)), (1, rgb(0x04161C))])
var rng = Rand(state: 7)
let ring = CGPath(
ellipseIn: CGRect(x: 300 - 105, y: 560 - 105, width: 210, height: 210), transform: nil)
glowStroke(ctx, ring, width: 10, color: rgb(0x35D0C5))
for i in 0..<9 {
// Right-angle traces on a 40 px grid, some feeding out of the ring's four gates.
var p = i < 4
? CGPoint(x: 300 + [-105, 105, 0, 0][i], y: 560 + [0, 0, -105, 105][i])
: CGPoint(x: 40 * (rng.in_(1, 14)).rounded(), y: 40 * (rng.in_(1, 21)).rounded())
let path = CGMutablePath()
path.move(to: p)
var horizontal = rng.next() > 0.5
for _ in 0..<Int(rng.in_(3, 6)) {
let step = 40 * rng.in_(1, 4).rounded() * (rng.next() > 0.5 ? 1 : -1)
p = horizontal ? CGPoint(x: min(max(p.x + step, 20), 580), y: p.y)
: CGPoint(x: p.x, y: min(max(p.y + step, 20), 880))
path.addLine(to: p)
horizontal.toggle()
}
let color = rng.next() > 0.6 ? rgb(0x7FE8DE) : rgb(0x35D0C5)
glowStroke(ctx, path, width: 5, color: color)
glowDot(ctx, at: p, radius: 12, color: color.copy(alpha: 0.9)!)
}
}
private static func drawEmber(_ ctx: CGContext) {
sky(ctx, [(0, rgb(0x200A04)), (0.3, rgb(0x7A2E12)), (0.42, rgb(0xEF8F4B)), (1, rgb(0x2A0E06))])
glowDot(ctx, at: CGPoint(x: 300, y: 385), radius: 160, color: rgb(0xFFC37A, 0.85))
var rng = Rand(state: 41)
for (fill, baseline, rough) in [
(rgb(0x5A2410), CGFloat(340), CGFloat(42)),
(rgb(0x401708), CGFloat(255), CGFloat(56)),
(rgb(0x200A04), CGFloat(165), CGFloat(48)),
] {
let path = CGMutablePath()
path.move(to: CGPoint(x: 0, y: 0))
path.addLine(to: CGPoint(x: 0, y: baseline + rng.in_(-rough, rough)))
for i in 1...10 {
let x = CGFloat(i) / 10 * 600
path.addLine(to: CGPoint(x: x, y: baseline + rng.in_(-rough, rough)))
}
path.addLine(to: CGPoint(x: 600, y: 0))
path.closeSubpath()
ctx.setFillColor(fill)
ctx.addPath(path)
ctx.fillPath()
}
for _ in 0..<20 {
let p = CGPoint(x: rng.in_(30, 570), y: rng.in_(180, 620))
glowDot(ctx, at: p, radius: rng.in_(2.5, 6), color: rgb(0xFFB067, rng.in_(0.35, 0.9)))
}
}
}
#endif
@@ -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
@@ -48,11 +48,8 @@ struct GamepadPairView: View {
@StateObject private var ceremony = PairCeremony()
@State private var pin = ""
#if os(macOS)
@State private var clientName = Host.current().localizedName ?? "Mac"
#else
@State private var clientName = UIDevice.current.name
#endif
// Same source the connect path knocks with see the note in `PairSheet`.
@State private var clientName = DeviceName.current
@State private var focusID: String?
/// The field row the keyboard tray is editing; nil the row list owns the controller.
@State private var editing: String?
@@ -49,7 +49,7 @@ final class PairCeremony: ObservableObject {
let identity = try ClientIdentityStore.shared.loadForPairing()
return try PunktfunkKit.pair(
host: address, port: port, identity: identity,
pin: pin, name: name.isEmpty ? "Mac" : name)
pin: pin, name: name.isEmpty ? DeviceName.current : name)
}
await MainActor.run {
guard !token.cancelled else { return } // screen dismissed mid-ceremony
@@ -21,11 +21,9 @@ struct PairSheet: View {
let onPaired: (Data) -> Void
@State private var pin = ""
#if os(macOS)
@State private var clientName = Host.current().localizedName ?? "Mac"
#else
@State private var clientName = UIDevice.current.name
#endif
// Same source the connect path knocks with (`DeviceName.current`), so a device the operator
// approves from the console's pending list and one that pairs by PIN land under one name.
@State private var clientName = DeviceName.current
@StateObject private var ceremony = PairCeremony()
private var busy: Bool { ceremony.busy }
@@ -0,0 +1,66 @@
// The name this device tells a host it is the label an operator approves in the web console.
import Foundation
#if canImport(UIKit)
import UIKit
#endif
/// The name the USER knows this device by: "Enrico's iPad", "Wohnzimmer UG", "Enricos MacBook Pro".
///
/// The host shows it in its pending-approval list the web console's outstanding-pairings view and
/// the dialog that approves a knock and files the device under it in the trust store. It is the
/// ONLY thing distinguishing one waiting device from another there, so it must come from the OS
/// name the user set, not from a placeholder.
///
/// The core's own default (`punktfunk_connect_ex9` and earlier) reads `COMPUTERNAME` / `HOSTNAME`
/// a Windows variable and a shell variable. Neither exists in a `launchd`-started GUI app, so
/// every Apple client used to fall through to the literal "This device" and a console with an
/// iPad, an Apple TV and a Mac pending showed three rows of it. Pass this to
/// `punktfunk_connect_ex10` instead (`PunktfunkConnection.init` does, by default).
public enum DeviceName {
/// This device's user-facing name, never empty.
public static var current: String {
#if os(macOS)
let name = (Host.current().localizedName ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
return name.isEmpty ? (hostName ?? kind) : name
#else
let name = UIDevice.current.name.trimmingCharacters(in: .whitespacesAndNewlines)
// iOS/tvOS 16+ answer `name` with the MODEL ("iPad") unless the app holds the
// user-assigned-device-name entitlement which turns a household's three iPads into
// three identical rows in the host's approval list. The hostname is not behind that
// gate on every OS version, and when the user has named the device it carries that
// name ("Enricos-iPad"), so prefer it whenever `name` came back generic.
if name.isEmpty || name == kind {
if let host = hostName { return host }
}
return name.isEmpty ? kind : name
#endif
}
/// The OS hostname without its mDNS `.local` suffix nil when it is unset or the placeholder
/// every unconfigured device reports, which would name nothing.
private static var hostName: String? {
let host = ProcessInfo.processInfo.hostName
.trimmingCharacters(in: .whitespacesAndNewlines)
let bare = host.hasSuffix(".local") ? String(host.dropLast(6)) : host
guard !bare.isEmpty, bare.caseInsensitiveCompare("localhost") != .orderedSame else {
return nil
}
return bare
}
/// What to call the device when the OS has no name for it the product, which at least tells
/// an operator which of the pending rows is the Apple TV. (iOS/tvOS 16+ answer
/// `UIDevice.current.name` with exactly this unless the app holds the user-assigned-name
/// entitlement, so the two agree more often than not.)
public static var kind: String {
#if os(macOS)
return "Mac"
#elseif os(tvOS)
return "Apple TV"
#else
return UIDevice.current.model // "iPad" / "iPhone"
#endif
}
}
@@ -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
@@ -228,6 +228,16 @@ extension Artwork {
}
}
/// Anything that answers poster bytes for a cover-art URL. The production implementation is
/// [`LibraryArtLoader`]; the screenshot harness substitutes a canned source so store frames carry
/// artwork without a host on the network.
public protocol LibraryArtSource: Sendable {
func data(for url: URL) async throws -> Data
/// Release pooled connections when the owning screen goes away. Sources without connections
/// have nothing to do.
func close() async
}
/// Loads cover art for the library UI, routing each URL to the transport that suits its origin.
///
/// A `GameEntry`'s art candidates mix two very different things: the host's own art proxy
@@ -242,7 +252,7 @@ extension Artwork {
/// TLS handshake per tile.
///
/// Built once per library screen and reused across a whole grid's worth of posters.
public final class LibraryArtLoader: @unchecked Sendable {
public final class LibraryArtLoader: LibraryArtSource, @unchecked Sendable {
private let address: String
private let port: UInt16
private let identity: SecIdentity
@@ -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.
@@ -604,6 +747,7 @@ public final class PunktfunkConnection {
preferredCodec: UInt8 = 0, // 0 = auto; else PUNKTFUNK_CODEC_* soft preference
clientCaps: UInt8 = 0, // ABI v11: PUNKTFUNK_CLIENT_CAP_CURSOR = render the host cursor locally
launchID: String? = nil,
deviceName: String? = nil, // nil = this device's OS name (`DeviceName.current`)
timeoutMs: UInt32 = 10_000
) throws {
if let pin = pinSHA256, pin.count != 32 { throw PunktfunkClientError.invalidPin }
@@ -616,25 +760,33 @@ public final class PunktfunkConnection {
// host upgrades to a 10-bit / BT.2020 PQ stream only when set. 0 = 8-bit BT.709 SDR.
// `launchID` (a host library id like "steam:570") asks the host to launch that title in
// the session; the host resolves it against its own library nil = the host's default.
// `label` is what an unpaired knock shows up as in the host's approval list (and the web
// console's outstanding-pairings view): this device's OS name unless the caller overrode
// it. Without it the core falls back to environment variables no Apple app has, and every
// device pending approval reads "This device".
let override = deviceName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let label = override.isEmpty ? DeviceName.current : override
handle = host.withCString { cs in
withOptionalCString(identity?.certPEM) { cert in
withOptionalCString(identity?.keyPEM) { key in
withOptionalCString(launchID) { launch in
if let pin = pinSHA256 {
return pin.withUnsafeBytes { p in
punktfunk_connect_ex9(
cs, port, width, height, refreshHz, compositor.rawValue,
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
videoCodecs, preferredCodec, clientCaps, launch,
p.bindMemory(to: UInt8.self).baseAddress, &observed,
cert, key, timeoutMs, &connectStatus)
label.withCString { name in
if let pin = pinSHA256 {
return pin.withUnsafeBytes { p in
punktfunk_connect_ex10(
cs, port, width, height, refreshHz, compositor.rawValue,
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
videoCodecs, preferredCodec, clientCaps, launch,
p.bindMemory(to: UInt8.self).baseAddress, &observed,
cert, key, name, timeoutMs, &connectStatus)
}
}
return punktfunk_connect_ex10(
cs, port, width, height, refreshHz, compositor.rawValue,
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
videoCodecs, preferredCodec, clientCaps, launch,
nil, &observed, cert, key, name, timeoutMs, &connectStatus)
}
return punktfunk_connect_ex9(
cs, port, width, height, refreshHz, compositor.rawValue,
gamepad.rawValue, bitrateKbps, videoCaps, audioChannels,
videoCodecs, preferredCodec, clientCaps, launch,
nil, &observed, cert, key, timeoutMs, &connectStatus)
}
}
}
@@ -1271,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)
}
@@ -1287,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))
}
@@ -1339,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)
@@ -1353,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
@@ -1374,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
@@ -1583,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 }
+1
View File
@@ -50,6 +50,7 @@ fn plan_for(req: &ConnectRequest, fp_hex: &str, tofu: bool, opts: &SpawnOpts) ->
fp_hex: Some(fp_hex.to_string()),
mac: req.mac.clone(),
id: None,
mgmt_port: None, // this shell resolves the library port itself (`mgmt_port_for`)
},
req.launch.as_ref().map(|(id, _)| id.clone()),
// A plain card click carries no one-off: the resolver honors the host's own binding
+4 -2
View File
@@ -12,7 +12,8 @@ x64 Windows runner — `x86_64-pc-windows-msvc` builds natively, `aarch64-pc-win
cross-compiled (the x64 MSVC toolset ships the ARM64 cross compiler; since M10 nothing in the
package links FFmpeg, so neither arch needs a per-arch `FFMPEG_DIR` tree staged on the runner —
one less thing the ARM64 leg can be missing). Artifacts are arch-suffixed
(`..._x64.msix` / `..._arm64.msix`, each with its matching `.cer`); `pack-msix.ps1 -Arch x64|arm64`
(`..._x64.msix` / `..._arm64.msix`, plus a matching `.cer` only in the fallback signing modes 2 and 3
— Azure signing emits none); `pack-msix.ps1 -Arch x64|arm64`
stamps the manifest `ProcessorArchitecture` and names the output. See
[`windows-client.yml`](../../../.gitea/workflows/windows-client.yml) for the cross-build rationale.
@@ -52,7 +53,8 @@ low-level input hooks, WASAPI and SDL3.
MSIX requires a strictly 4-part numeric version. The workflow computes:
- `vX.Y.Z` tag → `X.Y.Z.0` (THE release; any `-rc`/`+meta` suffix is dropped for MSIX). Published to
the stable `latest/` alias and attached to the unified Gitea Release.
- `main` push / `workflow_dispatch``0.3.<run_number>.0` (canary, climbs by run number; `canary/` alias).
- `main` push / `workflow_dispatch``X.<Y+1>.<run_number>.0` (canary — the minor *after* the latest
`v*` tag, per `scripts/ci/pf-version.ps1`, climbing by run number; `canary/` alias).
## Signing & install
+6
View File
@@ -28,6 +28,11 @@ pub struct DiscoveredHost {
/// `linux[/<family>][/<id>]`), sanitized — drives the host tile's OS mark and is
/// persisted like `mac`. Empty if absent (older host).
pub os: String,
/// The management API's port from the mDNS `mgmt` TXT — where the game library is served.
/// Persisted like `mac` (`trust::learn_mgmt_port`), and load-bearing rather than cosmetic:
/// a host moved off 47990 loses its library once mDNS is gone unless we write this down.
/// `None` if absent (older host) — resolve via `library::DEFAULT_MGMT_PORT`.
pub mgmt_port: Option<u16>,
}
/// Forces the running browse to re-query now — the hosts page's Refresh. Mirrors
@@ -124,6 +129,7 @@ pub fn browse() -> (async_channel::Receiver<DiscoveredHost>, Rescan) {
.filter(|s| !s.is_empty())
.collect(),
os: pf_client_core::os::sanitize_os(&val("os")),
mgmt_port: val("mgmt").parse().ok(),
};
if tx.send_blocking(host).is_err() {
break; // UI gone — stop browsing
+1
View File
@@ -160,6 +160,7 @@ pub(crate) fn spawn_session(
fp_hex: Some(fp_hex.to_string()),
mac: Vec::new(), // wake ran before this spawn (initiate_waking) — not the plan's job
id: None,
mgmt_port: None, // the library fetch runs in the shell (`Target`), never off a spawn plan
},
launch.map(str::to_string),
profile.map(str::to_string),
+2 -2
View File
@@ -8,6 +8,6 @@
//! still load via a serde alias in core.
pub use pf_client_core::trust::{
hex, learn_mac, learn_os, load_or_create_identity, pair_error_message, parse_hex32, KnownHost,
KnownHosts, Settings,
hex, learn_mac, learn_mgmt_port, learn_os, load_or_create_identity, pair_error_message,
parse_hex32, KnownHost, KnownHosts, Settings,
};
+62 -22
View File
@@ -1450,16 +1450,21 @@ pub fn pipewire_thread(
RGB CSC; PUNKTFUNK_PIPEWIRE_NV12=0 restores the packed-RGB negotiation)"
);
}
// Modifiers our import stack handles for BGRx: the EGL-importable (tiled) set, plus LINEAR
// (0) — NVIDIA's EGL won't list it, but LINEAR dmabufs (gamescope's only offer) import via
// CUDA external memory instead. For the VAAPI passthrough path we advertise LINEAR only:
// radeonsi/iHD import it and any compositor can allocate it.
let mut modifiers = importer
.as_mut()
.map(|i| i.supported_modifiers(pf_frame::drm_fourcc(PixelFormat::Bgrx).unwrap()))
.unwrap_or_default();
if (importer.is_some() || vaapi_passthrough) && !modifiers.contains(&0) {
modifiers.push(0); // DRM_FORMAT_MOD_LINEAR
// Modifiers our import stack handles, enumerated PER FOURCC. `XR24` (BGRx) and `AR24` (BGRA)
// are asked separately on purpose: EGL/libva answer per format, and nothing entitles us to
// assume a driver that imports one imports the other. Keeping them apart is also what makes
// the BGRA pod below correct on AMD and Intel rather than an NVIDIA-shaped guess — each list
// is whatever THIS GPU's stack actually said.
//
// To each list we add LINEAR (0) — NVIDIA's EGL won't list it, but LINEAR dmabufs (gamescope's
// only offer) import via CUDA external memory instead. For the VAAPI passthrough path there is
// no importer at all, so the lists start empty and LINEAR is all we advertise: radeonsi/iHD
// import it and any compositor can allocate it.
let mut modifiers = Vec::new();
let mut modifiers_bgra = Vec::new();
if let Some(i) = importer.as_mut() {
modifiers = i.supported_modifiers(pf_frame::drm_fourcc(PixelFormat::Bgrx).unwrap());
modifiers_bgra = i.supported_modifiers(pf_frame::drm_fourcc(PixelFormat::Bgra).unwrap());
}
// PyroWave passthrough: the encoder imports through Vulkan, not libva — extend the
// advertisement with every modifier its device samples from, so compositors that
@@ -1468,12 +1473,20 @@ pub fn pipewire_thread(
// the host's `pyrowave` feature is on AND the session (or the global encoder pref) is
// PyroWave — so capture never calls back into `encode` and needs no feature gate of its
// own (the emptiness check gates it).
if vaapi_passthrough && !policy.pyrowave_modifiers.is_empty() {
for &m in &policy.pyrowave_modifiers {
if !modifiers.contains(&m) {
modifiers.push(m);
let extend_pyrowave = vaapi_passthrough && !policy.pyrowave_modifiers.is_empty();
for list in [&mut modifiers, &mut modifiers_bgra] {
if (importer.is_some() || vaapi_passthrough) && !list.contains(&0) {
list.push(0); // DRM_FORMAT_MOD_LINEAR
}
if extend_pyrowave {
for &m in &policy.pyrowave_modifiers {
if !list.contains(&m) {
list.push(m);
}
}
}
}
if extend_pyrowave {
tracing::info!(
count = modifiers.len(),
"zero-copy: advertising the PyroWave device's Vulkan-importable dmabuf modifiers"
@@ -1540,9 +1553,14 @@ pub fn pipewire_thread(
);
} else if want_dmabuf {
tracing::info!(
count = modifiers.len(),
bgrx_count = modifiers.len(),
bgra_count = modifiers_bgra.len(),
// `sample` is TRUNCATED to 6, and LINEAR is pushed last — so reading the sample as the
// whole list makes a perfectly good offer look tiled-only. That misreading cost a full
// debugging session on 2026-08-14, hence stating the one bit that was actually wanted.
linear_offered = modifiers.contains(&0),
sample = ?&modifiers[..modifiers.len().min(6)],
"zero-copy: advertising EGL-importable dmabuf modifiers"
"zero-copy: advertising EGL-importable dmabuf modifiers (BGRx + BGRA pods)"
);
} else if consumer.cpu_is_downgrade() {
// Reached only when no dmabuf is advertised at all (every arm above rules out a
@@ -2094,17 +2112,39 @@ pub fn pipewire_thread(
.map(|fmt| build_hdr_dmabuf_format(*fmt, preferred))
.collect::<Result<Vec<_>>>()?
} else if want_dmabuf {
let mut pods = Vec::with_capacity(if prefer_native_nv12 { 2 } else { 1 });
let mut pods = Vec::with_capacity(if prefer_native_nv12 { 3 } else { 2 });
if prefer_native_nv12 {
// First compatible consumer pod wins. Gamescope advertises NV12 and BGRx; pinning
// BT.709 limited here selects its RGB→NV12 shader with our bitstream colorimetry.
pods.push(build_dmabuf_format(VideoFormat::NV12, &[0], preferred)?);
}
pods.push(build_dmabuf_format(
VideoFormat::BGRx,
&modifiers,
preferred,
)?);
if !modifiers.is_empty() {
pods.push(build_dmabuf_format(
VideoFormat::BGRx,
&modifiers,
preferred,
)?);
}
// xdph (Hyprland/sway) offers ONLY **BGRA** on its dmabuf EnumFormat — it lists BGRA *and*
// BGRx on the SHM pod, so a BGRx-only dmabuf offer intersects with nothing and PipeWire
// fails the link outright:
// pw.link: negotiating -> error no more input formats (-22)
// Measured 2026-08-14 on Hyprland 0.55.4 + xdph 1.3.12: the 12 tiled modifiers matched on
// both sides perfectly — only the fourcc never did, which is why the failure reads like a
// GPU/modifier problem and is not one.
//
// BGRA and BGRx are the same 32-bit layout; the alpha byte is ignored the whole way to the
// encoder (`vk_util` maps both to `B8G8R8A8_UNORM`, VAAPI both to `Pixel::BGRA`), and the
// dmabuf import is driven by the NEGOTIATED format's fourcc, so an AR24 frame imports as
// AR24. Listed AFTER BGRx so a producer offering both still lands on the pre-existing path
// — first compatible consumer pod wins, so this is purely additive.
if !modifiers_bgra.is_empty() {
pods.push(build_dmabuf_format(
VideoFormat::BGRA,
&modifiers_bgra,
preferred,
)?);
}
pods
} else {
vec![serialize_pod(obj)?]
+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;
+7
View File
@@ -38,6 +38,12 @@ pub struct HostTarget {
pub fp_hex: Option<String>,
pub mac: Vec<String>,
pub id: Option<String>,
/// The host's management-API port (saved store or live advert) — where the library is
/// served, distinct from `port` (the native QUIC plane). Carried on the target for the same
/// reason as `mac`: a front-end holding a plan has no `KnownHost` in hand, and resolving to
/// [`crate::library::DEFAULT_MGMT_PORT`] there is what made a moved mgmt port work on the
/// LAN but not over a VPN. `None` = unknown, fall back to the constant.
pub mgmt_port: Option<u16>,
}
impl From<&KnownHost> for HostTarget {
@@ -49,6 +55,7 @@ impl From<&KnownHost> for HostTarget {
fp_hex: (!h.fp_hex.is_empty()).then(|| h.fp_hex.clone()),
mac: h.mac.clone(),
id: h.id.clone(),
mgmt_port: h.mgmt_port,
}
}
}
+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
@@ -0,0 +1,79 @@
//! The compositor output absolute coordinates belong to, by NAME — the Linux counterpart of the
//! Windows `stream_target` slot, and what the wlroots virtual-pointer backend aims at.
//!
//! `MouseMoveAbs` carries its own reference extent (`w`/`h` — the client's letterboxed video rect
//! in ITS window, not the streamed mode), and the wlr protocol normalizes `x`/`y` against it and
//! maps the result onto whichever `wl_output` the virtual pointer was **created with**. So the
//! extent takes care of itself and the OUTPUT is the whole question. The injector used to pass the
//! first `wl_output` the registry advertised, which is the oldest global — on any multi-head box
//! the operator's physical head, never the per-session headless output the client is looking at.
//! On the EXTEND backends (Hyprland, wlroots/sway) the streamed head sits *beside* the operator's,
//! so absolute samples landed on a screen no session was streaming. Reported from the field as
//! "no cursor was visible in the session", and later as a cursor pinned near the left edge that
//! vanished part-way across.
//!
//! The host publishes the streamed output's compositor name at capture bring-up
//! ([`set_stream_output`]) — Hyprland's `PF-<pid>-<n>`, sway's `HEADLESS-N`, or a mirrored head's
//! connector — and the wlr backend re-creates its virtual pointer bound to the matching `wl_output`
//! (`wl_output.name`, protocol v4; the name is explicitly "the same for all clients", so the name
//! `hyprctl`/`swaymsg` minted is the name we can match here).
//!
//! **One slot per process**, exactly like the Windows original: the injector is host-lifetime and
//! every concurrent session's input flows through it, so with parallel sessions the LAST capture
//! bring-up wins for every session's absolute input. Per-session routing needs source-tagged input
//! events (the injector has to become session-aware first — see [`crate::set_absolute_anchor`]'s
//! note), and the single slot is never worse than what it replaces: today EVERY session's absolute
//! input lands on a head that no session is streaming.
//!
//! With nothing published — before the first bring-up, or on a compositor whose `wl_output` is
//! older than v4 and therefore nameless — the pointer is bound to NO output, which maps absolute
//! coordinates over the whole layout. On a single-output compositor that is identical to binding
//! that output; on a multi-head one it is at least *reachable*, unlike a pin to the wrong head.
use std::sync::RwLock;
/// The streamed output's compositor name, or `None` when nothing has been published yet.
static STREAM_OUTPUT: RwLock<Option<String>> = RwLock::new(None);
/// Publish the compositor output (by name) that absolute input maps into. The host calls this at
/// capture bring-up, and ONLY there: nothing clears it at teardown, because an output that goes
/// away simply stops resolving (the backend falls back to whole-layout mapping, and between
/// sessions nothing injects anyway). A later bring-up is what rewrites it — including to `None`,
/// which a backend that needs no named binding passes so a stale name cannot outlive its
/// compositor. See the module doc for the one-slot-per-process trade with parallel sessions.
pub fn set_stream_output(name: Option<String>) {
let mut cur = STREAM_OUTPUT.write().unwrap_or_else(|e| e.into_inner());
if *cur != name {
tracing::info!(output = ?name, "absolute-input stream output set");
*cur = name;
}
}
/// The streamed output's compositor name, if one has been published.
pub fn stream_output() -> Option<String> {
STREAM_OUTPUT
.read()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
#[cfg(test)]
mod tests {
use super::*;
/// ONE test on purpose, like the libei anchor's: the slot is process-wide and cargo runs
/// tests on threads in one process, so splitting this into several would let them race.
#[test]
fn publishes_clears_and_round_trips() {
set_stream_output(Some("PF-1643-1".into()));
assert_eq!(stream_output().as_deref(), Some("PF-1643-1"));
// Re-publishing the same name is a no-op, not a second "set" (the backend keys its
// pointer re-creation off the resolved name, but the log line should not repeat).
set_stream_output(Some("PF-1643-1".into()));
assert_eq!(stream_output().as_deref(), Some("PF-1643-1"));
set_stream_output(Some("HEADLESS-2".into()));
assert_eq!(stream_output().as_deref(), Some("HEADLESS-2"));
set_stream_output(None);
assert_eq!(stream_output(), None);
}
}
+283 -26
View File
@@ -5,6 +5,11 @@
//! virtual keyboard (the host's layout via the standard `XKB_DEFAULT_LAYOUT` et al., defaulting
//! to evdev/US), and translate events into virtual pointer/keyboard requests, tracking modifier
//! state so the compositor resolves shifted keysyms correctly.
//!
//! **Absolute** motion is mapped by the compositor onto the `wl_output` the virtual pointer was
//! CREATED with, so which output that is decides where every absolute sample lands. We aim it at
//! the head the session is actually streaming — published by name in [`crate::stream_output`] and
//! re-resolved (re-creating the pointer) whenever it changes; see [`WlrootsInjector::retarget`].
use super::{gs_button_to_evdev, vk_to_evdev, InputEvent, InputInjector};
use anyhow::{bail, Context, Result};
@@ -12,7 +17,12 @@ use punktfunk_core::input::InputKind;
use std::io::Write;
use std::os::fd::{AsFd, FromRawFd};
use std::time::Instant;
use wayland_client::protocol::{wl_output::WlOutput, wl_pointer, wl_registry, wl_seat::WlSeat};
use wayland_client::backend::WaylandError;
use wayland_client::protocol::{
wl_output::{self, WlOutput},
wl_pointer, wl_registry,
wl_seat::WlSeat,
};
use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle};
use wayland_protocols_misc::zwp_virtual_keyboard_v1::client::{
zwp_virtual_keyboard_manager_v1::ZwpVirtualKeyboardManagerV1,
@@ -27,13 +37,65 @@ use xkbcommon::xkb;
/// `code` value marking a horizontal scroll event (mirrors `gamestream::input`).
const SCROLL_HORIZONTAL: u32 = 1;
/// `wl_output.name` — the connector name we match the streamed head on — arrived in v4. Nothing
/// else we ask of an output needs more than v1, so a lower advert only costs us the names (and
/// with them the ability to aim absolute input; see [`index_named`]). Same constant, same reason,
/// as `pf_vdisplay`'s `kwin_dpms`.
const WL_OUTPUT_MAX: u32 = 4;
/// One `wl_output` the compositor has advertised.
struct Output {
/// The registry global name — the key `wl_registry.global_remove` reports, and the user data
/// each `wl_output` event carries back so we know which head it describes.
global: u32,
proxy: WlOutput,
/// `wl_output.name` (protocol v4): the compositor's own name for the head — `HDMI-A-1`,
/// Hyprland's `PF-<pid>-<n>`, sway's `HEADLESS-N`. The protocol guarantees this is "the same
/// output name for all clients", which is what lets us match the name `hyprctl`/`swaymsg`
/// minted on the vdisplay side. `None` on a compositor stuck at v3, which has no name event at
/// all — then there is nothing to match on and the pointer stays unbound.
name: Option<String>,
}
/// Globals bound from the registry (the Wayland dispatch state).
#[derive(Default)]
struct Globals {
pointer_mgr: Option<ZwlrVirtualPointerManagerV1>,
keyboard_mgr: Option<ZwpVirtualKeyboardManagerV1>,
seat: Option<WlSeat>,
output: Option<WlOutput>,
/// EVERY advertised output, in advertisement order — not just the first. The streamed head is
/// created per session, so it is never the first one advertised (that is the operator's
/// oldest physical head), and binding only the first is what aimed absolute input at the
/// wrong screen on every EXTEND box.
outputs: Vec<Output>,
}
/// Which advertised output — by position in `names`, which is advertisement order — the virtual
/// pointer should bind to for the published target `want`.
///
/// The rule has **no fallback on purpose**, and that absence is the fix: what this replaced was a
/// fallback ("bind whatever `wl_output` came first"), and the first-advertised output is the oldest
/// global, i.e. the operator's physical head — never the per-session headless one the client is
/// looking at. A target that matches nothing therefore yields `None`, which binds the pointer to no
/// output and maps absolute coordinates over the whole layout: wrong-ish, but reachable, where a
/// pin to the wrong head is unreachable.
///
/// Split out of [`Globals::output_named`] so the rule is testable — a `WlOutput` proxy cannot be
/// constructed without a live Wayland connection, but the decision it feeds can.
fn index_named<'a>(
names: impl IntoIterator<Item = Option<&'a str>>,
want: Option<&str>,
) -> Option<usize> {
let want = want?;
names.into_iter().position(|n| n == Some(want))
}
impl Globals {
/// The `wl_output` whose compositor name is `want`, if it is currently advertised.
fn output_named(&self, want: &str) -> Option<WlOutput> {
index_named(self.outputs.iter().map(|o| o.name.as_deref()), Some(want))
.map(|i| self.outputs[i].proxy.clone())
}
}
impl Dispatch<wl_registry::WlRegistry, ()> for Globals {
@@ -45,13 +107,12 @@ impl Dispatch<wl_registry::WlRegistry, ()> for Globals {
_: &Connection,
qh: &QueueHandle<Self>,
) {
if let wl_registry::Event::Global {
name,
interface,
version,
} = event
{
match interface.as_str() {
match event {
wl_registry::Event::Global {
name,
interface,
version,
} => match interface.as_str() {
"zwlr_virtual_pointer_manager_v1" => {
state.pointer_mgr = Some(registry.bind(name, version.min(2), qh, ()));
}
@@ -61,16 +122,52 @@ impl Dispatch<wl_registry::WlRegistry, ()> for Globals {
"wl_seat" => {
state.seat = Some(registry.bind(name, version.min(7), qh, ()));
}
"wl_output" if state.output.is_none() => {
state.output = Some(registry.bind(name, version.min(3), qh, ()));
"wl_output" => {
// The `name` event is the only thing that tells the streamed head from the
// operator's. Older compositors bind lower and stay nameless (harmless:
// `output_named` then matches nothing and the pointer maps over the layout).
// The registry global name rides along as user data so the events that follow
// land on the right entry.
let proxy = registry.bind(name, version.min(WL_OUTPUT_MAX), qh, name);
state.outputs.push(Output {
global: name,
proxy,
name: None,
});
}
_ => {}
},
// A head went away — a session's headless output being torn down is the common case,
// and the pointer must stop being aimed at a dead object (`retarget` re-resolves and
// falls back to the whole layout on the next absolute sample).
wl_registry::Event::GlobalRemove { name } => {
state.outputs.retain(|o| o.global != name);
}
_ => {}
}
}
}
impl Dispatch<WlOutput, u32> for Globals {
fn event(
state: &mut Self,
_: &WlOutput,
event: wl_output::Event,
global: &u32,
_: &Connection,
_: &QueueHandle<Self>,
) {
// Only the name matters here: geometry/mode/scale are the compositor's problem, because
// binding the pointer to an output makes IT do the mapping (see `retarget`).
if let wl_output::Event::Name { name } = event {
if let Some(o) = state.outputs.iter_mut().find(|o| o.global == *global) {
o.name = Some(name);
}
}
}
}
// The managers, the two virtual devices, the seat and the output emit no events we use.
// The managers, the two virtual devices and the seat emit no events we use.
macro_rules! ignore_events {
($($t:ty),* $(,)?) => {$(
impl Dispatch<$t, ()> for Globals {
@@ -80,7 +177,6 @@ macro_rules! ignore_events {
}
ignore_events!(
WlSeat,
WlOutput,
ZwlrVirtualPointerManagerV1,
ZwlrVirtualPointerV1,
ZwpVirtualKeyboardManagerV1,
@@ -92,6 +188,14 @@ pub struct WlrootsInjector {
queue: EventQueue<Globals>,
globals: Globals,
pointer: ZwlrVirtualPointerV1,
/// The compositor name of the output `pointer` is bound to, or `None` when it is bound to no
/// output (absolute coordinates then span the whole layout). Compared against
/// [`crate::stream_output`] on every absolute sample; a difference re-creates the pointer.
bound_output: Option<String>,
/// evdev codes of the mouse buttons currently held on `pointer`, so re-creating the device
/// can release them first — the compositor has no reason to, and a virtual pointer destroyed
/// mid-press leaves the host with a stuck mouse button.
pressed: Vec<u32>,
keyboard: ZwpVirtualKeyboardV1,
xkb_state: xkb::State,
_keymap_file: std::fs::File, // keep the memfd alive for the compositor's mmap
@@ -100,6 +204,25 @@ pub struct WlrootsInjector {
start: Instant,
}
/// Resolve the published stream output ([`crate::stream_output`]) against the outputs this
/// connection has bound: `(proxy, name)` when the target is live, `(None, None)` otherwise.
///
/// `(None, None)` covers three cases that all want the same answer — nothing published yet (before
/// the first capture bring-up), the target's `wl_output` global not advertised yet (the injector
/// opens on the first input event, which can beat the session's display), and the target torn down
/// (session end). A pointer bound to no output maps absolute coordinates over the whole layout,
/// which on a single-output compositor is exactly that output and on a multi-head one at least
/// keeps the streamed head reachable — unlike a pin to a head nobody is streaming.
fn resolve_target(globals: &Globals) -> (Option<WlOutput>, Option<String>) {
let Some(want) = crate::stream_output() else {
return (None, None);
};
match globals.output_named(&want) {
Some(proxy) => (Some(proxy), Some(want)),
None => (None, None),
}
}
/// Cap on distinct characters the dynamic text keymap holds before it restarts from scratch
/// (keycodes grow upward from 9; xkb tops out at 255, so stay well under).
const TEXT_KEYMAP_MAX: usize = 200;
@@ -140,12 +263,16 @@ impl WlrootsInjector {
.clone()
.context("compositor advertised no wl_seat")?;
let pointer = pointer_mgr.create_virtual_pointer_with_output(
Some(&seat),
globals.output.as_ref(),
&qh,
(),
);
// A second roundtrip: the first only said WHICH globals exist. The `wl_output.name` events
// that identify each head are emitted on the objects we bound *during* that roundtrip, so
// they only land now — and the pointer's output has to be resolved before we create it.
queue
.roundtrip(&mut globals)
.context("Wayland output-name roundtrip")?;
let (target, bound_output) = resolve_target(&globals);
let pointer =
pointer_mgr.create_virtual_pointer_with_output(Some(&seat), target.as_ref(), &qh, ());
let keyboard = keyboard_mgr.create_virtual_keyboard(&seat, &qh, ());
// The keymap the compositor resolves our raw evdev keycodes with. Empty names defer to
@@ -174,7 +301,9 @@ impl WlrootsInjector {
conn.flush().ok();
tracing::info!(
output = globals.output.is_some(),
outputs = globals.outputs.len(),
want = ?crate::stream_output(),
bound = ?bound_output,
"wlroots virtual input ready (pointer + keyboard)"
);
Ok(Self {
@@ -182,6 +311,8 @@ impl WlrootsInjector {
queue,
globals,
pointer,
bound_output,
pressed: Vec::new(),
keyboard,
xkb_state,
_keymap_file: file,
@@ -190,6 +321,90 @@ impl WlrootsInjector {
})
}
/// Aim the virtual pointer at the output the session is streaming, re-creating it when that
/// changes — the fix for absolute input landing on the operator's screen.
///
/// The wlr protocol maps `motion_absolute` onto the output the pointer was **created with**
/// and offers no way to re-aim one, so a change means destroy + create. Cheap and rare: the
/// host publishes the target once per capture bring-up, so a re-create fires at most a couple
/// of times per session. The no-change path — every other absolute sample — costs one `RwLock`
/// read and a scan of the output list, which has one entry per head.
///
/// Called from the `MouseMoveAbs` arm immediately BEFORE the motion is sent, so a re-created
/// pointer gets its first position in the same batch rather than sitting wherever the
/// compositor puts a brand-new device.
///
/// Resolution is by NAME, never by size: `MouseMoveAbs`'s extent is the client's letterboxed
/// content rect in ITS window, not the streamed mode, so no size ladder could identify the
/// head. Falling back to no output at all (whole-layout mapping) when the target is unknown is
/// deliberate — see [`crate::stream_output`]'s module doc.
fn retarget(&mut self) {
let (target, want) = resolve_target(&self.globals);
if want == self.bound_output {
return;
}
let (Some(mgr), Some(seat)) = (self.globals.pointer_mgr.clone(), self.globals.seat.clone())
else {
return; // cannot re-create without the manager/seat; keep the pointer we have
};
// Never destroy a device with a button held: nothing else will release it.
if !self.pressed.is_empty() {
let t = self.now_ms();
for btn in std::mem::take(&mut self.pressed) {
self.pointer
.button(t, btn, wl_pointer::ButtonState::Released);
}
self.pointer.frame();
}
self.pointer.destroy();
self.pointer = mgr.create_virtual_pointer_with_output(
Some(&seat),
target.as_ref(),
&self.queue.handle(),
(),
);
tracing::info!(
from = ?self.bound_output,
to = ?want,
"wlroots virtual pointer re-aimed (absolute input now maps into this output)"
);
self.bound_output = want;
}
/// Drain the compositor's half of the connection, then push our batch to it — run after every
/// injected event.
///
/// The **read** is the load-bearing half, and it used to be missing: `dispatch_pending`'s own
/// documentation says it "will not perform reads on the Wayland socket", so the queue only
/// ever held what [`Self::open`]'s roundtrips put there. Two consequences, both real. The
/// injector could never learn about a `wl_output` created AFTER it opened — which is exactly
/// the ordering the field report was captured in, and would have left [`Self::retarget`] with
/// nothing to resolve. And everything the compositor sent us piled up unread in the socket
/// buffer for the host's lifetime, including the protocol errors the code here claimed to be
/// surfacing but structurally could not.
///
/// Non-blocking by construction: `read()` is documented to answer `WouldBlock` when the socket
/// has nothing for us, which is the common case at input rates and is not an error.
fn pump(&mut self) -> Result<()> {
// `prepare_read` will not hand out a guard while events are still queued, so dispatch first.
self.queue
.dispatch_pending(&mut self.globals)
.context("wayland dispatch")?;
if let Some(guard) = self.conn.prepare_read() {
match guard.read() {
Ok(_) => {
self.queue
.dispatch_pending(&mut self.globals)
.context("wayland dispatch (post-read)")?;
}
Err(WaylandError::Io(e)) if e.kind() == std::io::ErrorKind::WouldBlock => {}
Err(e) => return Err(e).context("wayland read"),
}
}
self.conn.flush().context("wayland flush")?;
Ok(())
}
fn now_ms(&self) -> u32 {
self.start.elapsed().as_millis() as u32
}
@@ -271,6 +486,12 @@ impl InputInjector for WlrootsInjector {
let w = (event.flags >> 16) & 0xffff;
let h = event.flags & 0xffff;
if w > 0 && h > 0 {
// The compositor maps these onto the pointer's bound output, so make sure that
// is the head this session streams before sending any. Checked here rather
// than per inject: only absolute motion depends on the binding, and a pointer
// swapped mid-drag is the one thing `retarget` has to work to be safe about.
self.retarget();
let t = self.now_ms(); // `retarget` may have consumed time releasing buttons
let x = event.x.clamp(0, w as i32) as u32;
let y = event.y.clamp(0, h as i32) as u32;
self.pointer.motion_absolute(t, x, y, w, h);
@@ -280,8 +501,12 @@ impl InputInjector for WlrootsInjector {
InputKind::MouseButtonDown | InputKind::MouseButtonUp => {
if let Some(btn) = gs_button_to_evdev(event.code) {
let st = if event.kind == InputKind::MouseButtonDown {
if !self.pressed.contains(&btn) {
self.pressed.push(btn);
}
wl_pointer::ButtonState::Pressed
} else {
self.pressed.retain(|&b| b != btn);
wl_pointer::ButtonState::Released
};
self.pointer.button(t, btn, st);
@@ -328,12 +553,7 @@ impl InputInjector for WlrootsInjector {
// wlroots has no virtual-touch protocol wired here; touch is the libei path only.
InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp => {}
}
// Surface protocol errors / disconnects, then push the batch to the compositor.
self.queue
.dispatch_pending(&mut self.globals)
.context("wayland dispatch")?;
self.conn.flush().context("wayland flush")?;
Ok(())
self.pump()
}
}
@@ -383,3 +603,40 @@ fn memfd_with(s: &str) -> Result<std::fs::File> {
f.write_all(&[0]).context("write keymap NUL")?;
Ok(f)
}
#[cfg(test)]
mod tests {
use super::*;
/// The live-box layout the field report came from: the operator's `HDMI-A-1` is advertised
/// FIRST (it exists from compositor start), and the session's headless head is added later —
/// so "first advertised" is always the wrong answer, whichever order the injector and the
/// display happen to come up in.
const HYPRLAND_BOX: [Option<&str>; 2] = [Some("HDMI-A-1"), Some("PF-87756-3")];
#[test]
fn binds_the_streamed_head_not_the_first_advertised_one() {
assert_eq!(index_named(HYPRLAND_BOX, Some("PF-87756-3")), Some(1));
assert_eq!(index_named(HYPRLAND_BOX, Some("HDMI-A-1")), Some(0));
// sway's own naming, and a mirrored physical head, resolve the same way.
let sway = [Some("HEADLESS-1"), Some("DP-2"), Some("HEADLESS-2")];
assert_eq!(index_named(sway, Some("HEADLESS-2")), Some(2));
assert_eq!(index_named(sway, Some("DP-2")), Some(1));
}
/// Every "we don't know" must land on NO output (whole-layout mapping), never on a guess —
/// the regression this whole change exists to prevent.
#[test]
fn an_unknown_target_binds_nothing_rather_than_falling_back() {
// Published but not advertised (yet, or any more — the injector opens on the first input
// event, which can beat the display, and the head goes away at session end).
assert_eq!(index_named(HYPRLAND_BOX, Some("PF-87756-9")), None);
// Nothing published at all — before the first capture bring-up.
assert_eq!(index_named(HYPRLAND_BOX, None), None);
// A compositor older than wl_output v4 emits no `name` event, so nothing is matchable.
assert_eq!(index_named([None, None], Some("PF-87756-3")), None);
// …and a compositor advertising no outputs at all cannot resolve anything either.
let headless: [Option<&str>; 0] = [];
assert_eq!(index_named(headless, Some("PF-87756-3")), None);
}
}
+17
View File
@@ -149,6 +149,15 @@ static ABSOLUTE_ANCHOR: std::sync::RwLock<Option<AbsoluteAnchor>> = std::sync::R
/// record in `design/per-monitor-portal-capture.md` §5.3) and wrong for anything per-client. A
/// per-session anchor needs the injector to become session-aware first; don't call this from a
/// session path until it is.
///
/// The wlroots backend does **not** consult this — it aims at a named output via
/// `stream_output::set_stream_output` (Linux), which the host DOES publish per session and which
/// therefore takes exactly the last-bring-up-wins trade this warning describes: on purpose, and
/// stated in the open in that module's doc, matching the Windows `stream_target` slot that already
/// made the same call. The two are separate slots because they answer different questions and are
/// written by different owners: this anchor is the operator's host-wide capture pin, recomputed
/// from policy whenever the console writes it — which would wipe a per-session value written here —
/// while the stream output is whatever head the session's capture actually attached to.
pub fn set_absolute_anchor(anchor: Option<AbsoluteAnchor>) {
let anchor = anchor.filter(|a| !a.is_empty());
tracing::debug!(?anchor, "input: absolute-coordinate anchor set");
@@ -529,6 +538,14 @@ pub mod pen;
pub mod stream_target;
#[cfg(target_os = "windows")]
pub use stream_target::set_stream_target;
/// Linux: the streamed compositor output (by name) that absolute coordinates map into — the
/// counterpart of the Windows `stream_target` module, published by the host at capture bring-up and
/// consumed by the wlroots virtual-pointer backend, which binds its pointer to that `wl_output`.
#[cfg(target_os = "linux")]
#[path = "inject/linux/stream_output.rs"]
pub mod stream_output;
#[cfg(target_os = "linux")]
pub use stream_output::{set_stream_output, stream_output};
/// Stub — pen injection needs the Linux uinput tablet or Windows synthetic pointers;
/// `pen_supported()` is false here, so no host advertises the cap and no batches arrive.
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
+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";
+19
View File
@@ -79,6 +79,10 @@ pub(crate) fn emit_display_event(ev: DisplayEvent) {
#[path = "vdisplay/backend.rs"]
pub(crate) mod backend;
pub use backend::{DisplayOwnership, VirtualDisplay, VirtualOutput};
/// The NEGOTIATED ScreenCast cursor mode of a portal-backed output, reported per session by
/// [`VirtualDisplay::last_portal_cursor_mode`]. (The module itself stays private — the ladder that
/// picks the mode is this crate's business; the verdict is the caller's.)
pub use portal_cursor::Mode as PortalCursorMode;
/// Time-bounded child-process helpers — every compositor query shells out, and an unbounded one
/// can wedge the calling (session) thread forever.
@@ -833,6 +837,21 @@ mod portal_config;
#[path = "vdisplay/linux/portal_cursor.rs"]
mod portal_cursor;
/// The line fed to xdph's custom picker to select an output headlessly.
///
/// Declared unconditionally for the same reason again: it is a wire format with no schema and no
/// error report, so the transcribed-parser tests are the only place a malformed line is visible
/// without a compositor. That is not hypothetical — a missing separator shipped, and the one
/// assertion that existed for it passed throughout.
#[path = "vdisplay/linux/portal_picker.rs"]
mod portal_picker;
/// The single, never-dropped tokio runtime the portal handshakes run on. Linux-only: it exists to
/// outlive ashpd's process-global cached D-Bus connection, and only the Linux backends speak to it.
#[cfg(target_os = "linux")]
#[path = "vdisplay/linux/portal_rt.rs"]
mod portal_rt;
#[cfg(target_os = "linux")]
#[path = "vdisplay/linux/hyprland.rs"]
mod hyprland;
@@ -76,6 +76,20 @@ pub struct VirtualOutput {
/// capturer must hold frames until that renegotiation lands. Linux-only.
#[cfg(target_os = "linux")]
pub expect_exact_dims: bool,
/// The compositor's own name for this output (Hyprland's `PF-<pid>-<n>`, sway's `HEADLESS-N`,
/// a mirrored head's connector) — the Linux answer to what `win_capture` carries on Windows:
/// the identity the host needs to aim **absolute input** at the head it is streaming
/// (`pf_inject::set_stream_output`, called from `capture::capture_virtual_output`).
///
/// It is the `wl_output.name` of that head, which the protocol guarantees is the same string
/// for every client — so the injector can match it on its own Wayland connection. `None` on
/// the backends whose absolute mapping does not need it (KWin/Mutter inject through libei,
/// which selects by region; gamescope owns its whole seat).
///
/// This crate must not depend on pf-inject (see the crate doc), so the name is only CARRIED
/// here — the host publishes it.
#[cfg(target_os = "linux")]
pub output_name: Option<String>,
}
impl VirtualOutput {
@@ -101,6 +115,8 @@ impl VirtualOutput {
pool_gen: None,
#[cfg(target_os = "linux")]
expect_exact_dims: false,
#[cfg(target_os = "linux")]
output_name: None,
}
}
}
@@ -184,6 +200,33 @@ pub trait VirtualDisplay: Send {
fn hw_cursor(&self) -> bool {
false
}
/// The ScreenCast cursor mode the backend's portal actually NEGOTIATED for the most recent
/// [`create`](Self::create) — the answer to [`set_hw_cursor`](Self::set_hw_cursor), which is
/// only ever a *request*.
///
/// This is the difference between the two that matters downstream: on the whole wlr family
/// (xdph, xdpw) `AvailableCursorModes` is `Hidden|Embedded`, so a session that asked for
/// metadata is served **`Embedded`** — the compositor paints the pointer into the frames and
/// sends no `SPA_META_Cursor`, ever, wherever the pointer is. A consumer that reads "no cursor
/// overlay" as a symptom (the host's park schedule reads it as "the seat pointer has not
/// reached the streamed output" — true on Mutter, which suppresses metadata while the pointer
/// is off the recorded view) is then acting on noise; see
/// [`PortalCursorMode::delivers_metadata`](crate::PortalCursorMode::delivers_metadata).
///
/// `None` — the default, and what every non-portal backend reports — means "nothing was
/// negotiated through the xdg ScreenCast portal here, so this says nothing at all": KWin
/// (`zkde_screencast` `pointer` mode), Mutter (`RecordVirtual` `cursor-mode`), gamescope (no
/// pointer either way) and Windows (IddCx) all get exactly what they ask for through their own
/// protocols, and their consumers must keep behaving as they always did. It is also `None`
/// before the first `create`.
///
/// Reported by the wlr-family backends (`hyprland`, `wlroots`) and by the monitor
/// [`mirror`](crate::open_mirror) when it delegates to one. Those outputs are never registry-
/// pooled (`remote_fd.is_some()` — the portal fd cannot be re-opened per attach), so a reused
/// kept display can never hand back a *stale* answer here.
fn last_portal_cursor_mode(&self) -> Option<crate::PortalCursorMode> {
None
}
/// The stable identity slot the backend resolved for the most recent [`create`](Self::create) —
/// the per-client id the identity policy assigned (`Some`), or `None` for shared/anonymous. The
/// registry reads it right after `create` to key the display's group **arrangement** (manual
@@ -525,6 +525,9 @@ impl VirtualDisplay for GamescopeDisplay {
reused_gen: None,
pool_gen: None,
expect_exact_dims: false,
// gamescope owns its own seat and injects through its EIS socket, not the wlr
// virtual pointer (`point_injector_at_eis`) — nothing here to aim by name.
output_name: None,
});
}
check_gamescope_version(); // diagnostic only — warns on known-deadlock-prone versions
@@ -718,6 +721,9 @@ fn create_managed_session(client: &str, mode: Mode, hdr: bool) -> Result<Virtual
reused_gen: None,
pool_gen: None,
expect_exact_dims: false,
// gamescope owns its own seat and injects through its EIS socket, not the wlr
// virtual pointer (`point_injector_at_eis`) — nothing here to aim by name.
output_name: None,
});
}
// B1b: a desktop-session Steam (outside any gamescope unit) also holds the single instance and
@@ -834,6 +840,9 @@ fn managed_output(node_id: u32, mode: Mode) -> VirtualOutput {
reused_gen: None,
pool_gen: None,
expect_exact_dims: false,
// gamescope owns its own seat and injects through its EIS socket, not the wlr
// virtual pointer (`point_injector_at_eis`) — nothing here to aim by name.
output_name: None,
}
}
@@ -3595,6 +3604,9 @@ pub(crate) fn stream_existing_output(
Ok(crate::mirror::MirrorStream {
node_id,
remote_fd: None,
// No xdg portal in this path at all (gamescope publishes the node itself), and no pointer
// in the node either way — nothing to report.
cursor_mode: None,
keepalive: Box::new(()),
})
}
+257 -79
View File
@@ -16,10 +16,12 @@
//! 3. The xdg ScreenCast portal (served by **xdph**) yields the output's PipeWire node. There is
//! no GUI to pick an output headlessly, so xdph is steered through its **custom picker**: a
//! managed config (`~/.config/hypr/xdph.conf`) points `screencopy:custom_picker_binary` at a tiny
//! installed shim that cats a per-session selection file we write (`[SELECTION]screen:<NAME>`)
//! right before the handshake — byte-for-byte the xdpw pattern, xdph's picker wire format.
//! 4. Teardown is RAII: drop stops the portal thread (its zbus connection ends the cast) and runs
//! `hyprctl output remove NAME`.
//! installed shim that cats a per-session selection file we write right before the handshake —
//! `[SELECTION]/screen:<NAME>`, whose leading `/` is xdph's mandatory empty-flags separator (see
//! [`crate::portal_picker`], which owns the format and its tests).
//! 4. Teardown is RAII **and ordered**: drop closes the ScreenCast session and WAITS for the portal
//! to confirm it, and only then runs `hyprctl output remove NAME`. Removing the output first is
//! what made every stream after the first one fail on Hyprland — see [`StopGuard`].
//!
//! Requirements: the host runs inside (or can reach) the Hyprland session — either
//! `HYPRLAND_INSTANCE_SIGNATURE` is inherited, or [`is_available`] discovers it from
@@ -27,7 +29,8 @@
//! the ScreenCast interface routed to xdph (`scripts/headless/portals.conf`).
//!
//! Contracts verified on **Hyprland 0.55.4 + xdph 1.3.x** (`design/hyprland-support.md` Phase 0):
//! `hyprctl` subcommands / JSON shapes, the `[SELECTION]screen:<name>` picker format, the
//! `hyprctl` subcommands / JSON shapes, the `[SELECTION]/screen:<name>` picker format (re-derived
//! from xdph 1.3.12's own parser on 2026-08-14, which is when the missing `/` turned up), the
//! `~/.config/hypr/xdph.conf` path + `screencopy:custom_picker_binary` key, and that `eval` needs
//! the Lua config manager. Not yet exercised end-to-end on real DRM hardware: a headless output's
//! GBM/dmabuf allocation (fails on a nested/NVIDIA test box — Sunshine#4197); `set_monitor_rule`
@@ -44,7 +47,7 @@ use std::thread;
use std::time::{Duration, Instant};
/// Per-session file the xdph custom picker reads the selected output from. We write
/// `screen:<NAME>\n` here right before the portal handshake selects sources. Lives under
/// [`picker_selection_line`] here right before the portal handshake selects sources. Lives under
/// `$XDG_RUNTIME_DIR` (per-user, 0700) — NOT a world-writable /tmp path another local user could
/// pre-create or rewrite between our write and xdph's read (steer capture elsewhere). Mirrors the
/// wlroots chooser file.
@@ -61,13 +64,11 @@ fn picker_shim_path() -> String {
format!("{dir}/punktfunk-xdph-picker.sh")
}
/// The picker line for output `name`. Verified against xdph 1.3.x / hyprland-share-picker on
/// Hyprland 0.55.4: xdph reads the custom picker's stdout and requires the `[SELECTION]` marker
/// followed by `screen:<name>` (or `window:<addr>` / `region:…`); anything else is rejected as
/// "strange output" and falls back to the interactive picker. So a monitor selection is
/// `[SELECTION]screen:<name>`.
/// The picker line for output `name` — `[SELECTION]/screen:<name>`, whose every byte is load-bearing.
/// Lives in [`crate::portal_picker`] with a transcription of xdph's parser, because it is a wire
/// format with no error report and this file only compiles on Linux.
fn picker_selection_line(name: &str) -> String {
format!("[SELECTION]screen:{name}\n")
crate::portal_picker::selection_line(name)
}
/// Monotonic per-process counter for headless output names (`PF-<pid>-1`, `PF-<pid>-2`, …). Named
@@ -131,11 +132,18 @@ pub struct HyprlandDisplay {
/// only. Every session on this backend therefore resolves to `Embedded` today; KWin/Mutter
/// remain the legs where the metadata channel is actually exercised.
hw_cursor: bool,
/// What the portal actually gave us on the most recent [`create`](VirtualDisplay::create) — see
/// [`VirtualDisplay::last_portal_cursor_mode`], which is how the host learns that a cursor
/// overlay is never coming instead of inferring it from an absence.
last_cursor_mode: Option<crate::portal_cursor::Mode>,
}
impl HyprlandDisplay {
pub fn new() -> Result<Self> {
Ok(HyprlandDisplay { hw_cursor: false })
Ok(HyprlandDisplay {
hw_cursor: false,
last_cursor_mode: None,
})
}
}
@@ -201,6 +209,10 @@ impl VirtualDisplay for HyprlandDisplay {
self.hw_cursor
}
fn last_portal_cursor_mode(&self) -> Option<crate::PortalCursorMode> {
self.last_cursor_mode
}
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
// Log the permission-system caveat once per process (silent black frames otherwise).
preflight_once();
@@ -224,16 +236,21 @@ impl VirtualDisplay for HyprlandDisplay {
// thread (it parks to keep the cast alive, like the other backends). Serialized: the
// selection is one per-user file, so a concurrent session's write between ours and xdph's
// read would silently capture the wrong output (see `SELECTION_LOCK`).
let (fd, node_id, stop) = {
let (fd, node_id, cursor_mode, stop) = {
let _sel = SELECTION_LOCK.lock().unwrap_or_else(|e| e.into_inner());
select_and_cast(&name, self.hw_cursor)?
};
// Latched for `last_portal_cursor_mode`: on today's xdph this is `embedded` whatever we
// asked for, and the session's whole cursor behaviour follows from that fact rather than
// from `hw_cursor`.
self.last_cursor_mode = Some(cursor_mode);
tracing::info!(
node_id,
output = %name,
w = mode.width,
h = mode.height,
hz = mode.refresh_hz,
cursor = cursor_mode.name(),
"hyprland headless output ready"
);
Ok(VirtualOutput {
@@ -251,24 +268,100 @@ impl VirtualDisplay for HyprlandDisplay {
reused_gen: None,
pool_gen: None,
expect_exact_dims: false,
// Hyprland is an EXTEND topology: this head sits BESIDE the operator's, so absolute
// input has to be aimed at it by name or it lands on their screen. `hyprctl`'s monitor
// name is the head's `wl_output.name`, which is what the injector matches.
output_name: Some(name),
})
}
}
/// Drop order matters: stop the portal thread first (zbus connection drop ends the cast), then
/// remove the output (fields drop in declaration order).
/// Drop order matters, and it is the whole fix: [`StopGuard`] **blocks until the ScreenCast session
/// is actually closed**, and only then does [`OutputGuard`] remove the compositor output (fields drop
/// in declaration order).
///
/// 🛑 THIS ORDERING USED TO BE A LIE. `StopGuard::drop` only set an atomic and returned, while the
/// portal thread noticed it 200 ms later — so `OutputGuard::drop` ran `hyprctl output remove` on an
/// output xdph was still actively capturing, every single teardown. See [`StopGuard`] for what that
/// did to xdph.
struct Keepalive {
_stop: StopGuard,
_output: OutputGuard,
}
/// Dropping this ends the portal keepalive thread, closing its zbus connection — the portal then
/// tears the screencast session down.
struct StopGuard(Arc<AtomicBool>);
/// How long teardown waits for the portal to confirm the ScreenCast session is closed before giving
/// up and removing the output anyway. One D-Bus round trip through xdg-desktop-portal to xdph; three
/// seconds is generous. Bounded on purpose: a portal that has already wedged must not be able to
/// wedge the host's teardown with it — every other blocking helper on this path is bounded the same
/// way (see [`HYPRCTL_BUDGET`]).
const CAST_CLOSE_BUDGET: Duration = Duration::from_secs(3);
/// Ends the cast: signals the portal thread, then **waits for it to have closed the ScreenCast
/// session**, so the caller may safely remove the output afterwards.
///
/// 🛑 THE WAIT IS THE POINT — "only the first stream after a portal start works" on Hyprland was
/// this, root-caused 2026-08-14 against Hyprland 0.55.4 + xdph 1.3.12 + xdg-desktop-portal 1.20.4.
///
/// This used to be a bare `AtomicBool` that `drop` merely SET. The portal thread polled it every
/// 200 ms and then just dropped its zbus connection, and xdph destroys a session on exactly one
/// event — an explicit `org.freedesktop.impl.portal.Session.Close` (`Session.cpp:37`,
/// `onCloseSession`); it has no peer-vanished watcher of its own. The frontend does have one
/// (`xdg-desktop-portal.c:230` `peer_died_cb` → `close_sessions_for_sender`), but it only fires once
/// our unique bus name goes away, which is *after* the 200 ms poll, and it runs asynchronously on a
/// GTask thread. Meanwhile `OutputGuard::drop` had already removed the output — synchronously,
/// microseconds after the flag was set.
///
/// So every teardown destroyed the `wl_output` out from under a live screencopy session. xdph's next
/// `Start` then built a PipeWire stream against that wreckage and fell into
///
/// ```text
/// while (pSession->sharingData.nodeID == SPA_ID_INVALID) { // Screencopy.cpp:307-313
/// int ret = pw_loop_iterate(g_pPortalManager->m_sPipewire.loop, 0); // timeout 0 = NON-blocking
/// ```
///
/// — an unbounded hot spin on xdph's ONLY event-loop thread, inside the `Start` handler, holding its
/// `m_mEventLock`. From that moment xdph answers no D-Bus, no Wayland and no PipeWire, ever again, and
/// every later `select_and_cast` dies on our 20 s timeout. MEASURED on the box: the wedged instance's
/// unit reported `Consumed 3min 51.971s CPU time over 23min 41.092s wall clock`, and there were
/// exactly 232.7 s of wall clock between its last log flush and its restart — 231.971 s of CPU
/// against 232.7 s of wall, i.e. one core pinned solid for precisely the wedged interval.
///
/// Waiting here closes that window: `Session.Close` is answered synchronously by the frontend
/// (`xdp-session.c:217` `handle_close` → `xdp_session_close` →
/// `xdp_dbus_impl_session_call_close_sync`), so by the time `close()` returns, xdph has already run
/// `destroyStream` and logged `Session destroyed`. The output we remove next is one nobody is
/// capturing.
struct StopGuard {
stop: Arc<AtomicBool>,
/// Signalled by the portal thread once it has closed the ScreenCast session.
///
/// `None` on every path where no cast was ever established (a rejected or timed-out handshake):
/// there is nothing to close, and a portal that just failed to answer for 20 s is precisely the
/// one that would burn the whole budget here for nothing.
closed: Option<std::sync::mpsc::Receiver<()>>,
}
impl Drop for StopGuard {
fn drop(&mut self) {
self.0.store(true, Ordering::Relaxed);
self.stop.store(true, Ordering::Relaxed);
let Some(closed) = self.closed.take() else {
return;
};
match closed.recv_timeout(CAST_CLOSE_BUDGET) {
// Closed — xdph has torn the capture down, the output is safe to remove.
Ok(()) => {}
// The thread is gone without confirming (it panicked, or the runtime died). Nothing is
// holding the cast either way, so there is nothing left to wait for.
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {}
// Still going after the budget. Fall through and remove the output anyway — a leaked
// output is worse than a racy one — but say so, because this is the state that wedges
// xdph and the next session will be the one that pays for it.
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => tracing::warn!(
budget_s = CAST_CLOSE_BUDGET.as_secs(),
"the ScreenCast session did not close in time — removing the output underneath it, \
which is what wedges xdph's frame loop; the next cast may find the portal busy"
),
}
}
}
@@ -349,6 +442,12 @@ impl Drop for OutputGuard {
/// stream thread, whose only way to end a session is to return, so one hung query used to wedge the
/// session for good. Generous next to a healthy call (single-digit milliseconds), and every call
/// site already has a failed-query path.
/// Ceiling on the whole ScreenCast handshake (`create_session` → `select_sources` → `start` →
/// `open_pipe_wire_remote`). Deliberately under [`select_and_cast`]'s 20 s wait so a stuck portal is
/// reported by the thread that owns it, with a reason, instead of the caller timing out on it — and,
/// far more importantly, so that thread EXITS. See the note at the handshake itself.
const HANDSHAKE_BUDGET: Duration = Duration::from_secs(15);
const HYPRCTL_BUDGET: Duration = Duration::from_secs(5);
/// Budget for the one-shot xdph restart. `systemctl --user try-restart` waits for the user manager's
@@ -401,19 +500,31 @@ impl Drop for SelectionFile {
/// Point xdph's custom picker at `output` and run the ScreenCast handshake, returning the portal fd
/// + node id and the guard that stops the cast. The caller must hold [`SELECTION_LOCK`].
fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, StopGuard)> {
fn select_and_cast(
output: &str,
hw_cursor: bool,
) -> Result<(OwnedFd, u32, crate::portal_cursor::Mode, StopGuard)> {
ensure_xdph_config()?;
let sel = selection_file();
std::fs::write(&sel, picker_selection_line(output)).with_context(|| format!("write {sel}"))?;
// Owned from the write on: every arm below (and every `?`) leaves the handshake, which is the
// only thing that reads it.
let _sel_file = SelectionFile(sel);
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
// The NEGOTIATED cursor mode rides back with the fd and node id: it is decided inside the
// portal thread (only there is the proxy to ask), and nothing downstream can re-derive it —
// `hw_cursor` is the request, not the answer.
let (setup_tx, setup_rx) =
std::sync::mpsc::channel::<Result<(OwnedFd, u32, crate::portal_cursor::Mode), String>>();
// The teardown handshake: the thread signals this once it has closed the ScreenCast session, and
// `StopGuard::drop` waits on it before the output is removed (see `StopGuard`). Kept a SEPARATE
// channel from the setup one above — it fires at the other end of the cast's life, long after
// `setup_rx` has been consumed.
let (closed_tx, closed_rx) = std::sync::mpsc::channel::<()>();
let stop = Arc::new(AtomicBool::new(false));
let stop_thread = stop.clone();
thread::Builder::new()
.name("punktfunk-hypr-cast".into())
.spawn(move || portal_thread(setup_tx, stop_thread, hw_cursor))
.spawn(move || portal_thread(setup_tx, closed_tx, stop_thread, hw_cursor))
.context("spawn hyprland portal thread")?;
// Built BEFORE the wait so EVERY error arm below sets the flag on its way out — as Mutter's
// `create` does. Returning the bare `Arc` and letting the CALLER wrap it left the two failure
@@ -422,9 +533,14 @@ fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, StopG
// parks forever on `while !stop`, holding a live ScreenCast session, its zbus connection, an
// `OwnedFd` and a 2-worker tokio runtime — one more set per slow-portal connect, for the host's
// lifetime, against an output that no longer exists.
let guard = StopGuard(stop);
let mut guard = StopGuard { stop, closed: None };
match setup_rx.recv_timeout(Duration::from_secs(20)) {
Ok(Ok((fd, node_id))) => Ok((fd, node_id, guard)),
Ok(Ok((fd, node_id, cursor_mode))) => {
// A cast exists now, so teardown has something to close and must wait for it. Only this
// arm arms the wait: see the field note on `StopGuard::closed`.
guard.closed = Some(closed_rx);
Ok((fd, node_id, cursor_mode, guard))
}
Ok(Err(e)) => bail!("ScreenCast portal on {output} failed: {e}"),
Err(_) => bail!("timed out waiting for the ScreenCast portal on {output}"),
}
@@ -440,10 +556,11 @@ pub(crate) fn stream_existing_output(
hw_cursor: bool,
) -> Result<crate::mirror::MirrorStream> {
let _sel = SELECTION_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let (fd, node_id, stop) = select_and_cast(connector, hw_cursor)?;
let (fd, node_id, cursor_mode, stop) = select_and_cast(connector, hw_cursor)?;
Ok(crate::mirror::MirrorStream {
node_id,
remote_fd: Some(fd),
cursor_mode: Some(cursor_mode),
keepalive: Box::new(stop),
})
}
@@ -793,7 +910,8 @@ fn ensure_xdph_config() -> Result<()> {
/// custom picker, no dialog. (Kept separate from wlroots' copy so each wlr-family backend stays
/// self-owned per D1; unify if they ever diverge no further.)
fn portal_thread(
setup_tx: Sender<Result<(OwnedFd, u32), String>>,
setup_tx: Sender<Result<(OwnedFd, u32, crate::portal_cursor::Mode), String>>,
closed_tx: Sender<()>,
stop: Arc<AtomicBool>,
hw_cursor: bool,
) {
@@ -801,16 +919,15 @@ fn portal_thread(
use ashpd::desktop::PersistMode;
use ashpd::enumflags2::BitFlags;
// Multi-thread runtime: the zbus background reader must be pumped across the
// create_session → select_sources → start handshake (see capture/linux.rs).
let rt = match tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
{
// 🛑 The SHARED, never-dropped runtime — NOT a per-cast one. ashpd caches its D-Bus connection
// process-globally, and a per-cast runtime takes that connection's background reader down with
// it when the cast ends, leaving every later handshake in this process awaiting a reply nothing
// is alive to read. That is the whole "the first stream works, the rest are black" bug. See
// [`crate::portal_rt`] for the measurement.
let rt = match crate::portal_rt::portal_runtime() {
Ok(rt) => rt,
Err(e) => {
let _ = setup_tx.send(Err(format!("build tokio runtime: {e}")));
let _ = setup_tx.send(Err(e));
return;
}
};
@@ -818,9 +935,21 @@ fn portal_thread(
rt.block_on(async move {
let result: Result<()> = async {
let proxy = Screencast::new().await.context(
"connect ScreenCast portal (is xdg-desktop-portal running with the hyprland backend/xdph?)",
)?;
// Inside the bound below, deliberately: when the cached connection was orphaned this is
// where the thread hung — `Screencast::new()` itself, before a single handshake call —
// and a bound that started after it reported the caller's generic timeout instead.
let connect = async {
Screencast::new().await.context(
"connect ScreenCast portal (is xdg-desktop-portal running with the hyprland backend/xdph?)",
)
};
let proxy = match tokio::time::timeout(HANDSHAKE_BUDGET, connect).await {
Ok(v) => v?,
Err(_) => bail!(
"connecting to the ScreenCast portal did not return within {}s",
HANDSHAKE_BUDGET.as_secs()
),
};
// NEGOTIATED against what xdph advertises, never asserted from `hw_cursor` alone: a
// cursor mode the backend does not offer does not degrade — xdg-desktop-portal's
// FRONTEND fails the call ("Unavailable cursor mode %x") before xdph sees it.
@@ -829,51 +958,99 @@ fn portal_thread(
// hardcode killed EVERY cursor-forward session here, on today's packages, not just on
// old installs: `unavailable cursor mode 4`, "pipeline build failed", black client.
let cursor_mode = crate::portal_cursor::negotiate(&proxy, hw_cursor, "xdph").await;
let session = proxy
.create_session(Default::default())
.await
.context("create_session")?;
proxy
.select_sources(
&session,
SelectSourcesOptions::default()
.set_cursor_mode(cursor_mode)
// xdph offers MONITOR; the custom picker selects our output.
.set_sources(BitFlags::from_flag(SourceType::Monitor))
.set_multiple(false)
.set_persist_mode(PersistMode::DoNot),
)
.await
.context("select_sources")?
.response()
.context("select_sources rejected")?;
let streams = proxy
.start(&session, None, Default::default())
.await
.context("start cast")?
.response()
.context("start response (custom picker declined? check the xdph config/shim/selection file)")?;
let stream = streams
.streams()
.first()
.context("portal returned no streams")?
.clone();
let node_id = stream.pipe_wire_node_id();
let fd = proxy
.open_pipe_wire_remote(&session, Default::default())
.await
.context("open_pipe_wire_remote")?;
// 🛑 BOUNDED, and that bound is load-bearing. `select_sources`/`start` await a D-Bus
// reply a wedged portal never sends, and an await that never returns CANNOT be cancelled
// by the `stop` flag — the thread never reaches the park loop that reads it. That is how
// one host accumulated NINE live cast threads (28 tokio workers) on 2026-08-14: each
// timed-out attempt left one behind holding a half-created portal session on this
// process's shared D-Bus connection, and from the first hang onwards EVERY later request
// from this process hung too — while a freshly-spawned process talking to the very same
// portal completed the identical handshake fine. Shorter than the caller's 20 s wait, so
// the failure is reported HERE with a reason instead of surfacing as a bare timeout.
let handshake = async {
let session = proxy
.create_session(Default::default())
.await
.context("create_session")?;
proxy
.select_sources(
&session,
SelectSourcesOptions::default()
.set_cursor_mode(cursor_mode.to_ashpd())
// xdph offers MONITOR; the custom picker selects our output.
.set_sources(BitFlags::from_flag(SourceType::Monitor))
.set_multiple(false)
.set_persist_mode(PersistMode::DoNot),
)
.await
.context("select_sources")?
.response()
.context("select_sources rejected")?;
let streams = proxy
.start(&session, None, Default::default())
.await
.context("start cast")?
.response()
.context("start response (custom picker declined? check the xdph config/shim/selection file)")?;
let stream = streams
.streams()
.first()
.context("portal returned no streams")?
.clone();
let node_id = stream.pipe_wire_node_id();
let fd = proxy
.open_pipe_wire_remote(&session, Default::default())
.await
.context("open_pipe_wire_remote")?;
Ok::<_, anyhow::Error>((session, fd, node_id))
};
let (session, fd, node_id) =
match tokio::time::timeout(HANDSHAKE_BUDGET, handshake).await {
Ok(v) => v?,
Err(_) => bail!(
"the ScreenCast portal did not complete the handshake within {}s — \
abandoning it instead of parking this thread on it forever (a hung \
request poisons every later one from this process)",
HANDSHAKE_BUDGET.as_secs()
),
};
setup_tx
.send(Ok((fd, node_id)))
.send(Ok((fd, node_id, cursor_mode)))
.map_err(|_| anyhow!("virtual-output opener went away"))?;
// Park, keeping `proxy` + `session` (the zbus connection) alive until stopped — the cast
// is torn down when the connection drops.
// Park, keeping `proxy` + `session` alive until stopped. Polled at 20 ms rather than the
// 200 ms this used to use, because the teardown now WAITS on what follows — every
// millisecond here is a millisecond of stream teardown.
let _keep_alive = (&proxy, &session);
while !stop.load(Ordering::Relaxed) {
tokio::time::sleep(Duration::from_millis(200)).await;
tokio::time::sleep(Duration::from_millis(20)).await;
}
// 🛑 CLOSE THE SESSION, AND CLOSE IT *BEFORE* THE OUTPUT GOES AWAY. Dropping the
// connection and trusting the peer to notice is what this used to do, and it is not the
// contract: xdph destroys a session only on an explicit
// `org.freedesktop.impl.portal.Session.Close` (`Session.cpp:37`). The caller is blocked
// in `StopGuard::drop` waiting for the signal below, and only removes the compositor
// output afterwards — that ordering is the whole fix; see `StopGuard`.
//
// Bounded: `close()` goes through xdg-desktop-portal to xdph, and an already-wedged xdph
// never answers. Timing out here still signals, so teardown pays the budget once and
// moves on rather than hanging on a portal that is already gone.
match tokio::time::timeout(CAST_CLOSE_BUDGET, session.close()).await {
Ok(Ok(())) => {}
Ok(Err(e)) => tracing::warn!(
error = %e,
"closing the ScreenCast session failed — the next cast may find xdph busy"
),
Err(_) => tracing::warn!(
budget_s = CAST_CLOSE_BUDGET.as_secs(),
"the ScreenCast portal did not answer Session.Close in time — it is probably \
already wedged"
),
}
// Release the teardown. Best-effort: the receiver is gone if the caller already gave up.
let _ = closed_tx.send(());
Ok(())
}
.await;
@@ -929,9 +1106,10 @@ mod tests {
}
}
/// The backend hands the picker exactly what [`crate::portal_picker`] says — that module owns the
/// format and its xdph-parser tests, which run on every platform rather than only this leg.
#[test]
fn picker_line_carries_the_selection_marker() {
// xdph requires the `[SELECTION]` prefix; a bare `screen:NAME` is rejected as strange output.
assert_eq!(picker_selection_line("PF-1"), "[SELECTION]screen:PF-1\n");
fn picker_line_is_the_shared_selection_format() {
assert_eq!(picker_selection_line("PF-1"), "[SELECTION]/screen:PF-1\n");
}
}
@@ -1467,6 +1467,9 @@ pub(crate) fn stream_existing_output(
node_id,
// KWin publishes on the user's own PipeWire daemon — no portal remote to carry.
remote_fd: None,
// Not an xdg-portal session either: the `zkde_screencast` pointer mode was asked of KWin
// directly and KWin honours it, so the request IS the answer.
cursor_mode: None,
keepalive: Box::new(StopOnDrop(stop)),
})
}
@@ -602,6 +602,9 @@ pub(crate) fn stream_existing_output(
node_id,
// Mutter's RecordMonitor node lives on the user's PipeWire daemon (like RecordVirtual).
remote_fd: None,
// Not an xdg-portal session: `cursor-mode` was set directly on `RecordMonitor` and Mutter
// honours it, so the request IS the answer and there is nothing to report back.
cursor_mode: None,
keepalive: Box::new(guard),
})
}
@@ -33,8 +33,15 @@
/// A ScreenCast cursor mode, valued as the portal's own wire bits — which is what a backend prints
/// when it rejects one, so `Metadata`'s `4` is literally the number in the field report.
///
/// Public because the NEGOTIATED mode is a per-session fact the consumer needs: the host's stream
/// loop reads it back off the backend ([`VirtualDisplay::last_portal_cursor_mode`]) to know whether
/// `SPA_META_Cursor` can ever arrive on this output. Re-exported as
/// [`crate::PortalCursorMode`](crate::PortalCursorMode).
///
/// [`VirtualDisplay::last_portal_cursor_mode`]: crate::VirtualDisplay::last_portal_cursor_mode
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Mode {
pub enum Mode {
/// No pointer in the cast at all.
Hidden = 1,
/// The compositor paints the pointer into the frames it hands us.
@@ -52,7 +59,7 @@ impl Mode {
}
/// The spelling used in logs and in `PUNKTFUNK_PORTAL_CURSOR_MODE`.
pub(crate) const fn name(self) -> &'static str {
pub const fn name(self) -> &'static str {
match self {
Mode::Hidden => "hidden",
Mode::Embedded => "embedded",
@@ -60,6 +67,20 @@ impl Mode {
}
}
/// Can `SPA_META_Cursor` EVER arrive under this mode? Only under [`Metadata`](Mode::Metadata) —
/// and this is the whole point of surfacing the negotiated mode.
///
/// Under `Embedded` the compositor paints the pointer into the frames and sends no cursor
/// metadata **regardless of where the pointer is**, so on such a session the absence of a cursor
/// overlay carries NO information: not about the pointer's position, not about whether the
/// capture is healthy. Consumers that treat "no overlay" as a symptom (the host's seat-pointer
/// park schedule, which reads it as "the pointer has not reached the streamed output" — true on
/// Mutter, which suppresses metadata while the pointer is off the recorded view) must ask this
/// first. Under `Hidden` there is no pointer at all, so the same holds.
pub const fn delivers_metadata(self) -> bool {
matches!(self, Mode::Metadata)
}
/// What to ask for instead, best first, when this mode is not advertised.
const fn fallbacks(self) -> [Mode; 2] {
match self {
@@ -185,7 +206,7 @@ pub(crate) fn want(hw_cursor: bool, backend: &str) -> Mode {
#[cfg(target_os = "linux")]
impl Mode {
fn to_ashpd(self) -> ashpd::desktop::screencast::CursorMode {
pub(crate) fn to_ashpd(self) -> ashpd::desktop::screencast::CursorMode {
use ashpd::desktop::screencast::CursorMode;
match self {
Mode::Hidden => CursorMode::Hidden,
@@ -198,12 +219,17 @@ impl Mode {
/// Ask the portal what it supports, run the ladder, and hand back the mode to put in
/// `SelectSources`. Infallible by construction: a backend we cannot interrogate gets `Embedded`,
/// the mode that predates the property and that every implementation has always had.
///
/// Returns OUR [`Mode`], not ashpd's — the caller converts with [`Mode::to_ashpd`] for the request
/// and carries the value out of the portal thread, because what was negotiated (as opposed to
/// asked for) governs how the session's cursor behaves for its whole life. See
/// [`Mode::delivers_metadata`].
#[cfg(target_os = "linux")]
pub(crate) async fn negotiate(
proxy: &ashpd::desktop::screencast::Screencast,
hw_cursor: bool,
backend: &str,
) -> ashpd::desktop::screencast::CursorMode {
) -> Mode {
let want = want(hw_cursor, backend);
let advertised = match proxy.available_cursor_modes().await {
Ok(avail) => avail.bits(),
@@ -216,7 +242,7 @@ pub(crate) async fn negotiate(
error = %e,
"ScreenCast: AvailableCursorModes query failed — requesting Embedded cursor"
);
return Mode::Embedded.to_ashpd();
return Mode::Embedded;
}
};
let choice = pick(advertised, want);
@@ -238,7 +264,7 @@ pub(crate) async fn negotiate(
(requesting it anyway would close the session)"
),
}
choice.mode.to_ashpd()
choice.mode
}
#[cfg(test)]
@@ -286,6 +312,21 @@ mod tests {
assert_eq!(c.wanted, Some(Mode::Metadata));
}
/// The consumer-facing half of the same incident: xdph negotiates `3` down to `Embedded`, and
/// under Embedded no `SPA_META_Cursor` ever arrives — so a host that reads "no cursor overlay"
/// as "the pointer has not reached the streamed output" (true on Mutter, which suppresses
/// metadata off-view) re-centres the user's pointer forever. Field report 2026-08-14: the seat
/// pointer warped to centre once a second for the full park cap on a working Hyprland stream.
#[test]
fn only_metadata_can_deliver_a_cursor_overlay() {
assert!(Mode::Metadata.delivers_metadata());
assert!(!Mode::Embedded.delivers_metadata());
assert!(!Mode::Hidden.delivers_metadata());
// The negotiated mode is what governs, not the wanted one: this is the exact ladder result
// on xdph/xdpw, and it says "no overlay is ever coming" even though metadata was requested.
assert!(!pick(3, Mode::Metadata).mode.delivers_metadata());
}
/// The same portal, a session with no cursor channel: already asking for what exists, so the
/// fix must not perturb it.
#[test]
@@ -0,0 +1,153 @@
//! The line we feed xdg-desktop-portal-hyprland's **custom picker** to select an output headlessly.
//!
//! xdph has no headless source-selection API: it runs `screencopy:custom_picker_binary` and parses
//! one line from its stdout. The Hyprland backend points that at a shim which cats a per-session
//! file, and this module is the format of what goes in the file — a wire format with no schema, no
//! validation and no error report, whose only observable failure is a line in the portal's log.
//!
//! Declared unconditionally although only `hyprland.rs` calls it, for the reason `portal_config` and
//! `portal_cursor` give: this is pure string handling whose tests are the only place its behaviour
//! is checkable without a compositor in front of you, so they run on every platform's CI rather than
//! only on the leg that compiles `mod hyprland`. That is not hypothetical here — the bug below
//! shipped, and the one test that existed for it passed the whole time.
/// The picker line selecting monitor `name`: `[SELECTION]<flags>/<selection>`, with **empty flags**.
///
/// 🛑 THE `/` IS MANDATORY AND WE USED TO OMIT IT. xdph splits the line on the FIRST `/` into flags
/// and selection ([xdph 1.3.12] `src/shared/ScreencopyShared.cpp:86-87`):
///
/// ```text
/// const auto FLAGS = SELECTION.substr(0, SELECTION.find_first_of('/'));
/// const auto SEL = SELECTION.substr(SELECTION.find_first_of('/') + 1);
/// ```
///
/// With no `/` anywhere, `find_first_of` returns `npos`, so `FLAGS` becomes the WHOLE payload — and
/// `SEL` becomes the whole payload too, purely because `npos + 1` wraps to `0`. The output name
/// therefore still parsed correctly, which is exactly why this survived: the only thing it broke was
/// the flag loop (`:89-94`), which then walked `screen:<name>` one character at a time —
///
/// ```text
/// [screencopy] unknown flag from share-picker: s
/// [screencopy] unknown flag from share-picker: c
/// [screencopy] unknown flag from share-picker: e … one line per character
/// ```
///
/// — and, because `sc*r*een` contains an `r`, which is xdph's "allow restore token" flag, set
/// `data.allowToken = true`. xdph then answered every `Start` with a `restore_data` +
/// `persist_mode: 2` we never asked for (we request `PersistMode::DoNot`), which is the
/// `[screencopy] Sent restore token to …` on every single session in the field log.
///
/// The reference picker prints the separator unconditionally
/// (`hyprland-share-picker/main.cpp:133-136`):
///
/// ```text
/// std::cout << "[SELECTION]";
/// std::cout << (ALLOWTOKENBUTTON->isChecked() ? "r" : "");
/// std::cout << "/";
/// std::cout << "screen:" << outputName.toStdString() << "\n";
/// ```
///
/// so empty flags are spelled as a bare leading `/`, not as nothing at all.
///
/// ⚠️ This was NOT the cause of the "only the first stream works" stall — see `hyprland.rs`'s
/// `StopGuard` for that. The sessions that streamed fine logged the identical flag spam and the
/// identical restore token, so it never discriminated. It is a real bug on its own terms and nothing
/// more.
///
/// The trailing newline is equally load-bearing: xdph does `data.output.pop_back()` unconditionally
/// after `SEL.substr(7)` (`:96-100`), so without it the last character of the output name is eaten.
///
/// [xdph 1.3.12]: https://github.com/hyprwm/xdg-desktop-portal-hyprland/blob/v1.3.12/src/shared/ScreencopyShared.cpp
pub(crate) fn selection_line(name: &str) -> String {
format!("[SELECTION]/screen:{name}\n")
}
#[cfg(test)]
mod tests {
use super::*;
/// xdph 1.3.12's parser (`ScreencopyShared.cpp:82-100`), transcribed — including the `npos`
/// arithmetic, which is the entire subtlety. Returns `(flags, output)`, or `None` where xdph
/// would fall through to its interactive picker.
///
/// Transcribed rather than asserted on the string, because the bug this catches is invisible in
/// the string: the old line yielded the RIGHT OUTPUT NAME while handing xdph the whole selection
/// as a FLAG STRING. Only running its parser tells the two apart.
fn xdph_parse(picker_stdout: &str) -> Option<(String, String)> {
// `if (!RETVAL.contains("[SELECTION]")) return data;` — a default `SSelectionData`, i.e.
// TYPE_INVALID, which makes `SelectSources` fail.
let marker = picker_stdout.find("[SELECTION]")?;
let selection = &picker_stdout[marker + "[SELECTION]".len()..];
// `substr(0, npos)` is the whole string, and `substr(npos + 1)` is `substr(0)` — also the
// whole string. Unsigned wraparound, not a special case in xdph.
let (flags, sel) = match selection.find('/') {
Some(i) => (&selection[..i], &selection[i + 1..]),
None => (selection, selection),
};
let name = sel.strip_prefix("screen:")?;
// `data.output.pop_back()` — unconditional, hence the mandatory trailing newline.
let mut output = name.to_string();
output.pop();
Some((flags.to_string(), output))
}
/// The three load-bearing parts of the line, pinned as bytes.
#[test]
fn the_line_carries_marker_empty_flags_separator_and_newline() {
assert_eq!(selection_line("PF-1"), "[SELECTION]/screen:PF-1\n");
}
/// What xdph actually makes of our line: the exact output, and NO flags.
#[test]
fn xdph_reads_our_line_as_an_output_with_no_flags() {
for name in ["PF-1", "PF-1620-1", "HDMI-A-1", "DP-2"] {
let (flags, output) = xdph_parse(&selection_line(name)).expect("xdph parses our line");
assert_eq!(output, name, "xdph must recover the exact output name");
assert_eq!(flags, "", "we ask for no flags at all");
assert!(
!flags.contains('r'),
"an `r` in the flags makes xdph hand back restore_data + persist_mode=2 we never \
requested (Screencopy.cpp:261-267)"
);
}
}
/// The regression itself, so it cannot come back by "simplifying" the leading `/` away: the line
/// we used to send parsed the whole selection as flags, `r` included.
#[test]
fn without_the_separator_the_whole_selection_becomes_flags() {
let (flags, output) = xdph_parse("[SELECTION]screen:PF-1620-1\n").expect("still parses");
assert_eq!(
output, "PF-1620-1",
"the output name did survive — which is precisely why this hid for so long"
);
assert_eq!(
flags, "screen:PF-1620-1\n",
"…while the entire selection was handed to the flag loop"
);
assert!(
flags.contains('r'),
"the `r` of `sc*r*een` is xdph's allow-restore-token flag"
);
}
/// Without the trailing newline xdph's unconditional `pop_back()` eats a character of the name —
/// a silently wrong output, not an error.
#[test]
fn the_trailing_newline_is_what_pop_back_consumes() {
assert!(selection_line("PF-1620-1").ends_with('\n'));
let (_, truncated) = xdph_parse("[SELECTION]/screen:PF-1620-1").expect("parses");
assert_eq!(
truncated, "PF-1620-",
"pop_back() takes the last real character"
);
}
/// A line with no marker at all is xdph's documented empty-read fallback: it prompts instead. The
/// shim relies on this when no session has written the selection file.
#[test]
fn an_empty_read_is_not_a_selection() {
assert!(xdph_parse("").is_none());
assert!(xdph_parse("screen:PF-1\n").is_none());
}
}
@@ -0,0 +1,50 @@
//! The ONE tokio runtime every portal handshake runs on, for the life of the process.
//!
//! 🛑🛑🛑 This exists because of a lifetime bug that cost a full day of misdiagnosis, so the reason
//! is written down rather than left to be rediscovered.
//!
//! ashpd caches its D-Bus connection **process-globally** — `static SESSION: OnceLock<Connection>`
//! (ashpd 0.13.13, `src/proxy.rs:27`). The first `Screencast::new()` in the process creates that
//! connection, and zbus spawns the connection's background reader as a task **on whichever tokio
//! runtime happens to be current at that moment**.
//!
//! Each backend used to build its own multi-thread runtime per cast and drop it at teardown. So the
//! FIRST cast of a host process created the cached connection on a runtime that was then destroyed
//! when that cast ended — and the `OnceLock` went on handing the same, now-executor-less connection
//! to every later `Screencast::new()`, which then awaited a reply nothing was left alive to read.
//!
//! MEASURED 2026-08-14 (Hyprland 0.55.4 + xdph 1.3.12): the first cast of a host process streamed;
//! every cast after it hung, in a process whose surviving cast thread sat in `futex_do_wait` inside
//! runtime shutdown. The discriminator that pins it on us rather than on the compositor stack: a
//! freshly spawned process completed the identical handshake against the identical xdph, repeatedly,
//! while the long-lived host could complete none — and xdph itself was idle (28 ms of CPU).
//!
//! ⚠ Therefore: **never build a per-cast runtime, and never drop this one.** A `OnceLock` that is
//! only ever read keeps the connection's reader alive for the process lifetime, which is exactly as
//! long as the cached connection itself lives. `block_on` takes `&self`, so every cast thread can
//! park on this one runtime concurrently.
use std::sync::OnceLock;
use tokio::runtime::Runtime;
/// Build failures are reported to the caller rather than panicking: a host that cannot build a
/// runtime should fail the cast with a reason, not abort the process.
static PORTAL_RT: OnceLock<std::io::Result<Runtime>> = OnceLock::new();
/// The shared portal runtime, or the error from trying to build it.
///
/// Multi-thread with 2 workers: the zbus background reader must be pumped *across* the
/// `create_session` → `select_sources` → `start` handshake while a cast thread blocks on it, which a
/// current-thread runtime cannot do.
pub(crate) fn portal_runtime() -> Result<&'static Runtime, String> {
match PORTAL_RT.get_or_init(|| {
tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.thread_name("punktfunk-portal-rt")
.enable_all()
.build()
}) {
Ok(rt) => Ok(rt),
Err(e) => Err(format!("build the shared portal runtime: {e}")),
}
}
+201 -64
View File
@@ -11,8 +11,10 @@
//! (`~/.config/xdg-desktop-portal-wlr/config`, written once + portal restarted on change)
//! sets `chooser_type=simple` with a `chooser_cmd` that cats the chooser file, which we
//! write per session (`Monitor: <NAME>` — xdpw 0.8 parses that prefix strictly).
//! 4. Teardown is RAII: drop stops the portal thread (its zbus connection ends the cast) and
//! runs `swaymsg output <NAME> unplug` (headless outputs support unplug since sway 1.8).
//! 4. Teardown is RAII **and ordered**: drop closes the ScreenCast session and WAITS for the portal
//! to confirm it, and only then runs `swaymsg output <NAME> unplug` (headless outputs support
//! unplug since sway 1.8). See [`StopGuard`] — and the long root-cause note on `hyprland.rs`'s
//! copy, which is where this was measured.
//!
//! Requirements: the host runs inside the sway session's environment (`SWAYSOCK` for swaymsg,
//! and the portal activation env — `WAYLAND_DISPLAY`/`XDG_CURRENT_DESKTOP=sway` imported into
@@ -67,11 +69,18 @@ pub struct WlrootsDisplay {
/// never be served out-of-band: it now degrades to `Embedded` and streams, where it used to
/// cancel the cast and hand the client a black screen.
hw_cursor: bool,
/// What the portal actually gave us on the most recent [`create`](VirtualDisplay::create) — see
/// [`VirtualDisplay::last_portal_cursor_mode`], which is how the host learns that a cursor
/// overlay is never coming instead of inferring it from an absence.
last_cursor_mode: Option<crate::portal_cursor::Mode>,
}
impl WlrootsDisplay {
pub fn new() -> Result<Self> {
Ok(WlrootsDisplay { hw_cursor: false })
Ok(WlrootsDisplay {
hw_cursor: false,
last_cursor_mode: None,
})
}
}
@@ -100,6 +109,10 @@ impl VirtualDisplay for WlrootsDisplay {
self.hw_cursor
}
fn last_portal_cursor_mode(&self) -> Option<crate::PortalCursorMode> {
self.last_cursor_mode
}
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
warn_topology_is_extend_only();
// Snapshot → create → identify, all under CREATE_LOCK. sway names the headless output
@@ -146,16 +159,21 @@ impl VirtualDisplay for WlrootsDisplay {
// its own thread (it parks to keep the cast alive, like the other backends). Serialized:
// the chooser is one per-user file, so a concurrent session's write between ours and xdpw's
// read would silently capture the wrong output (see `SELECTION_LOCK`).
let (fd, node_id, stop) = {
let (fd, node_id, cursor_mode, stop) = {
let _sel = SELECTION_LOCK.lock().unwrap_or_else(|e| e.into_inner());
select_and_cast(&name, self.hw_cursor)?
};
// Latched for `last_portal_cursor_mode`: xdpw refuses metadata by construction, so this is
// `embedded` whatever we asked for, and the session's whole cursor behaviour follows from
// that fact rather than from `hw_cursor`.
self.last_cursor_mode = Some(cursor_mode);
tracing::info!(
node_id,
output = %name,
w = mode.width,
h = mode.height,
hz = mode.refresh_hz,
cursor = cursor_mode.name(),
"sway headless output ready"
);
Ok(VirtualOutput {
@@ -173,24 +191,75 @@ impl VirtualDisplay for WlrootsDisplay {
reused_gen: None,
pool_gen: None,
expect_exact_dims: false,
// Same EXTEND problem as Hyprland: on a sway session with real heads this `HEADLESS-N`
// sits beside them, and absolute input must be aimed at it by name. `swaymsg`'s output
// name is the head's `wl_output.name`, which is what the injector matches.
output_name: Some(name),
})
}
}
/// Drop order matters: stop the portal thread first (zbus connection drop ends the cast),
/// then unplug the output (fields drop in declaration order).
/// Drop order matters, and it is the whole fix: [`StopGuard`] **blocks until the ScreenCast session
/// is actually closed**, and only then does [`OutputGuard`] unplug the output (fields drop in
/// declaration order). This used to unplug first — see [`StopGuard`].
struct Keepalive {
_stop: StopGuard,
_output: OutputGuard,
}
/// Dropping this ends the portal keepalive thread, closing its zbus connection — the portal
/// then tears the screencast session down.
struct StopGuard(Arc<AtomicBool>);
/// How long teardown waits for the portal to confirm the ScreenCast session is closed before giving
/// up and unplugging the output anyway. See `hyprland.rs`'s twin.
const CAST_CLOSE_BUDGET: Duration = Duration::from_secs(3);
/// Ceiling on the whole ScreenCast handshake, under the caller's 20 s wait — see the note at the
/// handshake, and the longer one on `hyprland.rs`'s copy.
const HANDSHAKE_BUDGET: Duration = Duration::from_secs(15);
/// Ends the cast: signals the portal thread, then **waits for it to have closed the ScreenCast
/// session**, so the caller may safely unplug the output afterwards.
///
/// 🛑 THE WAIT IS THE POINT. Root-caused on the Hyprland leg (see the long note on `hyprland.rs`'s
/// `StopGuard`, which carries the measurements); the defect is the same here, and this is NOT an
/// assumption of symmetry — xdpw was read to confirm it, against `emersion/xdg-desktop-portal-wlr`:
///
/// * **Only `Close` tears a session down.** `src/core/session.c` gives the session object exactly
/// one method — `SD_BUS_METHOD("Close", …, method_close, …)` — and nothing else calls
/// `xdpw_session_destroy` for a live cast. Like xdph, xdpw has no peer-vanished watcher of its own
/// and depends entirely on xdg-desktop-portal's `peer_died_cb` calling `Close` for us, which
/// happens only after our bus name goes away, asynchronously, and therefore after the old
/// `StopGuard` had already let `OutputGuard` unplug the output.
/// * **The same unbounded busy-wait is waiting for it.** `src/screencast/screencast.c:599-605`:
/// `while (cast->node_id == SPA_ID_INVALID) { pw_loop_iterate(state->pw_loop, 0); }` — timeout 0,
/// i.e. non-blocking, i.e. a hot spin on the portal's only loop with no escape if the stream never
/// gets a node id. xdph's copy (`Screencopy.cpp:307-313`) is this code; that is the one measured
/// pinning a core solid until it was restarted.
///
/// So sway's `output unplug` yanks a captured output out from under a live session exactly the way
/// Hyprland's `output remove` did. Whether xdpw wedges *identically* has not been observed on glass
/// — no sway box was available — but the two preconditions are present in its source, and closing
/// the session before unplugging is the correct order regardless of what the backend does with it.
struct StopGuard {
stop: Arc<AtomicBool>,
/// Signalled by the portal thread once it has closed the ScreenCast session. `None` when no cast
/// was ever established — nothing to close, and nothing worth spending the budget on.
closed: Option<std::sync::mpsc::Receiver<()>>,
}
impl Drop for StopGuard {
fn drop(&mut self) {
self.0.store(true, Ordering::Relaxed);
self.stop.store(true, Ordering::Relaxed);
let Some(closed) = self.closed.take() else {
return;
};
match closed.recv_timeout(CAST_CLOSE_BUDGET) {
// Closed, or the thread is gone without confirming — either way nothing holds the cast.
Ok(()) | Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => tracing::warn!(
budget_s = CAST_CLOSE_BUDGET.as_secs(),
"the ScreenCast session did not close in time — unplugging the output underneath \
it; the next cast may find the portal busy"
),
}
}
}
@@ -346,7 +415,10 @@ impl Drop for ChooserFile {
/// Point xdpw's chooser at `output` and run the ScreenCast handshake, returning the portal fd +
/// node id and the guard that stops the cast. The caller must hold [`SELECTION_LOCK`].
fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, StopGuard)> {
fn select_and_cast(
output: &str,
hw_cursor: bool,
) -> Result<(OwnedFd, u32, crate::portal_cursor::Mode, StopGuard)> {
ensure_xdpw_config()?;
let chooser = chooser_file();
std::fs::write(&chooser, format!("Monitor: {output}\n"))
@@ -354,12 +426,21 @@ fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, StopG
// Owned from the write on: every arm below (and every `?`) leaves the handshake, which is the
// only thing that reads it.
let _chooser = ChooserFile(chooser);
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
// The NEGOTIATED cursor mode rides back with the fd and node id: it is decided inside the
// portal thread (only there is the proxy to ask), and nothing downstream can re-derive it —
// `hw_cursor` is the request, not the answer.
let (setup_tx, setup_rx) =
std::sync::mpsc::channel::<Result<(OwnedFd, u32, crate::portal_cursor::Mode), String>>();
// The teardown handshake: the thread signals this once it has closed the ScreenCast session, and
// `StopGuard::drop` waits on it before the output is unplugged (see `StopGuard`). Kept a
// SEPARATE channel from the setup one above — it fires at the other end of the cast's life,
// long after `setup_rx` has been consumed.
let (closed_tx, closed_rx) = std::sync::mpsc::channel::<()>();
let stop = Arc::new(AtomicBool::new(false));
let stop_thread = stop.clone();
thread::Builder::new()
.name("punktfunk-wlr-cast".into())
.spawn(move || portal_thread(setup_tx, stop_thread, hw_cursor))
.spawn(move || portal_thread(setup_tx, closed_tx, stop_thread, hw_cursor))
.context("spawn wlroots portal thread")?;
// Built BEFORE the wait so EVERY error arm below sets the flag on its way out — as Mutter's
// `create` does. Returning the bare `Arc` and letting the CALLER wrap it left the two failure
@@ -368,9 +449,13 @@ fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, StopG
// parks forever on `while !stop`, holding a live ScreenCast session, its zbus connection, an
// `OwnedFd` and a 2-worker tokio runtime — one more set per slow-portal connect, for the host's
// lifetime, against an output that no longer exists.
let guard = StopGuard(stop);
let mut guard = StopGuard { stop, closed: None };
match setup_rx.recv_timeout(Duration::from_secs(20)) {
Ok(Ok((fd, node_id))) => Ok((fd, node_id, guard)),
Ok(Ok((fd, node_id, cursor_mode))) => {
// A cast exists now, so teardown has something to close and must wait for it.
guard.closed = Some(closed_rx);
Ok((fd, node_id, cursor_mode, guard))
}
Ok(Err(e)) => bail!("ScreenCast portal on {output} failed: {e}"),
Err(_) => bail!("timed out waiting for the ScreenCast portal on {output}"),
}
@@ -387,10 +472,11 @@ pub(crate) fn stream_existing_output(
hw_cursor: bool,
) -> Result<crate::mirror::MirrorStream> {
let _sel = SELECTION_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let (fd, node_id, stop) = select_and_cast(connector, hw_cursor)?;
let (fd, node_id, cursor_mode, stop) = select_and_cast(connector, hw_cursor)?;
Ok(crate::mirror::MirrorStream {
node_id,
remote_fd: Some(fd),
cursor_mode: Some(cursor_mode),
keepalive: Box::new(stop),
})
}
@@ -513,7 +599,8 @@ fn ensure_xdpw_config() -> Result<()> {
/// reports the fd + node id and parks until stopped — the zbus connection is the cast's
/// lifetime). xdpw answers the source selection via the chooser, no dialog.
fn portal_thread(
setup_tx: Sender<Result<(OwnedFd, u32), String>>,
setup_tx: Sender<Result<(OwnedFd, u32, crate::portal_cursor::Mode), String>>,
closed_tx: Sender<()>,
stop: Arc<AtomicBool>,
hw_cursor: bool,
) {
@@ -523,14 +610,13 @@ fn portal_thread(
// Multi-thread runtime: the zbus background reader must be pumped across the
// create_session → select_sources → start handshake (see capture/linux.rs).
let rt = match tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
{
// The SHARED, never-dropped runtime — see [`crate::portal_rt`] and the long note on
// `hyprland.rs`'s copy: a per-cast runtime kills ashpd's process-global cached connection when
// the cast ends, and every later handshake in the process then hangs.
let rt = match crate::portal_rt::portal_runtime() {
Ok(rt) => rt,
Err(e) => {
let _ = setup_tx.send(Err(format!("build tokio runtime: {e}")));
let _ = setup_tx.send(Err(e));
return;
}
};
@@ -538,9 +624,20 @@ fn portal_thread(
rt.block_on(async move {
let result: Result<()> = async {
let proxy = Screencast::new().await.context(
"connect ScreenCast portal (is xdg-desktop-portal running with the wlr backend?)",
)?;
// Bounded, like `hyprland.rs`'s copy: an orphaned cached connection hangs HERE, before
// any handshake call, so a bound that starts later never fires.
let connect = async {
Screencast::new().await.context(
"connect ScreenCast portal (is xdg-desktop-portal running with the wlr backend?)",
)
};
let proxy = match tokio::time::timeout(HANDSHAKE_BUDGET, connect).await {
Ok(v) => v?,
Err(_) => bail!(
"connecting to the ScreenCast portal did not return within {}s",
HANDSHAKE_BUDGET.as_secs()
),
};
// NEGOTIATED against what xdpw advertises, never asserted from `hw_cursor` alone — see
// the xdph copy in `hyprland.rs` for the incident. xdpw is the sharper case: its
// screencast.c refuses the mode outright —
@@ -549,51 +646,91 @@ fn portal_thread(
// — so EVERY cursor-forward session on this backend asked for a mode that cancelled the
// cast. Different wording from xdph's "unavailable cursor mode 4", same dead session.
let cursor_mode = crate::portal_cursor::negotiate(&proxy, hw_cursor, "xdpw").await;
let session = proxy
.create_session(Default::default())
.await
.context("create_session")?;
proxy
.select_sources(
&session,
SelectSourcesOptions::default()
.set_cursor_mode(cursor_mode)
// xdpw offers MONITOR only; the chooser picks our output.
.set_sources(BitFlags::from_flag(SourceType::Monitor))
.set_multiple(false)
.set_persist_mode(PersistMode::DoNot),
)
.await
.context("select_sources")?
.response()
.context("select_sources rejected")?;
let streams = proxy
.start(&session, None, Default::default())
.await
.context("start cast")?
.response()
.context("start response (chooser declined? check the xdpw config/chooser file)")?;
let stream = streams
.streams()
.first()
.context("portal returned no streams")?
.clone();
let node_id = stream.pipe_wire_node_id();
let fd = proxy
.open_pipe_wire_remote(&session, Default::default())
.await
.context("open_pipe_wire_remote")?;
// Bounded for the same reason as `hyprland.rs`'s copy (the long note lives there): an
// await on a wedged portal never returns, the `stop` flag is only read by the park loop
// further down, so the thread leaks — and a leaked half-handshake poisons every later
// portal request from this process. xdpw has the identical unbounded node-id spin as
// xdph (`screencast.c`), so it can wedge the same way.
let handshake = async {
let session = proxy
.create_session(Default::default())
.await
.context("create_session")?;
proxy
.select_sources(
&session,
SelectSourcesOptions::default()
.set_cursor_mode(cursor_mode.to_ashpd())
// xdpw offers MONITOR only; the chooser picks our output.
.set_sources(BitFlags::from_flag(SourceType::Monitor))
.set_multiple(false)
.set_persist_mode(PersistMode::DoNot),
)
.await
.context("select_sources")?
.response()
.context("select_sources rejected")?;
let streams = proxy
.start(&session, None, Default::default())
.await
.context("start cast")?
.response()
.context(
"start response (chooser declined? check the xdpw config/chooser file)",
)?;
let stream = streams
.streams()
.first()
.context("portal returned no streams")?
.clone();
let node_id = stream.pipe_wire_node_id();
let fd = proxy
.open_pipe_wire_remote(&session, Default::default())
.await
.context("open_pipe_wire_remote")?;
Ok::<_, anyhow::Error>((session, fd, node_id))
};
let (session, fd, node_id) =
match tokio::time::timeout(HANDSHAKE_BUDGET, handshake).await {
Ok(v) => v?,
Err(_) => bail!(
"the ScreenCast portal did not complete the handshake within {}s — \
abandoning it instead of parking this thread on it forever (a hung \
request poisons every later one from this process)",
HANDSHAKE_BUDGET.as_secs()
),
};
setup_tx
.send(Ok((fd, node_id)))
.send(Ok((fd, node_id, cursor_mode)))
.map_err(|_| anyhow!("virtual-output opener went away"))?;
// Park, keeping `proxy` + `session` (the zbus connection) alive until stopped —
// the cast is torn down when the connection drops.
// Park, keeping `proxy` + `session` alive until stopped. Polled at 20 ms rather than the
// 200 ms this used to use, because teardown now WAITS on what follows.
let _keep_alive = (&proxy, &session);
while !stop.load(Ordering::Relaxed) {
tokio::time::sleep(Duration::from_millis(200)).await;
tokio::time::sleep(Duration::from_millis(20)).await;
}
// 🛑 CLOSE THE SESSION, AND CLOSE IT *BEFORE* THE OUTPUT IS UNPLUGGED. `Session.Close` is
// the only thing that ends an xdpw session (`src/core/session.c`); dropping the
// connection and trusting the peer to notice is not the contract. The caller is blocked
// in `StopGuard::drop` on the signal below — see `StopGuard`. Bounded, so an
// already-wedged portal cannot hang teardown with it.
match tokio::time::timeout(CAST_CLOSE_BUDGET, session.close()).await {
Ok(Ok(())) => {}
Ok(Err(e)) => tracing::warn!(
error = %e,
"closing the ScreenCast session failed — the next cast may find the portal busy"
),
Err(_) => tracing::warn!(
budget_s = CAST_CLOSE_BUDGET.as_secs(),
"the ScreenCast portal did not answer Session.Close in time — it is probably \
already wedged"
),
}
// Release the teardown. Best-effort: the receiver is gone if the caller already gave up.
let _ = closed_tx.send(());
Ok(())
}
.await;
+23 -1
View File
@@ -31,6 +31,12 @@ use anyhow::{bail, Context, Result};
pub(crate) struct MirrorStream {
pub node_id: u32,
pub remote_fd: Option<std::os::fd::OwnedFd>,
/// The cursor mode the xdg ScreenCast portal NEGOTIATED for this recording, for the two
/// portal-based backends; `None` for the compositor-protocol ones (KWin/Mutter/gamescope),
/// which get what they ask for. Reported on to the host as
/// [`VirtualDisplay::last_portal_cursor_mode`] — same split as `remote_fd` above, and for the
/// same reason: only the portal path has an answer that can differ from the request.
pub cursor_mode: Option<crate::portal_cursor::Mode>,
/// Dropping this ends the recording. It never owns the monitor — we did not create it.
pub keepalive: Box<dyn Send>,
}
@@ -40,6 +46,9 @@ pub struct MirrorDisplay {
compositor: Compositor,
connector: String,
hw_cursor: bool,
/// What the portal gave the most recent [`create`](VirtualDisplay::create), when this mirror
/// delegated to a portal-based backend. See [`VirtualDisplay::last_portal_cursor_mode`].
last_cursor_mode: Option<crate::portal_cursor::Mode>,
}
impl MirrorDisplay {
@@ -48,6 +57,7 @@ impl MirrorDisplay {
compositor,
connector,
hw_cursor: false,
last_cursor_mode: None,
})
}
}
@@ -65,6 +75,10 @@ impl VirtualDisplay for MirrorDisplay {
self.hw_cursor
}
fn last_portal_cursor_mode(&self) -> Option<crate::PortalCursorMode> {
self.last_cursor_mode
}
fn poolable_now(&self) -> bool {
// Never. `create` below always reports `DisplayOwnership::External` — we did not make this
// head and must not keep it — so the registry never pools a mirror, and the trait's `true`
@@ -125,10 +139,15 @@ impl VirtualDisplay for MirrorDisplay {
),
};
// Latched for `last_portal_cursor_mode` — the delegate's verdict is this mirror's verdict.
self.last_cursor_mode = stream.cursor_mode;
// NOTE: aiming absolute input at this head is the HOST's job, not ours — this crate must
// not depend on pf-inject (see the crate doc: "never on capture/inject"). The host sets the
// anchor from the same pin at startup; §7.2 of the design doc explains why it is host-level
// rather than set here per session.
// rather than set here per session. We only CARRY the head's name out (`output_name`
// below), which is what the wlr injector needs to bind its virtual pointer to this head —
// the libei anchor above cannot serve it, because that backend selects by region.
tracing::info!(
connector = %target.connector,
mode = %target.mode_label(),
@@ -145,6 +164,9 @@ impl VirtualDisplay for MirrorDisplay {
out.remote_fd = stream.remote_fd;
// Never pooled, never lingered, never made primary/exclusive: we don't own this head.
out.ownership = DisplayOwnership::External;
// The head absolute input maps into is the one we mirror — its connector IS its
// `wl_output.name` on the wlroots/Hyprland backends, where the injector matches on it.
out.output_name = Some(target.connector.clone());
Ok(out)
}
}
+24 -1
View File
@@ -302,6 +302,14 @@ mod pool {
pub(super) keepalive: Box<dyn Send>,
pub(super) node_id: u32,
pub(super) preferred_mode: Option<(u32, u32, u32)>,
/// The compositor's name for this output ([`VirtualOutput::output_name`]) — the identity the
/// host aims absolute input with. Kept across a keep-alive reuse for the same reason
/// `preferred_mode` is: the reused display IS the same head, so the output the caller is
/// handed must answer with the same name a fresh create would. No poolable backend sets it
/// today — the ones that do are all passed through unpooled (Hyprland/sway carry a portal
/// fd, a mirror is `External`) — so this only exists so that stops being a silent trap the
/// day one does.
pub(super) output_name: Option<String>,
pub(super) mode: Mode,
pub(super) backend: &'static str,
/// The identity slot the backend resolved for this display (KWin per-slot naming; `None` for
@@ -601,6 +609,7 @@ mod pool {
keepalive: Box::new(()),
node_id: 0,
preferred_mode: None,
output_name: None,
mode: Mode {
width: 1920,
height: 1080,
@@ -1083,6 +1092,7 @@ mod linux {
fn output_for(
node_id: u32,
preferred_mode: Option<(u32, u32, u32)>,
output_name: Option<String>,
generation: u64,
quit: Arc<AtomicBool>,
reused: bool,
@@ -1093,6 +1103,8 @@ mod linux {
preferred_mode,
Box::new(DisplayLease { generation, quit }),
);
// The head is the same one the entry was created for, so it answers with the same name.
out.output_name = output_name;
// A2: tell the pipeline builder this was a REUSED kept display, so a first-frame failure can
// `mark_failed(generation)` (tear the corpse down) rather than re-wedge the retry loop on the same node.
out.reused_gen = reused.then_some(generation);
@@ -1176,6 +1188,7 @@ mod linux {
let generation = r.generation.fetch_add(1, Ordering::Relaxed);
es[idx].generation = generation;
let preferred_mode = es[idx].preferred_mode;
let output_name = es[idx].output_name.clone();
tracing::info!(
backend,
node_id,
@@ -1184,6 +1197,7 @@ mod linux {
ReuseOutcome::Reused(output_for(
node_id,
preferred_mode,
output_name,
generation,
quit.clone(),
true,
@@ -1279,6 +1293,7 @@ mod linux {
let node_id = real.node_id;
let preferred_mode = real.preferred_mode;
let output_name = real.output_name.clone();
// Fresh creates only: the backend may have birthed the output at a sacrificial mode whose
// stream must renegotiate before frames count (KWin >60 Hz — see backend.rs). A REUSED kept
// display already renegotiated in its prior session (the producer's rebuilt offer persists
@@ -1295,6 +1310,7 @@ mod linux {
keepalive: real.keepalive,
node_id,
preferred_mode,
output_name: output_name.clone(),
mode,
backend,
identity_slot,
@@ -1349,7 +1365,14 @@ mod linux {
if (position.x, position.y) != (0, 0) {
vd.apply_position(position.x, position.y);
}
let mut out = output_for(node_id, preferred_mode, generation, quit, false);
let mut out = output_for(
node_id,
preferred_mode,
output_name,
generation,
quit,
false,
);
out.expect_exact_dims = expect_exact_dims;
Ok(out)
}
+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"
+261 -7
View File
@@ -1811,6 +1811,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex7(
observed_sha256_out,
client_cert_pem,
client_key_pem,
std::ptr::null(), // pre-v21 variant: no device name, so the OS default stands
timeout_ms,
std::ptr::null_mut(),
)
@@ -1873,6 +1874,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex8(
observed_sha256_out,
client_cert_pem,
client_key_pem,
std::ptr::null(), // pre-v21 variant: no device name, so the OS default stands
timeout_ms,
status_out,
)
@@ -1935,6 +1937,79 @@ pub unsafe extern "C" fn punktfunk_connect_ex9(
observed_sha256_out,
client_cert_pem,
client_key_pem,
std::ptr::null(), // pre-v21 variant: no device name, so the OS default stands
timeout_ms,
status_out,
)
}
}
/// Like [`punktfunk_connect_ex9`], plus `device_name` (ABI v21): the human-readable label this
/// device knocks with — what the host's **pending-approval** list (and the web console's
/// outstanding-pairings view and its approve dialog) shows for an unpaired client, and what the
/// trust store files it under once approved. Pass the name the user already recognises this
/// device by: `Host.current().localizedName` on macOS, `UIDevice.current.name` on iOS/tvOS,
/// `Settings.Global.DEVICE_NAME` on Android.
///
/// NULL / empty = the [`crate::client::device_name`] default, exactly as every earlier variant.
/// That default is an OS hostname, which no Apple GUI process could reach until v21 — every one
/// of them knocked as the literal "This device", so a console with three of them pending showed
/// three identical rows. Longer than [`crate::quic::HELLO_NAME_MAX`] bytes of UTF-8 is truncated
/// (on a character boundary) rather than rejected: a too-long label is a cosmetic problem, and
/// failing a connect over it would be a much worse one.
///
/// # Safety
/// Same as [`punktfunk_connect_ex9`]; `device_name`, when non-null, must be a NUL-terminated C
/// string that stays valid for the duration of the call.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
#[allow(clippy::too_many_arguments)]
pub unsafe extern "C" fn punktfunk_connect_ex10(
host: *const std::os::raw::c_char,
port: u16,
width: u32,
height: u32,
refresh_hz: u32,
compositor: u32,
gamepad: u32,
bitrate_kbps: u32,
video_caps: u8,
audio_channels: u8,
video_codecs: u8,
preferred_codec: u8,
client_caps: u8,
launch_id: *const std::os::raw::c_char,
pin_sha256: *const u8,
observed_sha256_out: *mut u8,
client_cert_pem: *const std::os::raw::c_char,
client_key_pem: *const std::os::raw::c_char,
device_name: *const std::os::raw::c_char,
timeout_ms: u32,
status_out: *mut i32,
) -> *mut PunktfunkConnection {
// SAFETY: the pointer arguments are forwarded UNCHANGED to the versioned entry point, which
// applies the same ABI contract to them; this shim dereferences nothing itself.
unsafe {
connect_ex_impl(
host,
port,
client_caps,
width,
height,
refresh_hz,
compositor,
gamepad,
bitrate_kbps,
video_caps,
audio_channels,
video_codecs,
preferred_codec,
launch_id,
pin_sha256,
observed_sha256_out,
client_cert_pem,
client_key_pem,
device_name,
timeout_ms,
status_out,
)
@@ -1958,9 +2033,27 @@ pub const PUNKTFUNK_CLIENT_CAP_PHASE_LOCK: u8 = 0x02;
/// with [`PUNKTFUNK_HOST_CAP_PAD_AUDIO`]. (Mirrors `quic::CLIENT_CAP_PAD_AUDIO`.)
pub const PUNKTFUNK_CLIENT_CAP_PAD_AUDIO: u8 = 0x08;
/// A [`punktfunk_connect_ex10`] device name cut to what a [`crate::quic::Hello`] carries.
/// [`crate::quic::HELLO_NAME_MAX`] is a BYTE cap while the cut must land on a character
/// boundary — "Wohnzimmer-Fernseher überm Sofa" is 33 characters and 34 bytes, and slicing a
/// name mid-scalar panics. Too long is truncated rather than rejected: the wire encoder would
/// truncate it anyway, and failing a connect over a cosmetic label would be far worse than
/// showing a shortened one.
#[cfg(feature = "quic")]
fn clamp_device_name(s: &str) -> String {
let end = s
.char_indices()
.map(|(i, c)| i + c.len_utf8())
.take_while(|&i| i <= crate::quic::HELLO_NAME_MAX)
.last()
.unwrap_or(0);
s[..end].to_string()
}
/// Shared body of [`punktfunk_connect_ex7`] / [`punktfunk_connect_ex8`]: `status_out`
/// (nullable) is written on EVERY path — `Ok`, the mapped [`PunktfunkError`],
/// `InvalidArg` for bad arguments, `Panic` if the connect panicked.
/// `InvalidArg` for bad arguments, `Panic` if the connect panicked. `device_name` (nullable,
/// [`punktfunk_connect_ex10`]) is the label this device knocks with; null = the OS default.
#[cfg(feature = "quic")]
#[allow(clippy::too_many_arguments)]
unsafe fn connect_ex_impl(
@@ -1982,6 +2075,7 @@ unsafe fn connect_ex_impl(
observed_sha256_out: *mut u8,
client_cert_pem: *const std::os::raw::c_char,
client_key_pem: *const std::os::raw::c_char,
device_name: *const std::os::raw::c_char,
timeout_ms: u32,
status_out: *mut i32,
) -> *mut PunktfunkConnection {
@@ -2013,6 +2107,16 @@ unsafe fn connect_ex_impl(
Ok(Some(s)) if !s.is_empty() => Some(s.to_string()),
_ => None,
};
// The label the host's pending-approval list shows. Same non-fatal treatment as `launch`:
// an absent / empty / bad-UTF-8 name falls back to the OS default rather than failing a
// connect over a cosmetic field. Truncation is on a CHARACTER boundary — `HELLO_NAME_MAX`
// is a byte cap, and slicing a multi-byte name mid-scalar would panic.
// SAFETY: per the ABI contract - a caller-supplied C string, NUL-terminated or null,
// borrowed only for this call.
let name = match unsafe { opt_cstr(device_name) } {
Ok(Some(s)) if !s.trim().is_empty() => clamp_device_name(s.trim()),
_ => crate::client::device_name(),
};
let mode = crate::config::Mode {
width,
height,
@@ -2069,15 +2173,15 @@ unsafe fn connect_ex_impl(
client_caps,
// The C ABI cannot carry slice-progressive parts yet — `PunktfunkFrame` has no
// part/completeness fields, so a part would be indistinguishable from a whole AU.
// An `ex10` variant adds the opt-in together with those fields when an ABI embedder
// An `ex11` variant adds the opt-in together with those fields when an ABI embedder
// (Apple) grows a partial-feed decode path.
false,
launch,
// The C ABI has no device-name parameter (only `punktfunk_pair` takes one), so every
// embedder gets the OS hostname default — this is what the host's pending-approval
// list shows when an unpaired embedder knocks. An `ex10` variant can make it explicit
// if an embedder ever wants a custom label (e.g. the platform's marketing name).
Some(crate::client::device_name()),
// What the host's pending-approval list shows when this embedder knocks unpaired, and
// the trust-store label on approval. [`punktfunk_connect_ex10`]'s `device_name` when
// the embedder supplied one (the name the USER knows the device by — an Apple app has
// it and the OS default cannot reach it), else that OS default.
Some(name),
pin,
identity,
std::time::Duration::from_millis(timeout_ms as u64),
@@ -3895,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.
@@ -4940,6 +5171,29 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_is_holding(
mod tests {
use super::*;
/// The `ex10` device name is cut to the Hello's BYTE budget on a CHARACTER boundary — the
/// naive `s[..HELLO_NAME_MAX]` panics on any multi-byte name that straddles it, and an
/// operator naming a device in German or Japanese is not an edge case.
#[test]
fn device_name_truncates_on_a_character_boundary() {
let max = crate::quic::HELLO_NAME_MAX;
assert_eq!(clamp_device_name("Enrico's iPad"), "Enrico's iPad");
// Straddling: 2-byte characters over an odd-length prefix, so the cap lands mid-scalar.
let straddle = format!("{}{}", "x".repeat(max - 1), "ü".repeat(4));
let cut = clamp_device_name(&straddle);
assert!(cut.len() <= max, "{} bytes exceeds the cap", cut.len());
assert_eq!(
cut,
"x".repeat(max - 1),
"must drop the whole ü, not half of it"
);
// A name whose FIRST character already exceeds the cap has nothing to keep — the
// `unwrap_or(0)` path, which must yield "" rather than panicking on an empty iterator.
assert_eq!(clamp_device_name(&"".repeat(max)), "".repeat(max / 3));
}
/// A C embedder writing `ev->kind = 42` must come back as a status code, not UB. The test
/// stages the event in `MaybeUninit` storage so no `&InputEvent` to an invalid value ever
/// exists on the test's own side either.
@@ -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,
}
+136 -2
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>,
@@ -479,8 +511,16 @@ fn register_hot_tid(reg: &Mutex<Vec<i32>>) {
/// This machine's name — the default value for [`NativeClient::connect`]'s `name` parameter
/// (what a host shows in its pending-approval list and files this client under when approved).
/// `/etc/hostname` first (the answer on any Linux box, and available in a minimal build with no
/// desktop toolkit to ask), then the usual environment fallbacks. Lives here (not in a client
/// shell crate) so the C ABI's `punktfunk_connect` can share the same default.
/// desktop toolkit to ask), then the usual environment fallbacks, then the OS hostname itself.
/// Lives here (not in a client shell crate) so the C ABI's `punktfunk_connect` can share the
/// same default.
///
/// The `gethostname` step is what saves the GUI clients: **no** Apple app has `COMPUTERNAME`
/// (Windows-only) or `HOSTNAME` (a shell variable — never exported into a `launchd`-started
/// process) in its environment, so before it every Mac, iPad, iPhone and Apple TV knocked as
/// the literal "This device" and the console's pending list could not tell them apart. An
/// embedder that knows a better, user-facing name should pass it explicitly instead
/// ([`crate::abi::punktfunk_connect_ex10`]'s `device_name`) — this is only the floor.
pub fn device_name() -> String {
#[cfg(target_os = "linux")]
if let Ok(s) = std::fs::read_to_string("/etc/hostname") {
@@ -493,9 +533,36 @@ pub fn device_name() -> String {
.or_else(|_| std::env::var("HOSTNAME"))
.ok()
.filter(|s| !s.trim().is_empty())
.or_else(os_hostname)
.unwrap_or_else(|| "This device".into())
}
/// The OS hostname (`gethostname`), or `None` when it is missing/unset/useless. macOS returns
/// the user's computer name as an mDNS host label ("Enricos-MacBook-Pro.local"), iOS/tvOS the
/// device name — so the `.local` suffix comes off, and the placeholder answers every platform
/// gives when nothing is configured ("localhost") is rejected: it labels nothing.
#[cfg(unix)]
fn os_hostname() -> Option<String> {
let mut buf = [0u8; 256];
// SAFETY: `gethostname` writes at most `len` bytes into the caller's buffer; this one is a
// stack array we own and pass its true length. A truncating write may omit the NUL, which
// the `position` fallback below covers.
if unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len()) } != 0 {
return None;
}
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
let s = std::str::from_utf8(&buf[..end]).ok()?.trim();
let s = s.strip_suffix(".local").unwrap_or(s);
(!s.is_empty() && !s.eq_ignore_ascii_case("localhost")).then(|| s.to_string())
}
/// Windows has no `gethostname` without linking winsock (and `COMPUTERNAME` is always set there
/// anyway, so the env step above never falls through to this).
#[cfg(not(unix))]
fn os_hostname() -> Option<String> {
None
}
impl NativeClient {
/// Connect to a `punktfunk/1` host and start the session at (up to) `mode`. Blocks until the
/// handshake completes or `timeout` elapses.
@@ -577,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));
@@ -594,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();
@@ -610,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())
@@ -685,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)?;
@@ -716,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,
@@ -983,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
@@ -1394,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,
}
}
}
+24 -1
View File
@@ -185,7 +185,30 @@ pub use stats::Stats;
/// every existing function keeps its signature and behaviour, and an embedder that never calls it
/// is unchanged. The `Welcome` grew a trailing field, which older peers skip in both directions
/// (see `Welcome::encode`), so [`WIRE_VERSION`] is unchanged.
pub const ABI_VERSION: u32 = 20;
/// v21: added `punktfunk_connect_ex10` — `connect_ex9` plus `device_name`, the label an unpaired
/// client knocks with: what the host's pending-approval list (and the web console's
/// outstanding-pairings view and approve dialog) shows, and the trust-store name on approval. The
/// C ABI had no such parameter, so every embedder took [`client::device_name`]'s OS default —
/// which resolves through `COMPUTERNAME`/`HOSTNAME`, neither of which exists in an Apple GUI
/// process, leaving every Mac, iPad, iPhone and Apple TV knocking as the literal "This device"
/// (a console with three of them pending showed three identical rows). A NEW symbol, not a
/// 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.
/// 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);
}
}
+11
View File
@@ -135,6 +135,17 @@ pub fn capture_virtual_output(
// handshake already resolved that through [`capturer_supports_hdr_for`] before the Welcome,
// so passing it through here is the whole of this arm's HDR logic. It used to be dropped on
// the floor, which is what kept the Linux native plane at 8 bits.
//
// Aim the wlr injector's absolute mapping (abs-mouse, and `park_pointer`'s opening warp) at
// THIS head — the Linux counterpart of the `set_stream_target` call in the Windows arm below.
// The wlroots virtual pointer maps `motion_absolute` onto the `wl_output` it was created with,
// and on the EXTEND backends (Hyprland, sway) the streamed head sits BESIDE the operator's, so
// without this every absolute sample landed on their screen and the cursor never entered the
// stream at all. `None` (KWin/Mutter/gamescope, none of which inject through that backend)
// CLEARS the slot rather than leaving a stale name: one compositor serves the whole host, so a
// `None` here means no session on this host wants a named binding — e.g. a Game-Mode switch
// from a Hyprland desktop to gamescope, after which the old `PF-…` name means nothing.
crate::inject::set_stream_output(vout.output_name.clone());
pf_capture::open_virtual_output(
vout.remote_fd,
vout.node_id,
+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);
}
}

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