18 Commits

Author SHA1 Message Date
enricobuehler af13f0b749 chore(release): 0.6.0
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled
audit / cargo-audit (push) Has been cancelled
android-screenshots / screenshots (push) Successful in 2m18s
android / android (push) Successful in 4m13s
decky / build-publish (push) Successful in 26s
windows-host / package (push) Successful in 6m36s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m10s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 2m50s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 35s
release / apple (push) Successful in 7m53s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m32s
deb / build-publish (push) Successful in 9m52s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m21s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 53s
web-screenshots / screenshots (push) Successful in 2m38s
ci / web (push) Successful in 48s
ci / rust (push) Successful in 11m43s
linux-client-screenshots / screenshots (push) Successful in 1m33s
flatpak / build-publish (push) Successful in 4m8s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 10m10s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m49s
docker / deploy-docs (push) Successful in 25s
ci / docs-site (push) Successful in 57s
ci / bench (push) Successful in 5m9s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:19:18 +00:00
enricobuehler d285d4a0b2 fix(tray): live-probe the web console instead of sniffing the install layout
windows-drivers / probe-and-proto (push) Successful in 29s
audit / cargo-audit (push) Successful in 1m31s
apple / swift (push) Successful in 1m8s
windows-drivers / driver-build (push) Successful in 1m35s
android / android (push) Successful in 4m45s
ci / web (push) Successful in 1m2s
ci / docs-site (push) Successful in 1m0s
release / apple (push) Successful in 7m35s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
windows-host / package (push) Has been cancelled
apple / screenshots (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
The "Open web console" entry was gated on {exe dir}\web\web-run.cmd (Windows)
/ the punktfunk-web unit file (Linux) — which misses consoles run from a repo
checkout (the RTX box, caught on-glass) and shows a dead entry while an
installed console is stopped. The poller now probes https://127.0.0.1:<web
port>/ each cycle (any HTTP response = up, transport failure = down) and the
menu follows live on both platforms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:17:01 +00:00
enricobuehler 04f370999c fix(web): pin the sidebar at viewport height
Sticky h-dvh sidebar: long pages scroll the content, not the nav — the flex
stretch was pushing the language switcher below the fold; overflow-y-auto keeps
the nav usable on short viewports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:09:52 +00:00
enricobuehler 2c937855b3 fix(packaging/windows): Windows 11 22H2 floor + tray install task + stale console-port fixes
The OS floor is now enforced at install time (MinVersion=10.0.22621 with an
explanatory [Messages] override): pf-vdisplay is built against IddCx 1.10, and
on Windows 10 (incl. LTSC) / Win11 21H2 the device fails start with Code 10
STATUS_DEVICE_POWER_FAILURE (field-reported). Docs (site requirements/install/
windows-host pages + README) state the floor; new docs-site Security page.

Installer also gains the trayicon task (punktfunk-tray.exe file + HKLM Run key,
post-install launch as the signed-in user, upgrade taskkill + uninstall
--quit/taskkill choreography before file deletion), and the wizard/cleanup
text/port sweeps move off the stale :3000 web-console references to :47992
(cleanups sweep both for upgrades from old installs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:09:52 +00:00
enricobuehler 8005b11faf feat(tray): system-tray status icon for the host (Windows + Linux)
New crates/punktfunk-tray — a small per-user companion showing the host service
state at a glance (running / stopped / starting / degraded / failed + the live
session in the tooltip) with one-click actions: open web console, approve a
pending pairing request, start/stop/restart, open logs. No more digging through
logs to learn whether the service came back after a reboot or an update.

Status is service-manager-FIRST (SCM / systemd user unit — a port squatter can
never fake Running), then the new loopback-only unauthenticated
GET /api/v1/local/summary (counts/booleans only; the mgmt token and cert.pem
are SYSTEM/Admins-DACL'd on Windows, so a non-elevated tray cannot bearer-auth).

Windows: windows_subsystem binary (a console exe in the Run key would flash a
terminal at sign-in), Shell_NotifyIcon + hidden window, per-session single
instance, TaskbarCreated re-add, --quit for the uninstaller; service actions
elevate per click via ShellExecuteW "runas" onto the new
`punktfunk-host service restart` (stop → wait Stopped → start).
Linux: ksni/StatusNotifierItem over zbus, systemctl --user actions (no polkit),
/etc/xdg/autostart entry whose --autostart self-gates to actual host users.
Icons: scripts/gen-tray-icons.py (pure stdlib) renders the brand lens + status
dot into committed .ico/hicolor assets; deb/rpm/arch ship binary+autostart+icons.

Live-validated: Linux on the headless KDE session (SNI registration, state
transitions, menu-driven start, dbusmenu layout); Windows on the RTX box
(session-1 launch with no NIM_ADD failure, single instance, --quit, restart
round-trip, summary loopback-200/LAN-401).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:09:35 +00:00
enricobuehler 01fcb01019 fix(encode/windows): resolve NVENC at runtime — AMD/Intel hosts no longer crash at start
The nvenc build linked nvEncodeAPI64.dll's entry points at load time, so a
--features nvenc binary hard-crashed on any box without the NVIDIA driver
(AMD/Intel). Entry points now come from a runtime LoadLibrary table
(encode/windows/nvenc.rs load_api); a missing DLL just falls through the
encoder auto-detect to AMF/QSV/software. The generated import lib and all its
plumbing (gen-nvenc-importlib.ps1, nvenc.def, PUNKTFUNK_NVENC_LIB_DIR,
setup-build-env wiring) are gone.

Live-validated on the RTX 4090 box (NVENC session, 7000+ frames).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:09:18 +00:00
enricobuehler 95a08e99c3 feat(host/windows): seal the host↔driver channels (frame + gamepad, proto v2)
Frame ring (pf-vdisplay) and both gamepad SHM channels move off named Global\
objects (openable by any sibling LocalService) to UNNAMED sections/events whose
handles the host DuplicateHandles into the driver's verified WUDFHost with least
access — frame delivery over the SYSTEM+admins-only IOCTL_SET_FRAME_CHANNEL,
pads over a 32-byte named bootstrap mailbox (pid + handle value only, DoS-bounded;
HID minidrivers have no control device). Driver-validated pad_index kills
cross-pad redirects; v1↔v2 mixes fail closed with diagnosis logs on both sides.
Sibling-LocalService denial proven empirically (design/idd-push-security.md,
design/gamepad-channel-sealing.md).

Driver-side raw ops now live behind pf-umdf-util (checked shm accessors, the
forbid(unsafe_code) ChannelClient state machine, WDF request tokens) — the pad
drivers' logic is 100% safe Rust; whole drivers workspace clippy-gated in CI.

driver install --gamepad now sweeps SWD\punktfunk phantom devnodes: a re-created
SwDevice REVIVES the old devnode with its previously-bound driver (never
re-ranks), so an upgrade otherwise leaves the old driver serving — or, across
the v1→v2 fence, a dead pad (found live on the RTX box).

On-glass validated on the RTX 4090 box: frame path 7007 frames p50 2.06 ms
cross-machine; DualSense + XUSB "sealed pad channel mapped"/proto=2 attach via
both the test harness and a real streaming session; phantom-sweep repro.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:08:56 +00:00
enricobuehler a3e1ea2b44 fix(android/ci): retry transient Play API failures in play-upload.py
apple / swift (push) Successful in 1m9s
apple / screenshots (push) Successful in 4m2s
android / android (push) Successful in 11m51s
ci / web (push) Successful in 1m0s
ci / docs-site (push) Successful in 1m13s
ci / rust (push) Successful in 4m30s
deb / build-publish (push) Successful in 3m35s
ci / bench (push) Successful in 4m47s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
decky / build-publish (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m59s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 10m3s
docker / deploy-docs (push) Successful in 20s
The uploader only caught HTTPError — a URLError (TLS "EOF occurred in
violation of protocol", the failure that dropped two release uploads on
2026-07-02) or a Google 5xx killed the job outright. Retry those with
3/9/27 s backoff; 4xx still fails fast. The edits API is transactional
until commit, so re-sending is safe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:05:27 +00:00
enricobuehler 6686fcdded fix(gamestream/tests): sender_delivers_batches flaked under CI load — burst overflowed the default socket buffer
apple / swift (push) Successful in 1m12s
apple / screenshots (push) Successful in 4m26s
windows-host / package (push) Successful in 6m25s
ci / rust (push) Successful in 5m5s
ci / web (push) Successful in 51s
ci / docs-site (push) Successful in 1m4s
android / android (push) Failing after 10m7s
deb / build-publish (push) Successful in 3m35s
decky / build-publish (push) Successful in 21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
ci / bench (push) Successful in 4m38s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m53s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m53s
docker / deploy-docs (push) Successful in 18s
The test burst 3×100 1200 B datagrams into an undrained loopback socket: at
~2.5 KB kernel truesize each, the default ~212 KB rmem holds only ~80, so on
a starved CI runner (parallel release builds) the kernel silently dropped the
overflow and the recv loop could never reach 300 — surfacing as WouldBlock
after the 3 s timeout. Size the burst (3×20) to fit the default buffer even
with zero concurrent draining, and give recv a starvation-tolerant 10 s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:35:23 +00:00
enricobuehler 31c382fde0 chore(release): 0.5.1
audit / cargo-audit (push) Successful in 54s
apple / swift (push) Successful in 1m15s
ci / web (push) Successful in 57s
ci / docs-site (push) Successful in 1m1s
ci / bench (push) Successful in 4m40s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 40s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 46s
release / apple (push) Successful in 7m51s
windows-host / package (push) Successful in 6m46s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m7s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m10s
apple / screenshots (push) Successful in 4m4s
android-screenshots / screenshots (push) Successful in 1m14s
decky / build-publish (push) Successful in 15s
deb / build-publish (push) Successful in 3m25s
flatpak / build-publish (push) Successful in 4m20s
linux-client-screenshots / screenshots (push) Successful in 6m12s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m31s
web-screenshots / screenshots (push) Successful in 2m37s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m16s
docker / deploy-docs (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
ci / rust (push) Successful in 4m32s
android / android (push) Failing after 11m14s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:05:43 +00:00
enricobuehler d707ee4d4e feat(apple,android): three-way touch input — trackpad cursor (default), direct pointer, real multi-touch passthrough
android / android (push) Has been cancelled
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
release / apple (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
The two touch clients had exactly complementary gaps: iOS forwarded fingers
ONLY as raw wire touches (no way to drive the host cursor from the touch
screen), Android had the two mouse modes but no passthrough. Both now share
one three-way "Touch input" setting: Trackpad (default) / Direct pointer /
Touch passthrough.

iOS/iPadOS: Input/TouchMouse.swift ports the Android gesture engine 1:1
(same px-based acceleration curve; tap=click, two-finger tap=right-click,
two-finger drag=scroll, tap-then-drag=held drag, three-finger tap=stats
HUD via the shared hudEnabled default); direct-pointer mode maps through
the aspect-fit letterbox; the previous always-on behavior lives on as the
passthrough option. The mode latches per gesture (a Settings change never
splits one gesture across models), touchesCancelled releases held state
without synthesizing a click, and session stop flushes a mid-drag button.
Settings picker on iPhone + iPad next to the iPad-only pointer-capture
toggle. Deliberate default change: trackpad, not passthrough.

Android: new nativeSendTouch JNI shim → wire TouchDown/Move/Up (the host
already injects real touch on every backend — libei touchscreen, wlroots,
KWin fake-input, SendInput); streamTouchPassthrough forwards every finger
with stable ids and lifts still-held contacts on teardown; the trackpadMode
Boolean becomes the TouchMode enum (old pref migrated on load, never
rewritten) with a Settings dropdown.

Verified: macOS swift build + full suite (incl. new TouchMouseTests), iOS
Simulator Swift compile, cargo check/fmt/clippy on the native crate, Kotlin
app+kit compile + unit tests. On-glass feel of the iOS ballistics and
Android passthrough against a touch-aware app still pending.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 00:02:12 +02:00
enricobuehler e8196b33b8 feat(client/linux): Steam Deck batch — idle gamepad grab, fullscreen streams, in-band HDR colors, gamescope-safe settings, pad-pin persistence
windows-host / package (push) Successful in 6m41s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m5s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m6s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 47s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 54s
apple / swift (push) Successful in 1m17s
audit / cargo-audit (push) Successful in 17s
android / android (push) Successful in 3m46s
ci / web (push) Successful in 49s
ci / docs-site (push) Successful in 57s
release / apple (push) Successful in 8m41s
deb / build-publish (push) Has been cancelled
ci / bench (push) Successful in 4m39s
decky / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
apple / screenshots (push) Has been cancelled
ci / rust (push) Successful in 8m21s
Root-caused fixes from on-Deck testing (owner + first external tester):

- System input broke while the app was merely OPEN: SDL's Steam Deck HIDAPI
  driver clears the built-in controller's "lizard mode" (trackpad-mouse,
  clicky pads) at device ENUMERATION and keeps feeding the firmware watchdog
  (SDL_hidapi_steamdeck.c InitDevice/UpdateDevice) — and we enabled that
  driver at startup and held every pad open app-lifetime. The Valve HIDAPI
  hints are now enabled only while a session is attached, and only the active
  pad is opened (Settings enumerates via SDL's ID-based metadata getters, no
  open). Close/detach hands the hardware back; the watchdog restores lizard
  mode within seconds. This also unblocks click-to-capture on the Deck (the
  dead trackpad made "input not passed through" a symptom, not a cause).
- Washed-out colors from a Windows host with an HDR desktop: the host ships
  Main10 BT.2020 PQ IN-BAND (correct VUI) while the Welcome still says SDR;
  this client rendered everything as BT.709 narrow. Colour signaling is now
  read per-frame (video::ColorDesc from the AVFrame CICP fields) and drives
  the GdkDmabufTexture color state, the software path's swscale matrix/range
  plus a tagged MemoryTexture for PQ, and an "· HDR" HUD chip — GTK tone-maps
  correctly on SDR displays, mid-session SDR↔HDR flips included. Regression-
  tested against a checked-in Main10 PQ fixture (tests/pq-frame.h265).
- Streams start fullscreen by default (Settings toggle; F11 / the controller
  chord lead out, and the pointer at the top edge reveals the header while
  input isn't captured — a Deck desktop has no F11). Gaming-Mode launches
  (--fullscreen / Deck env) build the stream page with NO header bar at all:
  gamescope doesn't reliably ACK xdg_toplevel fullscreen, so anything keyed
  on is_fullscreen() could leave the title bar drawn over the stream.
- Game Mode settings were uneditable: GTK popovers are xdg_popups, which
  gamescope never maps for nested apps — every ComboRow dropdown flashed and
  died. Under gamescope the preferences dialog now uses in-window selection
  subpages (PreferencesDialog::push_subpage) via a ChoiceRow that stays a
  stock ComboRow on desktops. Covered by an in-process GTK test
  (choice_row_modes, #[ignore]d — needs a display).
- Forwarded-controller pin persists across restarts (Settings::forward_pad,
  stable vid:pid:name key — SDL instance ids are per-run) and survives
  disconnects; automatic selection skips Steam Input's sensor-less virtual
  pad (28de:11ff) so gyro doesn't silently die on Bazzite/Deck.
- "Punktfunk" branding in the About dialog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:37:43 +00:00
enricobuehler fd699b3e2c feat(decky): plugin overhaul — on-Deck update check, exec-bit-free runner, About/host-detail UI, Punktfunk branding
Fixes from live debugging on the Deck:

- check_update() was dead on-device: Decky Loader's embedded (PyInstaller)
  Python has no usable default CA paths, so every HTTPS fetch failed with
  CERTIFICATE_VERIFY_FAILED. Build the SSL context explicitly: default paths
  first, then the known system bundles (SteamOS/Arch, Debian, Fedora/Bazzite,
  openSUSE), then certifi if importable. Verification stays on; the check
  stays offline-tolerant with its 30-min cache.
- "could not chmod runner" on every use: Decky extracts plugin zips without
  exec bits into a root-owned dir the unprivileged backend can't chmod. The
  Steam shortcut now launches the runner through /bin/sh with the script as a
  %command% argument — no exec bit needed, existing shortcuts migrate on
  reuse, the chmod attempt is gone.

UI/structure:

- index.tsx (660 lines) split into page/pair/settings/hooks/boundary modules;
  PluginErrorBoundary kept guarding every surface.
- New About section/tab: visible version + channel, explicit check-for-updates
  (forces past the cache, always toasts an outcome), setup-guide link, leave-
  chord help, and a Force-stop backstop for a wedged stream.
- Host rows open a details modal (address, protocol, pairing policy, paired
  state, fingerprint). Settings gain 1280×800 (Deck native), Xbox One and
  DualShock 4 pad types, and a host-compositor picker.
- Update flows note the Decky store contact can stall a couple of minutes on
  networks that blackhole plugins.deckbrew.xyz (observed live).
- "Punktfunk" in all user-facing strings; plugin id/paths/env unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:37:43 +00:00
enricobuehler 79dd8f58e3 docs(readme): status refresh — Windows client streaming live, console features
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:37:43 +00:00
enricobuehler be879c946a fix(host/logs): mdns-sd noise gate + tracing-log target normalization in the log ring
log-crate events arrive through the tracing-log bridge under the shim target
"log" — normalize them back to the real module path (NormalizeEvent) so the
console's target column and the noise gate see mdns_sd::… , and suppress the
bridge's log.* bookkeeping fields like the stderr fmt layer does.

Gate known-chatty third-party DEBUG targets (mdns-sd DEBUG-logs every
unparseable multicast packet — one AirPlay device floods thousands of entries
per hour) to INFO-and-up in the ring, so ambient LAN noise can't evict the
tail the ring exists to preserve. stderr under RUST_LOG is unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:37:43 +00:00
enricobuehler f3646d4e7c feat(apple/gamepad): claim controller system gestures during capture — PS button opens the Steam overlay, share/create stops screenshotting locally
apple / swift (push) Successful in 1m6s
ci / rust (push) Successful in 2m1s
ci / web (push) Successful in 56s
android / android (push) Successful in 3m19s
ci / docs-site (push) Successful in 58s
deb / build-publish (push) Successful in 3m13s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m43s
release / apple (push) Successful in 8m1s
apple / screenshots (push) Successful in 5m33s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m23s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
While a pad drives a stream, GamepadCapture now sets EVERY element's
preferredSystemGestureState to .disabled (restored to .enabled on unbind).
iOS/macOS attach system gestures to several controller buttons — share/create
took a LOCAL screenshot instead of reaching the game, and only the Home
element was opted out before. With the gestures claimed, the already-wired
chains do their job: PS/Home → wire guide → BTN_MODE on the virtual xpad
(the Steam-overlay button) / the PS bit on the virtual DualSense.

Also fold the share/create/capture element (GCInputButtonShare) into the
back/select wire bit — clone pads like the GameSir G8 expose their screenshot
button only as the share element, not buttonOptions (OR onto the same bit, so
double-exposed pads are harmless). The G8's other extra button (M) is a
firmware-local modifier (turbo/hair-trigger/swap) invisible to the OS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:36:16 +02:00
enricobuehler 396c3453f5 feat(apple/gamepad): rewrite rumble renderer — bounded divergence + iOS 27 plain-player fix
apple / swift (push) Successful in 1m8s
ci / rust (push) Successful in 1m59s
ci / web (push) Successful in 51s
android / android (push) Successful in 3m44s
ci / docs-site (push) Successful in 1m3s
deb / build-publish (push) Successful in 3m11s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m47s
release / apple (push) Successful in 8m38s
apple / screenshots (push) Successful in 5m27s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m26s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m16s
Ground-up RumbleRenderer rewrite around one principle: rumble is idempotent
state on a lossy channel, and the actuator's divergence from it must be
bounded, not best-effort. The old renderer rebuilt an infinite-duration
CHHapticAdvancedPatternPlayer per 0xCA datagram via an async stop; one stop
lost inside CoreHaptics left an unstoppable player buzzing forever (the
"entered the menu and rumble never stopped" bug).

- Finite 4 s segments, never infinite events — a leaked player self-silences;
  steady levels re-arm seamlessly ON the engine timeline (no stop/start race)
- GamepadFeedback drains the rumble plane DRY per cycle, newest-wins (was one
  datagram per 8 ms through a 16-deep drop-newest queue = lag + shed stops)
- Host 500 ms state refreshes dedupe to a liveness stamp; zero applies
  immediately; nonzero ramps throttle to one rebake/25 ms per motor
- Throwing player stop escalates to engine.stop() (kills leaked players);
  1.6 s staleness watchdog (Policy.session) force-silences on a dead channel;
  the test panel holds levels via Policy.manual
- Plain makePlayer, NEVER makeAdvancedPlayer: gamecontrollerd's controller
  haptics server advertises `adv players: 0`, and iOS 27 beta 2 hard-drops
  advanced loads with an XPC decode fault (-4811/4097, rumble silently dead).
  Live-verified on an iOS 27 beta 2 iPhone: DualSense rumble works
- Split-handle engines fall back to one combined .default engine on repeated
  failure; renderer publishes health transitions and the test panel shows
  them (a refused system service no longer reads as silent app breakage)
- Per-motor sharpness on split handles (0.3 heavy / 0.7 light); macOS
  DualSense raw-HID path gains a ~1 s keepalive re-write while nonzero
- RumbleTuningTests pin the scheduling math, tuning relations, and a
  queue/ticker teardown smoke test

Stuck-rumble streaming repro revalidation on glass still pending.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:06:45 +02:00
enricobuehler 6921e147dd ci(release): idempotent registry publish — survive re-tagged releases
apple / swift (push) Successful in 1m3s
ci / rust (push) Successful in 2m2s
ci / web (push) Successful in 56s
android / android (push) Successful in 3m22s
ci / docs-site (push) Successful in 58s
apple / screenshots (push) Successful in 5m38s
deb / build-publish (push) Successful in 3m12s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m41s
flatpak / build-publish (push) Successful in 4m8s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m24s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m12s
docker / deploy-docs (push) Successful in 19s
A moved release tag re-fires the publish workflows, and the Gitea
registries reject duplicate uploads with 409 (deb pool, rpm group, and
the generic packages' versioned URLs; the channel aliases already
pre-deleted). Delete any prior copy of the exact version before
uploading (404 on first publish tolerated), so a republished tag
overwrites instead of wedging — v0.5.0's retag left stale no-port-change
artifacts published and every re-run red.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 19:23:04 +00:00
147 changed files with 8883 additions and 2446 deletions
+8
View File
@@ -126,6 +126,14 @@ jobs:
run: | run: |
for DEB in dist/*.deb; do for DEB in dist/*.deb; do
echo "uploading $DEB" echo "uploading $DEB"
# A re-tagged release re-fires this workflow and the apt registry 409s on duplicate
# package versions — delete any prior copy of this exact name/version/arch first
# (404 on the first publish is fine).
NAME=$(dpkg-deb -f "$DEB" Package)
VER=$(dpkg-deb -f "$DEB" Version)
ARCH=$(dpkg-deb -f "$DEB" Architecture)
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/$NAME/$VER/$ARCH" || true
# PAT owner (enricobuehler), not the push actor — matches docker.yml's registry login. # PAT owner (enricobuehler), not the push actor — matches docker.yml's registry login.
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$DEB" \ curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$DEB" \
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload" "https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload"
+7 -2
View File
@@ -122,8 +122,13 @@ jobs:
TOKEN: ${{ secrets.REGISTRY_TOKEN }} TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: | run: |
BASE="https://$REGISTRY/api/packages/$OWNER/generic/$PACKAGE" BASE="https://$REGISTRY/api/packages/$OWNER/generic/$PACKAGE"
# 1) Immutable, versioned URL + its update manifest (the manifest's `artifact` points # 1) Versioned URL + its update manifest (the manifest's `artifact` points here, so the
# here, so the published sha256 keeps matching what Decky later downloads). # published sha256 keeps matching what Decky later downloads). A re-tagged release
# re-fires this workflow and the registry 409s on duplicate uploads — delete any
# prior copy of this version first (404 on the first publish is fine).
for f in punktfunk.zip manifest.json; do
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE "$BASE/$VERSION/$f" || true
done
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/punktfunk.zip" \ curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/punktfunk.zip" \
"$BASE/$VERSION/punktfunk.zip" "$BASE/$VERSION/punktfunk.zip"
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/manifest.json" \ curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/manifest.json" \
+4 -1
View File
@@ -133,7 +133,10 @@ jobs:
TOKEN: ${{ secrets.REGISTRY_TOKEN }} TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: | run: |
BASE="https://$REGISTRY/api/packages/$OWNER/generic/$PACKAGE" BASE="https://$REGISTRY/api/packages/$OWNER/generic/$PACKAGE"
# 1) Immutable, versioned URL. # 1) Versioned URL. A re-tagged release re-fires this workflow and the registry 409s on
# duplicate uploads — delete any prior copy first (404 on the first publish is fine).
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
"$BASE/$VERSION/$BUNDLE" || true
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$BUNDLE" \ curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$BUNDLE" \
"$BASE/$VERSION/$BUNDLE" "$BASE/$VERSION/$BUNDLE"
echo "published $BASE/$VERSION/$BUNDLE" echo "published $BASE/$VERSION/$BUNDLE"
+8
View File
@@ -103,6 +103,14 @@ jobs:
for rpm in dist/*.rpm; do for rpm in dist/*.rpm; do
case "$rpm" in *debuginfo*|*debugsource*) echo "skip $rpm"; continue;; esac case "$rpm" in *debuginfo*|*debugsource*) echo "skip $rpm"; continue;; esac
echo "uploading $rpm" echo "uploading $rpm"
# A re-tagged release re-fires this workflow and the rpm registry 409s on duplicate
# package versions — delete any prior copy of this exact name/version-release/arch
# first (404 on the first publish is fine).
NAME=$(rpm -qp --qf '%{NAME}' "$rpm" 2>/dev/null)
VR=$(rpm -qp --qf '%{VERSION}-%{RELEASE}' "$rpm" 2>/dev/null)
ARCH=$(rpm -qp --qf '%{ARCH}' "$rpm" 2>/dev/null)
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
"https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/package/$NAME/$VR/$ARCH" || true
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$rpm" \ curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$rpm" \
"https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/upload" "https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/upload"
done done
+13 -3
View File
@@ -131,11 +131,21 @@ jobs:
# dispatched provisioning workflow landing on a different one. Path is relative to the job # dispatched provisioning workflow landing on a different one. Path is relative to the job
# working-directory (packaging/windows/drivers). Near-noop once the toolchain is present. # working-directory (packaging/windows/drivers). Near-noop once the toolchain is present.
run: ../../../scripts/ci/ensure-windows-toolchain.ps1 run: ../../../scripts/ci/ensure-windows-toolchain.ps1
- name: cargo build the driver workspace (wdk-probe + wdk-iddcx + pf-vdisplay) - name: cargo build the driver workspace (wdk-probe + wdk-iddcx + pf-vdisplay + gamepad drivers)
# Whole workspace: wdk-probe (toolchain/surface-assert probe) + wdk-iddcx (DDI wrappers) + # Whole workspace: wdk-probe (toolchain/surface-assert probe) + wdk-iddcx (DDI wrappers) +
# pf-vdisplay (the real IddCx driver). pf-vdisplay linking proves the IddCx call sites resolve # pf-vdisplay (the real IddCx driver) + pf-umdf-util (the safe UMDF primitive layer) + the two
# against IddCxStub end-to-end (M1 step 2 gate). # gamepad drivers. pf-vdisplay linking proves the IddCx call sites resolve against IddCxStub
# end-to-end (M1 step 2 gate); the gamepad drivers prove pf-umdf-util's WDF dispatch links.
run: cargo build -v run: cargo build -v
- name: cargo clippy the shipped drivers (-D warnings — enforces the unsafe-audit gates)
# The gamepad drivers' business logic is 100% safe (it moved onto pf-umdf-util, the audited
# unsafe layer); pf-vdisplay + wdk-iddcx are inherently FFI-bound but every `unsafe {}` carries a
# `// SAFETY:` proof. Both invariants are lint-gated (`unsafe_op_in_unsafe_fn` +
# `undocumented_unsafe_blocks`); this step keeps them from regressing. (wdk-probe is a
# toolchain-only probe crate and is excluded.)
run: cargo clippy -p pf-umdf-util -p pf-xusb -p pf-dualsense -p wdk-iddcx -p pf-vdisplay --all-targets -- -D warnings
- name: cargo fmt --check the safe-layer + gamepad drivers
run: cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense --check
- name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build - name: Inspect /INTEGRITYCHECK (before) — expect FORCE_INTEGRITY set by wdk-build
run: | run: |
# explicit --target (.cargo/config.toml) -> output under the triple subdir. # explicit --target (.cargo/config.toml) -> output under the triple subdir.
+13 -10
View File
@@ -23,8 +23,9 @@
# (import once to LocalMachine\TrustedPublisher). See packaging/windows/pack-host-installer.ps1. # (import once to LocalMachine\TrustedPublisher). See packaging/windows/pack-host-installer.ps1.
# #
# GPU backends: the host builds with --features nvenc,amf-qsv = all three vendors in one installer. # GPU backends: the host builds with --features nvenc,amf-qsv = all three vendors in one installer.
# - NVENC (NVIDIA, direct SDK): the only link need is nvencodeapi.lib, synthesised from a 2-export # - NVENC (NVIDIA, direct SDK): nothing needed at build time — the entry points are resolved at
# .def with llvm-dlltool (no GPU/SDK at build time). # RUNTIME from the driver's nvEncodeAPI64.dll (a link-time import would kill the binary on
# AMD/Intel-only boxes before main).
# - AMF/QSV (AMD/Intel, libavcodec): link-imports the FFmpeg libs from FFMPEG_DIR (the BtbN lgpl-shared # - AMF/QSV (AMD/Intel, libavcodec): link-imports the FFmpeg libs from FFMPEG_DIR (the BtbN lgpl-shared
# tree the client uses; includes the *_amf/*_qsv encoders) and bundles its DLLs into the installer. # tree the client uses; includes the *_amf/*_qsv encoders) and bundles its DLLs into the installer.
# lgpl-shared (not gpl-shared) keeps those bundled DLLs LGPL (we never use the GPL-only x264/x265). # lgpl-shared (not gpl-shared) keeps those bundled DLLs LGPL (we never use the GPL-only x264/x265).
@@ -37,6 +38,7 @@ on:
paths: paths:
- 'crates/punktfunk-host/**' - 'crates/punktfunk-host/**'
- 'crates/punktfunk-core/**' - 'crates/punktfunk-core/**'
- 'crates/punktfunk-tray/**'
- 'packaging/windows/**' - 'packaging/windows/**'
- 'scripts/windows/**' - 'scripts/windows/**'
- 'web/**' - 'web/**'
@@ -109,21 +111,22 @@ jobs:
"PUNKTFUNK_BUILD_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 "PUNKTFUNK_BUILD_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
Write-Output "host version $v" Write-Output "host version $v"
- name: Generate NVENC import lib
shell: pwsh
run: |
& packaging/windows/nvenc/gen-nvenc-importlib.ps1 -OutDir C:\t\nvenc
"PUNKTFUNK_NVENC_LIB_DIR=C:\t\nvenc" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
- name: Build (release, nvenc + amf-qsv) - name: Build (release, nvenc + amf-qsv)
shell: pwsh shell: pwsh
# All-vendor host: NVENC (NVIDIA, direct SDK) + AMF/QSV (AMD/Intel, libavcodec via FFMPEG_DIR). # All-vendor host: NVENC (NVIDIA, direct SDK) + AMF/QSV (AMD/Intel, libavcodec via FFMPEG_DIR).
run: cargo build --release -p punktfunk-host --features nvenc,amf-qsv run: cargo build --release -p punktfunk-host --features nvenc,amf-qsv
- name: Clippy (host, Windows) - name: Build (release, status tray)
shell: pwsh
# The per-user notification-area companion the installer bundles (punktfunk-tray.exe).
run: cargo build --release -p punktfunk-tray
- name: Clippy (host + tray, Windows)
shell: pwsh shell: pwsh
# First-ever Windows lint coverage for the host (Linux CI never lints the windows-cfg code). # First-ever Windows lint coverage for the host (Linux CI never lints the windows-cfg code).
run: cargo clippy -p punktfunk-host --features nvenc,amf-qsv -- -D warnings run: |
cargo clippy -p punktfunk-host --features nvenc,amf-qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" }
cargo clippy -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" }
- name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer) - name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
shell: pwsh shell: pwsh
+76 -7
View File
@@ -100,16 +100,39 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
work with no kernel bus driver (validated live: slot connected, state + rumble round-trip; Xbox One work with no kernel bus driver (validated live: slot connected, state + rumble round-trip; Xbox One
folds to this 360 path). All three UMDF drivers (DualSense/DS4 + XUSB) are built from source in CI folds to this 360 path). All three UMDF drivers (DualSense/DS4 + XUSB) are built from source in CI
(`packaging/windows/drivers/`) and installed by the Inno Setup installer via (`packaging/windows/drivers/`) and installed by the Inno Setup installer via
`punktfunk-host.exe driver install --gamepad`. `punktfunk-host.exe driver install --gamepad`. The gamepad drivers' **business logic is 100 % safe
Rust**: every raw shared-memory / sealed-channel / WDF-request operation lives behind
`pf-umdf-util` (the audited unsafe layer — `section::MappedView` checked accessors, the
`#![forbid(unsafe_code)]` `channel::ChannelClient` state machine, `wdf::Request` tokens), so a
memory-safety bug can only live in that one small crate. The whole drivers workspace is lint-gated
(`deny(unsafe_op_in_unsafe_fn)` + `deny(clippy::undocumented_unsafe_blocks)`) with a
`cargo clippy -D warnings` step in `windows-drivers.yml`; pf-vdisplay stays FFI-bound (D3D11/IddCx)
but every `unsafe {}` there now carries a `// SAFETY:` proof (unsafe-audited, not unsafe-free).
**Multi-pad ready**: the host stamps each pad's index into the device Location (`pszDeviceLocation`), **Multi-pad ready**: the host stamps each pad's index into the device Location (`pszDeviceLocation`),
the driver reads it (`WdfDeviceAllocAndQueryProperty`) to map its own `*-shm-<index>`, and the driver reads it (`WdfDeviceAllocAndQueryProperty`) to map its own `*-shm-<index>`, and
`UmdfHostProcessSharing=ProcessSharingDisabled` gives each pad its own host (per-pad statics) — `UmdfHostProcessSharing=ProcessSharingDisabled` gives each pad its own host (per-pad statics) —
validated live with 2 distinct XInput slots + 2 DualSense pads. (Client-side multi-pad forwarding is validated live with 2 distinct XInput slots + 2 DualSense pads. (Client-side multi-pad forwarding is
the remaining piece.) the remaining piece.)
- **Windows host: implemented and shipping (all-vendor, x64-only).** `#[cfg(windows)]` backends - **Windows host: implemented and shipping (all-vendor, x64-only, Windows 11 22H2+).** The OS floor
is HARD: pf-vdisplay is built against IddCx 1.10 (1.10 stub + HDR `*2` DDIs + FP16 caps, no runtime
downgrade) — on Windows 10 (incl. LTSC) / Win11 21H2 the driver installs but the device fails start
with Code 10 `STATUS_DEVICE_POWER_FAILURE` (field-reported 2026-07); the installer gates on
`MinVersion=10.0.22621`. `#[cfg(windows)]` backends
behind the same traits as Linux — **IDD-push capture** straight into the in-house all-Rust IddCx behind the same traits as Linux — **IDD-push capture** straight into the in-house all-Rust IddCx
**pf-vdisplay** virtual display (`capture/windows/idd_push.rs`, `vdisplay/windows/pf_vdisplay.rs`; **pf-vdisplay** virtual display (`capture/windows/idd_push.rs`, `vdisplay/windows/pf_vdisplay.rs`;
DXGI Desktop Duplication / WGC as fallbacks, `capture/windows/dxgi.rs`), GPU encode (NVENC DXGI Desktop Duplication / WGC as fallbacks, `capture/windows/dxgi.rs`). The host↔driver frame
ring is a **sealed channel** (proto v2, `design/idd-push-security.md`): all shared objects
UNNAMED, handles `DuplicateHandle`d into the driver's WUDFHost and delivered as values over
`IOCTL_SET_FRAME_CHANNEL` (SY+BA-only control device) — only the two endpoint processes can ever
reach a frame (DDA's isolation property in user mode; adopt-on-success handle-ownership contract,
newest-delivery-wins re-attach). *Sealed channel: CI-pending + on-glass revalidation pending.*
The **gamepad SHM channels are sealed the same way** (gamepad proto v2,
`design/gamepad-channel-sealing.md`): the pad DATA sections (`XusbShm`/`PadShm`, now with a
driver-validated `pad_index`) are unnamed + handle-duplicated into the pad WUDFHost
(`gamepad_raii.rs` `PadChannel`); since the HID minidrivers have no control device, the handshake
runs over a tiny named bootstrap mailbox (`Global\pf…-boot-<i>`, pid + handle value only — tampering
is DoS-bounded). *Sealed pad channel: needs both pad drivers redeployed with the host, physical-pad
validation pending.* GPU encode (NVENC
`--features nvenc`; AMD/Intel `--features amf-qsv`), SendInput + the in-house UMDF gamepad drivers `--features nvenc`; AMD/Intel `--features amf-qsv`), SendInput + the in-house UMDF gamepad drivers
(`inject/windows/`), WASAPI loopback + virtual mic (`audio/windows/wasapi_*`). **Keyboard wire (`inject/windows/`), WASAPI loopback + virtual mic (`audio/windows/wasapi_*`). **Keyboard wire
convention: US-positional VKs** (every first-party client sends the physical key's US-layout VK; convention: US-positional VKs** (every first-party client sends the physical key's US-layout VK;
@@ -155,6 +178,25 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
HKLM-registered by the installer. **Live-validated: Doom: The Dark Ages enables HDR over the virtual HKLM-registered by the installer. **Live-validated: Doom: The Dark Ages enables HDR over the virtual
display.** **AMF/QSV is CI-green but not yet on-glass validated** (no AMD/Intel Windows box in the display.** **AMF/QSV is CI-green but not yet on-glass validated** (no AMD/Intel Windows box in the
lab); NVENC is live-validated. Newer/less battle-tested than the Linux host. Packaging: `packaging/windows/`. lab); NVENC is live-validated. Newer/less battle-tested than the Linux host. Packaging: `packaging/windows/`.
- **Status tray (`crates/punktfunk-tray`, Windows + Linux).** A small per-user companion binary
showing the host service state at a glance (running / stopped / starting / degraded / failed +
streaming session in the tooltip) with one-click actions: open web console, approve-pairing
shortcut, start/stop/restart, open logs, exit. Status precedence is **service manager first**
(SCM / systemd user unit — a port-squatter can't fake Running), then the new **loopback-only
unauthenticated** `GET /api/v1/local/summary` (counts/booleans only — no PINs/fingerprints/names;
gated in `require_auth` by peer address, needed because `mgmt-token`/`cert.pem` are
SYSTEM/Admins-DACL'd on Windows so a non-elevated tray cannot bearer-auth). Windows:
`#![windows_subsystem = "windows"]` hidden-window + `Shell_NotifyIconW` (per-session `Local\`
mutex, TaskbarCreated re-add, `--quit` for the uninstaller), actions elevate per click via
`ShellExecuteW "runas"` on `punktfunk-host.exe service start|stop|restart` (new `service restart`
subcommand: stop → wait Stopped → start); installed by the Inno `trayicon` task (HKLM Run key).
Linux: ksni (SNI over zbus, `async-io`+`blocking` features), `systemctl --user` actions (no
polkit), `/etc/xdg/autostart` entry whose `--autostart` self-gates (silent exit unless
`~/.config/punktfunk` exists or the unit is enabled); deb/rpm/arch ship binary + autostart +
hicolor icons. Icons generated by `scripts/gen-tray-icons.py` (pure-stdlib; committed .ico/.png,
brand lens + status dot). *Linux live-validated on the headless KDE session (SNI registration,
stop/start transitions, menu-driven start, dbusmenu layout); Windows code MSVC-cross-type-checked
+ clippy-clean but real Windows CI build + on-glass validation pending.*
## What's left ## What's left
@@ -168,11 +210,26 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
controller discovery + selection in Settings (`GamepadManager` — exactly one pad controller discovery + selection in Settings (`GamepadManager` — exactly one pad
forwarded as pad 0, auto or pinned; pad TYPE auto-resolves from the physical forwarded as pad 0, auto or pinned; pad TYPE auto-resolves from the physical
controller, user-overridable), capture incl. DualSense touchpad/motion controller, user-overridable), capture incl. DualSense touchpad/motion
(`GamepadCapture`/`GamepadWire`), feedback rendering (rumble → CoreHaptics; lightbar / (`GamepadCapture`/`GamepadWire`; while streaming, EVERY element's
`preferredSystemGestureState` is claimed `.disabled` — share/create reaches the host as
select instead of screenshotting locally, PS/Home reaches the host as guide/`BTN_MODE` =
the Steam-overlay button — restored `.enabled` on unbind), feedback rendering (rumble → CoreHaptics; lightbar /
player LEDs / adaptive triggers → `GCDeviceLight`/`playerIndex`/ player LEDs / adaptive triggers → `GCDeviceLight`/`playerIndex`/
`GCDualSenseAdaptiveTrigger` via the table-driven `DualSenseTriggerEffect` parser). `GCDualSenseAdaptiveTrigger` via the table-driven `DualSenseTriggerEffect` parser).
Loopback-tested end to end (`PUNKTFUNK_TEST_FEEDBACK=1` scripted burst); DualSense Loopback-tested end to end (`PUNKTFUNK_TEST_FEEDBACK=1` scripted burst); DualSense
motion sign/scale derived, not yet live-verified. **Gamepad UI (iOS/iPadOS + macOS, motion sign/scale derived, not yet live-verified. **Rumble renderer rewritten
(2026-07-02, `RumbleRenderer.swift`)** around "rumble is idempotent state, divergence
must be bounded": the old per-datagram infinite-duration CoreHaptics players could leak
one dropped async `stop` into a forever-buzzing motor (the stuck-rumble-after-menu bug)
— now finite self-expiring segments with seamless engine-timeline re-arm, newest-wins
dry drain of the 0xCA plane (was 1 datagram/8 ms), dedupe of the host's 500 ms state
refreshes, zero-immediate/ramp-throttled rebakes, escalation to `engine.stop()` on a
throwing player stop, and a 1.6 s staleness watchdog (`Policy.session`; the settings
test panel uses `.manual` = hold). Controller engines use **plain `makePlayer` — never
`makeAdvancedPlayer`**: the controller haptics server (gamecontrollerd) advertises
`adv players: 0`, and iOS 27 beta 2 hard-drops advanced-player loads (XPC decode fault →
CoreHaptics -4811/4097, rumble silently dead). Unit-tested (`RumbleTuningTests`);
stuck-rumble repro on-glass revalidation pending. **Gamepad UI (iOS/iPadOS + macOS,
2026-07-02 rework):** a connected pad swaps the home for a console-style launcher 2026-07-02 rework):** a connected pad swaps the home for a console-style launcher
(`Home/Gamepad*` + `Settings/GamepadSettingsView`) — host carousel with a trailing Add (`Home/Gamepad*` + `Settings/GamepadSettingsView`) — host carousel with a trailing Add
Host tile (A connect · Y library · X settings · B back), a controller-navigable Host tile (A connect · Y library · X settings · B back), a controller-navigable
@@ -189,7 +246,13 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
"always show scroll bars" overrides `.hidden`); launcher/settings/add-host/keyboard "always show scroll bars" overrides `.hidden`); launcher/settings/add-host/keyboard
render-verified live on this Mac via `PUNKTFUNK_FORCE_GAMEPAD_UI=1` (dev hook, forces render-verified live on this Mac via `PUNKTFUNK_FORCE_GAMEPAD_UI=1` (dev hook, forces
the mode without a pad). Controller-in-hand on-glass validation still pending on all the mode without a pad). Controller-in-hand on-glass validation still pending on all
platforms. Tests: `swift test` in platforms. **Touch input (iOS/iPadOS, 2026-07-02):** a 3-way model in Settings —
**Trackpad** (default; the Android client's gesture vocabulary ported 1:1 in
`Input/TouchMouse.swift`: tap=click · two-finger tap=right-click · two-finger drag=scroll ·
tap-then-drag=held drag · three-finger tap=HUD toggle, relative ballistics with the same
px-based acceleration curve), **Direct pointer** (cursor jumps to the finger), **Touch
passthrough** (the previous always-on behavior — real wire touches). Latched per gesture
from `DefaultsKey.touchMode`; not yet on-glass validated. Tests: `swift test` in
`clients/apple` (unit + real-codec round trip), `clients/apple` (unit + real-codec round trip),
`test-loopback.sh` (Swift client vs synthetic punktfunk1-hosts on loopback — runs on macOS; `test-loopback.sh` (Swift client vs synthetic punktfunk1-hosts on loopback — runs on macOS;
includes the pairing ceremony + `--require-pairing` gate), includes the pairing ceremony + `--require-pairing` gate),
@@ -335,7 +398,11 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
the `MulticastLock` + permission UX), SPAKE2 PIN pairing + TOFU (Keystore identity + the `MulticastLock` + permission UX), SPAKE2 PIN pairing + TOFU (Keystore identity +
known-host store), Compose UI (Connect/Settings/Stream) with D-pad/controller focus nav. Built for known-host store), Compose UI (Connect/Settings/Stream) with D-pad/controller focus nav. Built for
`arm64-v8a` + `x86_64`; published to Google Play (Internal Testing) via `android.yml` `arm64-v8a` + `x86_64`; published to Google Play (Internal Testing) via `android.yml`
(`ci/play-upload.py`). Next: real-device gamepad/HDR live-verify, presenter/latency polish. (`ci/play-upload.py`). Touch input is the same 3-way model as iOS (2026-07-02): the existing
Trackpad/Direct mouse modes plus new **real multi-touch passthrough**
(`streamTouchPassthrough``nativeSendTouch` → wire TouchDown/Move/Up), a `TouchMode`
Settings dropdown replacing the old trackpad Boolean (migrated on load); not yet
on-device validated. Next: real-device gamepad/HDR live-verify, presenter/latency polish.
2. **Sub-frame pipelining**: overlap encode and transmit within a frame. Requires a direct 2. **Sub-frame pipelining**: overlap encode and transmit within a frame. Requires a direct
NVENC SDK wrapper (libavcodec only emits whole AUs) — the next big latency lever (~24 ms NVENC SDK wrapper (libavcodec only emits whole AUs) — the next big latency lever (~24 ms
at high res). at high res).
@@ -422,6 +489,7 @@ crates/punktfunk-host/
capture/{linux/,windows/{dxgi,idd_push}}.rs · audio/{linux/,windows/wasapi_*}.rs capture/{linux/,windows/{dxgi,idd_push}}.rs · audio/{linux/,windows/wasapi_*}.rs
windows/{service,install,interactive}.rs SCM service + in-binary driver/web install windows/{service,install,interactive}.rs SCM service + in-binary driver/web install
capture.rs · encode.rs · audio.rs · gpu.rs · spike.rs · punktfunk1.rs · mgmt.rs · native_pairing.rs · stats_recorder.rs · library.rs capture.rs · encode.rs · audio.rs · gpu.rs · spike.rs · punktfunk1.rs · mgmt.rs · native_pairing.rs · stats_recorder.rs · library.rs
crates/punktfunk-tray/ per-user status tray (Win32 Shell_NotifyIcon · Linux ksni/SNI); icons via scripts/gen-tray-icons.py
clients/probe/ punktfunk/1 reference/probe client (headless test/measurement tool) clients/probe/ punktfunk/1 reference/probe client (headless test/measurement tool)
clients/linux/ native Linux client (GTK4/libadwaita · FFmpeg · PipeWire · SDL3) clients/linux/ native Linux client (GTK4/libadwaita · FFmpeg · PipeWire · SDL3)
clients/windows/ native Windows client (WinUI 3 via windows-reactor · D3D11 · WASAPI · SDL3) clients/windows/ native Windows client (WinUI 3 via windows-reactor · D3D11 · WASAPI · SDL3)
@@ -429,6 +497,7 @@ clients/apple/ native macOS/iOS/tvOS client (Swift · VideoToolbox · GameCon
clients/android/ native Android client (Kotlin app + native/ Rust JNI core over punktfunk-core) clients/android/ native Android client (Kotlin app + native/ Rust JNI core over punktfunk-core)
clients/decky/ Steam Deck Decky plugin clients/decky/ Steam Deck Decky plugin
packaging/windows/drivers/{pf-vdisplay,pf-dualsense,pf-xusb}/ in-house UMDF drivers (built from source in CI) packaging/windows/drivers/{pf-vdisplay,pf-dualsense,pf-xusb}/ in-house UMDF drivers (built from source in CI)
packaging/windows/drivers/pf-umdf-util/ audited unsafe layer (safe shm + sealed-channel + WDF request primitives) — gamepad drivers' logic is 100% safe over it
web/ TanStack web console over the mgmt API (status · devices · pairing · GPU selection · performance graphs) web/ TanStack web console over the mgmt API (status · devices · pairing · GPU selection · performance graphs)
packaging/ apt(deb) · RPM/COPR · Arch/sysext · Flatpak · Bazzite bootc · Windows host installer (per-dir READMEs) packaging/ apt(deb) · RPM/COPR · Arch/sysext · Flatpak · Bazzite bootc · Windows host installer (per-dir READMEs)
tools/{loss-harness,latency-probe}/ measurement (plan §10) tools/{loss-harness,latency-probe}/ measurement (plan §10)
Generated
+179 -8
View File
@@ -228,6 +228,67 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "async-executor"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
dependencies = [
"async-task",
"concurrent-queue",
"fastrand",
"futures-lite",
"pin-project-lite",
"slab",
]
[[package]]
name = "async-io"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
dependencies = [
"autocfg",
"cfg-if",
"concurrent-queue",
"futures-io",
"futures-lite",
"parking",
"polling",
"rustix",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-lock"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
dependencies = [
"event-listener",
"event-listener-strategy",
"pin-project-lite",
]
[[package]]
name = "async-process"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
dependencies = [
"async-channel",
"async-io",
"async-lock",
"async-signal",
"async-task",
"blocking",
"cfg-if",
"event-listener",
"futures-lite",
"rustix",
]
[[package]] [[package]]
name = "async-recursion" name = "async-recursion"
version = "1.1.1" version = "1.1.1"
@@ -239,6 +300,30 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "async-signal"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
dependencies = [
"async-io",
"async-lock",
"atomic-waker",
"cfg-if",
"futures-core",
"futures-io",
"rustix",
"signal-hook-registry",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-task"
version = "4.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.89" version = "0.1.89"
@@ -434,6 +519,19 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "blocking"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
dependencies = [
"async-channel",
"async-task",
"futures-io",
"futures-lite",
"piper",
]
[[package]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.20.3" version = "3.20.3"
@@ -2002,9 +2100,26 @@ dependencies = [
"libloading", "libloading",
] ]
[[package]]
name = "ksni"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da9eeb3f510b6148ae68f963af2c1fbb0de4d9e4e05f82813cfb319837c3ad2b"
dependencies = [
"async-executor",
"async-io",
"async-lock",
"futures-channel",
"futures-lite",
"futures-util",
"pastey",
"serde",
"zbus",
]
[[package]] [[package]]
name = "latency-probe" name = "latency-probe"
version = "0.5.0" version = "0.6.0"
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
@@ -2136,7 +2251,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]] [[package]]
name = "loss-harness" name = "loss-harness"
version = "0.5.0" version = "0.6.0"
dependencies = [ dependencies = [
"punktfunk-core", "punktfunk-core",
] ]
@@ -2561,6 +2676,12 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pastey"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]] [[package]]
name = "pem" name = "pem"
version = "3.0.6" version = "3.0.6"
@@ -2599,6 +2720,17 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "piper"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
dependencies = [
"atomic-waker",
"fastrand",
"futures-io",
]
[[package]] [[package]]
name = "pipewire" name = "pipewire"
version = "0.9.2" version = "0.9.2"
@@ -2654,6 +2786,20 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "polling"
version = "3.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi",
"pin-project-lite",
"rustix",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "polyval" name = "polyval"
version = "0.6.2" version = "0.6.2"
@@ -2729,7 +2875,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-android" name = "punktfunk-client-android"
version = "0.5.0" version = "0.6.0"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"jni", "jni",
@@ -2743,7 +2889,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-linux" name = "punktfunk-client-linux"
version = "0.5.0" version = "0.6.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -2765,7 +2911,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-windows" name = "punktfunk-client-windows"
version = "0.5.0" version = "0.6.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -2788,7 +2934,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-core" name = "punktfunk-core"
version = "0.5.0" version = "0.6.0"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"bytes", "bytes",
@@ -2818,7 +2964,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-host" name = "punktfunk-host"
version = "0.5.0" version = "0.6.0"
dependencies = [ dependencies = [
"aes", "aes",
"aes-gcm", "aes-gcm",
@@ -2839,6 +2985,7 @@ dependencies = [
"khronos-egl", "khronos-egl",
"libc", "libc",
"libloading", "libloading",
"log",
"mdns-sd", "mdns-sd",
"nvidia-video-codec-sdk", "nvidia-video-codec-sdk",
"openh264", "openh264",
@@ -2863,6 +3010,7 @@ dependencies = [
"tokio-rustls", "tokio-rustls",
"tower", "tower",
"tracing", "tracing",
"tracing-log",
"tracing-subscriber", "tracing-subscriber",
"ureq", "ureq",
"usbip-sim", "usbip-sim",
@@ -2885,7 +3033,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-probe" name = "punktfunk-probe"
version = "0.5.0" version = "0.6.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"mdns-sd", "mdns-sd",
@@ -2897,6 +3045,23 @@ dependencies = [
"tracing-subscriber", "tracing-subscriber",
] ]
[[package]]
name = "punktfunk-tray"
version = "0.6.0"
dependencies = [
"anyhow",
"ksni",
"libc",
"rustls",
"serde",
"serde_json",
"sha2",
"ureq",
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
"windows-service",
"winresource",
]
[[package]] [[package]]
name = "quick-error" name = "quick-error"
version = "1.2.3" version = "1.2.3"
@@ -5219,8 +5384,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285"
dependencies = [ dependencies = [
"async-broadcast", "async-broadcast",
"async-executor",
"async-io",
"async-lock",
"async-process",
"async-recursion", "async-recursion",
"async-task",
"async-trait", "async-trait",
"blocking",
"enumflags2", "enumflags2",
"event-listener", "event-listener",
"futures-core", "futures-core",
+2 -1
View File
@@ -4,6 +4,7 @@ members = [
"crates/punktfunk-core", "crates/punktfunk-core",
"crates/punktfunk-host", "crates/punktfunk-host",
"crates/punktfunk-host/vendor/usbip-sim", "crates/punktfunk-host/vendor/usbip-sim",
"crates/punktfunk-tray",
"crates/pf-driver-proto", "crates/pf-driver-proto",
"clients/probe", "clients/probe",
"clients/linux", "clients/linux",
@@ -16,7 +17,7 @@ members = [
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"] exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
[workspace.package] [workspace.package]
version = "0.5.0" version = "0.6.0"
edition = "2021" edition = "2021"
rust-version = "1.82" rust-version = "1.82"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
+5 -5
View File
@@ -49,12 +49,12 @@ protocol, FEC, and crypto, linked into the host and every client over a stable C
| **Core**`punktfunk-core` + C ABI (protocol · FEC · crypto · QUIC) | ✅ Complete & hardened | | **Core**`punktfunk-core` + C ABI (protocol · FEC · crypto · QUIC) | ✅ Complete & hardened |
| **GameStream host** → stock Moonlight | ✅ Live end-to-end: pairing, RTSP, audio, per-client virtual output at native resolution, GPU zero-copy NVENC, gamepads | | **GameStream host** → stock Moonlight | ✅ Live end-to-end: pairing, RTSP, audio, per-client virtual output at native resolution, GPU zero-copy NVENC, gamepads |
| **Native protocol**`punktfunk/1` | ✅ Validated live: QUIC control + GF(2¹⁶) FEC/AES-GCM data plane, PIN pairing, mDNS discovery, mid-stream mode renegotiation | | **Native protocol**`punktfunk/1` | ✅ Validated live: QUIC control + GF(2¹⁶) FEC/AES-GCM data plane, PIN pairing, mDNS discovery, mid-stream mode renegotiation |
| **Windows host** (x64) | 🟡 Implemented & shipping as a signed installer: DXGI/WGC capture · its own all-Rust IddCx **virtual display** (secure-desktop capable) · GPU encode (NVENC on NVIDIA, AMF/QSV on AMD/Intel, software H.264 without a GPU) · WASAPI audio · bundled virtual-gamepad drivers (no ViGEmBus) · HDR incl. Vulkan-game HDR. NVIDIA live-validated; AMD/Intel CI-green | | **Windows host** (Windows 11 22H2+, x64) | 🟡 Implemented & shipping as a signed installer: DXGI/WGC capture · its own all-Rust IddCx **virtual display** (secure-desktop capable) · GPU encode (NVENC on NVIDIA, AMF/QSV on AMD/Intel, software H.264 without a GPU) · WASAPI audio · bundled virtual-gamepad drivers (no ViGEmBus) · HDR incl. Vulkan-game HDR. NVIDIA live-validated; AMD/Intel CI-green |
| **macOS / iOS / tvOS client** (`clients/apple`) | ✅ Streaming live: VideoToolbox decode, controllers incl. DualSense, discovery, pairing, speed test | | **macOS / iOS / tvOS client** (`clients/apple`) | ✅ Streaming live: VideoToolbox decode, controllers incl. DualSense, discovery, pairing, speed test |
| **Linux client** (`clients/linux`, GTK4) | ✅ Streaming live: FFmpeg + VAAPI zero-copy decode, PipeWire audio, SDL3 controllers; ships as Flatpak/apt/rpm/Arch | | **Linux client** (`clients/linux`, GTK4) | ✅ Streaming live: FFmpeg + VAAPI zero-copy decode, PipeWire audio, SDL3 controllers; ships as Flatpak/apt/rpm/Arch |
| **Android client** (`clients/android`, phone + TV) | ✅ Streaming live: AMediaCodec decode + HDR10, AAudio audio, controllers, discovery, pairing | | **Android client** (`clients/android`, phone + TV) | ✅ Streaming live: AMediaCodec decode + HDR10, AAudio audio, controllers, discovery, pairing |
| **Windows client** (`clients/windows`, WinUI 3) | 🟡 Stage 1 complete, ships as signed MSIX (x64 + ARM64); D3D11VA decode + HDR present pending on-glass validation | | **Windows client** (`clients/windows`, WinUI 3) | Streaming live: D3D11VA hardware decode on all GPU vendors (NVIDIA + Intel validated on glass) with software fallback, WASAPI audio, SDL3 controllers, discovery, pairing; ships as signed MSIX (x64 + ARM64). HDR10 implemented, on-glass validation pending |
| **Web console + management API** (`web/`) | ✅ TanStack console over the OpenAPI mgmt API: host status, paired devices, on-demand PIN pairing | | **Web console + management API** (`web/`) | ✅ TanStack console over the OpenAPI mgmt API: host status, paired devices, on-demand PIN pairing, GPU selection, performance capture graphs, live host logs |
The **GameStream host works with a stock Moonlight client** — validated live on NVIDIA hardware The **GameStream host works with a stock Moonlight client** — validated live on NVIDIA hardware
(RTX 5070 Ti, RTX 4090): PIN pairing that persists across restarts, an app catalog, RTSP/ENet/audio, (RTX 5070 Ti, RTX 4090): PIN pairing that persists across restarts, an app catalog, RTSP/ENet/audio,
@@ -82,7 +82,7 @@ Windows host also ships as a signed installer (all-vendor: NVIDIA, AMD, Intel).
| **Ubuntu / Debian** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu — GNOME](https://docs.punktfunk.unom.io/docs/ubuntu-gnome) · [KDE](https://docs.punktfunk.unom.io/docs/ubuntu-kde) | | **Ubuntu / Debian** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu — GNOME](https://docs.punktfunk.unom.io/docs/ubuntu-gnome) · [KDE](https://docs.punktfunk.unom.io/docs/ubuntu-kde) |
| **Fedora / Bazzite** (rpm-ostree) | `rpm-ostree install punktfunk punktfunk-web` *(or the bootc image)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) · [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) | | **Fedora / Bazzite** (rpm-ostree) | `rpm-ostree install punktfunk punktfunk-web` *(or the bootc image)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) · [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
| **Arch / Steam Deck** (PKGBUILD / sysext) | `makepkg -si` *(Arch)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) | | **Arch / Steam Deck** (PKGBUILD / sysext) | `makepkg -si` *(Arch)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) |
| **Windows** (x64) | signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) | | **Windows** (11 22H2+, x64) | signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) |
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status). `punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status).
After install, run `punktfunk-host serve` inside your desktop session (the secure native default; After install, run `punktfunk-host serve` inside your desktop session (the secure native default;
@@ -135,7 +135,7 @@ clients/
android/ Android phone + TV app (Kotlin · Rust JNI core · AMediaCodec · AAudio) android/ Android phone + TV app (Kotlin · Rust JNI core · AMediaCodec · AAudio)
probe/ headless reference / measurement client for punktfunk/1 probe/ headless reference / measurement client for punktfunk/1
decky/ Steam Deck Decky plugin decky/ Steam Deck Decky plugin
web/ web console (TanStack) over the management API — status · devices · pairing web/ web console (TanStack) over the management API — status · devices · pairing · GPUs · performance · logs
packaging/ apt · rpm / COPR · Arch · Flatpak · Bazzite bootc image packaging/ apt · rpm / COPR · Arch · Flatpak · Bazzite bootc image
docs-site/ public documentation site (Fumadocs) — https://docs.punktfunk.unom.io docs-site/ public documentation site (Fumadocs) — https://docs.punktfunk.unom.io
design/ design notes & deep-dive plans (index: design/README.md) design/ design notes & deep-dive plans (index: design/README.md)
+96 -1
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0", "name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0" "identifier": "MIT OR Apache-2.0"
}, },
"version": "0.5.0" "version": "0.6.0"
}, },
"paths": { "paths": {
"/api/v1/clients": { "/api/v1/clients": {
@@ -578,6 +578,41 @@
} }
} }
}, },
"/api/v1/local/summary": {
"get": {
"tags": [
"host"
],
"summary": "Local status summary for the tray icon",
"description": "Non-sensitive status (counts and booleans only — no PIN values, no fingerprints, no device\nnames). Unauthenticated, but served to loopback peers only.",
"operationId": "getLocalSummary",
"responses": {
"200": {
"description": "Non-sensitive local host status (loopback peers only)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LocalSummary"
}
}
}
},
"401": {
"description": "Non-loopback peer",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
},
"security": [
{}
]
}
},
"/api/v1/logs": { "/api/v1/logs": {
"get": { "get": {
"tags": [ "tags": [
@@ -2083,6 +2118,66 @@
} }
} }
}, },
"LocalSummary": {
"type": "object",
"description": "Non-sensitive host status for the local tray icon: counts and booleans only — no PIN values,\nno fingerprints, no device names. Served unauthenticated to LOOPBACK peers only (see\n`require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on Windows, so the\nper-user tray process cannot authenticate — this narrow read-only route is its status source.",
"required": [
"version",
"video_streaming",
"audio_streaming",
"paired_clients",
"native_paired_clients",
"pin_pending",
"pending_approvals"
],
"properties": {
"audio_streaming": {
"type": "boolean",
"description": "True while the audio stream thread is running."
},
"native_paired_clients": {
"type": "integer",
"format": "int32",
"description": "Number of paired native (punktfunk/1) devices.",
"minimum": 0
},
"paired_clients": {
"type": "integer",
"format": "int32",
"description": "Number of pinned (paired) GameStream client certificates.",
"minimum": 0
},
"pending_approvals": {
"type": "integer",
"format": "int32",
"description": "Native pairing knocks awaiting the operator's approval (count only).",
"minimum": 0
},
"pin_pending": {
"type": "boolean",
"description": "True while a GameStream pairing handshake is parked waiting for the user's PIN."
},
"session": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/SessionInfo",
"description": "The active launch session (set by Moonlight's `/launch`, cleared on cancel/stop)."
}
]
},
"version": {
"type": "string",
"description": "Host version (mirrors `/health`)."
},
"video_streaming": {
"type": "boolean",
"description": "True while the video stream thread is running."
}
}
},
"LogEntry": { "LogEntry": {
"type": "object", "type": "object",
"description": "One captured log event.", "description": "One captured log event.",
@@ -33,13 +33,19 @@ data class Settings(
/** Show the live stats overlay (FPS / throughput / latency) during a stream. */ /** Show the live stats overlay (FPS / throughput / latency) during a stream. */
val statsHudEnabled: Boolean = true, val statsHudEnabled: Boolean = true,
/** /**
* Touch input model. `true` (default) = trackpad: the cursor stays put on touch-down and moves * Touch input model — how touchscreen fingers drive the host. [TouchMode.TRACKPAD] (default):
* by the finger's relative delta (swipe to nudge, lift and re-swipe to walk it across), tap to * the cursor stays put on touch-down and moves by the finger's relative delta (swipe to nudge,
* click where it is. `false` = direct pointing: the cursor jumps to the finger (the old behaviour). * lift and re-swipe to walk it across), tap to click where it is. [TouchMode.POINTER]: the
* cursor jumps to the finger (direct pointing). [TouchMode.TOUCH]: real multi-touch
* passthrough — every finger reaches the host as a touchscreen contact, for apps/games that
* understand touch. Mirrors the Apple client's TouchInputMode.
*/ */
val trackpadMode: Boolean = true, val touchMode: TouchMode = TouchMode.TRACKPAD,
) )
/** [Settings.touchMode] values; persisted by name. */
enum class TouchMode { TRACKPAD, POINTER, TOUCH }
/** Loads/saves [Settings] in the app-private `punktfunk_settings` prefs. */ /** Loads/saves [Settings] in the app-private `punktfunk_settings` prefs. */
class SettingsStore(context: Context) { class SettingsStore(context: Context) {
private val prefs = private val prefs =
@@ -57,7 +63,10 @@ class SettingsStore(context: Context) {
codec = prefs.getString(K_CODEC, "auto") ?: "auto", codec = prefs.getString(K_CODEC, "auto") ?: "auto",
micEnabled = prefs.getBoolean(K_MIC, false), micEnabled = prefs.getBoolean(K_MIC, false),
statsHudEnabled = prefs.getBoolean(K_HUD, true), statsHudEnabled = prefs.getBoolean(K_HUD, true),
trackpadMode = prefs.getBoolean(K_TRACKPAD, true), touchMode = prefs.getString(K_TOUCH_MODE, null)
?.let { name -> TouchMode.entries.firstOrNull { it.name == name } }
// Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct).
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
) )
fun save(s: Settings) { fun save(s: Settings) {
@@ -73,7 +82,7 @@ class SettingsStore(context: Context) {
.putString(K_CODEC, s.codec) .putString(K_CODEC, s.codec)
.putBoolean(K_MIC, s.micEnabled) .putBoolean(K_MIC, s.micEnabled)
.putBoolean(K_HUD, s.statsHudEnabled) .putBoolean(K_HUD, s.statsHudEnabled)
.putBoolean(K_TRACKPAD, s.trackpadMode) .putString(K_TOUCH_MODE, s.touchMode.name)
.apply() .apply()
} }
@@ -89,6 +98,9 @@ class SettingsStore(context: Context) {
const val K_CODEC = "codec" const val K_CODEC = "codec"
const val K_MIC = "mic_enabled" const val K_MIC = "mic_enabled"
const val K_HUD = "stats_hud_enabled" const val K_HUD = "stats_hud_enabled"
const val K_TOUCH_MODE = "touch_mode"
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
const val K_TRACKPAD = "trackpad_mode" const val K_TRACKPAD = "trackpad_mode"
} }
} }
@@ -195,6 +207,13 @@ val COMPOSITOR_OPTIONS = listOf(
"gamescope", "gamescope",
) )
/** (mode, label) for the touch-input model. */
val TOUCH_MODE_OPTIONS = listOf(
TouchMode.TRACKPAD to "Trackpad",
TouchMode.POINTER to "Direct pointer",
TouchMode.TOUCH to "Touch passthrough",
)
/** index = GamepadPref wire byte (0=Auto 1=Xbox360 2=DualSense 3=XboxOne 4=DualShock4). */ /** index = GamepadPref wire byte (0=Auto 1=Xbox360 2=DualSense 3=XboxOne 4=DualShock4). */
val GAMEPAD_OPTIONS = listOf( val GAMEPAD_OPTIONS = listOf(
"Automatic", "Automatic",
@@ -165,13 +165,21 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
) )
} }
SettingsGroup("Pointer") { SettingsGroup("Touch input") {
ToggleRow( SettingDropdown(
title = "Trackpad mode", label = "Touch input",
subtitle = "Relative cursor like a laptop touchpad — swipe to nudge, tap to click. " + options = TOUCH_MODE_OPTIONS,
"Off = the cursor jumps to your finger.", selected = s.touchMode,
checked = s.trackpadMode, onSelect = { mode -> update(s.copy(touchMode = mode)) },
onCheckedChange = { on -> update(s.copy(trackpadMode = on)) }, )
Text(
"Trackpad: relative cursor like a laptop touchpad — tap to click, two-finger " +
"tap right-clicks, two fingers scroll, tap-then-drag holds the button. " +
"Direct pointer: the cursor jumps to your finger. Touch passthrough: real " +
"multi-touch reaches the host, for apps that understand touch.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
) )
} }
@@ -57,7 +57,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
var stats by remember { mutableStateOf<DoubleArray?>(null) } var stats by remember { mutableStateOf<DoubleArray?>(null) }
var showStats by remember { mutableStateOf(initialSettings.statsHudEnabled) } var showStats by remember { mutableStateOf(initialSettings.statsHudEnabled) }
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes). // Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
val trackpad = initialSettings.trackpadMode val touchMode = initialSettings.touchMode
LaunchedEffect(handle, showStats) { LaunchedEffect(handle, showStats) {
NativeBridge.nativeSetVideoStatsEnabled(handle, showStats) NativeBridge.nativeSetVideoStatsEnabled(handle, showStats)
if (showStats) { if (showStats) {
@@ -148,11 +148,18 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
if (showStats) { if (showStats) {
stats?.let { StatsOverlay(it, Modifier.align(Alignment.TopStart).padding(12.dp)) } stats?.let { StatsOverlay(it, Modifier.align(Alignment.TopStart).padding(12.dp)) }
} }
// Touch → mouse (trackpad vs. direct pointing + the shared gesture vocabulary — see // Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture
// streamTouchInput in TouchInput.kt). // vocabulary) or real multi-touch passthrough — see TouchInput.kt.
Box( Box(
Modifier.fillMaxSize().pointerInput(handle, trackpad) { Modifier.fillMaxSize().pointerInput(handle, touchMode) {
streamTouchInput(handle, trackpad, onToggleStats = { showStats = !showStats }) when (touchMode) {
TouchMode.TOUCH -> streamTouchPassthrough(handle)
else -> streamTouchInput(
handle,
trackpad = touchMode == TouchMode.TRACKPAD,
onToggleStats = { showStats = !showStats },
)
}
}, },
) )
} }
@@ -2,7 +2,11 @@ package io.unom.punktfunk
import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.ui.input.pointer.PointerId
import androidx.compose.ui.input.pointer.PointerInputScope import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.changedToDownIgnoreConsumed
import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
import androidx.compose.ui.input.pointer.positionChanged
import io.unom.punktfunk.kit.NativeBridge import io.unom.punktfunk.kit.NativeBridge
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.hypot import kotlin.math.hypot
@@ -38,6 +42,54 @@ private const val ACCEL_MAX = 3.0f
* two-finger drag = scroll; tap-then-press-and-drag = left-drag (text selection / moving * two-finger drag = scroll; tap-then-press-and-drag = left-drag (text selection / moving
* windows); three-finger tap = [onToggleStats] (the stats HUD). * windows); three-finger tap = [onToggleStats] (the stats HUD).
*/ */
/**
* Real multi-touch passthrough ([TouchMode.TOUCH]): every finger forwards as a host touchscreen
* contact (down/move/up with a stable per-finger id), with NO gesture interpretation — taps,
* drags and multi-finger input mean whatever the remote app decides. Coordinates are overlay
* pixels with the overlay size as the surface, exactly like the absolute-mouse path (the host
* normalizes and maps into the output). On teardown (stream leaves composition) every still-held
* contact is lifted so nothing stays stuck on the host.
*/
internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long) {
val ids = mutableMapOf<PointerId, Int>()
fun alloc(p: PointerId): Int {
var id = 0
while (ids.containsValue(id)) id++
ids[p] = id
return id
}
try {
awaitPointerEventScope {
while (true) {
val ev = awaitPointerEvent()
val sw = size.width
val sh = size.height
if (sw <= 0 || sh <= 0) continue
for (c in ev.changes) {
val x = c.position.x.roundToInt().coerceIn(0, sw - 1)
val y = c.position.y.roundToInt().coerceIn(0, sh - 1)
when {
c.changedToDownIgnoreConsumed() ->
NativeBridge.nativeSendTouch(handle, alloc(c.id), 0, x, y, sw, sh)
c.changedToUpIgnoreConsumed() ->
ids.remove(c.id)?.let {
NativeBridge.nativeSendTouch(handle, it, 2, 0, 0, sw, sh)
}
c.positionChanged() ->
ids[c.id]?.let {
NativeBridge.nativeSendTouch(handle, it, 1, x, y, sw, sh)
}
}
c.consume()
}
}
}
} finally {
// Lift anything still down (composition/session teardown mid-touch).
ids.values.forEach { NativeBridge.nativeSendTouch(handle, it, 2, 0, 0, 1, 1) }
}
}
internal suspend fun PointerInputScope.streamTouchInput( internal suspend fun PointerInputScope.streamTouchInput(
handle: Long, handle: Long,
trackpad: Boolean, trackpad: Boolean,
@@ -27,6 +27,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.unom.punktfunk.BrandDark import io.unom.punktfunk.BrandDark
import io.unom.punktfunk.Settings import io.unom.punktfunk.Settings
import io.unom.punktfunk.TouchMode
import io.unom.punktfunk.SettingsScreen import io.unom.punktfunk.SettingsScreen
import io.unom.punktfunk.StatsOverlay import io.unom.punktfunk.StatsOverlay
import io.unom.punktfunk.components.HostCard import io.unom.punktfunk.components.HostCard
@@ -109,7 +110,7 @@ internal fun SettingsScene() {
gamepad = 2, gamepad = 2,
micEnabled = true, micEnabled = true,
statsHudEnabled = true, statsHudEnabled = true,
trackpadMode = true, touchMode = TouchMode.TRACKPAD,
), ),
onChange = {}, onChange = {},
onBack = {}, onBack = {},
+24 -7
View File
@@ -37,13 +37,30 @@ def call(method, url, token=None, data=None, content_type=None, want_json=True):
headers["Authorization"] = f"Bearer {token}" headers["Authorization"] = f"Bearer {token}"
if content_type: if content_type:
headers["Content-Type"] = content_type headers["Content-Type"] = content_type
req = urllib.request.Request(url, data=data, method=method, headers=headers) # Transient-fault retries: googleapis.com occasionally drops the TLS session ("EOF
try: # occurred in violation of protocol" — failed two release uploads on 2026-07-02) or
with urllib.request.urlopen(req, timeout=300) as r: # answers 5xx. Retry those with backoff; 4xx raises immediately (a real API error).
body = r.read() # The edits API is transactional until commit, so re-sending any of these is safe.
except urllib.error.HTTPError as e: last = None
raise ApiError(e.code, method, url, e.read().decode("utf-8", "replace")) for attempt in range(4):
return json.loads(body) if (want_json and body) else body if attempt:
delay = 3**attempt
print(f"transient Play API failure ({last}); retry {attempt}/3 in {delay}s")
time.sleep(delay)
req = urllib.request.Request(url, data=data, method=method, headers=headers)
try:
with urllib.request.urlopen(req, timeout=300) as r:
body = r.read()
return json.loads(body) if (want_json and body) else body
except urllib.error.HTTPError as e:
if e.code >= 500:
last = f"HTTP {e.code}"
continue
raise ApiError(e.code, method, url, e.read().decode("utf-8", "replace"))
except urllib.error.URLError as e:
last = str(getattr(e, "reason", e))
continue
sys.exit(f"ERROR: {method} {url} still failing after retries: {last}")
def load_sa(): def load_sa():
@@ -159,6 +159,22 @@ object NativeBridge {
/** One scroll step. axis: 0=vertical 1=horizontal. delta: signed, 120-scaled, +=up/right. */ /** One scroll step. axis: 0=vertical 1=horizontal. delta: signed, 120-scaled, +=up/right. */
external fun nativeSendScroll(handle: Long, axis: Int, delta: Int) external fun nativeSendScroll(handle: Long, axis: Int, delta: Int)
/**
* One REAL touchscreen transition (the touch-passthrough input mode). [kind]: 0=down 1=move
* 2=up. [id] distinguishes fingers and is reusable after up; coordinates are pixels on the
* client's touch surface — the host rescales against [surfaceWidth]×[surfaceHeight] and
* injects a real touch contact. On up only [id] matters.
*/
external fun nativeSendTouch(
handle: Long,
id: Int,
kind: Int,
x: Int,
y: Int,
surfaceWidth: Int,
surfaceHeight: Int,
)
/** One key transition. vk: Windows VK (0 = dropped by Rust). mods: VK modifier mask (0 for now). */ /** One key transition. vk: Windows VK (0 = dropped by Rust). mods: VK modifier mask (0 for now). */
external fun nativeSendKey(handle: Long, vk: Int, down: Boolean, mods: Int) external fun nativeSendKey(handle: Long, vk: Int, down: Boolean, mods: Int)
@@ -93,6 +93,34 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendScroll(
send_event(handle, InputKind::MouseScroll, axis as u32, delta, 0, 0); send_event(handle, InputKind::MouseScroll, axis as u32, delta, 0, 0);
} }
/// `NativeBridge.nativeSendTouch(handle, id, kind, x, y, surfaceWidth, surfaceHeight)` — one REAL
/// touchscreen transition (`kind`: 0=down 1=move 2=up), for the touch-passthrough input mode. `id`
/// distinguishes fingers (reusable after up); coordinates are pixels on the client's touch
/// surface, whose size rides in `flags` so the host can rescale into the output (identical
/// packing to MouseMoveAbs). On up only the id matters. The host injects a real touch contact
/// (libei touchscreen / wlroots / SendInput).
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendTouch(
_env: JNIEnv,
_this: JObject,
handle: jlong,
id: jint,
kind: jint,
x: jint,
y: jint,
surface_width: jint,
surface_height: jint,
) {
let kind = match kind {
0 => InputKind::TouchDown,
1 => InputKind::TouchMove,
_ => InputKind::TouchUp,
};
let w = (surface_width.max(0) as u32) & 0xffff;
let h = (surface_height.max(0) as u32) & 0xffff;
send_event(handle, kind, id as u32, x, y, (w << 16) | h);
}
/// `NativeBridge.nativeSendKey(handle, vk, down, mods)` — one key transition. `vk`: Windows /// `NativeBridge.nativeSendKey(handle, vk, down, mods)` — one key transition. `vk`: Windows
/// Virtual-Key code (0 = unmapped → dropped). `down`: 1=press, 0=release. `mods`: VK modifier /// Virtual-Key code (0 = unmapped → dropped). `down`: 1=press, 0=release. `mods`: VK modifier
/// bitmask (0 for now — the host folds modifiers from the L/R modifier key events themselves). /// bitmask (0 for now — the host folds modifiers from the L/R modifier key events themselves).
@@ -255,6 +255,10 @@ struct ControllerTestView: View {
Toggle("Light motor (right)", isOn: $lightOn) Toggle("Light motor (right)", isOn: $lightOn)
Label("Backend: \(tester.rumbleBackend)", systemImage: "waveform") Label("Backend: \(tester.rumbleBackend)", systemImage: "waveform")
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary) .font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
if let problem = tester.rumbleHealth {
Label(problem, systemImage: "exclamationmark.triangle.fill")
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.orange)
}
Text("Toggle a motor to feel it. The host maps a game's low/high-frequency " Text("Toggle a motor to feel it. The host maps a game's low/high-frequency "
+ "rumble onto these two. A DualSense is driven over raw HID (CoreHaptics " + "rumble onto these two. A DualSense is driven over raw HID (CoreHaptics "
+ "can't reach its motors on macOS).") + "can't reach its motors on macOS).")
@@ -201,25 +201,36 @@ extension SettingsView {
} }
#if os(iOS) #if os(iOS)
/// iPad-only pointer-capture toggle: lock the mouse/trackpad for relative movement (games) vs /// Touch-input model (iPhone + iPad) plus the iPad-only pointer-capture toggle: lock the
/// forward an absolute cursor position (desktop). Empty on iPhone (no hardware-pointer lock /// mouse/trackpad for relative movement (games) vs forward an absolute cursor position.
/// the mouse path there is always the absolute fallback).
@ViewBuilder var pointerSection: some View { @ViewBuilder var pointerSection: some View {
if UIDevice.current.userInterfaceIdiom == .pad { let isPad = UIDevice.current.userInterfaceIdiom == .pad
Section { Section {
Toggle("Capture pointer for games", isOn: $pointerCapture) Picker("Touch input", selection: $touchMode) {
} header: { Text("Trackpad").tag(TouchInputMode.trackpad.rawValue)
Text("Pointer") Text("Direct pointer").tag(TouchInputMode.pointer.rawValue)
} footer: { Text("Touch passthrough").tag(TouchInputMode.touch.rawValue)
Text("With a mouse or trackpad connected, lock the pointer and send relative "
+ "movement — the expected behavior for games (mouse-look). Turn this off for "
+ "desktop use to keep the pointer free and send its absolute position instead. "
+ "The lock needs the stream full-screen and frontmost; it falls back to the "
+ "absolute pointer automatically (Stage Manager, Slide Over). Finger touch is "
+ "unaffected. Applies from the next session.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
} }
if isPad {
Toggle("Capture pointer for games", isOn: $pointerCapture)
}
} header: {
Text("Touch & pointer")
} footer: {
Text("Trackpad: your finger nudges the host cursor like a laptop touchpad — tap to "
+ "click, two-finger tap for a right click, two-finger drag to scroll, "
+ "tap-then-drag to hold the button, three-finger tap for the stats overlay. "
+ "Direct pointer: the cursor jumps to your finger. Touch passthrough: real "
+ "multi-touch reaches the host, for apps that understand touch. Applies from "
+ "the next touch."
+ (isPad
? " Pointer capture locks a hardware mouse/trackpad for relative movement "
+ "(mouse-look); off keeps the pointer free and sends absolute positions. "
+ "The lock needs the stream full-screen and frontmost, and falls back "
+ "automatically (Stage Manager, Slide Over)."
: ""))
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
} }
} }
#endif #endif
@@ -43,6 +43,7 @@ struct SettingsView: View {
#endif #endif
#if os(iOS) #if os(iOS)
@AppStorage(DefaultsKey.pointerCapture) var pointerCapture = true @AppStorage(DefaultsKey.pointerCapture) var pointerCapture = true
@AppStorage(DefaultsKey.touchMode) var touchMode = TouchInputMode.trackpad.rawValue
// The sidebar selection drives the detail pane on iPad and the pushed sub-page on iPhone. // The sidebar selection drives the detail pane on iPad and the pushed sub-page on iPhone.
// Width class decides the initial value: nil on iPhone (show the category list first), // Width class decides the initial value: nil on iPhone (show the category list first),
// General on iPad (a two-column layout should never open with an empty detail). // General on iPad (a two-column layout should never open with an empty detail).
@@ -10,13 +10,20 @@ import GameController
/// a passing test exercises the exact code a session runs. /// a passing test exercises the exact code a session runs.
@MainActor @MainActor
public final class ControllerTester: ObservableObject { public final class ControllerTester: ObservableObject {
private let renderer = RumbleRenderer() // `.manual`: the panel's toggles hold a level until changed no session wire refreshes
// exist here to keep the renderer's staleness watchdog fed.
private let renderer = RumbleRenderer(policy: .manual)
private weak var controller: GCController? private weak var controller: GCController?
/// The rumble backend now in use "DualSense HID · USB/Bluetooth", "CoreHaptics", or "" /// The rumble backend now in use "DualSense HID · USB/Bluetooth", "CoreHaptics", or ""
/// for the test panel to display so it's obvious which path a given pad takes. /// for the test panel to display so it's obvious which path a given pad takes.
@Published public private(set) var rumbleBackend = "" @Published public private(set) var rumbleBackend = ""
/// Why rumble structurally cannot work right now (nil = healthy) e.g. the device's
/// haptics service refusing every connection, or a pad with no rumble engine. Shown by the
/// test panel so silence diagnoses itself instead of reading as an app bug.
@Published public private(set) var rumbleHealth: String?
public init() {} public init() {}
/// Aim the feedback at a controller (nil releases it). Idempotent safe to call on every /// Aim the feedback at a controller (nil releases it). Idempotent safe to call on every
@@ -24,9 +31,14 @@ public final class ControllerTester: ObservableObject {
public func target(_ c: GCController?) { public func target(_ c: GCController?) {
guard c !== controller else { return } guard c !== controller else { return }
controller = c controller = c
renderer.retarget(c) { [weak self] note in renderer.retarget(
Task { @MainActor in self?.rumbleBackend = note } c,
} onBackend: { [weak self] note in
Task { @MainActor in self?.rumbleBackend = note }
},
onHealth: { [weak self] problem in
Task { @MainActor in self?.rumbleHealth = problem }
})
} }
/// Drive both motors at 0...1 amplitudes low = left/heavy, high = right/light mapped to /// Drive both motors at 0...1 amplitudes low = left/heavy, high = right/light mapped to
@@ -102,6 +102,13 @@ public final class GamepadCapture {
tp?.primary.valueChangedHandler = nil tp?.primary.valueChangedHandler = nil
tp?.secondary.valueChangedHandler = nil tp?.secondary.valueChangedHandler = nil
} }
// Hand the system gestures back to the OS before letting the old pad go outside a
// stream the share button's screenshot and the Home overlay are the user's, not ours.
if let old = bound {
for element in old.physicalInputProfile.elements.values {
element.preferredSystemGestureState = .enabled
}
}
if let motion = bound?.motion { if let motion = bound?.motion {
motion.valueChangedHandler = nil motion.valueChangedHandler = nil
// Power the sensors back down left active they keep the pad streaming // Power the sensors back down left active they keep the pad streaming
@@ -114,14 +121,21 @@ public final class GamepadCapture {
ext.valueChangedHandler = { [weak self] g, _ in ext.valueChangedHandler = { [weak self] g, _ in
MainActor.assumeIsolated { self?.sync(g) } MainActor.assumeIsolated { self?.sync(g) }
} }
// The Home/PS button ( guide; the host maps it to the DualSense PS / Xbox guide bit). On // Claim EVERY element's system gesture while this pad drives a stream. The OS attaches
// macOS the SYSTEM grabs it by default (opens Launchpad's Games folder), so it never reached // gestures to several controller buttons share/create local screenshot/recording,
// the app `preferredSystemGestureState = .disabled` on the element is what hands it to us. // Home Game Center overlay (iOS) / Launchpad's Games folder (macOS) and with a
// We drive `guide` DIRECTLY from this handler's pressed value (not via buttonMask), because // gesture attached the press is the system's, not the game's. During capture the remote
// the legacy `extendedGamepad.buttonHome` is unreliable/often nil even when the physical // session IS the game: the share button must reach the host (e.g. Steam screenshots),
// element exists. On tvOS the element is absent (reserved) nil, the whole block no-ops. // the PS button must open the host's Steam overlay. Restored to .enabled on unbind.
for element in c.physicalInputProfile.elements.values {
element.preferredSystemGestureState = .disabled
}
// The Home/PS button ( guide; the host maps it to the DualSense PS / Xbox guide bit,
// BTN_MODE on the virtual xpad the Steam-overlay button). Driven DIRECTLY from this
// handler's pressed value (not via buttonMask), because the legacy
// `extendedGamepad.buttonHome` is unreliable/often nil even when the physical element
// exists. On tvOS the element is absent (reserved) nil, the whole block no-ops.
if let home = c.physicalInputProfile.buttons[GCInputButtonHome] { if let home = c.physicalInputProfile.buttons[GCInputButtonHome] {
home.preferredSystemGestureState = .disabled
home.pressedChangedHandler = { [weak self] _, _, pressed in home.pressedChangedHandler = { [weak self] _, _, pressed in
MainActor.assumeIsolated { self?.sendGuide(down: pressed) } MainActor.assumeIsolated { self?.sendGuide(down: pressed) }
} }
@@ -192,6 +206,11 @@ public final class GamepadCapture {
if g.dpad.right.isPressed { b |= GamepadWire.dpadRight } if g.dpad.right.isPressed { b |= GamepadWire.dpadRight }
if g.buttonMenu.isPressed { b |= GamepadWire.start } if g.buttonMenu.isPressed { b |= GamepadWire.start }
if g.buttonOptions?.isPressed == true { b |= GamepadWire.back } if g.buttonOptions?.isPressed == true { b |= GamepadWire.back }
// The share/create/capture element (Xbox Series share, a clone pad's screenshot button
// e.g. the GameSir G8's, below its d-pad) folds into back/select too. On pads that expose
// the create button BOTH as buttonOptions and as the share element this OR is harmless
// same wire bit.
if g.buttons[GCInputButtonShare]?.isPressed == true { b |= GamepadWire.back }
if g.leftThumbstickButton?.isPressed == true { b |= GamepadWire.leftStickClick } if g.leftThumbstickButton?.isPressed == true { b |= GamepadWire.leftStickClick }
if g.rightThumbstickButton?.isPressed == true { b |= GamepadWire.rightStickClick } if g.rightThumbstickButton?.isPressed == true { b |= GamepadWire.rightStickClick }
if g.leftShoulder.isPressed { b |= GamepadWire.leftShoulder } if g.leftShoulder.isPressed { b |= GamepadWire.leftShoulder }
@@ -25,7 +25,7 @@ public final class GamepadFeedback {
private let flag = StopFlag() private let flag = StopFlag()
private let drainDone = DispatchSemaphore(value: 0) private let drainDone = DispatchSemaphore(value: 0)
private var drainStarted = false private var drainStarted = false
private let rumble = RumbleRenderer() private let rumble = RumbleRenderer(policy: .session)
private var activeSub: AnyCancellable? private var activeSub: AnyCancellable?
// Last applied feedback (main-actor) replayed when the active controller changes. // Last applied feedback (main-actor) replayed when the active controller changes.
@@ -82,8 +82,21 @@ public final class GamepadFeedback {
// poll here starved it and throttled HDR to ~1 fps (SDR, which never drains HDR // poll here starved it and throttled HDR to ~1 fps (SDR, which never drains HDR
// meta, was unaffected). Pacing with a short sleep OUTSIDE the lock (below) keeps // meta, was unaffected). Pacing with a short sleep OUTSIDE the lock (below) keeps
// rumble/HID latency low while leaving the lock free between polls. // rumble/HID latency low while leaving the lock free between polls.
if let r = try connection.nextRumble(timeoutMs: 0), r.pad == 0 { //
self?.rumble.apply(low: r.low, high: r.high) // Rumble is idempotent state, so drain the plane DRY and apply only the newest
// level. The old one-datagram-per-cycle shape let a burst outpace the ~125 Hz
// drain: levels rendered up to ~130 ms late through the core's 16-deep queue,
// and its drop-newest overflow could shed a stop while stale nonzero states
// queued ahead of it buzzing until the host's next 500 ms refresh.
var newest: (low: UInt16, high: UInt16)?
var rumbleBurst = 0
while rumbleBurst < 64, !flag.isStopped,
let r = try connection.nextRumble(timeoutMs: 0) {
if r.pad == 0 { newest = (r.low, r.high) }
rumbleBurst += 1
}
if let n = newest {
self?.rumble.apply(low: n.low, high: n.high)
} }
// Drain a BOUNDED burst of hidout events so sustained 0xCD traffic (a game writing // Drain a BOUNDED burst of hidout events so sustained 0xCD traffic (a game writing
// per-frame LED/trigger reports) can't spin here or block stop() past one cycle. // per-frame LED/trigger reports) can't spin here or block stop() past one cycle.
@@ -5,28 +5,145 @@ import os
private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad") private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad")
/// Rumble CoreHaptics, isolated on one serial queue (CHHapticEngine is not main-bound, /// Tuning constants + the pure scheduling decisions of the rumble renderer, split out so the
/// but it isn't a free-for-all either). Engines are created lazily on the first nonzero /// policy is unit-testable without a `CHHapticEngine` or a physical pad.
/// amplitude and torn down on retarget; players run only while their motor is on, so an enum RumbleTuning {
/// idle controller costs no radio traffic. Failures (pads without haptics, engine resets) /// Haptic segment length. **No event is ever infinite**: a player the renderer loses track
/// downgrade to silence rumble is best-effort by design. /// of (a stop dropped inside CoreHaptics, an engine race) self-silences when its segment
/// /// expires, so this is the hard ceiling on how long the actuator can diverge from the
/// `@unchecked Sendable` is sound because every property (`controller`/`low`/`high`/`broken`) is /// target state.
/// read and written only inside `queue` closures the serial queue is the synchronization. static let segmentSeconds: TimeInterval = 4.0
final class RumbleRenderer: @unchecked Sendable { /// Re-arm the successor segment once the current one has less than this left. Generous
private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive) /// against the ticker period so a steady rumble can never miss the boundary and gap.
static let rearmHeadroom: TimeInterval = 1.0
/// Renderer ticker period while anything is (or should be) audible. Silence runs no timer.
static let tickSeconds: TimeInterval = 0.05
/// Minimum spacing between player rebuilds for nonzerononzero level changes a game
/// ramping rumble per frame would otherwise stop/start players at 60+ Hz, which is exactly
/// the churn that lost stops inside CoreHaptics. Newest level wins when the window opens;
/// zero is never throttled.
static let minRebakeSeconds: TimeInterval = 0.025
/// Session watchdog: silence the motors when no wire command arrived for this long. The
/// host re-sends the current rumble state every 500 ms as its loss heal, so this trips only
/// after 3 consecutive refreshes vanished i.e. the channel or host died while audible.
static let sessionStaleSeconds: TimeInterval = 1.6
/// Levels closer than this (0.4 % of full scale) are the same level an identical host
/// refresh must never rebuild a player.
static let levelEpsilon: Float = 1.0 / 256.0
/// macOS DualSense raw-HID path: re-write an unchanged nonzero level this often so the
/// pad's firmware never times the rumble out mid-effect (Bluetooth pads watchdog output
/// reports), and a dropped report heals.
static let hidKeepaliveSeconds: TimeInterval = 0.9
/// One actuator's started engine plus the player currently driving it (nil = idle). The /// `CHHapticEvent` sharpness = actuator frequency. A DualSense's voice-coil motors need a
/// player is rebuilt per level change `drive` bakes the target intensity into a fresh /// defined frequency to move at all (an intensity-only event left them silent) while a
/// continuous event rather than scaling a long-lived one with a dynamic parameter. /// classic Xbox ERM rotor ignores it. On split-handle pads the wire's two motors render at
/// distinct frequencies mirroring the real hardware they emulate low/left the heavy
/// low-frequency rotor, high/right the light buzzer; a single combined actuator keeps the
/// proven mid value.
static let sharpnessLow: Float = 0.3
static let sharpnessHigh: Float = 0.7
static let sharpnessCombined: Float = 0.5
/// Wire amplitude (0...0xFFFF) CoreHaptics intensity (0...1).
static func amplitude(_ wire: UInt16) -> Float { Float(wire) / 65535 }
/// Wire amplitude DualSense HID motor byte.
static func hidByte(_ wire: UInt16) -> UInt8 { UInt8(wire >> 8) }
/// Single-actuator pads render whichever motor is stronger.
static func combined(low: UInt16, high: UInt16) -> UInt16 { max(low, high) }
/// Are two baked levels the same (skip the rebuild)?
static func sameLevel(_ a: Float, _ b: Float) -> Bool { abs(a - b) <= levelEpsilon }
/// Time for a segment handoff to act (engine timeline).
static func shouldRearm(endsAt: TimeInterval, now: TimeInterval) -> Bool {
endsAt - now <= rearmHeadroom
}
/// When the successor segment starts: exactly as the current one expires unless that
/// already passed (the gap already happened; start now).
static func handoffStart(endsAt: TimeInterval, now: TimeInterval) -> TimeInterval {
max(endsAt, now)
}
}
/// Rumble the active physical controller (CoreHaptics; a DualSense on macOS goes over raw HID
/// instead, see `DualSenseHID`), built around one principle: **rumble is idempotent state on a
/// lossy channel, and the actuator's divergence from that state must be bounded** not
/// best-effort. The previous renderer drove infinite-duration players torn down and rebuilt per
/// wire update; one asynchronous `stop` dropped inside CoreHaptics left an unstoppable player
/// buzzing with its handle discarded, which no later (0,0) could reach the "walked into the
/// menu and the rumble never stopped" bug.
///
/// The invariants that bound divergence now:
/// 1. **No infinite events.** A motor plays finite `segmentSeconds` segments; while the level
/// holds, the successor is scheduled ON the engine timeline to start exactly when the
/// current segment expires (seamless no stop/start race in steady state). A leaked player
/// therefore self-silences in `segmentSeconds`.
/// 2. **Idempotent targets.** An update equal to the current target (the host re-sends rumble
/// state every 500 ms as its loss heal) is a liveness stamp, never a player rebuild.
/// 3. **Zero is immediate, ramps are throttled.** (0,0) stops players the moment it lands;
/// nonzerononzero changes rebuild at most every `minRebakeSeconds` per motor (the ticker
/// lands the newest value once the window opens).
/// 4. **Escalating stop.** A throwing `player.stop` means the engine's state is unknown the
/// whole engine is stopped (silencing every player it hosts) and lazily rebuilt behind the
/// exponential backoff.
/// 5. **Staleness watchdog** (`Policy.session`): audible with no wire command for
/// `sessionStaleSeconds` force silence. A lost stop can outlive the host's 500 ms heal
/// only if the channel itself died, and then the pad must not buzz forever. `Policy.manual`
/// (the settings test panel) instead holds a level until it is changed.
///
/// Engines are created lazily on the first nonzero amplitude and torn down on retarget;
/// failures (pads without haptics, engine resets) downgrade to silence rumble is best-effort
/// by design, but *staying silent* when told to stop is not.
///
/// `@unchecked Sendable` is sound because every property is read and written only inside
/// `queue` closures the serial queue is the synchronization.
final class RumbleRenderer: @unchecked Sendable {
/// What an un-refreshed nonzero target means. A live session ties motor life to wire
/// liveness (the host refreshes state every 500 ms); the controller test panel holds a
/// slider level indefinitely.
struct Policy {
let staleAfter: TimeInterval?
static let session = Policy(staleAfter: RumbleTuning.sessionStaleSeconds)
static let manual = Policy(staleAfter: nil)
}
private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive)
private let policy: Policy
/// One finite haptic play on a motor: the player plus when (engine timeline) it expires.
/// A PLAIN pattern player on purpose: the controller haptics server (gamecontrollerd)
/// advertises `adv players: 0`, and as of iOS 27 beta 2 an advanced-player sequence load
/// doesn't degrade gracefully there the daemon faults decoding the XPC message and drops
/// it (CoreHaptics -4811/4097, rumble dead). We only need `start(atTime:)`/`stop(atTime:)`,
/// which the plain protocol has.
private struct Segment {
let player: CHHapticPatternPlayer
let endsAt: TimeInterval
}
/// One actuator's started engine and the segment(s) realizing `level` on it. `retiring` is
/// the predecessor across a segment handoff left to expire naturally (its successor
/// starts the instant it ends), but the reference is held so a level change or stop can
/// still force-stop it.
private struct Motor { private struct Motor {
let engine: CHHapticEngine let engine: CHHapticEngine
var player: CHHapticAdvancedPatternPlayer? let sharpness: Float
var level: Float = 0
var current: Segment?
var retiring: Segment?
var lastRebake = DispatchTime(uptimeNanoseconds: 0)
} }
private var controller: GCController? private var controller: GCController?
private var low: Motor? private var low: Motor?
private var high: Motor? private var high: Motor?
/// Wire-truth target (raw wire units) and when it was last confirmed by any command.
private var target: (low: UInt16, high: UInt16) = (0, 0)
private var lastCommand = DispatchTime(uptimeNanoseconds: 0)
/// Runs while anything is (or should be) audible: staleness watchdog, segment re-arm,
/// throttled-level catch-up, engine rebuild after a reset, HID keepalive. Nil while silent,
/// so an idle controller costs no timer wakeups and no radio traffic.
private var ticker: DispatchSourceTimer?
// `broken` latches OFF only for a controller that genuinely has no haptics engine (an Xbox pad // `broken` latches OFF only for a controller that genuinely has no haptics engine (an Xbox pad
// on an OS that doesn't expose rumble through GameController, a Siri Remote) nothing to retry // on an OS that doesn't expose rumble through GameController, a Siri Remote) nothing to retry
// until the controller changes. A transient engine failure does NOT latch it; it tears down for // until the controller changes. A transient engine failure does NOT latch it; it tears down for
@@ -39,86 +156,277 @@ final class RumbleRenderer: @unchecked Sendable {
// break fires neither stoppedHandler nor resetHandler, so without a cooldown the next rumble // break fires neither stoppedHandler nor resetHandler, so without a cooldown the next rumble
// update immediately rebuilds into the same dead connection, flooding the log and never // update immediately rebuilds into the same dead connection, flooding the log and never
// recovering. Delay the next setup() growing 0.5124 s on repeated failure and clear it // recovering. Delay the next setup() growing 0.5124 s on repeated failure and clear it
// the moment a player runs cleanly (or the controller changes). // the moment a player is actually running (or the controller changes).
private var retryAfter = Date.distantPast private var retryAfter = DispatchTime(uptimeNanoseconds: 0)
private var consecutiveFailures = 0 private var consecutiveFailures = 0
/// Downgrade after split-handle engines fail: retry with ONE combined `.default` engine
/// CHHapticEvent sharpness = actuator frequency. A DualSense's voice-coil motors need a /// the configuration virtually every iOS game (and this app's own menu haptics) uses before
/// defined frequency to move at all an intensity-only event (no sharpness) left them /// treating the service as unreachable. A haptics daemon that mishandles per-handle
/// silent, while a classic Xbox rotor (which ignores sharpness) rumbled fine. 0.5 is the mid /// localities for a particular pad can still serve the combined engine. One-way per
/// value the known-working macOS DualSense rumble implementations use. (Used only on the /// controller; retarget resets it.
/// CoreHaptics path a DualSense on macOS is driven over raw HID instead, see below.) private var preferCombined = false
private static let sharpness: Float = 0.5 /// Health reporting for the debug test panel: a human-readable problem while rumble cannot
/// work (nil = healthy). Without this, a wedged system haptics service (gamecontrollerd
/// refusing every XPC connection CoreHaptics -4811/4097, which no in-app retry can fix)
/// reads as "the app's rumble is broken" when actually no app on the device can rumble.
private var healthSink: ((String?) -> Void)?
private var lastHealth: String?
#if os(macOS) #if os(macOS)
/// Set when the active pad is a DualSense: its motors are driven over raw HID (CoreHaptics /// Set when the active pad is a DualSense: its motors are driven over raw HID (CoreHaptics
/// does not reach them on macOS adaptive triggers/lightbar work, rumble is silent). nil for /// does not reach them on macOS adaptive triggers/lightbar work, rumble is silent). nil for
/// every other controller, which keeps the CoreHaptics path. /// every other controller, which keeps the CoreHaptics path.
private var dualSenseHID: DualSenseHID? private var dualSenseHID: DualSenseHID?
private var lastHidWrite: (levels: (UInt8, UInt8), at: DispatchTime) =
((0, 0), DispatchTime(uptimeNanoseconds: 0))
#endif #endif
init(policy: Policy = .session) {
self.policy = policy
}
/// `onBackend`, if given, is invoked (on the internal queue) with a human-readable name of the /// `onBackend`, if given, is invoked (on the internal queue) with a human-readable name of the
/// rumble backend now in use for the debug controller-test panel. /// rumble backend now in use; `onHealth` with a problem description whenever rumble transitions
func retarget(_ c: GCController?, onBackend: ((String) -> Void)? = nil) { /// between working and structurally failing (nil = healthy) both for the debug test panel.
func retarget(
_ c: GCController?, onBackend: ((String) -> Void)? = nil,
onHealth: ((String?) -> Void)? = nil
) {
queue.async { queue.async {
self.teardown() self.teardown()
self.closeHID() self.closeHID()
self.controller = c self.controller = c
self.broken = false self.broken = false
self.preferCombined = false
self.consecutiveFailures = 0 self.consecutiveFailures = 0
self.retryAfter = .distantPast self.retryAfter = DispatchTime(uptimeNanoseconds: 0)
if let onHealth { self.healthSink = onHealth }
self.lastHealth = nil
self.healthSink?(nil)
_ = self.openHIDIfDualSense(c) _ = self.openHIDIfDualSense(c)
onBackend?(self.backendNote(for: c)) onBackend?(self.backendNote(for: c))
// The target survives the swap: render replays the current level onto the new pad
// right away (a mid-rumble controller change keeps rumbling, like moving a real pad
// between hands mid-effect).
self.render()
} }
} }
/// Set the wire-truth target. Called with every 0xCA state the host sends level changes
/// AND the 500 ms refreshes; refreshes stamp liveness for the watchdog and are otherwise
/// free (invariant 2).
func apply(low lowAmp: UInt16, high highAmp: UInt16) { func apply(low lowAmp: UInt16, high highAmp: UInt16) {
queue.async { queue.async {
self.lastCommand = .now()
let active = lowAmp != 0 || highAmp != 0 let active = lowAmp != 0 || highAmp != 0
if active != self.wasActive { if active != self.wasActive {
self.wasActive = active self.wasActive = active
log.debug( log.debug(
"rumble: \(active ? "active" : "stop", privacy: .public) low=\(lowAmp, privacy: .public) high=\(highAmp, privacy: .public)") "rumble: \(active ? "active" : "stop", privacy: .public) low=\(lowAmp, privacy: .public) high=\(highAmp, privacy: .public)")
} }
// A DualSense on macOS is driven over raw HID; CoreHaptics is the path for every guard (lowAmp, highAmp) != self.target else { return }
// other pad (and for a DualSense whose HID device could not be opened). self.target = (lowAmp, highAmp)
if self.hidRumble(low: lowAmp, high: highAmp) { return } self.render()
guard !self.broken else { return }
if active, self.low == nil, self.high == nil, Date() >= self.retryAfter {
self.setup()
}
let ok: Bool
if self.high != nil {
// Per-handle: low = left/heavy motor, high = right/light the XInput convention
// the wire carries.
let okLow = self.drive(&self.low, Float(lowAmp) / 65535)
let okHigh = self.drive(&self.high, Float(highAmp) / 65535)
ok = okLow && okHigh
} else {
// Combined engine: whichever motor is stronger wins.
ok = self.drive(&self.low, Float(max(lowAmp, highAmp)) / 65535)
}
// Rebuild on the next nonzero amplitude if an engine errored and tear down OUTSIDE
// the `inout` accesses above, so teardown() never mutates a motor that a `drive` call
// still holds an exclusive reference to. Back off so a broken XPC isn't re-hit every
// update; once a player is actually running the path has recovered, so clear the backoff.
if !ok {
self.teardown()
self.scheduleRetryBackoff()
} else if self.low?.player != nil || self.high?.player != nil {
self.consecutiveFailures = 0
self.retryAfter = .distantPast
}
} }
} }
/// Silence the motors and drop the engines. Blocks until done call off the main actor.
func stop() { func stop() {
queue.sync { queue.sync {
self.ticker?.cancel()
self.ticker = nil
self.target = (0, 0)
self.wasActive = false
self.teardown() self.teardown()
self.closeHID() self.closeHID()
} }
} }
// MARK: - Reconciliation (all on `queue`)
/// Drive the actuators toward `target`. Idempotent safe to call from every wire update,
/// tick, and retarget; when everything already matches it does nothing.
private func render() {
defer { updateTicker() }
if renderHID() { return }
guard !broken else { return }
let audible = target.low != 0 || target.high != 0
if audible, low == nil, high == nil, DispatchTime.now() >= retryAfter {
setup()
}
// Reconcile BOTH motors (no short-circuit skipping the second on a first-motor error),
// and tear down OUTSIDE the `inout` accesses so teardown() never mutates a motor a
// reconcile call still holds an exclusive reference to.
let ok: Bool
if high != nil {
// Per-handle: low = left/heavy motor, high = right/light the XInput convention
// the wire carries.
let okLow = reconcile(&low, to: RumbleTuning.amplitude(target.low))
let okHigh = reconcile(&high, to: RumbleTuning.amplitude(target.high))
ok = okLow && okHigh
} else {
let mixed = RumbleTuning.combined(low: target.low, high: target.high)
ok = reconcile(&low, to: RumbleTuning.amplitude(mixed))
}
if !ok {
let wasSplit = high != nil
teardown()
scheduleRetryBackoff()
if wasSplit, !preferCombined {
preferCombined = true
log.info("rumble: split-handle engines failing — will retry with one combined engine")
}
} else if low?.current != nil || high?.current != nil {
// A player is actually running the path has recovered; clear the backoff.
consecutiveFailures = 0
retryAfter = DispatchTime(uptimeNanoseconds: 0)
reportHealth(nil)
}
}
/// Publish a health transition to the test panel (deduped transitions only).
private func reportHealth(_ problem: String?) {
guard problem != lastHealth else { return }
lastHealth = problem
healthSink?(problem)
}
/// Watchdog + housekeeping heartbeat while audible.
private func tick() {
if let after = policy.staleAfter, target != (0, 0), seconds(since: lastCommand) > after {
// The host refreshes rumble state every 500 ms; this much silence means the channel
// (or host) died while a motor was on. A direct-connected pad would have been
// stopped by its game long ago force the same outcome.
log.warning(
"rumble: no wire refresh for \(after, format: .fixed(precision: 1), privacy: .public)s — auto-silencing")
target = (0, 0)
}
render()
}
/// Drive one motor toward `desired`, per the invariants above. Returns false when the
/// engine errored the caller then tears everything down (outside this `inout` access) for
/// a lazy, backoff-gated rebuild.
private func reconcile(_ slot: inout Motor?, to desired: Float) -> Bool {
guard var m = slot else { return true }
defer { slot = m }
// Release a handed-off predecessor once it has expired on its own.
if let r = m.retiring, m.engine.currentTime >= r.endsAt + 0.25 {
m.retiring = nil
}
if desired <= RumbleTuning.levelEpsilon {
guard m.level > 0 || m.current != nil || m.retiring != nil else { return true }
m.level = 0
return stopSegments(&m)
}
if RumbleTuning.sameLevel(desired, m.level), m.current != nil {
return rearmIfNeeded(&m)
}
// Nonzero level change. Throttled: the ticker re-runs render() and lands the newest
// value once the window opens (zero above is never throttled).
if m.current != nil, seconds(since: m.lastRebake) < RumbleTuning.minRebakeSeconds {
return true
}
guard stopSegments(&m) else { return false }
do {
m.current = try makeSegment(
m.engine, sharpness: m.sharpness, amplitude: desired, at: CHHapticTimeImmediate)
m.level = desired
m.lastRebake = .now()
return true
} catch {
// A transient failure (the engine stopped/reset between its handler firing and now).
// Signal a rebuild do NOT latch rumble off for the session.
log.warning("rumble: haptic start failed — rebuilding: \(error, privacy: .public)")
return false
}
}
/// Keep a steady level seamless across the finite-segment boundary: when the current
/// segment nears its end, start the successor ON the engine timeline exactly as it expires
/// no stop call, no race, no gap. The old segment is kept as `retiring` until it dies
/// naturally, so a level change can still force-stop it.
private func rearmIfNeeded(_ m: inout Motor) -> Bool {
guard let cur = m.current else { return true }
let now = m.engine.currentTime
guard RumbleTuning.shouldRearm(endsAt: cur.endsAt, now: now) else { return true }
// A predecessor still held this deep into the segment already expired; drop it.
m.retiring = nil
do {
let next = try makeSegment(
m.engine, sharpness: m.sharpness, amplitude: m.level,
at: RumbleTuning.handoffStart(endsAt: cur.endsAt, now: now))
m.retiring = m.current
m.current = next
return true
} catch {
log.warning("rumble: segment re-arm failed — rebuilding: \(error, privacy: .public)")
return false
}
}
/// Stop every segment on the motor NOW. False = a stop threw, so the engine's real state is
/// unknown (a player may still run with its handle gone) the caller must escalate to a
/// full engine teardown, whose `engine.stop()` silences every player the engine hosts.
private func stopSegments(_ m: inout Motor) -> Bool {
var ok = true
for seg in [m.current, m.retiring].compactMap({ $0 }) {
do {
try seg.player.stop(atTime: CHHapticTimeImmediate)
} catch {
log.warning(
"rumble: player stop failed — escalating to engine stop: \(error, privacy: .public)")
ok = false
}
}
m.current = nil
m.retiring = nil
return ok
}
/// Build + start one finite continuous event at `amplitude`. `at` is `CHHapticTimeImmediate`
/// or an absolute engine-timeline instant (a scheduled handoff). The intensity is BAKED into
/// the event: a fixed event scaled by a dynamic `.hapticIntensityControl` parameter drives
/// the iPhone Taptic Engine but is silent on a controller's haptic engine.
private func makeSegment(
_ engine: CHHapticEngine, sharpness: Float, amplitude: Float, at start: TimeInterval
) throws -> Segment {
let event = CHHapticEvent(
eventType: .hapticContinuous,
parameters: [
CHHapticEventParameter(parameterID: .hapticIntensity, value: amplitude),
CHHapticEventParameter(parameterID: .hapticSharpness, value: sharpness),
],
relativeTime: 0,
duration: RumbleTuning.segmentSeconds)
let player = try engine.makePlayer(
with: CHHapticPattern(events: [event], parameters: []))
try player.start(atTime: start)
let begins = start == CHHapticTimeImmediate ? engine.currentTime : start
return Segment(player: player, endsAt: begins + RumbleTuning.segmentSeconds)
}
/// The ticker runs only while something needs tending any nonzero target (watchdog,
/// throttle catch-up, HID keepalive, post-reset engine rebuild) or segments still alive.
private func updateTicker() {
let needed = target != (0, 0)
|| low?.current != nil || low?.retiring != nil
|| high?.current != nil || high?.retiring != nil
if needed, ticker == nil {
let t = DispatchSource.makeTimerSource(queue: queue)
t.schedule(
deadline: .now() + RumbleTuning.tickSeconds, repeating: RumbleTuning.tickSeconds)
t.setEventHandler { [weak self] in self?.tick() }
t.resume()
ticker = t
} else if !needed, let t = ticker {
t.cancel()
ticker = nil
}
}
// MARK: - Engine lifecycle
/// Engines per handle when the pad distinguishes them (low = left/heavy motor, /// Engines per handle when the pad distinguishes them (low = left/heavy motor,
/// high = right/light the Xbox/XInput convention the wire carries); one combined /// high = right/light the Xbox/XInput convention the wire carries); one combined
/// engine otherwise, driven by whichever amplitude is stronger. /// engine otherwise, driven by whichever amplitude is stronger.
@@ -130,20 +438,28 @@ final class RumbleRenderer: @unchecked Sendable {
// the controller changes; latch off (retarget clears it) and say so once. // the controller changes; latch off (retarget clears it) and say so once.
log.info("rumble: active controller exposes no haptics engine — rumble unavailable") log.info("rumble: active controller exposes no haptics engine — rumble unavailable")
broken = true broken = true
reportHealth("This controller exposes no rumble engine to apps on this OS.")
return return
} }
let localities = haptics.supportedLocalities let localities = haptics.supportedLocalities
if localities.contains(.leftHandle), localities.contains(.rightHandle) { let split =
low = makeMotor(haptics, .leftHandle) !preferCombined && localities.contains(.leftHandle)
high = makeMotor(haptics, .rightHandle) && localities.contains(.rightHandle)
if split {
low = makeMotor(haptics, .leftHandle, sharpness: RumbleTuning.sharpnessLow)
high = makeMotor(haptics, .rightHandle, sharpness: RumbleTuning.sharpnessHigh)
} else { } else {
low = makeMotor(haptics, .default) low = makeMotor(haptics, .default, sharpness: RumbleTuning.sharpnessCombined)
} }
if low == nil, high == nil { if low == nil, high == nil {
// Haptics present but no engine could be built right now (server busy / XPC broken). Do // Haptics present but no engine could be built right now (server busy / XPC broken). Do
// NOT latch broken back off and the next nonzero amplitude past the cooldown retries. // NOT latch broken back off and a later render past the cooldown retries.
log.warning("rumble: haptics present but engine setup failed — backing off, will retry") log.warning("rumble: haptics present but engine setup failed — backing off, will retry")
scheduleRetryBackoff() scheduleRetryBackoff()
if split {
preferCombined = true
log.info("rumble: split-handle engines failing — will retry with one combined engine")
}
} }
} }
@@ -153,10 +469,20 @@ final class RumbleRenderer: @unchecked Sendable {
private func scheduleRetryBackoff() { private func scheduleRetryBackoff() {
consecutiveFailures += 1 consecutiveFailures += 1
let shift = min(consecutiveFailures - 1, 4) let shift = min(consecutiveFailures - 1, 4)
retryAfter = Date().addingTimeInterval(min(0.5 * Double(1 << shift), 4)) retryAfter = .now() + min(0.5 * Double(1 << shift), 4)
if consecutiveFailures >= 2 {
// One failure is a hiccup; repeated ones are the wedged-service signature (every
// XPC connection to gamecontrollerd.haptics breaks no app on the device can
// rumble until it relaunches). Say so instead of failing silently.
reportHealth(
"The system haptics service is refusing connections — no app can rumble a "
+ "controller right now. Rebooting the device usually clears it.")
}
} }
private func makeMotor(_ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality) -> Motor? { private func makeMotor(
_ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality, sharpness: Float
) -> Motor? {
guard let engine = haptics.createEngine(withLocality: locality) else { return nil } guard let engine = haptics.createEngine(withLocality: locality) else { return nil }
// A controller's motors carry no audio, so keep this engine OUT of the app's audio session // A controller's motors carry no audio, so keep this engine OUT of the app's audio session
// (the default is to join it). Streaming keeps an AVAudioSession active the whole time; // (the default is to join it). Streaming keeps an AVAudioSession active the whole time;
@@ -167,7 +493,8 @@ final class RumbleRenderer: @unchecked Sendable {
// audio-session interruption (a call, Siri, another audio app), or a server crash. Left // audio-session interruption (a call, Siri, another audio app), or a server crash. Left
// unhandled the players go dead and every later rumble throws, latching rumble off for the // unhandled the players go dead and every later rumble throws, latching rumble off for the
// rest of the session (the "rumble worked, then went spotty" failure). Tear down on the // rest of the session (the "rumble worked, then went spotty" failure). Tear down on the
// serial queue so the next nonzero amplitude lazily rebuilds the engine, instead. // serial queue; the ticker (or the next wire update) lazily rebuilds the engine and
// re-renders the still-current target.
engine.stoppedHandler = { [weak self] reason in engine.stoppedHandler = { [weak self] reason in
log.info("rumble: haptic engine stopped (reason \(reason.rawValue, privacy: .public)) — will rebuild") log.info("rumble: haptic engine stopped (reason \(reason.rawValue, privacy: .public)) — will rebuild")
self?.queue.async { self?.teardown() } self?.queue.async { self?.teardown() }
@@ -177,72 +504,42 @@ final class RumbleRenderer: @unchecked Sendable {
self?.queue.async { self?.teardown() } self?.queue.async { self?.teardown() }
} }
do { do {
// Start the engine now; the player that actually moves the motor is built per level // Start the engine now; the players that actually move the motor are the finite
// change in `drive` (a fresh event baked at the target intensity). // segments `reconcile` bakes per level.
try engine.start() try engine.start()
return Motor(engine: engine, player: nil) return Motor(engine: engine, sharpness: sharpness)
} catch { } catch {
log.warning("haptic engine setup failed (\(locality.rawValue, privacy: .public)): \(error, privacy: .public)") log.warning("haptic engine setup failed (\(locality.rawValue, privacy: .public)): \(error, privacy: .public)")
return nil return nil
} }
} }
/// Drive one motor at `amplitude` (0...1) by (re)building a continuous player whose intensity
/// is BAKED into the event. On a DualSense this is what actually moves the actuators: a
/// fixed-intensity event scaled by a dynamic `.hapticIntensityControl` parameter (the old
/// path) drives the iPhone Taptic Engine but is silent on a controller's haptic engine. The
/// event carries an explicit sharpness (frequency) so the voice coils respond, and an infinite
/// duration so a single host update the host sends rumble only when the level changes
/// sustains until the next one. Returns false if the engine errored; the caller tears down for
/// a rebuild (done outside this `inout` access to avoid an exclusivity violation).
private func drive(_ motor: inout Motor?, _ amplitude: Float) -> Bool {
guard var m = motor else { return true }
// Replace any running player: stop the old, and for a zero level leave the motor idle.
try? m.player?.stop(atTime: CHHapticTimeImmediate)
m.player = nil
guard amplitude > 0 else { motor = m; return true }
do {
let event = CHHapticEvent(
eventType: .hapticContinuous,
parameters: [
CHHapticEventParameter(parameterID: .hapticIntensity, value: amplitude),
CHHapticEventParameter(parameterID: .hapticSharpness, value: Self.sharpness),
],
relativeTime: 0,
duration: TimeInterval(GCHapticDurationInfinite))
let player = try m.engine.makeAdvancedPlayer(
with: CHHapticPattern(events: [event], parameters: []))
try player.start(atTime: CHHapticTimeImmediate)
m.player = player
motor = m
return true
} catch {
// A transient failure (the engine stopped/reset between its handler firing and now).
// Signal a rebuild do NOT latch rumble off for the session (the old "spotty" bug).
log.warning("rumble: haptic update failed — rebuilding: \(error, privacy: .public)")
motor = m
return false
}
}
private func teardown() { private func teardown() {
for m in [low, high].compactMap({ $0 }) { for m in [low, high].compactMap({ $0 }) {
// Disarm the handlers before stopping so stop() can't re-enter teardown via them. // Disarm the handlers before stopping so stop() can't re-enter teardown via them.
// (Both properties are non-optional closures on this SDK, so assign no-ops, not nil.) // (Both properties are non-optional closures on this SDK, so assign no-ops, not nil.)
m.engine.stoppedHandler = { _ in } m.engine.stoppedHandler = { _ in }
m.engine.resetHandler = {} m.engine.resetHandler = {}
try? m.player?.stop(atTime: CHHapticTimeImmediate) for seg in [m.current, m.retiring].compactMap({ $0 }) {
try? seg.player.stop(atTime: CHHapticTimeImmediate)
}
// The authoritative silencer: a stopped engine plays nothing, including any player
// whose individual stop was dropped.
m.engine.stop() m.engine.stop()
} }
low = nil low = nil
high = nil high = nil
} }
private func seconds(since t: DispatchTime) -> TimeInterval {
TimeInterval(DispatchTime.now().uptimeNanoseconds - t.uptimeNanoseconds) / 1_000_000_000
}
// MARK: - DualSense raw-HID rumble (macOS) // MARK: - DualSense raw-HID rumble (macOS)
// //
// On macOS the DualSense's motors aren't reachable through CHHapticEngine, so for a DualSense // On macOS the DualSense's motors aren't reachable through CHHapticEngine, so for a DualSense
// we drive them over raw HID (see `DualSenseHID`); every other pad keeps the CoreHaptics path. // we drive them over raw HID (see `DualSenseHID`); every other pad keeps the CoreHaptics path.
// All three run on the serial `queue`, like the rest of the renderer state. // Runs on the serial `queue`, like the rest of the renderer state.
private func openHIDIfDualSense(_ c: GCController?) -> Bool { private func openHIDIfDualSense(_ c: GCController?) -> Bool {
#if os(macOS) #if os(macOS)
@@ -256,12 +553,19 @@ final class RumbleRenderer: @unchecked Sendable {
#endif #endif
} }
/// Drive the DualSense's motors over HID if that's the active backend; false not a HID pad, /// Write the target to the DualSense over HID if that's the active backend; false not a
/// so the caller uses CoreHaptics. The wire's 0...0xFFFF amplitudes scale to the pad's 0...255. /// HID pad, so the caller renders via CoreHaptics. Deduped on the pad's 0...255 resolution,
private func hidRumble(low: UInt16, high: UInt16) -> Bool { /// with a periodic keepalive re-write while nonzero (the ticker calls back in here).
private func renderHID() -> Bool {
#if os(macOS) #if os(macOS)
guard let hid = dualSenseHID else { return false } guard let hid = dualSenseHID else { return false }
hid.rumble(low: UInt8(low >> 8), high: UInt8(high >> 8)) let levels = (RumbleTuning.hidByte(target.low), RumbleTuning.hidByte(target.high))
let keepalive = levels != (0, 0)
&& seconds(since: lastHidWrite.at) > RumbleTuning.hidKeepaliveSeconds
if levels != lastHidWrite.levels || keepalive {
hid.rumble(low: levels.0, high: levels.1)
lastHidWrite = (levels, .now())
}
return true return true
#else #else
return false return false
@@ -270,8 +574,9 @@ final class RumbleRenderer: @unchecked Sendable {
private func closeHID() { private func closeHID() {
#if os(macOS) #if os(macOS)
dualSenseHID?.close() dualSenseHID?.close() // writes (0,0) before releasing
dualSenseHID = nil dualSenseHID = nil
lastHidWrite = ((0, 0), DispatchTime(uptimeNanoseconds: 0))
#endif #endif
} }
@@ -0,0 +1,285 @@
// Finger touches host mouse, for the touchscreen devices: a port of the Android client's
// touch gesture model (clients/android .../TouchInput.kt) so the two touch clients feel
// identical. Two mouse modes share one gesture vocabulary tap = left click · two-finger
// tap = right click · two-finger drag = scroll · tap-then-press-and-drag = held left drag
// (text selection / window moves) · three-finger tap = stats-HUD toggle:
//
// * trackpad (default): the cursor STAYS PUT on touch-down and moves by the finger's
// relative delta with mild acceleration swipe to nudge, lift and re-swipe to walk it
// across, tap to click where it is. This is what makes the cursor reachable on a small
// screen.
// * pointer: the cursor jumps to the finger and follows it (absolute moves through the
// aspect-fit letterbox) direct pointing for desktop-style use.
//
// The third `TouchInputMode` (`touch`) never reaches this type: `StreamLayerUIView` forwards
// those fingers as REAL wire touches (multi-touch passthrough) instead.
#if os(iOS)
import Foundation
import PunktfunkCore
import UIKit
/// How touchscreen fingers drive the host persisted under `DefaultsKey.touchMode`, latched
/// per gesture by `StreamLayerUIView` (a Settings change applies from the NEXT touch, and a
/// gesture never splits across models). `trackpad` is the default: a cursor is the
/// universally workable model; passthrough only helps hosts/apps that actually speak touch.
public enum TouchInputMode: String, CaseIterable, Sendable {
case trackpad
case pointer
case touch
/// The persisted setting, defaulting to trackpad when unset/unknown.
public static var current: TouchInputMode {
TouchInputMode(
rawValue: UserDefaults.standard.string(forKey: DefaultsKey.touchMode) ?? ""
) ?? .trackpad
}
}
/// The gesture state machine behind the two mouse modes. One instance per stream view, fed
/// only the DIRECT touches (fingers/Pencil indirect pointers have their own path). Runs
/// entirely on the main thread (UIKit touch delivery). Touches are tracked by identity key
/// with positions cached per event `UITouch` objects are never retained.
final class TouchMouse {
/// Gesture/ballistics tuning. Distances are in points where they gate gestures; the
/// relative ballistics work in PHYSICAL pixels (point deltas × screen scale) so the
/// acceleration curve matches the Android client's pixel-based constants 1:1.
enum Tuning {
/// Movement under this (pt) still counts as a tap, not a drag.
static let tapSlop: CGFloat = 8
/// A new touch this soon (s) after a tap, near it, starts a held left-button drag.
static let tapDragWindow: TimeInterval = 0.25
/// Two-finger pan distance (pt) per 120-unit wheel notch matches the feel of the
/// indirect-trackpad scroll path in StreamViewIOS (~10 pt per notch).
static let scrollNotchPt: CGFloat = 10
/// Base finger-px host-px gain (~1:1, never twitchy). The acceleration below lets a
/// flick cross the screen while a slow drag stays precise.
static let pointerSens: CGFloat = 1.3
/// Above `accelSpeedFloor` px/ms the gain ramps by `accelGain` per px/ms, capped at
/// `accelMax` (so a fast swipe can't fling the cursor uncontrollably).
static let accelGain: CGFloat = 0.6
static let accelSpeedFloor: CGFloat = 0.3
static let accelMax: CGFloat = 3.0
/// Acceleration multiplier for a finger speed in physical px per ms.
static func accel(forSpeed speed: CGFloat) -> CGFloat {
min(1 + accelGain * max(speed - accelSpeedFloor, 0), accelMax)
}
}
/// Wire events out (the owner gates them on its capture state).
var send: ((PunktfunkInputEvent) -> Void)?
/// View-space point host-mode pixels through the letterbox (pointer mode's moves).
var hostPoint: ((CGPoint) -> StreamLayerUIView.HostPoint?)?
/// No gesture in flight (all fingers up) the view uses this to release its mode latch.
var isIdle: Bool { !sessionActive && lastPos.isEmpty }
private var trackpad = true
/// Last known position per active finger (identity key) kept because moved events only
/// carry the CHANGED touches while the scroll centroid needs every finger.
private var lastPos: [ObjectIdentifier: CGPoint] = [:]
private var sessionActive = false
private var startPoint = CGPoint.zero
private var maxFingers = 0
private var moved = false
private var scrolling = false
private var dragHeld = false
// Trackpad relative-motion state: the tracked finger, its last position/time, and the
// sub-pixel remainder so a slow drag isn't lost to integer truncation.
private var trackKey: ObjectIdentifier?
private var prevPoint = CGPoint.zero
private var prevTime: TimeInterval = 0
private var carryX: CGFloat = 0
private var carryY: CGFloat = 0
/// Scroll anchor (centroid) re-anchored every time a notch fires.
private var scrollAnchor = CGPoint.zero
// Tap-drag arming: a quick tap leaves a window in which the next nearby touch drags.
private var lastTapUp: TimeInterval = 0
private var lastTapPoint = CGPoint.zero
/// GameStream mouse button ids.
private enum Button { static let left: UInt32 = 1; static let right: UInt32 = 3 }
func began(_ touches: Set<UITouch>, in view: UIView, trackpad: Bool) {
let starting = lastPos.isEmpty
for touch in touches {
lastPos[ObjectIdentifier(touch)] = touch.location(in: view)
}
if starting, let first = touches.first {
self.trackpad = trackpad
sessionActive = true
startPoint = first.location(in: view)
maxFingers = 0
moved = false
scrolling = false
// A touch landing just after a quick tap nearby = tap-and-drag: hold the left
// button for this whole gesture (laptop-trackpad convention).
dragHeld = first.timestamp - lastTapUp < Tuning.tapDragWindow
&& abs(startPoint.x - lastTapPoint.x) < Tuning.tapSlop
&& abs(startPoint.y - lastTapPoint.y) < Tuning.tapSlop
lastTapUp = 0 // consume the arming either way
// Pointer mode jumps the cursor to the finger; trackpad leaves it put (the whole
// point you nudge it with swipes instead).
if !trackpad, let h = hostPoint?(startPoint) {
send?(.mouseMoveAbs(x: h.x, y: h.y, surfaceWidth: h.w, surfaceHeight: h.h))
}
if dragHeld { send?(.mouseButton(Button.left, down: true)) }
trackKey = ObjectIdentifier(first)
prevPoint = startPoint
prevTime = first.timestamp
carryX = 0
carryY = 0
}
maxFingers = max(maxFingers, lastPos.count)
}
func moved(_ touches: Set<UITouch>, in view: UIView) {
guard sessionActive else { return }
for touch in touches where lastPos[ObjectIdentifier(touch)] != nil {
lastPos[ObjectIdentifier(touch)] = touch.location(in: view)
}
if lastPos.count >= 2 {
scrollByCentroid()
} else if !scrolling, let touch = touches.first(where: {
lastPos[ObjectIdentifier($0)] != nil
}) {
singleFinger(touch, in: view)
}
}
func ended(_ touches: Set<UITouch>, in view: UIView) {
guard sessionActive || !lastPos.isEmpty else { return }
var upTime: TimeInterval = 0
for touch in touches {
lastPos.removeValue(forKey: ObjectIdentifier(touch))
if trackKey == ObjectIdentifier(touch) { trackKey = nil }
upTime = max(upTime, touch.timestamp)
}
guard lastPos.isEmpty, sessionActive else { return }
sessionActive = false
if dragHeld {
dragHeld = false
send?(.mouseButton(Button.left, down: false)) // end the drag
} else if !moved {
switch maxFingers {
case 3...:
Self.toggleHUD() // in-stream stats-overlay toggle, same as Android
case 2: // two-finger tap right click
send?(.mouseButton(Button.right, down: true))
send?(.mouseButton(Button.right, down: false))
default: // tap left click (at the cursor's current spot), arm tap-drag
send?(.mouseButton(Button.left, down: true))
send?(.mouseButton(Button.left, down: false))
lastTapUp = upTime
lastTapPoint = startPoint
}
}
}
/// System-cancelled touches (incoming call, gesture takeover): release anything held but
/// never synthesize a click out of a cancellation.
func cancelled(_ touches: Set<UITouch>) {
for touch in touches {
lastPos.removeValue(forKey: ObjectIdentifier(touch))
if trackKey == ObjectIdentifier(touch) { trackKey = nil }
}
if lastPos.isEmpty { abortSession() }
}
/// Session teardown: release anything held on the wire and forget all gesture state.
func reset() {
lastPos.removeAll()
trackKey = nil
abortSession()
lastTapUp = 0
}
private func abortSession() {
if dragHeld {
dragHeld = false
send?(.mouseButton(Button.left, down: false))
}
sessionActive = false
scrolling = false
moved = false
}
// MARK: - Per-event work
/// Two fingers (or more) scroll by the centroid delta; never move the cursor. Fires a
/// notch per `scrollNotchPt` of pan and re-anchors on fire; finger up scrolls up, finger
/// right scrolls right (the host WHEEL(120) convention).
private func scrollByCentroid() {
let n = CGFloat(lastPos.count)
let cx = lastPos.values.reduce(0) { $0 + $1.x } / n
let cy = lastPos.values.reduce(0) { $0 + $1.y } / n
if !scrolling {
scrolling = true
scrollAnchor = CGPoint(x: cx, y: cy)
}
let notchesY = Int32((scrollAnchor.y - cy) / Tuning.scrollNotchPt)
let notchesX = Int32((cx - scrollAnchor.x) / Tuning.scrollNotchPt)
if notchesY != 0 {
send?(.scroll(notchesY * 120))
scrollAnchor.y = cy
moved = true
}
if notchesX != 0 {
send?(.scroll(notchesX * 120, horizontal: true))
scrollAnchor.x = cx
moved = true
}
}
/// One finger (and the gesture never became a scroll dropping back from two fingers to
/// one must not jerk the cursor).
private func singleFinger(_ touch: UITouch, in view: UIView) {
let loc = touch.location(in: view)
if abs(loc.x - startPoint.x) > Tuning.tapSlop || abs(loc.y - startPoint.y) > Tuning.tapSlop {
moved = true
}
guard trackpad else {
if let h = hostPoint?(loc) { // pointer mode: the cursor follows the finger
send?(.mouseMoveAbs(x: h.x, y: h.y, surfaceWidth: h.w, surfaceHeight: h.h))
}
return
}
// Relative: move by the finger delta × (sensitivity × acceleration), carrying the
// sub-pixel remainder. Re-anchor (zero delta this frame) if the tracked finger
// changed, so lifting one of several fingers never jumps the cursor.
let key = ObjectIdentifier(touch)
if key != trackKey {
trackKey = key
prevPoint = loc
prevTime = touch.timestamp
return
}
// Ballistics in physical pixels so the curve matches the Android tuning exactly.
let scale = view.window?.screen.scale ?? view.traitCollection.displayScale
let dx = (loc.x - prevPoint.x) * scale
let dy = (loc.y - prevPoint.y) * scale
let dtMs = max((touch.timestamp - prevTime) * 1000, 1)
prevPoint = loc
prevTime = touch.timestamp
let gain = Tuning.pointerSens * Tuning.accel(forSpeed: hypot(dx, dy) / dtMs)
carryX += dx * gain
carryY += dy * gain
let outX = Int32(carryX) // truncates toward zero remainder kept with its sign
let outY = Int32(carryY)
if outX != 0 || outY != 0 {
send?(.mouseMove(dx: outX, dy: outY))
carryX -= CGFloat(outX)
carryY -= CGFloat(outY)
}
}
/// Three-finger tap toggles the stats overlay through the shared `hudEnabled` default,
/// which the app's HUD views observe via @AppStorage (so this needs no wiring to them).
private static func toggleHUD() {
let defaults = UserDefaults.standard
let on = defaults.object(forKey: DefaultsKey.hudEnabled) as? Bool ?? true
defaults.set(!on, forKey: DefaultsKey.hudEnabled)
}
}
#endif
@@ -41,6 +41,11 @@ public enum DefaultsKey {
/// scene and silently falls back to the absolute pointer when it can't (Stage Manager / Slide /// scene and silently falls back to the absolute pointer when it can't (Stage Manager / Slide
/// Over). Read by `StreamViewController.prefersPointerLocked`. /// Over). Read by `StreamViewController.prefersPointerLocked`.
public static let pointerCapture = "punktfunk.pointerCapture" public static let pointerCapture = "punktfunk.pointerCapture"
/// iPhone/iPad: how touchscreen fingers drive the host a `TouchInputMode` raw value:
/// "trackpad" (default: relative cursor with tap-click / two-finger-scroll gestures),
/// "pointer" (the cursor jumps to the finger), or "touch" (real multi-touch passthrough).
/// Read live per gesture by `StreamLayerUIView`.
public static let touchMode = "punktfunk.touchMode"
/// Experimental: show the host's game library (browsed over the management API). Off by default. /// Experimental: show the host's game library (browsed over the management API). Off by default.
public static let libraryEnabled = "punktfunk.libraryEnabled" public static let libraryEnabled = "punktfunk.libraryEnabled"
/// macOS: take the window fullscreen while streaming and restore it on the host list. On by default. /// macOS: take the window fullscreen while streaming and restore it on the host list. On by default.
@@ -339,6 +339,9 @@ public final class StreamViewController: UIViewController {
setCaptured(false) setCaptured(false)
inputCapture?.stop() inputCapture?.stop()
inputCapture = nil inputCapture = nil
// Release anything the touch-driven mouse still holds (a mid-drag session end) while
// onTouchEvent can still deliver the button-up.
streamView.resetTouchInput()
streamView.onTouchEvent = nil streamView.onTouchEvent = nil
streamView.onPointerMoveAbs = nil streamView.onPointerMoveAbs = nil
streamView.onPointerButton = nil streamView.onPointerButton = nil
@@ -454,7 +457,8 @@ final class StreamLayerUIView: UIView {
/// Reads the LIVE negotiated mode in pixels (the touch/pointer coordinate space). /// Reads the LIVE negotiated mode in pixels (the touch/pointer coordinate space).
var currentHostMode: (() -> CGSize)? var currentHostMode: (() -> CGSize)?
/// Direct fingers / Pencil wire touch events. /// Direct fingers / Pencil wire events: real touches in passthrough mode, or the
/// touch-driven mouse events (`TouchMouse`) in the trackpad/pointer modes.
var onTouchEvent: ((PunktfunkInputEvent) -> Void)? var onTouchEvent: ((PunktfunkInputEvent) -> Void)?
/// Indirect pointer (mouse/trackpad with no lock) absolute cursor moves. /// Indirect pointer (mouse/trackpad with no lock) absolute cursor moves.
var onPointerMoveAbs: ((HostPoint) -> Void)? var onPointerMoveAbs: ((HostPoint) -> Void)?
@@ -468,6 +472,22 @@ final class StreamLayerUIView: UIView {
/// GameStream button held per active indirect-pointer touch (one click/drag session); /// GameStream button held per active indirect-pointer touch (one click/drag session);
/// released when that touch ends. /// released when that touch ends.
private var pointerButtons: [ObjectIdentifier: UInt32] = [:] private var pointerButtons: [ObjectIdentifier: UInt32] = [:]
/// Touch-driven mouse for the trackpad/pointer `TouchInputMode`s (see TouchMouse.swift).
private lazy var touchMouse: TouchMouse = {
let mouse = TouchMouse()
mouse.send = { [weak self] event in self?.onTouchEvent?(event) }
mouse.hostPoint = { [weak self] point in self?.hostPoint(from: point) }
return mouse
}()
/// The finger route latched at gesture start a Settings change mid-gesture applies to
/// the NEXT touch, so one gesture never splits across input models.
private var fingerRoute: TouchInputMode?
/// Release anything the touch-driven mouse holds and forget gesture state session stop.
func resetTouchInput() {
touchMouse.reset()
fingerRoute = nil
}
#endif #endif
override init(frame: CGRect) { override init(frame: CGRect) {
@@ -504,10 +524,10 @@ final class StreamLayerUIView: UIView {
route(touches, event: event, kind: .up) route(touches, event: event, kind: .up)
} }
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) { override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
route(touches, event: event, kind: .up) route(touches, event: event, kind: .cancel)
} }
private enum TouchKind { case down, move, up } private enum TouchKind { case down, move, up, cancel }
/// Split a touch batch by kind: an INDIRECT POINTER (mouse/trackpad with no lock) drives /// Split a touch batch by kind: an INDIRECT POINTER (mouse/trackpad with no lock) drives
/// the host cursor as an absolute mouse; everything else (direct finger, Pencil) is a host /// the host cursor as an absolute mouse; everything else (direct finger, Pencil) is a host
@@ -521,7 +541,28 @@ final class StreamLayerUIView: UIView {
fingers.insert(touch) fingers.insert(touch)
} }
} }
if !fingers.isEmpty { forwardTouches(fingers, kind: kind) } if !fingers.isEmpty { forwardFingers(fingers, kind: kind) }
}
/// Route direct fingers by the touch-input model, latched for the whole gesture:
/// passthrough real wire touches; trackpad/pointer the TouchMouse gesture engine.
private func forwardFingers(_ touches: Set<UITouch>, kind: TouchKind) {
let mode = fingerRoute ?? TouchInputMode.current
fingerRoute = mode
switch mode {
case .touch:
// A cancellation lifts the wire touch like a normal up the host just sees the
// contact end.
forwardTouches(touches, kind: kind == .cancel ? .up : kind)
case .trackpad, .pointer:
switch kind {
case .down: touchMouse.began(touches, in: self, trackpad: mode == .trackpad)
case .move: touchMouse.moved(touches, in: self)
case .up: touchMouse.ended(touches, in: self)
case .cancel: touchMouse.cancelled(touches)
}
}
if touchIDs.isEmpty, touchMouse.isIdle { fingerRoute = nil }
} }
/// An indirect-pointer touch is a button-held click/drag session: forward its position as /// An indirect-pointer touch is a button-held click/drag session: forward its position as
@@ -537,7 +578,7 @@ final class StreamLayerUIView: UIView {
onPointerButton?(button, true) onPointerButton?(button, true)
case .move: case .move:
if let host { onPointerMoveAbs?(host) } if let host { onPointerMoveAbs?(host) }
case .up: case .up, .cancel:
if let host { onPointerMoveAbs?(host) } if let host { onPointerMoveAbs?(host) }
if let button = pointerButtons.removeValue(forKey: key) { if let button = pointerButtons.removeValue(forKey: key) {
onPointerButton?(button, false) onPointerButton?(button, false)
@@ -554,7 +595,7 @@ final class StreamLayerUIView: UIView {
case .down: case .down:
id = nextFreeID() id = nextFreeID()
touchIDs[key] = id touchIDs[key] = id
case .move, .up: case .move, .up, .cancel:
guard let known = touchIDs[key] else { continue } guard let known = touchIDs[key] else { continue }
id = known id = known
} }
@@ -0,0 +1,97 @@
import XCTest
@testable import PunktfunkKit
/// Pins the rumble renderer's pure scheduling/mapping decisions and the relations between its
/// tuning constants that the design depends on (see `RumbleRenderer`'s invariants). No
/// CHHapticEngine or physical pad involved.
final class RumbleTuningTests: XCTestCase {
func testAmplitudeMapsWireRangeToUnitInterval() {
XCTAssertEqual(RumbleTuning.amplitude(0), 0)
XCTAssertEqual(RumbleTuning.amplitude(0xFFFF), 1)
XCTAssertEqual(RumbleTuning.amplitude(0x8000), Float(0x8000) / 65535, accuracy: 1e-6)
// Monotonic a stronger wire value can never render weaker.
XCTAssertLessThan(RumbleTuning.amplitude(0x1000), RumbleTuning.amplitude(0x2000))
}
func testHidByteMapsWireRangeToPadRange() {
XCTAssertEqual(RumbleTuning.hidByte(0), 0)
XCTAssertEqual(RumbleTuning.hidByte(0xFFFF), 255)
XCTAssertEqual(RumbleTuning.hidByte(0x8000), 0x80)
}
func testCombinedActuatorRendersStrongerMotor() {
XCTAssertEqual(RumbleTuning.combined(low: 0x4000, high: 0x8000), 0x8000)
XCTAssertEqual(RumbleTuning.combined(low: 0x8000, high: 0x4000), 0x8000)
XCTAssertEqual(RumbleTuning.combined(low: 0, high: 0), 0)
}
func testLevelDedupeEpsilon() {
// An identical host refresh (and LSB jitter) is the same level no player rebuild.
XCTAssertTrue(RumbleTuning.sameLevel(0.5, 0.5))
XCTAssertTrue(RumbleTuning.sameLevel(0.5, 0.5 + RumbleTuning.levelEpsilon))
// A real level change is not.
XCTAssertFalse(RumbleTuning.sameLevel(0.5, 0.5 + RumbleTuning.levelEpsilon * 3))
XCTAssertFalse(RumbleTuning.sameLevel(0, 1))
}
func testRearmDecision() {
let ends: TimeInterval = 100
XCTAssertFalse(
RumbleTuning.shouldRearm(endsAt: ends, now: ends - RumbleTuning.rearmHeadroom - 0.1))
XCTAssertTrue(
RumbleTuning.shouldRearm(endsAt: ends, now: ends - RumbleTuning.rearmHeadroom + 0.1))
// Even a segment already past its end re-arms (the gap already happened; recover).
XCTAssertTrue(RumbleTuning.shouldRearm(endsAt: ends, now: ends + 1))
}
func testHandoffStartsAtSegmentEndNeverInThePast() {
// Successor starts exactly at the predecessor's end...
XCTAssertEqual(RumbleTuning.handoffStart(endsAt: 100, now: 99.5), 100)
// ...unless that instant already passed then start immediately, not in the past.
XCTAssertEqual(RumbleTuning.handoffStart(endsAt: 100, now: 100.5), 100.5)
}
func testPolicies() {
// The session policy ties motor life to wire liveness; the manual (test-panel) policy
// holds a level indefinitely.
XCTAssertNotNil(RumbleRenderer.Policy.session.staleAfter)
XCTAssertNil(RumbleRenderer.Policy.manual.staleAfter)
}
/// Exercise the renderer's queue/ticker machinery without a physical pad: a wire-rate call
/// storm, an audible target left to the ticker (watchdog path), then `stop()` which runs
/// `queue.sync` against the same serial queue the ticker fires on and must not deadlock.
func testRendererSurvivesCallStormAndTeardownWithoutController() {
let renderer = RumbleRenderer(policy: .session)
renderer.retarget(nil)
for i in 0..<500 {
renderer.apply(
low: i % 2 == 0 ? 0x8000 : 0, high: UInt16(truncatingIfNeeded: i &* 37))
}
// Leave a nonzero target long enough for the ticker to spin a few times.
renderer.apply(low: 0x4000, high: 0x4000)
Thread.sleep(forTimeInterval: 0.2)
renderer.stop()
}
func testTuningRelationsTheDesignDependsOn() {
// The watchdog must tolerate a couple of lost 500 ms host refreshes (heals, not gaps)
// but trip well before a stuck rumble reads as "still going".
XCTAssertGreaterThan(RumbleTuning.sessionStaleSeconds, 2 * 0.5)
XCTAssertLessThanOrEqual(RumbleTuning.sessionStaleSeconds, 2.5)
// Re-arm headroom must clear several ticker periods, or a steady rumble could miss the
// segment boundary and gap.
XCTAssertGreaterThanOrEqual(
RumbleTuning.rearmHeadroom, 4 * RumbleTuning.tickSeconds)
// The headroom must fit inside a segment, or re-arm would trigger instantly forever.
XCTAssertLessThan(RumbleTuning.rearmHeadroom, RumbleTuning.segmentSeconds)
// The rebake throttle must be far under the host refresh period, or refreshed level
// changes would queue behind it; and under a frame at 30 fps so ramps stay smooth.
XCTAssertLessThan(RumbleTuning.minRebakeSeconds, 1.0 / 30)
// The ticker (which lands throttled levels) must outpace the HID keepalive and the
// watchdog, or those deadlines could be overshot by a full period.
XCTAssertLessThan(RumbleTuning.tickSeconds, RumbleTuning.hidKeepaliveSeconds)
XCTAssertLessThan(RumbleTuning.tickSeconds, RumbleTuning.sessionStaleSeconds)
}
}
@@ -0,0 +1,42 @@
#if os(iOS)
import XCTest
@testable import PunktfunkKit
/// Pins the touch-mouse tuning contract (ported 1:1 from the Android client's TouchInput.kt
/// so the two touch clients feel identical) and the mode parsing. The gesture state machine
/// itself needs UITouch instances and is validated on-glass.
final class TouchMouseTests: XCTestCase {
func testModeParsingDefaultsToTrackpad() {
XCTAssertEqual(TouchInputMode(rawValue: "trackpad"), .trackpad)
XCTAssertEqual(TouchInputMode(rawValue: "pointer"), .pointer)
XCTAssertEqual(TouchInputMode(rawValue: "touch"), .touch)
// Unknown/unset values must fall back to trackpad never crash or go touch-silent.
XCTAssertNil(TouchInputMode(rawValue: "bogus"))
}
func testAccelerationCurve() {
// At or below the speed floor: no acceleration slow drags stay precise.
XCTAssertEqual(TouchMouse.Tuning.accel(forSpeed: 0), 1)
XCTAssertEqual(TouchMouse.Tuning.accel(forSpeed: TouchMouse.Tuning.accelSpeedFloor), 1)
// Above the floor the gain ramps...
let mid = TouchMouse.Tuning.accel(forSpeed: 1.0)
XCTAssertGreaterThan(mid, 1)
XCTAssertLessThan(mid, TouchMouse.Tuning.accelMax)
// ...and a flick is capped so it can't fling the cursor uncontrollably.
XCTAssertEqual(TouchMouse.Tuning.accel(forSpeed: 100), TouchMouse.Tuning.accelMax)
// Monotonic in between.
XCTAssertLessThanOrEqual(
TouchMouse.Tuning.accel(forSpeed: 0.5), TouchMouse.Tuning.accel(forSpeed: 1.5))
}
func testTuningRelations() {
// The tap-drag window must be long enough to hit but short enough not to turn every
// second tap into a drag.
XCTAssertGreaterThan(TouchMouse.Tuning.tapDragWindow, 0.1)
XCTAssertLessThan(TouchMouse.Tuning.tapDragWindow, 0.5)
// A wheel notch per ~10 pt of two-finger pan (the indirect-trackpad path's feel).
XCTAssertGreaterThan(TouchMouse.Tuning.scrollNotchPt, 0)
}
}
#endif
+20 -16
View File
@@ -1,7 +1,7 @@
# punktfunk — Steam Deck plugin (Decky) # Punktfunk — Steam Deck plugin (Decky)
Stream to your **Steam Deck** without ever leaving Gaming Mode. This Stream to your **Steam Deck** without ever leaving Gaming Mode. This
**[Decky Loader](https://decky.xyz/)** plugin adds a **punktfunk** panel to the Quick Access Menu **[Decky Loader](https://decky.xyz/)** plugin adds a **Punktfunk** panel to the Quick Access Menu
(the `…` button): discover hosts on your network, pair with a PIN, tweak stream settings, and launch (the `…` button): discover hosts on your network, pair with a PIN, tweak stream settings, and launch
a fullscreen, gamescope-focused stream — all from the couch, gamepad-navigable. a fullscreen, gamescope-focused stream — all from the couch, gamepad-navigable.
@@ -12,12 +12,16 @@ the panel looks and feels native to Gaming Mode.
## What it does ## What it does
1. **Discover** — browses the LAN over mDNS for punktfunk hosts, in both the QAM panel and a 1. **Discover** — browses the LAN over mDNS for Punktfunk hosts, in both the QAM panel and a
fullscreen page. fullscreen page; each host row opens a details view (address, pairing policy, certificate
fingerprint to cross-check against the host's log).
2. **Pair** — for a host that requires it, a gamepad-navigable PIN keypad runs the SPAKE2 pairing 2. **Pair** — for a host that requires it, a gamepad-navigable PIN keypad runs the SPAKE2 pairing
ceremony headlessly, then remembers the host so future streams connect silently. ceremony headlessly, then remembers the host so future streams connect silently.
3. **Stream** — launches fullscreen via a hidden Steam shortcut so gamescope focuses it. 3. **Stream** — launches fullscreen via a hidden Steam shortcut so gamescope focuses it.
4. **Settings** — resolution / refresh / bitrate / gamepad / mic, written to the client's config. 4. **Settings** — resolution / refresh / bitrate / gamepad type / host compositor / mic, written
to the client's config.
5. **About** — plugin version, an explicit "Check for updates" button, the setup-guide link, and
a force-stop for a wedged stream client.
To leave a stream: the in-client controller chord (**L1 + R1 + Start + Select**), or close the To leave a stream: the in-client controller chord (**L1 + R1 + Start + Select**), or close the
"game" from the Steam overlay — either returns you to Gaming Mode. "game" from the Steam overlay — either returns you to Gaming Mode.
@@ -37,8 +41,10 @@ https://git.unom.io/api/packages/unom/generic/punktfunk-decky/latest/punktfunk.z
``` ```
(or a pinned `.../punktfunk-decky/<version>/punktfunk.zip`). The plugin then **self-updates** without (or a pinned `.../punktfunk-decky/<version>/punktfunk.zip`). The plugin then **self-updates** without
the Decky store — when a newer build exists, an **Update to vX** button appears and drives Decky the Decky store — when a newer build exists, an **Update** button appears and drives Decky
Loader's own (SHA-256-verified) install. Loader's own (SHA-256-verified) install. Installs and updates can take a couple of minutes on some
networks: Decky's installer also contacts its plugin store first, which may be slow or blackholed
before the actual download proceeds.
## Build & sideload (development) ## Build & sideload (development)
@@ -58,20 +64,18 @@ restart is required for an out-of-band install to appear.
| File | Role | | File | Role |
| --- | --- | | --- | --- |
| `src/index.tsx` | Frontend: QAM panel + the `/punktfunk` fullscreen page (host list, PIN keypad, settings). | | `src/index.tsx` | Plugin entry: the QAM panel + route registration. |
| `src/steam.ts` | Steam-shortcut launch (`AddShortcut` / `SetAppLaunchOptions` / `RunGame`) — the focus-correct stream start. | | `src/page.tsx` | The `/punktfunk` fullscreen page — Hosts (with per-host details) / Settings / About tabs. |
| `src/settings.tsx` · `src/pair.tsx` | Stream-settings section; the gamepad-navigable PIN-pairing modal. |
| `src/hooks.ts` · `src/boundary.tsx` | Shared discovery/update hooks + actions; the render error boundary. |
| `src/steam.ts` | Steam-shortcut launch (`AddShortcut` / `SetAppLaunchOptions` / `RunGame`) — the focus-correct stream start. The shortcut's exe is `/bin/sh` with the wrapper passed as an argument, so the script never needs an exec bit (Decky's zip extraction drops it and the root-owned plugins dir can't be chmodded by the unprivileged backend). |
| `src/backend.ts` | Typed `callable` bridges to `main.py`. | | `src/backend.ts` | Typed `callable` bridges to `main.py`. |
| `bin/punktfunkrun.sh` | The launch wrapper the Steam shortcut targets (so the window is focusable). | | `bin/punktfunkrun.sh` | The launch wrapper the Steam shortcut runs (so the window is focusable). |
| `main.py` | Backend: `discover` (via `avahi-browse`) / `pair` / settings / `kill_stream` / `check_update`. | | `main.py` | Backend: `discover` (via `avahi-browse`) / `pair` / settings / `kill_stream` / `check_update` (with an explicit CA-bundle search — Decky's embedded Python has no usable default TLS roots on SteamOS). |
| `plugin.json` · `update.json` | Decky manifest; CI-baked update channel. | | `plugin.json` · `update.json` | Decky manifest; CI-baked update channel. |
The client binary is resolved `PATH``/usr/bin``/usr/local/bin``~/.local/bin` → a
`flatpak run io.unom.Punktfunk` fallback, so the flatpak install always works.
## Limitations / next steps ## Limitations / next steps
- **Needs on-Deck validation in Gaming Mode** — the Steam-shortcut launch and headless pairing follow
MoonDeck's proven pattern but are verified only at build time here.
- No manual "add host by IP" entry yet (discovery is mDNS-only). - No manual "add host by IP" entry yet (discovery is mDNS-only).
- No in-stream overlay inside the plugin — the client owns the session once launched. - No in-stream overlay inside the plugin — the client owns the session once launched.
- Pairing needs the operator to **arm pairing on the host** so it shows the PIN; the plugin can't arm - Pairing needs the operator to **arm pairing on the host** so it shows the PIN; the plugin can't arm
+5
View File
@@ -18,6 +18,11 @@
# #
# Runs as the `deck` user (Steam launched it), so the --user flatpak install is visible and # Runs as the `deck` user (Steam launched it), so the --user flatpak install is visible and
# WAYLAND_DISPLAY / XDG_RUNTIME_DIR are already correct for gamescope. # WAYLAND_DISPLAY / XDG_RUNTIME_DIR are already correct for gamescope.
#
# NO EXEC BIT REQUIRED: the Steam shortcut's exe is `/bin/sh` and this script rides behind
# `%command%` as an argument (see src/steam.ts). Decky extracts plugin zips without preserving
# permission bits and ~/homebrew/plugins is root-owned (the unprivileged plugin backend can't
# chmod), so the launch path must never depend on +x. Keep this script POSIX-sh clean.
set -u set -u
APPID="${PF_APPID:-io.unom.Punktfunk}" APPID="${PF_APPID:-io.unom.Punktfunk}"
+60 -9
View File
@@ -29,7 +29,6 @@ import json
import os import os
import shutil import shutil
import ssl import ssl
import stat
import time import time
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
@@ -125,13 +124,68 @@ def _semver_tuple(v: str) -> tuple[int, int, int]:
return (parts[0], parts[1], parts[2]) return (parts[0], parts[1], parts[2])
# Decky Loader ships its own embedded (PyInstaller) Python whose compiled-in OpenSSL default
# verify paths don't exist on SteamOS — ``ssl.create_default_context()`` then trusts NOTHING
# and every HTTPS fetch dies with CERTIFICATE_VERIFY_FAILED (seen live on the Deck). Fix: find
# a real CA bundle on disk and load it explicitly. Verification is NEVER disabled — if no
# bundle exists the fetch just fails, and check_update() is non-fatal by design.
_CA_BUNDLES = (
"/etc/ssl/certs/ca-certificates.crt", # SteamOS / Arch / Debian / Ubuntu
"/etc/ssl/cert.pem", # Arch/openssl compat symlink
"/etc/pki/tls/certs/ca-bundle.crt", # Fedora / Bazzite
"/etc/ssl/ca-bundle.pem", # openSUSE
)
_ssl_context_cache: ssl.SSLContext | None = None
def _build_ssl_context() -> ssl.SSLContext:
"""A verifying SSLContext that actually has CA roots under Decky's embedded Python."""
ctx = ssl.create_default_context() # honors SSL_CERT_FILE / SSL_CERT_DIR when set
if ctx.cert_store_stats().get("x509_ca", 0):
return ctx # the interpreter found its own roots (e.g. a system python)
dvp = ssl.get_default_verify_paths()
candidates: list[str | None] = [dvp.cafile, dvp.openssl_cafile, *_CA_BUNDLES]
try: # not shipped by Decky's runtime, but honor it when importable
import certifi
candidates.append(certifi.where())
except ImportError:
pass
tried: set[str] = set()
for cafile in candidates:
if not cafile or cafile in tried or not Path(cafile).is_file():
continue
tried.add(cafile)
try:
ctx.load_verify_locations(cafile=cafile)
except (ssl.SSLError, OSError):
continue
if ctx.cert_store_stats().get("x509_ca", 0):
decky.logger.info("TLS roots loaded from %s", cafile)
return ctx
decky.logger.warning(
"no CA bundle found — HTTPS update checks will fail certificate verification"
)
return ctx
def _ssl_context() -> ssl.SSLContext:
"""The (cached) context for registry fetches; building it scans disk, so do it once."""
global _ssl_context_cache
if _ssl_context_cache is None:
_ssl_context_cache = _build_ssl_context()
return _ssl_context_cache
def _fetch_json(url: str, timeout: float = 8.0) -> dict: def _fetch_json(url: str, timeout: float = 8.0) -> dict:
"""Blocking HTTPS GET of a small JSON document (run in an executor).""" """Blocking HTTPS GET of a small JSON document (run in an executor)."""
req = urllib.request.Request( req = urllib.request.Request(
url, headers={"Accept": "application/json", "User-Agent": "punktfunk-decky"} url, headers={"Accept": "application/json", "User-Agent": "punktfunk-decky"}
) )
ctx = ssl.create_default_context() with urllib.request.urlopen(req, timeout=timeout, context=_ssl_context()) as resp:
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
return json.loads(resp.read().decode("utf-8", errors="replace")) return json.loads(resp.read().decode("utf-8", errors="replace"))
@@ -319,13 +373,10 @@ class Plugin:
async def runner_info(self) -> dict: async def runner_info(self) -> dict:
"""The wrapper-script path + flatpak app id the frontend needs to create the Steam """The wrapper-script path + flatpak app id the frontend needs to create the Steam
shortcut. Also (re)asserts the script's exec bit — packaging can drop it.""" shortcut. The shortcut invokes the script through ``/bin/sh`` (see steam.ts), so no
exec bit is needed — Decky's zip extraction drops it, and the root-owned plugins dir
means this unprivileged backend couldn't chmod it back on anyway."""
path = _runner_path() path = _runner_path()
try:
st = os.stat(path)
os.chmod(path, st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
except OSError:
decky.logger.warning("could not chmod runner %s", path)
return {"runner": path, "app_id": APP_ID, "exists": Path(path).exists()} return {"runner": path, "app_id": APP_ID, "exists": Path(path).exists()}
async def get_settings(self) -> dict: async def get_settings(self) -> dict:
+3 -2
View File
@@ -1,14 +1,15 @@
{ {
"name": "punktfunk-decky", "name": "punktfunk-decky",
"version": "0.0.1", "version": "0.0.1",
"description": "SteamOS / Steam Deck Gaming-Mode launcher for the punktfunk streaming client.", "description": "SteamOS / Steam Deck Gaming-Mode launcher for the Punktfunk streaming client.",
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "rollup -c", "build": "rollup -c",
"watch": "rollup -c -w", "watch": "rollup -c -w",
"typecheck": "tsc --noEmit --skipLibCheck",
"package": "pnpm build && bash scripts/package.sh", "package": "pnpm build && bash scripts/package.sh",
"deploy": "bash scripts/deploy.sh", "deploy": "bash scripts/deploy.sh",
"test": "echo \"Error: no test specified\" && exit 1" "test": "pnpm typecheck"
}, },
"keywords": [ "keywords": [
"decky", "decky",
+1 -1
View File
@@ -5,7 +5,7 @@
"api_version": 1, "api_version": 1,
"publish": { "publish": {
"tags": ["streaming", "game-streaming", "remote-play"], "tags": ["streaming", "game-streaming", "remote-play"],
"description": "Launch the punktfunk low-latency streaming client from Gaming Mode: discover hosts on the LAN over mDNS and connect to one.", "description": "Launch the Punktfunk low-latency streaming client from Gaming Mode: discover hosts on the LAN over mDNS, pair with a PIN, and stream.",
"image": "https://opengraph.githubassets.com/1/SteamDeckHomebrew/PluginLoader" "image": "https://opengraph.githubassets.com/1/SteamDeckHomebrew/PluginLoader"
} }
} }
+6 -2
View File
@@ -6,7 +6,8 @@ export interface Host {
host: string; host: string;
port: number; port: number;
pair: string; // "required" | "optional" — the HOST's policy pair: string; // "required" | "optional" — the HOST's policy
fp: string; fp: string; // host cert SHA-256 fingerprint (lowercase hex) from the mDNS advert
proto: string; // advertised protocol, e.g. "punktfunk/1"
paired: boolean; // whether THIS device has already PIN-paired this host (by fingerprint) paired: boolean; // whether THIS device has already PIN-paired this host (by fingerprint)
} }
@@ -22,12 +23,15 @@ export interface RunnerInfo {
exists: boolean; exists: boolean;
} }
// The slice of the flatpak client's settings JSON this UI surfaces. The file can hold more
// keys (codec, decoder, … set from the desktop client's own UI) — they round-trip untouched
// because get_settings returns the whole parsed file and patches are object spreads.
export interface StreamSettings { export interface StreamSettings {
width: number; // 0 = native width: number; // 0 = native
height: number; // 0 = native height: number; // 0 = native
refresh_hz: number; // 0 = native refresh_hz: number; // 0 = native
bitrate_kbps: number; // 0 = host default bitrate_kbps: number; // 0 = host default
gamepad: string; // "auto" | "xbox360" | "dualsense" gamepad: string; // "auto" | "xbox360" | "xboxone" | "dualsense" | "dualshock4" | "steamdeck"
compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope" compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope"
inhibit_shortcuts: boolean; inhibit_shortcuts: boolean;
mic_enabled: boolean; mic_enabled: boolean;
+51
View File
@@ -0,0 +1,51 @@
// Error boundary — contains ANY render failure in our UI so a single bad render can never take
// down the whole Quick Access "Decky" section (Decky's tab-level boundary shows the generic
// "Something went wrong while displaying this content" for the entire tab when one plugin
// throws). The realistic trigger is a future Steam client update that makes a @decky/ui
// component resolve to `undefined` (React then throws "Element type is invalid"). The fallback
// is built from ONLY plain DOM elements + inline styles, so it cannot itself depend on a
// (possibly broken) Steam-internal component — it is guaranteed to render.
import { Component, ErrorInfo, ReactNode } from "react";
export class PluginErrorBoundary extends Component<
{ children: ReactNode },
{ error: Error | null }
> {
state: { error: Error | null } = { error: null };
static getDerivedStateFromError(error: Error) {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
// Surface it for diagnosis, but never rethrow — containment is the whole point.
// eslint-disable-next-line no-console
console.error("[punktfunk] contained UI render error:", error, info?.componentStack);
}
render() {
const { error } = this.state;
if (!error) return this.props.children;
return (
<div style={{ padding: "1em", lineHeight: 1.45 }}>
<div style={{ fontWeight: "bold", marginBottom: "0.4em" }}>
Punktfunk couldnt draw this view
</div>
<div style={{ opacity: 0.8, marginBottom: "0.6em" }}>
The plugin hit a display error your Steam Deck is fine. Reload Punktfunk from
Decky&apos;s plugin list, or update the plugin.
</div>
<div
style={{
opacity: 0.55,
fontFamily: "monospace",
fontSize: "0.8em",
wordBreak: "break-word",
}}
>
{String(error?.message ?? error)}
</div>
</div>
);
}
}
+139
View File
@@ -0,0 +1,139 @@
// Shared state hooks + user actions for the QAM panel and the fullscreen page.
import { toaster } from "@decky/api";
import { Navigation } from "@decky/ui";
import { useCallback, useEffect, useState } from "react";
import { checkUpdate, discover, Host, UpdateInfo } from "./backend";
import { launchStream } from "./steam";
export const DOCS_URL = "https://docs.punktfunk.unom.io/docs/steam-deck";
// Decky Loader exposes its already-authenticated WSRouter as a global. This is NOT part of
// @decky/api (it's a loader internal), so we treat it as optional and guard every use — on a
// loader without it we fall back to manual "Install Plugin from URL". We use it to drive
// Decky's own privileged install path (the root loader does the download + SHA-256 verify +
// extract + hot-reload), which is the only way a plugin can update itself: ~/homebrew/plugins
// is root-owned, so our unprivileged backend can't swap its own files.
declare global {
interface Window {
DeckyBackend?: {
callable: (route: string) => (...args: unknown[]) => Promise<unknown>;
};
}
}
// PluginInstallType.UPDATE in decky-loader's browser.py (INSTALL=0/REINSTALL=1/UPDATE=2/…).
const INSTALL_TYPE_UPDATE = 2;
// ----------------------------------------------------------------------------------------
// Discovery — mDNS scan state shared by the QAM panel and the full page.
// ----------------------------------------------------------------------------------------
export function useHosts() {
const [hosts, setHosts] = useState<Host[]>([]);
const [scanning, setScanning] = useState(false);
const refresh = useCallback(async () => {
setScanning(true);
try {
setHosts(await discover());
} catch (e) {
toaster.toast({ title: "Punktfunk", body: `Discovery failed: ${e}` });
} finally {
setScanning(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
return { hosts, scanning, refresh };
}
// ----------------------------------------------------------------------------------------
// Self-update — checks our registry on mount (the backend caches for 30 min + is non-fatal
// offline); `check(true)` bypasses the cache for the explicit "Check for updates" button.
// ----------------------------------------------------------------------------------------
export function useUpdate() {
const [info, setInfo] = useState<UpdateInfo | null>(null);
const [checking, setChecking] = useState(false);
const check = useCallback(async (force: boolean): Promise<UpdateInfo | null> => {
setChecking(true);
try {
const res = await checkUpdate(force);
setInfo(res);
return res;
} catch {
return null;
} finally {
setChecking(false);
}
}, []);
useEffect(() => {
void check(false);
}, [check]);
return { info, checking, check };
}
/** The explicit "Check for updates" action — always ends in a toast so the tap has feedback. */
export async function checkForUpdatesNow(
check: (force: boolean) => Promise<UpdateInfo | null>,
): Promise<void> {
const res = await check(true);
let body: string;
if (!res || res.error === "fetch-failed") {
body = "Couldnt reach the update server — are you online?";
} else if (res.error === "update-channel-unknown") {
body = "Development build — update checks are disabled.";
} else if (res.update_available) {
body = `Update available: v${res.current} → v${res.latest}.`;
} else {
body = `Youre up to date (v${res.current}).`;
}
toaster.toast({ title: "Punktfunk", body });
}
export async function applyUpdate(info: UpdateInfo): Promise<void> {
try {
const backend = window.DeckyBackend;
if (backend?.callable) {
// Fire-and-forget: the loader reinstalls + reloads THIS plugin, tearing the panel down
// before any result could arrive — so never await it. Decky shows its own confirm prompt.
void backend.callable("utilities/install_plugin")(
info.artifact,
"punktfunk",
info.latest,
info.hash,
INSTALL_TYPE_UPDATE,
);
toaster.toast({
title: "Punktfunk",
// Decky's installer also phones the plugin store first, which can hang on some
// networks before the actual install proceeds — set expectations.
body: `Updating to v${info.latest} — confirm Deckys prompt. This can take a couple of minutes.`,
});
return;
}
} catch {
// fall through to the manual path
}
toaster.toast({
title: "Punktfunk",
body: "Update from Decky → Developer → Install Plugin from URL.",
});
}
// ----------------------------------------------------------------------------------------
// Stream launch — via the hidden Steam shortcut (see steam.ts for why).
// ----------------------------------------------------------------------------------------
export async function startStream(h: Host): Promise<void> {
try {
await launchStream(h.host, h.port);
Navigation.CloseSideMenus();
toaster.toast({ title: "Punktfunk", body: `Starting stream — ${h.name}` });
} catch (e) {
toaster.toast({ title: "Punktfunk", body: `Launch failed: ${e}` });
}
}
+56 -558
View File
@@ -1,591 +1,65 @@
// Plugin entry: the Quick Access Menu panel + route registration. The fullscreen page lives
// in page.tsx; shared hooks/actions in hooks.ts; the Steam-shortcut launch in steam.ts.
import { import {
ButtonItem, ButtonItem,
Dropdown,
Field, Field,
Focusable,
DialogButton,
ModalRoot,
Navigation, Navigation,
PanelSection, PanelSection,
PanelSectionRow, PanelSectionRow,
SliderField,
Spinner, Spinner,
Tabs,
ToggleField,
showModal, showModal,
staticClasses, staticClasses,
} from "@decky/ui"; } from "@decky/ui";
import { definePlugin, routerHook, toaster } from "@decky/api"; import { definePlugin, routerHook } from "@decky/api";
import { import { FC } from "react";
Component, import { FaDownload, FaLock, FaLockOpen, FaSyncAlt, FaTv } from "react-icons/fa";
CSSProperties, import { PluginErrorBoundary } from "./boundary";
ErrorInfo, import { applyUpdate, checkForUpdatesNow, startStream, useHosts, useUpdate } from "./hooks";
FC, import { PunktfunkRoute, ROUTE } from "./page";
ReactNode, import { PairModal } from "./pair";
useCallback,
useEffect,
useState,
} from "react";
import {
FaTv,
FaSyncAlt,
FaLock,
FaLockOpen,
FaPlay,
FaArrowLeft,
FaDownload,
} from "react-icons/fa";
import {
discover,
getSettings,
pair,
setSettings,
checkUpdate,
Host,
StreamSettings,
UpdateInfo,
} from "./backend";
import { launchStream } from "./steam";
const ROUTE = "/punktfunk";
// Decky Loader exposes its already-authenticated WSRouter as a global. This is NOT part of
// @decky/api (it's a loader internal), so we treat it as optional and guard every use — on a
// loader without it we fall back to manual "Install Plugin from URL". We use it to drive
// Decky's own privileged install path (the root loader does the download + SHA-256 verify +
// extract + hot-reload), which is the only way a plugin can update itself: ~/homebrew/plugins
// is root-owned, so our unprivileged backend can't swap its own files.
declare global {
interface Window {
DeckyBackend?: {
callable: (route: string) => (...args: unknown[]) => Promise<unknown>;
};
}
}
// PluginInstallType.UPDATE in decky-loader's browser.py (INSTALL=0/REINSTALL=1/UPDATE=2/…).
const INSTALL_TYPE_UPDATE = 2;
// ----------------------------------------------------------------------------------------
// Error boundary — contains ANY render failure in our UI so a single bad render can never take
// down the whole Quick Access "Decky" section (Decky's tab-level boundary shows the generic
// "Something went wrong while displaying this content" for the entire tab when one plugin
// throws). The realistic trigger is a future Steam client update that makes a @decky/ui
// component resolve to `undefined` (React then throws "Element type is invalid"). The fallback
// is built from ONLY plain DOM elements + inline styles, so it cannot itself depend on a
// (possibly broken) Steam-internal component — it is guaranteed to render.
// ----------------------------------------------------------------------------------------
class PluginErrorBoundary extends Component<
{ children: ReactNode },
{ error: Error | null }
> {
state: { error: Error | null } = { error: null };
static getDerivedStateFromError(error: Error) {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
// Surface it for diagnosis, but never rethrow — containment is the whole point.
// eslint-disable-next-line no-console
console.error("[punktfunk] contained UI render error:", error, info?.componentStack);
}
render() {
const { error } = this.state;
if (!error) return this.props.children;
return (
<div style={{ padding: "1em", lineHeight: 1.45 }}>
<div style={{ fontWeight: "bold", marginBottom: "0.4em" }}>
punktfunk couldnt draw this view
</div>
<div style={{ opacity: 0.8, marginBottom: "0.6em" }}>
The plugin hit a display error your Steam Deck is fine. Reload punktfunk from
Decky&apos;s plugin list, or update the plugin.
</div>
<div
style={{
opacity: 0.55,
fontFamily: "monospace",
fontSize: "0.8em",
wordBreak: "break-word",
}}
>
{String(error?.message ?? error)}
</div>
</div>
);
}
}
// Checks our registry for a newer build on mount (the backend caches + is non-fatal offline).
function useUpdate() {
const [info, setInfo] = useState<UpdateInfo | null>(null);
useEffect(() => {
void checkUpdate(false)
.then(setInfo)
.catch(() => {});
}, []);
return info;
}
async function applyUpdate(info: UpdateInfo) {
try {
const backend = window.DeckyBackend;
if (backend?.callable) {
// Fire-and-forget: the loader reinstalls + reloads THIS plugin, tearing the panel down
// before any result could arrive — so never await it. Decky shows its own confirm prompt.
void backend.callable("utilities/install_plugin")(
info.artifact,
"punktfunk",
info.latest,
info.hash,
INSTALL_TYPE_UPDATE,
);
toaster.toast({
title: "punktfunk",
body: `Updating to v${info.latest}… confirm the Decky prompt.`,
});
return;
}
} catch {
// fall through to the manual path
}
toaster.toast({
title: "punktfunk",
body: "Update from Decky → Developer → Install Plugin from URL.",
});
}
// ----------------------------------------------------------------------------------------
// Discovery hook — shared by the QAM panel and the full page.
// ----------------------------------------------------------------------------------------
function useHosts() {
const [hosts, setHosts] = useState<Host[]>([]);
const [scanning, setScanning] = useState(false);
const refresh = useCallback(async () => {
setScanning(true);
try {
setHosts(await discover());
} catch (e) {
toaster.toast({ title: "punktfunk", body: `Discovery failed: ${e}` });
} finally {
setScanning(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
return { hosts, scanning, refresh };
}
async function startStream(h: Host) {
try {
await launchStream(h.host, h.port);
Navigation.CloseSideMenus();
toaster.toast({ title: "punktfunk", body: `Starting stream — ${h.name}` });
} catch (e) {
toaster.toast({ title: "punktfunk", body: `Launch failed: ${e}` });
}
}
// ----------------------------------------------------------------------------------------
// PIN pairing modal — a gamepad-navigable digit grid (the OSK is unreliable in Gaming Mode).
// The host displays the PIN after the operator arms pairing; the user enters it here.
// ----------------------------------------------------------------------------------------
const PairModal: FC<{
host: Host;
closeModal?: () => void;
onPaired: () => void;
}> = ({ host, closeModal, onPaired }) => {
const [pin, setPin] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const press = (d: string) => setPin((p) => (p.length >= 4 ? p : p + d));
const back = () => setPin((p) => p.slice(0, -1));
const submit = async () => {
setBusy(true);
setError(null);
try {
const res = await pair(host.host, host.port, pin, "Steam Deck");
if (res.ok) {
toaster.toast({ title: "punktfunk", body: `Paired with ${host.name}` });
onPaired();
closeModal?.();
} else {
setError(res.error ?? "pairing failed");
setPin("");
}
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
};
return (
<ModalRoot closeModal={closeModal}>
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.3em" }}>
Pair with {host.name}
</div>
<div style={{ opacity: 0.8, marginBottom: "1em" }}>
Arm pairing on the host (its console or web UI), then enter the 4-digit PIN it shows.
</div>
<div
style={{
fontSize: "2.2em",
letterSpacing: "0.4em",
textAlign: "center",
fontFamily: "monospace",
minHeight: "1.4em",
marginBottom: "0.6em",
}}
>
{pin.padEnd(4, "•")}
</div>
{error && (
<div style={{ color: "#ff6b6b", textAlign: "center", marginBottom: "0.6em" }}>
{error}
</div>
)}
<Focusable
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: "0.5em",
}}
>
{["1", "2", "3", "4", "5", "6", "7", "8", "9"].map((d) => (
<DialogButton key={d} disabled={busy} onClick={() => press(d)}>
{d}
</DialogButton>
))}
<DialogButton disabled={busy} onClick={back}>
</DialogButton>
<DialogButton disabled={busy} onClick={() => press("0")}>
0
</DialogButton>
<DialogButton
disabled={busy || pin.length !== 4}
onClick={submit}
>
{busy ? <Spinner style={{ height: "1em" }} /> : "Pair"}
</DialogButton>
</Focusable>
</ModalRoot>
);
};
// ----------------------------------------------------------------------------------------
// Settings section — resolution / refresh / bitrate / gamepad, written to the client's JSON.
// ----------------------------------------------------------------------------------------
const RESOLUTIONS: [number, number, string][] = [
[0, 0, "Native display"],
[1280, 720, "1280 × 720"],
[1920, 1080, "1920 × 1080"],
[2560, 1440, "2560 × 1440"],
];
const REFRESH = [0, 30, 60, 90, 120];
const GAMEPADS = ["auto", "xbox360", "dualsense", "steamdeck"];
const GAMEPAD_LABELS: Record<string, string> = {
auto: "Automatic",
xbox360: "Xbox 360",
dualsense: "DualSense",
steamdeck: "Steam Deck",
};
const SettingsSection: FC = () => {
const [s, setS] = useState<StreamSettings | null>(null);
useEffect(() => {
void getSettings().then(setS);
}, []);
const patch = (p: Partial<StreamSettings>) => {
setS((cur) => {
if (!cur) return cur;
const next = { ...cur, ...p };
void setSettings(next);
return next;
});
};
if (!s) return <Spinner style={{ height: "1.5em" }} />;
const resIdx = Math.max(
0,
RESOLUTIONS.findIndex(([w, h]) => w === s.width && h === s.height),
);
return (
<>
<Field
label="Resolution"
description="The host creates a virtual output at exactly this size"
childrenContainerWidth="max"
>
<Dropdown
rgOptions={RESOLUTIONS.map(([, , label], i) => ({ data: i, label }))}
selectedOption={resIdx}
onChange={(o) => {
const [w, h] = RESOLUTIONS[o.data as number];
patch({ width: w, height: h });
}}
/>
</Field>
<Field label="Refresh rate" childrenContainerWidth="max">
<Dropdown
rgOptions={REFRESH.map((r) => ({ data: r, label: r === 0 ? "Native" : `${r} Hz` }))}
selectedOption={s.refresh_hz}
onChange={(o) => patch({ refresh_hz: o.data as number })}
/>
</Field>
<SliderField
label="Bitrate"
description="Mbit/s · 0 = host default"
value={Math.round(s.bitrate_kbps / 1000)}
min={0}
max={150}
step={5}
showValue
valueSuffix=" Mbit/s"
onChange={(v) => patch({ bitrate_kbps: v * 1000 })}
/>
<Field label="Gamepad type" childrenContainerWidth="max">
<Dropdown
rgOptions={GAMEPADS.map((g) => ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))}
selectedOption={s.gamepad}
onChange={(o) => patch({ gamepad: o.data as string })}
/>
</Field>
{s.gamepad === "steamdeck" && (
<Field
label="⚠ Disable Steam Input"
description="Steam Deck mode forwards the paddles, both trackpads, and gyro to the host. For that, Steam Input must be OFF for punktfunk: on the game page tap ⚙ → Controller Settings → set Steam Input to Off. Otherwise Steam keeps the Deck's controls and only the sticks + buttons reach the host."
/>
)}
<ToggleField
label="Stream microphone"
checked={s.mic_enabled}
onChange={(v) => patch({ mic_enabled: v })}
/>
</>
);
};
// ----------------------------------------------------------------------------------------
// One host row on the full page.
// ----------------------------------------------------------------------------------------
const HostRow: FC<{ host: Host }> = ({ host }) => {
// The host's policy is `pair=required`, but if THIS device is already paired we don't need to
// pair again — show it as trusted and go straight to Stream.
const needsPair = host.pair === "required" && !host.paired;
return (
<Field
label={
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
{needsPair ? <FaLock /> : <FaLockOpen />}
{host.name}
</span>
}
description={`${host.host}:${host.port}${
needsPair ? " · pairing required" : host.paired ? " · paired" : ""
}`}
childrenContainerWidth="max"
>
<Focusable style={{ display: "flex", gap: "0.5em" }}>
{needsPair && (
<DialogButton
style={{ minWidth: "5em" }}
onClick={() =>
showModal(<PairModal host={host} onPaired={() => {}} />)
}
>
Pair
</DialogButton>
)}
<DialogButton style={{ minWidth: "6em" }} onClick={() => startStream(host)}>
<FaPlay style={{ marginRight: "0.4em" }} />
Stream
</DialogButton>
</Focusable>
</Field>
);
};
// ----------------------------------------------------------------------------------------
// The fullscreen page (registered as the /punktfunk route) — a tabbed Hosts / Settings view.
// ----------------------------------------------------------------------------------------
// Bottom inset so the last control clears Gaming Mode's footer hint bar. Routed pages render
// *under* that bar otherwise — that's why the last Stream-settings row was getting hidden. The
// value is generous on purpose (and harmless where the tab area already insets); tune to taste.
const SAFE_BOTTOM = "80px";
// Each tab is its own scroll area so long content is always reachable above the footer.
const tabScroll: CSSProperties = {
height: "100%",
overflowY: "auto",
padding: "0.5em 2.5em",
paddingBottom: SAFE_BOTTOM,
boxSizing: "border-box",
};
const HostsTab: FC<{
hosts: Host[];
scanning: boolean;
refresh: () => void;
}> = ({ hosts, scanning, refresh }) => (
<div style={tabScroll}>
<Field
label="Discover"
description={
scanning
? "Scanning the LAN…"
: `${hosts.length} host${hosts.length === 1 ? "" : "s"} on your network`
}
childrenContainerWidth="max"
bottomSeparator={hosts.length ? "standard" : "none"}
>
<DialogButton style={{ minWidth: "8em" }} disabled={scanning} onClick={refresh}>
{scanning ? (
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
) : (
<FaSyncAlt style={{ marginRight: "0.5em" }} />
)}
{scanning ? "Scanning…" : "Refresh"}
</DialogButton>
</Field>
{hosts.length === 0 && !scanning && (
<Field
focusable={false}
description="No punktfunk hosts found. Make sure a host is running on the same network."
>
No hosts found
</Field>
)}
{hosts.map((h) => (
<HostRow key={h.fp || `${h.host}:${h.port}`} host={h} />
))}
</div>
);
const SettingsTab: FC = () => (
<div style={tabScroll}>
<SettingsSection />
</div>
);
const PunktfunkPage: FC = () => {
const { hosts, scanning, refresh } = useHosts();
const update = useUpdate();
const [tab, setTab] = useState("hosts");
return (
<div
style={{
marginTop: "40px",
height: "calc(100% - 40px)",
display: "flex",
flexDirection: "column",
}}
>
<Focusable
style={{
display: "flex",
alignItems: "center",
gap: "1em",
padding: "0 2.5em",
marginBottom: "0.4em",
flexShrink: 0,
}}
>
<DialogButton
style={{ width: "3em", minWidth: "3em", padding: 0 }}
onClick={() => Navigation.NavigateBack()}
>
<FaArrowLeft />
</DialogButton>
<div className={staticClasses?.Title} style={{ flex: 1, margin: 0 }}>
punktfunk
</div>
{update?.update_available && (
<DialogButton style={{ minWidth: "9em" }} onClick={() => applyUpdate(update)}>
<FaDownload style={{ marginRight: "0.4em" }} />
Update v{update.latest}
</DialogButton>
)}
</Focusable>
<div style={{ flex: 1, minHeight: 0 }}>
<Tabs
activeTab={tab}
onShowTab={(id: string) => setTab(id)}
autoFocusContents
tabs={[
{
id: "hosts",
title: "Hosts",
content: <HostsTab hosts={hosts} scanning={scanning} refresh={refresh} />,
},
{
id: "settings",
title: "Settings",
content: <SettingsTab />,
},
]}
/>
</div>
</div>
);
};
// ---------------------------------------------------------------------------------------- // ----------------------------------------------------------------------------------------
// QAM panel — quick status + entry into the full page + one-tap stream for known hosts. // QAM panel — quick status + entry into the full page + one-tap stream for known hosts.
// ---------------------------------------------------------------------------------------- // ----------------------------------------------------------------------------------------
const QamPanel: FC = () => { const QamPanel: FC = () => {
const { hosts, scanning, refresh } = useHosts(); const { hosts, scanning, refresh } = useHosts();
const update = useUpdate(); const { info: update, checking, check } = useUpdate();
return ( return (
<> <>
{update?.update_available && ( {update?.update_available && (
<PanelSection title="Update"> <PanelSection title="Update available">
<PanelSectionRow> <PanelSectionRow>
<ButtonItem <ButtonItem
layout="below" layout="below"
onClick={() => applyUpdate(update)} onClick={() => applyUpdate(update)}
label={`v${update.current} → v${update.latest}`} label={`v${update.current} → v${update.latest}`}
description="Installing can take a couple of minutes"
> >
<FaDownload style={{ marginRight: "0.5em" }} /> <FaDownload style={{ marginRight: "0.5em" }} />
Update punktfunk Update Punktfunk
</ButtonItem> </ButtonItem>
</PanelSectionRow> </PanelSectionRow>
</PanelSection> </PanelSection>
)} )}
<PanelSection title="punktfunk"> <PanelSection title="Punktfunk">
<PanelSectionRow> <PanelSectionRow>
<ButtonItem <ButtonItem
layout="below" layout="below"
description="Host details, stream settings, and help"
onClick={() => { onClick={() => {
Navigation.Navigate(ROUTE); Navigation.Navigate(ROUTE);
Navigation.CloseSideMenus(); Navigation.CloseSideMenus();
}} }}
> >
<FaTv style={{ marginRight: "0.5em" }} /> <FaTv style={{ marginRight: "0.5em" }} />
Open punktfunk Open Punktfunk
</ButtonItem> </ButtonItem>
</PanelSectionRow> </PanelSectionRow>
</PanelSection>
<PanelSection title="Hosts">
<PanelSectionRow> <PanelSectionRow>
<ButtonItem layout="below" onClick={refresh} disabled={scanning}> <ButtonItem layout="below" onClick={refresh} disabled={scanning}>
{scanning ? ( {scanning ? (
@@ -593,15 +67,21 @@ const QamPanel: FC = () => {
) : ( ) : (
<FaSyncAlt style={{ marginRight: "0.5em" }} /> <FaSyncAlt style={{ marginRight: "0.5em" }} />
)} )}
{scanning ? "Scanning…" : "Refresh hosts"} {scanning ? "Scanning…" : "Refresh"}
</ButtonItem> </ButtonItem>
</PanelSectionRow> </PanelSectionRow>
</PanelSection> {hosts.length === 0 && scanning && (
<PanelSectionRow>
<PanelSection title="Hosts"> <Field focusable={false} description="Scanning your network…" />
</PanelSectionRow>
)}
{hosts.length === 0 && !scanning && ( {hosts.length === 0 && !scanning && (
<PanelSectionRow> <PanelSectionRow>
<Field focusable={false}>No hosts found.</Field> <Field
focusable={false}
label="No hosts found"
description="Start a Punktfunk host on this network, then refresh."
/>
</PanelSectionRow> </PanelSectionRow>
)} )}
{hosts.map((h) => { {hosts.map((h) => {
@@ -629,24 +109,42 @@ const QamPanel: FC = () => {
); );
})} })}
</PanelSection> </PanelSection>
<PanelSection title="About">
<PanelSectionRow>
<Field
focusable={false}
label="Version"
description={
update
? `v${update.current}${update.channel ? ` · ${update.channel}` : " · dev build"}`
: "…"
}
/>
</PanelSectionRow>
<PanelSectionRow>
<ButtonItem
layout="below"
disabled={checking}
onClick={() => void checkForUpdatesNow(check)}
>
{checking ? "Checking…" : "Check for updates"}
</ButtonItem>
</PanelSectionRow>
</PanelSection>
</> </>
); );
}; };
// Full page behind the boundary — registered as the /punktfunk route.
const PunktfunkRoute: FC = () => (
<PluginErrorBoundary>
<PunktfunkPage />
</PluginErrorBoundary>
);
export default definePlugin(() => { export default definePlugin(() => {
routerHook.addRoute(ROUTE, PunktfunkRoute, { exact: true }); routerHook.addRoute(ROUTE, PunktfunkRoute, { exact: true });
return { return {
// `name` is the plugin's INTERNAL id — it must stay in sync with plugin.json (the loader
// keys plugins by it), so it stays lowercase; user-facing strings say "Punktfunk".
name: "punktfunk", name: "punktfunk",
// `staticClasses?.Title` is guarded so a future client that drops the export can't throw // `staticClasses?.Title` is guarded so a future client that drops the export can't throw
// at plugin-load time (an error boundary only catches render-time, not load-time, errors). // at plugin-load time (an error boundary only catches render-time, not load-time, errors).
titleView: <div className={staticClasses?.Title}>punktfunk</div>, titleView: <div className={staticClasses?.Title}>Punktfunk</div>,
content: ( content: (
<PluginErrorBoundary> <PluginErrorBoundary>
<QamPanel /> <QamPanel />
+338
View File
@@ -0,0 +1,338 @@
// The fullscreen page (registered as the /punktfunk route) — Hosts / Settings / About tabs.
import {
DialogButton,
Field,
Focusable,
ModalRoot,
Navigation,
Spinner,
Tabs,
showModal,
staticClasses,
} from "@decky/ui";
import { toaster } from "@decky/api";
import { CSSProperties, FC, useState } from "react";
import {
FaArrowLeft,
FaDownload,
FaExternalLinkAlt,
FaInfoCircle,
FaLock,
FaLockOpen,
FaPlay,
FaSyncAlt,
} from "react-icons/fa";
import { Host, UpdateInfo, killStream } from "./backend";
import { PluginErrorBoundary } from "./boundary";
import {
DOCS_URL,
applyUpdate,
checkForUpdatesNow,
startStream,
useHosts,
useUpdate,
} from "./hooks";
import { PairModal } from "./pair";
import { SettingsSection } from "./settings";
import { stopStream } from "./steam";
export const ROUTE = "/punktfunk";
// Bottom inset so the last control clears Gaming Mode's footer hint bar. Routed pages render
// *under* that bar otherwise — that's why the last Stream-settings row was getting hidden. The
// value is generous on purpose (and harmless where the tab area already insets); tune to taste.
const SAFE_BOTTOM = "80px";
// Each tab is its own scroll area so long content is always reachable above the footer.
const tabScroll: CSSProperties = {
height: "100%",
overflowY: "auto",
padding: "0.5em 2.5em",
paddingBottom: SAFE_BOTTOM,
boxSizing: "border-box",
};
// ----------------------------------------------------------------------------------------
// Host details — everything the mDNS advert told us, incl. the fingerprint to cross-check
// against the host's own log / web console before trusting it.
// ----------------------------------------------------------------------------------------
const HostDetailsModal: FC<{ host: Host; closeModal?: () => void }> = ({
host,
closeModal,
}) => {
const fp = host.fp ? (host.fp.match(/.{1,4}/g) ?? [host.fp]).join(" ") : "not advertised";
return (
<ModalRoot closeModal={closeModal}>
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.4em" }}>
{host.name}
</div>
<Field focusable={false} label="Address">
{host.host}:{host.port}
</Field>
<Field focusable={false} label="Protocol">
{host.proto || "unknown"}
</Field>
<Field focusable={false} label="Pairing policy">
{host.pair === "required" ? "PIN pairing required" : "Open (trust on first connect)"}
</Field>
<Field focusable={false} label="This Deck">
{host.paired ? "Paired" : "Not paired yet"}
</Field>
<Field
focusable={false}
label="Certificate fingerprint (SHA-256)"
description={
<span
style={{ fontFamily: "monospace", fontSize: "0.85em", wordBreak: "break-word" }}
>
{fp}
</span>
}
/>
</ModalRoot>
);
};
// ----------------------------------------------------------------------------------------
// One host row: status icon + address, details / pair / stream actions.
// ----------------------------------------------------------------------------------------
const HostRow: FC<{ host: Host; onPaired: () => void }> = ({ host, onPaired }) => {
// The host's policy is `pair=required`, but if THIS device is already paired we don't need to
// pair again — show it as trusted and go straight to Stream.
const needsPair = host.pair === "required" && !host.paired;
return (
<Field
label={
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
{needsPair ? <FaLock /> : <FaLockOpen />}
{host.name}
</span>
}
description={`${host.host}:${host.port}${
needsPair ? " · pairing required" : host.paired ? " · paired" : ""
}`}
childrenContainerWidth="max"
>
<Focusable style={{ display: "flex", gap: "0.5em" }}>
<DialogButton
style={{ width: "3em", minWidth: "3em", padding: 0 }}
onClick={() => showModal(<HostDetailsModal host={host} />)}
>
<FaInfoCircle />
</DialogButton>
{needsPair && (
<DialogButton
style={{ minWidth: "5em" }}
onClick={() => showModal(<PairModal host={host} onPaired={onPaired} />)}
>
Pair
</DialogButton>
)}
<DialogButton style={{ minWidth: "6em" }} onClick={() => startStream(host)}>
<FaPlay style={{ marginRight: "0.4em" }} />
Stream
</DialogButton>
</Focusable>
</Field>
);
};
const HostsTab: FC<{
hosts: Host[];
scanning: boolean;
refresh: () => void;
}> = ({ hosts, scanning, refresh }) => (
<div style={tabScroll}>
<Field
label="Discover"
description={
scanning
? "Scanning the LAN…"
: `${hosts.length} host${hosts.length === 1 ? "" : "s"} on your network`
}
childrenContainerWidth="max"
bottomSeparator={hosts.length ? "standard" : "none"}
>
<DialogButton style={{ minWidth: "8em" }} disabled={scanning} onClick={refresh}>
{scanning ? (
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
) : (
<FaSyncAlt style={{ marginRight: "0.5em" }} />
)}
{scanning ? "Scanning…" : "Refresh"}
</DialogButton>
</Field>
{hosts.length === 0 && !scanning && (
<Field
focusable={false}
label="No hosts found"
description="Start a Punktfunk host on the same network, then refresh. The setup guide (About tab) covers installing a host."
/>
)}
{hosts.map((h) => (
<HostRow key={h.fp || `${h.host}:${h.port}`} host={h} onPaired={refresh} />
))}
</div>
);
const SettingsTab: FC = () => (
<div style={tabScroll}>
<SettingsSection />
</div>
);
// ----------------------------------------------------------------------------------------
// About — plugin version + explicit update check, docs link, stream-exit help, force-stop.
// ----------------------------------------------------------------------------------------
async function forceStopStream(): Promise<void> {
stopStream(); // ask Steam to end the "game" first (clean path)
const res = await killStream(); // then the flatpak-level hammer for a wedged client
toaster.toast({
title: "Punktfunk",
body: res.ok ? "Stream client stopped." : "Couldnt stop the stream client.",
});
}
const AboutTab: FC<{
update: UpdateInfo | null;
checking: boolean;
check: (force: boolean) => Promise<UpdateInfo | null>;
}> = ({ update, checking, check }) => (
<div style={tabScroll}>
<Field
label="Version"
description={
update
? `v${update.current}${
update.channel ? ` · ${update.channel} channel` : " · development build"
}`
: "…"
}
childrenContainerWidth="max"
>
<DialogButton
style={{ minWidth: "11em" }}
disabled={checking}
onClick={() => void checkForUpdatesNow(check)}
>
{checking ? <Spinner style={{ height: "1em" }} /> : "Check for updates"}
</DialogButton>
</Field>
{update?.update_available && (
<Field
label={`Update available — v${update.latest}`}
description="Installing can take a couple of minutes; Decky reloads the plugin when done"
childrenContainerWidth="max"
>
<DialogButton style={{ minWidth: "9em" }} onClick={() => applyUpdate(update)}>
<FaDownload style={{ marginRight: "0.4em" }} />
Update
</DialogButton>
</Field>
)}
<Field
label="Setup guide"
description="Hosts, pairing, controllers, and troubleshooting — docs.punktfunk.unom.io"
childrenContainerWidth="max"
>
<DialogButton
style={{ minWidth: "8em" }}
onClick={() => Navigation.NavigateToExternalWeb(DOCS_URL)}
>
<FaExternalLinkAlt style={{ marginRight: "0.4em" }} />
Open
</DialogButton>
</Field>
<Field
focusable={false}
label="Leaving a stream"
description="Hold L1 + R1 + Start + Select inside the stream, or close the “game” from the Steam overlay — either returns you to Gaming Mode."
/>
<Field
label="Stream stuck?"
description="Force-stop the stream client if a session wedges"
childrenContainerWidth="max"
>
<DialogButton style={{ minWidth: "8em" }} onClick={() => void forceStopStream()}>
Force-stop
</DialogButton>
</Field>
</div>
);
const PunktfunkPage: FC = () => {
const { hosts, scanning, refresh } = useHosts();
const { info: update, checking, check } = useUpdate();
const [tab, setTab] = useState("hosts");
return (
<div
style={{
marginTop: "40px",
height: "calc(100% - 40px)",
display: "flex",
flexDirection: "column",
}}
>
<Focusable
style={{
display: "flex",
alignItems: "center",
gap: "1em",
padding: "0 2.5em",
marginBottom: "0.4em",
flexShrink: 0,
}}
>
<DialogButton
style={{ width: "3em", minWidth: "3em", padding: 0 }}
onClick={() => Navigation.NavigateBack()}
>
<FaArrowLeft />
</DialogButton>
<div className={staticClasses?.Title} style={{ flex: 1, margin: 0 }}>
Punktfunk
</div>
{update?.update_available && (
<DialogButton style={{ minWidth: "9em" }} onClick={() => applyUpdate(update)}>
<FaDownload style={{ marginRight: "0.4em" }} />
Update v{update.latest}
</DialogButton>
)}
</Focusable>
<div style={{ flex: 1, minHeight: 0 }}>
<Tabs
activeTab={tab}
onShowTab={(id: string) => setTab(id)}
autoFocusContents
tabs={[
{
id: "hosts",
title: "Hosts",
content: <HostsTab hosts={hosts} scanning={scanning} refresh={refresh} />,
},
{
id: "settings",
title: "Settings",
content: <SettingsTab />,
},
{
id: "about",
title: "About",
content: <AboutTab update={update} checking={checking} check={check} />,
},
]}
/>
</div>
</div>
);
};
// Full page behind the boundary — registered as the /punktfunk route.
export const PunktfunkRoute: FC = () => (
<PluginErrorBoundary>
<PunktfunkPage />
</PluginErrorBoundary>
);
+91
View File
@@ -0,0 +1,91 @@
// PIN pairing modal — a gamepad-navigable digit grid (the OSK is unreliable in Gaming Mode).
// The host displays the PIN after the operator arms pairing; the user enters it here.
import { DialogButton, Focusable, ModalRoot, Spinner } from "@decky/ui";
import { toaster } from "@decky/api";
import { FC, useState } from "react";
import { Host, pair } from "./backend";
export const PairModal: FC<{
host: Host;
closeModal?: () => void;
onPaired: () => void;
}> = ({ host, closeModal, onPaired }) => {
const [pin, setPin] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const press = (d: string) => setPin((p) => (p.length >= 4 ? p : p + d));
const back = () => setPin((p) => p.slice(0, -1));
const submit = async () => {
setBusy(true);
setError(null);
try {
const res = await pair(host.host, host.port, pin, "Steam Deck");
if (res.ok) {
toaster.toast({ title: "Punktfunk", body: `Paired with ${host.name}` });
onPaired();
closeModal?.();
} else {
setError(res.error ?? "pairing failed");
setPin("");
}
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
};
return (
<ModalRoot closeModal={closeModal}>
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.3em" }}>
Pair with {host.name}
</div>
<div style={{ opacity: 0.8, marginBottom: "1em" }}>
Arm pairing on the host (its console or web UI), then enter the 4-digit PIN it shows.
</div>
<div
style={{
fontSize: "2.2em",
letterSpacing: "0.4em",
textAlign: "center",
fontFamily: "monospace",
minHeight: "1.4em",
marginBottom: "0.6em",
}}
>
{pin.padEnd(4, "•")}
</div>
{error && (
<div style={{ color: "#ff6b6b", textAlign: "center", marginBottom: "0.6em" }}>
{error}
</div>
)}
<Focusable
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: "0.5em",
}}
>
{["1", "2", "3", "4", "5", "6", "7", "8", "9"].map((d) => (
<DialogButton key={d} disabled={busy} onClick={() => press(d)}>
{d}
</DialogButton>
))}
<DialogButton disabled={busy} onClick={back}>
</DialogButton>
<DialogButton disabled={busy} onClick={() => press("0")}>
0
</DialogButton>
<DialogButton disabled={busy || pin.length !== 4} onClick={submit}>
{busy ? <Spinner style={{ height: "1em" }} /> : "Pair"}
</DialogButton>
</Focusable>
</ModalRoot>
);
};
+127
View File
@@ -0,0 +1,127 @@
// Stream settings — resolution / refresh / bitrate / gamepad / compositor / mic, written to
// the flatpak client's JSON (main.py set_settings), which the client reads on launch. The
// accepted gamepad/compositor names mirror punktfunk-core's `*Pref::from_name`.
import { Dropdown, Field, SliderField, Spinner, ToggleField } from "@decky/ui";
import { FC, useEffect, useState } from "react";
import { getSettings, setSettings, StreamSettings } from "./backend";
const RESOLUTIONS: [number, number, string][] = [
[0, 0, "Native display"],
[1280, 720, "1280 × 720"],
[1280, 800, "1280 × 800 (Deck)"],
[1920, 1080, "1920 × 1080"],
[2560, 1440, "2560 × 1440"],
];
const REFRESH = [0, 30, 60, 90, 120];
const GAMEPADS = ["auto", "xbox360", "xboxone", "dualsense", "dualshock4", "steamdeck"];
const GAMEPAD_LABELS: Record<string, string> = {
auto: "Automatic",
xbox360: "Xbox 360",
xboxone: "Xbox One",
dualsense: "DualSense",
dualshock4: "DualShock 4",
steamdeck: "Steam Deck",
};
const COMPOSITORS = ["auto", "kwin", "wlroots", "mutter", "gamescope"];
const COMPOSITOR_LABELS: Record<string, string> = {
auto: "Automatic",
kwin: "KDE Plasma (KWin)",
wlroots: "Sway (wlroots)",
mutter: "GNOME (Mutter)",
gamescope: "gamescope",
};
export const SettingsSection: FC = () => {
const [s, setS] = useState<StreamSettings | null>(null);
useEffect(() => {
void getSettings().then(setS);
}, []);
const patch = (p: Partial<StreamSettings>) => {
setS((cur) => {
if (!cur) return cur;
const next = { ...cur, ...p };
void setSettings(next);
return next;
});
};
if (!s) return <Spinner style={{ height: "1.5em" }} />;
const resIdx = Math.max(
0,
RESOLUTIONS.findIndex(([w, h]) => w === s.width && h === s.height),
);
return (
<>
<Field
label="Resolution"
description="The host creates a virtual output at exactly this size"
childrenContainerWidth="max"
>
<Dropdown
rgOptions={RESOLUTIONS.map(([, , label], i) => ({ data: i, label }))}
selectedOption={resIdx}
onChange={(o) => {
const [w, h] = RESOLUTIONS[o.data as number];
patch({ width: w, height: h });
}}
/>
</Field>
<Field label="Refresh rate" childrenContainerWidth="max">
<Dropdown
rgOptions={REFRESH.map((r) => ({ data: r, label: r === 0 ? "Native" : `${r} Hz` }))}
selectedOption={s.refresh_hz}
onChange={(o) => patch({ refresh_hz: o.data as number })}
/>
</Field>
<SliderField
label="Bitrate"
description="Mbit/s · 0 = host default"
value={Math.round(s.bitrate_kbps / 1000)}
min={0}
max={150}
step={5}
showValue
valueSuffix=" Mbit/s"
onChange={(v) => patch({ bitrate_kbps: v * 1000 })}
/>
<Field
label="Gamepad type"
description="Which virtual controller the host creates for your inputs"
childrenContainerWidth="max"
>
<Dropdown
rgOptions={GAMEPADS.map((g) => ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))}
selectedOption={s.gamepad}
onChange={(o) => patch({ gamepad: o.data as string })}
/>
</Field>
{s.gamepad === "steamdeck" && (
<Field
label="⚠ Disable Steam Input"
description="Steam Deck mode forwards the paddles, both trackpads, and gyro to the host. For that, Steam Input must be OFF for Punktfunk: on the game page tap ⚙ → Controller Settings → set Steam Input to Off. Otherwise Steam keeps the Deck's controls and only the sticks + buttons reach the host."
/>
)}
<Field
label="Host compositor"
description="Which compositor backend the host uses for the virtual display — Automatic suits almost every host"
childrenContainerWidth="max"
>
<Dropdown
rgOptions={COMPOSITORS.map((c) => ({ data: c, label: COMPOSITOR_LABELS[c] ?? c }))}
selectedOption={s.compositor}
onChange={(o) => patch({ compositor: o.data as string })}
/>
</Field>
<ToggleField
label="Stream microphone"
description="Send the Deck's microphone to the host's virtual mic"
checked={s.mic_enabled}
onChange={(v) => patch({ mic_enabled: v })}
/>
</>
);
};
+30 -25
View File
@@ -3,9 +3,10 @@
// THE LAUNCH MECHANISM (verified against MoonDeck): gamescope only gives focus/fullscreen to // THE LAUNCH MECHANISM (verified against MoonDeck): gamescope only gives focus/fullscreen to
// the window tree Steam launched via `reaper` (it detects the "current app" by AppID — see // the window tree Steam launched via `reaper` (it detects the "current app" by AppID — see
// gamescope#484). So we cannot launch the flatpak from the plugin backend; we register ONE // gamescope#484). So we cannot launch the flatpak from the plugin backend; we register ONE
// hidden non-Steam shortcut that points at our wrapper script (bin/punktfunkrun.sh), pass the // hidden non-Steam shortcut whose exe is `/bin/sh` running our wrapper script
// per-session host as the shortcut's Steam launch options, and start it with RunGame. The // (bin/punktfunkrun.sh), pass the per-session host as the shortcut's Steam launch options,
// wrapper then execs `flatpak run io.unom.Punktfunk --connect <host>` as a reaper descendant. // and start it with RunGame. The wrapper then execs
// `flatpak run io.unom.Punktfunk --connect <host>` as a reaper descendant.
import { runnerInfo } from "./backend"; import { runnerInfo } from "./backend";
@@ -49,7 +50,15 @@ function hideShortcut(appId: number): void {
setTimeout(attempt, 2500); // fresh shortcut: retry once its app overview lands setTimeout(attempt, 2500); // fresh shortcut: retry once its app overview lands
} }
const SHORTCUT_NAME = "punktfunk"; // The shortcut name is user-visible (Steam overlay + library while streaming) — brand-case it.
const SHORTCUT_NAME = "Punktfunk";
// The shortcut's exe is /bin/sh, NOT the script itself: Decky extracts plugin zips without
// preserving the exec bit, and ~/homebrew/plugins is root-owned so the unprivileged plugin
// backend can't chmod it back on. Passing the script as an argument to the always-executable
// shell removes the +x dependency entirely. SteamOS /bin/sh is bash; the wrapper is plain
// POSIX sh regardless.
const SHELL = "/bin/sh";
// The 64-bit "gameid" RunGame wants, derived from a 32-bit non-Steam shortcut appId: the // The 64-bit "gameid" RunGame wants, derived from a 32-bit non-Steam shortcut appId: the
// standard non-Steam-game encoding (appid << 32 | 0x02000000). MoonDeck/decky tools use this. // standard non-Steam-game encoding (appid << 32 | 0x02000000). MoonDeck/decky tools use this.
@@ -78,39 +87,34 @@ function recallAppId(): number | null {
} }
/** /**
* Ensure exactly one hidden "punktfunk" shortcut exists pointing at the wrapper script, and * Ensure exactly one hidden "Punktfunk" shortcut exists (exe = /bin/sh; the wrapper script is
* return its appId. Reuses the remembered one when its exe still matches the current runner * appended per-launch via the launch options), and return its appId + the current runner path.
* path (the plugin dir can change across reinstalls). * Reuses the remembered shortcut, re-pointing it each time — the plugin dir can change across
* reinstalls, and pre-0.4 shortcuts pointed at the script directly and relied on its exec bit.
*/ */
async function ensureShortcut(): Promise<number> { async function ensureShortcut(): Promise<{ appId: number; runner: string }> {
const info = await runnerInfo(); const info = await runnerInfo();
if (!info.exists) { if (!info.exists) {
throw new Error(`launch wrapper missing at ${info.runner}`); throw new Error(`launch wrapper missing at ${info.runner}`);
} }
const startDir = info.runner.replace(/\/[^/]*$/, ""); // the plugin's bin/ dir
const remembered = recallAppId(); const remembered = recallAppId();
if (remembered != null) { if (remembered != null) {
// Re-point the existing shortcut at the current runner path (cheap + idempotent). // Re-point + rename the existing shortcut (cheap + idempotent — migrates old installs).
SteamClient.Apps.SetShortcutExe(remembered, info.runner); SteamClient.Apps.SetShortcutExe(remembered, SHELL);
SteamClient.Apps.SetShortcutStartDir( SteamClient.Apps.SetShortcutStartDir(remembered, startDir);
remembered, SteamClient.Apps.SetShortcutName(remembered, SHORTCUT_NAME);
info.runner.replace(/\/[^/]*$/, ""), return { appId: remembered, runner: info.runner };
);
return remembered;
} }
const appId = await SteamClient.Apps.AddShortcut( const appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, "");
SHORTCUT_NAME,
info.runner,
info.runner.replace(/\/[^/]*$/, ""), // start dir = the bin/ dir
"",
);
SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME); SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME);
// Hide it from the library — it's an implementation detail, launched programmatically. // Hide it from the library — it's an implementation detail, launched programmatically.
// Best-effort + deferred (see hideShortcut); never let it block the launch. // Best-effort + deferred (see hideShortcut); never let it block the launch.
hideShortcut(appId); hideShortcut(appId);
rememberAppId(appId); rememberAppId(appId);
return appId; return { appId, runner: info.runner };
} }
/** /**
@@ -138,13 +142,14 @@ function disableSteamInputForShortcut(appId: number): void {
* shortcut's launch options (so one generic shortcut serves every host), then RunGame. * shortcut's launch options (so one generic shortcut serves every host), then RunGame.
*/ */
export async function launchStream(host: string, port: number): Promise<void> { export async function launchStream(host: string, port: number): Promise<void> {
const appId = await ensureShortcut(); const { appId, runner } = await ensureShortcut();
// Best-effort so the Deck's rich controls reach the client; no-op if the API is absent (the user // Best-effort so the Deck's rich controls reach the client; no-op if the API is absent (the user
// disables Steam Input manually — see the Settings instruction). // disables Steam Input manually — see the Settings instruction).
disableSteamInputForShortcut(appId); disableSteamInputForShortcut(appId);
const target = port && port !== 9777 ? `${host}:${port}` : host; const target = port && port !== 9777 ? `${host}:${port}` : host;
// KEY=value ... %command% — the wrapper reads PF_HOST from the environment. // KEY=value ... %command% args — %command% expands to the shortcut exe (/bin/sh); the wrapper
SteamClient.Apps.SetAppLaunchOptions(appId, `PF_HOST=${target} %command%`); // script rides behind it as an argument and reads PF_HOST from the environment.
SteamClient.Apps.SetAppLaunchOptions(appId, `PF_HOST=${target} %command% "${runner}"`);
SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100); SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100);
} }
+10
View File
@@ -128,6 +128,16 @@ fn build_ui(gtk_app: &adw::Application) {
hosts: RefCell::new(None), hosts: RefCell::new(None),
}); });
// Re-apply the persisted forwarded-controller pin (stable key; the service matches it
// whenever such a pad connects) — without this the pin silently resets to Automatic on
// every launch, and Automatic may resolve to a gyro-less pad (Steam's virtual gamepad).
{
let forward = app.settings.borrow().forward_pad.clone();
if !forward.is_empty() {
app.gamepad.set_pinned(Some(forward));
}
}
let hosts_ui = Rc::new(crate::ui_hosts::new( let hosts_ui = Rc::new(crate::ui_hosts::new(
app.settings.clone(), app.settings.clone(),
HostsCallbacks { HostsCallbacks {
+167 -87
View File
@@ -2,12 +2,21 @@
//! `GamepadCapture`/`GamepadFeedback`). //! `GamepadCapture`/`GamepadFeedback`).
//! //!
//! One worker thread owns SDL for the process lifetime: it tracks connected pads for the //! One worker thread owns SDL for the process lifetime: it tracks connected pads for the
//! Settings UI, selects the ONE controller forwarded as pad 0 (user pin, else the most //! Settings UI (metadata only — see below), selects the ONE controller forwarded as pad 0
//! recently connected), and — while a session is attached — forwards buttons/axes, //! (the user pin — persisted in Settings by stable `vid:pid:name` key — else the most
//! DualSense touchpad contacts and motion samples (0xCC), and renders feedback: rumble on //! recently connected real pad; Steam Input's virtual pad is skipped), and — while a
//! every pad, lightbar via SDL, and on a real DualSense the raw effects packet //! session is attached — forwards buttons/axes, DualSense touchpad contacts and motion
//! (adaptive-trigger blocks replayed verbatim, player LEDs). Held state is zeroed on the //! samples (0xCC), and renders feedback: rumble, lightbar via SDL, and on a real DualSense
//! wire when the active pad switches or the session detaches, so nothing sticks down. //! the raw effects packet (adaptive-trigger blocks replayed verbatim, player LEDs). Held
//! state is zeroed on the wire when the active pad switches or the session detaches, so
//! nothing sticks down.
//!
//! **Idle means hands off the hardware.** Outside an attached session the worker never
//! opens a device and keeps SDL's Valve HIDAPI drivers disabled ([`set_valve_hidapi`]):
//! the Steam Deck driver clears the built-in controller's "lizard mode" (trackpad-mouse,
//! clicky pads) the moment the device *enumerates* and keeps feeding that watchdog — so an
//! idle host-list window would kill the Deck's system input. The pad list for Settings is
//! built from SDL's ID-based metadata getters, which need no open.
//! //!
//! This thread is also the single consumer of the rumble and HID-output pull planes. //! This thread is also the single consumer of the rumble and HID-output pull planes.
@@ -15,7 +24,6 @@ use punktfunk_core::client::NativeClient;
use punktfunk_core::config::GamepadPref; use punktfunk_core::config::GamepadPref;
use punktfunk_core::input::{gamepad as wire, InputEvent, InputKind}; use punktfunk_core::input::{gamepad as wire, InputEvent, InputKind};
use punktfunk_core::quic::{HidOutput, RichInput}; use punktfunk_core::quic::{HidOutput, RichInput};
use std::collections::HashMap;
use std::sync::mpsc::{Receiver, Sender}; use std::sync::mpsc::{Receiver, Sender};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -44,12 +52,18 @@ const DISCONNECT_HOLD: Duration = Duration::from_millis(1500);
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct PadInfo { pub struct PadInfo {
pub id: u32,
pub name: String, pub name: String,
/// Stable identity (`vid:pid:name`) for pinning across restarts — SDL instance ids are
/// per-run, so [`Settings::forward_pad`](crate::trust::Settings) persists this instead.
pub key: String,
/// The virtual pad "Automatic" resolves to for this physical controller (so the host creates a /// The virtual pad "Automatic" resolves to for this physical controller (so the host creates a
/// matching pad: DualSense → DualSense, DS4 → DualShock 4, Xbox One/Series → Xbox One, anything /// matching pad: DualSense → DualSense, DS4 → DualShock 4, Xbox One/Series → Xbox One, anything
/// else → Xbox 360). Drives [`GamepadService::auto_pref`] and the rich-feedback render path. /// else → Xbox 360). Drives [`GamepadService::auto_pref`] and the rich-feedback render path.
pub pref: GamepadPref, pub pref: GamepadPref,
/// Steam Input's emulated pad ("Steam Virtual Gamepad", Valve 28de:11ff). It shadows the
/// physical controller and has no sensors/touchpad, so auto-selection skips it while a real
/// pad is connected — otherwise gyro silently dies on Bazzite/Deck game mode.
pub steam_virtual: bool,
} }
impl PadInfo { impl PadInfo {
@@ -71,6 +85,24 @@ impl PadInfo {
} }
} }
/// Enable/disable SDL's Valve HIDAPI drivers at runtime. The Steam Deck driver sends
/// `ID_CLEAR_DIGITAL_MAPPINGS` + `TRACKPAD_NONE` in `InitDevice` — at *enumeration*, before
/// any open — and its `UpdateDevice` keeps feeding the firmware's lizard-mode watchdog
/// (`SDL_hidapi_steamdeck.c`), so a Deck's built-in trackpad-mouse dies for the whole
/// system while the driver merely runs. These drivers therefore run ONLY while a session
/// is attached (input is captured then anyway, and streaming wants the paddles, both
/// trackpads, and gyro first-class). SDL3 applies the hint changes live: disabling detaches
/// the driver and the firmware watchdog restores lizard mode within seconds.
///
/// On a Deck in Game Mode, Steam Input still holds the device — the user must disable
/// Steam Input for this app (see the Decky UX); on a desktop client (or a Deck with Steam
/// Input off) the in-session enable just works.
fn set_valve_hidapi(enabled: bool) {
let v = if enabled { "1" } else { "0" };
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAMDECK", v);
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAM", v);
}
/// Map the SDL-reported controller type to the virtual pad we'd ask the host to create. /// Map the SDL-reported controller type to the virtual pad we'd ask the host to create.
fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref { fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref {
use sdl3::gamepad::GamepadType as T; use sdl3::gamepad::GamepadType as T;
@@ -85,14 +117,13 @@ fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref {
enum Ctl { enum Ctl {
Attach(Arc<NativeClient>), Attach(Arc<NativeClient>),
Detach, Detach,
Pin(Option<u32>), Pin(Option<String>),
} }
#[derive(Clone)] #[derive(Clone)]
pub struct GamepadService { pub struct GamepadService {
pads: Arc<Mutex<Vec<PadInfo>>>, pads: Arc<Mutex<Vec<PadInfo>>>,
active: Arc<Mutex<Option<PadInfo>>>, active: Arc<Mutex<Option<PadInfo>>>,
pinned: Arc<Mutex<Option<u32>>>,
ctl: Sender<Ctl>, ctl: Sender<Ctl>,
/// Fires once per press of the [`ESCAPE_CHORD`]; the stream page consumes it to leave /// Fires once per press of the [`ESCAPE_CHORD`]; the stream page consumes it to leave
/// fullscreen + release capture. /// fullscreen + release capture.
@@ -106,15 +137,14 @@ impl GamepadService {
pub fn start() -> GamepadService { pub fn start() -> GamepadService {
let pads = Arc::new(Mutex::new(Vec::new())); let pads = Arc::new(Mutex::new(Vec::new()));
let active = Arc::new(Mutex::new(None)); let active = Arc::new(Mutex::new(None));
let pinned = Arc::new(Mutex::new(None));
let (ctl, ctl_rx) = std::sync::mpsc::channel(); let (ctl, ctl_rx) = std::sync::mpsc::channel();
let (escape_tx, escape_rx) = async_channel::unbounded(); let (escape_tx, escape_rx) = async_channel::unbounded();
let (disconnect_tx, disconnect_rx) = async_channel::unbounded(); let (disconnect_tx, disconnect_rx) = async_channel::unbounded();
let (p, a, pin) = (pads.clone(), active.clone(), pinned.clone()); let (p, a) = (pads.clone(), active.clone());
if let Err(e) = std::thread::Builder::new() if let Err(e) = std::thread::Builder::new()
.name("punktfunk-gamepad".into()) .name("punktfunk-gamepad".into())
.spawn(move || { .spawn(move || {
if let Err(e) = run(&p, &a, &pin, &ctl_rx, &escape_tx, &disconnect_tx) { if let Err(e) = run(&p, &a, &ctl_rx, &escape_tx, &disconnect_tx) {
tracing::warn!(error = %e, "gamepad service ended — pads disabled"); tracing::warn!(error = %e, "gamepad service ended — pads disabled");
} }
}) })
@@ -124,7 +154,6 @@ impl GamepadService {
GamepadService { GamepadService {
pads, pads,
active, active,
pinned,
ctl, ctl,
escape_rx, escape_rx,
disconnect_rx, disconnect_rx,
@@ -151,12 +180,11 @@ impl GamepadService {
self.active.lock().unwrap().clone() self.active.lock().unwrap().clone()
} }
pub fn pinned(&self) -> Option<u32> { /// Pin the forwarded controller by stable key (`PadInfo::key`) — `None` = automatic.
*self.pinned.lock().unwrap() /// The pin persists as `Settings::forward_pad` (the UI's source of truth) and survives
} /// the pad disconnecting: it re-applies the moment a matching controller shows up again.
pub fn set_pinned(&self, key: Option<String>) {
pub fn set_pinned(&self, id: Option<u32>) { let _ = self.ctl.send(Ctl::Pin(key));
let _ = self.ctl.send(Ctl::Pin(id));
} }
pub fn attach(&self, connector: Arc<NativeClient>) { pub fn attach(&self, connector: Arc<NativeClient>) {
@@ -279,11 +307,16 @@ struct Worker<'a> {
/// UI-facing state (the `GamepadService` accessors): pad list, active pad, pin. /// UI-facing state (the `GamepadService` accessors): pad list, active pad, pin.
pads_out: &'a Mutex<Vec<PadInfo>>, pads_out: &'a Mutex<Vec<PadInfo>>,
active_out: &'a Mutex<Option<PadInfo>>, active_out: &'a Mutex<Option<PadInfo>>,
pinned_out: &'a Mutex<Option<u32>>, /// The ONE device held open — the active pad while a session is attached, `None`
opened: HashMap<u32, sdl3::gamepad::Gamepad>, /// otherwise. Opening is what grabs the hardware (SDL's HIDAPI drivers take the
/// Connection order; the most recently connected is the auto selection. /// hidraw device away from the system), so idle keeps this empty; see the module doc.
open: Option<(u32, sdl3::gamepad::Gamepad)>,
/// Connected pad ids in connection order (metadata only, no device open); the most
/// recently connected is the auto selection.
order: Vec<u32>, order: Vec<u32>,
pinned: Option<u32>, /// Stable key of the user-pinned controller (persisted in Settings) — matched against
/// connected pads, so it survives restarts and disconnects.
pinned: Option<String>,
attached: Option<Arc<NativeClient>>, attached: Option<Arc<NativeClient>>,
/// Wire state of the active pad — zeroed on the wire at switch/detach. /// Wire state of the active pad — zeroed on the wire at switch/detach.
last_axis: [i32; 6], last_axis: [i32; 6],
@@ -308,32 +341,95 @@ struct Worker<'a> {
impl Worker<'_> { impl Worker<'_> {
fn active_id(&self) -> Option<u32> { fn active_id(&self) -> Option<u32> {
self.pinned // The pin matches by stable key (most recently connected wins if two identical pads
.filter(|id| self.opened.contains_key(id)) // share one); an unmatched pin falls through to automatic without being cleared.
if let Some(key) = &self.pinned {
if let Some(id) = self
.order
.iter()
.rev()
.copied()
.find(|&id| self.pad_info(id).is_some_and(|p| &p.key == key))
{
return Some(id);
}
}
// Automatic: the most recently connected pad — but never Steam Input's virtual pad
// while a real controller is present (see `PadInfo::steam_virtual`).
self.order
.iter()
.rev()
.copied()
.find(|&id| self.pad_info(id).is_some_and(|p| !p.steam_virtual))
.or_else(|| self.order.last().copied()) .or_else(|| self.order.last().copied())
} }
/// Pad metadata from SDL's ID-based getters — deliberately NO device open (see the
/// module doc; an open would grab the hardware).
fn pad_info(&self, id: u32) -> Option<PadInfo> { fn pad_info(&self, id: u32) -> Option<PadInfo> {
let pad = self.opened.get(&id)?; if !self.order.contains(&id) {
let mut pref = pref_for_type( return None;
self.subsystem }
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)), let jid = sdl3::sys::joystick::SDL_JoystickID(id);
let mut pref = pref_for_type(self.subsystem.type_for_id(jid));
let (vid, pid) = (
self.subsystem.vendor_for_id(jid).unwrap_or(0),
self.subsystem.product_for_id(jid).unwrap_or(0),
); );
// There is no SDL gamepad type for the Steam Deck / Steam Controller, so detect Valve by // There is no SDL gamepad type for the Steam Deck / Steam Controller, so detect Valve by
// VID/PID (Deck 0x1205, SC wired 0x1102, SC dongle 0x1142) — the host then builds the virtual // VID/PID (Deck 0x1205, SC wired 0x1102, SC dongle 0x1142) — the host then builds the virtual
// hid-steam pad with the back grips + dual trackpads and the right glyph identity. // hid-steam pad with the back grips + dual trackpads and the right glyph identity.
if pad.vendor_id() == Some(0x28DE) if vid == 0x28DE && matches!(pid, 0x1205 | 0x1102 | 0x1142) {
&& matches!(pad.product_id(), Some(0x1205 | 0x1102 | 0x1142))
{
pref = GamepadPref::SteamDeck; pref = GamepadPref::SteamDeck;
} }
let name = self
.subsystem
.name_for_id(jid)
.unwrap_or_else(|_| "Controller".into());
Some(PadInfo { Some(PadInfo {
id, key: format!("{vid:04x}:{pid:04x}:{name}"),
name: pad.name().unwrap_or_else(|| "Controller".into()), steam_virtual: (vid == 0x28DE && pid == 0x11FF)
|| name.starts_with("Steam Virtual Gamepad"),
name,
pref, pref,
}) })
} }
/// Hold exactly the right device: the active pad while a session is attached, nothing
/// otherwise. The single place that decides to open (= grab) hardware; dropping the
/// old handle closes it (`SDL_CloseGamepad`) — on a Deck the firmware watchdog then
/// restores lizard mode.
fn sync_open(&mut self) {
let want = if self.attached.is_some() {
self.active_id()
} else {
None
};
if self.open.as_ref().map(|(id, _)| *id) == want {
return;
}
self.open = None;
let Some(id) = want else { return };
match self.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(id)) {
Ok(pad) => {
self.open = Some((id, pad));
self.set_sensors(true);
}
Err(e) => tracing::warn!(id, error = %e, "gamepad open failed"),
}
}
/// React to anything that may have moved the active-pad selection (hotplug, pin
/// change): flush held wire state if it did, then re-sync the opened device and the
/// UI-facing snapshot.
fn refresh_active(&mut self, before: Option<u32>) {
if self.active_id() != before {
self.flush_held();
}
self.sync_open();
self.publish();
}
/// Zero everything the host believes is held — on pad switch and detach. /// Zero everything the host believes is held — on pad switch and detach.
fn flush_held(&mut self) { fn flush_held(&mut self) {
if let Some(c) = &self.attached { if let Some(c) = &self.attached {
@@ -432,8 +528,7 @@ impl Worker<'_> {
/// Sensors stream only while a session wants them (they cost USB/BT bandwidth). /// Sensors stream only while a session wants them (they cost USB/BT bandwidth).
fn set_sensors(&mut self, enabled: bool) { fn set_sensors(&mut self, enabled: bool) {
let Some(id) = self.active_id() else { return }; if let Some((_, pad)) = self.open.as_mut() {
if let Some(pad) = self.opened.get_mut(&id) {
use sdl3::sensor::SensorType; use sdl3::sensor::SensorType;
for s in [SensorType::Gyroscope, SensorType::Accelerometer] { for s in [SensorType::Gyroscope, SensorType::Accelerometer] {
if unsafe { pad.has_sensor(s) } { if unsafe { pad.has_sensor(s) } {
@@ -459,9 +554,10 @@ impl Worker<'_> {
return; return;
}; };
let multi = self let multi = self
.opened .open
.get(&which) .as_ref()
.map(|p| p.touchpads_count() >= 2) .filter(|(id, _)| *id == which)
.map(|(_, p)| p.touchpads_count() >= 2)
.unwrap_or(false); .unwrap_or(false);
let (cx, cy) = (x.clamp(0.0, 1.0), y.clamp(0.0, 1.0)); let (cx, cy) = (x.clamp(0.0, 1.0), y.clamp(0.0, 1.0));
let surface = if multi { (touchpad as u8) + 1 } else { 0 }; let surface = if multi { (touchpad as u8) + 1 } else { 0 };
@@ -503,7 +599,6 @@ impl Worker<'_> {
list.reverse(); // most recent first — the Settings list order list.reverse(); // most recent first — the Settings list order
*self.pads_out.lock().unwrap() = list; *self.pads_out.lock().unwrap() = list;
*self.active_out.lock().unwrap() = self.active_id().and_then(|id| self.pad_info(id)); *self.active_out.lock().unwrap() = self.active_id().and_then(|id| self.pad_info(id));
*self.pinned_out.lock().unwrap() = self.pinned;
} }
/// Apply queued control-plane messages from the UI thread. Returns false when the /// Apply queued control-plane messages from the UI thread. Returns false when the
@@ -515,23 +610,22 @@ impl Worker<'_> {
self.attached = Some(c); self.attached = Some(c);
self.last_axis = [i32::MIN; 6]; self.last_axis = [i32::MIN; 6];
self.reset_chord(); // every session starts un-latched (Attach doesn't flush) self.reset_chord(); // every session starts un-latched (Attach doesn't flush)
self.set_sensors(true); // The Valve HIDAPI drivers run only in-session (see set_valve_hidapi);
// enabling them re-enumerates a Deck's built-in pad with paddles/
// trackpads/gyro first-class — sync_open follows the churn events.
set_valve_hidapi(true);
self.sync_open();
} }
Ok(Ctl::Detach) => { Ok(Ctl::Detach) => {
self.flush_held(); self.flush_held();
self.set_sensors(false);
self.attached = None; self.attached = None;
self.sync_open(); // closes the held device
set_valve_hidapi(false);
} }
Ok(Ctl::Pin(id)) => { Ok(Ctl::Pin(key)) => {
let before = self.active_id(); let before = self.active_id();
self.pinned = id; self.pinned = key;
if self.active_id() != before { self.refresh_active(before);
self.flush_held();
if self.attached.is_some() {
self.set_sensors(true);
}
}
self.publish();
} }
Err(std::sync::mpsc::TryRecvError::Empty) => return true, Err(std::sync::mpsc::TryRecvError::Empty) => return true,
Err(std::sync::mpsc::TryRecvError::Disconnected) => return false, // app gone Err(std::sync::mpsc::TryRecvError::Disconnected) => return false, // app gone
@@ -546,35 +640,22 @@ impl Worker<'_> {
let active = self.active_id(); let active = self.active_id();
match event { match event {
Event::ControllerDeviceAdded { which, .. } => { Event::ControllerDeviceAdded { which, .. } => {
if !self.opened.contains_key(&which) { if !self.order.contains(&which) {
match self self.order.push(which);
.subsystem if let Some(p) = self.pad_info(which) {
.open(sdl3::sys::joystick::SDL_JoystickID(which)) tracing::info!(name = p.name, "gamepad attached");
{
Ok(pad) => {
tracing::info!(
name = pad.name().unwrap_or_default(),
"gamepad attached"
);
self.opened.insert(which, pad);
self.order.push(which);
if self.attached.is_some() && self.active_id() == Some(which) {
self.set_sensors(true);
}
self.publish();
}
Err(e) => tracing::warn!(error = %e, "gamepad open failed"),
} }
self.refresh_active(active);
} }
} }
Event::ControllerDeviceRemoved { which, .. } => { Event::ControllerDeviceRemoved { which, .. } => {
if self.opened.remove(&which).is_some() { if self.order.contains(&which) {
self.order.retain(|&id| id != which); self.order.retain(|&id| id != which);
if active == Some(which) { if self.open.as_ref().map(|(id, _)| *id) == Some(which) {
self.flush_held(); self.open = None; // the device is gone; drop our handle
} }
tracing::info!("gamepad detached"); tracing::info!("gamepad detached");
self.publish(); self.refresh_active(active);
} }
} }
Event::ControllerButtonDown { which, button, .. } if active == Some(which) => { Event::ControllerButtonDown { which, button, .. } if active == Some(which) => {
@@ -687,7 +768,7 @@ impl Worker<'_> {
}; };
while let Ok((pad, low, high)) = connector.next_rumble(Duration::ZERO) { while let Ok((pad, low, high)) = connector.next_rumble(Duration::ZERO) {
if pad == 0 { if pad == 0 {
if let Some(p) = self.active_id().and_then(|id| self.opened.get_mut(&id)) { if let Some((_, p)) = self.open.as_mut() {
// Surface a failed SDL rumble write: a swallowed error here (DualSense not in // Surface a failed SDL rumble write: a swallowed error here (DualSense not in
// the right HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The // the right HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The
// host logs the send side on 0xCA, so the two together pinpoint host-game vs // host logs the send side on 0xCA, so the two together pinpoint host-game vs
@@ -703,9 +784,12 @@ impl Worker<'_> {
} }
} }
while let Ok(hid) = connector.next_hidout(Duration::ZERO) { while let Ok(hid) = connector.next_hidout(Duration::ZERO) {
let Some(id) = self.active_id() else { continue }; let is_ds = self
let is_ds = self.pad_info(id).is_some_and(|p| p.is_dualsense()); .open
let Some(pad) = self.opened.get_mut(&id) else { .as_ref()
.and_then(|(id, _)| self.pad_info(*id))
.is_some_and(|p| p.is_dualsense());
let Some((_, pad)) = self.open.as_mut() else {
continue; continue;
}; };
match hid { match hid {
@@ -734,7 +818,6 @@ impl Worker<'_> {
fn run( fn run(
pads_out: &Mutex<Vec<PadInfo>>, pads_out: &Mutex<Vec<PadInfo>>,
active_out: &Mutex<Option<PadInfo>>, active_out: &Mutex<Option<PadInfo>>,
pinned_out: &Mutex<Option<u32>>,
ctl: &Receiver<Ctl>, ctl: &Receiver<Ctl>,
escape_tx: &async_channel::Sender<()>, escape_tx: &async_channel::Sender<()>,
disconnect_tx: &async_channel::Sender<()>, disconnect_tx: &async_channel::Sender<()>,
@@ -743,12 +826,10 @@ fn run(
// own thread. // own thread.
sdl3::hint::set("SDL_NO_SIGNAL_HANDLERS", "1"); sdl3::hint::set("SDL_NO_SIGNAL_HANDLERS", "1");
sdl3::hint::set("SDL_JOYSTICK_THREAD", "1"); sdl3::hint::set("SDL_JOYSTICK_THREAD", "1");
// Let SDL's HIDAPI drivers open Valve Steam Controller / Steam Deck devices directly, so the // The Valve HIDAPI drivers start DISABLED (SDL defaults the Deck one ON, and its mere
// paddles, both trackpads, and gyro arrive as first-class SDL gamepad inputs. On a Deck in Game // enumeration kills the Deck's trackpad-mouse system-wide — see set_valve_hidapi);
// Mode, Steam Input still holds the device — the user must disable Steam Input for this app (see // they are enabled for the duration of an attached session only.
// the Decky UX); on a desktop client (or a Deck with Steam Input off) the hints just work. set_valve_hidapi(false);
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAMDECK", "1");
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAM", "1");
let sdl = sdl3::init().map_err(|e| e.to_string())?; let sdl = sdl3::init().map_err(|e| e.to_string())?;
let subsystem = sdl.gamepad().map_err(|e| e.to_string())?; let subsystem = sdl.gamepad().map_err(|e| e.to_string())?;
let mut pump = sdl.event_pump().map_err(|e| e.to_string())?; let mut pump = sdl.event_pump().map_err(|e| e.to_string())?;
@@ -757,8 +838,7 @@ fn run(
subsystem, subsystem,
pads_out, pads_out,
active_out, active_out,
pinned_out, open: None,
opened: HashMap::new(),
order: Vec::new(), order: Vec::new(),
pinned: None, pinned: None,
attached: None, attached: None,
+7 -4
View File
@@ -265,13 +265,16 @@ impl SessionUi {
stop: self.stop.clone(), stop: self.stop.clone(),
inhibit_shortcuts: self.inhibit, inhibit_shortcuts: self.inhibit,
show_stats: self.show_stats, show_stats: self.show_stats,
chromeless: self.app.fullscreen,
title, title,
}); });
self.app.nav.push(&p.page); self.app.nav.push(&p.page);
// Steam Deck / Gaming Mode: gamescope fullscreens the window but GTK doesn't // Streams start fullscreen by default (Settings toggle) — a streaming window with
// know it, so its header bar stays drawn. Enter GTK fullscreen explicitly — // chrome is never what anyone wants mid-game; F11 / the controller chord / the
// the stream page's `connect_fullscreened_notify` then hides all chrome. // top-edge header reveal lead back out. Gaming-Mode launches (`--fullscreen`)
if self.app.fullscreen { // fullscreen regardless: gamescope fullscreens the window at its level but GTK
// doesn't know it, so the header bar would stay drawn.
if self.app.fullscreen || self.app.settings.borrow().fullscreen_on_stream {
self.app.window.fullscreen(); self.app.window.fullscreen();
} }
self.page = Some(p); self.page = Some(p);
+25
View File
@@ -182,6 +182,10 @@ pub struct Settings {
/// Requested encoder bitrate (kbps); 0 = host default. /// Requested encoder bitrate (kbps); 0 = host default.
pub bitrate_kbps: u32, pub bitrate_kbps: u32,
pub gamepad: String, pub gamepad: String,
/// Stable identity (`vid:pid:name`, see `PadInfo::key`) of the physical controller
/// forwarded as pad 0; empty = automatic (most recently connected). Applied to the
/// gamepad service at startup so the choice survives restarts.
pub forward_pad: String,
/// Which host compositor backend to request (advisory; the host falls back to /// Which host compositor backend to request (advisory; the host falls back to
/// auto-detect when unavailable). /// auto-detect when unavailable).
pub compositor: String, pub compositor: String,
@@ -201,6 +205,9 @@ pub struct Settings {
pub decoder: String, pub decoder: String,
/// Show the on-stream statistics overlay (toggle live with Ctrl+Alt+Shift+S). /// Show the on-stream statistics overlay (toggle live with Ctrl+Alt+Shift+S).
pub show_stats: bool, pub show_stats: bool,
/// Enter fullscreen when a stream starts (F11 / the controller chord / the top-edge
/// header reveal exit it). Gaming-Mode launches (`--fullscreen`) fullscreen regardless.
pub fullscreen_on_stream: bool,
/// Experimental: the game-library browser ("Browse library…" on saved cards) — /// Experimental: the game-library browser ("Browse library…" on saved cards) —
/// mirrors the Apple client's "Show game library" toggle, default off. /// mirrors the Apple client's "Show game library" toggle, default off.
pub library_enabled: bool, pub library_enabled: bool,
@@ -230,6 +237,7 @@ impl Default for Settings {
refresh_hz: 0, refresh_hz: 0,
bitrate_kbps: 0, bitrate_kbps: 0,
gamepad: "auto".into(), gamepad: "auto".into(),
forward_pad: String::new(),
compositor: "auto".into(), compositor: "auto".into(),
inhibit_shortcuts: true, inhibit_shortcuts: true,
mic_enabled: false, mic_enabled: false,
@@ -237,6 +245,7 @@ impl Default for Settings {
codec: "auto".into(), codec: "auto".into(),
decoder: "auto".into(), decoder: "auto".into(),
show_stats: true, show_stats: true,
fullscreen_on_stream: true,
library_enabled: false, library_enabled: false,
} }
} }
@@ -263,3 +272,19 @@ impl Settings {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
/// A pre-`forward_pad` settings file (≤ 0.5.0) loads with the pin on automatic.
#[test]
fn settings_forward_pad_defaults_empty() {
let old = r#"{"width":1280,"height":720,"refresh_hz":60,"bitrate_kbps":0,
"gamepad":"auto","compositor":"auto","inhibit_shortcuts":true,"mic_enabled":true}"#;
let s: Settings = serde_json::from_str(old).unwrap();
assert_eq!(s.forward_pad, "");
let round: Settings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
assert_eq!(round.forward_pad, "");
}
}
+384 -81
View File
@@ -3,7 +3,7 @@
use crate::trust::Settings; use crate::trust::Settings;
use adw::prelude::*; use adw::prelude::*;
use std::cell::RefCell; use std::cell::{Cell, RefCell};
use std::rc::Rc; use std::rc::Rc;
/// `(0, 0)` = the native size of the monitor the window is on, resolved at connect. /// `(0, 0)` = the native size of the monitor the window is on, resolved at connect.
@@ -25,7 +25,7 @@ const DECODERS: &[&str] = &["auto", "vaapi", "software"];
/// punktfunk's own license (MIT OR Apache-2.0), shown on the About dialog's Legal page. /// punktfunk's own license (MIT OR Apache-2.0), shown on the About dialog's Legal page.
const APP_LICENSE: &str = concat!( const APP_LICENSE: &str = concat!(
"punktfunk is licensed under MIT OR Apache-2.0, at your option.\n\n", "Punktfunk is licensed under MIT OR Apache-2.0, at your option.\n\n",
"================================ MIT ================================\n\n", "================================ MIT ================================\n\n",
include_str!("../../../LICENSE-MIT"), include_str!("../../../LICENSE-MIT"),
"\n\n=============================== Apache-2.0 ===============================\n\n", "\n\n=============================== Apache-2.0 ===============================\n\n",
@@ -39,7 +39,7 @@ const THIRD_PARTY_NOTICES: &str = include_str!("../../../THIRD-PARTY-NOTICES.txt
/// from the primary menu (app.rs `win.about`). /// from the primary menu (app.rs `win.about`).
pub fn show_about(parent: &impl IsA<gtk::Widget>) { pub fn show_about(parent: &impl IsA<gtk::Widget>) {
let about = adw::AboutDialog::builder() let about = adw::AboutDialog::builder()
.application_name("punktfunk") .application_name("Punktfunk")
.developer_name("unom") .developer_name("unom")
.version(env!("CARGO_PKG_VERSION")) .version(env!("CARGO_PKG_VERSION"))
.website("https://git.unom.io/unom/punktfunk") .website("https://git.unom.io/unom/punktfunk")
@@ -67,6 +67,179 @@ pub fn show_about(parent: &impl IsA<gtk::Widget>) {
about.present(Some(parent)); about.present(Some(parent));
} }
/// True inside a gamescope session (Steam game mode on the Deck / Bazzite): GTK popovers
/// are xdg_popups, which gamescope never maps for nested apps — a ComboRow's dropdown
/// flashes the row but no list ever appears. Selection UI must stay inside the toplevel.
fn gamescope_session() -> bool {
std::env::var("XDG_CURRENT_DESKTOP").is_ok_and(|d| d.eq_ignore_ascii_case("gamescope"))
|| std::env::var("GAMESCOPE_WAYLAND_DISPLAY").is_ok()
}
type ChangedFn = Rc<RefCell<Option<Box<dyn Fn(u32)>>>>;
/// A titled single-choice preference row. On a desktop this is a stock popover
/// [`adw::ComboRow`]; under gamescope (see [`gamescope_session`]) it becomes an activatable
/// row that pushes an in-window selection subpage onto the preferences dialog instead.
struct ChoiceRow {
row: adw::PreferencesRow,
selected: Rc<Cell<u32>>,
/// Fires on user changes only — [`connect_changed`](Self::connect_changed) is installed
/// after seeding, so programmatic `set_selected` during setup never fires it.
changed: ChangedFn,
/// Subpage mode only: the current value rendered as the row's suffix.
value_label: Option<gtk::Label>,
options: Rc<Vec<String>>,
}
impl ChoiceRow {
/// `inline` = subpage mode (gamescope): computed once per dialog via
/// [`gamescope_session`] and passed in so tests can drive both modes directly.
fn new(
dialog: &adw::PreferencesDialog,
inline: bool,
title: &str,
subtitle: &str,
options: &[&str],
) -> ChoiceRow {
let options: Rc<Vec<String>> = Rc::new(options.iter().map(|s| s.to_string()).collect());
let selected = Rc::new(Cell::new(0u32));
let changed: ChangedFn = Rc::new(RefCell::new(None));
if !inline {
let row = adw::ComboRow::builder()
.title(title)
.subtitle(subtitle)
.model(&gtk::StringList::new(
&options.iter().map(String::as_str).collect::<Vec<_>>(),
))
.build();
let (sel, chg) = (selected.clone(), changed.clone());
row.connect_selected_notify(move |r| {
if sel.replace(r.selected()) != r.selected() {
if let Some(f) = chg.borrow().as_ref() {
f(r.selected());
}
}
});
return ChoiceRow {
row: row.upcast(),
selected,
changed,
value_label: None,
options,
};
}
let value = gtk::Label::builder().css_classes(["dim-label"]).build();
let row = adw::ActionRow::builder()
.title(title)
.subtitle(subtitle)
.activatable(true)
.build();
row.add_suffix(&value);
row.add_suffix(&gtk::Image::from_icon_name("go-next-symbolic"));
{
let dialog = dialog.downgrade();
let (options, sel, chg, value) = (
options.clone(),
selected.clone(),
changed.clone(),
value.clone(),
);
let title = title.to_string();
row.connect_activated(move |_| {
let Some(dialog) = dialog.upgrade() else {
return;
};
let list = gtk::ListBox::builder()
.selection_mode(gtk::SelectionMode::None)
.css_classes(["boxed-list"])
.build();
for (i, opt) in options.iter().enumerate() {
let check = gtk::Image::from_icon_name("object-select-symbolic");
check.set_visible(i as u32 == sel.get());
let opt_row = adw::ActionRow::builder()
.title(opt)
.use_markup(false)
.activatable(true)
.build();
opt_row.add_suffix(&check);
let idx = i as u32;
let dlg = dialog.downgrade();
let (sel, chg, value, label) =
(sel.clone(), chg.clone(), value.clone(), opt.clone());
opt_row.connect_activated(move |_| {
let user_change = sel.replace(idx) != idx;
value.set_text(&label);
if user_change {
if let Some(f) = chg.borrow().as_ref() {
f(idx);
}
}
if let Some(d) = dlg.upgrade() {
d.pop_subpage();
}
});
list.append(&opt_row);
}
let clamp = adw::Clamp::builder()
.child(&list)
.margin_top(24)
.margin_bottom(24)
.margin_start(12)
.margin_end(12)
.build();
let scroll = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.child(&clamp)
.build();
let view = adw::ToolbarView::new();
view.add_top_bar(&adw::HeaderBar::new());
view.set_content(Some(&scroll));
dialog.push_subpage(&adw::NavigationPage::new(&view, &title));
});
}
let cr = ChoiceRow {
row: row.upcast(),
selected,
changed,
value_label: Some(value),
options,
};
cr.sync_value();
cr
}
/// Subpage mode: reflect the current selection in the row's suffix label.
fn sync_value(&self) {
if let Some(l) = &self.value_label {
let i = self.selected.get() as usize;
l.set_text(self.options.get(i).map(String::as_str).unwrap_or(""));
}
}
fn widget(&self) -> &adw::PreferencesRow {
&self.row
}
fn selected(&self) -> u32 {
self.selected.get()
}
fn set_selected(&self, i: u32) {
if let Some(combo) = self.row.downcast_ref::<adw::ComboRow>() {
combo.set_selected(i); // the notify handler syncs the cell
} else {
self.selected.set(i);
self.sync_value();
}
}
fn connect_changed(&self, f: impl Fn(u32) + 'static) {
*self.changed.borrow_mut() = Some(Box::new(f));
}
}
/// `on_closed` runs after the settings are saved (the app shell refreshes the hosts grid /// `on_closed` runs after the settings are saved (the app shell refreshes the hosts grid
/// there so the experimental library toggle takes effect without a nav round-trip). /// there so the experimental library toggle takes effect without a nav round-trip).
pub fn show( pub fn show(
@@ -75,6 +248,11 @@ pub fn show(
gamepads: &crate::gamepad::GamepadService, gamepads: &crate::gamepad::GamepadService,
on_closed: impl Fn() + 'static, on_closed: impl Fn() + 'static,
) { ) {
// The dialog exists before the rows: ChoiceRow's gamescope mode pushes its selection
// subpage onto it.
let dialog = adw::PreferencesDialog::new();
dialog.set_title("Preferences");
let inline = gamescope_session();
let page = adw::PreferencesPage::new(); let page = adw::PreferencesPage::new();
let stream = adw::PreferencesGroup::builder().title("Stream").build(); let stream = adw::PreferencesGroup::builder().title("Stream").build();
@@ -88,13 +266,13 @@ pub fn show(
} }
}) })
.collect(); .collect();
let res_row = adw::ComboRow::builder() let res_row = ChoiceRow::new(
.title("Resolution") &dialog,
.subtitle("The host creates a virtual output at exactly this size") inline,
.model(&gtk::StringList::new( "Resolution",
&res_names.iter().map(String::as_str).collect::<Vec<_>>(), "The host creates a virtual output at exactly this size",
)) &res_names.iter().map(String::as_str).collect::<Vec<_>>(),
.build(); );
let hz_names: Vec<String> = REFRESH let hz_names: Vec<String> = REFRESH
.iter() .iter()
.map(|&r| { .map(|&r| {
@@ -105,123 +283,153 @@ pub fn show(
} }
}) })
.collect(); .collect();
let hz_row = adw::ComboRow::builder() let hz_row = ChoiceRow::new(
.title("Refresh rate") &dialog,
.model(&gtk::StringList::new( inline,
&hz_names.iter().map(String::as_str).collect::<Vec<_>>(), "Refresh rate",
)) "",
.build(); &hz_names.iter().map(String::as_str).collect::<Vec<_>>(),
);
let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 5.0); let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 5.0);
bitrate_row.set_title("Bitrate"); bitrate_row.set_title("Bitrate");
bitrate_row.set_subtitle("Mbit/s · 0 = host default · run a speed test before going high"); bitrate_row.set_subtitle("Mbit/s · 0 = host default · run a speed test before going high");
let compositor_row = adw::ComboRow::builder() let compositor_row = ChoiceRow::new(
.title("Host compositor") &dialog,
.subtitle("Advisory — the host falls back to auto-detect when unavailable") inline,
.model(&gtk::StringList::new(&[ "Host compositor",
"Advisory — the host falls back to auto-detect when unavailable",
&[
"Automatic", "Automatic",
"KWin", "KWin",
"wlroots (Sway/Hyprland)", "wlroots (Sway/Hyprland)",
"Mutter (GNOME)", "Mutter (GNOME)",
"gamescope", "gamescope",
])) ],
.build(); );
let decoder_row = adw::ComboRow::builder() let decoder_row = ChoiceRow::new(
.title("Video decoder") &dialog,
.subtitle("Automatic tries VAAPI hardware decode, then software") inline,
.model(&gtk::StringList::new(&[ "Video decoder",
"Automatic tries VAAPI hardware decode, then software",
&[
"Automatic (VAAPI → software)", "Automatic (VAAPI → software)",
"Hardware (VAAPI)", "Hardware (VAAPI)",
"Software", "Software",
])) ],
.build(); );
let stats_row = adw::SwitchRow::builder() let stats_row = adw::SwitchRow::builder()
.title("Show statistics overlay") .title("Show statistics overlay")
.subtitle("fps · bitrate · latency on the stream — Ctrl+Alt+Shift+S toggles live") .subtitle("fps · bitrate · latency on the stream — Ctrl+Alt+Shift+S toggles live")
.build(); .build();
stream.add(&res_row); let fullscreen_row = adw::SwitchRow::builder()
stream.add(&hz_row); .title("Start streams in fullscreen")
.subtitle("F11, the mouse at the top edge, or L1+R1+Start+Select lead back out")
.build();
stream.add(res_row.widget());
stream.add(hz_row.widget());
stream.add(&bitrate_row); stream.add(&bitrate_row);
stream.add(&compositor_row); stream.add(compositor_row.widget());
stream.add(&decoder_row); stream.add(decoder_row.widget());
stream.add(&fullscreen_row);
stream.add(&stats_row); stream.add(&stats_row);
let input = adw::PreferencesGroup::builder().title("Input").build(); let input = adw::PreferencesGroup::builder().title("Input").build();
// Which physical controller forwards as pad 0: automatic = the most recently // Which physical controller forwards as pad 0: automatic = the most recently connected
// connected; pinning survives until the app exits (Swift parity). // real pad (Steam's virtual pad skipped). A pin is persisted by stable key
// (`Settings::forward_pad`), so it survives restarts — and disconnects: an offline
// pinned pad keeps its entry here instead of silently snapping back to Automatic.
let pads = gamepads.pads(); let pads = gamepads.pads();
let saved_pin = settings.borrow().forward_pad.clone();
let mut pad_names = vec!["Automatic (most recent)".to_string()]; let mut pad_names = vec!["Automatic (most recent)".to_string()];
pad_names.extend(pads.iter().map(|p| { let mut pad_keys: Vec<String> = Vec::new();
for p in &pads {
let kind = p.kind_label(); let kind = p.kind_label();
if kind.is_empty() { pad_names.push(if kind.is_empty() {
p.name.clone() p.name.clone()
} else { } else {
format!("{} · {kind}", p.name) format!("{} · {kind}", p.name)
} });
})); pad_keys.push(p.key.clone());
let forward_row = adw::ComboRow::builder() }
.title("Forwarded controller") if !saved_pin.is_empty() && !pad_keys.contains(&saved_pin) {
.subtitle(if pads.is_empty() { let name = saved_pin
.splitn(3, ':')
.nth(2)
.unwrap_or("Saved controller");
pad_names.push(format!("{name} (not connected)"));
pad_keys.push(saved_pin.clone());
}
let forward_row = ChoiceRow::new(
&dialog,
inline,
"Forwarded controller",
if pads.is_empty() {
"No controllers detected" "No controllers detected"
} else { } else {
"Exactly one controller is forwarded to the host" "Exactly one controller is forwarded to the host"
}) },
.model(&gtk::StringList::new( &pad_names.iter().map(String::as_str).collect::<Vec<_>>(),
&pad_names.iter().map(String::as_str).collect::<Vec<_>>(), );
)) let pinned_i = pad_keys
.build(); .iter()
let pinned_i = gamepads .position(|k| k == &saved_pin)
.pinned()
.and_then(|id| pads.iter().position(|p| p.id == id))
.map_or(0, |i| i + 1); .map_or(0, |i| i + 1);
forward_row.set_selected(pinned_i as u32); forward_row.set_selected(pinned_i as u32);
// The dialog-local choice, written into Settings on close (reading the service back
// would race its worker thread applying the Pin message).
let chosen_pin: Rc<RefCell<String>> = Rc::new(RefCell::new(saved_pin));
{ {
let svc = gamepads.clone(); let svc = gamepads.clone();
let ids: Vec<u32> = pads.iter().map(|p| p.id).collect(); let keys = pad_keys.clone();
forward_row.connect_selected_notify(move |row| { let chosen = chosen_pin.clone();
let sel = row.selected() as usize; forward_row.connect_changed(move |sel| {
svc.set_pinned(if sel == 0 { let key = if sel == 0 {
None None
} else { } else {
ids.get(sel - 1).copied() keys.get(sel as usize - 1).cloned()
}); };
*chosen.borrow_mut() = key.clone().unwrap_or_default();
svc.set_pinned(key);
}); });
} }
let pad_row = adw::ComboRow::builder() let pad_row = ChoiceRow::new(
.title("Gamepad type") &dialog,
.subtitle("The virtual pad the host creates — Automatic matches the physical pad") inline,
.model(&gtk::StringList::new(&[ "Gamepad type",
"The virtual pad the host creates — Automatic matches the physical pad",
&[
"Automatic", "Automatic",
"Xbox 360", "Xbox 360",
"DualSense", "DualSense",
"Xbox One", "Xbox One",
"DualShock 4", "DualShock 4",
])) ],
.build(); );
let inhibit_row = adw::SwitchRow::builder() let inhibit_row = adw::SwitchRow::builder()
.title("Capture system shortcuts") .title("Capture system shortcuts")
.subtitle("Forward Alt+Tab, Super, … to the host while input is captured") .subtitle("Forward Alt+Tab, Super, … to the host while input is captured")
.build(); .build();
input.add(&forward_row); input.add(forward_row.widget());
input.add(&pad_row); input.add(pad_row.widget());
input.add(&inhibit_row); input.add(&inhibit_row);
let audio = adw::PreferencesGroup::builder().title("Audio").build(); let audio = adw::PreferencesGroup::builder().title("Audio").build();
let surround_row = adw::ComboRow::builder() let surround_row = ChoiceRow::new(
.title("Audio channels") &dialog,
.subtitle("Request stereo or surround (the host downmixes if its output has fewer)") inline,
.model(&gtk::StringList::new(&[ "Audio channels",
"Stereo", "Request stereo or surround (the host downmixes if its output has fewer)",
"5.1 Surround", &["Stereo", "5.1 Surround", "7.1 Surround"],
"7.1 Surround", );
])) audio.add(surround_row.widget());
.build(); let codec_row = ChoiceRow::new(
audio.add(&surround_row); &dialog,
let codec_row = adw::ComboRow::builder() inline,
.title("Video codec") "Video codec",
.subtitle("Preferred codec — the host falls back if it can't encode this one") "Preferred codec — the host falls back if it can't encode this one",
.model(&gtk::StringList::new(CODEC_LABELS)) CODEC_LABELS,
.build(); );
stream.add(&codec_row); stream.add(codec_row.widget());
let mic_row = adw::SwitchRow::builder() let mic_row = adw::SwitchRow::builder()
.title("Stream microphone") .title("Stream microphone")
.subtitle("Send the default input device to the host's virtual microphone") .subtitle("Send the default input device to the host's virtual microphone")
@@ -268,6 +476,7 @@ pub fn show(
let dec_i = DECODERS.iter().position(|&d| d == s.decoder).unwrap_or(0); let dec_i = DECODERS.iter().position(|&d| d == s.decoder).unwrap_or(0);
decoder_row.set_selected(dec_i as u32); decoder_row.set_selected(dec_i as u32);
stats_row.set_active(s.show_stats); stats_row.set_active(s.show_stats);
fullscreen_row.set_active(s.fullscreen_on_stream);
inhibit_row.set_active(s.inhibit_shortcuts); inhibit_row.set_active(s.inhibit_shortcuts);
mic_row.set_active(s.mic_enabled); mic_row.set_active(s.mic_enabled);
library_row.set_active(s.library_enabled); library_row.set_active(s.library_enabled);
@@ -280,8 +489,6 @@ pub fn show(
codec_row.set_selected(codec_i as u32); codec_row.set_selected(codec_i as u32);
} }
let dialog = adw::PreferencesDialog::new();
dialog.set_title("Preferences");
dialog.add(&page); dialog.add(&page);
dialog.connect_closed(move |_| { dialog.connect_closed(move |_| {
let mut s = settings.borrow_mut(); let mut s = settings.borrow_mut();
@@ -290,10 +497,12 @@ pub fn show(
s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)]; s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)];
s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32; s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32;
s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string(); s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string();
s.forward_pad = chosen_pin.borrow().clone();
s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)] s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)]
.to_string(); .to_string();
s.decoder = DECODERS[(decoder_row.selected() as usize).min(DECODERS.len() - 1)].to_string(); s.decoder = DECODERS[(decoder_row.selected() as usize).min(DECODERS.len() - 1)].to_string();
s.show_stats = stats_row.is_active(); s.show_stats = stats_row.is_active();
s.fullscreen_on_stream = fullscreen_row.is_active();
s.inhibit_shortcuts = inhibit_row.is_active(); s.inhibit_shortcuts = inhibit_row.is_active();
s.mic_enabled = mic_row.is_active(); s.mic_enabled = mic_row.is_active();
s.audio_channels = match surround_row.selected() { s.audio_channels = match surround_row.selected() {
@@ -309,3 +518,97 @@ pub fn show(
}); });
dialog.present(Some(parent)); dialog.present(Some(parent));
} }
#[cfg(test)]
mod tests {
use super::*;
/// Depth-first search for an [`adw::ActionRow`] with the given title.
fn find_action_row(root: &gtk::Widget, title: &str) -> Option<adw::ActionRow> {
if let Some(row) = root.downcast_ref::<adw::ActionRow>() {
if row.title() == title {
return Some(row.clone());
}
}
let mut child = root.first_child();
while let Some(c) = child {
if let Some(hit) = find_action_row(&c, title) {
return Some(hit);
}
child = c.next_sibling();
}
None
}
fn pump() {
let ctx = gtk::glib::MainContext::default();
while ctx.iteration(false) {}
}
/// Both ChoiceRow modes in ONE test (GTK is thread-affine and libtest gives every test
/// its own thread, so the display tests can't be split). Gamescope mode: activating the
/// row pushes the in-window selection subpage; activating an option updates the
/// selection + suffix label, fires the change callback, and pops the subpage. Combo
/// mode: cell sync + change callback. Needs a display — run manually with
/// `cargo test -p punktfunk-client-linux -- --ignored` on a session box.
#[test]
#[ignore = "needs a Wayland/X display"]
fn choice_row_modes() {
assert!(gtk::init().is_ok() && adw::init().is_ok(), "no display");
let win = adw::Window::new();
let dialog = adw::PreferencesDialog::new();
let page = adw::PreferencesPage::new();
let group = adw::PreferencesGroup::new();
let row = ChoiceRow::new(&dialog, true, "Resolution", "sub", &["A", "B", "C"]);
group.add(row.widget());
page.add(&group);
dialog.add(&page);
let fired = Rc::new(Cell::new(u32::MAX));
{
let f = fired.clone();
row.connect_changed(move |i| f.set(i));
}
win.present();
dialog.present(Some(&win));
pump();
// Suffix label reflects the seed.
assert_eq!(row.value_label.as_ref().unwrap().text(), "A");
// Row activation → subpage with the options list.
row.widget()
.downcast_ref::<adw::ActionRow>()
.unwrap()
.emit_by_name::<()>("activated", &[]);
pump();
let opt_b = find_action_row(dialog.upcast_ref(), "B").expect("subpage option missing");
// Option activation → state + label + callback, subpage popped.
opt_b.emit_by_name::<()>("activated", &[]);
pump();
assert_eq!(row.selected(), 1);
assert_eq!(fired.get(), 1);
assert_eq!(row.value_label.as_ref().unwrap().text(), "B");
// Re-activating shows the check on the new selection (fresh subpage each time).
row.widget()
.downcast_ref::<adw::ActionRow>()
.unwrap()
.emit_by_name::<()>("activated", &[]);
pump();
assert!(find_action_row(dialog.upcast_ref(), "B").is_some());
// Desktop (ComboRow) mode: cell sync + change callback on selection change.
let combo = ChoiceRow::new(&dialog, false, "Codec", "", &["X", "Y"]);
combo.set_selected(1);
assert_eq!(combo.selected(), 1);
let combo_fired = Rc::new(Cell::new(u32::MAX));
{
let f = combo_fired.clone();
combo.connect_changed(move |i| f.set(i));
}
combo.set_selected(0);
assert_eq!(combo.selected(), 0);
assert_eq!(combo_fired.get(), 0);
}
}
+188 -42
View File
@@ -34,6 +34,9 @@ pub struct StreamPage {
/// Median capture→paintable-set latency (ms) over the frame consumer's last 1 s /// Median capture→paintable-set latency (ms) over the frame consumer's last 1 s
/// window — written there, folded into the OSD on each `Stats` event. /// window — written there, folded into the OSD on each `Stats` event.
present_ms: Rc<Cell<f32>>, present_ms: Rc<Cell<f32>>,
/// The stream is HDR (PQ) right now — set by the frame consumer from each frame's
/// signaling (the host can flip SDR↔HDR mid-session, in-band).
hdr: Rc<Cell<bool>>,
} }
impl StreamPage { impl StreamPage {
@@ -51,6 +54,9 @@ impl StreamPage {
line.push_str(" · "); line.push_str(" · ");
line.push_str(s.decoder); line.push_str(s.decoder);
} }
if self.hdr.get() {
line.push_str(" · HDR");
}
self.stats_label.set_text(&line); self.stats_label.set_text(&line);
} }
} }
@@ -72,6 +78,12 @@ pub struct StreamPageArgs {
pub inhibit_shortcuts: bool, pub inhibit_shortcuts: bool,
/// Show the stats OSD initially (Settings); Ctrl+Alt+Shift+S toggles it live. /// Show the stats OSD initially (Settings); Ctrl+Alt+Shift+S toggles it live.
pub show_stats: bool, pub show_stats: bool,
/// Gaming-Mode launch (`--fullscreen` / Deck env): build the page with NO header bar
/// at all. gamescope displays the window fullscreen but does not reliably ACK the
/// xdg_toplevel fullscreen state back, so anything keyed on `is_fullscreen()` (the
/// reveal-on-notify chrome hiding) may never fire — the title bar would stay drawn
/// over the stream. Chrome-less by construction cannot regress that way.
pub chromeless: bool,
pub title: String, pub title: String,
} }
@@ -184,9 +196,10 @@ pub fn new(args: StreamPageArgs) -> StreamPage {
stop, stop,
inhibit_shortcuts, inhibit_shortcuts,
show_stats, show_stats,
chromeless,
title, title,
} = args; } = args;
let w = build_widgets(&window, &title); let w = build_widgets(&window, &title, chromeless);
w.stats_label.set_visible(show_stats); w.stats_label.set_visible(show_stats);
let capture = Rc::new(Capture { let capture = Rc::new(Capture {
@@ -202,10 +215,20 @@ pub fn new(args: StreamPageArgs) -> StreamPage {
}); });
let present_ms = Rc::new(Cell::new(0.0f32)); let present_ms = Rc::new(Cell::new(0.0f32));
spawn_frame_consumer(&w.picture, frames, clock_offset_ns, present_ms.clone()); let hdr = Rc::new(Cell::new(false));
spawn_frame_consumer(
&w.picture,
frames,
clock_offset_ns,
present_ms.clone(),
hdr.clone(),
);
attach_keyboard(&w.overlay, &window, &capture, &stop, &w.stats_label); attach_keyboard(&w.overlay, &window, &capture, &stop, &w.stats_label);
attach_mouse(&w.overlay, &capture); attach_mouse(&w.overlay, &capture);
attach_scroll(&w.overlay, &capture); attach_scroll(&w.overlay, &capture);
if !chromeless {
attach_edge_reveal(&w.toolbar, &w.overlay, &window, &capture);
}
let active_handler = attach_capture_lifecycle(&w.overlay, &window, &capture); let active_handler = attach_capture_lifecycle(&w.overlay, &window, &capture);
let escape_future = spawn_escape_watch(&window, &capture, escape_rx); let escape_future = spawn_escape_watch(&window, &capture, escape_rx);
let disconnect_future = spawn_disconnect_watch(&window, &capture, &stop, disconnect_rx); let disconnect_future = spawn_disconnect_watch(&window, &capture, &stop, disconnect_rx);
@@ -222,6 +245,7 @@ pub fn new(args: StreamPageArgs) -> StreamPage {
page: w.page, page: w.page,
stats_label: w.stats_label, stats_label: w.stats_label,
present_ms, present_ms,
hdr,
} }
} }
@@ -231,6 +255,7 @@ struct PageWidgets {
stats_label: gtk::Label, stats_label: gtk::Label,
hint: gtk::Label, hint: gtk::Label,
overlay: gtk::Overlay, overlay: gtk::Overlay,
toolbar: adw::ToolbarView,
page: adw::NavigationPage, page: adw::NavigationPage,
/// Fullscreen-notify handler on the shared window — disconnected on page teardown. /// Fullscreen-notify handler on the shared window — disconnected on page teardown.
fs_handler: glib::SignalHandlerId, fs_handler: glib::SignalHandlerId,
@@ -238,7 +263,8 @@ struct PageWidgets {
/// The offloaded picture under an overlay (stats HUD, capture hint, fullscreen hint), a /// The offloaded picture under an overlay (stats HUD, capture hint, fullscreen hint), a
/// header bar with the fullscreen toggle, and the window's fullscreen behavior. /// header bar with the fullscreen toggle, and the window's fullscreen behavior.
fn build_widgets(window: &adw::ApplicationWindow, title: &str) -> PageWidgets { /// `chromeless` (Gaming Mode) builds NO header bar at all — see `StreamPageArgs`.
fn build_widgets(window: &adw::ApplicationWindow, title: &str, chromeless: bool) -> PageWidgets {
let picture = gtk::Picture::new(); let picture = gtk::Picture::new();
picture.set_content_fit(gtk::ContentFit::Contain); picture.set_content_fit(gtk::ContentFit::Contain);
@@ -265,12 +291,15 @@ fn build_widgets(window: &adw::ApplicationWindow, title: &str) -> PageWidgets {
hint.set_margin_bottom(24); hint.set_margin_bottom(24);
hint.set_visible(false); hint.set_visible(false);
// Flashed when entering fullscreen — the only exit affordances once the header bar is // Flashed when entering fullscreen — the exit affordances once the header bar is
// hidden (F11 on a keyboard; the L1+R1+Start+Select chord on a controller, which is the // hidden (F11 on a keyboard; the top-edge pointer reveal for mouse/trackpad-only
// only way out on a Steam Deck). // devices; the L1+R1+Start+Select chord on a controller). Gaming Mode has no F11,
let fs_hint = gtk::Label::new(Some( // no header to reveal, and Steam owns window management — only the chord applies.
"F11 · L1 + R1 + Start + Select — exit fullscreen (hold to disconnect)", let fs_hint = gtk::Label::new(Some(if chromeless {
)); "L1 + R1 + Start + Select — leave the stream (hold to disconnect)"
} else {
"F11 · mouse to the top edge · L1 + R1 + Start + Select — exit fullscreen (hold to disconnect)"
}));
fs_hint.add_css_class("osd"); fs_hint.add_css_class("osd");
fs_hint.set_halign(gtk::Align::Center); fs_hint.set_halign(gtk::Align::Center);
fs_hint.set_valign(gtk::Align::Start); fs_hint.set_valign(gtk::Align::Start);
@@ -284,23 +313,33 @@ fn build_widgets(window: &adw::ApplicationWindow, title: &str) -> PageWidgets {
overlay.add_overlay(&fs_hint); overlay.add_overlay(&fs_hint);
overlay.set_focusable(true); overlay.set_focusable(true);
let header = adw::HeaderBar::new(); let toolbar = adw::ToolbarView::new();
let fullscreen_btn = gtk::Button::from_icon_name("view-fullscreen-symbolic"); if !chromeless {
fullscreen_btn.set_tooltip_text(Some("Fullscreen (F11)")); let header = adw::HeaderBar::new();
{ let fullscreen_btn = gtk::Button::from_icon_name("view-fullscreen-symbolic");
let window = window.clone(); fullscreen_btn.set_tooltip_text(Some("Fullscreen (F11)"));
fullscreen_btn.connect_clicked(move |_| { {
if window.is_fullscreen() { let window = window.clone();
window.unfullscreen(); fullscreen_btn.connect_clicked(move |_| {
} else { if window.is_fullscreen() {
window.fullscreen(); window.unfullscreen();
} } else {
window.fullscreen();
}
});
}
header.pack_end(&fullscreen_btn);
toolbar.add_top_bar(&header);
} else {
// No header exists to hide, and gamescope may never ACK fullscreen — flash the
// chord hint when the stream maps instead of on the fullscreened notify.
let fs_hint = fs_hint.clone();
overlay.connect_map(move |_| {
fs_hint.set_visible(true);
let fs_hint = fs_hint.clone();
glib::timeout_add_seconds_local_once(4, move || fs_hint.set_visible(false));
}); });
} }
header.pack_end(&fullscreen_btn);
let toolbar = adw::ToolbarView::new();
toolbar.add_top_bar(&header);
toolbar.set_content(Some(&overlay)); toolbar.set_content(Some(&overlay));
// Fullscreen = the stream and nothing else. (Window handlers are disconnected when // Fullscreen = the stream and nothing else. (Window handlers are disconnected when
// the page dies — the window outlives every session.) // the page dies — the window outlives every session.)
@@ -310,6 +349,9 @@ fn build_widgets(window: &adw::ApplicationWindow, title: &str) -> PageWidgets {
window.connect_fullscreened_notify(move |w| { window.connect_fullscreened_notify(move |w| {
let fs = w.is_fullscreen(); let fs = w.is_fullscreen();
toolbar.set_reveal_top_bars(!fs); toolbar.set_reveal_top_bars(!fs);
if chromeless {
return; // the map handler above owns the hint; there is no bar to reveal
}
if fs { if fs {
fs_hint.set_visible(true); fs_hint.set_visible(true);
let fs_hint = fs_hint.clone(); let fs_hint = fs_hint.clone();
@@ -331,11 +373,48 @@ fn build_widgets(window: &adw::ApplicationWindow, title: &str) -> PageWidgets {
stats_label, stats_label,
hint, hint,
overlay, overlay,
toolbar,
page, page,
fs_handler, fs_handler,
} }
} }
/// Fullscreen chrome recovery for pointer-only devices (a Deck desktop has no F11): while
/// fullscreen and NOT captured, bumping the pointer against the top edge reveals the header
/// bar (back button, fullscreen toggle); moving back into the stream hides it again. While
/// captured the pointer belongs to the host — nothing reveals, and a still-revealed bar is
/// re-hidden on the first captured movement (release capture first: Ctrl+Alt+Shift+Q).
fn attach_edge_reveal(
toolbar: &adw::ToolbarView,
overlay: &gtk::Overlay,
window: &adw::ApplicationWindow,
capture: &Rc<Capture>,
) {
let motion = gtk::EventControllerMotion::new();
let toolbar = toolbar.clone();
let window = window.clone();
let cap = capture.clone();
motion.connect_motion(move |_, _x, y| {
if !window.is_fullscreen() {
return; // windowed chrome is the fullscreened-notify handler's business
}
if cap.captured.get() {
if toolbar.reveals_top_bars() {
toolbar.set_reveal_top_bars(false);
}
return;
}
if y <= 2.0 {
toolbar.set_reveal_top_bars(true);
} else if y > 4.0 && toolbar.reveals_top_bars() {
// Once revealed the content sits below the bar, so y stays small while the
// pointer hovers the boundary; anything deeper means the user moved back in.
toolbar.set_reveal_top_bars(false);
}
});
overlay.add_controller(motion);
}
/// Frame consumer: each decoded frame becomes the picture's paintable as soon as it /// Frame consumer: each decoded frame becomes the picture's paintable as soon as it
/// arrives (the session's tiny `force_send` queue already dropped anything older); GTK /// arrives (the session's tiny `force_send` queue already dropped anything older); GTK
/// then draws whatever paintable is current on its own frame clock. Ends itself when the /// then draws whatever paintable is current on its own frame clock. Ends itself when the
@@ -347,23 +426,67 @@ fn build_widgets(window: &adw::ApplicationWindow, title: &str) -> PageWidgets {
/// capture→paintable-SET — GTK's own present adds one compositor cycle after this. The /// capture→paintable-SET — GTK's own present adds one compositor cycle after this. The
/// 1 s p50 lands on the stats OSD (via `present_ms`) and in a "present window" debug /// 1 s p50 lands on the stats OSD (via `present_ms`) and in a "present window" debug
/// line for headless validation. /// line for headless validation.
/// One-entry cache of `ColorDesc` → `GdkColorState` (signaling changes at most on an
/// SDR↔HDR flip, never per frame).
#[derive(Default)]
struct ColorStateCache(Option<(crate::video::ColorDesc, Option<gdk::ColorState>)>);
impl ColorStateCache {
/// The color state for a frame's signaling. `rgb` = the pixels are already full-range
/// RGB (the CPU path — only transfer + primaries remain meaningful); else YUV, where
/// H.273 "unspecified" (2) fills in as BT.709 limited, the host's SDR default. `None`
/// = GDK can't represent the combo — the caller's default (sRGB) applies, which
/// matches the pre-color-management behavior.
fn get(&mut self, desc: crate::video::ColorDesc, rgb: bool) -> Option<gdk::ColorState> {
if let Some((cached, state)) = &self.0 {
if *cached == desc {
return state.clone();
}
}
let def = |v: u8, d: u32| if v == 2 { d } else { u32::from(v) };
let cicp = gdk::CicpParams::new();
if rgb {
cicp.set_color_primaries(def(desc.primaries, 1));
cicp.set_transfer_function(def(desc.transfer, 13)); // 13 = sRGB
cicp.set_matrix_coefficients(0); // identity — the matrix is already undone
cicp.set_range(gdk::CicpRange::Full);
} else {
cicp.set_color_primaries(def(desc.primaries, 1));
cicp.set_transfer_function(def(desc.transfer, 1));
cicp.set_matrix_coefficients(def(desc.matrix, 1));
cicp.set_range(if desc.full_range {
gdk::CicpRange::Full
} else {
gdk::CicpRange::Narrow
});
}
let state = cicp.build_color_state().ok();
if state.is_none() {
tracing::warn!(
?desc,
"GDK can't represent this colour signaling — using default"
);
}
self.0 = Some((desc, state.clone()));
state
}
}
fn spawn_frame_consumer( fn spawn_frame_consumer(
picture: &gtk::Picture, picture: &gtk::Picture,
frames: async_channel::Receiver<DecodedFrame>, frames: async_channel::Receiver<DecodedFrame>,
clock_offset_ns: i64, clock_offset_ns: i64,
present_ms: Rc<Cell<f32>>, present_ms: Rc<Cell<f32>>,
hdr: Rc<Cell<bool>>,
) { ) {
let picture = picture.downgrade(); let picture = picture.downgrade();
// The host encodes BT.709 limited-range; without an explicit color state GDK // The colour state follows the FRAMES' own signaling (the Windows host switches an HDR
// would convert NV12 dmabufs with the (BT.601) dmabuf default. // desktop to BT.2020 PQ in-band while the Welcome still says SDR): unspecified falls
let rec709 = { // back to BT.709 limited — without an explicit state GDK would convert NV12 dmabufs
let cicp = gdk::CicpParams::new(); // with the (BT.601) dmabuf default. Cached per distinct signaling; a change mid-stream
cicp.set_color_primaries(1); // (SDR↔HDR flip) just rebuilds once.
cicp.set_transfer_function(1); let mut yuv_state = ColorStateCache::default();
cicp.set_matrix_coefficients(1); let mut rgb_state = ColorStateCache::default();
cicp.set_range(gdk::CicpRange::Narrow);
cicp.build_color_state().ok()
};
glib::spawn_future_local(async move { glib::spawn_future_local(async move {
let mut win_lat_us: Vec<u64> = Vec::with_capacity(256); let mut win_lat_us: Vec<u64> = Vec::with_capacity(256);
let mut win_start = Instant::now(); let mut win_start = Instant::now();
@@ -372,16 +495,39 @@ fn spawn_frame_consumer(
break; break;
}; };
let mut presented = false; let mut presented = false;
match &f.image {
DecodedImage::Cpu(c) => hdr.set(c.color.is_pq()),
DecodedImage::Dmabuf(d) => hdr.set(d.color.is_pq()),
}
match f.image { match f.image {
DecodedImage::Cpu(c) => { DecodedImage::Cpu(c) => {
let bytes = glib::Bytes::from_owned(c.rgba); let bytes = glib::Bytes::from_owned(c.rgba);
let tex = gdk::MemoryTexture::new( // swscale undid the YUV matrix (full-range RGB) — but a PQ/BT.2020
c.width as i32, // stream keeps transfer + primaries baked in, so tag the texture and
c.height as i32, // let GTK tone-map. Plain SDR keeps the untagged (sRGB) fast path.
gdk::MemoryFormat::R8g8b8a8, let tagged = (c.color.is_pq() || c.color.primaries == 9)
&bytes, .then(|| rgb_state.get(c.color, true))
c.stride, .flatten();
); let tex: gdk::Texture = if let Some(state) = tagged {
gdk::MemoryTextureBuilder::new()
.set_width(c.width as i32)
.set_height(c.height as i32)
.set_format(gdk::MemoryFormat::R8g8b8a8)
.set_bytes(Some(&bytes))
.set_stride(c.stride)
.set_color_state(&state)
.build()
.upcast()
} else {
gdk::MemoryTexture::new(
c.width as i32,
c.height as i32,
gdk::MemoryFormat::R8g8b8a8,
&bytes,
c.stride,
)
.upcast()
};
picture.set_paintable(Some(&tex)); picture.set_paintable(Some(&tex));
presented = true; presented = true;
} }
@@ -393,7 +539,7 @@ fn spawn_frame_consumer(
.set_fourcc(d.fourcc) .set_fourcc(d.fourcc)
.set_modifier(d.modifier) .set_modifier(d.modifier)
.set_n_planes(d.planes.len() as u32) .set_n_planes(d.planes.len() as u32)
.set_color_state(rec709.as_ref()); .set_color_state(yuv_state.get(d.color, false).as_ref());
for (i, p) in d.planes.iter().enumerate() { for (i, p) in d.planes.iter().enumerate() {
b = unsafe { b.set_fd(i as u32, p.fd) } b = unsafe { b.set_fd(i as u32, p.fd) }
.set_offset(i as u32, p.offset) .set_offset(i as u32, p.offset)
+105 -14
View File
@@ -37,6 +37,43 @@ pub enum DecodedImage {
Dmabuf(DmabufFrame), Dmabuf(DmabufFrame),
} }
/// The stream's colour signaling, read PER-FRAME from the decoder (HEVC VUI → the
/// `AVFrame` CICP fields). The Windows host switches an HDR desktop to Main10 BT.2020 PQ
/// **in-band** (the Welcome still says SDR — clients are expected to follow the VUI, as
/// the Windows/Apple/Android clients do), so rendering must follow the frames, not the
/// handshake — else PQ content drawn as BT.709 comes out washed out and desaturated.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct ColorDesc {
/// H.273 code points as signaled (2 = unspecified → the renderer picks the SDR default).
pub primaries: u8,
pub transfer: u8,
pub matrix: u8,
pub full_range: bool,
}
impl ColorDesc {
/// Read the CICP fields off a raw decoded frame.
///
/// # Safety
/// `frame` must point to a valid `AVFrame` (alive for the duration of the call).
unsafe fn from_raw(frame: *const ffmpeg::ffi::AVFrame) -> ColorDesc {
// SAFETY: caller guarantees a live AVFrame; these are plain enum field reads.
unsafe {
ColorDesc {
primaries: (*frame).color_primaries as u32 as u8,
transfer: (*frame).color_trc as u32 as u8,
matrix: (*frame).colorspace as u32 as u8,
full_range: (*frame).color_range == ffmpeg::ffi::AVColorRange::AVCOL_RANGE_JPEG,
}
}
}
/// PQ (SMPTE ST.2084) transfer — the HDR10 signal.
pub fn is_pq(&self) -> bool {
self.transfer == 16
}
}
/// RGBA pixels for `GdkMemoryTexture` (which takes a stride). /// RGBA pixels for `GdkMemoryTexture` (which takes a stride).
pub struct CpuFrame { pub struct CpuFrame {
pub width: u32, pub width: u32,
@@ -44,6 +81,10 @@ pub struct CpuFrame {
/// RGBA row stride in bytes (≥ width*4 — swscale pads rows for SIMD). /// RGBA row stride in bytes (≥ width*4 — swscale pads rows for SIMD).
pub stride: usize, pub stride: usize,
pub rgba: Vec<u8>, pub rgba: Vec<u8>,
/// Signaling of the source frame. swscale already undid the YUV matrix + range (the
/// pixels are full-range RGB), but a PQ/BT.2020 stream keeps its transfer + primaries
/// baked in — the presenter tags the texture so GTK tone-maps it.
pub color: ColorDesc,
} }
/// A decoded frame still on the GPU: dmabuf fds + plane layout for /// A decoded frame still on the GPU: dmabuf fds + plane layout for
@@ -57,6 +98,9 @@ pub struct DmabufFrame {
pub fourcc: u32, pub fourcc: u32,
pub modifier: u64, pub modifier: u64,
pub planes: Vec<DmabufPlane>, pub planes: Vec<DmabufPlane>,
/// Signaling of the source frame — drives the `GdkDmabufTexture` color state (BT.709
/// narrow for SDR, BT.2020 PQ for an HDR stream).
pub color: ColorDesc,
pub guard: DrmFrameGuard, pub guard: DrmFrameGuard,
} }
@@ -174,8 +218,9 @@ impl Decoder {
struct SoftwareDecoder { struct SoftwareDecoder {
decoder: ffmpeg::decoder::Video, decoder: ffmpeg::decoder::Video,
/// Rebuilt whenever the decoded format/size changes (mid-stream `Reconfigure`). /// Rebuilt whenever the decoded format/size — or the colour signaling (a mid-stream
sws: Option<(scaling::Context, Pixel, u32, u32)>, /// SDR↔HDR flip) — changes.
sws: Option<(scaling::Context, Pixel, u32, u32, ColorDesc)>,
} }
impl SoftwareDecoder { impl SoftwareDecoder {
@@ -209,31 +254,41 @@ impl SoftwareDecoder {
fn convert_rgba(&mut self, frame: &AvFrame) -> Result<CpuFrame> { fn convert_rgba(&mut self, frame: &AvFrame) -> Result<CpuFrame> {
let (fmt, w, h) = (frame.format(), frame.width(), frame.height()); let (fmt, w, h) = (frame.format(), frame.width(), frame.height());
let rebuild = // SAFETY: `frame.as_ptr()` is the decoder-owned live AVFrame for this call.
!matches!(&self.sws, Some((_, f, sw, sh)) if *f == fmt && *sw == w && *sh == h); let color = unsafe { ColorDesc::from_raw(frame.as_ptr()) };
let rebuild = !matches!(&self.sws,
Some((_, f, sw, sh, c)) if *f == fmt && *sw == w && *sh == h && *c == color);
if rebuild { if rebuild {
let mut ctx = let mut ctx =
scaling::Context::get(fmt, w, h, Pixel::RGBA, w, h, scaling::Flags::POINT) scaling::Context::get(fmt, w, h, Pixel::RGBA, w, h, scaling::Flags::POINT)
.context("swscale context")?; .context("swscale context")?;
// swscale defaults to BT.601 coefficients, but our SDR HEVC stream is BT.709 limited // swscale defaults to BT.601 coefficients — set them from the FRAME's signaling
// range (the host signals BT.709 in the VUI). Without this, YUV→RGB decodes with BT.601 // (unspecified → BT.709 limited, the host's SDR default; a Windows HDR desktop
// and SDR colours shift (greens/reds off). Source = limited/studio YUV, destination = // streams BT.2020 in-band). Without this, YUV→RGB decodes with the wrong matrix
// full-range RGB. Inverse of the host's RGB→YUV CSC (encode/vaapi.rs). // and colours shift. Destination = full-range RGB; the transfer function stays
// baked in (the presenter tags PQ textures so GTK applies the EOTF).
const SWS_CS_ITU709: i32 = 1; const SWS_CS_ITU709: i32 = 1;
const SWS_CS_ITU601: i32 = 5;
const SWS_CS_BT2020: i32 = 9;
let cs = match color.matrix {
9 | 10 => SWS_CS_BT2020,
5 | 6 => SWS_CS_ITU601,
_ => SWS_CS_ITU709,
};
unsafe { unsafe {
let cs709 = ffmpeg::ffi::sws_getCoefficients(SWS_CS_ITU709); let coeffs = ffmpeg::ffi::sws_getCoefficients(cs);
ffmpeg::ffi::sws_setColorspaceDetails( ffmpeg::ffi::sws_setColorspaceDetails(
ctx.as_mut_ptr(), ctx.as_mut_ptr(),
cs709, // inv_table: source (YUV) coefficients — BT.709 coeffs, // inv_table: source (YUV) coefficients per the VUI
0, // srcRange: 0 = limited/studio (MPEG) color.full_range as i32, // srcRange: 0 = limited/studio (MPEG)
cs709, // table: destination coefficients (ignored for RGB output) coeffs, // table: destination coefficients (ignored for RGB output)
1, // dstRange: 1 = full-range RGB 1, // dstRange: 1 = full-range RGB
0, 0,
1 << 16, 1 << 16,
1 << 16, // brightness, contrast, saturation (defaults) 1 << 16, // brightness, contrast, saturation (defaults)
); );
} }
self.sws = Some((ctx, fmt, w, h)); self.sws = Some((ctx, fmt, w, h, color));
} }
let (sws, ..) = self.sws.as_mut().unwrap(); let (sws, ..) = self.sws.as_mut().unwrap();
// Single-pass conversion: swscale writes straight into the Vec the texture will // Single-pass conversion: swscale writes straight into the Vec the texture will
@@ -290,6 +345,7 @@ impl SoftwareDecoder {
height: h, height: h,
stride: dst_linesize[0] as usize, stride: dst_linesize[0] as usize,
rgba, rgba,
color,
}) })
} }
} }
@@ -474,6 +530,9 @@ impl VaapiDecoder {
fourcc, fourcc,
modifier, modifier,
planes, planes,
// SAFETY: `self.frame` is the live decoded AVFrame (unref'd only after
// this returns); plain CICP field reads.
color: ColorDesc::from_raw(self.frame),
guard, guard,
}) })
} }
@@ -555,4 +614,36 @@ mod tests {
None None
); );
} }
/// The wire → `ColorDesc` plumbing: an HDR10 stream's VUI (BT.2020 primaries, PQ
/// transfer, BT.2020-NCL matrix, limited range) must arrive on the decoded frame —
/// this is what the Windows host emits in-band for an HDR desktop, and mis-rendering
/// it as BT.709 is the washed-out-colors bug. Fixture: one 64×64 Main10 IDR
/// (`tests/pq-frame.h265`, x265 with explicit VUI).
#[test]
fn software_decode_carries_pq_signaling() {
let au = include_bytes!("../tests/pq-frame.h265");
let mut dec = SoftwareDecoder::new(ffmpeg::codec::Id::HEVC).expect("hevc decoder");
let mut got = dec.decode(au).expect("decode");
if got.is_none() {
// Low-delay decoders may still hold the frame until a flush — send EOF.
dec.decoder.send_eof().ok();
let mut frame = AvFrame::empty();
if dec.decoder.receive_frame(&mut frame).is_ok() {
got = Some(dec.convert_rgba(&frame).expect("convert"));
}
}
let f = got.expect("no frame decoded from the PQ fixture");
assert_eq!(
f.color,
ColorDesc {
primaries: 9,
transfer: 16,
matrix: 9,
full_range: false
}
);
assert!(f.color.is_pq());
assert_eq!((f.width, f.height), (64, 64));
}
} }
Binary file not shown.
+216 -47
View File
@@ -2,11 +2,17 @@
//! //!
//! Two planes: //! Two planes:
//! * [`control`] — the low-frequency `DeviceIoControl` plane (add/remove a virtual monitor, pin the //! * [`control`] — the low-frequency `DeviceIoControl` plane (add/remove a virtual monitor, pin the
//! render adapter, keepalive, info, clear-all). Owned, clean, versioned — NOT the SudoVDA ABI. //! render adapter, keepalive, info, clear-all, deliver the frame channel). Owned, clean, versioned —
//! * [`frame`] — the IDD-push frame transport: the host creates a ring of shared keyed-mutex textures //! NOT the SudoVDA ABI.
//! (+ a header + a frame-ready event) and the driver opens them and publishes composited frames into //! * [`frame`] — the IDD-push frame transport: the host creates a ring of **unnamed** shared
//! them. This crate owns the [`frame::SharedHeader`] layout, the [`frame::FrameToken`] packing, the //! keyed-mutex textures (+ a header + a frame-ready event), duplicates their handles into the
//! `Global\` object-name scheme, and the driver-status codes. //! driver's WUDFHost process and delivers the handle VALUES over
//! [`control::IOCTL_SET_FRAME_CHANNEL`]; the driver publishes composited frames into them. There is
//! deliberately no object-name scheme: an unnamed object cannot be enumerated, opened by name, or
//! pre-created ("squatted") — only the two endpoint processes ever hold a handle to any frame object
//! (the sealed channel, `design/idd-push-security.md`). This crate owns the [`frame::SharedHeader`]
//! layout, the [`frame::FrameToken`] packing, the channel-delivery struct, and the driver-status
//! codes.
//! //!
//! Both planes were previously hand-duplicated, byte-for-byte, across `idd_push.rs`/`frame_transport.rs` //! Both planes were previously hand-duplicated, byte-for-byte, across `idd_push.rs`/`frame_transport.rs`
//! and `vdisplay/sudovda.rs`/`control.rs` with only "must match" comments guarding them. Defining them //! and `vdisplay/sudovda.rs`/`control.rs` with only "must match" comments guarding them. Defining them
@@ -43,16 +49,22 @@ pub const fn interface_guid_fields() -> (u32, u16, u16, [u8; 8]) {
/// Bumped on any incompatible change to either plane. Exchanged via [`control::IOCTL_GET_INFO`]; host /// Bumped on any incompatible change to either plane. Exchanged via [`control::IOCTL_GET_INFO`]; host
/// and driver assert a match at startup so a mismatched pair fails loudly instead of corrupting. /// and driver assert a match at startup so a mismatched pair fails loudly instead of corrupting.
pub const PROTOCOL_VERSION: u32 = 1; /// v2: the sealed frame channel — the frame objects are unnamed and delivered by handle duplication
/// ([`control::IOCTL_SET_FRAME_CHANNEL`]), and [`control::AddReply`] grew `wudf_pid` (the duplication
/// target). A v1 driver has no channel-delivery IOCTL and expects named objects, so the pairing is
/// incompatible by design.
pub const PROTOCOL_VERSION: u32 = 2;
/// `CTL_CODE(FILE_DEVICE_UNKNOWN = 0x22, func, METHOD_BUFFERED = 0, FILE_ANY_ACCESS = 0)`. /// `CTL_CODE(FILE_DEVICE_UNKNOWN = 0x22, func, METHOD_BUFFERED = 0, FILE_ANY_ACCESS = 0)`.
pub const fn ctl_code(func: u32) -> u32 { pub const fn ctl_code(func: u32) -> u32 {
(0x22u32 << 16) | (func << 2) (0x22u32 << 16) | (func << 2)
} }
/// The control (`DeviceIoControl`) plane: add/remove a virtual monitor + adapter pin + keepalive. /// The control (`DeviceIoControl`) plane: add/remove a virtual monitor + adapter pin + keepalive +
/// frame-channel delivery.
pub mod control { pub mod control {
use super::ctl_code; use super::ctl_code;
use super::frame::RING_LEN;
use bytemuck::{Pod, Zeroable}; use bytemuck::{Pod, Zeroable};
// Contiguous op space at 0x900 — distinct from SudoVDA's gappy 0x800/0x888/0x8FF numbering. // Contiguous op space at 0x900 — distinct from SudoVDA's gappy 0x800/0x888/0x8FF numbering.
@@ -69,6 +81,10 @@ pub mod control {
/// Tear down every virtual monitor (host-startup orphan reap). No payload. First-class op — NOT the /// Tear down every virtual monitor (host-startup orphan reap). No payload. First-class op — NOT the
/// SudoVDA "send-and-hope-it's-ignored" hack. /// SudoVDA "send-and-hope-it's-ignored" hack.
pub const IOCTL_CLEAR_ALL: u32 = ctl_code(0x905); pub const IOCTL_CLEAR_ALL: u32 = ctl_code(0x905);
/// Deliver a monitor's IDD-push frame channel: the handle VALUES of the unnamed shared objects the
/// host duplicated into the driver's WUDFHost process. Input [`SetFrameChannelRequest`]. Sent once
/// after the ring is created and again on every mid-session ring recreate (HDR-mode flip).
pub const IOCTL_SET_FRAME_CHANNEL: u32 = ctl_code(0x906);
/// `IOCTL_ADD` input. A monotonic `session_id` keys the monitor (the host's refcount manager owns /// `IOCTL_ADD` input. A monotonic `session_id` keys the monitor (the host's refcount manager owns
/// collision safety — no more SudoVDA's 16-byte GUID + pid-mangling). The driver advertises this /// collision safety — no more SudoVDA's 16-byte GUID + pid-mangling). The driver advertises this
@@ -103,6 +119,11 @@ pub mod control {
/// `_reserved` (offset 12): an un-upgraded driver leaves it `0`, so the host can tell its /// `_reserved` (offset 12): an un-upgraded driver leaves it `0`, so the host can tell its
/// preference was ignored (stale driver) and log it instead of silently losing per-client config. /// preference was ignored (stale driver) and log it instead of silently losing per-client config.
pub resolved_monitor_id: u32, pub resolved_monitor_id: u32,
/// The driver's own process id (the WUDFHost hosting `pf_vdisplay`) — the target the host
/// duplicates the unnamed frame-object handles INTO (`OpenProcess(PROCESS_DUP_HANDLE)` +
/// `DuplicateHandle`, then [`IOCTL_SET_FRAME_CHANNEL`]). Reported per-ADD, not per-open, so a
/// WUDFHost restart between sessions can never leave the host duplicating into a dead process.
pub wudf_pid: u32,
} }
/// `IOCTL_REMOVE` input. /// `IOCTL_REMOVE` input.
@@ -129,6 +150,39 @@ pub mod control {
pub watchdog_timeout_s: u32, pub watchdog_timeout_s: u32,
} }
/// `IOCTL_SET_FRAME_CHANNEL` input — the sealed frame channel's bootstrap. Every handle field is a
/// handle VALUE already duplicated into the driver's WUDFHost process by the host; receiving it, the
/// driver OWNS those handles (it closes whatever it doesn't consume — a replaced, invalid, or
/// unmatched delivery must not leak entries in its own handle table).
///
/// Handle values are only meaningful inside the target process's handle table, so this struct is
/// harmless to any third party: reading it leaks nothing openable, and spoofing it (were the control
/// device reachable — it is ACL'd to SYSTEM + admins) could at worst feed the driver values that
/// don't resolve, a DoS of the attacker's own session. The frame objects themselves are unnamed and
/// therefore unreachable by any process that isn't one of the two endpoints.
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
pub struct SetFrameChannelRequest {
/// The OS target id from [`AddReply`] — which monitor this channel belongs to.
pub target_id: u32,
/// The ring generation these textures belong to (must match the shared header's generation at
/// attach time; a stale delivery is dropped by the driver — a fresh one follows every recreate).
pub generation: u32,
/// How many leading entries of `texture_handles` are valid (`1..=`[`RING_LEN`]).
pub ring_len: u32,
pub _pad: u32,
/// The shared-header file-mapping handle (the driver maps it and writes status/publish tokens).
pub header_handle: u64,
/// The frame-ready auto-reset event handle (the driver signals it after each publish).
pub event_handle: u64,
/// The ring textures' shared NT handles (opened via `ID3D11Device1::OpenSharedResource1`).
pub texture_handles: [u64; RING_LEN_USIZE],
}
/// [`RING_LEN`] as a usize for the `texture_handles` array length (the wire struct sizes the array
/// at the compile-time maximum; `ring_len` says how many entries are live).
pub const RING_LEN_USIZE: usize = RING_LEN as usize;
// Layout is load-bearing across the process boundary — pin it. (bytemuck's Pod derive already // Layout is load-bearing across the process boundary — pin it. (bytemuck's Pod derive already
// rejects any internal padding; these assert the externally-visible sizes too.) The `offset_of!` // rejects any internal padding; these assert the externally-visible sizes too.) The `offset_of!`
// asserts additionally catch a SAME-SIZE field reorder, which the size+Pod checks alone miss. // asserts additionally catch a SAME-SIZE field reorder, which the size+Pod checks alone miss.
@@ -142,11 +196,20 @@ pub mod control {
assert!(offset_of!(AddRequest, refresh_hz) == 16); assert!(offset_of!(AddRequest, refresh_hz) == 16);
assert!(offset_of!(AddRequest, preferred_monitor_id) == 20); assert!(offset_of!(AddRequest, preferred_monitor_id) == 20);
assert!(size_of::<AddReply>() == 16); assert!(size_of::<AddReply>() == 20);
assert!(offset_of!(AddReply, adapter_luid_low) == 0); assert!(offset_of!(AddReply, adapter_luid_low) == 0);
assert!(offset_of!(AddReply, adapter_luid_high) == 4); assert!(offset_of!(AddReply, adapter_luid_high) == 4);
assert!(offset_of!(AddReply, target_id) == 8); assert!(offset_of!(AddReply, target_id) == 8);
assert!(offset_of!(AddReply, resolved_monitor_id) == 12); assert!(offset_of!(AddReply, resolved_monitor_id) == 12);
assert!(offset_of!(AddReply, wudf_pid) == 16);
assert!(size_of::<SetFrameChannelRequest>() == 32 + 8 * RING_LEN_USIZE);
assert!(offset_of!(SetFrameChannelRequest, target_id) == 0);
assert!(offset_of!(SetFrameChannelRequest, generation) == 4);
assert!(offset_of!(SetFrameChannelRequest, ring_len) == 8);
assert!(offset_of!(SetFrameChannelRequest, header_handle) == 16);
assert!(offset_of!(SetFrameChannelRequest, event_handle) == 24);
assert!(offset_of!(SetFrameChannelRequest, texture_handles) == 32);
assert!(size_of::<RemoveRequest>() == 8); assert!(size_of::<RemoveRequest>() == 8);
assert!(offset_of!(RemoveRequest, session_id) == 0); assert!(offset_of!(RemoveRequest, session_id) == 0);
@@ -161,11 +224,12 @@ pub mod control {
}; };
} }
/// The IDD-push frame transport: the host-created shared ring header, the publish token, the names, and /// The IDD-push frame transport: the host-created shared ring header, the publish token, and the
/// the driver-status codes. The texture ring itself is host-created D3D11 keyed-mutex textures (opened /// driver-status codes. The texture ring itself is host-created **unnamed** D3D11 keyed-mutex textures;
/// by name on the driver side); only the *layout/contract* lives here. /// the driver reaches them (and the header + event) only through handles the host duplicated into its
/// process and delivered via [`crate::control::IOCTL_SET_FRAME_CHANNEL`] — the sealed channel. Only the
/// *layout/contract* lives here.
pub mod frame { pub mod frame {
use alloc::string::String;
use bytemuck::{Pod, Zeroable}; use bytemuck::{Pod, Zeroable};
/// Header magic (`"PFVD"` LE). The host stamps it LAST (after the ring textures exist) so the driver /// Header magic (`"PFVD"` LE). The host stamps it LAST (after the ring textures exist) so the driver
@@ -195,8 +259,10 @@ pub mod frame {
pub struct SharedHeader { pub struct SharedHeader {
pub magic: u32, pub magic: u32,
pub version: u32, pub version: u32,
/// Bumped by the host on a ring recreate (HDR-mode flip → new texture format/names). The driver /// Bumped by the host on a ring recreate (HDR-mode flip → new texture format + a fresh
/// re-attaches when it changes; a publish carries it so the host rejects a stale-ring publish. /// [`control::IOCTL_SET_FRAME_CHANNEL`](crate::control::IOCTL_SET_FRAME_CHANNEL) delivery). The
/// driver re-attaches when it changes; a publish carries it so the host rejects a stale-ring
/// publish.
pub generation: u32, pub generation: u32,
pub ring_len: u32, pub ring_len: u32,
pub width: u32, pub width: u32,
@@ -245,21 +311,6 @@ pub mod frame {
} }
} }
/// `Global\pfvd-hdr-<target>` — the shared metadata header mapping name.
pub fn header_name(target_id: u32) -> String {
alloc::format!("Global\\pfvd-hdr-{target_id}")
}
/// `Global\pfvd-evt-<target>` — the frame-ready auto-reset event name.
pub fn event_name(target_id: u32) -> String {
alloc::format!("Global\\pfvd-evt-{target_id}")
}
/// `Global\pfvd-tex-<target>-<generation>-<slot>` — a ring texture's shared-handle name. The
/// generation in the name means a recreate's new textures never collide with the old ring's
/// not-yet-released handles.
pub fn texture_name(target_id: u32, generation: u32, slot: u32) -> String {
alloc::format!("Global\\pfvd-tex-{target_id}-{generation}-{slot}")
}
// Size + per-field offsets are load-bearing: both sides access these via raw atomic views over the // Size + per-field offsets are load-bearing: both sides access these via raw atomic views over the
// mapping, so a same-size field reorder would silently corrupt. Pin every offset. The `_pad` after // mapping, so a same-size field reorder would silently corrupt. Pin every offset. The `_pad` after
// `dxgi_format` is what 8-aligns the `u64 latest` at offset 32 — assert that too. // `dxgi_format` is what 8-aligns the `u64 latest` at offset 32 — assert that too.
@@ -292,8 +343,10 @@ pub mod frame {
/// (`design/windows-host-rewrite.md` §2.7). Owning them here with `Pod` derives + `offset_of!` /// (`design/windows-host-rewrite.md` §2.7). Owning them here with `Pod` derives + `offset_of!`
/// asserts makes a one-sided edit a compile error. /// asserts makes a one-sided edit a compile error.
/// ///
/// The host creates the section (privileged, permissive DACL so the restricted WUDFHost token can /// Since v2 the channel is **sealed** (`design/gamepad-channel-sealing.md`, mirroring the frame
/// open it) and the driver maps it. Layout only; the section itself is host-created shared memory. /// channel): the host creates the DATA section ([`XusbShm`]/[`PadShm`]) UNNAMED (SYSTEM-only DACL)
/// and duplicates its handle into the driver's WUDFHost; only the tiny [`PadBootstrap`] mailbox
/// stays named (it carries nothing exploitable). Layout only; the sections are host-created.
pub mod gamepad { pub mod gamepad {
use alloc::string::String; use alloc::string::String;
use bytemuck::{Pod, Zeroable}; use bytemuck::{Pod, Zeroable};
@@ -316,15 +369,68 @@ pub mod gamepad {
/// The section starts zeroed, so `0` always means "no driver has attached (yet)"; a pre-health /// The section starts zeroed, so `0` always means "no driver has attached (yet)"; a pre-health
/// driver never writes the field and reads as not-attached, which the host log line calls out /// driver never writes the field and reads as not-attached, which the host log line calls out
/// (the remedy is the same: reinstall the drivers). Bump on a gamepad-layout change. /// (the remedy is the same: reinstall the drivers). Bump on a gamepad-layout change.
pub const GAMEPAD_PROTO_VERSION: u32 = 1; ///
/// v2: the **sealed pad channel** (`design/gamepad-channel-sealing.md`) — the DATA section
/// ([`XusbShm`]/[`PadShm`]) is UNNAMED and reaches the driver only as a handle the host duplicated
/// into its WUDFHost, bootstrapped through the named [`PadBootstrap`] mailbox; the DATA section
/// gained `pad_index` (carved from reserved space) so the driver rejects a cross-pad delivery.
/// A v1 driver opens `Global\pf…-shm-<i>` (which no longer exists) and a v1 host never creates
/// the mailbox a v2 driver polls, so a mixed pairing fails closed either way.
pub const GAMEPAD_PROTO_VERSION: u32 = 2;
/// `Global\pfxusb-shm-<index>` — the virtual Xbox 360 (XInput) shared section. /// Bootstrap-mailbox magic (`"PFBT"` LE) — the host stamps it LAST (after `host_proto`), so a
pub fn xusb_shm_name(index: u8) -> String { /// driver only trusts a fully-initialized mailbox.
alloc::format!("Global\\pfxusb-shm-{index}") pub const BOOT_MAGIC: u32 = 0x5442_4650;
/// `Global\pfxusb-boot-<index>` — the virtual Xbox 360 pad's bootstrap mailbox ([`PadBootstrap`]).
pub fn xusb_boot_name(index: u8) -> String {
alloc::format!("Global\\pfxusb-boot-{index}")
} }
/// `Global\pfds-shm-<index>` — the virtual DualSense / DualShock 4 shared section. /// `Global\pfds-boot-<index>` — the DualSense / DualShock 4 pad's bootstrap mailbox
pub fn pad_shm_name(index: u8) -> String { /// ([`PadBootstrap`]).
alloc::format!("Global\\pfds-shm-{index}") pub fn pad_boot_name(index: u8) -> String {
alloc::format!("Global\\pfds-boot-{index}")
}
/// The per-pad bootstrap mailbox (32 B, named `Global\pf…-boot-<index>`, SY+LS DACL) — the ONLY
/// named object left on the gamepad channel. It exists because the pad drivers are UMDF HID
/// minidrivers with no control device (hidclass owns the stack), so there is no IOCTL to hand the
/// driver a duplicated handle or learn its WUDFHost pid; this mailbox is the late-bound handshake:
///
/// 1. host creates it (zeroed), stamps `host_proto` then `magic` (in that order);
/// 2. driver opens it by name (pad index from `pszDeviceLocation`), writes `driver_proto`, and —
/// iff `host_proto` matches its own version — publishes `driver_pid`;
/// 3. host polls `driver_pid`, verifies the pid is a genuine WUDFHost, duplicates the unnamed DATA
/// section into it, then writes `data_handle` + `handle_pid` and bumps `handle_seq` LAST;
/// 4. driver sees a fresh `handle_seq` addressed to its own pid, maps `data_handle`, and validates
/// the mapped section's magic + `pad_index` before use.
///
/// Deliberately safe to leave named + LS-openable: it carries only pids (not sensitive) and a
/// handle VALUE (meaningless outside the target WUDFHost's handle table). A sibling LocalService
/// that tampers with it can at worst mis-route a delivery — a gamepad DoS, never a read or an
/// injection (it cannot place a valid section handle in the WUDFHost, and the driver's
/// magic+`pad_index` validation rejects any handle that doesn't resolve to this pad's section).
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
pub struct PadBootstrap {
/// [`BOOT_MAGIC`], host-stamped last at creation.
pub magic: u32,
/// The host's [`GAMEPAD_PROTO_VERSION`]. A driver whose own version differs must NOT publish
/// its pid (fail closed) — it still writes `driver_proto` so the host can log the mismatch.
pub host_proto: u32,
/// The driver's WUDFHost process id (driver-written; `0` = no driver yet). The duplication
/// target the host verifies (`verify_is_wudfhost`) before duplicating the DATA section into it.
pub driver_pid: u32,
/// The driver's [`GAMEPAD_PROTO_VERSION`] (driver-written; diagnostics only).
pub driver_proto: u32,
/// The DATA-section handle VALUE the host duplicated into `handle_pid`'s handle table
/// (host-written; valid only inside that process).
pub data_handle: u64,
/// The pid `data_handle` was duplicated for — a driver whose pid differs ignores the delivery.
pub handle_pid: u32,
/// Bumped by the host (host-global monotonic, never 0) AFTER `data_handle`/`handle_pid` are in
/// place — the driver's new-delivery trigger.
pub handle_seq: u32,
} }
/// Virtual Xbox 360 (XInput) shared section (64 B). The host writes the XInput state (a bumped /// Virtual Xbox 360 (XInput) shared section (64 B). The host writes the XInput state (a bumped
@@ -356,7 +462,12 @@ pub mod gamepad {
/// Bumped by the driver on every serviced XInput IOCTL — proves the game-visible path (it /// Bumped by the driver on every serviced XInput IOCTL — proves the game-visible path (it
/// only advances while something polls the slot, so a static value is not an error). /// only advances while something polls the slot, so a static value is not an error).
pub driver_heartbeat: u32, pub driver_heartbeat: u32,
pub _reserved1: [u8; 24], /// The pad index this section serves (host-stamped before the magic). The driver validates it
/// against its own `pszDeviceLocation` index when it maps the delivered handle, so a mis-routed
/// (or bootstrap-tampered) cross-pad delivery is rejected instead of silently cross-wiring two
/// pads. Carved from v1 reserved space (v2).
pub pad_index: u32,
pub _reserved1: [u8; 20],
} }
/// Virtual DualSense / DualShock 4 shared section (256 B). The host writes the `0x01`-style HID /// Virtual DualSense / DualShock 4 shared section (256 B). The host writes the `0x01`-style HID
@@ -384,7 +495,10 @@ pub mod gamepad {
/// Bumped by the driver's ~125 Hz timer each tick — a true liveness heartbeat (unlike the /// Bumped by the driver's ~125 Hz timer each tick — a true liveness heartbeat (unlike the
/// XUSB one, this advances whenever the driver is loaded, game or not). /// XUSB one, this advances whenever the driver is loaded, game or not).
pub driver_heartbeat: u32, pub driver_heartbeat: u32,
pub _reserved1: [u8; 104], /// The pad index this section serves (host-stamped before the magic) — see
/// [`XusbShm::pad_index`]. Carved from v1 reserved space (v2).
pub pad_index: u32,
pub _reserved1: [u8; 100],
} }
// Offsets are the wire contract the shipped drivers already read by hand — pin every one. A failing // Offsets are the wire contract the shipped drivers already read by hand — pin every one. A failing
@@ -408,6 +522,7 @@ pub mod gamepad {
assert!(offset_of!(XusbShm, rumble_small) == 29); assert!(offset_of!(XusbShm, rumble_small) == 29);
assert!(offset_of!(XusbShm, driver_proto) == 32); assert!(offset_of!(XusbShm, driver_proto) == 32);
assert!(offset_of!(XusbShm, driver_heartbeat) == 36); assert!(offset_of!(XusbShm, driver_heartbeat) == 36);
assert!(offset_of!(XusbShm, pad_index) == 40);
assert!(size_of::<PadShm>() == 256); assert!(size_of::<PadShm>() == 256);
assert!(offset_of!(PadShm, magic) == 0); assert!(offset_of!(PadShm, magic) == 0);
@@ -417,6 +532,16 @@ pub mod gamepad {
assert!(offset_of!(PadShm, device_type) == 140); assert!(offset_of!(PadShm, device_type) == 140);
assert!(offset_of!(PadShm, driver_proto) == 144); assert!(offset_of!(PadShm, driver_proto) == 144);
assert!(offset_of!(PadShm, driver_heartbeat) == 148); assert!(offset_of!(PadShm, driver_heartbeat) == 148);
assert!(offset_of!(PadShm, pad_index) == 152);
assert!(size_of::<PadBootstrap>() == 32);
assert!(offset_of!(PadBootstrap, magic) == 0);
assert!(offset_of!(PadBootstrap, host_proto) == 4);
assert!(offset_of!(PadBootstrap, driver_pid) == 8);
assert!(offset_of!(PadBootstrap, driver_proto) == 12);
assert!(offset_of!(PadBootstrap, data_handle) == 16);
assert!(offset_of!(PadBootstrap, handle_pid) == 24);
assert!(offset_of!(PadBootstrap, handle_seq) == 28);
}; };
} }
@@ -487,28 +612,71 @@ mod tests {
adapter_luid_high: -2, adapter_luid_high: -2,
target_id: 262, target_id: 262,
resolved_monitor_id: 7, resolved_monitor_id: 7,
wudf_pid: 4242,
}; };
let rbytes = bytemuck::bytes_of(&reply); let rbytes = bytemuck::bytes_of(&reply);
assert_eq!(rbytes.len(), 16); assert_eq!(rbytes.len(), 20);
assert_eq!(*bytemuck::from_bytes::<control::AddReply>(rbytes), reply); assert_eq!(*bytemuck::from_bytes::<control::AddReply>(rbytes), reply);
// resolved_monitor_id occupies the old `_reserved` slot at offset 12 — byte-compatible. // resolved_monitor_id occupies the old `_reserved` slot at offset 12 — byte-compatible.
assert_eq!(rbytes[12..16], 7u32.to_le_bytes()); assert_eq!(rbytes[12..16], 7u32.to_le_bytes());
// The v2 duplication-target pid trails at offset 16.
assert_eq!(rbytes[16..20], 4242u32.to_le_bytes());
} }
#[test] #[test]
fn names_are_stable() { fn frame_channel_request_roundtrips_through_bytes() {
assert_eq!(frame::header_name(10), "Global\\pfvd-hdr-10"); let mut req = control::SetFrameChannelRequest {
assert_eq!(frame::event_name(10), "Global\\pfvd-evt-10"); target_id: 262,
assert_eq!(frame::texture_name(10, 3, 5), "Global\\pfvd-tex-10-3-5"); generation: 3,
ring_len: frame::RING_LEN,
_pad: 0,
header_handle: 0x0000_0000_0000_1a2c,
event_handle: 0x0000_0000_0000_1b30,
texture_handles: [0; control::RING_LEN_USIZE],
};
for (k, t) in req.texture_handles.iter_mut().enumerate() {
*t = 0x2000 + k as u64 * 4;
}
let bytes = bytemuck::bytes_of(&req);
assert_eq!(bytes.len(), 32 + 8 * control::RING_LEN_USIZE);
assert_eq!(
*bytemuck::from_bytes::<control::SetFrameChannelRequest>(bytes),
req
);
// The handle values ride at 8-byte alignment from offset 16 (header, event, then the ring).
assert_eq!(bytes[16..24], 0x1a2cu64.to_le_bytes());
assert_eq!(bytes[24..32], 0x1b30u64.to_le_bytes());
assert_eq!(bytes[32..40], 0x2000u64.to_le_bytes());
} }
#[test] #[test]
fn gamepad_names_and_magics_are_stable() { fn gamepad_names_and_magics_are_stable() {
assert_eq!(gamepad::xusb_shm_name(0), "Global\\pfxusb-shm-0"); assert_eq!(gamepad::xusb_boot_name(0), "Global\\pfxusb-boot-0");
assert_eq!(gamepad::pad_shm_name(2), "Global\\pfds-shm-2"); assert_eq!(gamepad::pad_boot_name(2), "Global\\pfds-boot-2");
// Lock the exact u32 magics the shipped host/drivers use (inject/{gamepad,dualsense}_windows.rs). // Lock the exact u32 magics the shipped host/drivers use (inject/{gamepad,dualsense}_windows.rs).
assert_eq!(gamepad::XUSB_MAGIC, 0x5558_4650); assert_eq!(gamepad::XUSB_MAGIC, 0x5558_4650);
assert_eq!(gamepad::PAD_MAGIC, 0x5046_4453); assert_eq!(gamepad::PAD_MAGIC, 0x5046_4453);
// "PFBT" little-endian.
assert_eq!(gamepad::BOOT_MAGIC.to_le_bytes(), *b"PFBT");
}
#[test]
fn pad_bootstrap_roundtrips_through_bytes() {
let b = gamepad::PadBootstrap {
magic: gamepad::BOOT_MAGIC,
host_proto: gamepad::GAMEPAD_PROTO_VERSION,
driver_pid: 1234,
driver_proto: gamepad::GAMEPAD_PROTO_VERSION,
data_handle: 0x0000_0000_0000_2a4c,
handle_pid: 1234,
handle_seq: 7,
};
let bytes = bytemuck::bytes_of(&b);
assert_eq!(bytes.len(), 32);
assert_eq!(*bytemuck::from_bytes::<gamepad::PadBootstrap>(bytes), b);
// The handle value rides 8-aligned at offset 16; the seq trails at 28 (written LAST by the host).
assert_eq!(bytes[16..24], 0x2a4cu64.to_le_bytes());
assert_eq!(bytes[28..32], 7u32.to_le_bytes());
} }
#[test] #[test]
@@ -521,6 +689,7 @@ mod tests {
control::IOCTL_PING, control::IOCTL_PING,
control::IOCTL_GET_INFO, control::IOCTL_GET_INFO,
control::IOCTL_CLEAR_ALL, control::IOCTL_CLEAR_ALL,
control::IOCTL_SET_FRAME_CHANNEL,
]; ];
for (i, a) in all.iter().enumerate() { for (i, a) in all.iter().enumerate() {
for b in &all[i + 1..] { for b in &all[i + 1..] {
+11 -4
View File
@@ -15,6 +15,10 @@ quinn = "0.11"
anyhow = "1" anyhow = "1"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# The log ring (log_capture.rs) normalizes `log`-crate events off the bridge's "log" shim target
# back to the real module path, so the console's target column and the ring's noise gate see
# `mdns_sd::…` instead of "log".
tracing-log = "0.2"
axum = "0.8" axum = "0.8"
mdns-sd = "0.20" mdns-sd = "0.20"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
@@ -64,6 +68,8 @@ tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1" http-body-util = "0.1"
# Disposable directory fixtures for the Steam local-librarycache scan tests (library.rs). # Disposable directory fixtures for the Steam local-librarycache scan tests (library.rs).
tempfile = "3" tempfile = "3"
# Emit `log`-crate records through the tracing-log bridge in the log_capture tests.
log = "0.4"
# Opus encode for the host->client audio plane — stereo (`opus::Encoder`) AND 5.1/7.1 surround # Opus encode for the host->client audio plane — stereo (`opus::Encoder`) AND 5.1/7.1 surround
# (`opus::MSEncoder`, the safe multistream API the crate exposes; no `audiopus_sys` needed). The # (`opus::MSEncoder`, the safe multistream API the crate exposes; no `audiopus_sys` needed). The
@@ -226,10 +232,11 @@ pf-driver-proto = { path = "../pf-driver-proto" }
bytemuck = { version = "1.19", features = ["derive"] } bytemuck = { version = "1.19", features = ["derive"] }
[features] [features]
# NVENC hardware encode (Windows). OFF by default: it pulls the NVENC SDK, and the host then needs # NVENC hardware encode (Windows). OFF by default (it pulls the NVENC SDK crate); nothing is
# the NVENC entry points (NvEncodeAPICreateInstance / NvEncodeAPIGetMaxSupportedVersion) at link # needed at link time — the entry points are resolved at RUNTIME from the driver's
# time — i.e. `nvencodeapi.lib` from the NVIDIA Video Codec SDK (or an import lib generated from # nvEncodeAPI64.dll (encode/windows/nvenc.rs `load_api`), so the same binary starts fine on
# nvEncodeAPI64.dll) on the linker path. Build the GPU host with `--features nvenc`. # AMD/Intel-only boxes and falls through to AMF/QSV/software. Build the GPU host with
# `--features nvenc`.
nvenc = ["dep:nvidia-video-codec-sdk"] nvenc = ["dep:nvidia-video-codec-sdk"]
# AMD/Intel hardware encode on Windows (AMF/QSV via ffmpeg-next). OFF by default: it needs a # AMD/Intel hardware encode on Windows (AMF/QSV via ffmpeg-next). OFF by default: it needs a
# `FFMPEG_DIR` (BtbN lgpl-shared — includes `*_amf`/`*_qsv`; the GPL-only x264/x265 are never used, # `FFMPEG_DIR` (BtbN lgpl-shared — includes `*_amf`/`*_qsv`; the GPL-only x264/x265 are never used,
+6 -15
View File
@@ -1,10 +1,9 @@
//! Build script. The only thing it does: with the `nvenc` feature (Windows GPU host), tell the //! Build script: stamps the build version. NVENC deliberately needs NOTHING here — the entry
//! linker to pull the NVENC import library. The NVENC entry points //! points (`NvEncodeAPICreateInstance` / `NvEncodeAPIGetMaxSupportedVersion`) live in
//! (`NvEncodeAPICreateInstance` / `NvEncodeAPIGetMaxSupportedVersion`) live in `nvEncodeAPI64.dll` //! `nvEncodeAPI64.dll`, which only exists where the NVIDIA driver is installed, so
//! (shipped with the NVIDIA driver), so the host links against `nvencodeapi.lib`. Point //! `encode/windows/nvenc.rs` resolves them at RUNTIME (`LoadLibraryExW`). The former link-time
//! `PUNKTFUNK_NVENC_LIB_DIR` at a directory containing `nvencodeapi.lib` — from the NVIDIA Video //! import (`cargo:rustc-link-lib=nvencodeapi`) made the Windows loader kill the all-vendor host
//! Codec SDK, or an import lib generated from the driver's `nvEncodeAPI64.dll` //! binary on every AMD/Intel-only box before `main` ("nvencodeapi64.dll was not found").
//! (`lib /def:nvenc.def /machine:x64 /out:nvencodeapi.lib` with the two exports above).
fn main() { fn main() {
// Build provenance: stamp the exact package/build version into the binary so a running host // Build provenance: stamp the exact package/build version into the binary so a running host
// can report what it is (mgmt /health, the startup log, `--version`) and a stale/shadowed // can report what it is (mgmt /health, the startup log, `--version`) and a stale/shadowed
@@ -18,12 +17,4 @@ fn main() {
.unwrap_or_else(|| std::env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "unknown".into())); .unwrap_or_else(|| std::env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "unknown".into()));
println!("cargo:rustc-env=PUNKTFUNK_VERSION={version}"); println!("cargo:rustc-env=PUNKTFUNK_VERSION={version}");
println!("cargo:rerun-if-env-changed=PUNKTFUNK_BUILD_VERSION"); println!("cargo:rerun-if-env-changed=PUNKTFUNK_BUILD_VERSION");
if std::env::var_os("CARGO_FEATURE_NVENC").is_some() {
if let Some(dir) = std::env::var_os("PUNKTFUNK_NVENC_LIB_DIR") {
println!("cargo:rustc-link-search=native={}", dir.to_string_lossy());
}
println!("cargo:rustc-link-lib=dylib=nvencodeapi");
println!("cargo:rerun-if-env-changed=PUNKTFUNK_NVENC_LIB_DIR");
}
} }
@@ -42,6 +42,10 @@ pub struct WinCaptureTarget {
pub gdi_name: String, pub gdi_name: String,
/// Stable SudoVDA target id — re-resolved to the current GDI name on every recovery. /// Stable SudoVDA target id — re-resolved to the current GDI name on every recovery.
pub target_id: u32, pub target_id: u32,
/// The pf-vdisplay driver's WUDFHost pid (from the ADD reply) — the process the IDD-push capturer
/// duplicates the sealed frame channel's handles INTO (`idd_push::ChannelBroker`). `0` = unknown
/// (a pre-v2 pairing can't occur — the version handshake is hard — so this only guards misuse).
pub wudf_pid: u32,
} }
/// A GPU-resident captured texture (future NVENC-D3D11 zero-copy path). /// A GPU-resident captured texture (future NVENC-D3D11 zero-copy path).
@@ -1,14 +1,20 @@
//! P2 direct frame push (kill DDA) — HOST side. The pf-vdisplay driver's WUDFHost canNOT create named //! P2 direct frame push (kill DDA) — HOST side, over the **sealed channel**
//! kernel objects, so — exactly like the gamepad UMDF drivers (`inject/dualsense_windows.rs`) — the //! (`design/idd-push-security.md`). The frame channel carries whole-desktop pixels, so its protection
//! HOST (privileged) CREATES the shared header + frame-ready event + ring of keyed-mutex textures //! must match DDA's (where capturer and consumer are one process and there is no openable channel at
//! (`Global\` names, scoped `D:(A;;GA;;;SY)(A;;GA;;;LS)` to SYSTEM + the driver's LocalService host — //! all): the HOST (SYSTEM) creates the shared header + frame-ready event + ring of keyed-mutex textures
//! see `shared_object_sa`) on the discrete render GPU, and the driver only OPENS them and copies frames in. We then consume the ring //! **UNNAMED** on the discrete render GPU — nothing to enumerate, open by name, or pre-create
//! straight into the zero-copy NVENC path — no DXGI Desktop Duplication, no `win32u` hook. Gated by //! ("squat") — then DUPLICATES the handles into the pf-vdisplay driver's WUDFHost process
//! `PUNKTFUNK_IDD_PUSH`. Driver counterpart: `packaging/windows/drivers/pf-vdisplay/src/ //! ([`ChannelBroker`]; SYSTEM can `DuplicateHandle` into the LocalService host, the reverse is
//! correctly denied, which is why the HOST is the broker) and delivers the handle VALUES over the
//! SYSTEM-only control device (`IOCTL_SET_FRAME_CHANNEL`). A handle value is meaningless outside the
//! target process's handle table, so the bootstrap's ACL is not load-bearing; the only way to reach the
//! frames is to already be one of the two endpoint processes. The driver copies frames in; we consume
//! the ring straight into the zero-copy NVENC path — no DXGI Desktop Duplication, no `win32u` hook.
//! Gated by `PUNKTFUNK_IDD_PUSH`. Driver counterpart: `packaging/windows/drivers/pf-vdisplay/src/
//! frame_transport.rs`. The shared `SharedHeader` layout, `MAGIC`/`VERSION`/`RING_LEN`, the //! frame_transport.rs`. The shared `SharedHeader` layout, `MAGIC`/`VERSION`/`RING_LEN`, the
//! `DRV_STATUS_*` codes, the `Global\` name scheme and the publish token all come from //! `DRV_STATUS_*` codes, the channel-delivery struct and the publish token all come from
//! [`pf_driver_proto::frame`] (which OWNS the contract, with `const` size asserts) — both sides //! [`pf_driver_proto`] (which OWNS the contract, with `const` size asserts) — both sides `use` it, so
//! `use` it, so drift is a compile error rather than a "must match" comment. //! drift is a compile error rather than a "must match" comment.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program). // Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)] #![deny(clippy::undocumented_unsafe_blocks)]
@@ -16,12 +22,15 @@
use super::dxgi::{make_device, D3d11Frame, HdrP010Converter, VideoConverter, WinCaptureTarget}; use super::dxgi::{make_device, D3d11Frame, HdrP010Converter, VideoConverter, WinCaptureTarget};
use super::{CapturedFrame, Capturer, FramePayload, PixelFormat}; use super::{CapturedFrame, Capturer, FramePayload, PixelFormat};
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use pf_driver_proto::frame; use pf_driver_proto::{control, frame};
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use windows::core::{w, Interface, HSTRING}; use windows::core::{w, Interface, PCWSTR, PWSTR};
use windows::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE, LUID}; use windows::Win32::Foundation::{
DuplicateHandle, DUPLICATE_CLOSE_SOURCE, DUPLICATE_HANDLE_OPTIONS, DUPLICATE_SAME_ACCESS,
HANDLE, INVALID_HANDLE_VALUE, LUID,
};
use windows::Win32::Graphics::Direct3D11::{ use windows::Win32::Graphics::Direct3D11::{
ID3D11Device, ID3D11DeviceContext, ID3D11ShaderResourceView, ID3D11Texture2D, ID3D11Device, ID3D11DeviceContext, ID3D11ShaderResourceView, ID3D11Texture2D,
D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE, D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX, D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE, D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX,
@@ -42,47 +51,43 @@ use windows::Win32::System::Memory::{
CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS, CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS,
MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE, MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
}; };
use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject}; use windows::Win32::System::Threading::{
CreateEventW, GetCurrentProcess, OpenProcess, QueryFullProcessImageNameW, WaitForSingleObject,
PROCESS_DUP_HANDLE, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION,
};
// The frame-transport contract — `SharedHeader` layout, `MAGIC`/`VERSION`/`RING_LEN`, the // The frame-transport contract — `SharedHeader` layout, `MAGIC`/`VERSION`/`RING_LEN`, the
// `DRV_STATUS_*` codes and the `Global\` name helpers — lives in `pf_driver_proto::frame`; both sides // `DRV_STATUS_*` codes and the channel-delivery struct — lives in `pf_driver_proto`; both sides
// `use frame::*`, so a layout/name/code drift is a compile error (the proto has `const` size asserts). // `use` it, so a layout/code drift is a compile error (the proto has `const` size asserts).
use frame::{ use frame::{
event_name, header_name, texture_name, SharedHeader, DRV_STATUS_NO_DEVICE1, DRV_STATUS_OPENED, SharedHeader, DRV_STATUS_NO_DEVICE1, DRV_STATUS_OPENED, DRV_STATUS_TEX_FAIL, MAGIC, RING_LEN,
DRV_STATUS_TEX_FAIL, MAGIC, RING_LEN, VERSION, VERSION,
}; };
/// `DXGI_SHARED_RESOURCE_READ | _WRITE` for `CreateSharedHandle`/`OpenSharedResourceByName`. Local (not /// `DXGI_SHARED_RESOURCE_READ | _WRITE` for `CreateSharedHandle`/`OpenSharedResourceByName`. Local (not
/// part of the proto contract — it is a DXGI sharing-API arg, mirrored on the driver side). /// part of the proto contract — it is a DXGI sharing-API arg, mirrored on the driver side).
const DXGI_SHARED_RESOURCE_RW: u32 = 0x8000_0000 | 0x1; const DXGI_SHARED_RESOURCE_RW: u32 = 0x8000_0000 | 0x1;
/// Least access the driver needs on the duplicated **header section**: map it read/write (it reads the
/// layout + writes `driver_status`/`driver_render_luid`/the publish token). `SECTION_MAP_READ |
/// SECTION_MAP_WRITE` (== the driver's `FILE_MAP_READ | FILE_MAP_WRITE` map flag). Duplicating with
/// exactly this — instead of `DUPLICATE_SAME_ACCESS`, which would copy the host's full-access creator
/// handle — is the "grant least privilege" discipline for unnamed shared objects (Raymond Chen,
/// *"unnamed objects aren't safe just because they're unnamed"*): a compromised driver's handle can't
/// `WRITE_DAC`/`WRITE_OWNER`/`DELETE` the object, only map it.
const SECTION_MAP_RW: u32 = 0x0004 | 0x0002;
/// Least access the driver needs on the duplicated **frame-ready event**: it only `SetEvent`s it, which
/// requires `EVENT_MODIFY_STATE`. (The host holds `SYNCHRONIZE` on its own handle to wait.)
const EVENT_MODIFY_STATE: u32 = 0x0002;
/// Host-owned output-ring depth: distinct NVENC-input textures rotated per frame so the in-flight /// Host-owned output-ring depth: distinct NVENC-input textures rotated per frame so the in-flight
/// encode of frame N and the convert/copy of frame N+1 never touch the same texture. 3 covers a /// encode of frame N and the convert/copy of frame N+1 never touch the same texture. 3 covers a
/// pipeline depth of 2 with one slot of margin. /// pipeline depth of 2 with one slot of margin.
const OUT_RING: usize = 3; const OUT_RING: usize = 3;
/// Bring-up debug block (fixed name) — the host creates it; the driver writes diagnostics into it /// Monotonic per-process generation stamped into the header + every publish token, so the host rejects
/// independent of the per-target header. NOT part of `pf_driver_proto` (a host-side bring-up channel, /// a stale-ring publish and the driver detects a recreate. (With unnamed textures there is no name
/// not the data path); the matching `DebugBlock` lives in the OLD oracle driver's `frame_transport.rs`. /// collision to avoid — the generation's remaining job is the recreate/stale-publish handshake.)
#[repr(C)]
struct DebugBlock {
magic: u32,
run_core_entries: u32,
resolved_target_id: u32,
header_open_attempts: u32,
last_open_error: u32,
header_opened: u32,
render_luid_low: u32,
render_luid_high: i32,
frames_acquired: u32,
_pad: u32,
}
const DBG_NAME: &str = "Global\\pfvd-dbg";
const DBG_MAGIC: u32 = 0x4742_4450;
/// Monotonic per-process generation: each capturer instance stamps its ring-texture names with a
/// fresh value so a retried/overlapping `open()` never collides with a previous attempt's not-yet-
/// released shared-handle names (`DXGI_ERROR_NAME_ALREADY_EXISTS`). The driver reads it from the header.
static IDD_GENERATION: AtomicU32 = AtomicU32::new(1); static IDD_GENERATION: AtomicU32 = AtomicU32::new(1);
fn now_ns() -> u64 { fn now_ns() -> u64 {
@@ -94,7 +99,7 @@ fn now_ns() -> u64 {
/// RAII wrapper for a file-mapping object + its mapped view: on drop the view is `UnmapViewOfFile`'d, /// RAII wrapper for a file-mapping object + its mapped view: on drop the view is `UnmapViewOfFile`'d,
/// THEN the [`OwnedHandle`] closes the underlying mapping object (order matters — unmap before close). /// THEN the [`OwnedHandle`] closes the underlying mapping object (order matters — unmap before close).
/// A `header`/`dbg_block` raw pointer borrows into the view via [`ptr`](Self::ptr); the section must /// A `header` raw pointer borrows into the view via [`ptr`](Self::ptr); the section must
/// outlive it (it's declared before it in [`IddPushCapturer`], and moving the section doesn't move the /// outlive it (it's declared before it in [`IddPushCapturer`], and moving the section doesn't move the
/// OS mapping, so the borrowed pointer stays valid). /// OS mapping, so the borrowed pointer stays valid).
struct MappedSection { struct MappedSection {
@@ -122,10 +127,9 @@ impl Drop for MappedSection {
struct HostSlot { struct HostSlot {
tex: ID3D11Texture2D, tex: ID3D11Texture2D,
mutex: IDXGIKeyedMutex, mutex: IDXGIKeyedMutex,
/// The named shared-resource handle, held only to keep the resource alive (the driver opens it by /// The UNNAMED shared-resource NT handle: keeps the resource alive for the session AND is the
/// NAME). An [`OwnedHandle`] so it closes on drop (was a manual `CloseHandle` in a `Drop` impl); /// source the [`ChannelBroker`] duplicates into the driver's WUDFHost (the ONLY way the driver can
/// never read directly — its sole purpose is the RAII close. /// reach this texture — there is no name to open). An [`OwnedHandle`] so it closes on drop.
#[allow(dead_code)]
shared: OwnedHandle, shared: OwnedHandle,
/// SRV on the slot texture so the HDR path samples the FP16 slot DIRECTLY (no slot→scratch copy); /// SRV on the slot texture so the HDR path samples the FP16 slot DIRECTLY (no slot→scratch copy);
/// the convert pass writes the output ring while holding the slot's keyed mutex. Unused for SDR /// the convert pass writes the output ring while holding the slot's keyed mutex. Unused for SDR
@@ -168,28 +172,238 @@ impl Drop for KeyedMutexGuard<'_> {
} }
} }
/// Confirm the process is a genuine system WUDFHost — `%SystemRoot%\System32\WUDFHost.exe` — before a
/// broker duplicates sensitive handles into it. The pid is driver-reported (the frame channel's
/// [`control::AddReply::wudf_pid`], or the gamepad bootstrap's `driver_pid`); a spoofed devnode / a
/// tampered mailbox could name an arbitrary process to receive the channel, so this is the
/// confused-deputy gate. Best-effort image-path identity is proportionate: a fully-compromised REAL
/// driver is already a channel endpoint, and any *other* process (attacker exe, a non-driver pid)
/// fails this WUDFHost image check. `what` names the channel in the error (e.g. `"frame-channel"`);
/// shared with the gamepad sealed channel (`inject/windows/gamepad_raii.rs`).
///
/// # Safety
/// `process` must be a live process handle carrying `PROCESS_QUERY_LIMITED_INFORMATION`.
pub(crate) unsafe fn verify_is_wudfhost(process: HANDLE, wudf_pid: u32, what: &str) -> Result<()> {
let mut buf = [0u16; 512];
let mut len = buf.len() as u32;
// SAFETY: `process` carries QUERY_LIMITED per the contract; `buf`/`len` are a valid out-buffer and
// its capacity, and on success `len` is updated to the count of UTF-16 units written (no NUL).
unsafe {
QueryFullProcessImageNameW(
process,
PROCESS_NAME_WIN32,
PWSTR(buf.as_mut_ptr()),
&mut len,
)
.with_context(|| format!("QueryFullProcessImageNameW on the {what} pid"))?;
}
let path = String::from_utf16_lossy(&buf[..len as usize]);
let got = path.to_ascii_lowercase().replace('/', "\\");
let sysroot = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".to_string());
let expected = format!("{}\\system32\\wudfhost.exe", sysroot.to_ascii_lowercase());
if got != expected {
bail!(
"{what} pid {wudf_pid} is not the system WUDFHost (image={path:?}, expected \
{expected:?}) — refusing to duplicate the channel's handles into it (spoofed driver / \
wrong devnode?)"
);
}
Ok(())
}
/// The sealed channel's handle-duplication broker (`design/idd-push-security.md`): the frame objects
/// are unnamed, so the ONLY way the driver can reach them is handles this broker duplicates into its
/// WUDFHost process and delivers — as bare handle VALUES — over the SYSTEM-only control device
/// (`IOCTL_SET_FRAME_CHANNEL`). Ownership is a strict hand-off: on IOCTL success the DRIVER owns the
/// duplicates (it closes them); on any failure [`Self::send`] reaps every duplicate it already made
/// (`DUPLICATE_CLOSE_SOURCE`), so a half-delivered channel never leaks handles in WUDFHost.
struct ChannelBroker {
/// `PROCESS_DUP_HANDLE` handle to the driver's WUDFHost (pid from the ADD reply;
/// `ProcessSharingDisabled` makes that process exclusively pf-vdisplay's).
process: OwnedHandle,
/// The pf-vdisplay control device — owned by the `VirtualDisplayManager`, never closed for the
/// process lifetime, so holding the bare `HANDLE` is sound.
control: HANDLE,
}
impl ChannelBroker {
/// Open the duplication target. Fails when the driver predates the sealed channel (`wudf_pid == 0`
/// can't survive the v2 version handshake, but guard anyway) or the WUDFHost is gone (device
/// restart mid-open) — either way the caller fails the capture open cleanly.
///
/// `wudf_pid` comes from the driver's ADD reply, so before we duplicate whole-desktop frame handles
/// INTO it we VERIFY it is a genuine system WUDFHost ([`verify_is_wudfhost`]). Without that check a
/// spoofed devnode (same interface GUID) could name an arbitrary process and receive the frames; a
/// fully-compromised REAL pf_vdisplay driver is already a frame endpoint, so this specifically closes
/// the reachable-without-owning-the-driver case (`design/idd-push-security.md` §hardening).
fn open(wudf_pid: u32) -> Result<Self> {
if wudf_pid == 0 {
bail!("driver reported no WUDFHost pid for the frame channel");
}
let control = crate::vdisplay::manager::control_device_handle().context(
"pf-vdisplay control device not open (monitor not created via the manager?)",
)?;
// SAFETY: plain FFI; `wudf_pid` is a copy. The handle (checked by `?`) is owned solely here and
// moved into the `OwnedHandle` (single owner, closes on drop); `verify_is_wudfhost` borrows it
// for the duration of the synchronous check and forms no lasting alias.
let process = unsafe {
let h = OpenProcess(
PROCESS_DUP_HANDLE | PROCESS_QUERY_LIMITED_INFORMATION,
false,
wudf_pid,
)
.context("OpenProcess(PROCESS_DUP_HANDLE) on the driver's WUDFHost")?;
let process = OwnedHandle::from_raw_handle(h.0 as _);
verify_is_wudfhost(HANDLE(process.as_raw_handle()), wudf_pid, "frame-channel")?;
process
};
Ok(Self { process, control })
}
/// Duplicate `h` into the WUDFHost handle table, returning the handle VALUE valid there (and only
/// there — the value is meaningless in any other process). `access = Some(rights)` grants the
/// driver's handle exactly those rights (least privilege — see [`SECTION_MAP_RW`]);
/// `access = None` copies the source handle's access (`DUPLICATE_SAME_ACCESS`), used only where the
/// source is already scoped (the DXGI shared-texture handles, minted by `CreateSharedHandle` with
/// just `DXGI_SHARED_RESOURCE_READ|WRITE`).
///
/// # Safety
/// `h` must be a live handle of the current process.
unsafe fn dup_into(&self, h: HANDLE, access: Option<u32>) -> Result<u64> {
let mut out = HANDLE::default();
let (desired, options) = match access {
Some(rights) => (rights, DUPLICATE_HANDLE_OPTIONS(0)),
None => (0, DUPLICATE_SAME_ACCESS),
};
// SAFETY: `h` is live per the contract; `self.process` is the live PROCESS_DUP_HANDLE target;
// `&mut out` is a valid out-param. Either an explicit least-privilege access mask (options == 0)
// or `DUPLICATE_SAME_ACCESS` (desired ignored) — never both.
unsafe {
DuplicateHandle(
GetCurrentProcess(),
h,
HANDLE(self.process.as_raw_handle()),
&mut out,
desired,
false,
options,
)
}
.context("DuplicateHandle into the driver's WUDFHost")?;
Ok(out.0 as usize as u64)
}
/// Close a handle VALUE inside the WUDFHost table (the failure-path reaper): `DUPLICATE_CLOSE_SOURCE`
/// with no target closes the source handle regardless of the (ignored) result.
fn close_remote(&self, value: u64) {
if value == 0 {
return;
}
// SAFETY: `self.process` is the live duplication target and `value` is a handle value THIS
// broker just created in that process's table (callers only pass back `dup_into` results the
// driver never received); closing it there cannot touch any other process's handles.
unsafe {
let _ = DuplicateHandle(
HANDLE(self.process.as_raw_handle()),
HANDLE(value as usize as *mut core::ffi::c_void),
HANDLE::default(),
std::ptr::null_mut(),
0,
false,
DUPLICATE_CLOSE_SOURCE,
);
}
}
/// Duplicate the whole ring (header + event + every slot texture) into WUDFHost and deliver the
/// values via `IOCTL_SET_FRAME_CHANNEL`. All-or-nothing: on any failure every duplicate already
/// made is reaped remotely and an error returns (the caller fails the open / logs the recreate).
/// The ownership contract with the driver is adopt-on-success only — it closes the handles iff the
/// IOCTL succeeded, we reap them iff it didn't, so no value is ever closed twice.
///
/// # Safety
/// `header` and `event` must be live handles of the current process (the capturer's own section +
/// event, borrowed for this synchronous call).
unsafe fn send(
&self,
target_id: u32,
generation: u32,
header: HANDLE,
event: HANDLE,
slots: &[HostSlot],
) -> Result<()> {
debug_assert!(slots.len() <= control::RING_LEN_USIZE);
let mut req = control::SetFrameChannelRequest {
target_id,
generation,
ring_len: slots.len() as u32,
_pad: 0,
header_handle: 0,
event_handle: 0,
texture_handles: [0; control::RING_LEN_USIZE],
};
// SAFETY: `header`/`event` are live per this fn's contract; each slot's `shared` is the live
// `OwnedHandle` the slot keeps for exactly this purpose.
let result = unsafe { self.duplicate_and_deliver(&mut req, header, event, slots) };
if result.is_err() {
// The driver never adopted the delivery — reap every remote duplicate so nothing lingers.
self.close_remote(req.header_handle);
self.close_remote(req.event_handle);
for v in req.texture_handles {
self.close_remote(v);
}
}
result
}
/// The fallible middle of [`Self::send`]: fill `req` with fresh duplicates, then issue the IOCTL.
/// Split out so `send` can reap whatever landed in `req` when any step errors.
///
/// # Safety
/// As [`Self::send`].
unsafe fn duplicate_and_deliver(
&self,
req: &mut control::SetFrameChannelRequest,
header: HANDLE,
event: HANDLE,
slots: &[HostSlot],
) -> Result<()> {
// SAFETY: forwarded from the caller's contract — `header`/`event`/each `slot.shared` are live
// handles of this process, and `self.control` is the manager's control handle, never closed for
// the process lifetime (`send_frame_channel`'s precondition).
unsafe {
// Least privilege per handle: the header maps read/write, the event is only signalled, and
// the textures keep their already-scoped `CreateSharedHandle` access (see `dup_into`).
req.header_handle = self.dup_into(header, Some(SECTION_MAP_RW))?;
req.event_handle = self.dup_into(event, Some(EVENT_MODIFY_STATE))?;
for (k, s) in slots.iter().enumerate() {
req.texture_handles[k] = self.dup_into(HANDLE(s.shared.as_raw_handle()), None)?;
}
crate::vdisplay::pf_vdisplay::send_frame_channel(self.control, req)
}
}
}
/// Creates + owns the shared ring; yields the driver's frames as [`FramePayload::D3d11`]. /// Creates + owns the shared ring; yields the driver's frames as [`FramePayload::D3d11`].
pub struct IddPushCapturer { pub struct IddPushCapturer {
device: ID3D11Device, device: ID3D11Device,
context: ID3D11DeviceContext, context: ID3D11DeviceContext,
target_id: u32, target_id: u32,
/// Owns the shared-header file mapping + its mapped view (RAII unmap-then-close). Declared BEFORE /// Owns the shared-header file mapping + its mapped view (RAII unmap-then-close). Declared BEFORE
/// `header`, which is a raw pointer borrowed into this view via [`MappedSection::ptr`]. Never read /// `header`, which is a raw pointer borrowed into this view via [`MappedSection::ptr`]. Also the
/// directly (the `header` pointer is) — held purely so the mapping outlives the capturer. /// duplication source for the driver's header handle on every [`ChannelBroker::send`].
#[allow(dead_code)]
section: MappedSection, section: MappedSection,
header: *mut SharedHeader, header: *mut SharedHeader,
event: OwnedHandle, event: OwnedHandle,
/// Owns the bring-up debug section (mapping + view), or `None` when the debug block wasn't created. /// The sealed channel's handle-duplication broker (WUDFHost process + control device); used at open
/// Never read directly (the `dbg_block` pointer is) — held purely for the RAII unmap/close. /// and again on every ring recreate to deliver fresh duplicates.
#[allow(dead_code)] broker: ChannelBroker,
dbg_section: Option<MappedSection>,
dbg_block: *mut DebugBlock,
width: u32, width: u32,
height: u32, height: u32,
slots: Vec<HostSlot>, slots: Vec<HostSlot>,
/// The ring/texture generation, bumped every time the ring is recreated at a new format (the /// The ring/texture generation, bumped every time the ring is recreated at a new format (the
/// display's HDR mode flipped). Stamped into the texture names + the header so the driver re-attaches. /// display's HDR mode flipped). Stamped into the header + each delivery so the driver re-attaches
/// (and so stale-ring publishes are rejected).
generation: u32, generation: u32,
/// The CLIENT's advertised 10-bit capability (= negotiated `bit_depth >= 10`). Only used at `open` /// The CLIENT's advertised 10-bit capability (= negotiated `bit_depth >= 10`). Only used at `open`
/// to PROACTIVELY enable advanced color (so a 10-bit client gets HDR without a manual toggle); it /// to PROACTIVELY enable advanced color (so a 10-bit client gets HDR without a manual toggle); it
@@ -228,25 +442,31 @@ pub struct IddPushCapturer {
status_logged: bool, status_logged: bool,
_keepalive: Box<dyn Send>, _keepalive: Box<dyn Send>,
} }
// SAFETY: `IddPushCapturer` is `!Send` only because of its `*mut SharedHeader`/`*mut DebugBlock` raw // SAFETY: `IddPushCapturer` is `!Send` only because of its `*mut SharedHeader` raw pointer (and the
// pointers (and the COM interfaces). It is created, used, and dropped by a SINGLE thread — the owning // COM interfaces / the broker's bare control `HANDLE`, which is process-global and never closed). It is
// capture/encode thread — never shared: the `ID3D11DeviceContext` is the device's IMMEDIATE context // created, used, and dropped by a SINGLE thread — the owning capture/encode thread — never shared: the
// (single-threaded by D3D11 contract) and is only ever touched from that thread, and the header/ // `ID3D11DeviceContext` is the device's IMMEDIATE context (single-threaded by D3D11 contract) and is
// dbg_block pointers (into mappings this struct owns) are only dereferenced there. `Send` transfers // only ever touched from that thread, and the header pointer (into the mapping this struct owns) is
// ownership to one thread at a time with NO concurrent access; we do not (and must not) claim `Sync`. // only dereferenced there. `Send` transfers ownership to one thread at a time with NO concurrent
// access; we do not (and must not) claim `Sync`.
unsafe impl Send for IddPushCapturer {} unsafe impl Send for IddPushCapturer {}
/// Build a `SECURITY_ATTRIBUTES` granting GENERIC_ALL to **SYSTEM** (the host creates+publishes the /// Build a `SECURITY_ATTRIBUTES` granting GENERIC_ALL to **SYSTEM only** — `D:P(A;;GA;;;SY)`, protected
/// shared event + texture ring) and **LocalService** (the account the pf_vdisplay WUDFHost runs under, /// (no inherited ACEs), `bInheritHandle: false`. The sealed channel makes this the strictly-minimal
/// which consumes them) — `D:(A;;GA;;;SY)(A;;GA;;;LS)`, the same scoping as the gamepad section. The /// DACL: the objects are UNNAMED and the driver reaches them via **duplicated handles** (which carry the
/// old SDDL granted **Everyone** (`WD`), which let any local user open the `Global\pfvd-*` objects and /// source handle's access — `OpenSharedResourceByName`/`OpenSharedResource1` on a handle does not
/// read captured screen frames (security-review 2026-06-28 #5). Verified on the RTX box (2026-06-29): /// re-check the object DACL against the opener), so the pf_vdisplay WUDFHost (LocalService) no longer
/// the WUDFHost token is `S-1-5-19` (LocalService), SYSTEM integrity, zero restricted SIDs — so SY+LS /// needs a DACL ACE. Dropping the `LS` ACE removes the last theoretical surface where a leaked handle or
/// suffices for the driver and excludes normal user processes. `psd` must outlive `sa`. /// a name-grown-by-accident could be opened by the (many-service-shared) LocalService SID. Empirically
/// confirmed unreachable regardless: a LocalService token is DACL-denied `OpenProcess` on the WUDFHost
/// (`PROCESS_DUP_HANDLE`/`VM_READ`/even `QUERY_LIMITED` → ACCESS_DENIED, tested on the RTX box
/// 2026-07-03), so it cannot dup the handles out either. History: `Global\`-named + world-openable
/// (`WD`, security-review 2026-06-28 #5) → SY+LS-scoped → nameless → now SY-only. `psd` must outlive
/// `sa`. See `design/idd-push-security.md`.
unsafe fn shared_object_sa() -> Result<(SECURITY_ATTRIBUTES, PSECURITY_DESCRIPTOR)> { unsafe fn shared_object_sa() -> Result<(SECURITY_ATTRIBUTES, PSECURITY_DESCRIPTOR)> {
let mut psd = PSECURITY_DESCRIPTOR::default(); let mut psd = PSECURITY_DESCRIPTOR::default();
ConvertStringSecurityDescriptorToSecurityDescriptorW( ConvertStringSecurityDescriptorToSecurityDescriptorW(
w!("D:(A;;GA;;;SY)(A;;GA;;;LS)"), w!("D:P(A;;GA;;;SY)"),
SDDL_REVISION_1, SDDL_REVISION_1,
&mut psd, &mut psd,
None, None,
@@ -262,20 +482,18 @@ unsafe fn shared_object_sa() -> Result<(SECURITY_ATTRIBUTES, PSECURITY_DESCRIPTO
impl IddPushCapturer { impl IddPushCapturer {
/// Create the `RING_LEN` shared keyed-mutex textures for one ring generation, at `format` (matched /// Create the `RING_LEN` shared keyed-mutex textures for one ring generation, at `format` (matched
/// to the display's composition format — FP16 in HDR, BGRA in SDR). Each is shared by the name /// to the display's composition format — FP16 in HDR, BGRA in SDR). Each is shared through an
/// `pfvd-tex-<target>-<generation>-<k>` so the driver opens it; a fresh generation gives fresh names /// UNNAMED NT handle (nothing to open by name — the sealed channel); the driver reaches it only via
/// (so a recreate never collides with the old ring's not-yet-released handles). /// the duplicate the [`ChannelBroker`] sends after the ring is published.
unsafe fn create_ring_slots( unsafe fn create_ring_slots(
device: &ID3D11Device, device: &ID3D11Device,
target_id: u32,
generation: u32,
w: u32, w: u32,
h: u32, h: u32,
format: DXGI_FORMAT, format: DXGI_FORMAT,
) -> Result<Vec<HostSlot>> { ) -> Result<Vec<HostSlot>> {
let (sa, _psd) = shared_object_sa()?; let (sa, _psd) = shared_object_sa()?;
let mut slots = Vec::new(); let mut slots = Vec::new();
for k in 0..RING_LEN { for _ in 0..RING_LEN {
let desc = D3D11_TEXTURE2D_DESC { let desc = D3D11_TEXTURE2D_DESC {
Width: w, Width: w,
Height: h, Height: h,
@@ -304,7 +522,7 @@ impl IddPushCapturer {
.CreateSharedHandle( .CreateSharedHandle(
Some(&sa as *const SECURITY_ATTRIBUTES), Some(&sa as *const SECURITY_ATTRIBUTES),
DXGI_SHARED_RESOURCE_RW, DXGI_SHARED_RESOURCE_RW,
&HSTRING::from(texture_name(target_id, generation, k)), PCWSTR::null(), // UNNAMED — reachable only through the broker's duplicate
) )
.context("CreateSharedHandle(IDD-push ring slot)")?; .context("CreateSharedHandle(IDD-push ring slot)")?;
// Own the shared handle so the slot's `Drop` closes it via RAII (was a manual `CloseHandle`). // Own the shared handle so the slot's `Drop` closes it via RAII (was a manual `CloseHandle`).
@@ -381,22 +599,22 @@ impl IddPushCapturer {
// `u32` target id; they read/flip CCD display config and return owned values, borrowing nothing. // `u32` target id; they read/flip CCD display config and return owned values, borrowing nothing.
// - `CreateDXGIFactory1`, `EnumAdapterByLuid`, `make_device`, `shared_object_sa`, `CreateFileMappingW`, // - `CreateDXGIFactory1`, `EnumAdapterByLuid`, `make_device`, `shared_object_sa`, `CreateFileMappingW`,
// `MapViewOfFile`, `CreateEventW`, and `create_ring_slots` are all `?`-checked, so every returned // `MapViewOfFile`, `CreateEventW`, and `create_ring_slots` are all `?`-checked, so every returned
// interface/handle/view is non-error before use; `&sa`/`&adapter`/`&device`/the `&HSTRING` names // interface/handle/view is non-error before use; `&sa`/`&adapter`/`&device` are live borrows that
// are live borrows that outlive each synchronous call, and `sa.lpSecurityDescriptor` stays valid // outlive each synchronous call, and `sa.lpSecurityDescriptor` stays valid because its backing
// because its backing `_psd` is held in scope for the whole block. // `_psd` is held in scope for the whole block.
// - The header mapping is created AND viewed at `bytes == size_of::<SharedHeader>().max(64)`; the // - The header mapping is created AND viewed at `bytes == size_of::<SharedHeader>().max(64)`; the
// view's null is checked (`bail!` on failure, after which the owned `map` closes the mapping). The // view's null is checked (`bail!` on failure, after which the owned `map` closes the mapping). The
// OS view base is page-aligned, so `section.ptr::<SharedHeader>()` is suitably aligned for a // OS view base is page-aligned, so `section.ptr::<SharedHeader>()` is suitably aligned for a
// `SharedHeader`, and `write_bytes(.., 0, bytes)` plus the `(*header).field = ..` writes all stay // `SharedHeader`, and `write_bytes(.., 0, bytes)` plus the `(*header).field = ..` writes all stay
// within those `bytes` and write THROUGH the raw pointer without forming any `&mut`. The debug // within those `bytes` and write THROUGH the raw pointer without forming any `&mut`.
// section is the same pattern at `dbg_bytes == size_of::<DebugBlock>()`, only entered when its
// own view is non-null.
// - The `magic` publish stores through `addr_of!((*header).magic) as *const AtomicU32`: `addr_of!` // - The `magic` publish stores through `addr_of!((*header).magic) as *const AtomicU32`: `addr_of!`
// takes the field address without a reference; the field is a 4-aligned `u32` (valid for // takes the field address without a reference; the field is a 4-aligned `u32` (valid for
// `AtomicU32`), and the `Release` store after the `Release` fence is the cross-process handshake // `AtomicU32`), and the `Release` store after the `Release` fence is the cross-process handshake
// that orders all preceding writes before the driver may observe `MAGIC`. // that orders all preceding writes before the driver may observe `MAGIC`.
// - `header`/`dbg_block` point into the OS mappings, NOT into the `MappedSection` structs, so moving // - `broker.send` requires live `header`/`event` handles of this process: both borrow the just-
// `section`/`dbg_section` into `me` leaves them valid (see the `MappedSection` doc comment). // created owned section/event for the duration of that synchronous call.
// - `header` points into the OS mapping, NOT into the `MappedSection` struct, so moving `section`
// into `me` leaves it valid (see the `MappedSection` doc comment).
unsafe { unsafe {
// If we ENABLE advanced color for a 10-bit client, trust it (the driver will compose FP16) and // If we ENABLE advanced color for a 10-bit client, trust it (the driver will compose FP16) and
// size the ring FP16 directly — don't race the advanced_color_enabled poll, which may not have // size the ring FP16 directly — don't race the advanced_color_enabled poll, which may not have
@@ -428,14 +646,14 @@ impl IddPushCapturer {
let (sa, _psd) = shared_object_sa()?; let (sa, _psd) = shared_object_sa()?;
let bytes = std::mem::size_of::<SharedHeader>().max(64); let bytes = std::mem::size_of::<SharedHeader>().max(64);
// Header. // Header — UNNAMED (the sealed channel: the driver gets a duplicated handle, not a name).
let map = CreateFileMappingW( let map = CreateFileMappingW(
INVALID_HANDLE_VALUE, INVALID_HANDLE_VALUE,
Some(&sa), Some(&sa),
PAGE_READWRITE, PAGE_READWRITE,
0, 0,
bytes as u32, bytes as u32,
&HSTRING::from(header_name(target.target_id)), PCWSTR::null(),
) )
.context("CreateFileMapping(IDD-push header)")?; .context("CreateFileMapping(IDD-push header)")?;
// Own the mapping handle so it (and its view) free via `MappedSection` RAII even on bail. // Own the mapping handle so it (and its view) free via `MappedSection` RAII even on bail.
@@ -463,69 +681,45 @@ impl IddPushCapturer {
// reads this into its `ring_format` and drops any surface that doesn't match. // reads this into its `ring_format` and drops any surface that doesn't match.
(*header).dxgi_format = ring_fmt.0 as u32; (*header).dxgi_format = ring_fmt.0 as u32;
// Frame-ready event (auto-reset). // Frame-ready event (auto-reset) — UNNAMED, like everything on this channel.
let event = CreateEventW( let event = CreateEventW(Some(&sa), false, false, PCWSTR::null())
Some(&sa), .context("CreateEvent(IDD-push)")?;
false,
false,
&HSTRING::from(event_name(target.target_id)),
)
.context("CreateEvent(IDD-push)")?;
let event = OwnedHandle::from_raw_handle(event.0 as _); let event = OwnedHandle::from_raw_handle(event.0 as _);
// Ring of shared keyed-mutex textures, format matched to the display's current mode. // Ring of shared keyed-mutex textures, format matched to the display's current mode.
let slots = let slots = Self::create_ring_slots(&device, w, h, ring_fmt)?;
Self::create_ring_slots(&device, target.target_id, generation, w, h, ring_fmt)?;
// Bring-up debug block (fixed name) — the driver writes diagnostics here. Best-effort. // Publish: magic LAST (Release) — the ring must be fully initialized before the driver
let dbg_bytes = std::mem::size_of::<DebugBlock>(); // (which receives the channel strictly afterwards) can observe MAGIC.
let (dbg_section, dbg_block) = match CreateFileMappingW(
INVALID_HANDLE_VALUE,
Some(&sa),
PAGE_READWRITE,
0,
dbg_bytes as u32,
&HSTRING::from(DBG_NAME),
) {
Ok(dm) => {
// Own the mapping handle so it (and its view) free via `MappedSection` RAII.
let dm = OwnedHandle::from_raw_handle(dm.0 as _);
let dv = MapViewOfFile(
HANDLE(dm.as_raw_handle()),
FILE_MAP_ALL_ACCESS,
0,
0,
dbg_bytes,
);
if dv.Value.is_null() {
(None, std::ptr::null_mut()) // `dm` drops → mapping closed
} else {
let section = MappedSection {
handle: dm,
view: dv,
};
let p = section.ptr::<DebugBlock>();
std::ptr::write_bytes(p.cast::<u8>(), 0, dbg_bytes);
(*p).magic = DBG_MAGIC;
(Some(section), p)
}
}
Err(_) => (None, std::ptr::null_mut()),
};
// Publish: magic LAST (Release) — signals the driver the ring is ready to open.
std::sync::atomic::fence(Ordering::Release); std::sync::atomic::fence(Ordering::Release);
(*(std::ptr::addr_of!((*header).magic) as *const AtomicU32)) (*(std::ptr::addr_of!((*header).magic) as *const AtomicU32))
.store(MAGIC, Ordering::Release); .store(MAGIC, Ordering::Release);
// Deliver the sealed channel: duplicate header + event + every slot texture into the
// driver's WUDFHost and hand it the values over the control device. All-or-nothing (the
// broker reaps its remote duplicates on failure), and a failure fails the open — without
// the delivery the driver can never attach.
let broker = ChannelBroker::open(target.wudf_pid)?;
broker
.send(
target.target_id,
generation,
HANDLE(section.handle.as_raw_handle()),
HANDLE(event.as_raw_handle()),
&slots,
)
.context("deliver IDD-push frame channel to the driver")?;
tracing::info!( tracing::info!(
target_id = target.target_id, target_id = target.target_id,
wudf_pid = target.wudf_pid,
render_luid = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart), render_luid = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart),
mode = format!("{w}x{h}"), mode = format!("{w}x{h}"),
display_hdr, display_hdr,
client_10bit, client_10bit,
ring_fp16 = display_hdr, ring_fp16 = display_hdr,
"IDD push(host): created shared ring; waiting for the driver to attach + publish" "IDD push(host): created sealed ring + delivered the channel; waiting for the driver \
to attach + publish"
); );
let me = Self { let me = Self {
device, device,
@@ -534,8 +728,7 @@ impl IddPushCapturer {
section, section,
header, header,
event, event,
dbg_section, broker,
dbg_block,
width: w, width: w,
height: h, height: h,
slots, slots,
@@ -659,34 +852,6 @@ impl IddPushCapturer {
} }
} }
/// Log the driver's bring-up diagnostics (the fixed-name debug block) — independent of the
/// per-target header, so it tells us whether the swap-chain processor ran, what target_id it
/// resolved, whether the header opened (+ error), and whether frames flowed.
fn log_debug_block(&self) {
if self.dbg_block.is_null() {
tracing::warn!("IDD push DEBUG: no debug block");
return;
}
// SAFETY: `self.dbg_block` was just checked non-null (the early return above); it points into the
// owned `dbg_section` mapping sized exactly `size_of::<DebugBlock>()` and page-aligned, so it is
// valid + aligned for `DebugBlock`. `d` is a short-lived SHARED reference used only to read the
// fields below; we never form `&mut` into this region, and the driver's cross-process writes are
// aligned `u32`s that don't tear (best-effort bring-up diagnostics).
let d = unsafe { &*self.dbg_block };
tracing::error!(
run_core_entries = d.run_core_entries,
resolved_target_id = d.resolved_target_id,
header_open_attempts = d.header_open_attempts,
last_open_error = format!("0x{:08x}", d.last_open_error),
header_opened = d.header_opened,
driver_render_luid = format!("{:08x}:{:08x}", d.render_luid_high, d.render_luid_low),
frames_acquired = d.frames_acquired,
"IDD push DEBUG: driver-reported diagnostics (run_core_entries=0 ⇒ swap-chain processor \
never ran; resolved_target_id≠ours ⇒ name mismatch; last_open_error 0x80070002 ⇒ header \
not found; frames_acquired=0 ⇒ idle display)"
);
}
/// The output texture format + the [`PixelFormat`] NVENC encodes, driven SOLELY by the DISPLAY's HDR /// The output texture format + the [`PixelFormat`] NVENC encodes, driven SOLELY by the DISPLAY's HDR
/// state (like the WGC path): HDR → `P010` (BT.2020 PQ 10-bit limited) → NVENC Main10, and the client /// state (like the WGC path): HDR → `P010` (BT.2020 PQ 10-bit limited) → NVENC Main10, and the client
/// auto-detects PQ from the HEVC VUI; SDR → `Nv12` (BT.709 8-bit limited). Both are native YUV so /// auto-detects PQ from the HEVC VUI; SDR → `Nv12` (BT.709 8-bit limited). Both are native YUV so
@@ -712,9 +877,10 @@ impl IddPushCapturer {
} }
/// Recreate the ring at the format for `new_display_hdr` (the user flipped "Use HDR"). Bumps the /// Recreate the ring at the format for `new_display_hdr` (the user flipped "Use HDR"). Bumps the
/// generation so the driver re-attaches ([`is_stale`]) to the new-format textures; clears the /// generation so the driver re-attaches ([`is_stale`]) to the new-format textures and DELIVERS the
/// header's `latest` so we don't consume a stale slot from the old ring; drops the conversion /// new channel (fresh duplicates of the header + event + the new textures — every delivery is a
/// textures so they rebuild at the new format. /// self-contained handle set the driver owns); clears the header's `latest` so we don't consume a
/// stale slot from the old ring; drops the conversion textures so they rebuild at the new format.
fn recreate_ring(&mut self, new_display_hdr: bool, new_w: u32, new_h: u32) -> Result<()> { fn recreate_ring(&mut self, new_display_hdr: bool, new_w: u32, new_h: u32) -> Result<()> {
self.display_hdr = new_display_hdr; self.display_hdr = new_display_hdr;
self.width = new_w; self.width = new_w;
@@ -725,16 +891,8 @@ impl IddPushCapturer {
// borrow of `self.device` (the capturer's own device, on which the slots are created) plus plain // borrow of `self.device` (the capturer's own device, on which the slots are created) plus plain
// `u32`/`DXGI_FORMAT` values, and `?` propagates any failure before the slots are used. Every // `u32`/`DXGI_FORMAT` values, and `?` propagates any failure before the slots are used. Every
// returned slot's texture + keyed mutex belongs to that same `self.device`. // returned slot's texture + keyed mutex belongs to that same `self.device`.
let new_slots = unsafe { let new_slots =
Self::create_ring_slots( unsafe { Self::create_ring_slots(&self.device, self.width, self.height, fmt)? };
&self.device,
self.target_id,
new_gen,
self.width,
self.height,
fmt,
)?
};
// SAFETY: `self.header` is the live, owned shared-header mapping (page-aligned, sized for a // SAFETY: `self.header` is the live, owned shared-header mapping (page-aligned, sized for a
// `SharedHeader`). The `latest`/`generation` stores go through `addr_of!`-formed field pointers (no // `SharedHeader`). The `latest`/`generation` stores go through `addr_of!`-formed field pointers (no
// references) of correctly-aligned `u64`/`u32` fields, valid for `AtomicU64`/`AtomicU32`; the // references) of correctly-aligned `u64`/`u32` fields, valid for `AtomicU64`/`AtomicU32`; the
@@ -759,6 +917,26 @@ impl IddPushCapturer {
} }
self.slots = new_slots; // drops the old slots → closes their shared handles + SRVs self.slots = new_slots; // drops the old slots → closes their shared handles + SRVs
self.generation = new_gen; self.generation = new_gen;
// Deliver the new generation's channel. The driver's old publisher sees the generation bump
// (`is_stale`), drops (closing its old handles), and re-attaches from this delivery. On failure
// the broker already reaped its remote duplicates; the recover-or-drop window in `try_consume`
// then ends the session cleanly (the driver can never attach to an undelivered ring).
// SAFETY: `broker.send` requires live `header`/`event` handles of this process — both borrow the
// owned `self.section.handle`/`self.event` for the duration of the synchronous call.
if let Err(e) = unsafe {
self.broker.send(
self.target_id,
new_gen,
HANDLE(self.section.handle.as_raw_handle()),
HANDLE(self.event.as_raw_handle()),
&self.slots,
)
} {
tracing::warn!(
error = %format!("{e:#}"),
"IDD push: frame-channel re-delivery failed after ring recreate"
);
}
self.last_seq = 0; self.last_seq = 0;
self.out_ring.clear(); // the output format changed → rebuild lazily at the new format self.out_ring.clear(); // the output format changed → rebuild lazily at the new format
self.video_conv = None; // converters are sized + HDR-specific → rebuild at the new mode self.video_conv = None; // converters are sized + HDR-specific → rebuild at the new mode
@@ -982,44 +1160,6 @@ impl IddPushCapturer {
} }
} }
/// Diagnostic observer (O3.1): create the IDD-push ring + debug block as the SYSTEM host (LocalSystem
/// — proper privileges, the gamepad pattern) ALONGSIDE the normal WGC path, which provides the
/// presentation trigger. Logs whether the driver's `run_core` ran and pushed frames into a
/// host-created ring — resolving the `run_core=0` ambiguity (a user-created ring may be unwritable by
/// the driver). Gated by `PUNKTFUNK_IDD_PUSH_OBSERVE`; spawns a short-lived sampling thread.
pub fn spawn_observer(target: WinCaptureTarget, preferred: Option<(u32, u32, u32)>) {
std::thread::spawn(move || {
let tid = target.target_id;
tracing::info!(
target_id = tid,
"IDD push OBSERVER: creating host ring (LocalSystem) + debug block alongside WGC"
);
match IddPushCapturer::open(target, preferred, false, Box::new(())) {
Ok(mut cap) => {
let mut frames = 0u32;
for _ in 0..40 {
match cap.try_consume() {
Ok(Some(_)) => frames += 1,
Ok(None) => {}
Err(e) => tracing::warn!("IDD push OBSERVER: consume error: {e:#}"),
}
std::thread::sleep(Duration::from_millis(750));
}
tracing::info!(
target_id = tid,
frames_from_ring = frames,
"IDD push OBSERVER: sampling done"
);
cap.log_debug_block();
}
Err((e, _keep)) => tracing::warn!(
target_id = tid,
"IDD push OBSERVER: ring open failed: {e:#}"
),
}
});
}
/// The selected render GPU LUID (where the encoder runs), falling back to the monitor's `OsAdapterLuid`. /// The selected render GPU LUID (where the encoder runs), falling back to the monitor's `OsAdapterLuid`.
fn resolve_render_adapter_luid_or(fallback_packed: i64) -> LUID { fn resolve_render_adapter_luid_or(fallback_packed: i64) -> LUID {
if let Some(l) = crate::win_adapter::resolve_render_adapter_luid() { if let Some(l) = crate::win_adapter::resolve_render_adapter_luid() {
@@ -1046,7 +1186,6 @@ impl Capturer for IddPushCapturer {
return Ok(f); return Ok(f);
} }
if Instant::now() > deadline { if Instant::now() > deadline {
self.log_debug_block();
// SAFETY: four in-bounds, aligned reads of the live, owned shared-header mapping — the same // SAFETY: four in-bounds, aligned reads of the live, owned shared-header mapping — the same
// best-effort diagnostic fields as `log_driver_status_once` (aligned word reads can't tear; // best-effort diagnostic fields as `log_driver_status_once` (aligned word reads can't tear;
// no reference into the shared region is formed). // no reference into the shared region is formed).
@@ -1093,8 +1232,10 @@ impl Capturer for IddPushCapturer {
impl Drop for IddPushCapturer { impl Drop for IddPushCapturer {
fn drop(&mut self) { fn drop(&mut self) {
self.slots.clear(); self.slots.clear();
// The shared header + debug sections (`MappedSection`) and the frame-ready `event` // The shared header section (`MappedSection`), the frame-ready `event` (`OwnedHandle`) and the
// (`OwnedHandle`) free themselves via RAII (each unmaps its view, then closes its handle). // broker's WUDFHost process handle free themselves via RAII (unmap view, then close handle)
// _keepalive drops after, REMOVEing the virtual display. // nothing of this session's channel outlives the capturer on the host side; the driver's
// duplicates die with its publisher / monitor / WUDFHost (teardown invariant,
// `design/idd-push-security.md`). _keepalive drops after, REMOVEing the virtual display.
} }
} }
+1 -1
View File
@@ -530,7 +530,7 @@ fn open_video_backend(
{ {
anyhow::bail!( anyhow::bail!(
"NVENC requested/detected but this host was built without it — rebuild \ "NVENC requested/detected but this host was built without it — rebuild \
with `--features nvenc` (needs the NVENC SDK's nvencodeapi.lib at link time)" with `--features nvenc`"
) )
} }
} }
+242 -54
View File
@@ -1,7 +1,10 @@
//! NVENC hardware encoder (Windows, D3D11 input) — zero-copy capture→encode on the GPU. //! NVENC hardware encoder (Windows, D3D11 input) — zero-copy capture→encode on the GPU.
//! //!
//! Drives the raw NVENC API via `nvidia_video_codec_sdk::{sys, ENCODE_API}` (the safe `Encoder` //! Drives the raw NVENC API via the `nvidia_video_codec_sdk` `sys` types and a **runtime-loaded**
//! wrapper is CUDA-only). Opens an encode session bound to the **same** `ID3D11Device` as the DXGI //! entry table ([`EncodeApi`] — the crate's `ENCODE_API`/safe `Encoder` are deliberately unused:
//! the safe wrapper is CUDA-only, and its statically-declared entry points would put a load-time
//! `nvEncodeAPI64.dll` import on the all-vendor binary, killing it on every AMD/Intel-only box).
//! Opens an encode session bound to the **same** `ID3D11Device` as the DXGI
//! capturer (the device is carried on `FramePayload::D3d11`), and **encodes the capturer's texture in //! capturer (the device is carried on `FramePayload::D3d11`), and **encodes the capturer's texture in
//! place** — it registers each input texture with NVENC once (cached by pointer) and `encode_picture`s //! place** — it registers each input texture with NVENC once (cached by pointer) and `encode_picture`s
//! it directly, with NO per-frame `CopyResource`. (That's safe because the host encode loop is //! it directly, with NO per-frame `CopyResource`. (That's safe because the host encode loop is
@@ -10,8 +13,10 @@
//! pipelined, the capturer must hand a ring of textures.) Mirrors the Linux NVENC config: CBR + //! pipelined, the capturer must hand a ring of textures.) Mirrors the Linux NVENC config: CBR +
//! ultra-low-latency, infinite GOP, P-frames only, forced-IDR for RFI, in-band SPS/PPS each keyframe. //! ultra-low-latency, infinite GOP, P-frames only, forced-IDR for RFI, in-band SPS/PPS each keyframe.
//! //!
//! Needs a real NVIDIA GPU at runtime (session creation fails otherwise) — compiles GPU-less, but //! Needs a real NVIDIA GPU at runtime (session creation fails otherwise) — compiles GPU-less and
//! `open`/`submit` only succeed on a GPU box. The software encoder (`super::sw`) is the fallback. //! **starts driver-less** (the DLL resolves at runtime; on an AMD/Intel box [`try_api`] fails
//! cleanly and the AMF/QSV/software backends carry the session). The software encoder
//! (`super::sw`) is the fallback.
//! //!
//! **Two-thread async retrieve** (`PUNKTFUNK_NVENC_ASYNC=1`, opt-in until on-glass validated — //! **Two-thread async retrieve** (`PUNKTFUNK_NVENC_ASYNC=1`, opt-in until on-glass validated —
//! gpu-contention plan §5.B): the NVENC guide mandates that the main thread only *submit* //! gpu-contention plan §5.B): the NVENC guide mandates that the main thread only *submit*
@@ -44,7 +49,182 @@ use windows::Win32::Graphics::Direct3D11::{ID3D11Device, ID3D11Texture2D};
use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject}; use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject};
use nvidia_video_codec_sdk::sys::nvEncodeAPI as nv; use nvidia_video_codec_sdk::sys::nvEncodeAPI as nv;
use nvidia_video_codec_sdk::ENCODE_API as API;
// ---------------------------------------------------------------------------------------------
// Runtime-loaded NVENC entry table.
//
// The NVENC entry points live in `nvEncodeAPI64.dll`, which exists ONLY where the NVIDIA driver
// is installed. They must be resolved at runtime (`LoadLibraryExW` + `GetProcAddress`), never as
// a link-time import: the shipped host binary compiles the `nvenc` feature in unconditionally,
// and a load-time DLL import makes the Windows loader refuse to start the process on every
// AMD/Intel-only box ("nvencodeapi64.dll was not found", before `main`) — `encode.rs` never gets
// the chance to dispatch to AMF/QSV. This is the Windows analogue of the Linux host's dlopen'd
// libcuda. Only the two real DLL exports are resolved by name; the rest of the table comes back
// through `NvEncodeAPICreateInstance`.
// ---------------------------------------------------------------------------------------------
/// The `NV_ENCODE_API_FUNCTION_LIST` entries this encoder uses, unwrapped once at load so call
/// sites stay `(api().encode_picture)(…)`. Field names mirror the sdk crate's `EncodeAPI`, whose
/// lazy static must NOT be referenced — it calls the statically-declared externs, which is what
/// demanded the import lib at link time.
struct EncodeApi {
open_encode_session_ex: unsafe extern "C" fn(
*mut nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS,
*mut *mut c_void,
) -> nv::NVENCSTATUS,
initialize_encoder:
unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_INITIALIZE_PARAMS) -> nv::NVENCSTATUS,
destroy_encoder: unsafe extern "C" fn(*mut c_void) -> nv::NVENCSTATUS,
get_encode_caps: unsafe extern "C" fn(
*mut c_void,
nv::GUID,
*mut nv::NV_ENC_CAPS_PARAM,
*mut core::ffi::c_int,
) -> nv::NVENCSTATUS,
get_encode_preset_config_ex: unsafe extern "C" fn(
*mut c_void,
nv::GUID,
nv::GUID,
nv::NV_ENC_TUNING_INFO,
*mut nv::NV_ENC_PRESET_CONFIG,
) -> nv::NVENCSTATUS,
create_bitstream_buffer: unsafe extern "C" fn(
*mut c_void,
*mut nv::NV_ENC_CREATE_BITSTREAM_BUFFER,
) -> nv::NVENCSTATUS,
destroy_bitstream_buffer:
unsafe extern "C" fn(*mut c_void, nv::NV_ENC_OUTPUT_PTR) -> nv::NVENCSTATUS,
lock_bitstream:
unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_LOCK_BITSTREAM) -> nv::NVENCSTATUS,
unlock_bitstream: unsafe extern "C" fn(*mut c_void, nv::NV_ENC_OUTPUT_PTR) -> nv::NVENCSTATUS,
register_resource:
unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_REGISTER_RESOURCE) -> nv::NVENCSTATUS,
unregister_resource:
unsafe extern "C" fn(*mut c_void, nv::NV_ENC_REGISTERED_PTR) -> nv::NVENCSTATUS,
map_input_resource:
unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_MAP_INPUT_RESOURCE) -> nv::NVENCSTATUS,
unmap_input_resource:
unsafe extern "C" fn(*mut c_void, nv::NV_ENC_INPUT_PTR) -> nv::NVENCSTATUS,
encode_picture:
unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_PIC_PARAMS) -> nv::NVENCSTATUS,
register_async_event:
unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_EVENT_PARAMS) -> nv::NVENCSTATUS,
unregister_async_event:
unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_EVENT_PARAMS) -> nv::NVENCSTATUS,
invalidate_ref_frames: unsafe extern "C" fn(*mut c_void, u64) -> nv::NVENCSTATUS,
}
/// Local `NVENCSTATUS` → `Result` (replaces the sdk's `result_without_string`, which lives in the
/// crate's `safe` module — code this file must not pull in, see [`EncodeApi`]). The raw status's
/// Debug repr (`NV_ENC_ERR_INVALID_PARAM`, …) is the error payload.
trait NvStatusExt {
fn nv_ok(self) -> std::result::Result<(), nv::NVENCSTATUS>;
}
impl NvStatusExt for nv::NVENCSTATUS {
fn nv_ok(self) -> std::result::Result<(), nv::NVENCSTATUS> {
match self {
nv::NVENCSTATUS::NV_ENC_SUCCESS => Ok(()),
err => Err(err),
}
}
}
/// Resolve the table once per process. `Err` = NVENC genuinely unavailable on this machine (no
/// NVIDIA driver/DLL, or a driver older than our headers) — the entry points
/// ([`NvencD3d11Encoder::open`], [`probe_can_encode_444`]) gate on it and the AMF/QSV/software
/// backends carry on.
fn try_api() -> std::result::Result<&'static EncodeApi, &'static str> {
static TABLE: std::sync::OnceLock<std::result::Result<EncodeApi, String>> =
std::sync::OnceLock::new();
TABLE
.get_or_init(|| {
let table = load_api();
if let Err(e) = &table {
// Once per process. Only reachable when something resolved to NVENC on this box
// (backend misdetect or a forced PUNKTFUNK_ENCODER=nvenc) — say why it will fail.
tracing::warn!("NVENC API unavailable: {e}");
}
table
})
.as_ref()
.map_err(|e| e.as_str())
}
/// The loaded table, for call sites past a [`try_api`] gate — a live session (or the probe's own
/// gate) implies the load succeeded, and the table lives for the process lifetime.
fn api() -> &'static EncodeApi {
try_api().expect("NVENC call before a successful try_api() gate")
}
fn load_api() -> std::result::Result<EncodeApi, String> {
use windows::core::{s, w};
use windows::Win32::System::LibraryLoader::{
GetProcAddress, LoadLibraryExW, LOAD_LIBRARY_SEARCH_SYSTEM32,
};
// SAFETY: `LoadLibraryExW`/`GetProcAddress` take static NUL-terminated names; the
// System32-only search path keeps a planted DLL out of the SYSTEM-service process. The two
// transmutes cast the resolved exports to their documented prototypes (nvEncodeAPI.h), the
// same contract the C SDK's own loader applies. `NvEncodeAPIGetMaxSupportedVersion` writes
// one u32 through a live pointer; `NvEncodeAPICreateInstance` fills `list`, a stack-local
// `#[repr(C)]` function list with `version` set, only during the call. The module is never
// freed, so every extracted function pointer stays valid for the process lifetime.
unsafe {
let module = LoadLibraryExW(w!("nvEncodeAPI64.dll"), None, LOAD_LIBRARY_SEARCH_SYSTEM32)
.map_err(|e| format!("nvEncodeAPI64.dll not loadable (no NVIDIA driver?): {e}"))?;
let get_version = GetProcAddress(module, s!("NvEncodeAPIGetMaxSupportedVersion"))
.ok_or("nvEncodeAPI64.dll exports no NvEncodeAPIGetMaxSupportedVersion")?;
let create_instance = GetProcAddress(module, s!("NvEncodeAPICreateInstance"))
.ok_or("nvEncodeAPI64.dll exports no NvEncodeAPICreateInstance")?;
let get_version: unsafe extern "C" fn(*mut u32) -> nv::NVENCSTATUS =
std::mem::transmute(get_version);
let create_instance: unsafe extern "C" fn(
*mut nv::NV_ENCODE_API_FUNCTION_LIST,
) -> nv::NVENCSTATUS = std::mem::transmute(create_instance);
let mut version = 0u32;
get_version(&mut version)
.nv_ok()
.map_err(|e| format!("NvEncodeAPIGetMaxSupportedVersion: {e:?}"))?;
// The sdk's assert_versions_match, minus the panic: an older driver is a clean Err.
let (major, minor) = (version >> 4, version & 0xf);
if (major, minor) < (nv::NVENCAPI_MAJOR_VERSION, nv::NVENCAPI_MINOR_VERSION) {
return Err(format!(
"driver NVENC API {major}.{minor} is older than the host's headers {}.{}\
update the NVIDIA driver",
nv::NVENCAPI_MAJOR_VERSION,
nv::NVENCAPI_MINOR_VERSION
));
}
let mut list = nv::NV_ENCODE_API_FUNCTION_LIST {
version: nv::NV_ENCODE_API_FUNCTION_LIST_VER,
..Default::default()
};
create_instance(&mut list)
.nv_ok()
.map_err(|e| format!("NvEncodeAPICreateInstance: {e:?}"))?;
const MISSING: &str = "NvEncodeAPICreateInstance left an entry point unfilled";
Ok(EncodeApi {
open_encode_session_ex: list.nvEncOpenEncodeSessionEx.ok_or(MISSING)?,
initialize_encoder: list.nvEncInitializeEncoder.ok_or(MISSING)?,
destroy_encoder: list.nvEncDestroyEncoder.ok_or(MISSING)?,
get_encode_caps: list.nvEncGetEncodeCaps.ok_or(MISSING)?,
get_encode_preset_config_ex: list.nvEncGetEncodePresetConfigEx.ok_or(MISSING)?,
create_bitstream_buffer: list.nvEncCreateBitstreamBuffer.ok_or(MISSING)?,
destroy_bitstream_buffer: list.nvEncDestroyBitstreamBuffer.ok_or(MISSING)?,
lock_bitstream: list.nvEncLockBitstream.ok_or(MISSING)?,
unlock_bitstream: list.nvEncUnlockBitstream.ok_or(MISSING)?,
register_resource: list.nvEncRegisterResource.ok_or(MISSING)?,
unregister_resource: list.nvEncUnregisterResource.ok_or(MISSING)?,
map_input_resource: list.nvEncMapInputResource.ok_or(MISSING)?,
unmap_input_resource: list.nvEncUnmapInputResource.ok_or(MISSING)?,
encode_picture: list.nvEncEncodePicture.ok_or(MISSING)?,
register_async_event: list.nvEncRegisterAsyncEvent.ok_or(MISSING)?,
unregister_async_event: list.nvEncUnregisterAsyncEvent.ok_or(MISSING)?,
invalidate_ref_frames: list.nvEncInvalidateRefFrames.ok_or(MISSING)?,
})
}
}
// Output bitstream buffers = max in-flight encodes. The helper deep-pipelines (submits several frames // Output bitstream buffers = max in-flight encodes. The helper deep-pipelines (submits several frames
// before locking the oldest) so per-frame GPU-scheduling waits OVERLAP instead of serializing under a // before locking the oldest) so per-frame GPU-scheduling waits OVERLAP instead of serializing under a
@@ -143,7 +323,7 @@ fn retrieve_loop(
outputBitstream: job.bs as *mut c_void, outputBitstream: job.bs as *mut c_void,
..Default::default() ..Default::default()
}; };
match (API.lock_bitstream)(enc as *mut c_void, &mut lock).result_without_string() { match (api().lock_bitstream)(enc as *mut c_void, &mut lock).nv_ok() {
Ok(()) => { Ok(()) => {
let data = std::slice::from_raw_parts( let data = std::slice::from_raw_parts(
lock.bitstreamBufferPtr as *const u8, lock.bitstreamBufferPtr as *const u8,
@@ -155,7 +335,7 @@ fn retrieve_loop(
nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_IDR nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_IDR
| nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_I | nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_I
); );
let _ = (API.unlock_bitstream)(enc as *mut c_void, job.bs as *mut c_void); let _ = (api().unlock_bitstream)(enc as *mut c_void, job.bs as *mut c_void);
Ok((data, keyframe)) Ok((data, keyframe))
} }
Err(e) => Err(format!("lock_bitstream (async): {e:?}")), Err(e) => Err(format!("lock_bitstream (async): {e:?}")),
@@ -255,6 +435,11 @@ impl NvencD3d11Encoder {
bit_depth: u8, bit_depth: u8,
chroma: ChromaFormat, chroma: ChromaFormat,
) -> Result<Self> { ) -> Result<Self> {
// The runtime DLL load is the real "is NVENC possible here" gate: fail the open with a
// clear reason (backend misdetect / forced PUNKTFUNK_ENCODER=nvenc on a non-NVIDIA box)
// instead of an opaque session error on the first frame. Every later NVENC call in this
// file sits behind this gate (or the probe's), so the infallible `api()` is sound.
try_api().map_err(|e| anyhow!("NVENC unavailable: {e}"))?;
Ok(Self { Ok(Self {
encoder: ptr::null_mut(), encoder: ptr::null_mut(),
codec, codec,
@@ -309,11 +494,11 @@ impl NvencD3d11Encoder {
// Unmap any in-flight inputs, then unregister every cached texture and destroy the bitstreams. // Unmap any in-flight inputs, then unregister every cached texture and destroy the bitstreams.
for (_, map, _) in &self.pending { for (_, map, _) in &self.pending {
if !map.is_null() { if !map.is_null() {
let _ = (API.unmap_input_resource)(self.encoder, *map); let _ = (api().unmap_input_resource)(self.encoder, *map);
} }
} }
for (reg, _tex) in self.regs.values() { for (reg, _tex) in self.regs.values() {
let _ = (API.unregister_resource)(self.encoder, *reg); let _ = (api().unregister_resource)(self.encoder, *reg);
} }
// Async events: unregister from the session, then close the Win32 handles. // Async events: unregister from the session, then close the Win32 handles.
for &ev in &self.events { for &ev in &self.events {
@@ -322,14 +507,14 @@ impl NvencD3d11Encoder {
completionEvent: ev as *mut c_void, completionEvent: ev as *mut c_void,
..Default::default() ..Default::default()
}; };
let _ = (API.unregister_async_event)(self.encoder, &mut ep); let _ = (api().unregister_async_event)(self.encoder, &mut ep);
let _ = CloseHandle(HANDLE(ev as *mut c_void)); let _ = CloseHandle(HANDLE(ev as *mut c_void));
} }
self.events.clear(); self.events.clear();
for &bs in &self.bitstreams { for &bs in &self.bitstreams {
let _ = (API.destroy_bitstream_buffer)(self.encoder, bs); let _ = (api().destroy_bitstream_buffer)(self.encoder, bs);
} }
let _ = (API.destroy_encoder)(self.encoder); let _ = (api().destroy_encoder)(self.encoder);
self.regs.clear(); // drops the texture clones, releasing our refs self.regs.clear(); // drops the texture clones, releasing our refs
self.bitstreams.clear(); self.bitstreams.clear();
self.pending.clear(); self.pending.clear();
@@ -350,9 +535,7 @@ impl NvencD3d11Encoder {
reserved: [0; 62], reserved: [0; 62],
}; };
let mut val: i32 = 0; let mut val: i32 = 0;
match (API.get_encode_caps)(enc, self.codec_guid, &mut param, &mut val) match (api().get_encode_caps)(enc, self.codec_guid, &mut param, &mut val).nv_ok() {
.result_without_string()
{
Ok(()) => val, Ok(()) => val,
Err(_) => 0, Err(_) => 0,
} }
@@ -374,8 +557,8 @@ impl NvencD3d11Encoder {
..Default::default() ..Default::default()
}; };
let mut enc: *mut c_void = ptr::null_mut(); let mut enc: *mut c_void = ptr::null_mut();
(API.open_encode_session_ex)(&mut params, &mut enc) (api().open_encode_session_ex)(&mut params, &mut enc)
.result_without_string() .nv_ok()
.map_err(|e| { .map_err(|e| {
anyhow!("NVENC open_encode_session_ex (caps probe): {e:?} (no NVIDIA GPU?)") anyhow!("NVENC open_encode_session_ex (caps probe): {e:?} (no NVIDIA GPU?)")
})?; })?;
@@ -392,7 +575,7 @@ impl NvencD3d11Encoder {
nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_CUSTOM_VBV_BUF_SIZE, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_CUSTOM_VBV_BUF_SIZE,
); );
let async_enc = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_ASYNC_ENCODE_SUPPORT); let async_enc = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_ASYNC_ENCODE_SUPPORT);
let _ = (API.destroy_encoder)(enc); let _ = (api().destroy_encoder)(enc);
// Reject an over-range mode with a clear message instead of an opaque InvalidParam. // Reject an over-range mode with a clear message instead of an opaque InvalidParam.
if wmax > 0 && hmax > 0 && (self.width as i32 > wmax || self.height as i32 > hmax) { if wmax > 0 && hmax > 0 && (self.width as i32 > wmax || self.height as i32 > hmax) {
@@ -449,8 +632,8 @@ impl NvencD3d11Encoder {
..Default::default() ..Default::default()
}; };
let mut enc: *mut c_void = ptr::null_mut(); let mut enc: *mut c_void = ptr::null_mut();
(API.open_encode_session_ex)(&mut params, &mut enc) (api().open_encode_session_ex)(&mut params, &mut enc)
.result_without_string() .nv_ok()
.map_err(|e| anyhow!("NVENC open_encode_session_ex: {e:?} (no NVIDIA GPU?)"))?; .map_err(|e| anyhow!("NVENC open_encode_session_ex: {e:?} (no NVIDIA GPU?)"))?;
// Seed the P1 + ultra-low-latency preset config. // Seed the P1 + ultra-low-latency preset config.
@@ -462,16 +645,16 @@ impl NvencD3d11Encoder {
}, },
..Default::default() ..Default::default()
}; };
if let Err(e) = (API.get_encode_preset_config_ex)( if let Err(e) = (api().get_encode_preset_config_ex)(
enc, enc,
self.codec_guid, self.codec_guid,
nv::NV_ENC_PRESET_P1_GUID, nv::NV_ENC_PRESET_P1_GUID,
nv::NV_ENC_TUNING_INFO::NV_ENC_TUNING_INFO_ULTRA_LOW_LATENCY, nv::NV_ENC_TUNING_INFO::NV_ENC_TUNING_INFO_ULTRA_LOW_LATENCY,
&mut preset, &mut preset,
) )
.result_without_string() .nv_ok()
{ {
let _ = (API.destroy_encoder)(enc); let _ = (api().destroy_encoder)(enc);
return Err(anyhow!("get_encode_preset_config_ex: {e:?}")); return Err(anyhow!("get_encode_preset_config_ex: {e:?}"));
} }
let mut cfg = preset.presetCfg; let mut cfg = preset.presetCfg;
@@ -613,10 +796,10 @@ impl NvencD3d11Encoder {
// splitEncodeMode is a C bitfield — set via the generated accessor, not a struct field. // splitEncodeMode is a C bitfield — set via the generated accessor, not a struct field.
init.set_splitEncodeMode(split_mode); init.set_splitEncodeMode(split_mode);
match (API.initialize_encoder)(enc, &mut init).result_without_string() { match (api().initialize_encoder)(enc, &mut init).nv_ok() {
Ok(()) => Ok(enc), Ok(()) => Ok(enc),
Err(e) => { Err(e) => {
let _ = (API.destroy_encoder)(enc); let _ = (api().destroy_encoder)(enc);
Err(anyhow!("initialize_encoder: {e:?}")) Err(anyhow!("initialize_encoder: {e:?}"))
} }
} }
@@ -624,8 +807,8 @@ impl NvencD3d11Encoder {
/// Lazily create the session on the first frame's D3D11 device (so capture + encode share it). /// Lazily create the session on the first frame's D3D11 device (so capture + encode share it).
fn init_session(&mut self, device: &ID3D11Device) -> Result<()> { fn init_session(&mut self, device: &ID3D11Device) -> Result<()> {
// SAFETY: every call below goes through a function pointer resolved once from the loaded // SAFETY: every call below goes through a function pointer resolved once from the
// `nvidia_video_codec_sdk::ENCODE_API` (`nvEncodeAPI`) table, or through this type's own // runtime-loaded [`EncodeApi`] table (`api()`, gated in `open`), or through this type's own
// `unsafe fn`s whose contract is met here. `query_caps`/`try_open_session` receive `device`, // `unsafe fn`s whose contract is met here. `query_caps`/`try_open_session` receive `device`,
// the live `ID3D11Device` the caller pulled off the first frame; each returns either a valid // the live `ID3D11Device` the caller pulled off the first frame; each returns either a valid
// open NVENC session handle or an `Err`. `destroy_encoder` is only ever called on a handle a // open NVENC session handle or an `Err`. `destroy_encoder` is only ever called on a handle a
@@ -729,7 +912,7 @@ impl NvencD3d11Encoder {
match self.try_open_session(device, mid, split_mode, use_async) { match self.try_open_session(device, mid, split_mode, use_async) {
Ok(e) => { Ok(e) => {
if !best.is_null() { if !best.is_null() {
let _ = (API.destroy_encoder)(best); let _ = (api().destroy_encoder)(best);
} }
best = e; best = e;
best_bps = mid; best_bps = mid;
@@ -778,8 +961,8 @@ impl NvencD3d11Encoder {
version: nv::NV_ENC_CREATE_BITSTREAM_BUFFER_VER, version: nv::NV_ENC_CREATE_BITSTREAM_BUFFER_VER,
..Default::default() ..Default::default()
}; };
(API.create_bitstream_buffer)(enc, &mut cb) (api().create_bitstream_buffer)(enc, &mut cb)
.result_without_string() .nv_ok()
.map_err(|e| anyhow!("create_bitstream_buffer: {e:?}"))?; .map_err(|e| anyhow!("create_bitstream_buffer: {e:?}"))?;
self.bitstreams.push(cb.bitstreamBuffer); self.bitstreams.push(cb.bitstreamBuffer);
} }
@@ -795,8 +978,8 @@ impl NvencD3d11Encoder {
completionEvent: ev.0, completionEvent: ev.0,
..Default::default() ..Default::default()
}; };
(API.register_async_event)(enc, &mut ep) (api().register_async_event)(enc, &mut ep)
.result_without_string() .nv_ok()
.map_err(|e| anyhow!("register_async_event: {e:?}"))?; .map_err(|e| anyhow!("register_async_event: {e:?}"))?;
self.events.push(ev.0 as usize); self.events.push(ev.0 as usize);
} }
@@ -852,7 +1035,7 @@ impl NvencD3d11Encoder {
// path's poll-side unmap, exactly once per mapping. // path's poll-side unmap, exactly once per mapping.
unsafe { unsafe {
if !map.is_null() { if !map.is_null() {
let _ = (API.unmap_input_resource)(self.encoder, map); let _ = (api().unmap_input_resource)(self.encoder, map);
} }
} }
let (data, keyframe) = done.result.map_err(|e| anyhow!("{e}"))?; let (data, keyframe) = done.result.map_err(|e| anyhow!("{e}"))?;
@@ -953,7 +1136,7 @@ impl Encoder for NvencD3d11Encoder {
} }
let slot = self.next % POOL; let slot = self.next % POOL;
self.next += 1; self.next += 1;
// SAFETY: every NVENC call goes through a function pointer from the loaded `ENCODE_API` table // SAFETY: every NVENC call goes through a function pointer from the runtime-loaded `EncodeApi` table
// and takes `self.encoder`, the live session `init_session` just established (non-null on the // and takes `self.encoder`, the live session `init_session` just established (non-null on the
// path that reaches here). `NV_ENC_REGISTER_RESOURCE rr` has `version = // path that reaches here). `NV_ENC_REGISTER_RESOURCE rr` has `version =
// NV_ENC_REGISTER_RESOURCE_VER` and registers `frame.texture` — a D3D11 texture from // NV_ENC_REGISTER_RESOURCE_VER` and registers `frame.texture` — a D3D11 texture from
@@ -986,8 +1169,8 @@ impl Encoder for NvencD3d11Encoder {
bufferUsage: nv::NV_ENC_BUFFER_USAGE::NV_ENC_INPUT_IMAGE, bufferUsage: nv::NV_ENC_BUFFER_USAGE::NV_ENC_INPUT_IMAGE,
..Default::default() ..Default::default()
}; };
(API.register_resource)(self.encoder, &mut rr) (api().register_resource)(self.encoder, &mut rr)
.result_without_string() .nv_ok()
.map_err(|e| anyhow!("register_resource: {e:?}"))?; .map_err(|e| anyhow!("register_resource: {e:?}"))?;
self.regs self.regs
.insert(key, (rr.registeredResource, frame.texture.clone())); .insert(key, (rr.registeredResource, frame.texture.clone()));
@@ -999,8 +1182,8 @@ impl Encoder for NvencD3d11Encoder {
registeredResource: reg, registeredResource: reg,
..Default::default() ..Default::default()
}; };
(API.map_input_resource)(self.encoder, &mut mp) (api().map_input_resource)(self.encoder, &mut mp)
.result_without_string() .nv_ok()
.map_err(|e| anyhow!("map_input_resource: {e:?}"))?; .map_err(|e| anyhow!("map_input_resource: {e:?}"))?;
let pts = self.frame_idx as u64; let pts = self.frame_idx as u64;
@@ -1076,8 +1259,8 @@ impl Encoder for NvencD3d11Encoder {
Codec::Av1 => {} Codec::Av1 => {}
} }
} }
(API.encode_picture)(self.encoder, &mut pic) (api().encode_picture)(self.encoder, &mut pic)
.result_without_string() .nv_ok()
.map_err(|e| anyhow!("encode_picture: {e:?}"))?; .map_err(|e| anyhow!("encode_picture: {e:?}"))?;
self.pending self.pending
.push_back((self.bitstreams[slot], mp.mappedResource, captured.pts_ns)); .push_back((self.bitstreams[slot], mp.mappedResource, captured.pts_ns));
@@ -1149,7 +1332,7 @@ impl Encoder for NvencD3d11Encoder {
// We tag each input with `inputTimeStamp = frame_idx` (0,1,2,…), which is also the client's // We tag each input with `inputTimeStamp = frame_idx` (0,1,2,…), which is also the client's
// frame number (the packetizer numbers frames in submit order), so the client's lost-frame // frame number (the packetizer numbers frames in submit order), so the client's lost-frame
// range maps 1:1 onto the timestamps NVENC invalidates here. // range maps 1:1 onto the timestamps NVENC invalidates here.
// SAFETY: `invalidate_ref_frames` is a function pointer from the loaded `ENCODE_API` table. // SAFETY: `invalidate_ref_frames` is a function pointer from the runtime-loaded `EncodeApi` table.
// `self.encoder` was checked non-null at the top of this fn and is the live session; this runs // `self.encoder` was checked non-null at the top of this fn and is the live session; this runs
// on the encode thread (like submit/poll), so there is no concurrent NVENC use. Each `ts` was // on the encode thread (like submit/poll), so there is no concurrent NVENC use. Each `ts` was
// clamped to `[oldest_in_dpb, frame_idx - 1]` above, so it names a frame still in the session's // clamped to `[oldest_in_dpb, frame_idx - 1]` above, so it names a frame still in the session's
@@ -1157,8 +1340,8 @@ impl Encoder for NvencD3d11Encoder {
// lifetime concern. // lifetime concern.
unsafe { unsafe {
for ts in first..=last { for ts in first..=last {
if (API.invalidate_ref_frames)(self.encoder, ts as u64) if (api().invalidate_ref_frames)(self.encoder, ts as u64)
.result_without_string() .nv_ok()
.is_err() .is_err()
{ {
return false; // any failure → fall back to IDR return false; // any failure → fall back to IDR
@@ -1195,7 +1378,7 @@ impl Encoder for NvencD3d11Encoder {
}; };
// SAFETY: a non-empty `pending` implies `submit` ran, so `self.encoder` is the live session // SAFETY: a non-empty `pending` implies `submit` ran, so `self.encoder` is the live session
// (`teardown` clears `pending` whenever it nulls the handle); all calls below use function // (`teardown` clears `pending` whenever it nulls the handle); all calls below use function
// pointers from the loaded `ENCODE_API` table on the encode thread. `NV_ENC_LOCK_BITSTREAM lock` // pointers from the runtime-loaded `EncodeApi` table on the encode thread. `NV_ENC_LOCK_BITSTREAM lock`
// (version = `NV_ENC_LOCK_BITSTREAM_VER`) locks `bs`, a pool bitstream a prior `encode_picture` // (version = `NV_ENC_LOCK_BITSTREAM_VER`) locks `bs`, a pool bitstream a prior `encode_picture`
// targeted; `lock_bitstream` blocks until that encode finishes, so on success // targeted; `lock_bitstream` blocks until that encode finishes, so on success
// `lock.bitstreamBufferPtr` is non-null and points at `lock.bitstreamSizeInBytes` bytes of // `lock.bitstreamBufferPtr` is non-null and points at `lock.bitstreamSizeInBytes` bytes of
@@ -1209,8 +1392,8 @@ impl Encoder for NvencD3d11Encoder {
outputBitstream: bs, outputBitstream: bs,
..Default::default() ..Default::default()
}; };
(API.lock_bitstream)(self.encoder, &mut lock) (api().lock_bitstream)(self.encoder, &mut lock)
.result_without_string() .nv_ok()
.map_err(|e| anyhow!("lock_bitstream: {e:?}"))?; .map_err(|e| anyhow!("lock_bitstream: {e:?}"))?;
let data = std::slice::from_raw_parts( let data = std::slice::from_raw_parts(
lock.bitstreamBufferPtr as *const u8, lock.bitstreamBufferPtr as *const u8,
@@ -1221,11 +1404,11 @@ impl Encoder for NvencD3d11Encoder {
lock.pictureType, lock.pictureType,
nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_IDR | nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_I nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_IDR | nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_I
); );
(API.unlock_bitstream)(self.encoder, bs) (api().unlock_bitstream)(self.encoder, bs)
.result_without_string() .nv_ok()
.map_err(|e| anyhow!("unlock_bitstream: {e:?}"))?; .map_err(|e| anyhow!("unlock_bitstream: {e:?}"))?;
if !map.is_null() { if !map.is_null() {
let _ = (API.unmap_input_resource)(self.encoder, map); let _ = (api().unmap_input_resource)(self.encoder, map);
} }
Ok(Some(EncodedFrame { Ok(Some(EncodedFrame {
data, data,
@@ -1267,6 +1450,11 @@ pub fn probe_can_encode_444(codec: Codec) -> bool {
if codec != Codec::H265 { if codec != Codec::H265 {
return false; return false;
} }
// No loadable NVENC on this box (non-NVIDIA / no driver) → the honest 4:4:4 answer is "no".
// This is also the `api()` gate for every NVENC call below.
if try_api().is_err() {
return false;
}
// SAFETY: a self-contained probe owning every handle it creates. `CreateDXGIFactory1`/ // SAFETY: a self-contained probe owning every handle it creates. `CreateDXGIFactory1`/
// `EnumAdapterByLuid` return owned COM objects or err (→ default-adapter fallback). // `EnumAdapterByLuid` return owned COM objects or err (→ default-adapter fallback).
// `D3D11CreateDevice` (explicit adapter + UNKNOWN driver type, or NULL adapter + HARDWARE) // `D3D11CreateDevice` (explicit adapter + UNKNOWN driver type, or NULL adapter + HARDWARE)
@@ -1321,8 +1509,8 @@ pub fn probe_can_encode_444(codec: Codec) -> bool {
..Default::default() ..Default::default()
}; };
let mut enc: *mut c_void = ptr::null_mut(); let mut enc: *mut c_void = ptr::null_mut();
if (API.open_encode_session_ex)(&mut params, &mut enc) if (api().open_encode_session_ex)(&mut params, &mut enc)
.result_without_string() .nv_ok()
.is_err() .is_err()
{ {
return false; return false;
@@ -1333,11 +1521,11 @@ pub fn probe_can_encode_444(codec: Codec) -> bool {
reserved: [0; 62], reserved: [0; 62],
}; };
let mut val: i32 = 0; let mut val: i32 = 0;
let ok = (API.get_encode_caps)(enc, nv::NV_ENC_CODEC_HEVC_GUID, &mut param, &mut val) let ok = (api().get_encode_caps)(enc, nv::NV_ENC_CODEC_HEVC_GUID, &mut param, &mut val)
.result_without_string() .nv_ok()
.is_ok() .is_ok()
&& val != 0; && val != 0;
let _ = (API.destroy_encoder)(enc); let _ = (api().destroy_encoder)(enc);
ok ok
} }
} }
+12 -5
View File
@@ -820,8 +820,10 @@ mod tests {
#[test] #[test]
fn sender_delivers_batches() { fn sender_delivers_batches() {
let rx_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); let rx_sock = UdpSocket::bind("127.0.0.1:0").unwrap();
// Generous: on a CI host saturated by parallel release builds, this thread can be
// starved for whole seconds between recv() wakeups.
rx_sock rx_sock
.set_read_timeout(Some(Duration::from_secs(3))) .set_read_timeout(Some(Duration::from_secs(10)))
.unwrap(); .unwrap();
let tx_sock = UdpSocket::bind("127.0.0.1:0").unwrap(); let tx_sock = UdpSocket::bind("127.0.0.1:0").unwrap();
tx_sock.connect(rx_sock.local_addr().unwrap()).unwrap(); tx_sock.connect(rx_sock.local_addr().unwrap()).unwrap();
@@ -837,10 +839,15 @@ mod tests {
) )
.unwrap(); .unwrap();
// 3 frames of 100 packets, content-tagged for verification. // 3 frames of 20 packets, content-tagged for verification. The TOTAL burst must fit
// the receive socket's DEFAULT buffer even if this thread never drains concurrently
// (a starved CI runner): a 1200 B datagram costs ~2.5 KB kernel truesize, and the
// default rmem (~212 KB) holds only ~80 — a bigger burst gets silently dropped by
// the kernel and the test can never complete (the old 3×100 flaked exactly there).
const PER_FRAME: usize = 20;
let mut sent = Vec::new(); let mut sent = Vec::new();
for f in 0..3u8 { for f in 0..3u8 {
let batch: PacketBatch = (0..100u8) let batch: PacketBatch = (0..PER_FRAME as u8)
.map(|i| { .map(|i| {
let mut p = vec![0u8; 1200]; let mut p = vec![0u8; 1200];
p[0] = f; p[0] = f;
@@ -859,10 +866,10 @@ mod tests {
let n = rx_sock.recv(&mut buf).expect("packet within timeout"); let n = rx_sock.recv(&mut buf).expect("packet within timeout");
assert_eq!(n, 1200); assert_eq!(n, 1200);
let (f, i) = (buf[0] as usize, buf[1] as usize); let (f, i) = (buf[0] as usize, buf[1] as usize);
assert_eq!(&buf[..n], &sent[f * 100 + i][..], "payload intact"); assert_eq!(&buf[..n], &sent[f * PER_FRAME + i][..], "payload intact");
got += 1; got += 1;
} }
assert_eq!(got, 300); assert_eq!(got, 3 * PER_FRAME);
assert!(running.load(Ordering::SeqCst), "no spurious client-gone"); assert!(running.load(Ordering::SeqCst), "no spurious client-gone");
} }
} }
@@ -1,15 +1,16 @@
//! Virtual Sony DualSense on Windows via the UMDF minidriver (`packaging/windows/dualsense-driver`). //! Virtual Sony DualSense on Windows via the UMDF minidriver (`packaging/windows/drivers/pf-dualsense`).
//! //!
//! The Windows analogue of the Linux UHID backend ([`super::dualsense`]): same [`DsState`] model and //! The Windows analogue of the Linux UHID backend ([`super::dualsense`]): same [`DsState`] model and
//! the same byte-level report codec ([`super::dualsense_proto`]), but a different transport. Where //! the same byte-level report codec ([`super::dualsense_proto`]), but a different transport. Where
//! the Linux backend writes report `0x01` to `/dev/uhid` and reads report `0x02` via `UHID_OUTPUT`, //! the Linux backend writes report `0x01` to `/dev/uhid` and reads report `0x02` via `UHID_OUTPUT`,
//! the Windows backend talks to the UMDF driver over a **named shared-memory section** //! the Windows backend talks to the UMDF driver over an **unnamed shared DATA section** (256 B `PadShm`:
//! `Global\pfds-shm-<idx>` (256 B: magic `u32@0`, input report `@8`, output seq `u32@72`, output //! magic `u32@0`, input report `@8`, output seq `u32@72`, output report `@76`) reached over the
//! report `@76`). The host creates the section (privileged → a permissive SDDL so the WUDFHost can //! **sealed channel** ([`PadChannel`], `design/gamepad-channel-sealing.md`): the host duplicates the
//! open it); the driver maps it from its timer, feeds game `READ_REPORT`s from the input bytes, and //! section handle into the driver's WUDFHost, bootstrapped via the named `Global\pfds-boot-<idx>`
//! publishes a game's `0x02` (rumble / lightbar / player-LEDs / adaptive triggers) into the output //! mailbox. The driver feeds game `READ_REPORT`s from the input bytes and publishes a game's `0x02`
//! bytes. `hidclass` gates the device stack, so this user-mode IPC is the only viable channel (a //! (rumble / lightbar / player-LEDs / adaptive triggers) into the output bytes. `hidclass` gates the
//! UMDF driver has no control device); see `windows-dualsense-scoping.md`. //! device stack, so this user-mode IPC is the only viable channel (a UMDF driver has no control
//! device); see `windows-dualsense-scoping.md`.
//! //!
//! Device lifecycle: each pad `SwDeviceCreate`s a `pf_pad_<index>` software devnode (hardware id //! Device lifecycle: each pad `SwDeviceCreate`s a `pf_pad_<index>` software devnode (hardware id
//! `pf_dualsense`, enumerator `punktfunk`) on open and `SwDeviceClose`s it on drop, so the virtual //! `pf_dualsense`, enumerator `punktfunk`) on open and `SwDeviceClose`s it on drop, so the virtual
@@ -20,12 +21,13 @@ use super::dualsense_proto::{
parse_ds_output, serialize_state, DsFeedback, DsState, DS_INPUT_REPORT_LEN, DS_TOUCH_H, parse_ds_output, serialize_state, DsFeedback, DsState, DS_INPUT_REPORT_LEN, DS_TOUCH_H,
DS_TOUCH_W, DS_TOUCH_W,
}; };
use super::gamepad_raii::PadChannel;
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use punktfunk_core::quic::{HidOutput, RichInput}; use punktfunk_core::quic::{HidOutput, RichInput};
use std::ffi::c_void; use std::ffi::c_void;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use windows::core::{w, GUID, HRESULT, HSTRING, PCWSTR}; use windows::core::{w, GUID, HRESULT, PCWSTR};
use windows::Win32::Devices::Enumeration::Pnp::{ use windows::Win32::Devices::Enumeration::Pnp::{
SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO, SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO,
}; };
@@ -49,17 +51,19 @@ pub(super) const OFF_DEVTYPE: usize =
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, device_type); core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, device_type);
pub(super) const OFF_DRIVER_PROTO: usize = pub(super) const OFF_DRIVER_PROTO: usize =
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, driver_proto); core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, driver_proto);
pub(super) const OFF_PAD_INDEX: usize =
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, pad_index);
pub(super) const DEVTYPE_DUALSHOCK4: u8 = pf_driver_proto::gamepad::DEVTYPE_DUALSHOCK4; pub(super) const DEVTYPE_DUALSHOCK4: u8 = pf_driver_proto::gamepad::DEVTYPE_DUALSHOCK4;
/// A single virtual DualSense: the SwDeviceCreate'd `pf_pad_<index>` software devnode (the driver /// A single virtual DualSense: the SwDeviceCreate'd `pf_pad_<index>` software devnode (the driver
/// loads on it and the HID DualSense appears to games) plus the shared-memory section the driver maps. /// loads on it and the HID DualSense appears to games) plus the sealed shared-memory channel.
/// Dropping it removes the devnode (`SwDeviceClose`) and unmaps + closes the section. /// Dropping it removes the devnode (`SwDeviceClose`) and closes both sections.
struct DsWinPad { struct DsWinPad {
/// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop). /// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop).
/// `None` falls back to an out-of-band `pf_dualsense` devnode (installer/devgen). /// `None` falls back to an out-of-band `pf_dualsense` devnode (installer/devgen).
_sw: Option<super::gamepad_raii::SwDevice>, _sw: Option<super::gamepad_raii::SwDevice>,
/// The named shared section the driver maps (RAII — unmapped + closed on drop). /// The sealed channel: unnamed DATA section (`PadShm`) + bootstrap mailbox + handle delivery.
shm: super::gamepad_raii::Shm, channel: PadChannel,
/// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis. /// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis.
attach: super::gamepad_raii::DriverAttach, attach: super::gamepad_raii::DriverAttach,
seq: u8, seq: u8,
@@ -184,7 +188,7 @@ pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result<(HSWDEVICE, Option<
.encode_utf16() .encode_utf16()
.chain(std::iter::once(0)) .chain(std::iter::once(0))
.collect(); .collect();
// The pad index, stamped into the device Location — the driver reads it to map `pfds-shm-<index>` // The pad index, stamped into the device Location — the driver reads it to poll `pfds-boot-<index>`
// (multi-pad). The buffer outlives the SwDeviceCreate call (we wait on the event before return). // (multi-pad). The buffer outlives the SwDeviceCreate call (we wait on the event before return).
let loc: Vec<u16> = format!("{}", p.container_index) let loc: Vec<u16> = format!("{}", p.container_index)
.encode_utf16() .encode_utf16()
@@ -266,17 +270,20 @@ pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result<(HSWDEVICE, Option<
} }
impl DsWinPad { impl DsWinPad {
/// Create + map the section `Global\pfds-shm-<index>`, stamp the magic, then spawn the /// Create the sealed channel (unnamed DATA section + `Global\pfds-boot-<index>` mailbox), stamp
/// `root\pf_dualsense` devnode (the driver loads on it and maps the section). The devnode lives /// the pad index + neutral report + the magic LAST, then spawn the `pf_pad_<index>` devnode (the
/// for the pad's lifetime — dropping the pad removes it (`SwDeviceClose`). /// driver loads on it and receives the DATA handle over the bootstrap). The devnode lives for the
/// pad's lifetime — dropping the pad removes it (`SwDeviceClose`).
fn open(index: u8) -> Result<DsWinPad> { fn open(index: u8) -> Result<DsWinPad> {
let shm_name = pf_driver_proto::gamepad::pad_shm_name(index); let boot_name = pf_driver_proto::gamepad::pad_boot_name(index);
let shm = super::gamepad_raii::Shm::create(&HSTRING::from(shm_name.as_str()), SHM_SIZE)?; let mut channel = PadChannel::create(boot_name.clone(), SHM_SIZE)?;
let base = shm.base(); let base = channel.data_base();
// Stamp the neutral input report, then the magic LAST (the driver only accepts the section // Stamp the pad index (the driver validates it on attach) + the neutral input report, then
// once magic is set). The device-type stays 0 (DualSense — the section is already zeroed). // the magic LAST (the driver only accepts the section once magic is set). The device-type
// SAFETY: base points at SHM_SIZE writable bytes. // stays 0 (DualSense — the section arrives zeroed).
// SAFETY: base points at SHM_SIZE writable bytes; OFF_PAD_INDEX/OFF_INPUT are in range.
unsafe { unsafe {
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; DS_INPUT_REPORT_LEN], { std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; DS_INPUT_REPORT_LEN], {
let mut r = [0u8; DS_INPUT_REPORT_LEN]; let mut r = [0u8; DS_INPUT_REPORT_LEN];
serialize_state(&mut r, &DsState::neutral(), 0, 0); serialize_state(&mut r, &DsState::neutral(), 0, 0);
@@ -286,7 +293,7 @@ impl DsWinPad {
} }
// Spawn the per-session devnode via SwDeviceCreate; `SwDeviceClose` removes it on drop. On the // Spawn the per-session devnode via SwDeviceCreate; `SwDeviceClose` removes it on drop. On the
// rare failure we keep the section + data plane and fall back to an out-of-band `pf_dualsense` // rare failure we keep the section + data plane and fall back to an out-of-band `pf_dualsense`
// devnode (installer / dev-box devgen). // devnode (installer / dev-box devgen) — its persistent driver polls the same mailbox name.
let inst = format!("pf_pad_{index}"); let inst = format!("pf_pad_{index}");
let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile { let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile {
instance: &inst, instance: &inst,
@@ -302,14 +309,17 @@ impl DsWinPad {
} }
}; };
let _sw = hsw.map(super::gamepad_raii::SwDevice::new); let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
// Bounded eager delivery so the driver holds the DATA section before hidclass asks it for
// descriptors (the driver reads `device_type` from the section to pick its HID identity).
channel.deliver_eager(Duration::from_millis(1500));
Ok(DsWinPad { Ok(DsWinPad {
_sw, _sw,
shm, channel,
attach: super::gamepad_raii::DriverAttach::new( attach: super::gamepad_raii::DriverAttach::new(
"pf_dualsense", "pf_dualsense",
"pf_dualsense.inf", "pf_dualsense.inf",
"C:\\Users\\Public\\pfds-driver.log", "C:\\Users\\Public\\pfds-driver.log",
shm_name, boot_name,
instance_id, instance_id,
), ),
seq: 0, seq: 0,
@@ -326,30 +336,40 @@ impl DsWinPad {
serialize_state(&mut r, st, self.seq, self.ts); serialize_state(&mut r, st, self.seq, self.ts);
// SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64. // SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64.
unsafe { unsafe {
std::ptr::copy_nonoverlapping(r.as_ptr(), self.shm.base().add(OFF_INPUT), r.len()) std::ptr::copy_nonoverlapping(
r.as_ptr(),
self.channel.data_base().add(OFF_INPUT),
r.len(),
)
}; };
} }
/// Poll the section's output slot; parse a new `0x02` report (rumble / LEDs / triggers) into a /// Poll the section's output slot; parse a new `0x02` report (rumble / LEDs / triggers) into a
/// [`DsFeedback`] for pad `pad`. Returns empty feedback if the driver hasn't published anything /// [`DsFeedback`] for pad `pad`. Returns empty feedback if the driver hasn't published anything
/// new. Also feeds the driver-attach health watcher (the driver's ~125 Hz timer stamps /// new. Also ticks the sealed-channel delivery and feeds the driver-attach health watcher (the
/// `driver_proto` while it has the section mapped). /// driver's ~125 Hz timer stamps `driver_proto` while it has the section mapped).
fn service(&mut self, pad: u8) -> DsFeedback { fn service(&mut self, pad: u8) -> DsFeedback {
self.channel.pump();
let mut fb = DsFeedback::default(); let mut fb = DsFeedback::default();
// SAFETY: base points at SHM_SIZE bytes. // SAFETY: base points at SHM_SIZE bytes.
let proto = unsafe { let proto = unsafe {
std::ptr::read_unaligned(self.shm.base().add(OFF_DRIVER_PROTO) as *const u32) std::ptr::read_unaligned(self.channel.data_base().add(OFF_DRIVER_PROTO) as *const u32)
}; };
self.attach.observe(proto); self.attach.observe(proto);
// SAFETY: base points at SHM_SIZE bytes. // SAFETY: base points at SHM_SIZE bytes.
let seq = let seq = unsafe {
unsafe { std::ptr::read_unaligned(self.shm.base().add(OFF_OUT_SEQ) as *const u32) }; std::ptr::read_unaligned(self.channel.data_base().add(OFF_OUT_SEQ) as *const u32)
};
if seq != self.last_out_seq { if seq != self.last_out_seq {
self.last_out_seq = seq; self.last_out_seq = seq;
let mut out = [0u8; 64]; let mut out = [0u8; 64];
// SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section. // SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section.
unsafe { unsafe {
std::ptr::copy_nonoverlapping(self.shm.base().add(OFF_OUTPUT), out.as_mut_ptr(), 64) std::ptr::copy_nonoverlapping(
self.channel.data_base().add(OFF_OUTPUT),
out.as_mut_ptr(),
64,
)
}; };
parse_ds_output(pad, &out, &mut fb); parse_ds_output(pad, &out, &mut fb);
} }
@@ -1,33 +1,33 @@
//! Virtual Sony DualShock 4 on Windows via the UMDF minidriver — the PS4 sibling of //! Virtual Sony DualShock 4 on Windows via the UMDF minidriver — the PS4 sibling of
//! [`super::dualsense_windows`]. Same transport (a per-session `SwDeviceCreate` devnode + the //! [`super::dualsense_windows`]. Same transport (a per-session `SwDeviceCreate` devnode + the sealed
//! `Global\pfds-shm-<idx>` shared section the driver maps), same controller model ([`DsState`]); only //! shared-memory channel bootstrapped via `Global\pfds-boot-<idx>`), same controller model
//! the PnP identity (`VID_054C&PID_09CC`, hardware id `pf_dualshock4`) and the report codec //! ([`DsState`]); only the PnP identity (`VID_054C&PID_09CC`, hardware id `pf_dualshock4`) and the
//! ([`super::dualshock4_proto`]) differ. The host stamps `device_type = 1` (DualShock 4) into the //! report codec ([`super::dualshock4_proto`]) differ. The host stamps `device_type = 1` (DualShock 4)
//! section so the one UMDF driver serves the DS4 descriptor / attributes / features instead of the //! into the DATA section so the one UMDF driver serves the DS4 descriptor / attributes / features
//! DualSense ones. Feedback is motor rumble (universal 0xCA plane) + the lightbar (0xCD `Led`); a DS4 //! instead of the DualSense ones. Feedback is motor rumble (universal 0xCA plane) + the lightbar
//! has no adaptive triggers / player LEDs. //! (0xCD `Led`); a DS4 has no adaptive triggers / player LEDs.
use super::dualsense_proto::DsState; use super::dualsense_proto::DsState;
use super::dualsense_windows::{ use super::dualsense_windows::{
create_swdevice, SwDeviceProfile, DEVTYPE_DUALSHOCK4, OFF_DEVTYPE, OFF_DRIVER_PROTO, OFF_INPUT, create_swdevice, SwDeviceProfile, DEVTYPE_DUALSHOCK4, OFF_DEVTYPE, OFF_DRIVER_PROTO, OFF_INPUT,
OFF_OUTPUT, OFF_OUT_SEQ, SHM_MAGIC, SHM_SIZE, OFF_OUTPUT, OFF_OUT_SEQ, OFF_PAD_INDEX, SHM_MAGIC, SHM_SIZE,
}; };
use super::dualshock4_proto::{ use super::dualshock4_proto::{
parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_TOUCH_H, DS4_TOUCH_W, parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_TOUCH_H, DS4_TOUCH_W,
}; };
use super::gamepad_raii::PadChannel;
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
use anyhow::Result; use anyhow::Result;
use punktfunk_core::quic::{HidOutput, RichInput}; use punktfunk_core::quic::{HidOutput, RichInput};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use windows::core::HSTRING;
/// A single virtual DualShock 4: the `SwDeviceCreate`'d `pf_ds4_<index>` devnode plus the mapped /// A single virtual DualShock 4: the `SwDeviceCreate`'d `pf_ds4_<index>` devnode plus the sealed
/// shared section. Dropping it removes the devnode and unmaps + closes the section. /// shared-memory channel. Dropping it removes the devnode and closes both sections.
struct Ds4WinPad { struct Ds4WinPad {
/// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop). /// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop).
_sw: Option<super::gamepad_raii::SwDevice>, _sw: Option<super::gamepad_raii::SwDevice>,
/// The named shared section the driver maps (RAII — unmapped + closed on drop). /// The sealed channel: unnamed DATA section (`PadShm`) + bootstrap mailbox + handle delivery.
shm: super::gamepad_raii::Shm, channel: PadChannel,
/// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis. /// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis.
attach: super::gamepad_raii::DriverAttach, attach: super::gamepad_raii::DriverAttach,
counter: u8, counter: u8,
@@ -36,16 +36,19 @@ struct Ds4WinPad {
} }
impl Ds4WinPad { impl Ds4WinPad {
/// Create + map the section, stamp `device_type = DualShock 4` + a neutral report + the magic, /// Create the sealed channel, stamp `device_type = DualShock 4` + the pad index + a neutral
/// then spawn the `pf_ds4_<index>` devnode (the driver loads on it and maps the section). /// report + the magic LAST, then spawn the `pf_ds4_<index>` devnode (the driver loads on it and
/// receives the DATA handle over the bootstrap).
fn open(index: u8) -> Result<Ds4WinPad> { fn open(index: u8) -> Result<Ds4WinPad> {
let shm_name = pf_driver_proto::gamepad::pad_shm_name(index); let boot_name = pf_driver_proto::gamepad::pad_boot_name(index);
let shm = super::gamepad_raii::Shm::create(&HSTRING::from(shm_name.as_str()), SHM_SIZE)?; let mut channel = PadChannel::create(boot_name.clone(), SHM_SIZE)?;
let base = shm.base(); let base = channel.data_base();
// device-type FIRST (so it's visible the moment magic is), neutral report, magic LAST. // device-type FIRST (so it's visible the moment magic is), pad index, neutral report,
// SAFETY: base points at SHM_SIZE writable bytes; OFF_DEVTYPE/OFF_INPUT are in range. // magic LAST.
// SAFETY: base points at SHM_SIZE writable bytes; the OFF_* offsets are in range.
unsafe { unsafe {
*base.add(OFF_DEVTYPE) = DEVTYPE_DUALSHOCK4; *base.add(OFF_DEVTYPE) = DEVTYPE_DUALSHOCK4;
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; DS4_INPUT_REPORT_LEN], { std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; DS4_INPUT_REPORT_LEN], {
let mut r = [0u8; DS4_INPUT_REPORT_LEN]; let mut r = [0u8; DS4_INPUT_REPORT_LEN];
serialize_state(&mut r, &DsState::neutral(), 0, 0); serialize_state(&mut r, &DsState::neutral(), 0, 0);
@@ -68,14 +71,18 @@ impl Ds4WinPad {
} }
}; };
let _sw = hsw.map(super::gamepad_raii::SwDevice::new); let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
// Bounded eager delivery — for the DS4 this is what closes the identity race: the driver
// must read `device_type = 1` from the delivered DATA section before hidclass asks it for
// descriptors, or the pad would enumerate with the (default) DualSense identity.
channel.deliver_eager(Duration::from_millis(1500));
Ok(Ds4WinPad { Ok(Ds4WinPad {
_sw, _sw,
shm, channel,
attach: super::gamepad_raii::DriverAttach::new( attach: super::gamepad_raii::DriverAttach::new(
"pf_dualshock4", "pf_dualshock4",
"pf_dualsense.inf", // one driver package serves both HID identities "pf_dualsense.inf", // one driver package serves both HID identities
"C:\\Users\\Public\\pfds-driver.log", "C:\\Users\\Public\\pfds-driver.log",
shm_name, boot_name,
instance_id, instance_id,
), ),
counter: 0, counter: 0,
@@ -92,29 +99,40 @@ impl Ds4WinPad {
serialize_state(&mut r, st, self.counter, self.ts); serialize_state(&mut r, st, self.counter, self.ts);
// SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64. // SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64.
unsafe { unsafe {
std::ptr::copy_nonoverlapping(r.as_ptr(), self.shm.base().add(OFF_INPUT), r.len()) std::ptr::copy_nonoverlapping(
r.as_ptr(),
self.channel.data_base().add(OFF_INPUT),
r.len(),
)
}; };
} }
/// Poll the section's output slot; parse a new `0x05` report (rumble / lightbar) into a /// Poll the section's output slot; parse a new `0x05` report (rumble / lightbar) into a
/// [`Ds4Feedback`]. Returns empty feedback if the driver hasn't published anything new. Also /// [`Ds4Feedback`]. Returns empty feedback if the driver hasn't published anything new. Also
/// feeds the driver-attach health watcher (the driver's ~125 Hz timer stamps `driver_proto`). /// ticks the sealed-channel delivery and feeds the driver-attach health watcher (the driver's
/// ~125 Hz timer stamps `driver_proto`).
fn service(&mut self) -> Ds4Feedback { fn service(&mut self) -> Ds4Feedback {
self.channel.pump();
let mut fb = Ds4Feedback::default(); let mut fb = Ds4Feedback::default();
// SAFETY: base points at SHM_SIZE bytes. // SAFETY: base points at SHM_SIZE bytes.
let proto = unsafe { let proto = unsafe {
std::ptr::read_unaligned(self.shm.base().add(OFF_DRIVER_PROTO) as *const u32) std::ptr::read_unaligned(self.channel.data_base().add(OFF_DRIVER_PROTO) as *const u32)
}; };
self.attach.observe(proto); self.attach.observe(proto);
// SAFETY: base points at SHM_SIZE bytes. // SAFETY: base points at SHM_SIZE bytes.
let seq = let seq = unsafe {
unsafe { std::ptr::read_unaligned(self.shm.base().add(OFF_OUT_SEQ) as *const u32) }; std::ptr::read_unaligned(self.channel.data_base().add(OFF_OUT_SEQ) as *const u32)
};
if seq != self.last_out_seq { if seq != self.last_out_seq {
self.last_out_seq = seq; self.last_out_seq = seq;
let mut out = [0u8; 64]; let mut out = [0u8; 64];
// SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section. // SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section.
unsafe { unsafe {
std::ptr::copy_nonoverlapping(self.shm.base().add(OFF_OUTPUT), out.as_mut_ptr(), 64) std::ptr::copy_nonoverlapping(
self.channel.data_base().add(OFF_OUTPUT),
out.as_mut_ptr(),
64,
)
}; };
parse_ds4_output(&out, &mut fb); parse_ds4_output(&out, &mut fb);
} }
@@ -1,14 +1,29 @@
//! Per-pad Windows resource RAII for the gamepad backends (DualSense / DualShock 4 / XUSB). //! Per-pad Windows resource RAII + the **sealed gamepad channel** broker (DualSense / DualShock 4 /
//! XUSB backends).
//! //!
//! Each virtual pad owns two OS resources: the named shared-memory section (+ its mapped view) the //! Each virtual pad owns three OS resources: the **unnamed** DATA section the `pf_dualsense`/`pf_xusb`
//! `pf_dualsense`/`pf_xusb` driver reads, and the `SwDeviceCreate`'d software devnode the driver loads //! driver works against (`XusbShm`/`PadShm`), the tiny **named** bootstrap mailbox
//! on. Before this module, all three backends hand-rolled the same `CreateFileMappingW` + //! (`pf_driver_proto::gamepad::PadBootstrap`) that hands the driver a duplicated handle to it, and the
//! `MapViewOfFile` and an identical `Drop` doing `SwDeviceClose` + `UnmapViewOfFile` + `CloseHandle` — //! `SwDeviceCreate`'d software devnode the driver loads on. [`Shm`] and [`SwDevice`] own the resources
//! easy to drift or leak on an error path. [`Shm`] and [`SwDevice`] own those resources with RAII, so a //! with RAII; [`PadChannel`] owns the two sections plus the delivery handshake.
//! backend just holds them and the cleanup (and ordering) happens by construction. //!
//! **Why the channel is sealed** (`design/gamepad-channel-sealing.md`): the DATA section used to be a
//! `Global\pf…-shm-<index>` named section with an SY+LS DACL, which let any *sibling LocalService*
//! process open it by name to read the live controller input or inject/forge input and rumble — the
//! same name-open vector the frame ring closed (`design/idd-push-security.md`). The DATA section is now
//! UNNAMED with a SYSTEM-only DACL and reaches the driver exclusively as a handle this host duplicated
//! into its WUDFHost (a duplicated handle carries the source's access, so no LS ACE is needed). The pad
//! drivers are UMDF HID minidrivers with **no control device** (hidclass owns the stack), so unlike the
//! frame channel there is no IOCTL to deliver the handle or learn the WUDFHost pid — hence the
//! late-bound [`PadBootstrap`] mailbox handshake, the one *named* object left. It carries only pids and
//! a handle VALUE (meaningless outside the target process), so tampering with it yields at worst a
//! gamepad DoS, never a read or an injection; the empirical floor from the frame work holds here too
//! (a LocalService token is DACL-denied `OpenProcess` on a UMDF WUDFHost for every access right).
use anyhow::{anyhow, Result}; use anyhow::{anyhow, bail, Context, Result};
use std::os::windows::io::{FromRawHandle, OwnedHandle}; use pf_driver_proto::gamepad::{PadBootstrap, BOOT_MAGIC, GAMEPAD_PROTO_VERSION};
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
use std::sync::atomic::{fence, AtomicU32, AtomicU64, Ordering};
use std::sync::OnceLock; use std::sync::OnceLock;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use windows::core::{w, HSTRING, PCWSTR}; use windows::core::{w, HSTRING, PCWSTR};
@@ -17,7 +32,10 @@ use windows::Win32::Devices::DeviceAndDriverInstallation::{
CM_PROB, CR_SUCCESS, DN_DRIVER_LOADED, DN_HAS_PROBLEM, DN_STARTED, CM_PROB, CR_SUCCESS, DN_DRIVER_LOADED, DN_HAS_PROBLEM, DN_STARTED,
}; };
use windows::Win32::Devices::Enumeration::Pnp::{SwDeviceClose, HSWDEVICE}; use windows::Win32::Devices::Enumeration::Pnp::{SwDeviceClose, HSWDEVICE};
use windows::Win32::Foundation::INVALID_HANDLE_VALUE; use windows::Win32::Foundation::{
DuplicateHandle, GetLastError, SetLastError, DUPLICATE_HANDLE_OPTIONS, ERROR_ALREADY_EXISTS,
HANDLE, INVALID_HANDLE_VALUE, WIN32_ERROR,
};
use windows::Win32::Security::Authorization::{ use windows::Win32::Security::Authorization::{
ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
}; };
@@ -26,54 +44,102 @@ use windows::Win32::System::Memory::{
CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS, CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS,
MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE, MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
}; };
use windows::Win32::System::Threading::{
GetCurrentProcess, OpenProcess, PROCESS_DUP_HANDLE, PROCESS_QUERY_LIMITED_INFORMATION,
};
/// A named, anonymous (pagefile-backed) shared section + its mapped read/write view. RAII: drop unmaps /// Least access the pad driver needs on the duplicated DATA section: it only MAPS it read/write, so
/// the view, then the [`OwnedHandle`] closes the section handle (in that order). Replaces the three /// `SECTION_MAP_READ | SECTION_MAP_WRITE` (== the driver's `FILE_MAP_RW`). Granted explicitly in
/// backends' hand-duplicated `CreateFileMappingW` + `MapViewOfFile` + manual `Drop`. /// [`PadChannel::deliver_to`] instead of `DUPLICATE_SAME_ACCESS` (least privilege for the sealed
/// /// section — the driver's handle then can't take ownership / change security / delete the object).
/// SDDL `D:(A;;GA;;;SY)(A;;GA;;;LS)`: GENERIC_ALL to **SYSTEM** (the host creates the section and const SECTION_MAP_RW: u32 = 0x0004 | 0x0002;
/// writes the live HID input report into it) and **LocalService** (the account the UMDF driver's
/// WUDFHost runs under, which reads it). The old SDDL granted **Everyone** (`WD`) — on the (mistaken) /// An anonymous (pagefile-backed) shared section + its mapped read/write view. RAII: drop unmaps the
/// assumption the driver needed a restricted token's broad access — letting any local user /// view, then the [`OwnedHandle`] closes the section handle (in that order). Created either
/// `OpenFileMapping` the section to inject controller input or tamper the trusted channel /// [unnamed](Self::create_unnamed) (the sealed DATA section — reachable only by handle duplication) or
/// (security-review 2026-06-28 #5). Verified on the RTX box (2026-06-29): the WUDFHost token is /// [named](Self::create_named) (the bootstrap mailbox the driver opens by name).
/// `S-1-5-19` (LocalService), SYSTEM integrity, with **zero restricted SIDs** — so scoping to SY+LS is
/// sufficient for the driver and excludes normal (medium-IL, non-service) user processes.
pub(super) struct Shm { pub(super) struct Shm {
/// Owns the section handle (closed on drop). Held only for ownership — never read after construction. /// Owns the section handle (closed on drop). Also the duplication source for the sealed channel —
_handle: OwnedHandle, /// see [`Shm::raw_handle`].
handle: OwnedHandle,
view: MEMORY_MAPPED_VIEW_ADDRESS, view: MEMORY_MAPPED_VIEW_ADDRESS,
} }
/// Build a `SECURITY_ATTRIBUTES` from an SDDL literal (`psd` is OS-allocated and leaked — acceptable
/// for the handful of pad channels a host creates; it must outlive the returned `SECURITY_ATTRIBUTES`).
fn sddl_sa(sddl: PCWSTR) -> Result<SECURITY_ATTRIBUTES> {
let mut psd = PSECURITY_DESCRIPTOR::default();
// SAFETY: the SDDL literal is valid; `psd` receives an OS-allocated descriptor (leaked — see above).
unsafe {
ConvertStringSecurityDescriptorToSecurityDescriptorW(
sddl,
SDDL_REVISION_1,
&mut psd,
None,
)?;
}
Ok(SECURITY_ATTRIBUTES {
nLength: core::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: psd.0,
bInheritHandle: false.into(),
})
}
impl Shm { impl Shm {
/// Create + zero a `size`-byte section named `name`, mapped read/write. The section handle is owned /// Create + zero an **unnamed** `size`-byte section, mapped read/write — the sealed DATA section.
/// immediately, so any failure below (or the returned `Shm`'s drop) closes it. /// SDDL `D:P(A;;GA;;;SY)` (SYSTEM-only, protected): with no name there is nothing to enumerate,
pub(super) fn create(name: &HSTRING, size: usize) -> Result<Shm> { /// open, or squat, and the driver reaches it through a duplicated handle, which carries the
let mut psd = PSECURITY_DESCRIPTOR::default(); /// source's access without re-checking the object DACL (the exact property the frame ring
// SAFETY: the SDDL literal is valid; `psd` receives an OS-allocated descriptor (freed at process /// validated on-glass — `design/idd-push-security.md`).
// exit — acceptable for a host-lifetime object). pub(super) fn create_unnamed(size: usize) -> Result<Shm> {
unsafe { let sa = sddl_sa(w!("D:P(A;;GA;;;SY)"))?;
ConvertStringSecurityDescriptorToSecurityDescriptorW( Self::create_inner(&sa, PCWSTR::null(), size).context("create unnamed gamepad DATA section")
w!("D:(A;;GA;;;SY)(A;;GA;;;LS)"), }
SDDL_REVISION_1,
&mut psd, /// Create + zero a **named** `size`-byte section, mapped read/write — the bootstrap mailbox. SDDL
None, /// `D:(A;;GA;;;SY)(A;;GA;;;LS)`: SYSTEM (this host) + LocalService (the driver's WUDFHost opens it
)?; /// by name). Safe to leave name-openable because it carries nothing exploitable (see the module
/// docs). **Squat-checked**: `Global\` names are creatable by any service holding
/// `SeCreateGlobalPrivilege` (LocalService has it), so if the name already exists —
/// `ERROR_ALREADY_EXISTS`, meaning `CreateFileMappingW` silently *opened* a pre-existing object we
/// don't control — we close and retry briefly (our own driver holds the name for microseconds per
/// poll tick), then fail loudly rather than run the handshake through an attacker-owned (or
/// another host instance's) mailbox.
pub(super) fn create_named(name: &HSTRING, size: usize) -> Result<Shm> {
let sa = sddl_sa(w!("D:(A;;GA;;;SY)(A;;GA;;;LS)"))?;
for attempt in 0..5 {
if attempt > 0 {
std::thread::sleep(Duration::from_millis(50));
}
// SAFETY: clearing the thread error slot so ERROR_ALREADY_EXISTS below is unambiguous.
unsafe { SetLastError(WIN32_ERROR(0)) };
let shm = Self::create_inner(&sa, PCWSTR(name.as_ptr()), size)
.with_context(|| format!("create gamepad bootstrap mailbox {name}"))?;
// SAFETY: read immediately after the create; windows-rs only touches the error slot on
// failure, so a success here preserves CreateFileMappingW's ALREADY_EXISTS signal.
if unsafe { GetLastError() } != ERROR_ALREADY_EXISTS {
return Ok(shm);
}
// `shm` drops here → unmap + close our handle to the foreign object, then retry.
} }
let sa = SECURITY_ATTRIBUTES { bail!(
nLength: core::mem::size_of::<SECURITY_ATTRIBUTES>() as u32, "bootstrap mailbox {name} already exists and stayed alive across retries — another \
lpSecurityDescriptor: psd.0, punktfunk-host instance is serving this pad index, or a local service is squatting the \
bInheritHandle: false.into(), name (gamepad DoS attempt?)"
}; );
// SAFETY: an anonymous (pagefile-backed) section of `size` bytes with the SDDL above. }
fn create_inner(sa: &SECURITY_ATTRIBUTES, name: PCWSTR, size: usize) -> Result<Shm> {
// SAFETY: an anonymous (pagefile-backed) section of `size` bytes with the caller's SDDL; the
// descriptor behind `sa` outlives this call (leaked by `sddl_sa`).
let map = unsafe { let map = unsafe {
CreateFileMappingW( CreateFileMappingW(
INVALID_HANDLE_VALUE, INVALID_HANDLE_VALUE,
Some(&sa), Some(sa),
PAGE_READWRITE, PAGE_READWRITE,
0, 0,
size as u32, size as u32,
PCWSTR(name.as_ptr()), name,
)? )?
}; };
// SAFETY: `map` is a fresh section handle we own; take ownership immediately so that the early // SAFETY: `map` is a fresh section handle we own; take ownership immediately so that the early
@@ -84,14 +150,11 @@ impl Shm {
let view = unsafe { MapViewOfFile(map, FILE_MAP_ALL_ACCESS, 0, 0, size) }; let view = unsafe { MapViewOfFile(map, FILE_MAP_ALL_ACCESS, 0, 0, size) };
if view.Value.is_null() { if view.Value.is_null() {
// `handle` drops here → closes the section. No view to unmap. // `handle` drops here → closes the section. No view to unmap.
return Err(anyhow!("MapViewOfFile failed for {name}")); return Err(anyhow!("MapViewOfFile failed"));
} }
// SAFETY: `view` points at `size` writable bytes (just mapped). // SAFETY: `view` points at `size` writable bytes (just mapped).
unsafe { core::ptr::write_bytes(view.Value as *mut u8, 0, size) }; unsafe { core::ptr::write_bytes(view.Value as *mut u8, 0, size) };
Ok(Shm { Ok(Shm { handle, view })
_handle: handle,
view,
})
} }
/// The mapped section's base pointer. Stable for the `Shm`'s lifetime (moving the `Shm` does not /// The mapped section's base pointer. Stable for the `Shm`'s lifetime (moving the `Shm` does not
@@ -99,11 +162,16 @@ impl Shm {
pub(super) fn base(&self) -> *mut u8 { pub(super) fn base(&self) -> *mut u8 {
self.view.Value as *mut u8 self.view.Value as *mut u8
} }
/// The section handle as a borrowed `HANDLE` (the sealed channel's duplication source).
fn raw_handle(&self) -> HANDLE {
HANDLE(self.handle.as_raw_handle())
}
} }
impl Drop for Shm { impl Drop for Shm {
fn drop(&mut self) { fn drop(&mut self) {
// SAFETY: `view` came from `MapViewOfFile`; unmap it BEFORE the `_handle` field closes the // SAFETY: `view` came from `MapViewOfFile`; unmap it BEFORE the `handle` field closes the
// section (struct fields drop only after this `Drop::drop` returns). // section (struct fields drop only after this `Drop::drop` returns).
unsafe { unsafe {
let _ = UnmapViewOfFile(self.view); let _ = UnmapViewOfFile(self.view);
@@ -111,6 +179,230 @@ impl Drop for Shm {
} }
} }
// ── The sealed-channel bootstrap broker ─────────────────────────────────────────────────────────
/// Global delivery sequence for [`PadBootstrap::handle_seq`] — host-wide monotonic and never 0, so two
/// consecutive pads on the same index can't hand the (persistent, out-of-band-devnode) driver the same
/// seq twice. Starts at 1.
static BOOT_SEQ: AtomicU32 = AtomicU32::new(1);
/// Hard cap on delivery attempts per pad: each attempt duplicates a handle into a WUDFHost, so a
/// tampered mailbox flapping `driver_pid` must not mint unbounded remote handles (DoS containment).
/// A legitimate pad needs exactly one (a driver restart within one pad lifetime is not a thing —
/// the WUDFHost dies with the devnode).
const MAX_DELIVERY_ATTEMPTS: u32 = 16;
/// One pad's sealed host↔driver channel: the unnamed DATA section (the real `XusbShm`/`PadShm`), the
/// named bootstrap mailbox, and the delivery state machine ([`Self::pump`]) that hands the driver's
/// WUDFHost a duplicated DATA handle once it publishes its pid. Owns both sections (RAII teardown —
/// dropping the channel closes the mailbox, whose *name* then disappears, which is how a persistent
/// (out-of-band-devnode) driver detects the host is gone).
pub(super) struct PadChannel {
data: Shm,
boot: Shm,
boot_name: String,
/// Last `driver_pid` acted on (delivered or rejected) — never retry the same value, so a failed
/// verify can't be spun into a hot loop by a static mailbox.
last_seen_pid: u32,
attempts: u32,
delivered: bool,
warned_proto: bool,
warned_cap: bool,
}
impl PadChannel {
/// Create the unnamed DATA section (`data_size` bytes, zeroed — the caller stamps its layout and
/// magic) plus the named bootstrap mailbox, stamped `host_proto` first and `BOOT_MAGIC` last so a
/// driver only trusts a fully-initialized mailbox.
pub(super) fn create(boot_name: String, data_size: usize) -> Result<PadChannel> {
let data = Shm::create_unnamed(data_size)?;
let boot = Shm::create_named(
&HSTRING::from(boot_name.as_str()),
core::mem::size_of::<PadBootstrap>(),
)?;
let base = boot.base();
// SAFETY: `base` is the live, page-aligned mailbox view (>= size_of::<PadBootstrap>()); the
// field offsets are pinned by the proto's asserts and naturally aligned, so the atomic views
// are valid. `host_proto` is published BEFORE `magic` (Release) — a driver that observes the
// magic (Acquire) sees the version.
unsafe {
(*(base.add(core::mem::offset_of!(PadBootstrap, host_proto)) as *const AtomicU32))
.store(GAMEPAD_PROTO_VERSION, Ordering::Relaxed);
fence(Ordering::Release);
(*(base.add(core::mem::offset_of!(PadBootstrap, magic)) as *const AtomicU32))
.store(BOOT_MAGIC, Ordering::Release);
}
Ok(PadChannel {
data,
boot,
boot_name,
last_seen_pid: 0,
attempts: 0,
delivered: false,
warned_proto: false,
warned_cap: false,
})
}
/// The DATA section's mapped base (the host side of `XusbShm`/`PadShm`).
pub(super) fn data_base(&self) -> *mut u8 {
self.data.base()
}
/// The bootstrap mailbox name (log labelling).
pub(super) fn boot_name(&self) -> &str {
&self.boot_name
}
/// Atomic `u32` load from a mailbox field.
fn boot_load(&self, off: usize) -> u32 {
// SAFETY: the mailbox view is live (owned by `self.boot`), page-aligned, and every
// `PadBootstrap` u32 field offset is 4-aligned (proto asserts), so the atomic view is valid;
// no reference into the shared region outlives the load.
unsafe { (*(self.boot.base().add(off) as *const AtomicU32)).load(Ordering::Acquire) }
}
/// One tick of the delivery state machine — called from the pad's regular service pump (≤4 ms
/// cadence) and from [`Self::deliver_eager`]. Cheap when idle: two atomic loads.
pub(super) fn pump(&mut self) {
// Version diagnostics: the driver writes its own proto version even when it refuses to
// publish a pid (host/driver mismatch), so the operator sees WHY the pad never attaches.
let drv_proto = self.boot_load(core::mem::offset_of!(PadBootstrap, driver_proto));
if drv_proto != 0 && drv_proto != GAMEPAD_PROTO_VERSION && !self.warned_proto {
self.warned_proto = true;
tracing::warn!(
mailbox = %self.boot_name,
driver_proto = drv_proto,
host_proto = GAMEPAD_PROTO_VERSION,
"gamepad driver/host protocol mismatch on the bootstrap mailbox — update the \
drivers: punktfunk-host.exe driver install --gamepad"
);
}
let pid = self.boot_load(core::mem::offset_of!(PadBootstrap, driver_pid));
if pid == 0 || pid == self.last_seen_pid {
return;
}
self.last_seen_pid = pid;
if self.attempts >= MAX_DELIVERY_ATTEMPTS {
if !self.warned_cap {
self.warned_cap = true;
tracing::warn!(
mailbox = %self.boot_name,
attempts = self.attempts,
"gamepad channel delivery cap reached — the bootstrap mailbox keeps changing \
its driver pid (tampering?); no further handles will be duplicated"
);
}
return;
}
self.attempts += 1;
match self.deliver_to(pid) {
Ok(seq) => {
self.delivered = true;
tracing::info!(
mailbox = %self.boot_name,
wudf_pid = pid,
seq,
"sealed gamepad channel delivered (DATA handle duplicated into the driver's \
WUDFHost)"
);
}
Err(e) => {
tracing::warn!(
mailbox = %self.boot_name,
pid,
error = %format!("{e:#}"),
"sealed gamepad channel delivery failed — will retry when the mailbox reports \
a different driver pid"
);
}
}
}
/// Duplicate the DATA section into `pid`'s handle table (after verifying it is a genuine
/// WUDFHost) and publish the handle value + owning pid, bumping `handle_seq` LAST. The driver
/// adopts the handle by consuming the delivery; an unconsumed duplicate dies with the target
/// process (nothing to reap — there is no fallible step after the duplication).
fn deliver_to(&self, pid: u32) -> Result<u32> {
// SAFETY: plain FFI; the handle (checked by `?`) is owned solely here and moved into the
// `OwnedHandle` (single owner, closes on drop); `verify_is_wudfhost` borrows it for the
// synchronous check and forms no lasting alias.
let process = unsafe {
let h = OpenProcess(
PROCESS_DUP_HANDLE | PROCESS_QUERY_LIMITED_INFORMATION,
false,
pid,
)
.context("OpenProcess(PROCESS_DUP_HANDLE) on the mailbox-reported pid")?;
let process = OwnedHandle::from_raw_handle(h.0 as _);
crate::capture::idd_push::verify_is_wudfhost(
HANDLE(process.as_raw_handle()),
pid,
"gamepad-channel",
)?;
process
};
let mut remote = HANDLE::default();
// SAFETY: `self.data.raw_handle()` is the live section handle this channel owns;
// `process` is the live PROCESS_DUP_HANDLE target; `&mut remote` is a valid out-param.
// Least privilege: the pad driver only MAPS the DATA section read/write (its `FILE_MAP_RW` =
// `SECTION_MAP_READ | SECTION_MAP_WRITE`), so grant exactly that instead of copying our
// full-access creator handle via `DUPLICATE_SAME_ACCESS` (Chen: don't over-grant unnamed
// shared objects — a compromised driver's handle then can't `WRITE_DAC`/`DELETE` the section).
unsafe {
DuplicateHandle(
GetCurrentProcess(),
self.data.raw_handle(),
HANDLE(process.as_raw_handle()),
&mut remote,
SECTION_MAP_RW,
false,
DUPLICATE_HANDLE_OPTIONS(0),
)
.context("DuplicateHandle(gamepad DATA section) into the driver's WUDFHost")?;
}
let value = remote.0 as usize as u64;
let base = self.boot.base();
let seq = BOOT_SEQ.fetch_add(1, Ordering::Relaxed);
// SAFETY: live, page-aligned mailbox view; `data_handle` is 8-aligned and `handle_pid`/
// `handle_seq` 4-aligned (proto asserts). The handle value + owning pid are published BEFORE
// the seq (Release) — a driver that observes the new seq (Acquire) sees a complete delivery.
unsafe {
(*(base.add(core::mem::offset_of!(PadBootstrap, data_handle)) as *const AtomicU64))
.store(value, Ordering::Relaxed);
(*(base.add(core::mem::offset_of!(PadBootstrap, handle_pid)) as *const AtomicU32))
.store(pid, Ordering::Relaxed);
fence(Ordering::Release);
(*(base.add(core::mem::offset_of!(PadBootstrap, handle_seq)) as *const AtomicU32))
.store(seq, Ordering::Release);
}
Ok(seq)
}
/// Bounded wait at pad-open: pump until the mailbox produces a driver pid we act on (delivered or
/// rejected) or `timeout` passes. Closes the identity race for the DualShock 4 (the driver reads
/// `device_type` from the DATA section when hidclass asks for descriptors — the channel should be
/// attached by then); the regular service pump takes over afterwards either way.
pub(super) fn deliver_eager(&mut self, timeout: Duration) {
let deadline = Instant::now() + timeout;
loop {
self.pump();
if self.last_seen_pid != 0 || Instant::now() >= deadline {
if !self.delivered {
tracing::debug!(
mailbox = %self.boot_name,
"eager gamepad-channel delivery window passed without an attach — the \
service pump keeps polling (driver-attach diagnosis follows if it stays \
silent)"
);
}
return;
}
std::thread::sleep(Duration::from_millis(10));
}
}
}
/// A `SwDeviceCreate`'d software devnode; drop removes it via `SwDeviceClose`. Replaces the manual /// A `SwDeviceCreate`'d software devnode; drop removes it via `SwDeviceClose`. Replaces the manual
/// `SwDeviceClose` each backend used to call in its `Drop`. /// `SwDeviceClose` each backend used to call in its `Drop`.
pub(super) struct SwDevice(HSWDEVICE); pub(super) struct SwDevice(HSWDEVICE);
@@ -151,7 +443,7 @@ pub(super) struct DriverAttach {
inf: &'static str, inf: &'static str,
/// The driver's own debug log, referenced in the diagnosis line. /// The driver's own debug log, referenced in the diagnosis line.
driver_log: &'static str, driver_log: &'static str,
/// Section name, for log lines. /// Bootstrap-mailbox name, for log lines (the DATA section is unnamed).
shm_name: String, shm_name: String,
/// PnP instance id of the SwDeviceCreate'd devnode (`None` on the out-of-band fallback path). /// PnP instance id of the SwDeviceCreate'd devnode (`None` on the out-of-band fallback path).
instance_id: Option<String>, instance_id: Option<String>,
@@ -241,8 +533,8 @@ impl DriverAttach {
devnode = %devnode, devnode = %devnode,
driver_log = self.driver_log, driver_log = self.driver_log,
"gamepad driver has not attached to the shared section — the virtual pad exists but no \ "gamepad driver has not attached to the shared section — the virtual pad exists but no \
driver is serving it (games will not see it); an old (pre-health) driver also reads as \ driver is serving it (games will not see it); an old (pre-sealed-channel) driver also \
not-attached: update with punktfunk-host.exe driver install --gamepad" reads as not-attached: update with punktfunk-host.exe driver install --gamepad"
); );
} }
} }
@@ -1,23 +1,23 @@
//! Windows virtual Xbox 360 gamepad via the punktfunk **XUSB companion** UMDF driver //! Windows virtual Xbox 360 gamepad via the punktfunk **XUSB companion** UMDF driver
//! (`packaging/windows/xusb-driver`) — the in-tree replacement for ViGEmBus. One virtual Xbox 360 //! (`packaging/windows/drivers/pf-xusb`) — the in-tree replacement for ViGEmBus. One virtual Xbox 360
//! controller per client pad index, visible to classic **XInput** (`XInputGetState`) with no kernel //! controller per client pad index, visible to classic **XInput** (`XInputGetState`) with no kernel
//! bus driver: each pad `SwDeviceCreate`s a `pf_xusb_<index>` devnode (the driver loads on it and //! bus driver: each pad `SwDeviceCreate`s a `pf_xusb_<index>` devnode (the driver loads on it and
//! registers `GUID_DEVINTERFACE_XUSB`) and the host pushes the XInput state into the shared section //! registers `GUID_DEVINTERFACE_XUSB`) and the host pushes the XInput state into an **unnamed** shared
//! `Global\pfxusb-shm-<index>`. GameStream/Moonlight already speak the XInput conventions (low-16 //! DATA section the driver reaches over the **sealed channel** ([`PadChannel`] — handle duplicated
//! button bits, sticks 32768..32767 +Y up, triggers 0..255), so the state copy is ~1:1. //! into its WUDFHost, bootstrapped via `Global\pfxusb-boot-<index>`; see
//! `design/gamepad-channel-sealing.md`). GameStream/Moonlight already speak the XInput conventions
//! (low-16 button bits, sticks 32768..32767 +Y up, triggers 0..255), so the state copy is ~1:1.
//! //!
//! Rumble flows back the other way: a game writes force-feedback via `XInputSetState`, the driver //! Rumble flows back the other way: a game writes force-feedback via `XInputSetState`, the driver
//! parses the `SET_STATE` packet into the shared section, and [`GamepadManager::pump_rumble`] relays //! parses the `SET_STATE` packet into the shared section, and [`GamepadManager::pump_rumble`] relays
//! level changes to the client (the universal 0xCA plane), mirroring the Linux `EV_FF` read path. //! level changes to the client (the universal 0xCA plane), mirroring the Linux `EV_FF` read path.
//!
//! NB: the driver currently maps `Global\pfxusb-shm-0` (hardcoded), so a single pad (index 0) is
//! fully correct; mixed multi-pad needs the driver to read its own index first (same limitation as
//! the DualSense backend).
use super::gamepad_raii::PadChannel;
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use std::ffi::c_void; use std::ffi::c_void;
use windows::core::{w, GUID, HRESULT, HSTRING, PCWSTR}; use std::time::Duration;
use windows::core::{w, GUID, HRESULT, PCWSTR};
use windows::Win32::Devices::Enumeration::Pnp::{ use windows::Win32::Devices::Enumeration::Pnp::{
SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO, SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO,
}; };
@@ -41,6 +41,7 @@ const OFF_RY: usize = core::mem::offset_of!(XusbShm, thumb_ry);
const OFF_RUMBLE_SEQ: usize = core::mem::offset_of!(XusbShm, rumble_seq); const OFF_RUMBLE_SEQ: usize = core::mem::offset_of!(XusbShm, rumble_seq);
const OFF_RUMBLE: usize = core::mem::offset_of!(XusbShm, rumble_large); // large @28, small @29 const OFF_RUMBLE: usize = core::mem::offset_of!(XusbShm, rumble_large); // large @28, small @29
const OFF_DRIVER_PROTO: usize = core::mem::offset_of!(XusbShm, driver_proto); const OFF_DRIVER_PROTO: usize = core::mem::offset_of!(XusbShm, driver_proto);
const OFF_PAD_INDEX: usize = core::mem::offset_of!(XusbShm, pad_index);
/// Context for the `SwDeviceCreate` completion callback: an event to signal, the HRESULT it reports, /// Context for the `SwDeviceCreate` completion callback: an event to signal, the HRESULT it reports,
/// and the PnP instance id PnP assigned (captured for devnode health diagnostics). /// and the PnP instance id PnP assigned (captured for devnode health diagnostics).
@@ -100,7 +101,7 @@ fn create_swdevice(index: u8) -> Result<(HSWDEVICE, Option<String>)> {
.encode_utf16() .encode_utf16()
.chain(std::iter::once(0)) .chain(std::iter::once(0))
.collect(); .collect();
// The pad index, stamped into the device Location — the driver reads it to map `pfxusb-shm-<index>` // The pad index, stamped into the device Location — the driver reads it to poll `pfxusb-boot-<index>`
// (multi-pad). The buffer must outlive the SwDeviceCreate call (it does; we wait on the event). // (multi-pad). The buffer must outlive the SwDeviceCreate call (it does; we wait on the event).
let loc: Vec<u16> = format!("{index}") let loc: Vec<u16> = format!("{index}")
.encode_utf16() .encode_utf16()
@@ -171,12 +172,13 @@ fn create_swdevice(index: u8) -> Result<(HSWDEVICE, Option<String>)> {
Ok((hsw, ctx.instance_id())) Ok((hsw, ctx.instance_id()))
} }
/// A single virtual Xbox 360 pad: the `pf_xusb_<index>` devnode plus the mapped shared section. /// A single virtual Xbox 360 pad: the `pf_xusb_<index>` devnode plus the sealed shared-memory channel.
struct XusbWinPad { struct XusbWinPad {
/// Owns the `pf_xusb_<index>` devnode (dropped → `SwDeviceClose`). `None` if `SwDeviceCreate` failed. /// Owns the `pf_xusb_<index>` devnode (dropped → `SwDeviceClose`). `None` if `SwDeviceCreate` failed.
_sw: Option<super::gamepad_raii::SwDevice>, _sw: Option<super::gamepad_raii::SwDevice>,
/// Owns `Global\pfxusb-shm-<index>` (the section + its mapped view; drop unmaps + closes). /// The sealed channel: the unnamed DATA section (the `XusbShm`) + the bootstrap mailbox + the
shm: super::gamepad_raii::Shm, /// handle-delivery state machine (drop closes both sections).
channel: PadChannel,
/// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis. /// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis.
attach: super::gamepad_raii::DriverAttach, attach: super::gamepad_raii::DriverAttach,
packet: u32, packet: u32,
@@ -184,17 +186,18 @@ struct XusbWinPad {
} }
impl XusbWinPad { impl XusbWinPad {
/// Create + map `Global\pfxusb-shm-<index>`, stamp the magic, then spawn the devnode. /// Create the sealed channel (unnamed DATA section + `Global\pfxusb-boot-<index>` mailbox), stamp
/// the pad index then the magic LAST, spawn the devnode, and eagerly deliver the DATA handle once
/// the driver publishes its pid.
fn open(index: u8) -> Result<XusbWinPad> { fn open(index: u8) -> Result<XusbWinPad> {
// Permissive-DACL named section the WUDFHost (whatever account) can open; `Shm` owns the let boot_name = pf_driver_proto::gamepad::xusb_boot_name(index);
// section handle + its mapped view (zero-filled) and unmaps/closes on drop. let mut channel = PadChannel::create(boot_name.clone(), SHM_SIZE)?;
let shm_name = pf_driver_proto::gamepad::xusb_shm_name(index); let base = channel.data_base();
let shm = super::gamepad_raii::Shm::create(&HSTRING::from(shm_name.as_str()), SHM_SIZE)?; // The section arrives zeroed; stamp the pad index (the driver validates it against its own
let base = shm.base(); // devnode index on attach) then the magic LAST (the driver only accepts it once magic is set).
// Zero the section then stamp the magic LAST (the driver only accepts it once magic is set). // SAFETY: base points at SHM_SIZE writable bytes; OFF_PAD_INDEX is in range.
// SAFETY: base points at SHM_SIZE writable bytes.
unsafe { unsafe {
std::ptr::write_bytes(base, 0, SHM_SIZE); std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC); std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
} }
let (hsw, instance_id) = match create_swdevice(index) { let (hsw, instance_id) = match create_swdevice(index) {
@@ -205,14 +208,18 @@ impl XusbWinPad {
} }
}; };
let _sw = hsw.map(super::gamepad_raii::SwDevice::new); let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
// Bounded eager delivery: the driver's EvtDeviceAdd publishes its pid right away; handing it
// the DATA handle before we return means the pad is live for the game's first XInput poll.
// On a missing/old driver this waits out the window once and the service pump takes over.
channel.deliver_eager(Duration::from_millis(1500));
Ok(XusbWinPad { Ok(XusbWinPad {
_sw, _sw,
shm, channel,
attach: super::gamepad_raii::DriverAttach::new( attach: super::gamepad_raii::DriverAttach::new(
"pf_xusb", "pf_xusb",
"pf_xusb.inf", "pf_xusb.inf",
"C:\\Users\\Public\\pfxusb-driver.log", "C:\\Users\\Public\\pfxusb-driver.log",
shm_name, boot_name,
instance_id, instance_id,
), ),
packet: 0, packet: 0,
@@ -225,7 +232,7 @@ impl XusbWinPad {
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn write_state(&mut self, buttons: u16, lt: u8, rt: u8, lx: i16, ly: i16, rx: i16, ry: i16) { fn write_state(&mut self, buttons: u16, lt: u8, rt: u8, lx: i16, ly: i16, rx: i16, ry: i16) {
self.packet = self.packet.wrapping_add(1); self.packet = self.packet.wrapping_add(1);
let base = self.shm.base(); let base = self.channel.data_base();
// SAFETY: `base` is the start of the mapped section (`SHM_SIZE` bytes, owned by `Shm`); every // SAFETY: `base` is the start of the mapped section (`SHM_SIZE` bytes, owned by `Shm`); every
// `OFF_*` is a fixed in-range offset into it and `write_unaligned` handles the unaligned field // `OFF_*` is a fixed in-range offset into it and `write_unaligned` handles the unaligned field
// writes. Single owner (`&mut self`), so no concurrent writer races these stores. // writes. Single owner (`&mut self`), so no concurrent writer races these stores.
@@ -242,10 +249,12 @@ impl XusbWinPad {
} }
/// Poll the section for a game's rumble (the driver bumps `rumble_seq` on each SET_STATE). Returns /// Poll the section for a game's rumble (the driver bumps `rumble_seq` on each SET_STATE). Returns
/// `(large, small)` motor levels (0..=255) when a new one arrived. Also feeds the driver-attach /// `(large, small)` motor levels (0..=255) when a new one arrived. Also ticks the sealed-channel
/// health watcher (the driver stamps `driver_proto` at device add + on every serviced IOCTL). /// delivery (a late-binding driver gets its handle here) and feeds the driver-attach health
/// watcher (the driver stamps `driver_proto` once it maps the delivered section + per IOCTL).
fn service(&mut self) -> Option<(u8, u8)> { fn service(&mut self) -> Option<(u8, u8)> {
let base = self.shm.base(); self.channel.pump();
let base = self.channel.data_base();
// SAFETY: base points at SHM_SIZE bytes. // SAFETY: base points at SHM_SIZE bytes.
let proto = unsafe { std::ptr::read_unaligned(base.add(OFF_DRIVER_PROTO) as *const u32) }; let proto = unsafe { std::ptr::read_unaligned(base.add(OFF_DRIVER_PROTO) as *const u32) };
self.attach.observe(proto); self.attach.observe(proto);
+78 -10
View File
@@ -8,6 +8,10 @@
//! //!
//! The ring keeps the *newest* [`CAPACITY`] entries (a log tail — unlike the stats recorder, //! The ring keeps the *newest* [`CAPACITY`] entries (a log tail — unlike the stats recorder,
//! which keeps the head of a capture). Readers poll with an `after` sequence cursor. //! which keeps the head of a capture). Readers poll with an `after` sequence cursor.
//!
//! `log`-crate events (arriving via the tracing-log bridge) are normalized to their real module
//! path, and known-chatty third-party targets ([`NOISY_DEBUG_TARGETS`]) are demoted to
//! INFO-and-up so ambient LAN noise can't evict the tail the ring exists to preserve.
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::VecDeque; use std::collections::VecDeque;
@@ -121,6 +125,21 @@ pub fn ring() -> &'static LogRing {
RING.get_or_init(LogRing::new) RING.get_or_init(LogRing::new)
} }
/// Targets whose DEBUG/TRACE output is steady-state per-packet chatter, not diagnostics — left
/// in, they evict the entire ring tail (mdns-sd DEBUG-logs every multicast packet it can't parse,
/// so one chatty AirPlay/HomePod device on the LAN floods thousands of entries per hour). The
/// ring keeps their INFO-and-up; stderr under `RUST_LOG` is unaffected. Prefix-matched on module
/// path boundaries.
const NOISY_DEBUG_TARGETS: &[&str] = &["mdns_sd"];
fn is_noisy_debug(target: &str) -> bool {
NOISY_DEBUG_TARGETS.iter().any(|t| {
target
.strip_prefix(t)
.is_some_and(|rest| rest.is_empty() || rest.starts_with("::"))
})
}
/// The tee: a `tracing_subscriber` layer pushing every event into [`ring`]. Install with a /// The tee: a `tracing_subscriber` layer pushing every event into [`ring`]. Install with a
/// per-layer `LevelFilter::DEBUG` so the ring sees DEBUG even when `RUST_LOG` keeps stderr at /// per-layer `LevelFilter::DEBUG` so the ring sees DEBUG even when `RUST_LOG` keeps stderr at
/// `info` (remote debugging must not require a restart with a different env). /// `info` (remote debugging must not require a restart with a different env).
@@ -132,7 +151,15 @@ impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for RingLayer {
event: &tracing::Event<'_>, event: &tracing::Event<'_>,
_ctx: tracing_subscriber::layer::Context<'_, S>, _ctx: tracing_subscriber::layer::Context<'_, S>,
) { ) {
let meta = event.metadata(); // Events from `log`-crate dependencies arrive through the tracing-log bridge under the
// shim target "log"; normalize back to the record's real module path so the console's
// target column and the noise gate below see `mdns_sd::…`.
use tracing_log::NormalizeEvent;
let normalized = event.normalized_metadata();
let meta = normalized.as_ref().unwrap_or_else(|| event.metadata());
if *meta.level() > tracing::Level::INFO && is_noisy_debug(meta.target()) {
return;
}
let mut fields = FieldFmt::default(); let mut fields = FieldFmt::default();
event.record(&mut fields); event.record(&mut fields);
ring().push(meta.level(), meta.target(), fields.finish()); ring().push(meta.level(), meta.target(), fields.finish());
@@ -152,7 +179,9 @@ impl tracing::field::Visit for FieldFmt {
use std::fmt::Write; use std::fmt::Write;
if field.name() == "message" { if field.name() == "message" {
let _ = write!(self.msg, "{value:?}"); let _ = write!(self.msg, "{value:?}");
} else { } else if !field.name().starts_with("log.") {
// `log.target`/`log.file`/… are tracing-log bridge bookkeeping (already surfaced via
// the normalized target), same suppression as the stderr fmt layer.
let _ = write!(self.fields, " {}={:?}", field.name(), value); let _ = write!(self.fields, " {}={:?}", field.name(), value);
} }
} }
@@ -161,7 +190,7 @@ impl tracing::field::Visit for FieldFmt {
use std::fmt::Write; use std::fmt::Write;
if field.name() == "message" { if field.name() == "message" {
self.msg.push_str(value); self.msg.push_str(value);
} else { } else if !field.name().starts_with("log.") {
let _ = write!(self.fields, " {}={value}", field.name()); let _ = write!(self.fields, " {}={value}", field.name());
} }
} }
@@ -236,20 +265,24 @@ mod tests {
assert_eq!(head.entries.first().map(|e| e.seq), Some(page.next + 1)); assert_eq!(head.entries.first().map(|e| e.seq), Some(page.next + 1));
} }
#[test] /// The singleton ring is process-wide — tests find its current tail first (parallel tests
fn layer_captures_events_into_the_singleton_ring() { /// may interleave, so they only assert on THEIR events appearing after it).
use tracing_subscriber::layer::SubscriberExt; fn tail_seq() -> u64 {
// The singleton ring is process-wide — find its current tail first (parallel tests may
// interleave, so only assert on OUR event appearing after it).
let mut cur = 0; let mut cur = 0;
loop { loop {
let page = ring().since(cur, MAX_PAGE); let page = ring().since(cur, MAX_PAGE);
if page.entries.is_empty() { if page.entries.is_empty() {
break; return cur;
} }
cur = page.next; cur = page.next;
} }
}
#[test]
fn layer_captures_events_into_the_singleton_ring() {
use tracing_subscriber::layer::SubscriberExt;
let cur = tail_seq();
let subscriber = tracing_subscriber::registry().with(RingLayer); let subscriber = tracing_subscriber::registry().with(RingLayer);
tracing::subscriber::with_default(subscriber, || { tracing::subscriber::with_default(subscriber, || {
@@ -272,6 +305,41 @@ mod tests {
assert!(hit.ts_ms > 0); assert!(hit.ts_ms > 0);
} }
#[test]
fn log_bridge_events_normalize_target_and_noisy_debug_is_dropped() {
use tracing_subscriber::layer::SubscriberExt;
// Route `log` records into tracing (what SubscriberInitExt::init does in main). Global,
// so tolerate a prior install; max_level explicit so debug! records reach the bridge.
let _ = tracing_log::LogTracer::init();
log::set_max_level(log::LevelFilter::Trace);
let cur = tail_seq();
let subscriber = tracing_subscriber::registry().with(RingLayer);
tracing::subscriber::with_default(subscriber, || {
log::debug!(target: "mdns_sd::service_daemon", "Invalid incoming DNS message: flood");
log::warn!(target: "mdns_sd::service_daemon", "a real mdns problem");
log::debug!(target: "mdns_sdx", "not actually mdns-sd");
});
let page = ring().since(cur, MAX_PAGE);
assert!(
!page.entries.iter().any(|e| e.msg.contains("flood")),
"noisy-target DEBUG must not reach the ring"
);
let warn = page
.entries
.iter()
.find(|e| e.msg.contains("a real mdns problem"))
.expect("noisy-target WARN kept");
// Normalized off the bridge's "log" shim, and the log.* bookkeeping fields are hidden.
assert_eq!(warn.target, "mdns_sd::service_daemon");
assert!(!warn.msg.contains("log.target"), "msg: {}", warn.msg);
// Prefix match respects module-path boundaries.
assert!(page.entries.iter().any(|e| e.target == "mdns_sdx"));
}
#[test] #[test]
fn message_truncation_keeps_char_boundary() { fn message_truncation_keeps_char_boundary() {
let f = FieldFmt { let f = FieldFmt {
+1 -1
View File
@@ -739,7 +739,7 @@ NOTES:
"\nWINDOWS SERVICE (end-user deployment — replaces a manual launch):\n\ "\nWINDOWS SERVICE (end-user deployment — replaces a manual launch):\n\
\x20 punktfunk-host service install register an auto-start SYSTEM service + firewall rules\n\ \x20 punktfunk-host service install register an auto-start SYSTEM service + firewall rules\n\
\x20 punktfunk-host service uninstall remove the service + firewall rules\n\ \x20 punktfunk-host service uninstall remove the service + firewall rules\n\
\x20 punktfunk-host service start|stop|status\n\ \x20 punktfunk-host service start|stop|restart|status\n\
\x20 config: %ProgramData%\\punktfunk\\host.env\n\ \x20 config: %ProgramData%\\punktfunk\\host.env\n\
\nWINDOWS DIAGNOSTICS:\n\ \nWINDOWS DIAGNOSTICS:\n\
\x20 punktfunk-host hdr-p010-selftest GPU colour check for the PUNKTFUNK_HDR_SHADER_P010 path\n\ \x20 punktfunk-host hdr-p010-selftest GPU colour check for the PUNKTFUNK_HDR_SHADER_P010 path\n\
+141 -1
View File
@@ -157,6 +157,7 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
.routes(routes!(list_gpus)) .routes(routes!(list_gpus))
.routes(routes!(set_gpu_preference)) .routes(routes!(set_gpu_preference))
.routes(routes!(get_status)) .routes(routes!(get_status))
.routes(routes!(get_local_summary))
.routes(routes!(list_paired_clients)) .routes(routes!(list_paired_clients))
.routes(routes!(unpair_client)) .routes(routes!(unpair_client))
.routes(routes!(get_pairing_status)) .routes(routes!(get_pairing_status))
@@ -353,6 +354,30 @@ struct StreamInfo {
codec: ApiCodec, codec: ApiCodec,
} }
/// Non-sensitive host status for the local tray icon: counts and booleans only — no PIN values,
/// no fingerprints, no device names. Served unauthenticated to LOOPBACK peers only (see
/// `require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on Windows, so the
/// per-user tray process cannot authenticate — this narrow read-only route is its status source.
#[derive(Serialize, ToSchema)]
struct LocalSummary {
/// Host version (mirrors `/health`).
version: String,
/// True while the video stream thread is running.
video_streaming: bool,
/// True while the audio stream thread is running.
audio_streaming: bool,
/// The active launch session (set by Moonlight's `/launch`, cleared on cancel/stop).
session: Option<SessionInfo>,
/// Number of pinned (paired) GameStream client certificates.
paired_clients: u32,
/// Number of paired native (punktfunk/1) devices.
native_paired_clients: u32,
/// True while a GameStream pairing handshake is parked waiting for the user's PIN.
pin_pending: bool,
/// Native pairing knocks awaiting the operator's approval (count only).
pending_approvals: u32,
}
/// A paired (certificate-pinned) Moonlight client. /// A paired (certificate-pinned) Moonlight client.
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
struct PairedClient { struct PairedClient {
@@ -488,13 +513,34 @@ where
/// Auth gate on the `/api/v1` routes: a paired client cert (mTLS, from anywhere) or the bearer token /// Auth gate on the `/api/v1` routes: a paired client cert (mTLS, from anywhere) or the bearer token
/// (from a **loopback** peer only) — required always (the host runs with a token by construction). /// (from a **loopback** peer only) — required always (the host runs with a token by construction).
/// `/api/v1/health` stays open for probes. The cert path authorizes only the read-only allowlist /// `/api/v1/health` stays open for probes; `/api/v1/local/summary` is open to loopback peers only
/// (the tray icon's status source). The cert path authorizes only the read-only allowlist
/// ([`cert_may_access`]); the bearer path authorizes the full admin surface and is therefore confined /// ([`cert_may_access`]); the bearer path authorizes the full admin surface and is therefore confined
/// to loopback so it is never LAN-exposed even when the listener binds all interfaces by default. /// to loopback so it is never LAN-exposed even when the listener binds all interfaces by default.
async fn require_auth(State(st): State<Arc<MgmtState>>, req: Request, next: Next) -> Response { async fn require_auth(State(st): State<Arc<MgmtState>>, req: Request, next: Next) -> Response {
if req.uri().path() == "/api/v1/health" { if req.uri().path() == "/api/v1/health" {
return next.run(req).await; // liveness probe is always open return next.run(req).await; // liveness probe is always open
} }
// The tray icon's status source: non-sensitive counts/booleans only, unauthenticated but
// confined to LOOPBACK peers. The bearer-token file (and cert.pem) are SYSTEM/Administrators-
// DACL'd on Windows, so the per-user tray process cannot authenticate — this one narrow
// read-only route is deliberately all it needs. Not on the cert allowlist: LAN mTLS clients
// already have the richer `/status`. (No PeerAddr ⇒ a unit test → treat as loopback, matching
// the bearer path below.)
if req.uri().path() == "/api/v1/local/summary" {
let from_loopback = req
.extensions()
.get::<PeerAddr>()
.is_none_or(|a| a.0.ip().is_loopback());
return if from_loopback {
next.run(req).await
} else {
api_error(
StatusCode::UNAUTHORIZED,
"the local summary is loopback-only",
)
};
}
// A paired native client authenticates by its mTLS certificate — the same identity + trust the // A paired native client authenticates by its mTLS certificate — the same identity + trust the
// QUIC data plane uses. But "paired to STREAM" is not "paired to ADMINISTER": a streaming cert // QUIC data plane uses. But "paired to STREAM" is not "paired to ADMINISTER": a streaming cert
// authorizes only the safe, read-only status routes, NOT state-changing or pairing-administration // authorizes only the safe, read-only status routes, NOT state-changing or pairing-administration
@@ -944,6 +990,45 @@ async fn get_status(State(st): State<Arc<MgmtState>>) -> Json<RuntimeStatus> {
}) })
} }
/// Local status summary for the tray icon
///
/// Non-sensitive status (counts and booleans only — no PIN values, no fingerprints, no device
/// names). Unauthenticated, but served to loopback peers only.
#[utoipa::path(
get,
path = "/local/summary",
tag = "host",
operation_id = "getLocalSummary",
// Override the document-global bearerAuth: loopback peers are exempt in `require_auth`.
security(()),
responses(
(status = OK, description = "Non-sensitive local host status (loopback peers only)", body = LocalSummary),
(status = UNAUTHORIZED, description = "Non-loopback peer", body = ApiError),
)
)]
async fn get_local_summary(State(st): State<Arc<MgmtState>>) -> Json<LocalSummary> {
let session = st.app.launch.lock().unwrap().map(|l| SessionInfo {
width: l.width,
height: l.height,
fps: l.fps,
});
let (native_paired_clients, pending_approvals) = st
.native
.as_ref()
.map(|n| (n.status().paired_clients, n.pending().len() as u32))
.unwrap_or((0, 0));
Json(LocalSummary {
version: env!("PUNKTFUNK_VERSION").into(),
video_streaming: st.app.streaming.load(Ordering::SeqCst),
audio_streaming: st.app.audio_streaming.load(Ordering::SeqCst),
session,
paired_clients: st.app.paired.lock().unwrap().len() as u32,
native_paired_clients,
pin_pending: st.app.pairing.pin.awaiting_pin(),
pending_approvals,
})
}
/// List paired clients /// List paired clients
#[utoipa::path( #[utoipa::path(
get, get,
@@ -2031,6 +2116,61 @@ mod tests {
assert_eq!(body["abi_version"], punktfunk_core::ABI_VERSION); assert_eq!(body["abi_version"], punktfunk_core::ABI_VERSION);
} }
/// The tray's `/local/summary` is unauthenticated for LOOPBACK peers only — a LAN peer is
/// rejected even though the route needs no bearer token, and the body never carries secret
/// material (no PIN values, no fingerprints, no device names — counts/booleans only).
#[tokio::test]
async fn local_summary_is_loopback_only_and_non_sensitive() {
let np = Arc::new(
crate::native_pairing::NativePairing::load_with(
Some(
std::env::temp_dir()
.join(format!("pf-mgmt-summary-{}.json", std::process::id())),
),
None,
false,
)
.unwrap(),
);
np.add("secret-device-name", "deadbeefcafe0123").unwrap();
let app = test_app_native(test_state(), np);
// Loopback peer, NO auth header → 200 with the expected shape.
let mut req = get_req("/api/v1/local/summary");
req.extensions_mut()
.insert(PeerAddr("127.0.0.1:40000".parse().unwrap()));
let (status, body) = send(&app, req).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["video_streaming"], false);
assert_eq!(body["native_paired_clients"], 1);
assert_eq!(body["pending_approvals"], 0);
assert!(body["version"].is_string());
// No secret material anywhere in the body (paired name / fingerprint must not leak).
let raw = body.to_string();
assert!(
!raw.contains("deadbeefcafe0123") && !raw.contains("secret-device-name"),
"summary must not leak fingerprints or device names: {raw}"
);
// The same request from a LAN peer → rejected (route is loopback-gated, not just tokenless).
let mut req = get_req("/api/v1/local/summary");
req.extensions_mut()
.insert(PeerAddr("192.168.1.50:40000".parse().unwrap()));
let (status, _) = send(&app, req).await;
assert_eq!(
status,
StatusCode::UNAUTHORIZED,
"the local summary must be rejected for a LAN peer"
);
// IPv6 loopback counts as loopback.
let mut req = get_req("/api/v1/local/summary");
req.extensions_mut()
.insert(PeerAddr("[::1]:40000".parse().unwrap()));
let (status, _) = send(&app, req).await;
assert_eq!(status, StatusCode::OK, "::1 is a loopback peer");
}
#[tokio::test] #[tokio::test]
async fn bearer_token_is_enforced() { async fn bearer_token_is_enforced() {
let app = test_app(test_state(), Some("sekrit")); let app = test_app(test_state(), Some("sekrit"));
@@ -39,11 +39,13 @@ pub(crate) enum MonitorKey {
Session(u64), Session(u64),
} }
/// What a backend's `add_monitor` returns: the REMOVE key + the OS target id + the render LUID. /// What a backend's `add_monitor` returns: the REMOVE key + the OS target id + the render LUID + the
/// driver's WUDFHost pid (the sealed frame channel's handle-duplication target).
pub(crate) struct AddedMonitor { pub(crate) struct AddedMonitor {
pub key: MonitorKey, pub key: MonitorKey,
pub target_id: u32, pub target_id: u32,
pub luid: LUID, pub luid: LUID,
pub wudf_pid: u32,
} }
/// The backend-specific IOCTL surface — the *only* thing that differs between SudoVDA and pf-vdisplay. /// The backend-specific IOCTL surface — the *only* thing that differs between SudoVDA and pf-vdisplay.
@@ -91,6 +93,9 @@ struct Monitor {
key: MonitorKey, key: MonitorKey,
target_id: u32, target_id: u32,
luid: LUID, luid: LUID,
/// The driver's WUDFHost pid (from the ADD reply) — carried into [`WinCaptureTarget`] so the
/// IDD-push capturer knows where to duplicate the sealed frame channel's handles.
wudf_pid: u32,
gdi_name: Option<String>, gdi_name: Option<String>,
mode: Mode, mode: Mode,
stop: Arc<AtomicBool>, stop: Arc<AtomicBool>,
@@ -109,6 +114,7 @@ impl Monitor {
adapter_luid: crate::capture::dxgi::pack_luid(self.luid), adapter_luid: crate::capture::dxgi::pack_luid(self.luid),
gdi_name: n, gdi_name: n,
target_id: self.target_id, target_id: self.target_id,
wudf_pid: self.wudf_pid,
}) })
} }
} }
@@ -166,6 +172,14 @@ pub(crate) fn vdm() -> &'static VirtualDisplayManager {
.expect("VirtualDisplayManager used before a backend initialised it") .expect("VirtualDisplayManager used before a backend initialised it")
} }
/// The live pf-vdisplay control-device handle, for the IDD-push capturer's sealed-channel delivery
/// (`IOCTL_SET_FRAME_CHANNEL`). Safe to hand out as a bare `HANDLE`: the device lives in a `OnceLock`
/// that is never cleared or closed for the process lifetime. `None` before the first backend open —
/// impossible for a capturer, which only exists on a monitor the manager created.
pub(crate) fn control_device_handle() -> Option<HANDLE> {
VDM.get().and_then(VirtualDisplayManager::device_handle)
}
impl VirtualDisplayManager { impl VirtualDisplayManager {
pub(crate) fn backend_name(&self) -> &'static str { pub(crate) fn backend_name(&self) -> &'static str {
self.driver.name() self.driver.name()
@@ -436,6 +450,7 @@ impl VirtualDisplayManager {
key: added.key, key: added.key,
target_id: added.target_id, target_id: added.target_id,
luid: added.luid, luid: added.luid,
wudf_pid: added.wudf_pid,
gdi_name, gdi_name,
mode, mode,
stop, stop,
@@ -158,6 +158,33 @@ unsafe fn set_render_adapter(h: HANDLE, luid: LUID) -> Result<()> {
.context("pf-vdisplay SET_RENDER_ADAPTER") .context("pf-vdisplay SET_RENDER_ADAPTER")
} }
/// Deliver a monitor's sealed frame channel to the driver: the handle values `req` carries were just
/// duplicated into the driver's WUDFHost by the IDD-push capturer's broker (`idd_push::ChannelBroker`),
/// and on IOCTL success the DRIVER owns them. No output buffer. The caller reaps the remote duplicates
/// on failure (the broker's `DUPLICATE_CLOSE_SOURCE` sweep) so no path leaks WUDFHost handles.
///
/// # Safety
/// `dev` must be a live pf-vdisplay control handle (see [`super::manager::control_device_handle`]).
pub(crate) unsafe fn send_frame_channel(
dev: HANDLE,
req: &control::SetFrameChannelRequest,
) -> Result<()> {
let mut none: [u8; 0] = [];
// SAFETY: per this fn's contract `dev` is the live control handle. `bytes_of(req)` borrows the
// caller's request for the duration of this synchronous call as the input bytes; `none` is empty,
// so there is no output buffer.
unsafe {
ioctl(
dev,
control::IOCTL_SET_FRAME_CHANNEL,
bytemuck::bytes_of(req),
&mut none,
)
}
.map(|_| ())
.context("pf-vdisplay SET_FRAME_CHANNEL")
}
unsafe fn open_device() -> Result<HANDLE> { unsafe fn open_device() -> Result<HANDLE> {
let hdev = SetupDiGetClassDevsW( let hdev = SetupDiGetClassDevsW(
Some(&PF_VDISPLAY_INTERFACE), Some(&PF_VDISPLAY_INTERFACE),
@@ -354,12 +381,13 @@ impl VdisplayDriver for PfVdisplayDriver {
HighPart: reply.adapter_luid_high, HighPart: reply.adapter_luid_high,
}; };
tracing::info!( tracing::info!(
"pf-vdisplay created {}x{}@{} (target_id={}, adapter_luid={:#x})", "pf-vdisplay created {}x{}@{} (target_id={}, adapter_luid={:#x}, wudf_pid={})",
mode.width, mode.width,
mode.height, mode.height,
mode.refresh_hz, mode.refresh_hz,
reply.target_id, reply.target_id,
luid.LowPart luid.LowPart,
reply.wudf_pid
); );
// Per-client identity diagnostic: did the driver honor the host's preferred (stable) monitor id? // Per-client identity diagnostic: did the driver honor the host's preferred (stable) monitor id?
// A pre-Phase-2 driver leaves resolved_monitor_id=0 (it ignored the field); a current driver echoes // A pre-Phase-2 driver leaves resolved_monitor_id=0 (it ignored the field); a current driver echoes
@@ -395,6 +423,7 @@ impl VdisplayDriver for PfVdisplayDriver {
key: MonitorKey::Session(session_id), key: MonitorKey::Session(session_id),
target_id: reply.target_id, target_id: reply.target_id,
luid, luid,
wudf_pid: reply.wudf_pid,
}) })
} }
@@ -162,9 +162,28 @@ fn install_gamepad(dir: &Path) -> Result<()> {
eprintln!("warning: pnputil /add-driver {} failed", inf.display()); eprintln!("warning: pnputil /add-driver {} failed", inf.display());
} }
} }
// Sweep pad devnodes, INCLUDING phantoms a host crash / service stop left behind: a re-created
// SwDevice with a known instance id REVIVES the existing devnode with its previously-bound
// driver — it never re-ranks against the store — so after an upgrade the old driver keeps
// serving (or, across the v1→v2 sealed-channel fence, fails closed and the pad plays dead).
// Proven in the field on the RTX box: a v1 phantom pinned the old package through a v2
// install. The devnodes are per-session objects the host recreates on demand, so removing
// them at driver-install time is always safe; the next pad binds the fresh package.
remove_pad_devnodes();
Ok(()) Ok(())
} }
/// `pnputil /remove-device` every punktfunk virtual-pad devnode (live or phantom).
fn remove_pad_devnodes() {
for id in pad_instance_ids() {
if run_quiet("pnputil", &["/remove-device", &id]) {
println!("removed stale pad devnode {id}");
} else {
eprintln!("warning: pnputil /remove-device {id} failed");
}
}
}
// ── `driver uninstall [--gamepad]` ────────────────────────────────────────────────────────────── // ── `driver uninstall [--gamepad]` ──────────────────────────────────────────────────────────────
// The uninstaller's cleanup counterpart (Inno [UninstallRun]) — the field report was that our // The uninstaller's cleanup counterpart (Inno [UninstallRun]) — the field report was that our
// virtual-device drivers survived an uninstall. Removes the pf-vdisplay device node(s) + driver // virtual-device drivers survived an uninstall. Removes the pf-vdisplay device node(s) + driver
@@ -204,6 +223,9 @@ fn uninstall_pf_vdisplay() -> Result<()> {
} }
fn uninstall_gamepad() -> Result<()> { fn uninstall_gamepad() -> Result<()> {
// Devnodes first (incl. phantoms — the same ghost-device complaint the vdisplay uninstall
// fixed), then the store packages.
remove_pad_devnodes();
delete_store_drivers(&["pf_dualsense", "pf_dualshock4", "pf_xusb"]); delete_store_drivers(&["pf_dualsense", "pf_dualshock4", "pf_xusb"]);
Ok(()) Ok(())
} }
@@ -235,6 +257,28 @@ fn pf_vdisplay_instance_ids() -> Vec<String> {
ids ids
} }
/// Instance IDs of punktfunk virtual-pad devnodes (`SWD\PUNKTFUNK\…`), INCLUDING phantoms left by
/// a host crash / service stop (`pnputil /enum-devices` lists disconnected devnodes too). Same
/// un-localized VALUE-side parsing as [`pf_vdisplay_instance_ids`]; matched on the instance-id
/// prefix itself — the pads span two device classes (HIDClass + System), so no `/class` filter.
fn pad_instance_ids() -> Vec<String> {
let out = run_capture("pnputil", &["/enum-devices"]);
let mut ids = Vec::new();
for block in out.split("\r\n\r\n").flat_map(|b| b.split("\n\n")) {
let Some(first) = block.lines().find(|l| !l.trim().is_empty()) else {
continue;
};
let Some((_, value)) = first.split_once(':') else {
continue;
};
let id = value.trim();
if id.to_ascii_uppercase().starts_with("SWD\\PUNKTFUNK\\") && !id.contains(' ') {
ids.push(id.to_string());
}
}
ids
}
/// Delete every driver-store package (`%WINDIR%\INF\oem*.inf`) whose INF text mentions one of /// Delete every driver-store package (`%WINDIR%\INF\oem*.inf`) whose INF text mentions one of
/// `needles` — our driver names are unique enough that a content match identifies the package /// `needles` — our driver names are unique enough that a content match identifies the package
/// without parsing `pnputil /enum-drivers`' localized output. `/uninstall /force` also unbinds it /// without parsing `pnputil /enum-drivers`' localized output. `/uninstall /force` also unbinds it
@@ -91,6 +91,7 @@ pub fn main(args: &[String]) -> Result<()> {
Some("uninstall") => uninstall(), Some("uninstall") => uninstall(),
Some("start") => sc(&["start", SERVICE_NAME]), Some("start") => sc(&["start", SERVICE_NAME]),
Some("stop") => sc(&["stop", SERVICE_NAME]), Some("stop") => sc(&["stop", SERVICE_NAME]),
Some("restart") => restart(),
Some("status") => sc(&["query", SERVICE_NAME]), Some("status") => sc(&["query", SERVICE_NAME]),
_ => { _ => {
eprintln!( eprintln!(
@@ -102,6 +103,7 @@ pub fn main(args: &[String]) -> Result<()> {
\x20 punktfunk-host service uninstall stop + remove the service + firewall rules\n\ \x20 punktfunk-host service uninstall stop + remove the service + firewall rules\n\
\x20 punktfunk-host service start start the service now\n\ \x20 punktfunk-host service start start the service now\n\
\x20 punktfunk-host service stop stop the service\n\ \x20 punktfunk-host service stop stop the service\n\
\x20 punktfunk-host service restart stop, wait for exit, start again\n\
\x20 punktfunk-host service status query the service\n\n\ \x20 punktfunk-host service status query the service\n\n\
Config: %ProgramData%\\punktfunk\\host.env Logs: %ProgramData%\\punktfunk\\logs\\" Config: %ProgramData%\\punktfunk\\host.env Logs: %ProgramData%\\punktfunk\\logs\\"
); );
@@ -691,6 +693,40 @@ fn install(args: &[String]) -> Result<()> {
Ok(()) Ok(())
} }
/// `service restart`: stop, wait for the service to actually reach Stopped (a bare
/// `sc stop && sc start` races the stop — START fails with "instance already running" while the
/// old process winds down), then start. The tray icon's Restart action runs this, elevated.
fn restart() -> Result<()> {
use windows_service::service::{ServiceAccess, ServiceState};
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)
.context("open Service Control Manager (run elevated)")?;
let svc = manager
.open_service(
SERVICE_NAME,
ServiceAccess::STOP | ServiceAccess::QUERY_STATUS | ServiceAccess::START,
)
.context("open service (run elevated)")?;
// Best-effort stop: ERROR_SERVICE_NOT_ACTIVE just means restart == start.
let _ = svc.stop();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
loop {
let state = svc.query_status().context("query service status")?;
if state.current_state == ServiceState::Stopped {
break;
}
if std::time::Instant::now() >= deadline {
anyhow::bail!("service did not stop within 30 s");
}
std::thread::sleep(std::time::Duration::from_millis(250));
}
svc.start(&[] as &[&std::ffi::OsStr])
.context("start service")?;
println!("Restarted service '{SERVICE_NAME}'.");
Ok(())
}
fn uninstall() -> Result<()> { fn uninstall() -> Result<()> {
use windows_service::service::ServiceAccess; use windows_service::service::ServiceAccess;
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess}; use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
+56
View File
@@ -0,0 +1,56 @@
[package]
name = "punktfunk-tray"
description = "System-tray status icon for the punktfunk streaming host (Windows notification area / Linux StatusNotifierItem)"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
[[bin]]
name = "punktfunk-tray"
path = "src/main.rs"
# Deliberately does NOT depend on punktfunk-host: the tray needs only the service name, the mgmt
# port, and the summary JSON shape — a dependency would drag the whole host (FFmpeg, PipeWire, …)
# into a 2 MB helper and make it un-buildable standalone. Non-Windows/non-Linux targets build a
# stub main (same pattern as the platform-gated clients).
[dependencies]
anyhow = "1"
[target.'cfg(any(windows, target_os = "linux"))'.dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Loopback HTTPS poll of GET /api/v1/local/summary. Same sync ureq + rustls(ring) stack and
# custom-verifier pattern as the Linux client's library fetch (clients/linux/src/library.rs) —
# but ring-only (no default aws-lc-rs provider: it needs a C toolchain per target and the agent
# pins the ring provider explicitly anyway).
ureq = { version = "2", default-features = false, features = ["tls"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
sha2 = "0.10"
[target.'cfg(windows)'.dependencies]
# SCM QUERY_STATUS works unprivileged — the service-state probe. Same crate the host service uses.
windows-service = "0.7"
windows = { version = "0.62", features = [
"Win32_Foundation",
"Win32_Graphics_Gdi",
"Win32_Security", # CreateMutexW's SECURITY_ATTRIBUTES parameter type
"Win32_System_LibraryLoader",
"Win32_System_Threading",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
] }
[target.'cfg(target_os = "linux")'.dependencies]
# StatusNotifierItem (pure Rust, zbus — the same zbus the host already pulls via ashpd). The tray
# is a plain-threads poller, so the blocking API over the small async-io executor (`blocking`
# alone is just the wrapper — zbus still needs an executor; no tokio runtime in a tray icon).
ksni = { version = "0.3", default-features = false, features = ["async-io", "blocking"] }
libc = "0.2"
# Build-time icon embedding (exe icon + the status-variant tray icons), host-gated like the
# Windows client's build.rs — cross-builds from Linux CI runners skip it.
[target.'cfg(windows)'.build-dependencies]
winresource = "0.1"
+27
View File
@@ -0,0 +1,27 @@
//! Embed the Windows version-info + icon resources into `punktfunk-tray.exe`: ordinal 1 is the
//! exe/file icon, ordinals 26 are the status-variant tray icons `src/win.rs` loads by id
//! (running / stopped / error / streaming / degraded). Same winresource pattern as
//! `clients/windows/build.rs`.
fn main() {
// cfg(windows) is the HOST (skips the Linux/macOS workspace stub build); CARGO_CFG_WINDOWS
// is the TARGET (mirrors the Windows client's build.rs).
#[cfg(windows)]
if std::env::var_os("CARGO_CFG_WINDOWS").is_some() {
let branding = "../../packaging/windows/branding";
let icons = [
(format!("{branding}/punktfunk.ico"), "1"),
(format!("{branding}/punktfunk-tray-running.ico"), "2"),
(format!("{branding}/punktfunk-tray-stopped.ico"), "3"),
(format!("{branding}/punktfunk-tray-error.ico"), "4"),
(format!("{branding}/punktfunk-tray-streaming.ico"), "5"),
(format!("{branding}/punktfunk-tray-degraded.ico"), "6"),
];
let mut res = winresource::WindowsResource::new();
for (path, id) in &icons {
println!("cargo:rerun-if-changed={path}");
res.set_icon_with_id(path, id);
}
res.compile().expect("embed windows icon resources");
}
}
+272
View File
@@ -0,0 +1,272 @@
//! Linux tray: a StatusNotifierItem (ksni/zbus) fed by the status poller. The host runs as the
//! systemd **user** unit `punktfunk-host.service`, so start/stop/restart are plain
//! `systemctl --user` calls — no polkit, no elevation. KDE (the project's primary Linux desktop)
//! renders SNI natively; GNOME needs the AppIndicator extension (without it the icon is invisible
//! — `--autostart` exits silently rather than erroring at every login).
use std::os::fd::AsRawFd;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, OnceLock};
use crate::status::{self, Poller, TrayStatus};
/// The tray's D-Bus/menu model. `status` + `web_console` are the mutable state; the poller
/// rewrites them via `Handle::update`, which re-emits the SNI properties (icon, tooltip, menu).
struct HostTray {
status: TrayStatus,
web_port: u16,
/// The console answered the poller's live loopback probe — the "Open web console" entry is
/// shown iff opening it would actually work (repo-run consoles included, stopped ones not).
web_console: bool,
/// Filled right after `spawn` (the poller needs the tray handle first) — lets menu actions
/// force an immediate re-poll instead of waiting out the cadence.
poller: Arc<OnceLock<Poller>>,
}
impl HostTray {
fn systemctl(&self, verb: &str) {
let _ = std::process::Command::new("systemctl")
.args(["--user", verb, status::UNIT_NAME])
.status();
if let Some(p) = self.poller.get() {
p.poke();
}
}
fn open_console(&self) {
let url = format!("https://127.0.0.1:{}", self.web_port);
let _ = std::process::Command::new("xdg-open").arg(url).spawn();
}
}
impl ksni::Tray for HostTray {
fn id(&self) -> String {
"punktfunk-tray".into()
}
fn title(&self) -> String {
"punktfunk host".into()
}
fn status(&self) -> ksni::Status {
match &self.status {
TrayStatus::Error(_) => ksni::Status::NeedsAttention,
s if s.pairing_attention() => ksni::Status::NeedsAttention,
_ => ksni::Status::Active,
}
}
/// Hicolor theme names (installed by the packages); `icon_pixmap` below is the fallback so a
/// `cargo run` from the repo shows an icon too.
fn icon_name(&self) -> String {
match &self.status {
TrayStatus::Running(_) if self.status.is_streaming() => {
"punktfunk-tray-streaming".into()
}
TrayStatus::Running(_) => "punktfunk-tray".into(),
TrayStatus::Starting | TrayStatus::Degraded => "punktfunk-tray-degraded".into(),
TrayStatus::Error(_) => "punktfunk-tray-error".into(),
TrayStatus::Stopped | TrayStatus::NotInstalled => "punktfunk-tray-stopped".into(),
}
}
fn icon_pixmap(&self) -> Vec<ksni::Icon> {
// Same dot palette as scripts/gen-tray-icons.py.
let rgb = match &self.status {
TrayStatus::Running(_) if self.status.is_streaming() => (0xb4, 0x4c, 0xf0), // violet
TrayStatus::Running(_) => (0x2e, 0xcc, 0x71), // green
TrayStatus::Starting | TrayStatus::Degraded => (0xf0, 0xa0, 0x30), // amber
TrayStatus::Error(_) => (0xe7, 0x4c, 0x3c), // red
TrayStatus::Stopped | TrayStatus::NotInstalled => (0x8a, 0x8a, 0x8a), // gray
};
vec![dot_icon(22, rgb), dot_icon(48, rgb)]
}
fn tool_tip(&self) -> ksni::ToolTip {
ksni::ToolTip {
title: self.status.headline(),
..Default::default()
}
}
fn menu(&self) -> Vec<ksni::MenuItem<Self>> {
use ksni::menu::*;
let running = matches!(
self.status,
TrayStatus::Running(_) | TrayStatus::Starting | TrayStatus::Degraded
);
let startable = matches!(
self.status,
TrayStatus::Stopped | TrayStatus::Error(_) | TrayStatus::NotInstalled
);
vec![
StandardItem {
label: self.status.headline(),
enabled: false,
..Default::default()
}
.into(),
MenuItem::Separator,
StandardItem {
label: "Open web console".into(),
visible: self.web_console,
activate: Box::new(|t: &mut Self| t.open_console()),
..Default::default()
}
.into(),
StandardItem {
label: "Approve pairing request…".into(),
visible: self.web_console && self.status.pairing_attention(),
activate: Box::new(|t: &mut Self| t.open_console()),
..Default::default()
}
.into(),
MenuItem::Separator,
StandardItem {
label: "Start host".into(),
visible: startable && !matches!(self.status, TrayStatus::NotInstalled),
activate: Box::new(|t: &mut Self| t.systemctl("start")),
..Default::default()
}
.into(),
StandardItem {
label: "Stop host".into(),
visible: running,
activate: Box::new(|t: &mut Self| t.systemctl("stop")),
..Default::default()
}
.into(),
StandardItem {
label: "Restart host".into(),
visible: running || matches!(self.status, TrayStatus::Error(_)),
activate: Box::new(|t: &mut Self| t.systemctl("restart")),
..Default::default()
}
.into(),
MenuItem::Separator,
StandardItem {
label: "Exit tray".into(),
activate: Box::new(|_: &mut Self| std::process::exit(0)),
..Default::default()
}
.into(),
]
}
/// Keep waiting when the watcher drops (plasmashell restart, GNOME shell reload) — the item
/// re-registers when it returns. Only `--autostart` runs get here with SNI truly absent, and
/// lingering invisibly is the documented trade-off (see `assume_sni_available` below).
fn watcher_offline(&self, _reason: ksni::OfflineReason) -> bool {
true
}
}
/// A flat antialiased status dot — the pixmap fallback when the hicolor icons aren't installed
/// (dev runs from `target/`). ARGB32, network byte order (per the SNI spec).
fn dot_icon(size: i32, (r, g, b): (u8, u8, u8)) -> ksni::Icon {
let mut data = Vec::with_capacity((size * size * 4) as usize);
let center = (size as f32 - 1.0) / 2.0;
let radius = size as f32 * 0.38;
for y in 0..size {
for x in 0..size {
let d = ((x as f32 - center).powi(2) + (y as f32 - center).powi(2)).sqrt();
// 1 px antialiasing ramp at the rim.
let alpha = ((radius - d + 0.5).clamp(0.0, 1.0) * 255.0) as u8;
data.extend_from_slice(&[alpha, r, g, b]);
}
}
ksni::Icon {
width: size,
height: size,
data,
}
}
/// Does this user's box run (or intend to run) a punktfunk host? Gates `--autostart` so the
/// packaged autostart entry doesn't put an icon in every desktop user's tray.
fn host_present() -> bool {
if status::punktfunk_config_dir().is_some_and(|d| d.exists()) {
return true;
}
std::process::Command::new("systemctl")
.args(["--user", "--quiet", "is-enabled", status::UNIT_NAME])
.status()
.is_ok_and(|s| s.success())
}
/// One tray per session: `flock` on a runtime-dir lockfile (held for the process lifetime).
fn acquire_instance_lock() -> Option<std::fs::File> {
let dir = std::env::var_os("XDG_RUNTIME_DIR")
.map(std::path::PathBuf::from)
.unwrap_or_else(std::env::temp_dir);
let file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(dir.join("punktfunk-tray.lock"))
.ok()?;
// SAFETY: `file` is an open, owned fd for the duration of the call; LOCK_NB makes this a
// non-blocking advisory lock attempt with no other side effects.
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
(rc == 0).then_some(file)
}
pub fn run(args: crate::Args) -> anyhow::Result<()> {
if args.quit {
// Windows-only convenience for the uninstaller; nothing to do here.
return Ok(());
}
if args.autostart && !host_present() {
return Ok(()); // not a host box — stay out of this user's tray
}
let Some(_lock) = acquire_instance_lock() else {
return Ok(()); // another instance already runs in this session
};
let poller_slot = Arc::new(OnceLock::new());
let tray = HostTray {
status: TrayStatus::Stopped, // placeholder; the poller fires within its first cycle
web_port: args.web_port,
web_console: false, // live-probed by the poller within its first cycle
poller: poller_slot.clone(),
};
// Autostart races the desktop (the watcher may register after us) → be lenient and wait for
// it. A manual launch should fail loudly instead (e.g. GNOME without the AppIndicator
// extension) so the user learns why there is no icon.
use ksni::blocking::TrayMethods;
let handle = match tray.assume_sni_available(args.autostart).spawn() {
Ok(h) => h,
Err(e) if args.autostart => {
eprintln!("punktfunk-tray: no StatusNotifier host ({e}); exiting");
return Ok(());
}
Err(e) => anyhow::bail!(
"no StatusNotifier tray available ({e}) — on GNOME, install the AppIndicator extension"
),
};
let dead = Arc::new(AtomicBool::new(false));
let dead_flag = dead.clone();
let update_handle = handle.clone();
let poller = Poller::spawn(
args.mgmt_addr.clone(),
args.mgmt_port,
args.web_port,
Box::new(move |st, console_up| {
let updated = update_handle.update(|t: &mut HostTray| {
t.status = st;
t.web_console = console_up;
});
if updated.is_none() {
dead_flag.store(true, Ordering::SeqCst); // tray service shut down
}
}),
);
let _ = poller_slot.set(poller);
// The SNI service runs on its own thread; park here until it dies (shell logout etc.).
while !dead.load(Ordering::SeqCst) && !handle.is_closed() {
std::thread::sleep(std::time::Duration::from_secs(2));
}
Ok(())
}
+97
View File
@@ -0,0 +1,97 @@
//! punktfunk-tray — a small per-user system-tray companion for the punktfunk host service.
//!
//! Shows at a glance whether the host is running / stopped / degraded / failed (no more digging
//! through logs after a reboot or an update), and offers the common one-click actions: open the
//! web console, start/stop/restart the service (UAC-elevated per action on Windows,
//! `systemctl --user` on Linux), review a pending pairing request, exit.
//!
//! Status comes from two sources, service manager FIRST (a fake listener on the mgmt port can
//! never make a stopped service look running): the SCM / systemd user unit for the process state,
//! then the host's loopback-only unauthenticated `GET /api/v1/local/summary` for the streaming
//! details. Windows-subsystem binary — a console exe in the HKLM Run key would flash a terminal
//! window at every sign-in.
#![cfg_attr(windows, windows_subsystem = "windows")]
#[cfg(target_os = "linux")]
mod linux;
#[cfg(any(windows, target_os = "linux"))]
mod status;
#[cfg(windows)]
mod win;
/// CLI configuration (hand-rolled parse, house style). The mgmt address/port default to the
/// host's defaults; they are flags because the tray cannot read `host.env` on Windows (it is
/// DACL-locked to SYSTEM/Administrators), so an operator who moved `--mgmt-bind` adjusts the
/// autostart command line instead.
pub struct Args {
/// Ask an already-running tray instance to exit (Windows; used by the uninstaller).
pub quit: bool,
/// Launched from the desktop autostart entry: exit silently when this box doesn't run a host
/// (Linux; the package installs the autostart file for every desktop user).
pub autostart: bool,
/// Management API address to poll (loopback only; the summary route rejects anything else).
pub mgmt_addr: String,
pub mgmt_port: u16,
/// Web console port for the "Open web console" action.
pub web_port: u16,
}
impl Default for Args {
fn default() -> Self {
Args {
quit: false,
autostart: false,
mgmt_addr: "127.0.0.1".into(),
mgmt_port: 47990,
web_port: 47992,
}
}
}
fn parse_args() -> anyhow::Result<Args> {
let mut args = Args::default();
let mut it = std::env::args().skip(1);
while let Some(a) = it.next() {
let mut value = |flag: &str| {
it.next()
.ok_or_else(|| anyhow::anyhow!("{flag} needs a value"))
};
match a.as_str() {
"--quit" => args.quit = true,
"--autostart" => args.autostart = true,
"--mgmt-addr" => args.mgmt_addr = value("--mgmt-addr")?,
"--mgmt-port" => args.mgmt_port = value("--mgmt-port")?.parse()?,
"--web-port" => args.web_port = value("--web-port")?.parse()?,
"--version" | "-V" => {
println!("punktfunk-tray {}", env!("CARGO_PKG_VERSION"));
std::process::exit(0);
}
other => anyhow::bail!(
"unknown argument '{other}'\n\nUSAGE:\n punktfunk-tray [--autostart] [--quit] \
[--mgmt-addr <IP>] [--mgmt-port <N>] [--web-port <N>]"
),
}
}
Ok(args)
}
fn main() -> anyhow::Result<()> {
let args = parse_args()?;
run(args)
}
#[cfg(windows)]
fn run(args: Args) -> anyhow::Result<()> {
win::run(args)
}
#[cfg(target_os = "linux")]
fn run(args: Args) -> anyhow::Result<()> {
linux::run(args)
}
#[cfg(not(any(windows, target_os = "linux")))]
fn run(_args: Args) -> anyhow::Result<()> {
// Workspace-stub build (macOS CI etc.) — the tray ships on Windows and Linux only.
anyhow::bail!("punktfunk-tray supports Windows and Linux hosts only")
}
+506
View File
@@ -0,0 +1,506 @@
//! Host status model + the poller thread feeding the platform tray implementations.
//!
//! Two sources, service manager FIRST: the SCM (Windows) / systemd user unit (Linux) decides
//! stopped-vs-running — a malicious local process squatting the mgmt port while the service is
//! down can never make the tray say Running. Only when the service manager reports Running does
//! the poller consult the host's loopback-only `GET /api/v1/local/summary` for streaming detail.
use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant};
/// What the service manager reports for the host service.
#[derive(Clone, Debug, PartialEq)]
pub enum ServiceState {
NotInstalled,
Stopped,
StartPending,
StopPending,
Running,
/// Linux `ActiveState=failed` (with the sub-state), or a Windows stop with a failure exit code.
Failed(String),
}
/// `GET /api/v1/local/summary` — the non-sensitive counts/booleans the host serves to loopback
/// peers without authentication (mgmt.rs `LocalSummary`). Unknown fields are ignored so a newer
/// host can grow the summary without breaking an older tray.
#[derive(Clone, Debug, PartialEq, serde::Deserialize)]
pub struct Summary {
pub version: String,
pub video_streaming: bool,
pub audio_streaming: bool,
pub session: Option<SessionInfo>,
pub paired_clients: u32,
pub native_paired_clients: u32,
pub pin_pending: bool,
pub pending_approvals: u32,
}
#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize)]
pub struct SessionInfo {
pub width: u32,
pub height: u32,
pub fps: u32,
}
/// What the icon shows.
#[derive(Clone, Debug, PartialEq)]
pub enum TrayStatus {
NotInstalled,
Stopped,
/// Service starting, or running with the mgmt API not answering yet (within [`START_GRACE`]).
Starting,
Running(Summary),
/// Service running but the summary unreachable past the grace period — amber, not red: a
/// custom `PUNKTFUNK_HOST_CMD` (no mgmt API) or a relocated `--mgmt-bind` is legitimate.
Degraded,
Error(String),
}
impl TrayStatus {
/// One-line headline for the tooltip / the disabled menu header.
pub fn headline(&self) -> String {
match self {
TrayStatus::NotInstalled => "punktfunk host — not installed".into(),
TrayStatus::Stopped => "punktfunk host — stopped".into(),
TrayStatus::Starting => "punktfunk host — starting…".into(),
TrayStatus::Degraded => "punktfunk host — running (status unavailable)".into(),
TrayStatus::Error(e) => format!("punktfunk host — failed ({e})"),
TrayStatus::Running(s) => match (&s.session, s.video_streaming) {
(Some(sess), true) => format!(
"punktfunk host {} — streaming {}×{}@{}",
s.version, sess.width, sess.height, sess.fps
),
(_, true) => format!("punktfunk host {} — streaming", s.version),
_ => format!("punktfunk host {} — idle", s.version),
},
}
}
pub fn is_streaming(&self) -> bool {
matches!(self, TrayStatus::Running(s) if s.video_streaming)
}
/// A pairing attempt is waiting on the operator (shown as an extra menu entry).
pub fn pairing_attention(&self) -> bool {
matches!(self, TrayStatus::Running(s) if s.pin_pending || s.pending_approvals > 0)
}
}
/// How long a running service may leave the summary unreachable before Starting turns Degraded.
/// Also re-applied mid-life: the SYSTEM supervisor relaunching a crashed host child looks like
/// "running, briefly unreachable" — that shows as Starting again, not an alarming flicker to red.
pub const START_GRACE: Duration = Duration::from_secs(15);
/// Pure status mapping (unit-tested): service-manager state first, summary second, grace third.
pub fn map_status(svc: &ServiceState, summary: Option<Summary>, grace_expired: bool) -> TrayStatus {
match svc {
ServiceState::NotInstalled => TrayStatus::NotInstalled,
ServiceState::Stopped | ServiceState::StopPending => TrayStatus::Stopped,
ServiceState::StartPending => TrayStatus::Starting,
ServiceState::Failed(e) => TrayStatus::Error(e.clone()),
ServiceState::Running => match summary {
Some(s) => TrayStatus::Running(s),
None if !grace_expired => TrayStatus::Starting,
None => TrayStatus::Degraded,
},
}
}
// ── Poller ──────────────────────────────────────────────────────────────────────────────────────
pub struct Poller {
shared: Arc<Shared>,
}
struct Shared {
poked: Mutex<bool>,
cv: Condvar,
}
impl Poller {
/// Spawn the poll thread; `on_change(status, console_up)` fires (from that thread) whenever
/// either changes. `console_up` is a live loopback probe of the web console on `web_port` —
/// ground truth for the "Open web console" menu entry (a layout sniff would miss consoles run
/// from a repo checkout, and shows a dead entry while an installed console is still starting).
pub fn spawn(
mgmt_addr: String,
mgmt_port: u16,
web_port: u16,
on_change: Box<dyn Fn(TrayStatus, bool) + Send>,
) -> Poller {
let shared = Arc::new(Shared {
poked: Mutex::new(false),
cv: Condvar::new(),
});
let thread_shared = shared.clone();
std::thread::Builder::new()
.name("status-poll".into())
.spawn(move || poll_loop(&thread_shared, &mgmt_addr, mgmt_port, web_port, on_change))
.expect("spawn status-poll thread");
Poller { shared }
}
/// Force an immediate re-poll (right after a start/stop/restart menu action).
pub fn poke(&self) {
*self.shared.poked.lock().unwrap() = true;
self.shared.cv.notify_one();
}
}
fn poll_loop(
shared: &Shared,
mgmt_addr: &str,
mgmt_port: u16,
web_port: u16,
on_change: Box<dyn Fn(TrayStatus, bool) + Send>,
) {
// IPv6 literals bracketed, like the Linux client's `base_url`.
let url = if mgmt_addr.contains(':') {
format!("https://[{mgmt_addr}]:{mgmt_port}/api/v1/local/summary")
} else {
format!("https://{mgmt_addr}:{mgmt_port}/api/v1/local/summary")
};
let console_url = format!("https://127.0.0.1:{web_port}/");
let agent = agent(load_pin());
let mut last: Option<(TrayStatus, bool)> = None;
// When the summary became unreachable while the service was running (grace anchor).
// Runs for the process lifetime (the tray exits by process exit; nothing to unwind).
let mut unreachable_since: Option<Instant> = None;
loop {
let svc = probe_service();
let summary = if svc == ServiceState::Running {
let s = fetch_summary(&agent, &url);
match s {
Some(_) => unreachable_since = None,
None if unreachable_since.is_none() => unreachable_since = Some(Instant::now()),
None => {}
}
s
} else {
unreachable_since = None;
None
};
let grace_expired = unreachable_since.is_some_and(|t| t.elapsed() >= START_GRACE);
let status = map_status(&svc, summary, grace_expired);
let console_up = probe_console(&agent, &console_url);
if last.as_ref() != Some(&(status.clone(), console_up)) {
on_change(status.clone(), console_up);
last = Some((status, console_up));
}
// 3 s while there is anything to watch; back off when the box just doesn't run a host.
let cadence = match last.as_ref().map(|(s, _)| s) {
Some(TrayStatus::Stopped) | Some(TrayStatus::NotInstalled) => Duration::from_secs(10),
_ => Duration::from_secs(3),
};
let mut poked = shared.poked.lock().unwrap();
if !*poked {
(poked, _) = shared.cv.wait_timeout(poked, cadence).unwrap();
}
*poked = false;
}
}
/// Is the web console answering on loopback? Any HTTP response (incl. the login redirect / 401)
/// counts as up — only a transport failure (nothing listening, TLS handshake dead) means down.
fn probe_console(agent: &ureq::Agent, url: &str) -> bool {
match agent.get(url).call() {
Ok(_) => true,
Err(ureq::Error::Status(..)) => true,
Err(_) => false,
}
}
// ── Summary fetch (loopback HTTPS) ──────────────────────────────────────────────────────────────
fn fetch_summary(agent: &ureq::Agent, url: &str) -> Option<Summary> {
let body = agent.get(url).call().ok()?.into_string().ok()?;
serde_json::from_str(&body).ok()
}
/// The host identity cert's SHA-256, when `cert.pem` is readable (Linux: same-user file). On
/// Windows the file is SYSTEM/Administrators-DACL'd, so the per-user tray can't pin — `None` =
/// accept any cert. That is acceptable here: the connection is loopback, carries no credentials,
/// and only *reads* non-sensitive data; stopped-vs-running is decided by the service manager, so
/// a port-squatter gains nothing but a fake "streaming" tooltip on an already-compromised box.
fn load_pin() -> Option<[u8; 32]> {
use rustls::pki_types::pem::PemObject;
use sha2::Digest;
let pem = std::fs::read(punktfunk_config_dir()?.join("cert.pem")).ok()?;
let der = rustls::pki_types::CertificateDer::from_pem_slice(&pem).ok()?;
Some(sha2::Sha256::digest(der.as_ref()).into())
}
/// The host's config dir, mirroring `gamestream::config_dir()` without linking the host crate:
/// `PUNKTFUNK_CONFIG_DIR` override, else `$XDG_CONFIG_HOME`/`~/.config` + `punktfunk` (Linux).
/// `None` on Windows — everything the tray would read there is SYSTEM/Admins-DACL'd anyway.
pub fn punktfunk_config_dir() -> Option<std::path::PathBuf> {
if let Some(d) = std::env::var_os("PUNKTFUNK_CONFIG_DIR") {
if !d.is_empty() {
return Some(std::path::PathBuf::from(d));
}
}
#[cfg(target_os = "linux")]
{
if let Some(x) = std::env::var_os("XDG_CONFIG_HOME") {
if !x.is_empty() {
return Some(std::path::PathBuf::from(x).join("punktfunk"));
}
}
std::env::var_os("HOME").map(|h| {
std::path::PathBuf::from(h)
.join(".config")
.join("punktfunk")
})
}
#[cfg(not(target_os = "linux"))]
None
}
/// A sync HTTPS agent over the same rustls(ring) stack the rest of the workspace uses, with a
/// pin-or-accept-any verifier (the Linux client's `PinVerify` pattern, `library.rs`).
fn agent(pin: Option<[u8; 32]>) -> ureq::Agent {
let provider = Arc::new(rustls::crypto::ring::default_provider());
let cfg = rustls::ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.expect("rustls default protocol versions")
.dangerous()
.with_custom_certificate_verifier(Arc::new(PinVerify { pin }))
.with_no_client_auth();
ureq::AgentBuilder::new()
.tls_config(Arc::new(cfg))
.timeout_connect(Duration::from_secs(2))
.timeout(Duration::from_secs(2))
.build()
}
/// Trust = the SHA-256 of the host's self-signed leaf (or any cert when un-pinned). Handshake
/// signatures are still verified for real — CertificateVerify proves the peer holds the key.
#[derive(Debug)]
struct PinVerify {
pin: Option<[u8; 32]>,
}
impl rustls::client::danger::ServerCertVerifier for PinVerify {
fn verify_server_cert(
&self,
end_entity: &rustls::pki_types::CertificateDer<'_>,
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
_server_name: &rustls::pki_types::ServerName<'_>,
_ocsp: &[u8],
_now: rustls::pki_types::UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
use sha2::Digest;
if let Some(expected) = self.pin {
let fp: [u8; 32] = sha2::Sha256::digest(end_entity.as_ref()).into();
if fp != expected {
return Err(rustls::Error::InvalidCertificate(
rustls::CertificateError::ApplicationVerificationFailure,
));
}
}
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls12_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls13_signature(
message,
cert,
dss,
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
rustls::crypto::ring::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
}
// ── Service-manager probe ───────────────────────────────────────────────────────────────────────
/// The SCM name registered by `punktfunk-host service install` (windows/service.rs SERVICE_NAME).
#[cfg(windows)]
pub const SERVICE_NAME: &str = "PunktfunkHost";
#[cfg(windows)]
pub fn probe_service() -> ServiceState {
use windows_service::service::{ServiceAccess, ServiceExitCode, ServiceState as Scm};
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
// CONNECT + QUERY_STATUS work unprivileged. Re-opened every poll on purpose: a reinstall
// (delete + create) invalidates old handles, and this picks the new service up within a poll.
let Ok(manager) = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)
else {
return ServiceState::NotInstalled;
};
let Ok(svc) = manager.open_service(SERVICE_NAME, ServiceAccess::QUERY_STATUS) else {
return ServiceState::NotInstalled; // ERROR_SERVICE_DOES_NOT_EXIST et al.
};
let Ok(status) = svc.query_status() else {
return ServiceState::NotInstalled;
};
match status.current_state {
Scm::StartPending => ServiceState::StartPending,
Scm::StopPending => ServiceState::StopPending,
Scm::Running | Scm::ContinuePending | Scm::PausePending | Scm::Paused => {
ServiceState::Running
}
Scm::Stopped => match status.exit_code {
// 0 = clean; 1077 = never started since boot (ERROR_SERVICE_NEVER_HAS_BEEN_RUN? no —
// "no attempts to start have been made"): both are an ordinary Stopped, not a failure.
ServiceExitCode::Win32(0) | ServiceExitCode::Win32(1077) => ServiceState::Stopped,
ServiceExitCode::Win32(code) => ServiceState::Failed(format!("exit code {code}")),
ServiceExitCode::ServiceSpecific(code) => {
ServiceState::Failed(format!("service error {code}"))
}
},
}
}
/// The systemd user unit the Linux packages install (scripts/punktfunk-host.service).
#[cfg(target_os = "linux")]
pub const UNIT_NAME: &str = "punktfunk-host.service";
#[cfg(target_os = "linux")]
pub fn probe_service() -> ServiceState {
// `systemctl show` exits 0 even for unknown units (LoadState=not-found) — parse, don't rely
// on the exit code.
let Ok(out) = std::process::Command::new("systemctl")
.args([
"--user",
"show",
UNIT_NAME,
"--property=LoadState,ActiveState,SubState",
])
.output()
else {
return ServiceState::NotInstalled; // no systemctl → nothing to watch
};
let text = String::from_utf8_lossy(&out.stdout);
let prop = |key: &str| {
text.lines()
.find_map(|l| l.strip_prefix(key)?.strip_prefix('='))
.unwrap_or("")
.to_string()
};
if prop("LoadState") == "not-found" {
return ServiceState::NotInstalled;
}
match prop("ActiveState").as_str() {
"active" | "reloading" => ServiceState::Running,
"activating" => ServiceState::StartPending,
"deactivating" => ServiceState::StopPending,
"failed" => ServiceState::Failed(prop("SubState")),
_ => ServiceState::Stopped, // "inactive" and anything new
}
}
#[cfg(test)]
mod tests {
use super::*;
fn summary(streaming: bool) -> Summary {
Summary {
version: "0.5.1".into(),
video_streaming: streaming,
audio_streaming: streaming,
session: streaming.then_some(SessionInfo {
width: 2560,
height: 1440,
fps: 120,
}),
paired_clients: 1,
native_paired_clients: 2,
pin_pending: false,
pending_approvals: 0,
}
}
/// The full (service state × summary × grace) table.
#[test]
fn status_mapping_table() {
use ServiceState as S;
use TrayStatus as T;
let cases: Vec<(S, Option<Summary>, bool, T)> = vec![
(S::NotInstalled, None, false, T::NotInstalled),
(S::Stopped, None, false, T::Stopped),
(S::StopPending, None, false, T::Stopped),
(S::StartPending, None, false, T::Starting),
(
S::Failed("code 3".into()),
None,
false,
T::Error("code 3".into()),
),
// Running + summary → Running regardless of grace.
(
S::Running,
Some(summary(false)),
true,
T::Running(summary(false)),
),
// Running + unreachable: Starting within grace, Degraded past it.
(S::Running, None, false, T::Starting),
(S::Running, None, true, T::Degraded),
// A summary while the SCM says Stopped is impossible by construction (the poller only
// fetches when Running) — but the mapping must still trust the service manager.
(S::Stopped, Some(summary(true)), false, T::Stopped),
];
for (svc, sum, grace, want) in cases {
assert_eq!(
map_status(&svc, sum.clone(), grace),
want,
"{svc:?} {sum:?} grace={grace}"
);
}
}
#[test]
fn headline_shows_session_and_reason() {
assert_eq!(
TrayStatus::Running(summary(true)).headline(),
"punktfunk host 0.5.1 — streaming 2560×1440@120"
);
assert_eq!(
TrayStatus::Running(summary(false)).headline(),
"punktfunk host 0.5.1 — idle"
);
assert!(TrayStatus::Error("exit code 3".into())
.headline()
.contains("exit code 3"));
assert!(TrayStatus::Degraded
.headline()
.contains("status unavailable"));
}
#[test]
fn pairing_attention_flags() {
let mut s = summary(false);
assert!(!TrayStatus::Running(s.clone()).pairing_attention());
s.pending_approvals = 1;
assert!(TrayStatus::Running(s.clone()).pairing_attention());
s.pending_approvals = 0;
s.pin_pending = true;
assert!(TrayStatus::Running(s).pairing_attention());
assert!(!TrayStatus::Degraded.pairing_attention());
}
}
+473
View File
@@ -0,0 +1,473 @@
//! Windows tray: a hidden top-level window + `Shell_NotifyIconW`, fed by the status poller.
//!
//! The host service (`PunktfunkHost`, LocalSystem) supervises from session 0 and its `serve`
//! child runs as SYSTEM — neither can own a per-user tray icon, so this is a separate small
//! process the installer puts in the HKLM `Run` key (one instance per interactive session,
//! enforced by a `Local\` mutex). Start/Stop/Restart open one UAC consent prompt each
//! (`ShellExecuteW "runas"` on `punktfunk-host.exe service …`) — service control is deliberately
//! left admin-gated rather than DACL-opened to every local user.
use std::os::windows::ffi::OsStrExt;
use std::sync::atomic::{AtomicBool, AtomicIsize, Ordering};
use std::sync::{Mutex, OnceLock};
use windows::core::{w, PCWSTR};
use windows::Win32::Foundation::{
GetLastError, ERROR_ALREADY_EXISTS, HWND, LPARAM, LRESULT, WPARAM,
};
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
use windows::Win32::System::Threading::CreateMutexW;
use windows::Win32::UI::Shell::{
ShellExecuteW, Shell_NotifyIconW, NIF_ICON, NIF_MESSAGE, NIF_SHOWTIP, NIF_TIP, NIM_ADD,
NIM_DELETE, NIM_MODIFY, NIM_SETVERSION, NIN_SELECT, NOTIFYICONDATAW, NOTIFYICON_VERSION_4,
};
use windows::Win32::UI::WindowsAndMessaging::{
AppendMenuW, CreatePopupMenu, CreateWindowExW, DefWindowProcW, DestroyMenu, DestroyWindow,
DispatchMessageW, FindWindowW, GetCursorPos, GetMessageW, LoadIconW, PostMessageW,
PostQuitMessage, RegisterClassW, RegisterWindowMessageW, SetForegroundWindow,
SetMenuDefaultItem, TrackPopupMenuEx, TranslateMessage, HICON, MF_GRAYED, MF_SEPARATOR,
MF_STRING, MSG, SW_HIDE, SW_SHOWNORMAL, TPM_BOTTOMALIGN, TPM_RIGHTBUTTON, WINDOW_EX_STYLE,
WM_APP, WM_CLOSE, WM_COMMAND, WM_CONTEXTMENU, WM_DESTROY, WM_ENDSESSION, WM_NULL, WNDCLASSW,
WS_OVERLAPPED,
};
use crate::status::{Poller, TrayStatus};
/// Keyboard "select" on the icon (Enter/Space) — `NIN_SELECT | NINF_KEY`; the windows crate
/// exports only NIN_SELECT.
const NIN_KEYSELECT: u32 = NIN_SELECT | 0x1;
/// Posted by the poller thread when the status changed (never touch TLS on the UI thread).
const WMAPP_STATUS: u32 = WM_APP + 2;
/// The notify-icon callback message (NOTIFYICON_VERSION_4 semantics).
const WMAPP_NOTIFYCALLBACK: u32 = WM_APP + 1;
// Menu command ids (WM_COMMAND LOWORD(wParam)).
const IDM_HEADER: usize = 0x0100; // disabled status line
const IDM_OPEN_WEB: usize = 0x0101;
const IDM_START: usize = 0x0102;
const IDM_STOP: usize = 0x0103;
const IDM_RESTART: usize = 0x0104;
const IDM_LOGS: usize = 0x0105;
const IDM_EXIT: usize = 0x0106;
const IDM_PAIRING: usize = 0x0107;
/// Icon resource ordinals (embedded by build.rs).
fn icon_ordinal(status: &TrayStatus) -> u16 {
match status {
TrayStatus::Running(_) if status.is_streaming() => 5,
TrayStatus::Running(_) => 2,
TrayStatus::Stopped | TrayStatus::NotInstalled => 3,
TrayStatus::Error(_) => 4,
TrayStatus::Starting | TrayStatus::Degraded => 6,
}
}
/// Global tray state — a tray has exactly one window and one wndproc, which cannot carry a
/// closure environment, so the state lives in a `OnceLock` set before window creation.
struct App {
hwnd: AtomicIsize,
status: Mutex<TrayStatus>,
poller: OnceLock<Poller>,
/// `TaskbarCreated` broadcast id — Explorer restarted, re-add the icon.
taskbar_created: u32,
/// `punktfunk-host.exe` next to this exe (the installer lays both in `{app}`).
host_exe: Option<std::path::PathBuf>,
/// The console answered the poller's live loopback probe — the "Open web console" entry is
/// shown iff opening it would actually work (repo-run consoles included, stopped ones not).
web_console: AtomicBool,
web_port: u16,
}
static APP: OnceLock<App> = OnceLock::new();
fn app() -> &'static App {
APP.get().expect("APP initialized before window creation")
}
fn to_wide(s: &str) -> Vec<u16> {
std::ffi::OsStr::new(s).encode_wide().chain([0]).collect()
}
/// Best-effort log for a windows-subsystem process (no stderr): `%LOCALAPPDATA%\punktfunk\tray.log`.
fn log(msg: &str) {
let Some(base) = std::env::var_os("LOCALAPPDATA") else {
return;
};
let dir = std::path::PathBuf::from(base).join("punktfunk");
let _ = std::fs::create_dir_all(&dir);
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(dir.join("tray.log"))
{
use std::io::Write;
let _ = writeln!(f, "{msg}");
}
}
pub fn run(args: crate::Args) -> anyhow::Result<()> {
let _ = args.autostart; // Linux-only flag, accepted for a uniform command line
if args.quit {
return quit_existing();
}
// One tray per session: `Local\` scopes the mutex to this logon session, so fast-user-switched
// sessions each keep their own icon. Handle deliberately leaked (held for the process life).
// SAFETY: CreateMutexW with a valid nul-terminated name and no security attributes; the
// returned handle is never closed (process-lifetime singleton guard).
let already = unsafe {
match CreateMutexW(None, false, w!("Local\\PunktfunkTray")) {
Ok(_) => GetLastError() == ERROR_ALREADY_EXISTS,
Err(_) => false, // can't tell — carry on rather than losing the icon
}
};
if already {
return Ok(());
}
let host_exe = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.join("punktfunk-host.exe")))
.filter(|p| p.exists());
// SAFETY: RegisterWindowMessageW with a static nul-terminated literal.
let taskbar_created = unsafe { RegisterWindowMessageW(w!("TaskbarCreated")) };
APP.set(App {
hwnd: AtomicIsize::new(0),
status: Mutex::new(TrayStatus::Stopped),
poller: OnceLock::new(),
taskbar_created,
host_exe,
web_console: AtomicBool::new(false), // live-probed by the poller within its first cycle
web_port: args.web_port,
})
.ok()
.expect("run() is called once");
// Hidden top-level window (NOT message-only — those never receive the TaskbarCreated
// broadcast, which is how the icon survives an Explorer restart).
// SAFETY: standard window-class registration + creation; the class name literal outlives the
// call, wndproc is a valid extern "system" fn, and the window is created on this thread which
// then runs the message loop.
let hwnd = unsafe {
let hinstance = GetModuleHandleW(None)?;
let class = WNDCLASSW {
lpfnWndProc: Some(wndproc),
hInstance: hinstance.into(),
lpszClassName: w!("PunktfunkTrayWindow"),
..Default::default()
};
if RegisterClassW(&class) == 0 {
anyhow::bail!("RegisterClassW failed: {:?}", GetLastError());
}
CreateWindowExW(
WINDOW_EX_STYLE(0),
w!("PunktfunkTrayWindow"),
w!("punktfunk tray"),
WS_OVERLAPPED,
0,
0,
0,
0,
None,
None,
Some(hinstance.into()),
None,
)?
};
app().hwnd.store(hwnd.0 as isize, Ordering::SeqCst);
// First NIM_ADD retried across the logon race (the taskbar may not exist yet at sign-in).
let mut added = false;
for _ in 0..10 {
if update_icon(hwnd, true) {
added = true;
break;
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
if !added {
log("Shell_NotifyIconW(NIM_ADD) kept failing — no taskbar?");
}
// The poller owns all network/SCM I/O; it only posts a message here.
let poller = Poller::spawn(
args.mgmt_addr.clone(),
args.mgmt_port,
args.web_port,
Box::new(move |st, console_up| {
*app().status.lock().unwrap() = st;
app().web_console.store(console_up, Ordering::SeqCst);
let hwnd = HWND(app().hwnd.load(Ordering::SeqCst) as *mut _);
// SAFETY: PostMessageW is documented thread-safe; a stale/destroyed hwnd fails
// harmlessly with an error we ignore.
unsafe {
let _ = PostMessageW(Some(hwnd), WMAPP_STATUS, WPARAM(0), LPARAM(0));
}
}),
);
let _ = app().poller.set(poller);
// SAFETY: classic message pump on the window's owning thread.
unsafe {
let mut msg = MSG::default();
while GetMessageW(&mut msg, None, 0, 0).into() {
let _ = TranslateMessage(&msg);
DispatchMessageW(&msg);
}
}
Ok(())
}
/// `--quit`: ask a running instance (this session) to exit — used by the uninstaller before file
/// deletion. High-IL callers may message a medium-IL window (UIPI blocks only low→high).
fn quit_existing() -> anyhow::Result<()> {
// SAFETY: FindWindowW/PostMessageW on a class-name literal; both fail harmlessly when no
// instance is running.
unsafe {
if let Ok(hwnd) = FindWindowW(w!("PunktfunkTrayWindow"), PCWSTR::null()) {
let _ = PostMessageW(Some(hwnd), WM_CLOSE, WPARAM(0), LPARAM(0));
}
}
Ok(())
}
/// Build/refresh the notify icon from the current status. Returns false when the shell rejected
/// the call (no taskbar yet).
fn update_icon(hwnd: HWND, add: bool) -> bool {
let status = app().status.lock().unwrap().clone();
let mut nid = NOTIFYICONDATAW {
cbSize: std::mem::size_of::<NOTIFYICONDATAW>() as u32,
hWnd: hwnd,
uID: 1,
uFlags: NIF_MESSAGE | NIF_ICON | NIF_TIP | NIF_SHOWTIP,
uCallbackMessage: WMAPP_NOTIFYCALLBACK,
..Default::default()
};
// SAFETY: LoadIconW by ordinal from this exe's embedded resources (build.rs); the ordinal is
// one of the ids compiled in, and a failure falls back to a null icon rather than UB.
nid.hIcon = unsafe {
LoadIconW(
Some(GetModuleHandleW(None).unwrap_or_default().into()),
PCWSTR(icon_ordinal(&status) as usize as *const u16),
)
}
.unwrap_or(HICON(std::ptr::null_mut()));
// Tooltip: truncate to the szTip capacity (127 UTF-16 units + nul).
let tip = to_wide(&status.headline());
let n = tip.len().min(nid.szTip.len() - 1);
nid.szTip[..n].copy_from_slice(&tip[..n]);
// SAFETY: nid is fully initialized with a correct cbSize; NIM_* calls only read it.
unsafe {
if add {
if !Shell_NotifyIconW(NIM_ADD, &nid).as_bool() {
return false;
}
let mut v = nid;
v.Anonymous.uVersion = NOTIFYICON_VERSION_4;
let _ = Shell_NotifyIconW(NIM_SETVERSION, &v);
true
} else {
if !Shell_NotifyIconW(NIM_MODIFY, &nid).as_bool() {
// Icon vanished (Explorer crash we missed) — re-add.
return update_icon(hwnd, true);
}
true
}
}
}
/// The right-click menu, rebuilt from the live status each time.
fn show_menu(hwnd: HWND) {
let status = app().status.lock().unwrap().clone();
let running = matches!(
status,
TrayStatus::Running(_) | TrayStatus::Starting | TrayStatus::Degraded
);
let startable = matches!(status, TrayStatus::Stopped | TrayStatus::Error(_));
let can_control = app().host_exe.is_some();
// SAFETY: menu handle created and destroyed here; AppendMenuW copies the item strings, whose
// wide buffers outlive each call. TrackPopupMenuEx requires the foreground quirk handled
// below (SetForegroundWindow before, WM_NULL after) per the Shell_NotifyIcon docs.
unsafe {
let Ok(menu) = CreatePopupMenu() else { return };
let add = |id: usize, text: &str, grayed: bool| {
let wide = to_wide(text);
let flags = if grayed {
MF_STRING | MF_GRAYED
} else {
MF_STRING
};
let _ = AppendMenuW(menu, flags, id, PCWSTR(wide.as_ptr()));
};
add(IDM_HEADER, &status.headline(), true);
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
if app().web_console.load(Ordering::SeqCst) {
add(IDM_OPEN_WEB, "Open web console", false);
let _ = SetMenuDefaultItem(menu, IDM_OPEN_WEB as u32, 0);
if status.pairing_attention() {
add(IDM_PAIRING, "Approve pairing request…", false);
}
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
}
if can_control {
if startable {
add(IDM_START, "Start host", false);
}
if running {
add(IDM_STOP, "Stop host", false);
add(IDM_RESTART, "Restart host", false);
} else if matches!(status, TrayStatus::Error(_)) {
add(IDM_RESTART, "Restart host", false);
}
}
add(IDM_LOGS, "Open logs folder", false);
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
add(IDM_EXIT, "Exit tray", false);
let mut pt = Default::default();
let _ = GetCursorPos(&mut pt);
let _ = SetForegroundWindow(hwnd);
let _ = TrackPopupMenuEx(
menu,
(TPM_RIGHTBUTTON | TPM_BOTTOMALIGN).0,
pt.x,
pt.y,
hwnd,
None,
);
let _ = PostMessageW(Some(hwnd), WM_NULL, WPARAM(0), LPARAM(0));
let _ = DestroyMenu(menu);
}
}
/// `ShellExecuteW` "open" on a URL / folder.
fn shell_open(hwnd: HWND, target: &str) {
let wide = to_wide(target);
// SAFETY: all strings nul-terminated and live across the call.
unsafe {
ShellExecuteW(
Some(hwnd),
w!("open"),
PCWSTR(wide.as_ptr()),
PCWSTR::null(),
PCWSTR::null(),
SW_SHOWNORMAL,
);
}
}
/// One UAC prompt per service action: relaunch the host exe elevated with `service <verb>`.
/// A declined prompt (ERROR_CANCELLED) is deliberately ignored.
fn elevate_service(hwnd: HWND, verb: &str) {
let Some(exe) = app().host_exe.as_ref() else {
return;
};
let exe_w = to_wide(&exe.to_string_lossy());
let params = to_wide(&format!("service {verb}"));
// SAFETY: nul-terminated strings live across the call; "runas" spawns the elevated child
// (hidden console — the tray re-polls for the outcome instead of scraping its output).
unsafe {
ShellExecuteW(
Some(hwnd),
w!("runas"),
PCWSTR(exe_w.as_ptr()),
PCWSTR(params.as_ptr()),
PCWSTR::null(),
SW_HIDE,
);
}
if let Some(p) = app().poller.get() {
p.poke();
}
}
fn open_web_console(hwnd: HWND) {
shell_open(hwnd, &format!("https://localhost:{}", app().web_port));
}
fn open_logs(hwnd: HWND) {
let Some(base) = std::env::var_os("ProgramData") else {
return;
};
let dir = std::path::PathBuf::from(base)
.join("punktfunk")
.join("logs");
shell_open(hwnd, &dir.to_string_lossy());
}
extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
let Some(app) = APP.get() else {
// SAFETY: pass-through for messages arriving before APP is set (CreateWindowExW sends
// WM_NCCREATE/WM_CREATE synchronously — APP is set before that, but stay defensive).
return unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) };
};
match msg {
WMAPP_STATUS => {
update_icon(hwnd, false);
LRESULT(0)
}
WMAPP_NOTIFYCALLBACK => {
// NOTIFYICON_VERSION_4: LOWORD(lParam) is the event.
match (lparam.0 as u32) & 0xffff {
WM_CONTEXTMENU => show_menu(hwnd),
x if x == NIN_SELECT || x == NIN_KEYSELECT => {
if app.web_console.load(Ordering::SeqCst) {
open_web_console(hwnd);
} else {
show_menu(hwnd);
}
}
_ => {}
}
LRESULT(0)
}
WM_COMMAND => {
match (wparam.0) & 0xffff {
IDM_OPEN_WEB => open_web_console(hwnd),
IDM_PAIRING => open_web_console(hwnd),
IDM_START => elevate_service(hwnd, "start"),
IDM_STOP => elevate_service(hwnd, "stop"),
IDM_RESTART => elevate_service(hwnd, "restart"),
IDM_LOGS => open_logs(hwnd),
// SAFETY: DestroyWindow on the wndproc's own window/thread.
IDM_EXIT => unsafe {
let _ = DestroyWindow(hwnd);
},
_ => {}
}
LRESULT(0)
}
WM_CLOSE | WM_ENDSESSION => {
// SAFETY: as above — triggers WM_DESTROY below.
unsafe {
let _ = DestroyWindow(hwnd);
}
LRESULT(0)
}
WM_DESTROY => {
let nid = NOTIFYICONDATAW {
cbSize: std::mem::size_of::<NOTIFYICONDATAW>() as u32,
hWnd: hwnd,
uID: 1,
..Default::default()
};
// SAFETY: minimal, correctly sized nid; NIM_DELETE only reads hWnd/uID.
unsafe {
let _ = Shell_NotifyIconW(NIM_DELETE, &nid);
PostQuitMessage(0);
}
LRESULT(0)
}
m if m == app.taskbar_created => {
// Explorer restarted — the icon is gone; add it back.
update_icon(hwnd, true);
LRESULT(0)
}
// SAFETY: default handling for everything else.
_ => unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) },
}
}
+236
View File
@@ -0,0 +1,236 @@
# Handoff — sealing the gamepad SHM channels
Status: **implemented (Option A), 2026-07-03 — Windows CI + on-glass validation pending.** The design
below was implemented as proposed; the "Implementation notes" section records what was actually built
and the deltas. Remaining: build + sign + redeploy both pad drivers, then the hardware validation plan
(§Validation) — it needs a physical controller on the box.
This closes the one open residual left by the IDD-push sealed-channel work
(`design/idd-push-security.md`): frames were sealed; the gamepad input/output channel was not.
## Unsafe hygiene (2026-07-03 follow-up — the drivers' `unsafe` was confined)
After the seal landed, the pad drivers' `unsafe` footprint (raw `OpenFileMapping`/`MapViewOfFile`,
`read_unaligned`, the whole bootstrap state machine as bare-pointer arithmetic) was pulled into a new
audited crate **`pf-umdf-util`** (`packaging/windows/drivers/pf-umdf-util/`), so the drivers benefit
from Rust instead of being C-in-Rust:
- `section::MappedView` — a mapped section wrapped as bounds- + alignment-checked accessors
(`load_u32`/`store_u32`/`read_bytes`/…). Callers never see the base pointer; an out-of-range offset
asserts instead of corrupting. `ViewCell` holds the adopted view as a leaked `&'static` (the
re-delivery-must-not-unmap rule, now type-enforced).
- `channel::ChannelClient` — the ENTIRE sealed-channel driver side (publish pid → adopt handle →
validate magic+`pad_index`), as a **`#![forbid(unsafe_code)]`** module over `MappedView`. One
implementation both pad drivers share (was hand-duplicated).
- `wdf::{Request, query_location_index, retrieve_next_request}` — the WDF request/memory/property FFI
behind safe methods; a callback turns its raw `WDFREQUEST` into a `Request` token once (the only
`unsafe` at the driver boundary), and completion consumes the token.
Result: `pf-xusb`/`pf-dualsense` business logic is **100 % safe Rust**; the only remaining `unsafe` in
them is the unavoidable WDF *setup* FFI in `DriverEntry`/`EvtDeviceAdd`/the timer, each with a
`// SAFETY:` proof. The display driver `pf-vdisplay` is inherently FFI-bound (D3D11 / IddCx DDIs /
cross-process textures) so it can't be unsafe-*free*, but it's now unsafe-*audited*: every `unsafe {}`
carries a proof. Both invariants are lint-gated across the whole drivers workspace
(`#![deny(unsafe_op_in_unsafe_fn)]` + `#![deny(clippy::undocumented_unsafe_blocks)]`) and enforced by
a new `cargo clippy -D warnings` step in `windows-drivers.yml`. Verified on the RTX box (.173): the
whole workspace builds + clippies + fmt-checks clean; both gamepad DLLs still produce.
## Implementation notes (what was built, 2026-07-03)
- **Contract** (`pf_driver_proto::gamepad`, `GAMEPAD_PROTO_VERSION = 2`): `PadBootstrap` (32 B —
`magic "PFBT"`, `host_proto`, `driver_pid`, `driver_proto`, `data_handle: u64`, `handle_pid`,
`handle_seq`) with `Pod` + `offset_of!` asserts; `xusb_boot_name`/`pad_boot_name`
(`Global\pf…-boot-<index>`) REPLACE the old `*_shm_name` fns (the DATA-section name is gone);
`XusbShm`/`PadShm` gained `pad_index` (carved from reserved space) so the DRIVER validates a
delivery resolves to *its own* pad — the authentic-side answer to the "redirect the dup into a
different pad's WUDFHost" hardening note (the section content is host-written and unreachable by a
sibling LS, so the check can't be spoofed). Both pad drivers now path-dep `pf-driver-proto` (as
pf-vdisplay does) instead of hand-synced literals.
- **Host** (`inject/windows/gamepad_raii.rs`): `Shm::create_unnamed` (DATA, `D:P(A;;GA;;;SY)`) +
`Shm::create_named` (mailbox, SY+LS, **squat-checked**`ERROR_ALREADY_EXISTS` on create is
close+retry×5 then a hard error, so the handshake never runs through a pre-created object; this also
turns the previously-silent two-hosts-same-index cross-wire into a loud failure). `PadChannel` owns
both + the delivery state machine: poll `driver_pid``OpenProcess`
`verify_is_wudfhost` (now shared with the frame broker in `capture/windows/idd_push.rs`) →
`DuplicateHandle` → publish `data_handle`/`handle_pid`, bump `handle_seq` last (Release). Pumped
from each backend's existing service tick (≤4 ms) + a bounded **eager delivery** (1.5 s) at pad-open
so the DS4's `device_type` is readable before hidclass asks for descriptors. Delivery attempts are
**capped at 16 per pad** so a tampered flapping mailbox can't mint unbounded remote handles. Same
pid never retried (failed verify can't be spun into a hot loop).
- **Drivers** (`pf-xusb`, `pf-dualsense`): per-tick `pump_bootstrap()` (the DS timer / every XUSB
IOCTL + a bounded EvtDeviceAdd worker thread for XUSB's no-game-running case) opens the mailbox *by
name each time* — the name existing doubles as host-liveness, replacing the old per-access section
open; mailbox gone → detach (DS additionally resets the pended-read report to neutral instead of
the old frozen-last-state behavior). The driver writes `driver_proto` always but publishes its pid
**only when `host_proto` matches** (fail closed both ways: v1 host never creates a mailbox a v2
driver polls; a v1 driver opens a name that no longer exists). A delivery is adopted once
(CAS on `handle_seq`, reset when the mailbox disappears so a new host session's counter can't
collide), mapped, and validated: `magic` AND `pad_index == SHM_INDEX` — else unmapped + ignored
(the handle is deliberately NOT closed on validation failure: a tampered value could name an
unrelated handle in the driver's own table). The adopted view is cached and never unmapped
(re-delivery swaps + leaks the old 64/256 B mapping on purpose — a concurrent reader may hold it).
Driver log line for validation step 3: `sealed pad channel mapped (index …)`.
- **Not built:** Option B (devnode custom properties). The residual named mailbox is documented and
DoS-bounded; migrate later if it's ever deemed worth removing.
## The problem (why this exists)
Each virtual pad's host↔driver channel is a **named** shared-memory section:
- `Global\pfxusb-shm-<index>` (64 B, [`pf_driver_proto::gamepad::XusbShm`]) — virtual Xbox 360 / XInput.
- `Global\pfds-shm-<index>` (256 B, [`pf_driver_proto::gamepad::PadShm`]) — virtual DualSense / DualShock 4.
Both are created by the SYSTEM host with DACL `D:(A;;GA;;;SY)(A;;GA;;;LS)` (`inject/windows/gamepad_raii.rs`
`Shm::create`) so the driver's WUDFHost (LocalService) can open them by name. That means **a sibling
LocalService process can `OpenFileMapping` the section by name** and:
- **read** the victim's live controller input (buttons/sticks/gyro/touchpad — host→driver `input` region), and
- **inject/forge** gamepad input or rumble (write the `input` region → the driver feeds it to whatever game
has focus; write the `output` region + bump `out_seq` → forge rumble/LED back to the client).
This is the *same* name-open vector we closed for frames, one module over. Severity is lower than desktop
capture (it's game-controller I/O, scoped to the focused app, and requires the attacker to already have
LocalService code execution), but it is real and it is inconsistent to leave named next to a sealed frame ring.
**Not a stopgap:** randomizing the section name is inadequate — the object namespace is enumerable with
`NtQueryDirectoryObject`, so a random name is discoverable. (Same reason it was rejected for frames.) The fix
is to remove the name.
## Why it isn't already sealed the frame way
The frame channel seals cleanly because pf-vdisplay has a **control device** (the IddCx device interface):
the host duplicates the unnamed handles into the driver's WUDFHost and delivers the values over
`IOCTL_SET_FRAME_CHANNEL`, and the driver reports its own pid in the `IOCTL_ADD` reply.
The pad drivers (`pf-dualsense`, `pf-xusb`) are **UMDF HID minidrivers with no control device** — hidclass
owns the device stack and blocks a freely-openable control interface. That is *why* they use a named section
in the first place. So there is no IOCTL to (a) hand the driver a duplicated handle or (b) learn the driver's
WUDFHost pid. Compounding it: `pszDeviceLocation` (the existing host→driver property) is fixed at
`SwDeviceCreate` time — **before** the WUDFHost process exists — so the host can't duplicate a handle into a
not-yet-created process and stamp its value there. A bidirectional, late-bound handshake is required.
## Current architecture (what to modify)
Host (`crates/punktfunk-host/src/inject/windows/`):
- `gamepad_raii.rs``Shm::create(name, size)` creates the **named** section (SY+LS SDDL) + maps it;
`SwDevice` wraps the `SwDeviceCreate` devnode.
- `gamepad_windows.rs` (XUSB), `dualsense_windows.rs` (DualSense/DS4), `dualshock4_windows.rs` — each creates
its `Shm`, then `create_swdevice(index)` / `create_swdevice(profile)` which stamps the pad **index** into
`info.pszDeviceLocation` (a UTF-16 decimal string) and creates `pf_xusb_<index>` / `pf_pad_<index>`.
Driver (`packaging/windows/drivers/pf-{xusb,dualsense}/src/lib.rs`):
- `query_shm_index(device)``WdfDeviceAllocAndQueryProperty(DevicePropertyLocationInformation)` → parses the
decimal → `SHM_INDEX` static.
- On first control activity it builds `format!("Global\\pf…-shm-{}", SHM_INDEX)`, `OpenFileMappingW` +
`MapViewOfFile`. The dualsense driver also runs a ~125 Hz timer (writes `driver_heartbeat`) — an existing
poll loop to piggyback a bootstrap-wait on.
Contract (`crates/pf-driver-proto/src/lib.rs` `mod gamepad`): owns `XusbShm`/`PadShm` layouts, the magics,
`xusb_shm_name`/`pad_shm_name`, `device_type`, `GAMEPAD_PROTO_VERSION`, and the driver_proto/heartbeat fields.
## Proposed design — a late-bound bootstrap handshake
Split each pad's channel into **(1) an unnamed DATA section** (the real `XusbShm`/`PadShm`, host↔driver) and
**(2) a tiny bootstrap mailbox** that carries only a magic + the driver's pid + a handle value. The handshake:
1. **Host**, per pad: create the DATA section **unnamed** (`CreateFileMappingW` with `PCWSTR::null()`, DACL
`D:P(A;;GA;;;SY)` — SYSTEM-only, exactly as the sealed frame ring now uses; the driver reaches it by
duplicated handle, which carries access, so no LS ACE is needed). Then create the devnode via
`SwDeviceCreate`, stamping the pad index into `pszDeviceLocation` **as today** (the index still identifies
*which* pad's bootstrap the driver should use).
2. **Driver** `EvtDeviceAdd`: read the index (unchanged `query_shm_index`). Write `std::process::id()` where
the host can read it, then **poll** (piggyback the existing timer) for a delivered handle value; map the
DATA section from it once non-zero.
3. **Host**: learn the driver's pid, `OpenProcess(PROCESS_DUP_HANDLE | PROCESS_QUERY_LIMITED_INFORMATION)`,
**verify it is the WUDFHost servicing this pad's devnode** (see hardening note), `DuplicateHandle` the
DATA section into the WUDFHost, and deliver the resulting handle value back to the driver.
Two viable transports for steps 23's pid-out / handle-in (pick one):
- **Option A — named bootstrap mailbox** (`Global\pf…-boot-<index>`, ~32 B, SY+LS): host creates it; driver
opens it by name (index from location), writes `driver_pid`, spins on `data_handle` != 0; host polls
`driver_pid`, dups the DATA section in, writes `data_handle` + a ready seq. **Safe to leave named + SY+LS**
because it carries *only* a pid (not sensitive) and a handle value (meaningless outside the target WUDFHost)
— identical to the frame channel's "the bootstrap ACL is not load-bearing" argument. A sibling LS that reads
it learns nothing exploitable; one that tampers it can at worst feed a bogus pid/handle → the driver maps a
value that doesn't resolve in its own table → **DoS, not a breach** (the attacker cannot place a valid
section handle in the WUDFHost, so it cannot make the driver map an attacker-controlled section). *Fastest to
build — reuses the existing named-section + poll machinery.*
- **Option B — devnode custom properties** (no `Global\` object at all): driver writes its pid via
`WdfDeviceAssignProperty(DEVPROPKEY_pf_pad_pid)`; host reads it via `CM_Get_DevNode_PropertyW` /
`SetupDiGetDevicePropertyW`, dups in, writes a `DEVPROPKEY_pf_pad_handle` property; driver re-queries it in
its timer. Tighter (property store isn't world-readable like the Global namespace) but more moving parts and
UMDF-property-write ergonomics to prove out. *Cleaner end-state.*
Recommendation: **build Option A first** (small, mirrors the frame channel, gets the DATA section unnamed —
which is the actual isolation win, proven by #3 below), then optionally migrate the bootstrap to Option B if
the residual named mailbox is deemed worth removing.
## Reuse the frame-channel precedent
- **Ownership/adopt-on-success** discipline from `capture/windows/idd_push.rs` `ChannelBroker` — exactly one
side ever closes a duplicated handle value; reap remote duplicates (`DUPLICATE_CLOSE_SOURCE`) on any failure.
- **`verify_is_wudfhost`** (`idd_push.rs`) — before duplicating into the driver-reported pid, confirm it's
`%SystemRoot%\System32\WUDFHost.exe`. **Strengthen it here**: also confirm the pid is the host *servicing
this pad's devnode* (walk devnode → process, e.g. via the driver writing a per-pad nonce it echoes, or a
devnode/PID association) so a tampered bootstrap can't redirect the dup into a *different* pad's WUDFHost.
- **Contract in `pf_driver_proto::gamepad`** — add the bootstrap layout (`PadBootstrap { magic, driver_pid,
data_handle: u64, seq }`) with `Pod` + `offset_of!` asserts, bump `GAMEPAD_PROTO_VERSION`, and (Option A)
keep `pad_shm_name`/`xusb_shm_name` only for the bootstrap mailbox, dropping the data-section name.
- **SDDL** on the DATA section: `D:P(A;;GA;;;SY)` (SYSTEM-only) — validated safe for a duplicated-handle
consumer on the frame ring (the driver's `OpenSharedResource`/`MapViewOfFile` on a handle does not re-check
the object DACL).
## Security properties after the change
- The **DATA section is unnamed** and only ever handle-duplicated into the pad WUDFHost. Empirically
(`design/idd-push-security.md`, RTX box 2026-07-03) a **LocalService token is DACL-denied `OpenProcess` on a
UMDF WUDFHost for every access right incl. `QUERY_LIMITED`** — so a sibling LS cannot dup the handle out or
read the WUDFHost's memory. Unnamed + unopenable-host ⇒ no sibling-LS path to the input/output data. This is
the same guarantee the frame channel now has, and it rests on the same verified property.
- **Residual (Option A):** the bootstrap mailbox stays named + SY+LS, but carries only a pid + handle value →
worst case a sibling LS causes a **gamepad DoS**, never a read or injection. Option B removes even that.
- **Unchanged inherent limits:** admin/SYSTEM = total; the game reading the pad sees the input by design.
## Validation plan (needs hardware)
The blocker for calling this done is that it **requires a physical controller on the box** — the memory notes
repeatedly flag the gamepad path as "needs a physical pad to live-verify," and neither the probe nor a
synthetic client exercises a real game reading the virtual pad.
1. Build + sign + redeploy `pf-dualsense` and `pf-xusb` (same loop as pf-vdisplay:
`packaging/windows/drivers/deploy-dev.ps1` per driver, or `redeploy-*`; DriverVer must strictly increase).
Bump `GAMEPAD_PROTO_VERSION` — a v_new host against a v_old pad driver (or vice-versa) must fail closed, so
deploy host + both pad drivers together.
2. Connect a real client with a physical controller; confirm in a game that input works and rumble/LED return.
3. Driver log (`C:\Users\Public\pfds-driver.log` / `pfxusb-driver.log` in debug builds): confirm the driver
reports its pid, receives a handle, and maps the DATA section (add a `dbglog!` "sealed pad channel mapped").
4. Re-run the **sibling-LS `OpenFileMapping` test**: from a LocalService scheduled task, attempt to open the
old `Global\pf…-shm-<index>` name — it must now **fail (name gone)**, and attempting to open the bootstrap
(Option A) must yield only pid+handle bytes. (Reuse the scheduled-task P/Invoke harness from the #3 frame
test — see the session that produced `design/idd-push-security.md`.)
5. Multi-pad: two controllers → two devnodes, two unnamed DATA sections, two bootstraps by index; confirm no
cross-talk and clean teardown (`SwDeviceClose` + host handle close; the WUDFHost dies with its devnode).
## Risks / gotchas
- **Regression risk to a working feature.** Gamepad input currently works on glass; this reroutes its
bootstrap. Keep the change behind the `GAMEPAD_PROTO_VERSION` bump and be ready to revert both drivers.
- **Chicken-and-egg timing.** The driver loads and wants the handle before the host has dup'd it — the poll
loop must tolerate a bounded wait (mirror the frame path's `wait_for_attach`, ~4 s) and the driver must not
block `EvtDeviceAdd` on it (spin in the timer, not the add callback).
- **Handle value in shared memory is a `u64`.** A WUDFHost handle value is process-local; writing it to the
bootstrap is safe (meaningless elsewhere), but the driver must treat it as untrusted (validate the mapped
DATA section's magic before use — the existing `XusbShm`/`PadShm` magic already gives this).
- **Two drivers, one contract.** DualSense and DualShock 4 share `pf-dualsense`/`PadShm`; XUSB is separate.
Factor the bootstrap into `pf_driver_proto::gamepad` so both drivers + the host use one definition (as the
frame channel does).
## Effort
Medium — comparable to the frame sealed-channel change but across **two** drivers plus the host inject code,
and gated on **physical-controller validation** that can't be driven over SSH. Files: `pf_driver_proto`
(gamepad module), `inject/windows/{gamepad_raii,gamepad_windows,dualsense_windows,dualshock4_windows}.rs`,
`packaging/windows/drivers/pf-{xusb,dualsense}/src/lib.rs`. Reference implementation: the frame sealed channel
(`capture/windows/idd_push.rs` + `packaging/windows/drivers/pf-vdisplay/src/{control,monitor,frame_transport}.rs`
+ `pf_driver_proto` `control`/`frame`).
+145
View File
@@ -0,0 +1,145 @@
# IDD-push frame channel — security model (the sealed channel)
Status: **implemented** (host `capture/windows/idd_push.rs` + driver
`packaging/windows/drivers/pf-vdisplay/src/{control,monitor,frame_transport}.rs`, contract
`crates/pf-driver-proto` v2). Windows CI-validated; on-glass validation pending.
## What is being protected
The IDD-push path moves **whole-desktop frames** — including the secure desktop (UAC prompts, the
lock screen) — from the pf-vdisplay driver (UMDF, running in a `WUDFHost.exe` under LocalService)
into the SYSTEM host for encoding. That data is SYSTEM-tier-sensitive, and because we bypass the OS
capture APIs (Desktop Duplication / WGC), **we own the isolation those APIs would have provided.**
DDA's isolation property is that capturer and consumer are the same process: there is no openable
channel at all — to reach the frames you must own the capturing process. The sealed channel
reproduces exactly that property for our two-process design.
## The design
```
┌──────────────────────────┐ control device (SY+BA only) ┌───────────────────────────┐
│ Host (SYSTEM service) │ ── IOCTL_SET_FRAME_CHANNEL: handle ────▶ │ pf-vdisplay driver │
│ creates header/event/ │ VALUES only (integers) │ (WUDFHost, LocalService) │
│ ring textures UNNAMED, │ │ maps/opens the duplicated │
│ DuplicateHandle()s them │ ◀── frames via keyed-mutex textures ──── │ handles; publishes frames │
│ INTO WUDFHost, encodes │ (no names anywhere) │ │
└──────────────────────────┘ └───────────────────────────┘
trust boundary: only these two processes ever hold a handle to any frame object
```
1. **Every frame object is unnamed** (header section, frame-ready event, all ring textures —
`CreateFileMappingW`/`CreateEventW`/`CreateSharedHandle` with a null name). An unnamed object is
in no namespace: it cannot be enumerated (`NtQueryDirectoryObject` can't see it), cannot be
opened by name, and cannot be pre-created ("squatted"). It can be shared **only** by handle
duplication.
2. **The host is the broker.** SYSTEM opens the driver's WUDFHost with `PROCESS_DUP_HANDLE` (the pid
comes from the `IOCTL_ADD` reply, per-monitor, so a WUDFHost restart can't leave us duplicating
into a dead process) and `DuplicateHandle`s each object in. The reverse direction — LocalService
injecting into SYSTEM — is correctly denied by the OS, which is why the broker must be the host.
3. **The bootstrap carries only integers.** `IOCTL_SET_FRAME_CHANNEL` delivers the duplicated handle
*values*. A handle value is only meaningful inside the target process's handle table: a third
party that read (or even forged) the message would learn nothing openable and could at most feed
values that don't resolve — a DoS of its own session, not a read. The bootstrap's ACL is therefore
**not load-bearing**; we still restrict the control device to `D:P(A;;GA;;;SY)(A;;GA;;;BA)`
(INF `Security`), because ADD/REMOVE/CLEAR_ALL shouldn't be world-callable either.
Net result: the only way to reach the frames is to already run code as SYSTEM (the host) or inside
that specific WUDFHost (the driver) — DDA's property, achieved in user mode.
## Why user-mode, not a kernel driver
Ring level does not govern cross-process memory visibility — the handle/VAD access checks do; a user
process cannot `ReadProcessMemory` a LocalService process regardless of rings. What kernel-mode
*would* change is the blast radius of a driver bug: UMDF caps a pf-vdisplay compromise at the
LocalService token, a KMDF display driver would make it ring-0 full-system. Least-blast-radius is
the reason punktfunk ships **zero** kernel drivers (the gamepad stack dropped ViGEmBus for UMDF for
the same reason). The correct control for "SYSTEM-tier data in the channel" is sealing the channel —
done above — not raising the ring.
## Handle-lifetime invariants (the auditable list)
1. Frame objects unnamed; bootstrap carries only handle values. ✔ by construction
2. `bInheritHandle: false` on every object — no child inherits a handle. ✔
3. Zero-init header + atomic `magic`-last publish (the driver never acts on a half-initialized
ring); generation-tagged publish tokens reject stale-ring frames. ✔
4. Attacker-influenced header fields are bounds-checked before use (generation/seq/slot unpacking;
`ring_len` clamped; the driver validates `IOCTL_SET_FRAME_CHANNEL` before adopting anything). ✔
5. **Adopt-on-success-only:** the driver owns (and eventually closes) the delivered handles iff the
IOCTL completed successfully; on ANY error completion it leaves them untouched and the host reaps
its remote duplicates (`DUPLICATE_CLOSE_SOURCE`). Exactly one side closes each value — no
double-close of possibly-reused handle values, no leak on a half-delivered channel. ✔
6. Single ownership inside the driver: each delivery lives in exactly one place (monitor stash →
publisher), and whichever owner dies — replaced stash, dropped publisher, removed monitor, reaped
watchdog, departed device — closes the handles (`FrameChannel`/publisher `Drop`). Host-side
objects are RAII (`MappedSection`, `OwnedHandle`); nothing survives the capturer. ✔
7. The object DACL is `D:P(A;;GA;;;SY)`**SYSTEM only, protected**. Since the driver reaches the
objects via duplicated handles (which carry their own access; `OpenSharedResource1` on a handle does
not re-check the object DACL), the LocalService ACE was dropped — the minimal DACL. ✔ *(on-glass
confirmed 2026-07-03: the driver still attaches + delivers frames with SYSTEM-only objects.)*
8. **The duplication target is verified.** Before duplicating frame handles into `AddReply.wudf_pid`,
the host confirms that pid is `%SystemRoot%\System32\WUDFHost.exe` (`verify_is_wudfhost`). A spoofed
devnode advertising our interface GUID cannot redirect frames to an arbitrary process. ✔
9. **Handles are duplicated with least privilege, not `DUPLICATE_SAME_ACCESS`.** The driver's copy of
the header section is `SECTION_MAP_READ|WRITE` (matched by the driver mapping `FILE_MAP_READ|WRITE`,
not `FILE_MAP_ALL_ACCESS`), the frame-ready event is `EVENT_MODIFY_STATE` (the driver only signals
it), and the ring textures keep their already-scoped `CreateSharedHandle` access
(`DXGI_SHARED_RESOURCE_READ|WRITE`). So a compromised driver's handles can map/signal but cannot
`WRITE_DAC`/`WRITE_OWNER`/`DELETE` the objects — the "give unnamed shared objects proper (minimal)
security attributes, because `DuplicateHandle` can still reach them" discipline (Raymond Chen,
*devblogs 2015-06-04*). Marginal here (the driver is already a trusted frame endpoint) but correct
hygiene, and it applies identically to the gamepad DATA section. ✔ *(on-glass confirmed 2026-07-03:
the driver attaches + streams `frames=7035` with the least-access header handle.)*
Ring recreation (mid-session HDR flip) and host build-retries re-deliver a complete fresh handle set;
the driver treats a pending delivery as newest-wins (a retry's ring is a *different* header mapping,
whose generation bump an old publisher can never observe).
## Empirical verification (2026-07-03, RTX box)
The headline claim — "reaching a frame requires already being one of the two endpoint processes" —
was tested, not just argued. A **LocalService-token** process (scheduled task, the sibling-service
stand-in) attempting `OpenProcess` on the pf_vdisplay WUDFHost was **denied every access right**:
`PROCESS_DUP_HANDLE`, `PROCESS_VM_READ`, `PROCESS_QUERY_INFORMATION`, and even
`PROCESS_QUERY_LIMITED_INFORMATION``ERROR_ACCESS_DENIED`. The `QUERY_LIMITED` denial is decisive:
it is a read-class right MIC permits across integrity levels, so its denial is a **DACL exclusion of
the LocalService SID**, not an integrity ceiling — meaning even a higher-integrity LocalService
*service* is denied (LocalService lacks `SeDebugPrivilege`, so it cannot bypass the DACL). Combined
with the objects being unnamed, a sibling LocalService has **no reachable path to a frame**: no
name to open, no way to dup the handles out of WUDFHost, no way to read WUDFHost's memory. The
baseline (an elevated admin, holding `SeDebugPrivilege`) opened WUDFHost freely — expected, and the
reason "admin/SYSTEM = total" stays on the residual list below.
## Residual limits — the honest floor
* **The virtual display is a real monitor.** Any process in the interactive session can capture it
through the ordinary OS APIs (DDA/WGC/BitBlt), exactly as it can capture any physical monitor.
That floor is identical for every virtual-display streaming stack (Sunshine + VDD, Apollo/SudoVDA);
the sealed channel keeps *our* transport above that floor rather than below it. **This is the single
most realistic way for unprivileged session code to see the streamed pixels, and it is outside our
channel entirely.**
* **The gamepad channels are now sealed too** (2026-07-03, `design/gamepad-channel-sealing.md`,
gamepad proto v2 — on-glass validation pending): the pad DATA sections (`XusbShm`/`PadShm`) are
UNNAMED with `D:P(A;;GA;;;SY)`, handle-duplicated into the pad's WUDFHost by the host broker
(`inject/windows/gamepad_raii.rs` `PadChannel`, reusing this design's `verify_is_wudfhost` +
adopt-on-success discipline), and the driver validates the mapped section's magic + `pad_index`
before use. The pad drivers have no control device (hidclass), so the handshake runs over a tiny
**named bootstrap mailbox** (`Global\pf…-boot-<index>`, SY+LS, `PadBootstrap`) that carries only
pids and a handle value — nothing exploitable; the *residual* is that a sibling LocalService can
tamper the mailbox for a **gamepad DoS** (never a read or an injection; deliveries are capped, and
the mailbox is squat-checked at create). The old sibling-LS read/inject vector on
`Global\pf…-shm-*` is gone — the names no longer exist.
* **Admin / SYSTEM = total.** The control device is `D:P(A;;GA;;;SY)(A;;GA;;;BA)`, so an admin can drive
`IOCTL_SET_FRAME_CHANNEL` (DoS a live session) and, with `SeDebugPrivilege`, dup a section into
WUDFHost to exfiltrate; and an admin can plant a fake devnode with our interface GUID to impersonate
the driver. All admin-gated (no non-privileged escalation), but the control plane is explicitly not a
boundary against admin. The host↔driver channel has no mutual authentication beyond the `GET_INFO`
version handshake + the `verify_is_wudfhost` image check.
* **`WDA_EXCLUDEFROMCAPTURE` windows are visible.** IDD-push taps the *present* side, not the
*capture* side, so windows that exclude themselves from capture still appear in the stream — true
of every virtual-display streaming stack. Untested on our lab box; treat as expected behavior.
* **DRM/HDCP:** protected content is blanked by DWM at composition, and HDCP is a monitor↔GPU
handshake an indirect display cannot satisfy — neither is bypassed by this path.
* IDD-push is currently the **sole Windows capture path** (DDA and the WGC relay were removed). An
OS-mediated-capture-only mode would trade away secure-desktop capture and latency; if a deployment
requires it, that's a feature request, not a toggle that exists today.
+14 -3
View File
@@ -45,7 +45,10 @@ interactive session for secure-desktop capture (why MSIX is unusable - see
| `packaging/windows/drivers/pf-dualsense/` `pf-xusb/` | `build-gamepad-drivers.ps1` (sign the workspace build) | `pf_{dualsense,xusb}.{dll,inf,cat}` + shared `.cer` | | `packaging/windows/drivers/pf-dualsense/` `pf-xusb/` | `build-gamepad-drivers.ps1` (sign the workspace build) | `pf_{dualsense,xusb}.{dll,inf,cat}` + shared `.cer` |
| `packaging/windows/pf-vkhdr-layer/` | `pack-host-installer.ps1` (`cargo build --release`) | `pf_vkhdr_layer.dll` + `.json` | | `packaging/windows/pf-vkhdr-layer/` | `pack-host-installer.ps1` (`cargo build --release`) | `pf_vkhdr_layer.dll` + `.json` |
| `web/` | `scripts/windows/build-web.ps1` (`bun run build`) | self-contained `.output` | | `web/` | `scripts/windows/build-web.ps1` (`bun run build`) | self-contained `.output` |
| `packaging/windows/nvenc/nvenc.def` | `gen-nvenc-importlib.ps1` (llvm-dlltool) | `nvencodeapi.lib` (link import, no GPU/SDK) |
(NVENC needs no build artifact: its entry points are resolved at runtime from the driver's
`nvEncodeAPI64.dll` — a link-time import would prevent the all-vendor exe from starting on
AMD/Intel-only machines.)
## 3. The driver workspace - `packaging/windows/drivers/` ## 3. The driver workspace - `packaging/windows/drivers/`
@@ -118,8 +121,9 @@ needs, on the runner:
to the runner default). *History:* LLVM 21.1.2 was briefly pinned (`C:\llvm-21`) to dodge a to the runner default). *History:* LLVM 21.1.2 was briefly pinned (`C:\llvm-21`) to dodge a
bindgen-0.71 layout-test overflow on clang 22; the 0.72 bump retired that pin, so there's now one bindgen-0.71 layout-test overflow on clang 22; the 0.72 bump retired that pin, so there's now one
toolchain for both driver builds (the pack and `windows-drivers.yml`). toolchain for both driver builds (the pack and `windows-drivers.yml`).
- NVENC import lib synthesised from a 2-export `.def` via `llvm-dlltool` (`gen-nvenc-importlib.ps1`) - - NVENC needs nothing at build time: the entry points are runtime-loaded from the driver's
no GPU or NVIDIA SDK at build time. `nvEncodeAPI64.dll` (`encode/windows/nvenc.rs` `load_api`). A link-time import would stop the
all-vendor exe from even starting on AMD/Intel-only machines.
- `FFMPEG_DIR` (the BtbN gpl-shared x64 tree) for the AMD/Intel AMF/QSV link; NASM + CMake + - `FFMPEG_DIR` (the BtbN gpl-shared x64 tree) for the AMD/Intel AMF/QSV link; NASM + CMake +
`CMAKE_POLICY_VERSION_MINIMUM=3.5` for the CMake-from-source deps (aws-lc, opus). `CMAKE_POLICY_VERSION_MINIMUM=3.5` for the CMake-from-source deps (aws-lc, opus).
- **Gotcha:** `CARGO_HOME` must be an ASCII path (a non-ASCII username breaks SDL3's MSVC precompiled - **Gotcha:** `CARGO_HOME` must be an ASCII path (a non-ASCII username breaks SDL3's MSVC precompiled
@@ -143,6 +147,13 @@ tasks** (all default-checked): install the pf-vdisplay driver, install the gamep
HDR Vulkan layer, start the service. Silent install: `/VERYSILENT` (omit a task with HDR Vulkan layer, start the service. Silent install: `/VERYSILENT` (omit a task with
`/MERGETASKS="!installdriver"`). `/MERGETASKS="!installdriver"`).
**OS floor: Windows 11 22H2 (build 22621)**`MinVersion=10.0.22621`, with a `[Messages]
WinVersionTooLowError` override naming the requirement. pf-vdisplay is built against **IddCx 1.10**
(the 1.10 `IddCxStub`, HDR `*2` DDIs, FP16 caps; no runtime `IddCxGetVersion` downgrade), which first
shipped in Windows 11 22H2 — on Windows 10 (incl. LTSC) / Windows 11 21H2 the driver package installs
but the device fails start with Code 10 `STATUS_DEVICE_POWER_FAILURE` (field-reported on Windows 10
LTSC, 2026-07). The installer gate turns that late failure into an upfront message.
Install-time work runs from `punktfunk-host.exe` subcommands, **not** locale-parsed PowerShell *files* - Install-time work runs from `punktfunk-host.exe` subcommands, **not** locale-parsed PowerShell *files* -
the `[Run]` section calls `driver install [--gamepad] --dir <stage>` and `web setup --app-dir <app> the `[Run]` section calls `driver install [--gamepad] --dir <stage>` and `web setup --app-dir <app>
[--password-file <f>]` (`crates/punktfunk-host/src/windows/install.rs`). This is the ANSI-codepage [--password-file <f>]` (`crates/punktfunk-host/src/windows/install.rs`). This is the ANSI-codepage
+23 -13
View File
@@ -82,12 +82,18 @@ query.
**IDD-push is the universal primary path.** Capture comes straight from the driver's shared keyed-mutex **IDD-push is the universal primary path.** Capture comes straight from the driver's shared keyed-mutex
texture ring (`capture/windows/idd_push.rs`) — no Desktop Duplication, no `win32u` reparenting hook. The texture ring (`capture/windows/idd_push.rs`) — no Desktop Duplication, no `win32u` reparenting hook. The
host creates the ring; the driver opens it (permissive `D:(A;;GA;;;WD)` SDDL). The generation-tagged host creates the ring as a **sealed channel** (proto v2, `design/idd-push-security.md`): the header,
`latest = gen<<40 | seq<<8 | slot` stale-ring reject kills the HDR-flip garbage frame; a host-owned frame-ready event, and ring textures are **unnamed** (nothing to enumerate, open by name, or squat), and
3-slot `OUT_RING` rotated per frame is the texture-ownership contract that enables `pipeline_depth=2` the host `DuplicateHandle`s them into the driver's WUDFHost and delivers the handle *values* over the
(convert/copy on the 3D engine overlapping NVENC on the ASIC). It captures the **secure desktop** SYSTEM+admins-only control device (`IOCTL_SET_FRAME_CHANNEL`), so only the two endpoint processes can
(Winlogon/UAC/lock) directly (validated 2026-06-25), so there is no separate secure capturer in the ever reach a frame — DDA's isolation property in user mode. (The objects keep a `D:(A;;GA;;;SY)(A;;GA;;;LS)`
primary path. DACL as defense-in-depth; it is no longer the isolation boundary. This supersedes the earlier named-ring
scheme, which was world-openable `Global\pfvd-*` (`D:(A;;GA;;;WD)`) then SY+LS-scoped.) The
generation-tagged `latest = gen<<40 | seq<<8 | slot` stale-ring reject kills the HDR-flip garbage frame;
a host-owned 3-slot `OUT_RING` rotated per frame is the texture-ownership contract that enables
`pipeline_depth=2` (convert/copy on the 3D engine overlapping NVENC on the ASIC). It captures the
**secure desktop** (Winlogon/UAC/lock) directly (validated 2026-06-25), so there is no separate secure
capturer in the primary path.
- **Open-time fallback:** `IddPushCapturer::open` waits a bounded ~4 s for a *first frame* (not just - **Open-time fallback:** `IddPushCapturer::open` waits a bounded ~4 s for a *first frame* (not just
`DRV_STATUS_OPENED`); on attach failure it returns the keepalive back so `capture.rs` opens **DDA** on `DRV_STATUS_OPENED`); on attach failure it returns the keepalive back so `capture.rs` opens **DDA** on
@@ -120,10 +126,12 @@ loss-recovery by query (only Windows direct-NVENC overrides it; the GameStream l
### 2.5 Host↔driver ABI & the `pf-vdisplay` driver ### 2.5 Host↔driver ABI & the `pf-vdisplay` driver
`pf-driver-proto` is one `no_std` crate in both build graphs. It owns the **frame plane** (`FrameToken` `pf-driver-proto` is one `no_std` crate in both build graphs. It owns the **frame plane** (`FrameToken`
+ `Global\pfvd-*` names), the **control plane** (a fresh interface GUID — *not* SudoVDA's `e5bcc234`; + `SharedHeader`; since proto v2 the frame objects are **unnamed** — no `Global\pfvd-*` names — and are
contiguous `0x900` IOCTL ops; a `GET_INFO` version handshake the host **asserts** + bails on mismatch), delivered by handle duplication over `IOCTL_SET_FRAME_CHANNEL`, the *sealed channel*:
and the **gamepad SHM** (`XusbShm`/`PadShm` incl. `device_type`). `bytemuck`-`Pod` + `size_of` **and** `design/idd-push-security.md`), the **control plane** (a fresh interface GUID — *not* SudoVDA's
`offset_of!` asserts make ABI drift a **compile error**. `e5bcc234`; contiguous `0x900` IOCTL ops; a `GET_INFO` version handshake the host **asserts** + bails on
mismatch), and the **gamepad SHM** (`XusbShm`/`PadShm` incl. `device_type`). `bytemuck`-`Pod` +
`size_of` **and** `offset_of!` asserts make ABI drift a **compile error**.
The driver (`packaging/windows/drivers/pf-vdisplay/src/`) is an all-Rust UMDF IddCx driver on The driver (`packaging/windows/drivers/pf-vdisplay/src/`) is an all-Rust UMDF IddCx driver on
`windows-drivers-rs` + the `iddcx` `wdk-sys` subset; the STEP 08 build is the checklist in §6.3, its `windows-drivers-rs` + the `iddcx` `wdk-sys` subset; the STEP 08 build is the checklist in §6.3, its
@@ -200,8 +208,10 @@ These are expensive empirical wins; keep them intact when touching the code:
the hot-loop `KeyedMutexGuard`, and the driver's `pod_init!`; all box-validated, clean `sc stop` in the hot-loop `KeyedMutexGuard`, and the driver's `pod_init!`; all box-validated, clean `sc stop` in
~1 s). The driver already has the deny. Revisit D1-host as a final discipline pass (staged per-module) ~1 s). The driver already has the deny. Revisit D1-host as a final discipline pass (staged per-module)
if desired. if desired.
5. **M6 scaffolding cleanup** delete the bring-up diagnostics (`spawn_observer`/`DebugBlock` in 5. **M6 scaffolding cleanup** — the bring-up diagnostics (`spawn_observer`/`DebugBlock` in
`idd_push.rs`) and, once full parity is proven on glass, the host monoliths. `idd_push.rs`) were deleted with the sealed-channel change (they were the last fixed-name
`Global\` objects on the frame path); once full parity is proven on glass, the host monoliths
remain.
**Explicitly NOT doing (stability decision): E1 — driver `DeviceContext` ownership + per-`IDDCX_MONITOR` **Explicitly NOT doing (stability decision): E1 — driver `DeviceContext` ownership + per-`IDDCX_MONITOR`
`EvtCleanupCallback`.** The current process-global design is *sound*: IddCx DDIs receive only an `EvtCleanupCallback`.** The current process-global design is *sound*: IddCx DDIs receive only an
@@ -260,7 +270,7 @@ Local pre-push checks (this Linux box can't compile the Windows paths):
cargo test -p pf-driver-proto # the ABI crate (cross-platform) cargo test -p pf-driver-proto # the ABI crate (cross-platform)
cargo check -p punktfunk-host # Linux paths; win_* mods are #[cfg(windows)] cargo check -p punktfunk-host # Linux paths; win_* mods are #[cfg(windows)]
cargo clippy -p punktfunk-host --all-targets -- -D warnings cargo clippy -p punktfunk-host --all-targets -- -D warnings
# Windows host clippy (on the box): PUNKTFUNK_NVENC_LIB_DIR=C:\t\nvenc; # Windows host clippy (on the box; NVENC needs no import lib — runtime-loaded):
# cargo clippy -p punktfunk-host --features nvenc --target x86_64-pc-windows-msvc -- -D warnings # cargo clippy -p punktfunk-host --features nvenc --target x86_64-pc-windows-msvc -- -D warnings
# Driver build (on the box): cd packaging/windows/drivers; Version_Number=10.0.26100.0; # Driver build (on the box): cd packaging/windows/drivers; Version_Number=10.0.26100.0;
# LIBCLANG_PATH='C:\Program Files\LLVM\bin'; cargo build # LIBCLANG_PATH='C:\Program Files\LLVM\bin'; cargo build
+3
View File
@@ -19,6 +19,9 @@ mid-stream. You flip between Gaming Mode and Desktop with Bazzite's normal Steam
> pure desktop machine, [Ubuntu/Fedora KDE](/docs/ubuntu-kde) or [GNOME](/docs/ubuntu-gnome) are > pure desktop machine, [Ubuntu/Fedora KDE](/docs/ubuntu-kde) or [GNOME](/docs/ubuntu-gnome) are
> simpler. > simpler.
> New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of
> the machine, so keep it on a trusted LAN or VPN and require pairing.
## Install ## Install
The host ships as an RPM in punktfunk's **Gitea RPM registry** (public), so a Bazzite / Fedora The host ships as an RPM in punktfunk's **Gitea RPM registry** (public), so a Bazzite / Fedora
+3
View File
@@ -10,6 +10,9 @@ systemd service and uses KWin to create per-client virtual displays, captured ze
> Validated live on **Fedora 44 KDE Plasma** with an RTX 4090: KWin virtual output + full > Validated live on **Fedora 44 KDE Plasma** with an RTX 4090: KWin virtual output + full
> zero-copy capture. Everything below is the reproducible flow — paste it on a fresh box. > zero-copy capture. Everything below is the reproducible flow — paste it on a fresh box.
> New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of
> the machine, so keep it on a trusted LAN or VPN and require pairing.
The setup has three parts: **NVIDIA driver****host RPM****KWin streaming session**. The setup has three parts: **NVIDIA driver****host RPM****KWin streaming session**.
## 1. NVIDIA driver (RPM Fusion akmod) ## 1. NVIDIA driver (RPM Fusion akmod)
+9 -5
View File
@@ -6,7 +6,11 @@ description: Install the punktfunk host — on Linux from its package registry,
On Linux, the package registries are the real distribution channel. Pick your distro, add the repo, and On Linux, the package registries are the real distribution channel. Pick your distro, add the repo, and
install with your native package manager. Each row links to the full per-distro guide (add the repo, install with your native package manager. Each row links to the full per-distro guide (add the repo,
first-run steps, the web console) — those are the source of truth, so this page doesn't duplicate them. first-run steps, the web console) — those are the source of truth, so this page doesn't duplicate them.
On **Windows** (NVIDIA), the host ships as a signed installer instead — see [Windows](#windows-nvidia). On **Windows**, the host ships as a signed installer instead — see [Windows](#windows).
> **First, read [Security & Safe Use](/docs/security).** A streaming host is remote control of the
> machine. It's built for trusted local networks — don't expose it to the internet, and be thoughtful
> about which machine you host on (especially on Windows).
## Pick your distro ## Pick your distro
@@ -26,10 +30,10 @@ tracks new builds automatically.
> at the **canary** channel instead (`canary` apt distribution / `*-canary` rpm group). See > at the **canary** channel instead (`canary` apt distribution / `*-canary` rpm group). See
> [Release Channels](/docs/channels). > [Release Channels](/docs/channels).
## Windows (NVIDIA) ## Windows
punktfunk also runs as a native host on **Windows 10/11 (x64) with an NVIDIA GPU**, shipped as a punktfunk also runs as a native host on **Windows 11 22H2+ (x64)**, shipped as a signed
signed installer — see [Windows Host](/docs/windows-host) for what it includes and its limitations. installer — see [Windows Host](/docs/windows-host) for what it includes and its limitations.
1. From the [packages page](https://git.unom.io/unom/-/packages) (generic group), download the newest 1. From the [packages page](https://git.unom.io/unom/-/packages) (generic group), download the newest
**`punktfunk-host-setup-<ver>.exe`** and its matching **`.cer`**. **`punktfunk-host-setup-<ver>.exe`** and its matching **`.cer`**.
@@ -53,7 +57,7 @@ fallback without one. More detail — including the CLI `punktfunk-host service
## What the packages are ## What the packages are
- **`punktfunk-host`** — the streaming host. Install this on your Linux + NVIDIA gaming machine. - **`punktfunk-host`** — the streaming host. Install this on your Linux gaming machine.
- **`punktfunk-web`** — the browser management console (pairing + status). Recommended alongside the - **`punktfunk-web`** — the browser management console (pairing + status). Recommended alongside the
host; on RPM list it explicitly (`rpm-ostree install punktfunk punktfunk-web`). host; on RPM list it explicitly (`rpm-ostree install punktfunk punktfunk-web`).
- **`punktfunk-client`** — the GTK4 desktop client, for streaming *to* a Linux box (also shipped via - **`punktfunk-client`** — the GTK4 desktop client, for streaming *to* a Linux box (also shipped via
+1
View File
@@ -3,6 +3,7 @@
"pages": [ "pages": [
"index", "index",
"how-it-works", "how-it-works",
"security",
"quickstart", "quickstart",
"install", "install",
"---Host Setup---", "---Host Setup---",
+6 -2
View File
@@ -5,16 +5,20 @@ description: From nothing to streaming — set up a host and connect your first
This is the shortest path to a working stream. Each step links to the details. This is the shortest path to a working stream. Each step links to the details.
> A streaming host is remote control of the machine, so it's built for **trusted local networks** — keep
> it on your LAN or a VPN and don't expose it to the internet. Two minutes on
> [Security & Safe Use](/docs/security) before you start is worth it.
## 1. Set up the host ## 1. Set up the host
On your Linux + NVIDIA machine, follow the guide for your system: On your Linux gaming machine (NVIDIA, AMD, or Intel GPU), follow the guide for your system:
- [Ubuntu — GNOME](/docs/ubuntu-gnome) - [Ubuntu — GNOME](/docs/ubuntu-gnome)
- [Ubuntu — KDE Plasma](/docs/ubuntu-kde) - [Ubuntu — KDE Plasma](/docs/ubuntu-kde)
- [Fedora — KDE Plasma](/docs/fedora-kde) - [Fedora — KDE Plasma](/docs/fedora-kde)
- [Bazzite — gamescope / Steam](/docs/bazzite) - [Bazzite — gamescope / Steam](/docs/bazzite)
Each one covers the NVIDIA driver, the dependencies, and how to build and run the host. Check the Each one covers the GPU driver, the dependencies, and how to build and run the host. Check the
[Requirements](/docs/requirements) first if you're not sure your machine is a fit. [Requirements](/docs/requirements) first if you're not sure your machine is a fit.
## 2. Start the host ## 2. Start the host
+10 -3
View File
@@ -20,8 +20,9 @@ environments it supports today, each with its own guide:
Other wlroots compositors (Sway/Hyprland) also work but aren't a primary target. If your desktop isn't Other wlroots compositors (Sway/Hyprland) also work but aren't a primary target. If your desktop isn't
listed, the host still needs one of these compositor backends to create a virtual display. listed, the host still needs one of these compositor backends to create a virtual display.
> **Windows host:** punktfunk also runs as a native host on **Windows 10/11 (x64)** — a signed > **Windows host:** punktfunk also runs as a native host on **Windows 11 22H2 or newer (x64)** — a
> installer that registers a service and bundles a virtual-display driver. It encodes on NVIDIA > signed installer that registers a service and bundles a virtual-display driver (whose driver-
> framework needs make 22H2 the hard floor — Windows 10 is not supported). It encodes on NVIDIA
> (NVENC), AMD (AMF), or Intel (QSV), with a software fallback, and is newer than the Linux host; see > (NVENC), AMD (AMF), or Intel (QSV), with a software fallback, and is newer than the Linux host; see
> [Windows Host](/docs/windows-host). > [Windows Host](/docs/windows-host).
@@ -63,10 +64,16 @@ Minimum compositor versions (newer is fine):
## Network ## Network
- Host and client on the **same network** — a LAN, or a VPN that puts them on one subnet. punktfunk - Host and client on the **same network** — a LAN, or a VPN that puts them on one subnet. punktfunk
assumes a trusted local network; it's not built to be exposed to the public internet. assumes a trusted local network; it's **not built to be exposed to the public internet — don't
port-forward it.** To stream from outside your home, use a VPN so the remote client is on the same
private subnet.
- For best results, a wired or fast Wi-Fi link. The host can run a built-in **speed test** to pick a - For best results, a wired or fast Wi-Fi link. The host can run a built-in **speed test** to pick a
bitrate for your link (see [Configuration](/docs/configuration)). bitrate for your link (see [Configuration](/docs/configuration)).
> **Before you set up a host, read [Security & Safe Use](/docs/security).** A streaming host is
> remote control of the machine — it's important to understand what that exposes, why to keep it on a
> trusted network, and how pairing protects you.
## A client ## A client
You also need something to stream *to* — see [Connect a Client](/docs/clients). There are native You also need something to stream *to* — see [Connect a Client](/docs/clients). There are native
@@ -91,7 +91,8 @@ session unit — see [Bazzite](/docs/bazzite).
On Windows the host runs as a `LocalSystem` service that launches into the interactive session, so it On Windows the host runs as a `LocalSystem` service that launches into the interactive session, so it
captures the secure desktop (UAC / lock screen) and survives reboots with nobody logged in — the same captures the secure desktop (UAC / lock screen) and survives reboots with nobody logged in — the same
model Sunshine/Apollo use. model Sunshine/Apollo use. Because it runs at that privilege level, keep it on a trusted network and be
deliberate about which machine you host on — see [Security & Safe Use](/docs/security).
The easy path is the **signed installer**: download `punktfunk-host-setup-<ver>.exe` from the package The easy path is the **signed installer**: download `punktfunk-host-setup-<ver>.exe` from the package
registry ([`punktfunk-host-windows`](https://git.unom.io/unom/-/packages)) and run it. It drops the host registry ([`punktfunk-host-windows`](https://git.unom.io/unom/-/packages)) and run it. It drops the host
+153
View File
@@ -0,0 +1,153 @@
---
title: Security & Safe Use
description: What a streaming host actually exposes, why to keep it on a trusted network, and how punktfunk protects you.
---
Read this before you put a host on a network you don't fully control. punktfunk is built to be secure
**on a trusted local network**, and that's the setting we support today. This page is upfront about what
a streaming host is, what protects it, and where the honest limits are.
> **The short version**
> - **Keep the host on a network you trust** — your home LAN, or a private VPN that puts host and client
> on the same subnet. **Do not port-forward it to the public internet.**
> - **A streaming host is remote control of the machine.** Anyone who can stream to it sees the screen
> and can move the mouse, type, and act as a controller — the same as sitting at the keyboard.
> - **Pairing is the security boundary.** Require pairing (the default), pick a strong console
> password, and review your paired devices from time to time.
> - **Be thoughtful about *which* machine you run it on** — especially on Windows, where the host runs
> with high system privileges so it can do its job. Prefer a dedicated or gaming PC over one holding
> your most sensitive data.
## What a streaming host really is
Low-latency desktop and game streaming means two things travel over the network: **the screen goes
out, and input comes back in.** A paired client doesn't just watch — it drives. Its mouse, keyboard,
and controller are injected into the host's desktop, so **for anything it can reach, a streaming client
is equivalent to a person sitting at that machine.**
That's the feature. It's also the risk to understand:
- The host can capture the **secure desktop** — UAC elevation prompts and the lock screen — so a
connected client can see and interact with those too. (This is what lets you unlock and administer a
headless box remotely; it's the same capability Sunshine and Apollo provide.)
- Injected input isn't sandboxed to a game. Whoever is streaming can alt-tab, open a terminal, read
files, or change settings — whatever the logged-in session can do.
This is true of **every** remote-access and game-streaming tool, not just punktfunk. The takeaway isn't
"don't use it" — it's "treat access to your host the way you'd treat handing someone your unlocked
keyboard." The rest of this page is about making sure only people you intend can get that access.
## Keep it on a trusted network
**punktfunk assumes a trusted local network. It is not designed, tested, or hardened to be exposed to
the public internet — do not port-forward it.** There is no WAN-hardening story yet: no rate-limited
public authentication gateway, no DDoS protection, no assumption that hostile traffic is constantly
probing the ports. Exposing the streaming ports directly to the internet puts an interactive
control surface for your machine in front of the entire world.
If you want to stream from outside your home, tunnel in instead of opening up:
- **Use a VPN** — WireGuard, Tailscale, or your router's built-in VPN. This puts your remote client on
the *same private subnet* as the host, so from punktfunk's point of view it's still a local
connection, and the tunnel (not punktfunk) handles internet-facing authentication and encryption.
Discovery, pairing, and streaming then work exactly as they do at home.
- **Don't** map a router port to the host. A port-forward turns "trusted LAN service" into
"internet-facing service" with none of the protections that implies.
A note for **portable machines**: the installer opens the streaming ports on the firewall for *all*
network profiles, including Public. That's convenient at home but means that if you take a laptop host
onto an untrusted network — a café, a hotel, a conference — other devices on that network can reach the
ports and attempt to pair. Pairing still protects you (an attacker who doesn't know the PIN can't get
in), but the safest habit is to stop the host service, or firewall it off, when you're on a network you
don't control.
## What actually protects you
punktfunk has **no accounts and no cloud**. Trust is established directly, device-to-device, and then
pinned. The layers, from the outside in:
- **Pairing is required by default.** A new device can't stream until it completes a one-time
**PIN pairing ceremony** (SPAKE2): the host shows a 4-digit PIN, you enter it on the client, and the
exchange cryptographically binds both identities. An attacker who doesn't know the PIN gets a
*single online guess* — no offline cracking, no dictionary attack. See
[Pairing & Trust](/docs/pairing).
- **Identities are pinned.** After pairing, the client remembers the host's certificate fingerprint and
the host stores the client's. Reconnects are automatic and mutually authenticated; if a host's
fingerprint ever changes, the client refuses to auto-trust it and forces re-pairing.
- **The admin surface is loopback-only.** The management API's read-only status is reachable by paired
clients over the LAN (authenticated by their certificate), but every state-changing action — arming
pairing, removing devices, session control — is honored **only from the local machine** (the web
console connects over loopback). It is never exposed to the network.
- **The web console has its own password.** On Windows it's set during install (a strong random default)
and stored readable only by Administrators and SYSTEM.
**GameStream / Moonlight compatibility is the weak-crypto path — trusted LAN only.** To interoperate
with stock Moonlight clients, punktfunk can speak the legacy GameStream protocol, which pairs over
plain HTTP and uses older encryption. It is **opt-in** (`serve --gamestream`) and appropriate only on a
network you fully trust. The default native `punktfunk/1` protocol is the secure path (modern AEAD
crypto, pinned identities); leave GameStream off unless you specifically need Moonlight.
## Choosing which machine to host on
We've put real work into hardening the host — sealed capture and gamepad channels, no kernel drivers,
loopback-gated admin, pinned trust — and we'll keep at it. But security is also about *blast radius*:
if a host is ever compromised, or you misconfigure trust, what does the attacker get? So pick the
machine with that in mind.
### The Windows host runs with high privileges
To capture the secure desktop (UAC, lock screen) and stream across reboots with nobody logged in, the
Windows host installs a service that runs as **`LocalSystem` (SYSTEM)** — the highest local privilege on
Windows. This is the same design Sunshine and Apollo use, and it's what makes headless, log-in-optional
streaming possible. It also means the host is a high-value component: a compromise of the host, or a
device you paired that you shouldn't have, is a foothold at the most powerful level of that machine.
We mitigate this deliberately:
- **Zero kernel drivers.** The virtual display and all three virtual gamepads are **user-mode (UMDF)**
drivers, so a driver bug is contained to a restricted service account — never ring-0, never
full-system. (This is why punktfunk dropped ViGEmBus.)
- **Sealed internal channels.** The desktop-frame ring and the gamepad input/output channels are
passed between the host and its drivers as duplicated handles to unnamed objects, so another local
service can't open them by name to read your screen or forge controller input. (Details:
[`idd-push-security.md`](https://git.unom.io/unom/punktfunk/src/branch/main/design/idd-push-security.md)
and [`gamepad-channel-sealing.md`](https://git.unom.io/unom/punktfunk/src/branch/main/design/gamepad-channel-sealing.md).)
- **Secrets are locked down.** The management token, the host identity key, and the console password
are stored with Administrators/SYSTEM-only permissions.
**The honest floor still applies.** None of this defends against an attacker who is *already* an
administrator or SYSTEM on the box — at that level they own the machine regardless of punktfunk. And a
virtual display is a real monitor: any process already running in your desktop session can capture it
through the ordinary OS screen-capture APIs, exactly as it could capture a physical monitor. That floor
is the same for every virtual-display streaming stack.
**Recommendation:** run the Windows host on a **dedicated or gaming PC**, not on a machine that also
holds your most sensitive material (work laptop, financial records, the box with your password vault).
A gaming rig you stream from is a great fit; your primary secrets machine is not.
### The Linux host runs as your desktop user
The Linux host runs inside your normal desktop session as your **regular user account**, not root — so a
worst-case compromise is scoped to that user rather than the whole system. The same network guidance
applies: keep it on a trusted LAN or a VPN, require pairing, and don't expose it to the internet.
## A short hardening checklist
- **Require pairing** — it's the default; don't run `--open` / `--allow-tofu` except on a network you
fully trust and control.
- **Use a strong console password** and keep it out of shared documents.
- **Stay on a trusted network** — LAN or VPN. Never port-forward to the internet.
- **Leave GameStream off** unless you specifically need Moonlight compatibility.
- **Review paired devices** in the web console periodically; remove anything you don't recognize.
- **Keep the host updated** — security fixes ship in new builds.
- **On portable hosts**, stop the service when you're on an untrusted network.
## For the technically curious
The deeper security design lives in the repository, and it's candid about residual limits:
- [`design/idd-push-security.md`](https://git.unom.io/unom/punktfunk/src/branch/main/design/idd-push-security.md) — the sealed frame channel (why the Windows capture path is isolated), and its honest floor.
- [`design/gamepad-channel-sealing.md`](https://git.unom.io/unom/punktfunk/src/branch/main/design/gamepad-channel-sealing.md) — the sealed gamepad channel.
- [`design/security-review-2026-06-28.md`](https://git.unom.io/unom/punktfunk/src/branch/main/design/security-review-2026-06-28.md) and [`design/security-review.md`](https://git.unom.io/unom/punktfunk/src/branch/main/design/security-review.md) — the standing security reviews.
Found a security issue? Please report it privately rather than opening a public issue.
+3
View File
@@ -12,6 +12,9 @@ desktop-class SteamOS box is a natural always-on streaming host. The **Steam Dec
device we can test on today, so it's what these instructions are validated against; the same device we can test on today, so it's what these instructions are validated against; the same
on-device build works on any SteamOS 3 system. on-device build works on any SteamOS 3 system.
> New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of
> the machine, so keep it on a trusted LAN or VPN and require pairing.
SteamOS is an immutable, read-only Arch base, so the host isn't a system package. Instead a single SteamOS is an immutable, read-only Arch base, so the host isn't a system package. Instead a single
script builds the host **natively inside a Debian-trixie distrobox** (ABI-matched to SteamOS's script builds the host **natively inside a Debian-trixie distrobox** (ABI-matched to SteamOS's
FFmpeg/glibc — the binary then runs natively on SteamOS) and wires it up as systemd user services. FFmpeg/glibc — the binary then runs natively on SteamOS) and wires it up as systemd user services.
+11
View File
@@ -73,6 +73,17 @@ Then log out and back in. On other distros this is `sudo usermod -aG input $USER
concurrent native sessions (up to 4 by default); heavy load is usually bitrate-bound, so concurrent native sessions (up to 4 by default); heavy load is usually bitrate-bound, so
lower the bitrate first. lower the bitrate first.
## Windows: "punktfunk Virtual Display" shows Code 10 in Device Manager
Sessions end with *"pf-vdisplay driver interface not found"* and Device Manager shows the
**punktfunk Virtual Display** device failed with **Code 10** (`STATUS_DEVICE_POWER_FAILURE`).
This means your Windows version is too old. The virtual-display driver requires the **IddCx 1.10**
driver framework, which first shipped in **Windows 11 22H2 (build 22621)** — on Windows 10
(including LTSC) and Windows 11 21H2 the driver installs but cannot start. Reinstalling won't help;
the fix is updating to Windows 11 22H2 or newer. (Current installers refuse to run on older
Windows for this reason; if you see this, the host was likely installed with an older installer.)
## Still stuck? ## Still stuck?
Run the host with `RUST_LOG=info` (or `debug`) and check `journalctl --user -u punktfunk-host` for the Run the host with `RUST_LOG=info` (or `debug`) and check `journalctl --user -u punktfunk-host` for the
+3 -1
View File
@@ -6,7 +6,9 @@ description: Set up a punktfunk host on Ubuntu with the GNOME desktop (Mutter).
Set up a punktfunk host on **Ubuntu** (Desktop or Server) running **GNOME**. The host uses GNOME's Set up a punktfunk host on **Ubuntu** (Desktop or Server) running **GNOME**. The host uses GNOME's
Mutter compositor to create a per-client virtual display. Tested on Ubuntu 24.04+ and GNOME 48+. Mutter compositor to create a per-client virtual display. Tested on Ubuntu 24.04+ and GNOME 48+.
> New to this? Skim [Requirements](/docs/requirements) first. > New to this? Skim [Requirements](/docs/requirements) first, and read
> [Security & Safe Use](/docs/security) — a streaming host is remote control of the machine, so keep it
> on a trusted LAN or VPN and require pairing.
## 1. NVIDIA driver ## 1. NVIDIA driver
+3 -1
View File
@@ -6,7 +6,9 @@ description: Set up a punktfunk host on Ubuntu with KDE Plasma (KWin).
Set up a punktfunk host on **Ubuntu** running **KDE Plasma**. The host uses KDE's KWin compositor to Set up a punktfunk host on **Ubuntu** running **KDE Plasma**. The host uses KDE's KWin compositor to
create a per-client virtual display. Needs **KWin 6.5.6 or newer**. create a per-client virtual display. Needs **KWin 6.5.6 or newer**.
> New to this? Skim [Requirements](/docs/requirements) first. > New to this? Skim [Requirements](/docs/requirements) first, and read
> [Security & Safe Use](/docs/security) — a streaming host is remote control of the machine, so keep it
> on a trusted LAN or VPN and require pairing.
## 1. NVIDIA driver ## 1. NVIDIA driver
+19 -3
View File
@@ -3,7 +3,7 @@ title: "Windows Host"
description: "Run the Punktfunk streaming host on a Windows PC — a first-class, all-vendor, virtual-display host." description: "Run the Punktfunk streaming host on a Windows PC — a first-class, all-vendor, virtual-display host."
--- ---
Set up a Punktfunk host on a **Windows 10/11 PC** and stream its desktop or games to any Punktfunk or Set up a Punktfunk host on a **Windows 11 PC (22H2 or newer)** and stream its desktop or games to any Punktfunk or
[Moonlight](/docs/moonlight) client. A signed installer registers a Windows service that streams at the [Moonlight](/docs/moonlight) client. A signed installer registers a Windows service that streams at the
client's **exact resolution and refresh** via Punktfunk's own **virtual display** — including client's **exact resolution and refresh** via Punktfunk's own **virtual display** — including
**HDR10** (10-bit BT.2020 PQ) when your Windows desktop is in HDR mode. The virtual display is created **HDR10** (10-bit BT.2020 PQ) when your Windows desktop is in HDR mode. The virtual display is created
@@ -12,13 +12,22 @@ the secure desktop (UAC prompts, the lock screen).
> New to this? Skim [Requirements](/docs/requirements) first. > New to this? Skim [Requirements](/docs/requirements) first.
> **Read [Security & Safe Use](/docs/security) before you set this up.** The Windows host runs as a
> `LocalSystem` service (so it can capture the secure desktop and stream headless), which makes it a
> high-privilege component — keep it on a trusted network, never expose it to the internet, and prefer
> a dedicated or gaming PC over a machine that holds your most sensitive data.
> This page is about the Windows **host** — streaming *from* a Windows PC. To stream *to* a Windows PC, > This page is about the Windows **host** — streaming *from* a Windows PC. To stream *to* a Windows PC,
> see the [Windows client](/docs/clients#windows-desktop-client). > see the [Windows client](/docs/clients#windows-desktop-client).
## Requirements ## Requirements
- **Windows 10 or 11, x64.** ARM64 is not built (no ARM64 NVIDIA driver, and the virtual-display - **Windows 11 22H2 (build 22621) or newer, x64.** Windows 10 — including LTSC — and Windows 11
driver is x64-only). 21H2 are **not supported**: the virtual-display driver needs the IddCx 1.10 driver framework,
which first shipped in Windows 11 22H2. On older Windows the driver installs but can't start
("punktfunk Virtual Display" shows **Code 10** in Device Manager and streaming fails); the
installer therefore refuses to run there. ARM64 is not built either (no ARM64 NVIDIA driver, and
the virtual-display driver is x64-only).
- **A GPU for hardware encode** — the host auto-detects the vendor: - **A GPU for hardware encode** — the host auto-detects the vendor:
- **NVIDIA** → NVENC - **NVIDIA** → NVENC
- **AMD** → AMF - **AMD** → AMF
@@ -96,6 +105,13 @@ prompts, the lock screen) and keep streaming across reboots with nobody logged i
Sunshine and Apollo use. Service registration, firewall rules, and the supervisor all live in Sunshine and Apollo use. Service registration, firewall rules, and the supervisor all live in
`punktfunk-host service install`; the installer just lays the exe down and calls it elevated. `punktfunk-host service install`; the installer just lays the exe down and calls it elevated.
Running as SYSTEM is what makes headless, log-in-optional streaming work — and it's why the host is a
high-privilege component worth being deliberate about. punktfunk mitigates this with **zero kernel
drivers** (the virtual display and gamepads are user-mode UMDF drivers), **sealed internal channels**
between the host and its drivers, and Administrators/SYSTEM-only permissions on its secrets. See
[Security & Safe Use](/docs/security) for the full picture, including why we recommend not hosting on
your most sensitive machine.
### One core, Windows backends ### One core, Windows backends
Most of Punktfunk is platform-agnostic. `punktfunk-core` (protocol, FEC, crypto, session, transport, Most of Punktfunk is platform-agnostic. `punktfunk-core` (protocol, FEC, crypto, session, transport,
+12 -1
View File
@@ -50,7 +50,7 @@ build() {
# The host's zero-copy FFI link-needs libcuda at build time; nvidia-utils provides it on an # The host's zero-copy FFI link-needs libcuda at build time; nvidia-utils provides it on an
# NVIDIA builder. On a GPU-less builder symlink the CUDA stub into the link path first (same # NVIDIA builder. On a GPU-less builder symlink the CUDA stub into the link path first (same
# caveat the RPM documents): ln -s "$(find / -name libcuda.so -path '*stubs*'|head -1)" /usr/lib/ # caveat the RPM documents): ln -s "$(find / -name libcuda.so -path '*stubs*'|head -1)" /usr/lib/
cargo build --release --locked -p punktfunk-host -p punktfunk-client-linux cargo build --release --locked -p punktfunk-host -p punktfunk-client-linux -p punktfunk-tray
# Management web console (opt-in): the Nitro `bun`-preset .output bundle (Bun.serve TLS), # Management web console (opt-in): the Nitro `bun`-preset .output bundle (Bun.serve TLS),
# built AND run with bun. # built AND run with bun.
if [ "${PF_WITH_WEB:-0}" = 1 ]; then if [ "${PF_WITH_WEB:-0}" = 1 ]; then
@@ -95,6 +95,17 @@ package_punktfunk-host() {
# connect). See the file's header comment. # connect). See the file's header comment.
install -Dm0644 "$R/packaging/linux/io.unom.Punktfunk.Host.desktop" \ install -Dm0644 "$R/packaging/linux/io.unom.Punktfunk.Host.desktop" \
"$pkgdir/usr/share/applications/io.unom.Punktfunk.Host.desktop" "$pkgdir/usr/share/applications/io.unom.Punktfunk.Host.desktop"
# Status tray: per-user SNI icon + XDG autostart entry (self-gating: --autostart exits silently
# for users who don't run a host) + the hicolor status icons it names.
install -Dm0755 "$T/punktfunk-tray" "$pkgdir/usr/bin/punktfunk-tray"
install -Dm0644 "$R/packaging/linux/io.unom.Punktfunk.Tray.desktop" \
"$pkgdir/etc/xdg/autostart/io.unom.Punktfunk.Tray.desktop"
local sz png
for sz in 22x22 48x48; do
for png in "$R"/packaging/linux/icons/hicolor/$sz/apps/*.png; do
install -Dm0644 "$png" "$pkgdir/usr/share/icons/hicolor/$sz/apps/$(basename "$png")"
done
done
# headless session helpers + env templates + OpenAPI doc # headless session helpers + env templates + OpenAPI doc
install -Dm0755 "$R/scripts/headless/run-headless-kde.sh" "$pkgdir/usr/share/punktfunk/headless/run-headless-kde.sh" install -Dm0755 "$R/scripts/headless/run-headless-kde.sh" "$pkgdir/usr/share/punktfunk/headless/run-headless-kde.sh"
install -Dm0755 "$R/scripts/headless/run-headless-sway.sh" "$pkgdir/usr/share/punktfunk/headless/run-headless-sway.sh" install -Dm0755 "$R/scripts/headless/run-headless-sway.sh" "$pkgdir/usr/share/punktfunk/headless/run-headless-sway.sh"
+15
View File
@@ -28,6 +28,11 @@ if [ ! -x "$BIN" ]; then
echo "==> building $PKG (release)" echo "==> building $PKG (release)"
PUNKTFUNK_BUILD_VERSION="$VERSION" cargo build --release -p "$PKG" --locked # stamp --version (build.rs) PUNKTFUNK_BUILD_VERSION="$VERSION" cargo build --release -p "$PKG" --locked # stamp --version (build.rs)
fi fi
TRAY_BIN="target/release/punktfunk-tray"
if [ ! -x "$TRAY_BIN" ]; then
echo "==> building punktfunk-tray (release)"
cargo build --release -p punktfunk-tray --locked
fi
STAGE="$(mktemp -d)" STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT trap 'rm -rf "$STAGE"' EXIT
@@ -57,6 +62,16 @@ sed -i 's#%h/punktfunk/scripts/headless/run-headless-kde.sh#/usr/share/punktfunk
# connect, so it has to be present before the host ever connects. See the file's header comment. # connect, so it has to be present before the host ever connects. See the file's header comment.
install -Dm0644 packaging/linux/io.unom.Punktfunk.Host.desktop \ install -Dm0644 packaging/linux/io.unom.Punktfunk.Host.desktop \
"$STAGE/usr/share/applications/io.unom.Punktfunk.Host.desktop" "$STAGE/usr/share/applications/io.unom.Punktfunk.Host.desktop"
# Status tray: the per-user SNI icon + its XDG autostart entry (self-gating: --autostart exits
# silently for users who don't run a host) + the hicolor status icons it names.
install -Dm0755 "$TRAY_BIN" "$STAGE/usr/bin/punktfunk-tray"
install -Dm0644 packaging/linux/io.unom.Punktfunk.Tray.desktop \
"$STAGE/etc/xdg/autostart/io.unom.Punktfunk.Tray.desktop"
for sz in 22x22 48x48; do
for png in packaging/linux/icons/hicolor/$sz/apps/*.png; do
install -Dm0644 "$png" "$STAGE/usr/share/icons/hicolor/$sz/apps/$(basename "$png")"
done
done
install -Dm0755 scripts/headless/run-headless-kde.sh "$SHAREDIR/headless/run-headless-kde.sh" install -Dm0755 scripts/headless/run-headless-kde.sh "$SHAREDIR/headless/run-headless-kde.sh"
install -Dm0755 scripts/headless/run-headless-sway.sh "$SHAREDIR/headless/run-headless-sway.sh" install -Dm0755 scripts/headless/run-headless-sway.sh "$SHAREDIR/headless/run-headless-sway.sh"
install -Dm0644 scripts/headless/kde-authorized "$SHAREDIR/headless/kde-authorized" install -Dm0644 scripts/headless/kde-authorized "$SHAREDIR/headless/kde-authorized"
Binary file not shown.

After

Width:  |  Height:  |  Size: 473 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 B

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