17 Commits

Author SHA1 Message Date
enricobuehler ca79f7f2d2 fix(rpm): build with the baked toolchain (RUSTUP_TOOLCHAIN), avoid EXDEV
apple / swift (push) Successful in 1m6s
ci / rust (push) Successful in 4m44s
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 59s
windows-host / package (push) Failing after 6m36s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 1m8s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Failing after 1m10s
ci / bench (push) Successful in 4m40s
release / apple (push) Successful in 8m51s
apple / screenshots (push) Successful in 5m35s
android-screenshots / screenshots (push) Successful in 2m21s
decky / build-publish (push) Failing after 11s
deb / build-publish (push) Failing after 7m15s
android / android (push) Successful in 9m49s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 32s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 2m48s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3m27s
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 2m20s
flatpak / build-publish (push) Failing after 4m12s
linux-client-screenshots / screenshots (push) Successful in 5m45s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 9m2s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m46s
web-screenshots / screenshots (push) Successful in 2m28s
docker / deploy-docs (push) Successful in 22s
rust-toolchain.toml floats `channel = "stable"` + requests rustfmt/clippy. When a
newer stable lands upstream, that makes rustup try to update the baked, minimal-
profile `stable` toolchain in place during %build, and the builder image's
OverlayFS rejects the staging rename with EXDEV ("Invalid cross-device link"),
failing the RPM build (started the day Rust 1.96.1 shipped). A release build needs
no rustfmt/clippy, so pin RUSTUP_TOOLCHAIN=stable to use the installed toolchain
as-is — no channel re-resolve, no component add, no update. Scoped to the RPM
%build; ci.yml/deb.yml (rust-ci image) are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 20:18:19 +02:00
enricobuehler 2262332150 chore(release): regenerate api/openapi.json for 0.4.1
The OpenAPI `info.version` tracks the crate version, so the 0.4.1 bump (4563a04)
left api/openapi.json stale at 0.3.0 and reddened
`mgmt::tests::openapi_document_is_complete_and_checked_in`. The API surface is
unchanged since v0.4.0, so this is the version string only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 20:18:19 +02:00
enricobuehler 71e3618f2e fix(rpm): restore lowercase package Name (unbreak Source0 / build)
apple / swift (push) Successful in 1m7s
ci / rust (push) Failing after 5m14s
audit / cargo-audit (push) Successful in 1m13s
ci / web (push) Successful in 51s
ci / docs-site (push) Successful in 59s
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
android-screenshots / screenshots (push) Successful in 47s
ci / bench (push) Has been cancelled
apple / screenshots (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Successful in 59s
android / android (push) Successful in 3m54s
decky / build-publish (push) Successful in 13s
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
deb / build-publish (push) Successful in 5m13s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m30s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
windows-host / package (push) Successful in 6m52s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m26s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m13s
linux-client-screenshots / screenshots (push) Successful in 1m42s
release / apple (push) Successful in 9m6s
flatpak / build-publish (push) Successful in 4m16s
web-screenshots / screenshots (push) Successful in 3m23s
docker / deploy-docs (push) Successful in 46s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 19s
ba39b08 capitalized the spec Name to `Punktfunk` in a branding sweep, but
build-rpm.sh writes the git-archive tarball as lowercase `punktfunk-<v>.tar.gz`
(prefix dir likewise). `%{name}` drives Source0, `%autosetup -n`, and the
`%{_datadir}/%{name}` install path, so the capital-P both broke the build
(rpmuncompress: `Punktfunk-<v>.tar.gz: No such file`) and would have renamed the
published package + its share dir vs every prior release. RPM names are lowercase
by convention; v0.3.x / v0.4.0 shipped as `punktfunk`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 19:34:05 +02:00
enricobuehler 4563a0490c chore(release): bump workspace version to 0.4.1; canary base to 0.5.0
The [workspace.package] version (inherited by every crate via version.workspace)
lagged at 0.3.0 through the 0.4.0 release — bump it to 0.4.1, the release being
cut, and refresh the 8 workspace entries in Cargo.lock to match (CI builds
--locked).

Also advance the CI canary-base fallbacks (deb/rpm/flatpak/android/release
workflows + build-rpm.sh) from 0.3.0 to 0.5.0 so main/canary builds sort one
minor ahead of the latest stable line, per the documented channel convention.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 19:22:42 +02:00
enricobuehler ba39b08e09 feat(web): consolidate paired devices, self-contained sections, docs + lint
apple / swift (push) Successful in 1m6s
ci / rust (push) Successful in 5m51s
android / android (push) Successful in 6m21s
ci / web (push) Successful in 49s
ci / docs-site (push) Successful in 58s
windows-host / package (push) Successful in 8m6s
release / apple (push) Successful in 8m17s
deb / build-publish (push) Successful in 3m26s
decky / build-publish (push) Successful in 25s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
ci / bench (push) Successful in 4m42s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 30s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m36s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m17s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 19s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 51s
apple / screenshots (push) Successful in 5m45s
docker / deploy-docs (push) Successful in 22s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 22s
Web console
- Pairing/Library/Stats refactored into self-contained subsections that each own
  their own queries + mutations; a shared slot-based layout (view.tsx) is filled by
  the live page (containers) and Storybook (pure cards + fixtures) so the layout can't
  drift.
- All paired devices in one list on Pairing with a protocol column (punktfunk/1 +
  Moonlight), routing each unpair to the right endpoint; the redundant Clients page is
  removed.
- Library: overview grid split from the add/edit form into separate files.
- Login screen links out to the docs.

Docs
- "Console login password" section on every host page (apt/RPM/Bazzite/SteamOS/Windows)
  plus a new "Forgot your Password?" troubleshooting page, linked from the login screen.
- Console served as HTTP/1.1 over TLS (drop the unusable HTTP/3 advertising) across the
  Bun entry, launchers, systemd units, and packaging.

Tooling
- Biome now respects .gitignore (stops linting generated code), config migrated to
  2.5.1; all lint issues fixed cleanly.

Also includes this branch's in-progress host, Apple client, packaging, and CI changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 19:05:22 +02:00
enricobuehler e1bc9fda22 style(library): rustfmt the cover-fetch helpers
apple / swift (push) Successful in 1m8s
windows-host / package (push) Successful in 6m27s
apple / screenshots (push) Successful in 5m47s
ci / web (push) Successful in 50s
decky / build-publish (push) Successful in 15s
android / android (push) Successful in 4m25s
ci / rust (push) Successful in 5m0s
ci / docs-site (push) Successful in 58s
deb / build-publish (push) Successful in 3m13s
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 4s
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
ci / bench (push) Successful in 4m45s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m35s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m38s
docker / deploy-docs (push) Successful in 17s
CI `cargo fmt --all --check` flagged fetch_image's base64/header chains (added in
12c7ec9 — clippy was run, fmt --check was missed). Pure formatting, no logic change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:21:03 +02:00
enricobuehler 12c7ec9e57 feat(gamestream): advertise HDR + surface the game library (with covers) to Moonlight
apple / swift (push) Successful in 1m6s
ci / rust (push) Failing after 1m11s
ci / web (push) Successful in 50s
ci / docs-site (push) Successful in 1m2s
android / android (push) Successful in 4m52s
apple / screenshots (push) Successful in 5m20s
windows-host / package (push) Successful in 6m30s
ci / bench (push) Successful in 4m42s
deb / build-publish (push) Successful in 3m19s
decky / build-publish (push) Successful in 13s
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 4s
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 4s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m32s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m34s
docker / deploy-docs (push) Successful in 18s
Bring the GameStream/Moonlight plane up to the native plane's capability parity.

HDR (Windows only):
- New host_hdr_capable() gate (Windows + PUNKTFUNK_10BIT, matching the native
  policy). serverinfo layers SCM_HEVC_MAIN10 onto the probed/static codec mask, so
  Moonlight finally offers its HDR toggle (live: mask 0x10101 -> 0x10301).
- Parse the client's dynamicRangeMode into StreamConfig.hdr and pass it through to
  OutputFormat::resolve, so a client HDR request proactively enables advanced color
  on the per-session virtual display (PQ flows even from an SDR desktop). The
  encoder bit depth now derives from the captured frame format (gs_bit_depth) rather
  than a hard-coded 8 that mislabeled the already-Main10 HDR stream.

Game library in /applist:
- The catalog now layers library::all_games() (Steam/Epic/GOG/Xbox/custom) on top of
  Desktop/apps.json, each with a STABLE GameStream id (FNV-1a, dedup-probed) and the
  store-qualified library id. Launch routes through the existing security-reviewed
  launch_title/launch_command via library::launch_gamestream_library — a client can
  only pick an existing title, never inject a command.
- /appasset cover proxy: Moonlight fetches per-app covers from the host, so resolve
  appid -> library cover URL and proxy the bytes (portrait -> header -> hero -> logo;
  data: + bounded http(s) fetch), on a blocking thread. IsHdrSupported reflects the
  host HDR capability.

4:4:4 stays off on GameStream by design: stock Moonlight is 4:2:0 and the Windows
IDD-push capturer can't deliver full chroma yet (capturer_supports_444() == false);
the gate is documented so it lights up once IDD-push full-chroma capture lands.

Validated live (Moonlight -> Windows NVENC host): HDR advertised, the Epic library
shows with covers, launch works. clippy clean; apps/serverinfo/library unit tests
cover the HDR mask, stable-id, dedup, and data-URL paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:07:07 +02:00
enricobuehler 5a89a64920 docs(windows-host): IDD-push capture, releases link, Punktfunk branding
apple / swift (push) Successful in 1m7s
apple / screenshots (push) Successful in 5m32s
android / android (push) Successful in 3m46s
ci / web (push) Successful in 1m0s
ci / docs-site (push) Successful in 1m6s
ci / rust (push) Successful in 5m13s
deb / build-publish (push) Successful in 3m17s
decky / build-publish (push) Successful in 13s
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 3s
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 46s
ci / bench (push) Successful in 4m40s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m25s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m28s
docker / deploy-docs (push) Successful in 20s
Rewrite the outdated Windows Host page:
- Capture is IDD direct-push only — drop the stale Windows.Graphics.Capture +
  Desktop Duplication claim and the (removed) monitor-capture fallback; the
  pf-vdisplay driver is now required.
- Install link points at the Gitea release (where the signed installer is
  attached) instead of the package registry.
- Brand prose as "Punktfunk" (executables/paths/protocol/URLs/service names
  stay as-is).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 12:41:18 +02:00
enricobuehler 4306d4f914 fix(windows/gamestream): create the virtual display on Windows (Moonlight black screen)
apple / swift (push) Successful in 1m6s
ci / rust (push) Successful in 5m17s
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 57s
release / apple (push) Successful in 8m42s
ci / bench (push) Successful in 4m55s
windows-host / package (push) Successful in 6m24s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m23s
apple / screenshots (push) Successful in 5m46s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m13s
android-screenshots / screenshots (push) Successful in 53s
android / android (push) Successful in 3m27s
decky / build-publish (push) Successful in 15s
deb / build-publish (push) Successful in 3m34s
linux-client-screenshots / screenshots (push) Successful in 2m17s
flatpak / build-publish (push) Successful in 4m6s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m57s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m44s
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 4s
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
web-screenshots / screenshots (push) Successful in 2m28s
The GameStream video path (open_gs_virtual_source) ran the Linux compositor-
detection state machine on every platform. On Windows detect_active_session()
returns None and vdisplay::detect() bails ("could not detect compositor ...
XDG_CURRENT_DESKTOP=''"), killing the video thread right after RTSP PLAY — so a
Moonlight client paired, negotiated, then black-screened and dropped.

The native punktfunk/1 path already guards this (resolve_compositor returns a
placeholder Compositor on Windows, since vdisplay::open ignores the compositor
arg there and always uses the pf-vdisplay IddCx backend). Mirror that guard in
the GameStream path: short-circuit to a placeholder on Windows, keep the Linux
session detection (apply_session_env/apply_input_env) under cfg(not(windows)).

Validated live: Moonlight -> this box now creates the pf-vdisplay virtual
monitor, attaches the IDD-push ring, and NVENC streams 5120x1440@240.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 12:11:31 +02:00
enricobuehler 915f11a712 fix(apple/tvOS): guard EDR HDR APIs unavailable on tvOS
apple / swift (push) Successful in 1m12s
release / apple (push) Successful in 9m11s
apple / screenshots (push) Successful in 4m49s
ci / web (push) Successful in 1m1s
ci / docs-site (push) Successful in 1m19s
ci / rust (push) Successful in 4m45s
deb / build-publish (push) Successful in 3m11s
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 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
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 4s
ci / bench (push) Successful in 6m51s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m55s
docker / deploy-docs (push) Successful in 5s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m49s
android / android (push) Successful in 3m10s
The tvOS archive failed compiling PunktfunkKit: a recent presenter HDR change
dropped the `#if os(macOS)` guard around the EDR calls and applied them "on all
platforms", but `wantsExtendedDynamicRangeContent`, `CAEDRMetadata`, and
`CAMetalLayer.edrMetadata` are all explicitly unavailable on tvOS.

Wrap the EDR usage (and the makeEDR helper, whose return type is the unavailable
CAEDRMetadata) in `#if !os(tvOS)`. macOS + iOS keep the reference-white-anchored
EDR path unchanged; tvOS now sets only the rgba16Float pixel format + itur_2100_PQ
colour space and lets its compositor tone-map from those. The 0xCE grade is still
cached on tvOS (harmless), it just can't be pushed to the layer there.

tvOS Simulator build: BUILD SUCCEEDED (PunktfunkKit Swift compile, the step that
failed). macOS build + test green (49 tests); iOS compiles clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 11:12:33 +02:00
enricobuehler fc35ea8c31 fix(windows): replace em dash with ASCII hyphen in install-vbcable.ps1
apple / swift (push) Successful in 1m9s
windows-host / package (push) Successful in 7m33s
android / android (push) Failing after 30s
ci / web (push) Successful in 54s
ci / docs-site (push) Successful in 1m17s
apple / screenshots (push) Successful in 5m38s
ci / rust (push) Successful in 4m44s
ci / bench (push) Successful in 4m38s
decky / build-publish (push) Successful in 14s
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 4s
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 4s
deb / build-publish (push) Successful in 3m20s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m4s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m14s
docker / deploy-docs (push) Successful in 17s
PS 5.1 mis-parses non-ASCII characters on non-UTF-8 locales; the
locale-safety gate CI check rejects any installer-run script containing
bytes above 0x7F.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 10:59:14 +02:00
enricobuehler 1e9a15699c fix(apple/iOS): capture all attached mice; gate UIKit pointer path under lock
apple / swift (push) Successful in 1m6s
ci / web (push) Has been cancelled
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
ci / rust (push) Has been cancelled
deb / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7s
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
decky / build-publish (push) Has been cancelled
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
release / apple (push) Successful in 7m30s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m39s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m43s
docker / deploy-docs (push) Successful in 18s
The iPad pointer lock engaged but a Magic Keyboard trackpad went dead the
moment a second pointer (a Universal Control "V-UC Automouse") was connected —
on-device PUNKTFUNK_INPUT_DEBUG logs showed only ONE GCMouse attached (whichever
was GCMouse.current), so the other device's motion handler was never installed.

InputCapture.start() now attaches a handler to EVERY GCMouse.mice(), not just
GCMouse.current, so a trackpad and a second mouse both drive (each GCMouse
delivers its own deltas through its own handler). New arrivals still come via the
GCMouseDidConnect observer.

Also gate the WHOLE UIKit indirect-pointer path (motion, buttons AND scroll) on
!gcMouseForwarding, not just motion+scroll: under pointer lock GCMouse owns
buttons too, and the trackpad/mouse also emit UIKit indirect-pointer events
pinned at the locked position — without the gate a click double-sent (GCMouse +
UIKit). The two paths are now exact mirrors on `gcMouseForwarding` (== locked).

Removes the investigation-only diagnostics (attachedMiceSummary/hasGCMouse, the
per-event UIKit pointer/scroll logs, the GCMouse attach/became-current logs);
the pre-existing `pointer lock isLocked=… captured=…` debug line is restored.

iOS compiles against the SDK; macOS swift build + test green (49 tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 10:56:37 +02:00
enricobuehler 6c2942ee45 fix(fmt): remove extra blank line in dxgi.rs
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
apple / swift (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
ci / rust (push) Has been cancelled
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
windows-host / package (push) Failing after 11s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 08:56:14 +00:00
enricobuehler 188b26b584 fix build
windows-drivers / probe-and-proto (push) Successful in 27s
ci / rust (push) Failing after 1m8s
apple / swift (push) Successful in 1m8s
windows-drivers / driver-build (push) Successful in 1m14s
windows-host / package (push) Failing after 12s
ci / web (push) Successful in 54s
ci / docs-site (push) Successful in 1m8s
android / android (push) Successful in 3m23s
apple / screenshots (push) Successful in 5m24s
deb / build-publish (push) Successful in 3m22s
decky / build-publish (push) Successful in 25s
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 46s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
ci / bench (push) Successful in 5m1s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m11s
docker / deploy-docs (push) Successful in 8s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m49s
2026-06-30 10:12:45 +02:00
enricobuehler 83ee53290e feat(windows-host): mic passthrough — auto-wire audio devices + bundle VB-CABLE
The Windows virtual mic worked only with manual Sound-settings fiddling: on a
headless host (no real audio output) BOTH the desktop-audio loopback and the
virtual mic must run on virtual cables, and on DIFFERENT ones or the loopback
re-captures the injected mic (echo). The Steam pair gives only one usable cable
(Steam Streaming Speakers loopback is silent — validated), so the mic + loopback
collided and echoed, and when the default playback happened to be the mic device
the anti-echo guard reported the mic "unavailable".

Host now auto-wires the devices at startup (audio/windows/audio_control.rs,
ensure_wired_once, hooked from open_audio_capture/open_virtual_mic): default
playback = a loopback-capable render that is NOT a cable and NOT the dead Steam
Speakers (real output > Steam Streaming Microphone); default recording = the mic
capture (VB-Cable "CABLE Output" preferred). Uses a hand-rolled IPolicyConfig
vtable (the only way to set a default endpoint; not in windows/wasapi crates).
Opt out with PUNKTFUNK_KEEP_DEFAULT. wasapi_mic candidates now prefer "cable
input". Validated live: from a deliberately-wrong start (playback=CABLE Input)
the host corrected both default endpoints at the OS level.

A Windows audio endpoint can only be created by a kernel-mode driver (no UMDF
path — ACX is KMDF-only), so we cannot self-sign our own like the UMDF gamepad/
display drivers. Instead the installer bundles + silently installs the official
base VB-CABLE (VB-Audio donationware, vendor-signed → loads with no test-signing,
redistributed under VB-Audio's bundling grant): install-vbcable.ps1 (seed the
VB-Audio cert into TrustedPublisher, run -i -h) + an installaudiocable task,
gated on -VbCableDir/$env:VBCABLE_DIR (the package binary is not in the repo).
Attribution in packaging/windows/licenses/VB-CABLE-NOTICE.txt. .iss compiles
with the path enabled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:09:26 +02:00
enricobuehler 0f798d62b6 feat(windows-host): pf-vdisplay — fix the ADD/REMOVE wedge + per-client display-config persistence
Two phases of pf-vdisplay (IddCx virtual display) lifecycle work, both validated on-glass on the RTX box.

Phase 1 — fix the long-standing IOCTL_ADD 0x80070490 (ERROR_NOT_FOUND) wedge that ghost-monitor
slot-budget exhaustion produced under ADD/REMOVE churn (the reset-script/reboot recurring failure).
Validated: 43 reconnect-churn cycles, 0 wedges, monitor-node count flat at 1.
  * driver: on IddCxMonitorArrival failure, tear the created-but-not-arrived monitor down with
    WdfObjectDelete + reclaim its id — the asymmetric-with-the-create-failure-path leak that exhausted
    the 16-monitor MaxMonitorsSupported budget; recover MONITOR_MODES from lock poisoning instead of
    failing closed (defensive; the driver builds panic=abort).
  * host: collapse the build-retry churn — hold ONE monitor lease across all build attempts and preempt
    only on Lingering (not Active), so a cold start does 1 ADD not 8; reap not-present "punktfunk"
    monitor PDOs on startup (the reset-script step-2 logic, in-process) and self-heal a detected
    0x80070490 by reaping + retrying ADD; force-preempt a stuck-Active prior monitor on the
    begin_idd_setup timeout (the safety net the Lingering-only preempt would otherwise drop).

Phase 2 — give each client (keyed by its cert FINGERPRINT) a STABLE virtual-monitor id (1..=15) so
Windows reapplies that client's saved per-monitor config (DPI SCALING) across reconnects, and two
clients never share/bleed config. Validated: distinct clients -> distinct ids (1, 2); the driver
honors the host's id (echoed resolved == preferred).
  * proto: rename AddRequest._reserved -> preferred_monitor_id (offset 20) and AddReply._reserved ->
    resolved_monitor_id (offset 12) — byte-compatible (offset asserts), NO PROTOCOL_VERSION bump, so a
    pre-Phase-2 driver degrades gracefully to auto-id (the host detects it via the resolved echo).
  * driver: create_monitor honors a host-supplied preferred id via resolve_id (range 1..=15, never
    collides with a live monitor) and seeds the EDID serial + IddCx ConnectorIndex + ContainerId from it.
  * host: a persisted LRU fingerprint->id map (%ProgramData%\punktfunk\pf-vdisplay-identity.json),
    threaded to add_monitor via a set_client_identity no-op trait method (Linux/GameStream unaffected).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:09:26 +02:00
enricobuehler 080c55dbf7 refactor(host/windows): collapse Windows capture to IDD-push only
apple / swift (push) Successful in 1m5s
ci / rust (push) Failing after 1m29s
windows-host / package (push) Failing after 1m11s
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 1m4s
android / android (push) Successful in 3m35s
apple / screenshots (push) Successful in 5m30s
deb / build-publish (push) Successful in 3m18s
decky / build-publish (push) Successful in 27s
ci / bench (push) Successful in 4m39s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 2m38s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m23s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 52s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m24s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m7s
docker / deploy-docs (push) Failing after 12m53s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
Remove DXGI Desktop Duplication (DuplCapturer), Windows.Graphics.Capture
(WgcCapturer), the two-process SYSTEM+helper relay (virtual_stream_relay /
HelperRelay / DesktopWatcher / composed_flip), and the five source files that
implemented them. IDD direct-push is now the sole Windows capture path; the
session topology is always SingleProcess.

Deleted files: wgc.rs, wgc_relay.rs, desktop_watch.rs, composed_flip.rs,
windows/wgc_helper.rs (+ wgc-helper subcommand in main.rs).

dxgi.rs is kept but carved to shared GPU primitives only (make_device,
HdrP010Converter, VideoConverter, install_gpu_pref_hook, WinCaptureTarget,
pack_luid) — ~2237 lines of DDA-only code removed; imports cleaned.

capture.rs: IDD-push open failure fails the session cleanly (no fallback).
Adds capturer_supports_444() — returns false on Windows (IDD-push 4:4:4 is a
follow-up), replacing the stale single_process gate in 4:4:4 negotiation.
session_plan.rs: CaptureBackend{Dda,Wgc} and SessionTopology::TwoProcessRelay
removed. config.rs: no_helper/force_helper/no_wgc/capture_backend/secure_dda
removed. merged_env_block relocated from wgc_relay to windows/interactive.rs.

Linux cargo check clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 06:46:52 +00:00
129 changed files with 4321 additions and 7243 deletions
+1 -1
View File
@@ -80,7 +80,7 @@ jobs:
run: | run: |
case "$GITHUB_REF" in case "$GITHUB_REF" in
refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="alpha" ;; # alpha = built-in closed testing refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="alpha" ;; # alpha = built-in closed testing
*) VN="0.3.0-ci${GITHUB_RUN_NUMBER}"; TRACK="internal" ;; *) VN="0.5.0-ci${GITHUB_RUN_NUMBER}"; TRACK="internal" ;;
esac esac
echo "VERSION_NAME=$VN" >> "$GITHUB_ENV" echo "VERSION_NAME=$VN" >> "$GITHUB_ENV"
echo "PLAY_TRACK=$TRACK" >> "$GITHUB_ENV" echo "PLAY_TRACK=$TRACK" >> "$GITHUB_ENV"
+16 -13
View File
@@ -36,8 +36,8 @@ jobs:
- name: Version + channel - name: Version + channel
# vX.Y.Z tag -> X.Y.Z, published to the `stable` apt distribution (a real release). # vX.Y.Z tag -> X.Y.Z, published to the `stable` apt distribution (a real release).
# A main push -> 0.3.0~ciN.g<sha>, published to the `canary` distribution: the '~' sorts # A main push -> 0.5.0~ciN.g<sha>, published to the `canary` distribution: the '~' sorts
# below the eventual 0.3.0 tag, it climbs monotonically by run number, and the canary base # below the eventual 0.5.0 tag, it climbs monotonically by run number, and the canary base
# stays one minor AHEAD of the latest stable so a stable->canary box re-point still moves # stays one minor AHEAD of the latest stable so a stable->canary box re-point still moves
# forward (see channels.md). Computed BEFORE the build so it's stamped into the binary # forward (see channels.md). Computed BEFORE the build so it's stamped into the binary
# (PUNKTFUNK_BUILD_VERSION -> build.rs -> --version). # (PUNKTFUNK_BUILD_VERSION -> build.rs -> --version).
@@ -45,7 +45,7 @@ jobs:
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8) SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
case "$GITHUB_REF" in case "$GITHUB_REF" in
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; DIST=stable ;; refs/tags/v*) V="${GITHUB_REF_NAME#v}"; DIST=stable ;;
*) V="0.3.0~ci${GITHUB_RUN_NUMBER}.g${SHORT}"; DIST=canary ;; *) V="0.5.0~ci${GITHUB_RUN_NUMBER}.g${SHORT}"; DIST=canary ;;
esac esac
echo "VERSION=$V" >> "$GITHUB_ENV" echo "VERSION=$V" >> "$GITHUB_ENV"
echo "DISTRIBUTION=$DIST" >> "$GITHUB_ENV" echo "DISTRIBUTION=$DIST" >> "$GITHUB_ENV"
@@ -87,12 +87,13 @@ jobs:
git config --global --add safe.directory "$PWD" git config --global --add safe.directory "$PWD"
cargo build --release -p punktfunk-host -p punktfunk-client-linux --locked cargo build --release -p punktfunk-host -p punktfunk-client-linux --locked
- name: Build + smoke-boot web console (node-server preset) - name: Build + smoke-boot web console (bun preset)
# Gate the .deb on a real node boot: the punktfunk-web .deb runs `node .output/server`, # Gate the .deb on a real bun boot: the punktfunk-web .deb runs the Nitro `bun` preset
# so prove the node-server build exists, isn't a bun bundle, and actually serves /login. # (our Bun.serve TLS entry), so prove the build IS a bun bundle and serves /login.
# No TLS env here, so the custom entry binds plain HTTP — the smoke curl stays simple.
run: | run: |
# bun builds the console. It's baked into the rust-ci image, but bootstrap it here too so # bun builds AND runs the console. Baked into the rust-ci image; bootstrap here too so the
# the job stays green against the PREVIOUS image (docker.yml bootstrap lag). # job stays green against the PREVIOUS image (docker.yml bootstrap lag).
command -v bun >/dev/null || { command -v bun >/dev/null || {
apt-get install -y --no-install-recommends unzip apt-get install -y --no-install-recommends unzip
curl -fsSL https://bun.sh/install | bash curl -fsSL https://bun.sh/install | bash
@@ -101,21 +102,23 @@ jobs:
cd web cd web
bun install --frozen-lockfile bun install --frozen-lockfile
bun run build bun run build
if grep -q 'Bun\.serve' .output/server/index.mjs; then if ! grep -q 'Bun\.serve' .output/server/index.mjs; then
echo "ERROR: web build is a bun bundle (Bun.serve) — need the node-server preset"; exit 1 echo "ERROR: web build is not a bun bundle — need the 'bun' preset + custom entry"; exit 1
fi fi
PORT=3009 HOST=127.0.0.1 PUNKTFUNK_UI_PASSWORD=ci node .output/server/index.mjs & PORT=3009 HOST=127.0.0.1 PUNKTFUNK_UI_PASSWORD=ci bun .output/server/index.mjs &
NP=$!; sleep 3 NP=$!; sleep 3
code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3009/login || echo 000) code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3009/login || echo 000)
kill "$NP" 2>/dev/null || true kill "$NP" 2>/dev/null || true
echo "web console smoke: /login -> $code" echo "web console smoke: /login -> $code"
[ "$code" = 200 ] || { echo "ERROR: web console failed to boot under node"; exit 1; } [ "$code" = 200 ] || { echo "ERROR: web console failed to boot under bun"; exit 1; }
- name: Build .debs - name: Build .debs
run: | run: |
export PATH="$HOME/.bun/bin:$PATH"
VERSION="$VERSION" bash packaging/debian/build-deb.sh VERSION="$VERSION" bash packaging/debian/build-deb.sh
VERSION="$VERSION" bash packaging/debian/build-client-deb.sh VERSION="$VERSION" bash packaging/debian/build-client-deb.sh
VERSION="$VERSION" bash packaging/debian/build-web-deb.sh # Reuse CI's bun for the vendored runtime (matches the amd64 runner) instead of downloading.
VERSION="$VERSION" BUN_BIN="$(command -v bun || true)" bash packaging/debian/build-web-deb.sh
- name: Publish to the Gitea apt registry - name: Publish to the Gitea apt registry
env: env:
+2 -2
View File
@@ -73,7 +73,7 @@ jobs:
- name: Version + channel - name: Version + channel
# Tag vX.Y.Z -> X.Y.Z on the OSTree `stable` branch (a real release); a main push -> # Tag vX.Y.Z -> X.Y.Z on the OSTree `stable` branch (a real release); a main push ->
# 0.3.0-ciN.g<sha> on the `canary` branch. The two branches live side-by-side in one repo # 0.5.0-ciN.g<sha> on the `canary` branch. The two branches live side-by-side in one repo
# (rsync runs without --delete), each tracked by its own .flatpakref, so `flatpak update` # (rsync runs without --delete), each tracked by its own .flatpakref, so `flatpak update`
# on a stable box never jumps to a canary build. The generic-registry version string allows # on a stable box never jumps to a canary build. The generic-registry version string allows
# letters/dots/hyphens. # letters/dots/hyphens.
@@ -81,7 +81,7 @@ jobs:
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8) SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
case "$GITHUB_REF" in case "$GITHUB_REF" in
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; BRANCH=stable; ALIAS=latest ;; refs/tags/v*) V="${GITHUB_REF_NAME#v}"; BRANCH=stable; ALIAS=latest ;;
*) V="0.3.0-ci${GITHUB_RUN_NUMBER}.g${SHORT}"; BRANCH=canary; ALIAS=canary ;; *) V="0.5.0-ci${GITHUB_RUN_NUMBER}.g${SHORT}"; BRANCH=canary; ALIAS=canary ;;
esac esac
echo "VERSION=$V" >> "$GITHUB_ENV" echo "VERSION=$V" >> "$GITHUB_ENV"
echo "BUNDLE=punktfunk-client-${V}.flatpak" >> "$GITHUB_ENV" echo "BUNDLE=punktfunk-client-${V}.flatpak" >> "$GITHUB_ENV"
+1 -1
View File
@@ -101,7 +101,7 @@ jobs:
run: | run: |
case "$GITHUB_REF" in case "$GITHUB_REF" in
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; V="${V%%-*}" ;; # App Store marketing version is numeric X.Y.Z (drop -rc) refs/tags/v*) V="${GITHUB_REF_NAME#v}"; V="${V%%-*}" ;; # App Store marketing version is numeric X.Y.Z (drop -rc)
*) V="0.3.0" ;; # canary marketing version; the build number disambiguates *) V="0.5.0" ;; # canary marketing version; the build number disambiguates
esac esac
echo "VERSION=$V" >> "$GITHUB_ENV" echo "VERSION=$V" >> "$GITHUB_ENV"
echo "BUILD_NUM=$GITHUB_RUN_NUMBER" >> "$GITHUB_ENV" echo "BUILD_NUM=$GITHUB_RUN_NUMBER" >> "$GITHUB_ENV"
+3 -3
View File
@@ -68,8 +68,8 @@ jobs:
restore-keys: cargo-home- restore-keys: cargo-home-
- name: Version + channel - name: Version + channel
# vX.Y.Z tag -> X.Y.Z-1 in the base group (a real release); main push -> 0.3.0-0.ciN.g<sha> # vX.Y.Z tag -> X.Y.Z-1 in the base group (a real release); main push -> 0.5.0-0.ciN.g<sha>
# in the `<base>-canary` group, whose "0." release sorts below the eventual 0.3.0-1 yet # in the `<base>-canary` group, whose "0." release sorts below the eventual 0.5.0-1 yet
# climbs by run number. The canary base stays one minor ahead of the latest stable so a # climbs by run number. The canary base stays one minor ahead of the latest stable so a
# stable->canary box re-point still moves forward. The spec %build stamps # stable->canary box re-point still moves forward. The spec %build stamps
# PUNKTFUNK_BUILD_VERSION from these macros into the binary (--version provenance). # PUNKTFUNK_BUILD_VERSION from these macros into the binary (--version provenance).
@@ -77,7 +77,7 @@ jobs:
SHORT=$(echo "$GITHUB_SHA" | cut -c1-8) SHORT=$(echo "$GITHUB_SHA" | cut -c1-8)
case "$GITHUB_REF" in case "$GITHUB_REF" in
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; GROUP="${{ matrix.group }}" ;; refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; GROUP="${{ matrix.group }}" ;;
*) V="0.3.0"; R="0.ci${GITHUB_RUN_NUMBER}.g${SHORT}"; GROUP="${{ matrix.group }}-canary" ;; *) V="0.5.0"; R="0.ci${GITHUB_RUN_NUMBER}.g${SHORT}"; GROUP="${{ matrix.group }}-canary" ;;
esac esac
echo "PF_VERSION=$V" >> "$GITHUB_ENV" echo "PF_VERSION=$V" >> "$GITHUB_ENV"
echo "PF_RELEASE=$R" >> "$GITHUB_ENV" echo "PF_RELEASE=$R" >> "$GITHUB_ENV"
+2 -2
View File
@@ -171,8 +171,8 @@ jobs:
Push-Location web Push-Location web
& $bun install --frozen-lockfile; if ($LASTEXITCODE) { throw "bun install failed ($LASTEXITCODE)" } & $bun install --frozen-lockfile; if ($LASTEXITCODE) { throw "bun install failed ($LASTEXITCODE)" }
& $bun run build; if ($LASTEXITCODE) { throw "web build failed ($LASTEXITCODE)" } & $bun run build; if ($LASTEXITCODE) { throw "web build failed ($LASTEXITCODE)" }
if (Select-String -Path .output\server\index.mjs -Pattern 'Bun\.serve' -Quiet) { if (-not (Select-String -Path .output\server\index.mjs -Pattern 'Bun\.serve' -Quiet)) {
throw "web build is a bun bundle (Bun.serve) - need the node-server preset" throw "web build is not a bun bundle - need the 'bun' preset + custom entry"
} }
Pop-Location Pop-Location
# Gate the installer on a real boot under the BUNDLED bun (the runtime it ships), serving /login. # Gate the installer on a real boot under the BUNDLED bun (the runtime it ships), serving /login.
Generated
+8 -8
View File
@@ -1995,7 +1995,7 @@ dependencies = [
[[package]] [[package]]
name = "latency-probe" name = "latency-probe"
version = "0.3.0" version = "0.4.1"
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
@@ -2127,7 +2127,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]] [[package]]
name = "loss-harness" name = "loss-harness"
version = "0.3.0" version = "0.4.1"
dependencies = [ dependencies = [
"punktfunk-core", "punktfunk-core",
] ]
@@ -2720,7 +2720,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-android" name = "punktfunk-client-android"
version = "0.3.0" version = "0.4.1"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"jni", "jni",
@@ -2734,7 +2734,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-linux" name = "punktfunk-client-linux"
version = "0.3.0" version = "0.4.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -2754,7 +2754,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-windows" name = "punktfunk-client-windows"
version = "0.3.0" version = "0.4.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -2774,7 +2774,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-core" name = "punktfunk-core"
version = "0.3.0" version = "0.4.1"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"bytes", "bytes",
@@ -2804,7 +2804,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-host" name = "punktfunk-host"
version = "0.3.0" version = "0.4.1"
dependencies = [ dependencies = [
"aes", "aes",
"aes-gcm", "aes-gcm",
@@ -2870,7 +2870,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-probe" name = "punktfunk-probe"
version = "0.3.0" version = "0.4.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"mdns-sd", "mdns-sd",
+1 -1
View File
@@ -16,7 +16,7 @@ members = [
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"] exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
[workspace.package] [workspace.package]
version = "0.3.0" version = "0.4.1"
edition = "2021" edition = "2021"
rust-version = "1.82" rust-version = "1.82"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
+1 -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.3.0" "version": "0.4.1"
}, },
"paths": { "paths": {
"/api/v1/clients": { "/api/v1/clients": {
+7 -5
View File
@@ -16,8 +16,9 @@ RUN dnf -y install \
"https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm" \ "https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm" \
"https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm" \ "https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm" \
&& dnf -y install \ && dnf -y install \
# rpmbuild + source-tarball tooling; nodejs runs the Gitea Actions JS (checkout/cache) # rpmbuild + source-tarball tooling; nodejs runs the Gitea Actions JS (checkout/cache) only
# AND the punktfunk-web .output at runtime; unzip is for the bun installer below. # the punktfunk-web console builds AND runs on bun (installed below); unzip is for the bun
# installer.
rpm-build rpmdevtools systemd-rpm-macros git tar gzip nodejs unzip \ rpm-build rpmdevtools systemd-rpm-macros git tar gzip nodejs unzip \
# build toolchain + bindgen # build toolchain + bindgen
gcc gcc-c++ clang clang-devel cmake nasm pkgconf-pkg-config curl ca-certificates \ gcc gcc-c++ clang clang-devel cmake nasm pkgconf-pkg-config curl ca-certificates \
@@ -28,9 +29,10 @@ RUN dnf -y install \
gtk4-devel libadwaita-devel SDL3-devel \ gtk4-devel libadwaita-devel SDL3-devel \
&& dnf clean all && dnf clean all
# bun — the build tool for the punktfunk-web console (`bun run build` -> the node-server .output # bun — both the BUILD tool and the RUNTIME for the punktfunk-web console (`bun run build` -> the
# the punktfunk-web RPM ships and runs with plain node). Not in Fedora repos; install the official # Nitro `bun`-preset .output, served by `Bun.serve` with TLS — HTTP/1.1 over TLS). The
# standalone binary to a system PATH dir so the rpmbuild `%build` (run as any uid) finds it. # RPM vendors THIS bun binary. Not in Fedora repos; install the official standalone binary to a
# system PATH dir so the rpmbuild `%build`/`%install` (run as any uid) find it.
RUN curl -fsSL https://bun.sh/install | bash \ RUN curl -fsSL https://bun.sh/install | bash \
&& install -m0755 /root/.bun/bin/bun /usr/local/bin/bun \ && install -m0755 /root/.bun/bin/bun /usr/local/bin/bun \
&& bun --version && bun --version
@@ -711,8 +711,8 @@ struct SettingsView: View {
} footer: { } footer: {
Text("Adds a “Browse Library…” action to each host that lists its games " Text("Adds a “Browse Library…” action to each host that lists its games "
+ "(Steam + custom) via the host's management API; tap a title to launch it. " + "(Steam + custom) via the host's management API; tap a title to launch it. "
+ "The host must expose that API on the LAN with a token " + "Works once you've paired with the host — the library is authorized by this "
+ "(serve --mgmt-bind 0.0.0.0 --mgmt-token …).") + "device's certificate, with no extra host setup.")
.font(.geist(12, relativeTo: .caption)) .font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -107,23 +107,6 @@ public final class InputCapture {
/// macOS (no GCMouse handlers installed; `sendMouseAbs` is never called there). Main-queue. /// macOS (no GCMouse handlers installed; `sendMouseAbs` is never called there). Main-queue.
public var gcMouseForwarding = false public var gcMouseForwarding = false
#if os(iOS)
/// Whether any device is attached as a `GCMouse` right now. The Magic Keyboard TRACKPAD does
/// not always register as a GCMouse on iPadOS (only a standalone mouse does) when no GCMouse
/// is present the relative GCMouse path can't carry pointer motion. Main-queue.
public var hasGCMouse: Bool { !mice.isEmpty }
/// Diagnostic: a one-line description of every attached GCMouse (count + GCDevice identity), so
/// PUNKTFUNK_INPUT_DEBUG can reveal whether the trackpad showed up as a mouse at all.
public var attachedMiceSummary: String {
guard !mice.isEmpty else { return "0 mice" }
let parts = mice.map { mouse -> String in
"\(mouse.productCategory)/\(mouse.vendorName ?? "?")"
}
return "\(mice.count) mice: \(parts.joined(separator: ", "))"
}
#endif
/// Fired on (the capture toggle detected here so it works in both states; the /// Fired on (the capture toggle detected here so it works in both states; the
/// event itself is swallowed). Main queue. /// event itself is swallowed). Main queue.
public var onToggleCapture: (() -> Void)? public var onToggleCapture: (() -> Void)?
@@ -177,7 +160,13 @@ public final class InputCapture {
previous.onPreempted?() previous.onPreempted?()
} }
Self.activeCapture = self Self.activeCapture = self
if let mouse = GCMouse.current { attach(mouse: mouse) } // Attach EVERY connected mouse, not just GCMouse.current. With two pointing devices (e.g.
// the iPad's own Magic Keyboard trackpad AND a Universal Control "V-UC Automouse"), only one
// is `current` at a time; attaching just that one left the OTHER device's motion handler
// uninstalled, so moving it did nothing. Each GCMouse delivers its own deltas through its own
// handler, so handling all of them lets either device drive. New arrivals are caught by the
// GCMouseDidConnect observer below.
for mouse in GCMouse.mice() { attach(mouse: mouse) }
if let keyboard = GCKeyboard.coalesced { attach(keyboard: keyboard) } if let keyboard = GCKeyboard.coalesced { attach(keyboard: keyboard) }
observers.append(NotificationCenter.default.addObserver( observers.append(NotificationCenter.default.addObserver(
forName: .GCMouseDidConnect, object: nil, queue: .main forName: .GCMouseDidConnect, object: nil, queue: .main
@@ -411,12 +400,6 @@ public final class InputCapture {
!mice.contains(where: { $0 === mouse }) // re-delivered on wake attach once !mice.contains(where: { $0 === mouse }) // re-delivered on wake attach once
else { return } else { return }
mice.append(mouse) mice.append(mouse)
#if os(iOS)
if inputDebug {
inputLog.debug(
"GCMouse attached: \(mouse.productCategory, privacy: .public)/\(mouse.vendorName ?? "?", privacy: .public) — now \(self.attachedMiceSummary, privacy: .public)")
}
#endif
// macOS drives motion + buttons from NSEvent (StreamLayerView's local monitor // macOS drives motion + buttons from NSEvent (StreamLayerView's local monitor
// sendMotion/sendMouseButton) because GCMouse's handlers proved unreliable there; // sendMotion/sendMouseButton) because GCMouse's handlers proved unreliable there;
// installing them too would double-send. iOS keeps GCMouse (raw deltas under // installing them too would double-send. iOS keeps GCMouse (raw deltas under
@@ -3,10 +3,11 @@
// /library page renders. Read-only on the client for now; launching a chosen title is a later // /library page renders. Read-only on the client for now; launching a chosen title is a later
// step. Gated behind `DefaultsKey.libraryEnabled` in the UI. // step. Gated behind `DefaultsKey.libraryEnabled` in the UI.
// //
// The management API is HTTP on a port distinct from the punktfunk/1 data plane (default 47990), // The management API serves HTTPS on a port distinct from the punktfunk/1 data plane (default
// binds loopback unless started with a token, and REQUIRES a bearer token for any non-loopback // 47990, also advertised in the host's mDNS `mgmt` TXT). A paired client is authorized for the
// bind. So to browse a host's library remotely the host must expose the mgmt API on the LAN with // read-only library route by its **mTLS certificate** no bearer token. The host binds this read
// `--mgmt-token`; the client carries that token per host. This mirrors the GameEntry/Artwork/ // surface to the LAN by DEFAULT (the bearer-gated admin surface stays loopback-only), so a paired
// client browses a host's library with no operator step. This mirrors the GameEntry/Artwork/
// LaunchSpec schema in `crates/punktfunk-host/src/library.rs`. // LaunchSpec schema in `crates/punktfunk-host/src/library.rs`.
import Foundation import Foundation
@@ -56,8 +57,9 @@ public enum LibraryError: LocalizedError {
case .http(let code): case .http(let code):
return "The management API returned HTTP \(code)." return "The management API returned HTTP \(code)."
case .unreachable(let why): case .unreachable(let why):
return "Couldn't reach the host's management API: \(why). The host must expose it on " return "Couldn't reach the host's management API: \(why). It binds the LAN by default, "
+ "the LAN (serve --mgmt-bind 0.0.0.0)." + "so check the host is updated and reachable (a host pinned to "
+ "`--mgmt-bind 127.0.0.1` is loopback-only and can't be browsed remotely)."
} }
} }
} }
@@ -223,32 +223,39 @@ public final class MetalVideoPresenter {
} }
/// Set the layer's pixel format + colour config for SDR or HDR. MAIN THREAD ONLY. EDR is requested /// Set the layer's pixel format + colour config for SDR or HDR. MAIN THREAD ONLY. EDR is requested
/// on ALL platforms the property is available on macOS/iOS/tvOS at our deployment floor, and the /// on macOS + iOS (the old `#if os(macOS)` guard left iOS EDR half-engaged). tvOS has NO EDR API
/// old `#if os(macOS)` guard left iOS/tvOS EDR half-engaged. /// (`wantsExtendedDynamicRangeContent`/`edrMetadata`/`CAEDRMetadata` are all unavailable there), so
/// it gets the PQ pixel format + colour space only the tvOS compositor tone-maps from those.
private func configureColor(hdr: Bool) { private func configureColor(hdr: Bool) {
if hdr { if hdr {
layer.pixelFormat = .rgba16Float layer.pixelFormat = .rgba16Float
layer.colorspace = CGColorSpace(name: CGColorSpace.itur_2100_PQ) layer.colorspace = CGColorSpace(name: CGColorSpace.itur_2100_PQ)
#if !os(tvOS)
layer.wantsExtendedDynamicRangeContent = true layer.wantsExtendedDynamicRangeContent = true
// Anchor reference white. Re-apply the real grade if one already arrived (0xCE before the // Anchor reference white. Re-apply the real grade if one already arrived (0xCE before the
// flip); otherwise the bare 203-nit anchor. Without this anchor the PQ signal is too bright. // flip); otherwise the bare 203-nit anchor. Without this anchor the PQ signal is too bright.
layer.edrMetadata = makeEDR(lastHdrMeta) layer.edrMetadata = makeEDR(lastHdrMeta)
#endif
} else { } else {
// SDR: gamma-encoded BT.709 [0,1] in an 8-bit drawable; a nil colorspace tags it device/sRGB // SDR: gamma-encoded BT.709 [0,1] in an 8-bit drawable; a nil colorspace tags it device/sRGB
// (the proven SDR path never showed the "too bright" issue, which was HDR-only). // (the proven SDR path never showed the "too bright" issue, which was HDR-only).
layer.pixelFormat = .bgra8Unorm layer.pixelFormat = .bgra8Unorm
layer.colorspace = nil layer.colorspace = nil
#if !os(tvOS)
layer.wantsExtendedDynamicRangeContent = false layer.wantsExtendedDynamicRangeContent = false
layer.edrMetadata = nil layer.edrMetadata = nil
#endif
} }
} }
#if !os(tvOS)
private func makeEDR(_ meta: PunktfunkConnection.HdrMeta?) -> CAEDRMetadata { private func makeEDR(_ meta: PunktfunkConnection.HdrMeta?) -> CAEDRMetadata {
CAEDRMetadata.hdr10( CAEDRMetadata.hdr10(
displayInfo: meta?.masteringDisplayColorVolume(), displayInfo: meta?.masteringDisplayColorVolume(),
contentInfo: meta?.contentLightLevelInfo(), contentInfo: meta?.contentLightLevelInfo(),
opticalOutputScale: hdrReferenceWhiteNits) opticalOutputScale: hdrReferenceWhiteNits)
} }
#endif
/// Update the HDR mastering metadata (drained from the host's 0xCE datagram) to refine the system /// Update the HDR mastering metadata (drained from the host's 0xCE datagram) to refine the system
/// tone-map from the real grade. Called from the PUMP thread, so the layer write is hopped to MAIN /// tone-map from the real grade. Called from the PUMP thread, so the layer write is hopped to MAIN
@@ -259,7 +266,11 @@ public final class MetalVideoPresenter {
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self else { return } guard let self else { return }
self.lastHdrMeta = meta self.lastHdrMeta = meta
// tvOS has no edrMetadata the cached grade is still kept above (harmless), it just can't
// be applied to the layer there. macOS/iOS refine the system tone-map from the real grade.
#if !os(tvOS)
if self.hdrActive { self.layer.edrMetadata = self.makeEDR(meta) } if self.hdrActive { self.layer.edrMetadata = self.makeEDR(meta) }
#endif
} }
} }
@@ -11,13 +11,18 @@
// host mode, so the host's rescale is the identity). // host mode, so the host's rescale is the identity).
// //
// A hardware mouse/trackpad is a pointer, not a finger. When the scene is pointer-LOCKED // A hardware mouse/trackpad is a pointer, not a finger. When the scene is pointer-LOCKED
// (full-screen + frontmost iPad) GCMouse delivers raw relative deltas and the system hides // (full-screen + frontmost iPad, and the user hasn't disabled pointer capture in Settings
// the cursor the gaming-grade path. When it CAN'T lock (Stage Manager, not frontmost, // see PointerLockChain, which steers the lock request through SwiftUI's hosting controllers)
// iPhone) the system shows its own cursor and routes the mouse through UIKit's pointer path: // GCMouse delivers raw relative deltas and the system hides the cursor the gaming-grade path.
// hover + indirect-pointer touches, which we forward as ABSOLUTE cursor position (+ buttons) // InputCapture handles EVERY connected mouse (GCMouse.mice), not just the current one, so a
// so the host cursor tracks the visible local one. We never forward an indirect pointer as a // trackpad + a second pointer (e.g. a Universal Control mouse) both drive. When the scene CAN'T
// touch doing so hid the cursor and made the host see taps instead of a moving mouse. // lock (Stage Manager, not frontmost, iPhone, capture disabled) the system shows its own cursor
// GCMouse is gated off whenever the lock isn't held so the two paths can't double-send. // and routes the mouse through UIKit's pointer path: hover + indirect-pointer touches, which we
// forward as ABSOLUTE cursor position (+ buttons) so the host cursor tracks the visible local one.
// We never forward an indirect pointer as a touch doing so hid the cursor and made the host see
// taps instead of a moving mouse. The two paths are mutually exclusive on `gcMouseForwarding`
// (== locked): GCMouse forwards only WHILE locked, the UIKit indirect path (motion, buttons AND
// scroll) only while NOT locked so a pointer that emits both channels under lock can't double-send.
// Hardware keyboard forwarding shares InputCapture with macOS auto-engaged when streaming // Hardware keyboard forwarding shares InputCapture with macOS auto-engaged when streaming
// starts, toggles (detected from the HID stream; there is no NSEvent monitor here). // starts, toggles (detected from the HID stream; there is no NSEvent monitor here).
// //
@@ -236,32 +241,24 @@ public final class StreamViewController: UIViewController {
guard self?.captureEnabled == true else { return } guard self?.captureEnabled == true else { return }
connection?.send(event) connection?.send(event)
} }
// Indirect pointer (mouse/trackpad with no lock) absolute cursor + buttons, routed // Indirect pointer (mouse/trackpad) WITHOUT a lock absolute cursor + buttons + scroll.
// through InputCapture so the forwarding gate and release-on-blur apply uniformly. // While the scene is pointer-LOCKED the GCMouse path owns motion AND buttons AND scroll, so
// the whole UIKit indirect path is gated off here (`gcMouseForwarding`). The trackpad and a
// mouse BOTH report through GCMouse under lock and ALSO emit UIKit indirect-pointer events
// (pinned at the locked position) without this gate a click double-sends (GCMouse + UIKit)
// and a second pointer (e.g. a Universal Control mouse) competes with the trackpad. The gate
// is the exact mirror of the GCMouse handlers, which fire only while locked.
streamView.onPointerMoveAbs = { [weak self] p in streamView.onPointerMoveAbs = { [weak self] p in
guard let self else { return } guard let self, self.inputCapture?.gcMouseForwarding == false else { return }
if iosInputDebug {
// Whether ANY UIKit pointer movement reaches us while the scene is LOCKED tells us
// if the trackpad (which may not be a GCMouse) can still be captured via UIKit.
iosInputLog.debug(
"UIKit pointer move x=\(p.x, privacy: .public) y=\(p.y, privacy: .public) locked=\(self.pointerLockEngaged() == true, privacy: .public) gcFwd=\(self.inputCapture?.gcMouseForwarding == true, privacy: .public)")
}
self.inputCapture?.sendMouseAbs( self.inputCapture?.sendMouseAbs(
x: p.x, y: p.y, surfaceWidth: p.w, surfaceHeight: p.h) x: p.x, y: p.y, surfaceWidth: p.w, surfaceHeight: p.h)
} }
streamView.onPointerButton = { [weak self] button, down in streamView.onPointerButton = { [weak self] button, down in
self?.inputCapture?.sendMouseButton(button, pressed: down) guard let self, self.inputCapture?.gcMouseForwarding == false else { return }
self.inputCapture?.sendMouseButton(button, pressed: down)
} }
// Trackpad two-finger / wheel scroll host scroll. The pan recognizer is the
// UNLOCKED regime; while locked, GCMouse's scroll handler owns it mirror the
// sendMouseAbs !gcMouseForwarding gate so the two can't double-send.
streamView.onScroll = { [weak self] dx, dy in streamView.onScroll = { [weak self] dx, dy in
guard let self else { return } guard let self, self.inputCapture?.gcMouseForwarding == false else { return }
if iosInputDebug {
iosInputLog.debug(
"UIKit scroll dx=\(dx, privacy: .public) dy=\(dy, privacy: .public) locked=\(self.pointerLockEngaged() == true, privacy: .public)")
}
guard self.inputCapture?.gcMouseForwarding == false else { return }
self.inputCapture?.sendScroll(dx: dx, dy: dy) self.inputCapture?.sendScroll(dx: dx, dy: dy)
} }
@@ -472,7 +469,7 @@ public final class StreamViewController: UIViewController {
pointerInteraction?.invalidate() // re-resolve the hidden/visible cursor for the state pointerInteraction?.invalidate() // re-resolve the hidden/visible cursor for the state
if iosInputDebug { if iosInputDebug {
iosInputLog.debug( iosInputLog.debug(
"pointer lock isLocked=\(locked, privacy: .public) captured=\(self.captured, privacy: .public) useGCMouse=\(useGCMouse, privacy: .public) [\(self.inputCapture?.attachedMiceSummary ?? "n/a", privacy: .public)]") "pointer lock isLocked=\(locked, privacy: .public) captured=\(self.captured, privacy: .public)")
} }
} }
#endif #endif
+30 -3
View File
@@ -80,7 +80,14 @@ pub mod control {
pub width: u32, pub width: u32,
pub height: u32, pub height: u32,
pub refresh_hz: u32, pub refresh_hz: u32,
pub _reserved: u32, /// Host-preferred per-client monitor id (`1..=15`) — the EDID serial / IddCx `ConnectorIndex` /
/// `ContainerId` the driver names this monitor by. A given client (keyed by its cert fingerprint)
/// gets a STABLE id across reconnects, so the OS device path + EDID stay identical and Windows
/// reapplies that client's saved per-monitor config (DPI scaling). `0` = AUTO: the driver
/// allocates the lowest-free id (the original slot-based behavior — used for anonymous/TOFU and
/// GameStream sessions). Byte-compatible with the old `_reserved` (offset 20): an un-upgraded
/// driver ignores it (→ auto), which the host detects via [`AddReply::resolved_monitor_id`].
pub preferred_monitor_id: u32,
} }
/// `IOCTL_ADD` reply: the OS target id + the adapter LUID the IDD landed on (split low/high to /// `IOCTL_ADD` reply: the OS target id + the adapter LUID the IDD landed on (split low/high to
@@ -91,7 +98,11 @@ pub mod control {
pub adapter_luid_low: u32, pub adapter_luid_low: u32,
pub adapter_luid_high: i32, pub adapter_luid_high: i32,
pub target_id: u32, pub target_id: u32,
pub _reserved: u32, /// The monitor id the driver ACTUALLY used — echoes [`AddRequest::preferred_monitor_id`] when the
/// preference was honored, or the auto-allocated id otherwise. Byte-compatible with the old
/// `_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.
pub resolved_monitor_id: u32,
} }
/// `IOCTL_REMOVE` input. /// `IOCTL_REMOVE` input.
@@ -129,11 +140,13 @@ pub mod control {
assert!(offset_of!(AddRequest, width) == 8); assert!(offset_of!(AddRequest, width) == 8);
assert!(offset_of!(AddRequest, height) == 12); assert!(offset_of!(AddRequest, height) == 12);
assert!(offset_of!(AddRequest, refresh_hz) == 16); assert!(offset_of!(AddRequest, refresh_hz) == 16);
assert!(offset_of!(AddRequest, preferred_monitor_id) == 20);
assert!(size_of::<AddReply>() == 16); assert!(size_of::<AddReply>() == 16);
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!(size_of::<RemoveRequest>() == 8); assert!(size_of::<RemoveRequest>() == 8);
assert!(offset_of!(RemoveRequest, session_id) == 0); assert!(offset_of!(RemoveRequest, session_id) == 0);
@@ -436,11 +449,25 @@ mod tests {
width: 3840, width: 3840,
height: 2160, height: 2160,
refresh_hz: 120, refresh_hz: 120,
_reserved: 0, preferred_monitor_id: 7,
}; };
let bytes = bytemuck::bytes_of(&req); let bytes = bytemuck::bytes_of(&req);
assert_eq!(bytes.len(), 24); assert_eq!(bytes.len(), 24);
assert_eq!(*bytemuck::from_bytes::<control::AddRequest>(bytes), req); assert_eq!(*bytemuck::from_bytes::<control::AddRequest>(bytes), req);
// preferred_monitor_id occupies the old `_reserved` slot at offset 20 — byte-compatible.
assert_eq!(bytes[20..24], 7u32.to_le_bytes());
let reply = control::AddReply {
adapter_luid_low: 0x1234_5678,
adapter_luid_high: -2,
target_id: 262,
resolved_monitor_id: 7,
};
let rbytes = bytemuck::bytes_of(&reply);
assert_eq!(rbytes.len(), 16);
assert_eq!(*bytemuck::from_bytes::<control::AddReply>(rbytes), reply);
// resolved_monitor_id occupies the old `_reserved` slot at offset 12 — byte-compatible.
assert_eq!(rbytes[12..16], 7u32.to_le_bytes());
} }
#[test] #[test]
+3
View File
@@ -182,6 +182,9 @@ windows = { version = "0.62", features = [
# Windows service supervisor (src/service.rs): a kill-on-close job object so a service crash never # Windows service supervisor (src/service.rs): a kill-on-close job object so a service crash never
# orphans the SYSTEM host it launched into the interactive session. # orphans the SYSTEM host it launched into the interactive session.
"Win32_System_JobObjects", "Win32_System_JobObjects",
# CoCreateInstance(PolicyConfigClient) — set the default audio playback/recording endpoints via the
# undocumented IPolicyConfig (audio/windows/audio_control.rs) so mic + desktop audio auto-wire.
"Win32_System_Com",
] } ] }
# The SCM plumbing for the `service` subcommand (define_windows_service! / dispatcher / control # The SCM plumbing for the `service` subcommand (define_windows_service! / dispatcher / control
# handler / ServiceManager install). Wraps the Win32 service API; the supervision loop itself uses # handler / ServiceManager install). Wraps the Win32 service API; the supervision loop itself uses
+5
View File
@@ -42,6 +42,7 @@ pub fn open_audio_capture(channels: u32) -> Result<Box<dyn AudioCapturer>> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub fn open_audio_capture(channels: u32) -> Result<Box<dyn AudioCapturer>> { pub fn open_audio_capture(channels: u32) -> Result<Box<dyn AudioCapturer>> {
audio_control::ensure_wired_once();
wasapi_cap::WasapiLoopbackCapturer::open(channels) wasapi_cap::WasapiLoopbackCapturer::open(channels)
.map(|c| Box::new(c) as Box<dyn AudioCapturer>) .map(|c| Box::new(c) as Box<dyn AudioCapturer>)
} }
@@ -77,6 +78,7 @@ pub fn open_virtual_mic(channels: u32) -> Result<Box<dyn VirtualMic>> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub fn open_virtual_mic(channels: u32) -> Result<Box<dyn VirtualMic>> { pub fn open_virtual_mic(channels: u32) -> Result<Box<dyn VirtualMic>> {
audio_control::ensure_wired_once();
wasapi_mic::WasapiVirtualMic::open(channels).map(|m| Box::new(m) as Box<dyn VirtualMic>) wasapi_mic::WasapiVirtualMic::open(channels).map(|m| Box::new(m) as Box<dyn VirtualMic>)
} }
@@ -85,6 +87,9 @@ pub fn open_virtual_mic(_channels: u32) -> Result<Box<dyn VirtualMic>> {
anyhow::bail!("virtual mic requires Linux + PipeWire or Windows + a virtual audio device") anyhow::bail!("virtual mic requires Linux + PipeWire or Windows + a virtual audio device")
} }
#[cfg(target_os = "windows")]
#[path = "audio/windows/audio_control.rs"]
mod audio_control;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
mod linux; mod linux;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
@@ -0,0 +1,227 @@
//! Windows audio device auto-wiring — production mic + desktop-audio passthrough with zero manual
//! setup.
//!
//! A headless host has no real audio output, so BOTH the desktop-audio loopback ([`super::wasapi_cap`])
//! and the virtual mic ([`super::wasapi_mic`]) must run on VIRTUAL audio cables — and on DIFFERENT
//! ones, or the loopback re-captures the injected mic (an infinite echo). The installer bundles
//! VB-Audio Virtual Cable (the mic target: its "CABLE Input" render endpoint → "CABLE Output" capture)
//! and the host auto-installs the Steam Streaming pair (a loopback-capable render). This module wires
//! them up at startup so no manual Sound-settings fiddling is ever needed:
//!
//! * default **PLAYBACK** → a loopback-capable render that is NOT the mic cable (a real output device
//! if one exists, else the Steam Streaming Microphone; **never** the Steam Streaming Speakers, whose
//! loopback is silent — validated live). This is the endpoint [`super::wasapi_cap`] loopback-captures
//! for desktop audio.
//! * default **RECORDING** → the virtual mic's capture endpoint (VB-Cable "CABLE Output") so host apps
//! record the client's mic by default.
//!
//! [`super::wasapi_mic::find_device`] then resolves the mic INJECT target to "CABLE Input" — a render
//! candidate that is NOT the default playback — guaranteeing loopback ≠ mic, so there is no echo.
//!
//! Setting a default endpoint uses the undocumented `IPolicyConfig` COM interface (the only way to set
//! a default device programmatically — neither the `windows` nor `wasapi` crate exposes it; it is the
//! same call `mmsys.cpl` makes). Opt out with `PUNKTFUNK_KEEP_DEFAULT` to leave the user's chosen
//! defaults untouched.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
#![deny(clippy::undocumented_unsafe_blocks)]
use anyhow::{anyhow, bail, Result};
use std::ffi::c_void;
use std::sync::Once;
use wasapi::Direction;
/// Run the audio device auto-wiring exactly once per process, before the first capturer/mic opens.
/// Blocks until done so the default playback is set before the loopback captures it. Best-effort:
/// every failure is logged, never fatal (the host then falls back to whatever the current defaults
/// are — exactly the pre-wiring behaviour).
pub(crate) fn ensure_wired_once() {
static WIRED: Once = Once::new();
WIRED.call_once(|| {
if std::env::var_os("PUNKTFUNK_KEEP_DEFAULT").is_some() {
tracing::info!("PUNKTFUNK_KEEP_DEFAULT set — leaving the audio default devices untouched");
return;
}
// Run on a dedicated COM-MTA thread so we never collide with the caller's apartment mode
// (the capture/mic threads each initialize their own COM separately).
let handle = std::thread::Builder::new()
.name("pf-audio-wiring".into())
.spawn(|| {
if wasapi::initialize_mta().ok().is_err() {
tracing::warn!("audio wiring: COM init (MTA) failed — skipping");
return;
}
if let Err(e) = ensure_audio_wiring() {
tracing::warn!(error = %format!("{e:#}"),
"audio auto-wiring failed — mic/desktop audio may need manual device defaults");
}
});
if let Ok(h) = handle {
let _ = h.join();
}
});
}
/// `(friendly_name, endpoint_id)` for every ACTIVE endpoint in direction `dir`.
fn list_endpoints(dir: Direction) -> Vec<(String, String)> {
let mut out = Vec::new();
let Ok(en) = wasapi::DeviceEnumerator::new() else {
return out;
};
let Ok(coll) = en.get_device_collection(&dir) else {
return out;
};
let Ok(n) = coll.get_nbr_devices() else {
return out;
};
for i in 0..n {
if let Ok(dev) = coll.get_device_at_index(i) {
let id = dev.get_id().unwrap_or_default();
if id.is_empty() {
continue;
}
out.push((dev.get_friendlyname().unwrap_or_default(), id));
}
}
out
}
/// Pick the loopback + mic-capture devices and set them as the default playback/recording.
fn ensure_audio_wiring() -> Result<()> {
let renders = list_endpoints(Direction::Render);
let captures = list_endpoints(Direction::Capture);
if renders.is_empty() {
bail!("no active render endpoints to wire");
}
// A render is unusable as the desktop-audio loopback if it is a VB-Cable endpoint (reserved for
// the mic inject) or the Steam Streaming Speakers (its loopback is silent — validated live).
let excluded_loopback =
|ln: &str| ln.contains("cable") || ln.contains("steam streaming speakers");
// "virtual-ish" = a known virtual cable; a render WITHOUT these markers is a real output device,
// the best loopback source (apps render there and the operator can also hear it).
let virtualish = |ln: &str| {
ln.contains("virtual")
|| ln.contains("cable")
|| ln.contains("steam streaming")
|| ln.contains("voicemeeter")
};
let loopback = renders
.iter()
.find(|(n, _)| {
let ln = n.to_lowercase();
!excluded_loopback(&ln) && !virtualish(&ln)
})
.or_else(|| {
renders
.iter()
.find(|(n, _)| n.to_lowercase().contains("steam streaming microphone"))
})
.or_else(|| {
renders
.iter()
.find(|(n, _)| !excluded_loopback(&n.to_lowercase()))
});
// The virtual mic's CAPTURE endpoint host apps record from — VB-Cable "CABLE Output" preferred.
let mic_capture = captures
.iter()
.find(|(n, _)| n.to_lowercase().contains("cable output"))
.or_else(|| {
captures
.iter()
.find(|(n, _)| n.to_lowercase().contains("steam streaming microphone"))
})
.or_else(|| {
captures.iter().find(|(n, _)| {
let ln = n.to_lowercase();
ln.contains("voicemeeter") || ln.contains("virtual")
})
});
match loopback {
Some((name, id)) => match set_default_endpoint(id) {
Ok(()) => tracing::info!(device = %name,
"audio wiring: default playback = desktop-audio loopback source"),
Err(e) => tracing::warn!(device = %name, error = %format!("{e:#}"),
"audio wiring: failed to set the default playback device"),
},
None => {
tracing::warn!("audio wiring: no usable desktop-audio loopback render endpoint found")
}
}
if let Some((name, id)) = mic_capture {
match set_default_endpoint(id) {
Ok(()) => tracing::info!(device = %name,
"audio wiring: default recording = virtual mic (apps record the client's mic)"),
Err(e) => tracing::warn!(device = %name, error = %format!("{e:#}"),
"audio wiring: failed to set the default recording device"),
}
}
Ok(())
}
// --- IPolicyConfig (undocumented): set a default audio endpoint by id, for all three roles. ---
/// The `IPolicyConfig` vtable. Only `SetDefaultEndpoint` is called; the 10 methods between `Release`
/// and it (`GetMixFormat` … `SetPropertyValue`) are placeholders so the slot offset is correct.
#[repr(C)]
struct IPolicyConfigVtbl {
query_interface: unsafe extern "system" fn(
*mut c_void,
*const windows::core::GUID,
*mut *mut c_void,
) -> windows::core::HRESULT,
add_ref: unsafe extern "system" fn(*mut c_void) -> u32,
release: unsafe extern "system" fn(*mut c_void) -> u32,
_reserved: [*const c_void; 10],
set_default_endpoint: unsafe extern "system" fn(
*mut c_void,
windows::core::PCWSTR,
u32,
) -> windows::core::HRESULT,
// SetEndpointVisibility follows — unused.
}
/// Set `device_id` as the default audio endpoint for eConsole/eMultimedia/eCommunications via the
/// undocumented `IPolicyConfig::SetDefaultEndpoint` (the call `mmsys.cpl` makes). Errs if any role
/// fails.
fn set_default_endpoint(device_id: &str) -> Result<()> {
use windows::core::{IUnknown, Interface, GUID, PCWSTR};
use windows::Win32::System::Com::{CoCreateInstance, CLSCTX_ALL};
// PolicyConfigClient coclass + IPolicyConfig (Win7+) IID.
const CLSID_POLICY_CONFIG: GUID = GUID::from_u128(0x870af99c_171d_4f9e_af0d_e63df40c2bc9);
const IID_IPOLICY_CONFIG: GUID = GUID::from_u128(0xf8679f50_850a_41cf_9c72_430f290290c8);
let wide: Vec<u16> = device_id.encode_utf16().chain(std::iter::once(0)).collect();
// SAFETY: CoCreateInstance with a valid CLSID returns an owned, refcounted IUnknown. We QI it for
// IPolicyConfig; on success (HRESULT ok + non-null pointer) we invoke its SetDefaultEndpoint slot
// through the documented vtable layout (3 IUnknown + 10 placeholder methods precede it) with a
// NUL-terminated UTF-16 id and an in-range ERole (0..=2), then Release the QI'd pointer. Every
// pointer is checked non-null before deref; `unk` is Released by its Drop on scope exit.
unsafe {
let unk: IUnknown = CoCreateInstance(&CLSID_POLICY_CONFIG, None, CLSCTX_ALL)
.map_err(|e| anyhow!("CoCreateInstance(PolicyConfig): {e}"))?;
let mut raw: *mut c_void = std::ptr::null_mut();
unk.query(&IID_IPOLICY_CONFIG, &mut raw)
.ok()
.map_err(|e| anyhow!("QueryInterface(IPolicyConfig): {e}"))?;
if raw.is_null() {
bail!("IPolicyConfig QueryInterface returned null");
}
let vtbl = *(raw as *const *const IPolicyConfigVtbl);
let mut result = Ok(());
for role in 0u32..=2 {
let hr = ((*vtbl).set_default_endpoint)(raw, PCWSTR(wide.as_ptr()), role);
if hr.is_err() {
result = hr
.ok()
.map_err(|e| anyhow!("SetDefaultEndpoint(role {role}): {e}"));
}
}
((*vtbl).release)(raw);
result
}
}
@@ -4,10 +4,12 @@
//! **capture** endpoint then surfaces as a microphone that host apps can record from. //! **capture** endpoint then surfaces as a microphone that host apps can record from.
//! //!
//! Target device, by friendly-name substring (first match wins; override with `PUNKTFUNK_MIC_DEVICE`): //! Target device, by friendly-name substring (first match wins; override with `PUNKTFUNK_MIC_DEVICE`):
//! "Steam Streaming Microphone" (ships with Steam Remote Play — exactly this purpose), VB-Audio //! VB-Audio "CABLE Input" (bundled by the installer — the preferred, dedicated mic target), the
//! "CABLE Input", VoiceMeeter, or anything with "virtual" in the name. If none is present we //! "Steam Streaming Microphone", VoiceMeeter, or anything with "virtual" in the name.
//! auto-install the Steam Streaming audio pair (see [`install_steam_audio_pair`]); failing that we //! [`super::audio_control`] sets the default playback to a DIFFERENT loopback-capable device so the
//! return an error with install guidance and the host runs without mic passthrough. //! chosen mic is never the endpoint the loopback captures. If no candidate is present we auto-install
//! the Steam Streaming audio pair (see [`install_steam_audio_pair`]); failing that we return an error
//! with install guidance and the host runs without mic passthrough.
//! //!
//! **Anti-echo guard (the whole point of this being non-trivial).** The desktop-audio plane //! **Anti-echo guard (the whole point of this being non-trivial).** The desktop-audio plane
//! ([`super::wasapi_cap`]) loopback-captures the **default render endpoint**. WASAPI loopback //! ([`super::wasapi_cap`]) loopback-captures the **default render endpoint**. WASAPI loopback
@@ -45,8 +47,8 @@ const MAX_QUEUE_BYTES: usize = (SAMPLE_RATE as usize * 80 / 1000) * BLOCK_ALIGN;
/// Render-endpoint friendly-name substrings (lowercased) we can write into so the device's capture /// Render-endpoint friendly-name substrings (lowercased) we can write into so the device's capture
/// endpoint becomes a host mic. Ordered by preference. /// endpoint becomes a host mic. Ordered by preference.
const CANDIDATES: &[&str] = &[ const CANDIDATES: &[&str] = &[
"cable input", // VB-Audio Virtual Cable — bundled by the installer; the preferred dedicated mic target
"steam streaming microphone", "steam streaming microphone",
"cable input",
"voicemeeter input", "voicemeeter input",
"voicemeeter aux input", "voicemeeter aux input",
"virtual", "virtual",
+31 -114
View File
@@ -59,7 +59,7 @@ pub struct OutputFormat {
/// Produce GPU-resident D3D11 frames (zero-copy for a GPU encoder — NVENC/AMF/QSV) rather than CPU /// Produce GPU-resident D3D11 frames (zero-copy for a GPU encoder — NVENC/AMF/QSV) rather than CPU
/// staging. `false` **only** for the GPU-less software encoder. /// staging. `false` **only** for the GPU-less software encoder.
pub gpu: bool, pub gpu: bool,
/// HDR: the capturer converts to 10-bit (IDD-push FP16 → `Rgb10a2`; the DDA secure-desktop HDR hint). /// HDR: the capturer converts to 10-bit (IDD-push FP16 → `P010`, or `Rgb10a2` for a 4:4:4 source).
/// `false` = 8-bit SDR. /// `false` = 8-bit SDR.
pub hdr: bool, pub hdr: bool,
/// Full-chroma 4:4:4 session: the capturer must keep full chroma — deliver packed **RGB** /// Full-chroma 4:4:4 session: the capturer must keep full chroma — deliver packed **RGB**
@@ -380,23 +380,12 @@ pub fn capture_virtual_output(
.map(|c| Box::new(c) as Box<dyn Capturer>) .map(|c| Box::new(c) as Box<dyn Capturer>)
} }
/// `PUNKTFUNK_NO_WGC=1` forces the pure single-process DDA (Desktop Duplication) path everywhere: it
/// skips WGC in [`capture_virtual_output`] AND bypasses the two-process secure-desktop relay (so even a
/// SYSTEM host captures in-process via DDA, the way Apollo does — one capturer for the normal AND the
/// secure desktop). For bringing DDA up to parity / validating it on its own; all the WGC code stays
/// compiled and comes back the moment the flag is unset.
#[cfg(target_os = "windows")]
pub(crate) fn wgc_disabled() -> bool {
crate::config::config().no_wgc
}
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub fn capture_virtual_output( pub fn capture_virtual_output(
vout: crate::vdisplay::VirtualOutput, vout: crate::vdisplay::VirtualOutput,
want: OutputFormat, want: OutputFormat,
capture: crate::session_plan::CaptureBackend, _capture: crate::session_plan::CaptureBackend,
) -> Result<Box<dyn Capturer>> { ) -> Result<Box<dyn Capturer>> {
use crate::session_plan::CaptureBackend;
let target = vout.win_capture.clone().ok_or_else(|| { let target = vout.win_capture.clone().ok_or_else(|| {
anyhow::anyhow!( anyhow::anyhow!(
"SudoVDA target not yet an active display (needs a WDDM GPU to activate it)" "SudoVDA target not yet an active display (needs a WDDM GPU to activate it)"
@@ -404,97 +393,36 @@ pub fn capture_virtual_output(
})?; })?;
let pref = vout.preferred_mode; let pref = vout.preferred_mode;
let keep = vout.keepalive; let keep = vout.keepalive;
// Full-chroma 4:4:4 needs a full-chroma RGB source. The IDD-push and WGC paths emit subsampled // IDD direct-push is the sole Windows capture path: consume frames straight from the pf-vdisplay
// NV12/P010 by default, which can't reconstruct 4:4:4; route a 4:4:4 session to DDA, which delivers // driver's shared ring (in-process, Session 0 — it captures the secure desktop too; no Desktop
// RGB (Bgra) when its `chroma_444` flag is set. (IDD-push/WGC 4:4:4 capture is a follow-up.) // Duplication, no WGC helper). A FRESH monitor + ring is created per session: a REUSED monitor's
if want.chroma_444 && capture != CaptureBackend::Dda { // swap-chain dies after ~2 sessions and can't be revived. The ring is always FP16 when the display
tracing::info!("4:4:4 session — using DDA capture (RGB source) instead of {capture:?}"); // is HDR (the driver composes the IDD in FP16); `want.hdr` proactively enables advanced color and
return dxgi::DuplCapturer::open(target, pref, keep, want.gpu, false, want.chroma_444) // selects the per-frame conversion (FP16 → P010 vs BGRA → NV12). `IddPushCapturer` takes the
.map(|c| Box::new(c) as Box<dyn Capturer>); // keepalive (it owns the virtual display). There is NO fallback (DDA + the WGC relay were removed):
} // if it can't open or the driver doesn't attach, the session fails cleanly and the client reconnects.
// P2 direct frame push (kill DDA): consume frames straight from the pf-vdisplay driver's shared idd_push::IddPushCapturer::open(target, pref, want.hdr, keep)
// ring — no Desktop Duplication, no win32u reparenting hook. Resolved once in the `SessionPlan`
// (was re-derived from `config().idd_push` here); `IddPush` takes the keepalive (owns the virtual
// display) so there's no fall-through.
if capture == CaptureBackend::IddPush {
// Recreate the monitor + ring per session (fix-teardown): a FRESH monitor reliably gets a
// working IddCx swap-chain, whereas a REUSED monitor's swap-chain dies after ~2 sessions and
// the host can't revive it. The driver's recreate crash (target id resolved to 0) is fixed by
// stamping target_id onto the monitor context. The ring is always FP16 (the driver composes
// the IDD in FP16); `want_hdr` selects the per-frame conversion (FP16 → Rgb10a2 vs Bgra).
// If IDD-push can't open OR the driver doesn't attach to the ring within a few seconds (e.g. a
// hybrid-GPU render mismatch), fall back to DDA so the session is NEVER left black (audit §5.1).
// `open()` hands the keepalive back on failure so DDA can take ownership of the virtual display.
match idd_push::IddPushCapturer::open(target.clone(), pref, want.hdr, keep) {
Ok(c) => return Ok(Box::new(c) as Box<dyn Capturer>),
Err((e, keep)) => {
tracing::warn!(
error = %format!("{e:#}"),
"IDD-push open/attach failed — falling back to DDA"
);
return dxgi::DuplCapturer::open(
target,
pref,
keep,
want.gpu,
false,
want.chroma_444,
)
.map(|c| Box::new(c) as Box<dyn Capturer>);
}
}
}
// WGC (Windows.Graphics.Capture) is the default: it captures the COMPOSED desktop including the
// overlay/independent-flip planes DXGI Desktop Duplication misses (the frozen-HDR-animation bug),
// and has no ACCESS_LOST-on-overlay churn. DDA stays available via PUNKTFUNK_CAPTURE=dda and is
// the secure-desktop (lock/UAC) fallback (WGC can't capture those). `keep` is moved into the
// chosen backend (it owns the SudoVDA keepalive), so there's no open-time auto-fallback. The
// backend choice (`dda`/`dxgi`/`PUNKTFUNK_NO_WGC` → DDA, else WGC) is now resolved once in the plan.
if capture == CaptureBackend::Dda {
return dxgi::DuplCapturer::open(target, pref, keep, want.gpu, false, want.chroma_444)
.map(|c| Box::new(c) as Box<dyn Capturer>);
}
// WGC default, with a watchdog'd DDA fallback. WGC's Direct3D11CaptureFramePool::CreateFreeThreaded
// intermittently HANGS on the headless SudoVDA (IddCx) display — a blocking call we can't error out
// of in place. So run WGC open on a dedicated thread and bound it: if it doesn't finish in time
// (hang) or errors, fall back to the reliable DDA path so the session is NEVER left black. WGC,
// when it opens, captures the composed desktop (overlay/MPO-correct HDR — fixes frozen animations);
// DDA is the safety net (+ the secure-desktop path). The encode thread is set MTA so the WGC
// objects built on the watchdog thread (also MTA) are usable here; the keepalive is handed to WGC
// only on success, else to DDA. A hung watchdog thread is abandoned (holds no keepalive).
// SAFETY: `RoInitialize` is a combase FFI call that initializes the WinRT apartment for the calling
// thread. It takes the `RO_INIT_MULTITHREADED` enum by value and borrows no memory, so there is no
// pointer/lifetime/aliasing obligation; it is safe on any thread and idempotent — a second call on a
// thread already in a compatible apartment returns S_FALSE / RPC_E_CHANGED_MODE, which we discard.
// Runs on the encode thread that goes on to use the WGC (WinRT) objects built by the watchdog thread.
unsafe {
let _ = windows::Win32::System::WinRT::RoInitialize(
windows::Win32::System::WinRT::RO_INIT_MULTITHREADED,
);
}
let (tx, rx) = std::sync::mpsc::channel();
let t = target.clone();
let _ = std::thread::Builder::new()
.name("wgc-open".into())
.spawn(move || {
let _ = tx.send(wgc::WgcCapturer::open(t, pref));
});
match rx.recv_timeout(std::time::Duration::from_secs(5)) {
Ok(Ok(mut c)) => {
c.attach_keepalive(keep);
Ok(Box::new(c) as Box<dyn Capturer>)
}
Ok(Err(e)) => {
tracing::warn!(error = %format!("{e:#}"), "WGC open failed — falling back to DDA");
dxgi::DuplCapturer::open(target, pref, keep, want.gpu, false, want.chroma_444)
.map(|c| Box::new(c) as Box<dyn Capturer>) .map(|c| Box::new(c) as Box<dyn Capturer>)
.map_err(|(e, _keep)| e.context("IDD-push capture open (no fallback)"))
} }
Err(_) => {
tracing::warn!("WGC open timed out (CreateFreeThreaded hang on the virtual display) — falling back to DDA"); /// Whether the active capturer can deliver a full-chroma (RGB) source for a 4:4:4 HEVC encode. The
dxgi::DuplCapturer::open(target, pref, keep, want.gpu, false, want.chroma_444) /// negotiator gates 4:4:4 on this so the host honestly downgrades to 4:2:0 when the capturer can only
.map(|c| Box::new(c) as Box<dyn Capturer>) /// produce subsampled frames. Linux (the portal capturer feeding CPU RGB → `yuv444p`) can; the Windows
/// IDD-push path delivers subsampled NV12/P010 today, so full-chroma capture there is a follow-up.
#[cfg(target_os = "linux")]
pub(crate) fn capturer_supports_444() -> bool {
true
} }
#[cfg(target_os = "windows")]
pub(crate) fn capturer_supports_444() -> bool {
// IDD-push 4:4:4 (full-chroma RGB from the FP16 ring) is the next step; until then the sole Windows
// capturer delivers subsampled NV12/P010 only, so the host honestly negotiates 4:2:0.
false
} }
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
pub(crate) fn capturer_supports_444() -> bool {
false
} }
#[cfg(not(any(target_os = "linux", target_os = "windows")))] #[cfg(not(any(target_os = "linux", target_os = "windows")))]
@@ -506,14 +434,9 @@ pub fn capture_virtual_output(
anyhow::bail!("virtual-output capture requires Linux or Windows") anyhow::bail!("virtual-output capture requires Linux or Windows")
} }
// Goal-1 stage 6: the Windows backends live under `capture/windows/`, the Linux one under `capture/linux/` // Goal-1 stage 6: the Windows backend lives under `capture/windows/`, the Linux one under `capture/linux/`
// (`#[path]` keeps the module names flat, so every `crate::capture::*` path is unchanged). // (`#[path]` keeps the module names flat, so every `crate::capture::*` path is unchanged). Windows capture
#[cfg(target_os = "windows")] // is IDD direct-push only — DXGI Desktop Duplication (DDA) and the WGC two-process relay were removed.
#[path = "capture/windows/composed_flip.rs"]
pub mod composed_flip;
#[cfg(target_os = "windows")]
#[path = "capture/windows/desktop_watch.rs"]
pub mod desktop_watch;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "capture/windows/dxgi.rs"] #[path = "capture/windows/dxgi.rs"]
pub mod dxgi; pub mod dxgi;
@@ -522,9 +445,3 @@ pub mod dxgi;
pub mod idd_push; pub mod idd_push;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
mod linux; mod linux;
#[cfg(target_os = "windows")]
#[path = "capture/windows/wgc.rs"]
pub mod wgc;
#[cfg(target_os = "windows")]
#[path = "capture/windows/wgc_relay.rs"]
pub mod wgc_relay;
@@ -1,217 +0,0 @@
//! Force-composed-flip overlay (Windows) — make the secure (Winlogon: UAC / lock / login) desktop
//! capturable by Desktop Duplication.
//!
//! The secure desktop's dialog/wallpaper presents via **fullscreen independent-flip / MPO**: it scans
//! out directly, bypassing DWM composition, so `IDXGIOutputDuplication::AcquireNextFrame` returns
//! `DXGI_ERROR_ACCESS_LOST` (born-lost) — there is no composed frame to hand out (the client sees
//! black). Independent-flip requires the presenting app to own the ENTIRE output: putting ANY other
//! visible window on that output disqualifies it, forcing DWM to **composite**, which DDA can then
//! capture. So we keep a tiny, click-through, near-invisible **topmost layered window** alive on the
//! *current input desktop* (which is Winlogon while the secure desktop is up). On a desktop switch the
//! window is orphaned, so a dedicated thread tracks the input desktop and recreates it there.
//!
//! This is the non-input alternative to "tap a key to wake the lock screen": it needs no SendInput and
//! no system-wide registry change (it does NOT disable MPO globally — it only nudges OUR output to
//! composed while a session is live). Effectiveness can be build/driver-dependent; gated by
//! `PUNKTFUNK_FORCE_COMPOSED` (default ON; set =0 to disable).
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use windows::core::w;
use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, WPARAM};
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
use windows::Win32::System::StationsAndDesktops::{
CloseDesktop, GetUserObjectInformationW, OpenInputDesktop, SetThreadDesktop,
DESKTOP_ACCESS_FLAGS, DESKTOP_CONTROL_FLAGS, UOI_NAME,
};
use windows::Win32::UI::WindowsAndMessaging::{
CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, PeekMessageW, RegisterClassW,
SetLayeredWindowAttributes, SetWindowPos, ShowWindow, TranslateMessage, HWND_TOPMOST,
LWA_ALPHA, MSG, PM_REMOVE, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SW_SHOWNOACTIVATE,
WNDCLASSW, WS_EX_LAYERED, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, WS_EX_TOPMOST, WS_EX_TRANSPARENT,
WS_POPUP,
};
/// A running force-composed-flip overlay. Drop signals the thread to tear down its window + exit.
pub struct ForceComposedFlip {
stop: Arc<AtomicBool>,
}
impl ForceComposedFlip {
/// Start the overlay (no-op + `None` if disabled via `PUNKTFUNK_FORCE_COMPOSED=0`).
pub fn start() -> Option<Self> {
if std::env::var("PUNKTFUNK_FORCE_COMPOSED").as_deref() == Ok("0") {
tracing::info!("force-composed-flip overlay disabled (PUNKTFUNK_FORCE_COMPOSED=0)");
return None;
}
let stop = Arc::new(AtomicBool::new(false));
let st = stop.clone();
std::thread::Builder::new()
.name("composed-flip".into())
// SAFETY: `run` is this module's `unsafe fn` (it owns a desktop+window lifecycle via Win32
// FFI); it takes ownership of `st` (the stop `Arc<AtomicBool>`) and has no caller-side memory
// precondition. It is designed to own its thread for its whole duration — exactly the
// dedicated `composed-flip` thread spawned here.
.spawn(move || unsafe { run(st) })
.ok()?;
tracing::info!("force-composed-flip overlay started (Winlogon-aware)");
Some(ForceComposedFlip { stop })
}
}
impl Drop for ForceComposedFlip {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
}
}
extern "system" fn wndproc(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) -> LRESULT {
// SAFETY: this is the window procedure the OS invokes with the window's own `hwnd` and a real
// message `(msg, wp, lp)`. `DefWindowProcW` performs default processing for exactly those
// parameters (all passed straight through by value); it borrows no Rust memory and is synchronous.
unsafe { DefWindowProcW(hwnd, msg, wp, lp) }
}
/// Read the current input-desktop name (e.g. "Default" / "Winlogon"); `None` if it can't be read.
unsafe fn input_desktop_name() -> Option<String> {
let desk = OpenInputDesktop(
DESKTOP_CONTROL_FLAGS(0),
false,
DESKTOP_ACCESS_FLAGS(0x0001),
)
.ok()?;
let mut buf = [0u16; 64];
let mut needed = 0u32;
let ok = GetUserObjectInformationW(
windows::Win32::Foundation::HANDLE(desk.0),
UOI_NAME,
Some(buf.as_mut_ptr() as *mut _),
(buf.len() * 2) as u32,
Some(&mut needed),
)
.is_ok();
let _ = CloseDesktop(desk);
if !ok {
return None;
}
Some(
String::from_utf16_lossy(&buf)
.trim_end_matches('\u{0}')
.to_string(),
)
}
/// Create the tiny topmost layered click-through window on the CURRENT thread's desktop. Caller must
/// have `SetThreadDesktop`'d to the target input desktop first.
unsafe fn make_overlay() -> Option<HWND> {
let hinst = GetModuleHandleW(None).ok()?;
let class = w!("PunktfunkComposedFlip");
// RegisterClassW is idempotent-ish: a second register for the same name fails harmlessly; we
// ignore the result and rely on the class existing. (One process, so it registers once.)
let wc = WNDCLASSW {
lpfnWndProc: Some(wndproc),
hInstance: hinst.into(),
lpszClassName: class,
..Default::default()
};
let atom = RegisterClassW(&wc);
if atom == 0 {
let e = windows::Win32::Foundation::GetLastError();
// 1410 = ERROR_CLASS_ALREADY_EXISTS is fine (re-register after a desktop switch).
if e.0 != 1410 {
tracing::warn!(err = e.0, "force-composed-flip: RegisterClassW failed");
}
}
let hwnd = match CreateWindowExW(
WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_TOPMOST | WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW,
class,
w!(""),
WS_POPUP,
0,
0,
1,
1,
None,
None,
Some(hinst.into()),
None,
) {
Ok(h) => h,
Err(e) => {
let le = windows::Win32::Foundation::GetLastError();
tracing::warn!(err = %format!("{e:?}"), last = le.0,
"force-composed-flip: CreateWindowExW failed");
return None;
}
};
// alpha=1: technically visible (so it disqualifies independent-flip) but imperceptible.
let _ = SetLayeredWindowAttributes(hwnd, windows::Win32::Foundation::COLORREF(0), 1, LWA_ALPHA);
let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE);
let _ = SetWindowPos(
hwnd,
Some(HWND_TOPMOST),
0,
0,
0,
0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE,
);
Some(hwnd)
}
unsafe fn run(stop: Arc<AtomicBool>) {
let mut cur_desktop: Option<String> = None;
let mut hwnd: Option<HWND> = None;
let mut ticks: u32 = 0;
while !stop.load(Ordering::Relaxed) {
// Follow the input desktop: if it changed (Default↔Winlogon), re-attach this thread and
// recreate the window there (a window is bound to the desktop it was created on).
let name = input_desktop_name();
if name != cur_desktop {
if let Some(h) = hwnd.take() {
let _ = DestroyWindow(h);
}
if let Ok(desk) = OpenInputDesktop(
DESKTOP_CONTROL_FLAGS(0),
false,
DESKTOP_ACCESS_FLAGS(0x1000_0000), // GENERIC_ALL (incl. DESKTOP_CREATEWINDOW=0x0002)
) {
if SetThreadDesktop(desk).is_ok() {
hwnd = make_overlay();
tracing::info!(desktop = ?name, created = hwnd.is_some(),
"force-composed-flip: overlay (re)created on input desktop");
}
// Leak `desk` while it's the thread desktop (closing the current thread desktop is UB).
}
cur_desktop = name;
}
// Re-assert topmost periodically (other windows on the secure desktop can push us down) and
// pump our message queue so the window stays responsive/composited.
if let Some(h) = hwnd {
let _ = SetWindowPos(
h,
Some(HWND_TOPMOST),
0,
0,
0,
0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE,
);
let mut msg = MSG::default();
while PeekMessageW(&mut msg, Some(h), 0, 0, PM_REMOVE).as_bool() {
let _ = TranslateMessage(&msg);
DispatchMessageW(&msg);
}
}
ticks = ticks.wrapping_add(1);
let _ = ticks;
std::thread::sleep(std::time::Duration::from_millis(200));
}
if let Some(h) = hwnd.take() {
let _ = DestroyWindow(h);
}
tracing::info!("force-composed-flip overlay stopped");
}
@@ -1,144 +0,0 @@
//! Input-desktop watcher (Windows) — the authoritative "normal vs secure desktop" signal for the
//! two-process secure-desktop design (design/archive/windows-secure-desktop.md).
//!
//! Windows switches the *input desktop* to "Winlogon" (the secure desktop) for UAC elevation, the
//! lock screen and the login screen, and back to "Default" for the normal session. WGC captures only
//! the normal desktop; DDA-as-SYSTEM captures the secure one. A dedicated thread polls the input
//! desktop's NAME (WTS session notifications miss UAC entirely, so the name is the reliable signal)
//! and publishes it as an atomic the capture mux + input path read.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
use std::sync::Arc;
use std::time::Duration;
use windows::Win32::Foundation::HANDLE;
use windows::Win32::System::StationsAndDesktops::{
CloseDesktop, GetUserObjectInformationW, OpenInputDesktop, DESKTOP_ACCESS_FLAGS,
DESKTOP_CONTROL_FLAGS, UOI_NAME,
};
/// The normal interactive desktop ("Default") — WGC capture applies.
pub const DESKTOP_NORMAL: u8 = 0;
/// The secure desktop ("Winlogon": UAC / lock / login) — DDA-as-SYSTEM capture applies.
pub const DESKTOP_SECURE: u8 = 1;
/// Polls the input-desktop name on its own thread and publishes [`DESKTOP_NORMAL`]/[`DESKTOP_SECURE`].
pub struct DesktopWatcher {
state: Arc<AtomicU8>,
stop: Arc<AtomicBool>,
}
impl DesktopWatcher {
pub fn start() -> Self {
// Compute the CURRENT desktop synchronously before returning, so the first reader (the capture
// mux) sees the real state immediately. Otherwise a session that begins already on the secure
// desktop (e.g. a reconnect to a locked box) would read DESKTOP_NORMAL for the first poll
// interval and relay one stale normal-desktop frame — the "flash of the login screen" bug.
// SAFETY: `is_secure_desktop` is this module's `unsafe fn` — unsafe only because it calls Win32
// desktop FFI (`OpenInputDesktop`/`GetUserObjectInformationW`/`CloseDesktop`), with no caller
// precondition; it opens, names, and closes the input-desktop handle internally and is safe to
// call from any thread (here, on the thread running `DesktopWatcher::start`).
let initial = if unsafe { is_secure_desktop() } {
DESKTOP_SECURE
} else {
DESKTOP_NORMAL
};
let state = Arc::new(AtomicU8::new(initial));
let stop = Arc::new(AtomicBool::new(false));
let s = state.clone();
let st = stop.clone();
let _ = std::thread::Builder::new()
.name("desktop-watch".into())
.spawn(move || {
// Debounce: only publish a change after the raw reading has been stable for several
// polls. The input desktop flaps Default↔Winlogon transiently during a lock/UAC
// transition; publishing every flap makes the capture mux thrash (rebuild storms).
const STABLE_POLLS: u32 = 4; // ~80ms
let mut published = initial;
let mut candidate = initial;
let mut stable = 0u32;
while !st.load(Ordering::Relaxed) {
// SAFETY: same as in `start` — `is_secure_desktop` is self-contained Win32 desktop
// FFI with no caller precondition, called here on the dedicated `desktop-watch`
// polling thread.
let v = if unsafe { is_secure_desktop() } {
DESKTOP_SECURE
} else {
DESKTOP_NORMAL
};
if v == candidate {
stable = stable.saturating_add(1);
} else {
candidate = v;
stable = 1;
}
if stable >= STABLE_POLLS && candidate != published {
s.store(candidate, Ordering::Release);
published = candidate;
tracing::info!(
desktop = if candidate == DESKTOP_SECURE {
"Winlogon(secure)"
} else {
"Default"
},
"input desktop changed (debounced)"
);
}
std::thread::sleep(Duration::from_millis(20));
}
});
DesktopWatcher { state, stop }
}
/// The shared atomic ([`DESKTOP_NORMAL`]/[`DESKTOP_SECURE`]) for the capture mux to read.
pub fn state(&self) -> Arc<AtomicU8> {
self.state.clone()
}
/// True when the secure (Winlogon) desktop is the input desktop right now.
pub fn is_secure(&self) -> bool {
self.state.load(Ordering::Acquire) == DESKTOP_SECURE
}
}
impl Drop for DesktopWatcher {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
}
}
/// True if the current input desktop is "Winlogon" (the secure desktop). Best-effort: if the desktop
/// can't be opened or named, report not-secure (the safe default — keep WGC/normal capture).
pub(crate) unsafe fn is_secure_desktop() -> bool {
let desk = match OpenInputDesktop(
DESKTOP_CONTROL_FLAGS(0),
false,
DESKTOP_ACCESS_FLAGS(DESKTOP_READOBJECTS),
) {
Ok(d) => d,
Err(_) => return false,
};
let mut buf = [0u16; 64];
let mut needed = 0u32;
let ok = GetUserObjectInformationW(
HANDLE(desk.0),
UOI_NAME,
Some(buf.as_mut_ptr() as *mut _),
(buf.len() * 2) as u32,
Some(&mut needed),
)
.is_ok();
let _ = CloseDesktop(desk);
if !ok {
return false;
}
let name = String::from_utf16_lossy(&buf);
name.trim_end_matches('\u{0}')
.eq_ignore_ascii_case("Winlogon")
}
/// `DESKTOP_READOBJECTS` access right (the windows crate exposes it as a typed flag; we need the raw
/// bit for `OpenInputDesktop`'s access mask).
const DESKTOP_READOBJECTS: u32 = 0x0001;
File diff suppressed because it is too large Load Diff
@@ -1,816 +0,0 @@
//! Windows.Graphics.Capture (WGC) capture backend — the HDR/animation-correct path.
//!
//! Why WGC over DXGI Desktop Duplication: DDA duplicates only the DWM-composed primary surface, so
//! HDR desktop animations the OS routes onto hardware overlay / independent-flip / MPO planes (Start
//! menu, Win11 Mica/acrylic, window resize) never enter the surface DDA reads — the stream shows a
//! frozen desktop ("broken HDR animations"). Engaging WGC capture pulls that content back through DWM
//! composition, so the surface WGC hands back contains the animations. WGC also has no
//! ACCESS_LOST-on-overlay-flip churn.
//!
//! It reuses the rest of the pipeline UNCHANGED: the frame's GPU texture (the OS already composited
//! the cursor into it — `IsCursorCaptureEnabled(true)`) goes through the same scRGB→BT.2020-PQ shader
//! ([`super::dxgi::HdrConverter`]) into a host-owned `R10G10B10A2` texture (HDR) or is copied into a
//! BGRA texture (SDR), which is handed to NVENC zero-copy (registered by pointer, encoded in place).
//! Shares the D3D11 device with NVENC via `FramePayload::D3d11`.
//!
//! Limitation: WGC cannot capture the secure desktop (lock / UAC / login) — the caller falls back to
//! the DDA backend ([`super::dxgi::DuplCapturer`]) for those (see capture.rs).
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::dxgi::{
find_output, hdr_shader_p010_enabled, make_device, nudge_cursor_onto, D3d11Frame, HdrConverter,
HdrP010Converter, VideoConverter, WinCaptureTarget,
};
use super::{CapturedFrame, Capturer, FramePayload, PixelFormat};
use anyhow::{bail, Context, Result};
use std::collections::VecDeque;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant};
use windows::core::{IInspectable, Interface};
use windows::Foundation::{TimeSpan, TypedEventHandler};
use windows::Graphics::Capture::{
Direct3D11CaptureFrame, Direct3D11CaptureFramePool, GraphicsCaptureItem, GraphicsCaptureSession,
};
use windows::Graphics::DirectX::DirectXPixelFormat;
use windows::Win32::Foundation::{CloseHandle, HANDLE};
use windows::Win32::Graphics::Direct3D11::{
ID3D11Device, ID3D11DeviceContext, ID3D11RenderTargetView, ID3D11ShaderResourceView,
ID3D11Texture2D, D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE, D3D11_TEXTURE2D_DESC,
D3D11_USAGE_DEFAULT,
};
use windows::Win32::Graphics::Dxgi::Common::{
DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020, DXGI_FORMAT_R10G10B10A2_UNORM,
DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_SAMPLE_DESC,
};
use windows::Win32::Graphics::Dxgi::{IDXGIDevice, IDXGIOutput6};
use windows::Win32::Security::{ImpersonateLoggedOnUser, RevertToSelf};
use windows::Win32::System::RemoteDesktop::{WTSGetActiveConsoleSessionId, WTSQueryUserToken};
use windows::Win32::System::WinRT::Direct3D11::{
CreateDirect3D11DeviceFromDXGIDevice, IDirect3DDxgiInterfaceAccess,
};
use windows::Win32::System::WinRT::Graphics::Capture::IGraphicsCaptureItemInterop;
use windows::Win32::System::WinRT::{RoInitialize, RO_INIT_MULTITHREADED};
/// Output texture ring depth. The encode loop pipelines one frame deep (NVENC encodes frame N while
/// the capturer produces N+1), so two live textures suffice; three gives headroom against a slow
/// `lock_bitstream` and matches the WGC frame-pool depth.
// Sized for the deep encode pipeline (`PUNKTFUNK_ENCODE_DEPTH`, default 4, clamped ≤ 6): up to DEPTH
// frames are in flight in NVENC at once, so the HDR convert ring and the SDR held-frame set must each
// keep DEPTH(+headroom) live textures, and the WGC pool needs spare buffers beyond what we hold.
const OUT_RING: usize = 8;
/// SDR zero-copy: how many recent WGC frames to keep alive so NVENC can encode the pool texture in
/// place (no `CopyResource`). Each in-flight encode reads a distinct frame, so this must exceed the
/// pipeline depth; the oldest is released once `HELD_FRAMES` newer ones exist.
const HELD_FRAMES: usize = 8;
/// WGC frame-pool buffer count. Must exceed `HELD_FRAMES` so the compositor always has free buffers
/// to render into while we hold frames for in-place (zero-copy) SDR encode.
const WGC_POOL_BUFFERS: i32 = 10;
/// The host runs as SYSTEM (so the DDA secure-desktop path works), but WGC will NOT activate under
/// the SYSTEM account (`CreateForMonitor` → 0x80070424). Impersonate the interactive console user
/// for the WGC activation. Returns the user token (the caller reverts + closes it after activation)
/// or `None` (no active user, or the host already runs AS the user — WTSQueryUserToken then fails and
/// WGC works without impersonation). SYSTEM-only; harmless under a user-token host.
unsafe fn impersonate_active_user() -> Option<HANDLE> {
let session = WTSGetActiveConsoleSessionId();
if session == 0xFFFF_FFFF {
return None;
}
let mut token = HANDLE::default();
if WTSQueryUserToken(session, &mut token).is_ok() {
if ImpersonateLoggedOnUser(token).is_ok() {
return Some(token);
}
let _ = CloseHandle(token);
}
None
}
/// RAII: reverts the WGC-activation impersonation when it drops (covers every `?` early-return).
struct Deimpersonate(Option<HANDLE>);
impl Drop for Deimpersonate {
fn drop(&mut self) {
if let Some(tok) = self.0.take() {
// SAFETY: `RevertToSelf` takes no arguments and undoes the thread impersonation set during
// WGC activation; `tok` is the impersonation token `HANDLE` from `impersonate_active_user`,
// owned by this `Deimpersonate` and closed exactly once here (taken out of the `Option`, so
// no double-close). Both are FFI calls borrowing no Rust memory.
unsafe {
let _ = RevertToSelf();
let _ = CloseHandle(tok);
}
}
}
}
/// Signal from the free-threaded FrameArrived callback to the encode thread: a monotonically
/// increasing count of arrived frames + a condvar to wake `next_frame`. The encode thread tracks how
/// many it has consumed; `TryGetNextFrame` is called exactly `available - consumed` times so we never
/// hit the empty-pool ambiguity, and draining to the newest keeps latency at one frame.
struct WgcSignal {
available: AtomicU64,
mtx: Mutex<()>,
cv: Condvar,
}
pub struct WgcCapturer {
device: ID3D11Device,
context: ID3D11DeviceContext,
// WGC objects — kept alive for the session's lifetime.
pool: Direct3D11CaptureFramePool,
session: GraphicsCaptureSession,
_item: GraphicsCaptureItem,
_frame_arrived_token: i64,
signal: Arc<WgcSignal>,
consumed: u64,
width: u32,
height: u32,
timeout_ms: u64,
first_frame: bool,
hdr: bool,
/// The source display's static HDR mastering metadata (ST.2086 + content light level), read from
/// `IDXGIOutput6::GetDesc1` at open when the output is HDR. Forwarded to the encoder (in-band SEI)
/// and the client (0xCE) by the stream loop. `None` when SDR. (The helper relay path also encodes,
/// so this is what gives the secure/normal-desktop HDR stream its mastering SEI.)
hdr_meta: Option<punktfunk_core::quic::HdrMeta>,
hdr_conv: Option<HdrConverter>,
fp16_src: Option<ID3D11Texture2D>,
fp16_srv: Option<ID3D11ShaderResourceView>,
/// `PUNKTFUNK_HDR_SHADER_P010` path: emit P010 (BT.2020 PQ 10-bit limited range) DIRECTLY from our
/// own shader (`HdrP010Converter`) so NVENC takes native P010 and skips its SM-side RGB→YUV CSC.
/// Gated by [`hdr_shader_p010_enabled`] AND `self.hdr`; `None`/empty when off → the existing R10 +
/// VideoProcessor paths run unchanged. `p010_disabled` latches a runtime failure (e.g. a driver
/// that rejects the planar plane RTV) so we fall back to the R10 path and stop retrying.
hdr_p010_conv: Option<HdrP010Converter>,
p010_out: Vec<ID3D11Texture2D>,
p010_idx: usize,
p010_disabled: bool,
/// Ring of host-owned output textures (BGRA for SDR, R10G10B10A2 for HDR), rotated per processed
/// frame. A ring — not one texture — is required because the encode loop is PIPELINED: NVENC
/// encodes frame N (in place, registered by pointer) while this capturer produces frame N+1, so
/// N+1 must land in a DIFFERENT texture or it clobbers the in-flight encode. (`fp16_src` stays
/// single: it's only touched within the D3D11 immediate context, whose op ordering already
/// serializes the convert's read against the next copy's write — NVENC's async engine read is the
/// only consumer that escapes that ordering, and it reads the ring output, never `fp16_src`.)
out_ring: Vec<ID3D11Texture2D>,
ring_idx: usize,
/// Video-processor RGB→YUV converter (off the 3D engine where possible) + its NV12/P010 output
/// ring. Preferred path: the OS-composited capture (cursor already in it) is converted DIRECTLY to
/// NVENC's native YUV — no `CopyResource`, no cursor draw, and NVENC skips its internal RGB→YUV.
/// `None`/error → falls back to the legacy SDR-zero-copy / HDR-shader paths.
video_conv: Option<VideoConverter>,
yuv_out: Vec<ID3D11Texture2D>,
yuv_idx: usize,
yuv_is_hdr: bool,
vp_disabled: bool,
/// SDR zero-copy: the recent WGC frames we hand to NVENC in place. Held so the pool doesn't
/// recycle the texture mid-encode; the oldest is released once `HELD_FRAMES` newer ones exist.
held: VecDeque<Direct3D11CaptureFrame>,
/// Last presentable GPU texture + format, repeated when no new frame arrived (static desktop).
last_present: Option<(ID3D11Texture2D, PixelFormat)>,
/// Owns the SudoVDA keepalive once attached (after WGC is confirmed open) — dropping the capturer
/// then REMOVEs the virtual output. `None` between open and attach so a WGC-open failure leaves
/// the keepalive with the caller for the DDA fallback.
_keepalive: Option<Box<dyn Send>>,
}
// SAFETY: like `DuplCapturer`. `WgcCapturer` holds D3D11 (free-threaded device/context) plus WGC WinRT
// objects (`Direct3D11CaptureFramePool` etc., created free-threaded via `CreateFreeThreaded`). COM/WinRT
// reference counting is interlocked, and the capturer is owned + used by exactly one encode thread,
// moved to it once and never shared (no `Sync`), so transferring ownership across threads is sound. The
// free-threaded `FrameArrived` callback touches only the `Arc<WgcSignal>` (itself `Send + Sync`), not
// the capturer's COM fields.
unsafe impl Send for WgcCapturer {}
impl WgcCapturer {
/// Open WGC capture. Does NOT take the keepalive — the caller attaches it via
/// [`attach_keepalive`](Self::attach_keepalive) only after open succeeds, so a failure leaves the
/// keepalive with the caller to hand to the DDA fallback.
pub fn open(target: WinCaptureTarget, preferred: Option<(u32, u32, u32)>) -> Result<Self> {
// SAFETY: runs on the thread opening the WGC session. `RoInitialize` inits this thread's WinRT
// apartment (idempotent; result ignored). `impersonate_active_user()` and `find_output()` are
// this module's `unsafe fn`s whose contracts (call on the activating thread; pass a GDI name)
// are met, and the impersonation is reverted by `_deimp`'s Drop on every return path. Every
// COM/WinRT call thereafter operates on an object obtained + `?`-checked earlier in this same
// block on this single thread — the `IDXGIOutput1` from `find_output`, the device/context from
// `make_device`, the factory/interop/item/pool/session — and the `TypedEventHandler` closure
// captures an `Arc<WgcSignal>` (Send+Sync) by move. No raw pointers are dereferenced; borrowed
// locals outlive their synchronous calls.
unsafe {
// WGC is WinRT — the calling thread needs a COM/WinRT apartment for the GraphicsCaptureItem
// activation factory (RoGetActivationFactory). Initialize MTA; ignore "already initialized"
// / "changed mode" (another component on this thread may have init'd a compatible apartment).
let ro = RoInitialize(RO_INIT_MULTITHREADED);
// Impersonate the interactive user for the duration of WGC activation (host runs as
// SYSTEM; WGC won't activate under SYSTEM). Reverted by the guard's Drop on return. The
// WGC objects, once created, are accessed from the (SYSTEM) encode thread thereafter.
let imp = impersonate_active_user();
let _deimp = Deimpersonate(imp);
tracing::info!(ro_result = ?ro, impersonated = imp.is_some(), "WGC: RoInitialize(MTA)");
// The SudoVDA output appears a beat after the display is created — settle-retry like DDA.
let deadline = Instant::now() + Duration::from_millis(2000);
let (adapter, output) = loop {
if let Some(n) = crate::win_display::resolve_gdi_name(target.target_id) {
if let Ok(found) = find_output(&n) {
break found;
}
}
if let Ok(found) = find_output(&target.gdi_name) {
break found;
}
if Instant::now() >= deadline {
bail!(
"WGC: no DXGI output for SudoVDA target {} yet",
target.target_id
);
}
std::thread::sleep(Duration::from_millis(100));
};
let (device, context) = make_device(&adapter)?;
let od = output.GetDesc().context("output GetDesc")?;
let hmonitor = od.Monitor;
// HDR iff the output's colour space is BT.2020 PQ (G2084) — matches the DDA FP16 detection.
// From the same desc, read the source display's mastering metadata (ST.2086) when HDR.
let desc1 = output
.cast::<IDXGIOutput6>()
.ok()
.and_then(|o6| o6.GetDesc1().ok());
let hdr = desc1
.as_ref()
.map(|d1| d1.ColorSpace == DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020)
.unwrap_or(false);
let hdr_meta = if hdr {
desc1.as_ref().map(|d| {
crate::hdr::hdr_meta_from_display(
(d.RedPrimary[0], d.RedPrimary[1]),
(d.GreenPrimary[0], d.GreenPrimary[1]),
(d.BluePrimary[0], d.BluePrimary[1]),
(d.WhitePoint[0], d.WhitePoint[1]),
d.MaxLuminance,
d.MinLuminance,
0, // MaxCLL: GetDesc1 has no content light level (Apollo zeroes it)
0, // MaxFALL
)
})
} else {
None
};
// Wrap our D3D11 device as a WinRT IDirect3DDevice so the frame pool allocates on it (the
// pool textures land on our device → CopyResource + NVENC are same-device, no readback).
let dxgi_device: IDXGIDevice = device.cast().context("ID3D11Device as IDXGIDevice")?;
let inspectable: IInspectable = CreateDirect3D11DeviceFromDXGIDevice(&dxgi_device)
.context("CreateDirect3D11DeviceFromDXGIDevice")?;
let d3d_device: windows::Graphics::DirectX::Direct3D11::IDirect3DDevice = inspectable
.cast()
.context("IInspectable as IDirect3DDevice")?;
tracing::info!(hdr, "WGC: device ready, creating capture item");
// GraphicsCaptureItem for the monitor (the SudoVDA output enumerates as a normal monitor).
let interop: IGraphicsCaptureItemInterop =
windows::core::factory::<GraphicsCaptureItem, IGraphicsCaptureItemInterop>()
.context("GraphicsCaptureItem interop factory")?;
let item: GraphicsCaptureItem = interop
.CreateForMonitor(hmonitor)
.context("CreateForMonitor")?;
let size = item.Size().context("item Size")?;
let (width, height) = (size.Width.max(0) as u32, size.Height.max(0) as u32);
tracing::info!(
width,
height,
"WGC: capture item created, creating frame pool"
);
let pixel_format = if hdr {
DirectXPixelFormat::R16G16B16A16Float // scRGB FP16 — same surface DDA gives on HDR
} else {
DirectXPixelFormat::B8G8R8A8UIntNormalized
};
// Extra buffers: SDR zero-copy holds the last `HELD_FRAMES` frames (encoded in place), so
// the pool needs headroom beyond that for the producer to keep rendering at 240 Hz.
let pool = Direct3D11CaptureFramePool::CreateFreeThreaded(
&d3d_device,
pixel_format,
WGC_POOL_BUFFERS,
size,
)
.context("CreateFreeThreaded frame pool")?;
let signal = Arc::new(WgcSignal {
available: AtomicU64::new(0),
mtx: Mutex::new(()),
cv: Condvar::new(),
});
let sig = signal.clone();
let handler = TypedEventHandler::<Direct3D11CaptureFramePool, IInspectable>::new(
move |_pool, _arg| {
sig.available.fetch_add(1, Ordering::Release);
sig.cv.notify_one();
Ok(())
},
);
let token = pool.FrameArrived(&handler).context("FrameArrived")?;
tracing::info!("WGC: creating capture session");
let session = pool
.CreateCaptureSession(&item)
.context("CreateCaptureSession")?;
// OS composites the cursor into the frame (HDR-correct, no manual composite pass).
let _ = session.SetIsCursorCaptureEnabled(true);
// Drop the yellow capture border (best-effort — older builds reject it).
let _ = session.SetIsBorderRequired(false);
// Lift the 60 Hz cap: allow up to the client's refresh (Win11 24H2+; below that this is a
// no-op and WGC caps ~60). 100 ns ticks per frame.
let refresh = preferred
.map(|(_, _, hz)| hz)
.filter(|&hz| hz > 0)
.unwrap_or(60);
let ticks = (10_000_000i64 / refresh.max(1) as i64).max(1);
let _ = session.SetMinUpdateInterval(TimeSpan { Duration: ticks });
tracing::info!("WGC: StartCapture");
session.StartCapture().context("StartCapture")?;
// WGC fires FrameArrived on CHANGE; a static desktop may never deliver the first frame
// (→ black, then the next_frame deadline ends the session). Nudge the cursor onto the
// output to force the first composition change, exactly like the DDA path does.
nudge_cursor_onto(&output);
let timeout_ms = (2000 / refresh.max(1) as u64).max(8);
tracing::info!(
width,
height,
hdr,
refresh,
"WGC capture started ({})",
if hdr {
"HDR FP16→BT.2020 PQ"
} else {
"SDR BGRA"
}
);
Ok(Self {
device,
context,
pool,
session,
_item: item,
_frame_arrived_token: token,
signal,
consumed: 0,
width,
height,
timeout_ms,
first_frame: true,
hdr,
hdr_meta,
hdr_conv: None,
fp16_src: None,
fp16_srv: None,
hdr_p010_conv: None,
p010_out: Vec::new(),
p010_idx: 0,
p010_disabled: false,
out_ring: Vec::new(),
ring_idx: 0,
video_conv: None,
yuv_out: Vec::new(),
yuv_idx: 0,
yuv_is_hdr: false,
vp_disabled: std::env::var_os("PUNKTFUNK_NO_VIDEO_PROCESSOR").is_some(),
held: VecDeque::new(),
last_present: None,
_keepalive: None,
})
}
}
/// Take ownership of the SudoVDA keepalive once the WGC session is confirmed open.
pub fn attach_keepalive(&mut self, keepalive: Box<dyn Send>) {
self._keepalive = Some(keepalive);
}
/// Block until a new frame arrives (cv), then drain `TryGetNextFrame` to the NEWEST queued frame
/// (skip stale → one-frame latency). Returns `None` on timeout (no new frame → caller repeats).
fn wait_and_drain(&mut self) -> Option<Direct3D11CaptureFrame> {
let wait_ms = if self.first_frame {
2000
} else {
self.timeout_ms
};
{
let mut g = self.signal.mtx.lock().unwrap();
while self.signal.available.load(Ordering::Acquire) <= self.consumed {
let (ng, res) = self
.signal
.cv
.wait_timeout(g, Duration::from_millis(wait_ms))
.unwrap();
g = ng;
if res.timed_out() {
return None;
}
}
}
let target = self.signal.available.load(Ordering::Acquire);
let mut last = None;
while self.consumed < target {
if let Ok(f) = self.pool.TryGetNextFrame() {
last = Some(f);
}
self.consumed += 1;
}
last
}
unsafe fn ensure_fp16_src(&mut self) -> Result<()> {
if self.fp16_src.is_some() {
return Ok(());
}
let desc = tex_desc(
self.width,
self.height,
DXGI_FORMAT_R16G16B16A16_FLOAT,
(D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32,
);
let mut t = None;
self.device
.CreateTexture2D(&desc, None, Some(&mut t))
.context("CreateTexture2D(wgc fp16 src)")?;
let t = t.context("fp16 src")?;
let mut srv = None;
self.device
.CreateShaderResourceView(&t, None, Some(&mut srv))?;
self.fp16_srv = Some(srv.context("fp16 srv")?);
self.fp16_src = Some(t);
Ok(())
}
/// Lazily allocate the HDR output texture ring (R10G10B10A2, the convert pass's render target →
/// NVENC input), `RENDER_TARGET`-bindable. SDR is zero-copy (encodes the WGC pool texture in
/// place) and uses no ring.
unsafe fn ensure_out_ring(
&mut self,
format: windows::Win32::Graphics::Dxgi::Common::DXGI_FORMAT,
) -> Result<()> {
if !self.out_ring.is_empty() {
return Ok(());
}
let desc = tex_desc(
self.width,
self.height,
format,
D3D11_BIND_RENDER_TARGET.0 as u32,
);
for _ in 0..OUT_RING {
let mut t = None;
self.device
.CreateTexture2D(&desc, None, Some(&mut t))
.context("CreateTexture2D(wgc out ring)")?;
self.out_ring.push(t.context("wgc out ring tex")?);
}
Ok(())
}
/// Convert `input` (the OS-composited WGC pool texture: BGRA or scRGB FP16) → NVENC's native YUV
/// (NV12 / P010) on the video processor. Returns the YUV texture (from a ring so consecutive
/// encodes don't collide), or `None` to fall back to the legacy RGB paths.
unsafe fn convert_to_yuv(
&mut self,
input: &ID3D11Texture2D,
hdr: bool,
) -> Option<ID3D11Texture2D> {
if self.vp_disabled {
return None;
}
if self.video_conv.is_none() || self.yuv_out.is_empty() || self.yuv_is_hdr != hdr {
self.video_conv = None;
self.yuv_out.clear();
self.yuv_idx = 0;
let vc = match VideoConverter::new(
&self.device,
&self.context,
self.width,
self.height,
hdr,
) {
Ok(vc) => vc,
Err(e) => {
tracing::warn!(error = %format!("{e:#}"),
"WGC: video processor unavailable — falling back to RGB path");
self.vp_disabled = true;
return None;
}
};
let fmt = if hdr {
windows::Win32::Graphics::Dxgi::Common::DXGI_FORMAT_P010
} else {
windows::Win32::Graphics::Dxgi::Common::DXGI_FORMAT_NV12
};
let desc = tex_desc(
self.width,
self.height,
fmt,
D3D11_BIND_RENDER_TARGET.0 as u32,
);
for _ in 0..OUT_RING {
let mut t = None;
if self
.device
.CreateTexture2D(&desc, None, Some(&mut t))
.is_err()
{
tracing::warn!("WGC: CreateTexture2D(YUV) failed — falling back to RGB path");
self.vp_disabled = true;
self.yuv_out.clear();
return None;
}
let Some(tex) = t else {
self.vp_disabled = true;
self.yuv_out.clear();
return None;
};
self.yuv_out.push(tex);
}
self.video_conv = Some(vc);
self.yuv_is_hdr = hdr;
tracing::info!(
hdr,
"WGC: video-processor YUV path active ({})",
if hdr { "P010" } else { "NV12" }
);
}
let slot = self.yuv_idx;
self.yuv_idx = (self.yuv_idx + 1) % self.yuv_out.len();
let out = self.yuv_out[slot].clone();
if let Err(e) = self.video_conv.as_ref()?.convert(input, &out) {
tracing::warn!(error = %format!("{e:#}"),
"WGC: VideoProcessorBlt failed — falling back to RGB path");
self.vp_disabled = true;
self.video_conv = None;
self.yuv_out.clear();
return None;
}
Some(out)
}
/// `PUNKTFUNK_HDR_SHADER_P010` path: convert the OS-composited FP16 scRGB capture DIRECTLY to a
/// host-owned P010 texture (BT.2020 PQ, 10-bit limited range) via [`HdrP010Converter`] — two
/// shader passes writing the P010 planes. NVENC then takes native P010 and skips its internal
/// RGB→YUV CSC. Returns the next ring slot's P010 texture, or `Err` if the converter / a planar
/// plane RTV fails (the caller latches `p010_disabled` and falls back to the R10 path).
unsafe fn hdr_to_p010(&mut self, src: &ID3D11Texture2D) -> Result<ID3D11Texture2D> {
let slot = self.p010_idx;
// Lazily allocate the FP16 source (shared with the R10 path) + the P010 output ring.
self.ensure_fp16_src()?;
let fp16 = self.fp16_src.clone().context("fp16 src")?;
self.context.CopyResource(&fp16, src);
if self.p010_out.is_empty() {
let desc = tex_desc(
self.width,
self.height,
windows::Win32::Graphics::Dxgi::Common::DXGI_FORMAT_P010,
D3D11_BIND_RENDER_TARGET.0 as u32,
);
for _ in 0..OUT_RING {
let mut t = None;
self.device
.CreateTexture2D(&desc, None, Some(&mut t))
.context("CreateTexture2D(wgc p010 ring)")?;
self.p010_out.push(t.context("wgc p010 ring tex")?);
}
}
self.p010_idx = (self.p010_idx + 1) % self.p010_out.len();
let out = self.p010_out[slot].clone();
if self.hdr_p010_conv.is_none() {
self.hdr_p010_conv = Some(HdrP010Converter::new(&self.device)?);
}
let srv = self.fp16_srv.clone().context("fp16 srv")?;
self.hdr_p010_conv.as_ref().unwrap().convert(
&self.device,
&self.context,
&srv,
&out,
self.width,
self.height,
)?;
Ok(out)
}
fn process_frame(&mut self, frame: Direct3D11CaptureFrame) -> Result<CapturedFrame> {
// SAFETY: runs on the capturer's single owning thread. `frame` is a live
// `Direct3D11CaptureFrame` from `self.pool`; `frame.Surface().cast::<IDirect3DDxgiInterfaceAccess
// >().GetInterface()` yields the frame's backing `ID3D11Texture2D`, which belongs to
// `self.device` (the pool was created on it via `CreateDirect3D11DeviceFromDXGIDevice`). Every
// helper called here — `hdr_to_p010`, `convert_to_yuv`, `ensure_fp16_src`, `ensure_out_ring`,
// `HdrConverter::convert`, `CopyResource`, `CreateRenderTargetView` — operates on
// `self.device`/`self.context` and that same-device texture, so all resources share one device.
// The frame is held in `self.held` until its async GPU read completes for the zero-copy paths.
// Single-threaded immediate-context use; borrowed textures/SRVs/RTVs outlive each synchronous call.
unsafe {
let surface = frame.Surface().context("frame Surface")?;
let access: IDirect3DDxgiInterfaceAccess = surface
.cast()
.context("surface as IDirect3DDxgiInterfaceAccess")?;
let src: ID3D11Texture2D = access
.GetInterface()
.context("GetInterface ID3D11Texture2D")?;
// GATED P010-shader path (`PUNKTFUNK_HDR_SHADER_P010`): for HDR, emit P010 (BT.2020 PQ
// 10-bit limited range) DIRECTLY from our shader so NVENC takes native P010 and skips its
// SM-side RGB→YUV CSC. Runs BEFORE the R10 + VideoProcessor path. A converter/plane-RTV
// failure latches `p010_disabled` → we fall through to the unchanged R10 path for the rest
// of the session. Default OFF → none of this executes and behaviour is byte-for-byte as
// today.
if self.hdr && !self.p010_disabled && hdr_shader_p010_enabled() {
match self.hdr_to_p010(&src) {
Ok(p010) => {
// The P010 output is host-owned (the ring), and the FP16 CopyResource read
// `src` synchronously on the immediate context before the shader passes — so we
// do NOT need to hold `frame` past here (unlike the SDR/R10 in-place paths).
// Dropping it returns the pool buffer to WGC immediately.
drop(frame);
self.last_present = Some((p010.clone(), PixelFormat::P010));
return Ok(self.d3d11_frame(p010, PixelFormat::P010));
}
Err(e) => {
tracing::warn!(error = %format!("{e:#}"),
"WGC: HDR P010 shader path failed — disabling it, falling back to R10");
self.p010_disabled = true;
self.hdr_p010_conv = None;
self.p010_out.clear();
}
}
}
// Preferred path: convert the OS-composited capture (cursor already in it) DIRECTLY to
// NVENC's native YUV on the video processor — no CopyResource, no cursor draw, and NVENC
// skips its internal RGB→YUV (the contended 3D step). WGC's multi-buffer pool + held set
// means reading the pool texture directly does NOT serialize (unlike DDA's single-frame
// model). The frame is held until the async Blt finishes. (HDR: the video processor can't
// ingest FP16 scRGB, so the Blt fails and we fall back to the R10 path below; the
// `PUNKTFUNK_HDR_SHADER_P010` branch above is the off-the-SM HDR path.)
if let Some(yuv) = self.convert_to_yuv(&src, self.hdr) {
let fmt = if self.hdr {
PixelFormat::P010
} else {
PixelFormat::Nv12
};
self.last_present = Some((yuv.clone(), fmt));
let out = self.d3d11_frame(yuv, fmt);
self.held.push_back(frame);
while self.held.len() > HELD_FRAMES {
self.held.pop_front();
}
return Ok(out);
}
// --- fallback (video processor unavailable) ---
if self.hdr {
// Next ring slot — the in-flight encode reads the slot we handed out last time, so
// this capture must land in a different one (see `out_ring`).
let slot = self.ring_idx;
self.ring_idx = (self.ring_idx + 1) % OUT_RING;
// FP16 (cursor already composited by the OS) → BT.2020 PQ 10-bit for NVENC.
self.ensure_fp16_src()?;
let fp16 = self.fp16_src.clone().context("fp16 src")?;
self.context.CopyResource(&fp16, &src);
self.ensure_out_ring(DXGI_FORMAT_R10G10B10A2_UNORM)?;
let out = self.out_ring[slot].clone();
if self.hdr_conv.is_none() {
self.hdr_conv = Some(HdrConverter::new(&self.device)?);
}
let srv = self.fp16_srv.clone().context("fp16 srv")?;
let mut rtv: Option<ID3D11RenderTargetView> = None;
self.device
.CreateRenderTargetView(&out, None, Some(&mut rtv))?;
let rtv = rtv.context("hdr10 rtv")?;
self.hdr_conv.as_ref().unwrap().convert(
&self.context,
&srv,
&rtv,
self.width,
self.height,
);
self.last_present = Some((out.clone(), PixelFormat::Rgb10a2));
Ok(self.d3d11_frame(out, PixelFormat::Rgb10a2))
} else {
// SDR ZERO-COPY: hand NVENC the WGC pool texture DIRECTLY — no `CopyResource`. The
// per-frame copy otherwise queues on the graphics engine behind a GPU-saturating game
// and stalls `lock_bitstream` ~20 ms (NVENC sits idle waiting for its input). Encoding
// the pool texture in place removes that graphics-queue dependency (Apollo's model).
// We must keep the frame alive until its async encode finishes, so retain the last
// `HELD_FRAMES`; the pool has spare buffers so the producer never starves.
self.last_present = Some((src.clone(), PixelFormat::Bgra));
let out = self.d3d11_frame(src, PixelFormat::Bgra);
self.held.push_back(frame);
while self.held.len() > HELD_FRAMES {
self.held.pop_front();
}
Ok(out)
}
}
}
fn d3d11_frame(&self, texture: ID3D11Texture2D, format: PixelFormat) -> CapturedFrame {
CapturedFrame {
width: self.width,
height: self.height,
pts_ns: now_ns(),
format,
payload: FramePayload::D3d11(D3d11Frame {
texture,
device: self.device.clone(),
}),
}
}
}
impl Capturer for WgcCapturer {
fn hdr_meta(&self) -> Option<punktfunk_core::quic::HdrMeta> {
self.hdr_meta
}
fn next_frame(&mut self) -> Result<CapturedFrame> {
let overall = Instant::now() + Duration::from_secs(20);
loop {
if let Some(frame) = self.wait_and_drain() {
self.first_frame = false;
return self.process_frame(frame);
}
// No new frame within the wait — repeat the last presented frame (static desktop).
if let Some((tex, fmt)) = &self.last_present {
return Ok(self.d3d11_frame(tex.clone(), *fmt));
}
if Instant::now() > overall {
bail!("no WGC frame within 20s (SudoVDA monitor not lit / no capture access?)");
}
}
}
fn try_latest(&mut self) -> Result<Option<CapturedFrame>> {
let target = self.signal.available.load(Ordering::Acquire);
if target <= self.consumed {
return Ok(None);
}
let mut last = None;
while self.consumed < target {
if let Ok(f) = self.pool.TryGetNextFrame() {
last = Some(f);
}
self.consumed += 1;
}
match last {
Some(frame) => self.process_frame(frame).map(Some),
None => Ok(None),
}
}
// set_active: the trait default (no-op) is correct — WGC keeps its session running across the
// active/idle gate (cheap; the frame pool just recycles), like the DDA duplication.
}
impl Drop for WgcCapturer {
fn drop(&mut self) {
let _ = self.session.Close();
let _ = self.pool.Close();
// _keepalive drops after, REMOVEing the SudoVDA monitor.
}
}
fn tex_desc(
width: u32,
height: u32,
format: windows::Win32::Graphics::Dxgi::Common::DXGI_FORMAT,
bind: u32,
) -> D3D11_TEXTURE2D_DESC {
D3D11_TEXTURE2D_DESC {
Width: width,
Height: height,
MipLevels: 1,
ArraySize: 1,
Format: format,
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
Usage: D3D11_USAGE_DEFAULT,
BindFlags: bind,
CPUAccessFlags: 0,
MiscFlags: 0,
}
}
fn now_ns() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0)
}
@@ -1,484 +0,0 @@
//! Host-side WGC helper relay (Windows two-process secure-desktop design,
//! design/archive/windows-secure-desktop.md — step 4).
//!
//! WGC won't activate under the SYSTEM account, so the SYSTEM host can't capture the normal desktop
//! itself. Instead it spawns `punktfunk-host wgc-helper` in the **interactive user session** (so WGC works)
//! via `CreateProcessAsUserW`, with the helper's **stdout** redirected to an anonymous pipe the host
//! reads. The helper ships framed Annex-B access units; this module parses them back into AUs the
//! host relays onto the live QUIC session (same `EncodedFrame` flow, just sourced over a pipe instead
//! of a local encoder). A second pipe carries a tiny **control** channel to the helper (stdin: force
//! keyframe), and the helper's **stderr** is forwarded line-by-line into host tracing so its logs are
//! visible from the SYSTEM host's console.
//!
//! Wire framing (must match `wgc_helper::write_au`): per AU
//! `[u32 magic "PFAU" LE][u32 len LE][u64 pts_ns LE][u8 keyframe][len bytes data]`.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use crate::capture::dxgi::WinCaptureTarget;
use anyhow::{bail, Context, Result};
use std::io::{BufRead, BufReader, Read};
use std::sync::mpsc::{Receiver, SyncSender};
use std::sync::Mutex;
use windows::core::PWSTR;
use windows::Win32::Foundation::SetHandleInformation;
use windows::Win32::Foundation::{CloseHandle, HANDLE};
use windows::Win32::Foundation::{HANDLE_FLAGS, HANDLE_FLAG_INHERIT};
use windows::Win32::Security::{
DuplicateTokenEx, SecurityImpersonation, TokenPrimary, SECURITY_ATTRIBUTES, TOKEN_ALL_ACCESS,
};
use windows::Win32::System::Environment::{CreateEnvironmentBlock, DestroyEnvironmentBlock};
use windows::Win32::System::Pipes::CreatePipe;
use windows::Win32::System::RemoteDesktop::{WTSGetActiveConsoleSessionId, WTSQueryUserToken};
use windows::Win32::System::Threading::{
CreateProcessAsUserW, TerminateProcess, CREATE_NO_WINDOW, CREATE_UNICODE_ENVIRONMENT,
PROCESS_INFORMATION, STARTF_USESTDHANDLES, STARTUPINFOW,
};
/// Must match [`crate::wgc_helper`]'s `AU_MAGIC` ("PFAU").
const AU_MAGIC: u32 = 0x5046_4155;
/// One access unit relayed from the helper, in the helper's (= the host's, same machine) monotonic
/// clock — `pts_ns` is directly comparable to the host's `now_ns()`.
pub struct RelayAu {
pub data: Vec<u8>,
pub pts_ns: u64,
pub keyframe: bool,
}
/// A running USER-session WGC helper whose AUs the SYSTEM host relays. Drop kills the child + closes
/// the pipes; the reader threads then end on the broken pipe.
pub struct HelperRelay {
proc: HANDLE,
thread: HANDLE,
/// Host write end of the helper's stdin — control commands (force keyframe). Mutex so the relay
/// can be shared while the encode thread requests keyframes.
stdin_w: Mutex<HANDLE>,
/// Parsed AUs from the helper's stdout reader thread.
rx: Receiver<RelayAu>,
}
// SAFETY: every field is itself `Send`: the `proc`/`thread` `HANDLE`s are process-global kernel
// handle values (plain integers valid from any thread, owned for the relay's lifetime and closed once
// on Drop), `stdin_w` is a `Mutex<HANDLE>`, and `rx` is an mpsc `Receiver<RelayAu>` (which is `Send`).
// The relay is moved to one thread and owned there, so transferring it across threads is sound.
unsafe impl Send for HelperRelay {}
// NOTE: `HelperRelay` is deliberately NOT `Sync`. Its `rx: Receiver<RelayAu>` is `!Sync` (std mpsc
// is single-consumer), and the relay is only ever a single-owner local in the punktfunk1 two-process
// mux loop — never shared by `&` across threads — so `Sync` is neither sound nor needed. (A prior
// `unsafe impl Sync` here asserted more than the fields support; removed.)
/// Control byte on the helper's stdin: force the next encoded frame to be an IDR (client decode
/// recovery). Mirrors `enc.request_keyframe()` in the single-process path.
const CTL_KEYFRAME: u8 = 0x01;
impl HelperRelay {
/// Spawn the helper in the interactive user session and start relaying its AUs. `target` is the
/// SudoVDA output the host already created (captured by GDI name only — the helper never touches
/// display topology). `(w, h, hz)` is the negotiated mode; `bitrate_kbps` the negotiated bitrate.
pub fn spawn(
target: &WinCaptureTarget,
mode: (u32, u32, u32),
bitrate_kbps: u32,
bit_depth: u8,
) -> Result<HelperRelay> {
let exe = std::env::current_exe().context("current_exe for helper spawn")?;
let exe = exe.to_string_lossy().into_owned();
let (w, h, hz) = mode;
// CreateProcessAsUserW takes a single mutable command line (argv[0] = exe).
let cmdline = format!(
"\"{exe}\" wgc-helper --gdi \"{}\" --target-id {} --mode {w}x{h}x{hz} --bitrate {bitrate_kbps} --bit-depth {bit_depth}",
target.gdi_name, target.target_id
);
tracing::info!(cmd = %cmdline, "spawning WGC helper in user session");
// SAFETY: `spawn_inner` is an `unsafe fn` only because it drives raw Win32 token/pipe/process
// FFI; it imposes no caller-side memory precondition beyond valid arguments. `cmdline` is a live
// `&str` borrowed for the synchronous call and `(w, h, hz)` are plain `u32`s. It validates its
// own runtime requirements (active console session, SYSTEM token) and returns `Err` otherwise.
unsafe { spawn_inner(&cmdline, w, h, hz) }
}
/// Receive the next relayed AU. Distinguishes a `Timeout` (helper slow/stalled — keep waiting)
/// from `Disconnected` (helper exited → its stdout closed → reader thread ended → channel
/// dropped), which returns *immediately* and means the relay must stop, not spin.
pub fn recv_timeout(
&self,
dur: std::time::Duration,
) -> Result<RelayAu, std::sync::mpsc::RecvTimeoutError> {
self.rx.recv_timeout(dur)
}
/// Non-blocking receive — used to drain stale buffered AUs (encoded while the secure desktop was
/// the live source) before resuming the relay. `Ok` while AUs remain, `Err` once empty.
pub fn try_recv(&self) -> Result<RelayAu, std::sync::mpsc::TryRecvError> {
self.rx.try_recv()
}
/// Ask the helper's encoder for an IDR on the next frame (client decode recovery). Best-effort:
/// a write failure means the helper is gone — the caller's recv loop will see the disconnect.
pub fn request_keyframe(&self) {
let h = self.stdin_w.lock().unwrap();
let mut written = 0u32;
// SAFETY: `*h` is the host's write end of the helper's stdin pipe — a live `HANDLE` owned by
// this `HelperRelay` (held under the `stdin_w` Mutex, locked here), closed only in Drop.
// `WriteFile` reads the 1-byte `&[CTL_KEYFRAME]` buffer and writes the byte count into
// `written`; both are live locals that outlive the synchronous call. A failure (helper gone) is
// discarded as documented.
unsafe {
let _ = windows::Win32::Storage::FileSystem::WriteFile(
*h,
Some(&[CTL_KEYFRAME]),
Some(&mut written),
None,
);
}
}
}
impl Drop for HelperRelay {
fn drop(&mut self) {
// SAFETY: `self.proc`/`self.thread` are the child process/thread `HANDLE`s from
// `CreateProcessAsUserW`, and `stdin_w` is the host's pipe write end — all owned by this
// `HelperRelay` and closed exactly once here in Drop (no double-close). `TerminateProcess` and
// the three `CloseHandle`s are FFI calls taking those handles by value, borrowing no Rust memory.
unsafe {
// Terminate the child first so its WGC capture + NVENC session tear down, then close our
// handles (the reader threads end on the resulting broken pipe).
let _ = TerminateProcess(self.proc, 1);
let _ = CloseHandle(*self.stdin_w.lock().unwrap());
let _ = CloseHandle(self.proc);
let _ = CloseHandle(self.thread);
}
tracing::info!("WGC helper relay torn down");
}
}
/// Inheritable anonymous pipe (read, write). The caller marks whichever end the host keeps as
/// non-inheritable so the child only inherits its own end.
unsafe fn make_pipe() -> Result<(HANDLE, HANDLE)> {
let mut read = HANDLE::default();
let mut write = HANDLE::default();
let sa = SECURITY_ATTRIBUTES {
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: std::ptr::null_mut(),
bInheritHandle: true.into(),
};
CreatePipe(&mut read, &mut write, Some(&sa), 0).context("CreatePipe")?;
Ok((read, write))
}
/// Mark a handle non-inheritable (the host keeps it; the child must not get a copy).
unsafe fn no_inherit(h: HANDLE) {
let _ = SetHandleInformation(h, HANDLE_FLAG_INHERIT.0, HANDLE_FLAGS(0));
}
/// Build a child environment block: the target session's block (so DLL/PATH/SystemRoot resolve) with
/// this process's `PUNKTFUNK_*` vars overlaid, so the child runs with the SAME settings this process
/// has (`PUNKTFUNK_ENCODER=nvenc`, `PUNKTFUNK_ZEROCOPY`, …) instead of the target shell's. Returns a
/// UTF-16, double-null-terminated block suitable for `CREATE_UNICODE_ENVIRONMENT`. Shared by the WGC
/// helper spawn (here) and the Windows service launching the host into the active session.
pub(crate) unsafe fn merged_env_block(user_block: *const u16) -> Vec<u16> {
// Parse the user block ("VAR=VALUE\0" … "\0") into entries.
let mut entries: Vec<String> = Vec::new();
if !user_block.is_null() {
let mut p = user_block;
loop {
let mut len = 0isize;
while *p.offset(len) != 0 {
len += 1;
}
if len == 0 {
break; // the trailing empty string = end of block
}
let slice = std::slice::from_raw_parts(p, len as usize);
entries.push(String::from_utf16_lossy(slice));
p = p.offset(len + 1);
}
}
// Overlay "our" settings — PUNKTFUNK_* and RUST_LOG — dropping whatever the target block had.
let is_ours = |k: &str| k.starts_with("PUNKTFUNK_") || k == "RUST_LOG";
entries.retain(|e| !is_ours(e.split('=').next().unwrap_or("")));
for (k, v) in std::env::vars().filter(|(k, _)| is_ours(k)) {
entries.push(format!("{k}={v}"));
}
// Serialize back to a UTF-16 double-null-terminated block.
let mut block: Vec<u16> = Vec::new();
for e in entries {
block.extend(e.encode_utf16());
block.push(0);
}
block.push(0);
block
}
unsafe fn spawn_inner(cmdline: &str, w: u32, h: u32, hz: u32) -> Result<HelperRelay> {
// The user token of the active console session (requires the host to be SYSTEM).
let session = WTSGetActiveConsoleSessionId();
if session == 0xFFFF_FFFF {
bail!("no active console session (WTSGetActiveConsoleSessionId)");
}
let mut user_token = HANDLE::default();
WTSQueryUserToken(session, &mut user_token)
.context("WTSQueryUserToken (host must run as SYSTEM)")?;
// A primary token for CreateProcessAsUserW.
let mut primary = HANDLE::default();
let dup = DuplicateTokenEx(
user_token,
TOKEN_ALL_ACCESS,
None,
SecurityImpersonation,
TokenPrimary,
&mut primary,
);
let _ = CloseHandle(user_token);
dup.context("DuplicateTokenEx(TokenPrimary)")?;
// The user's environment block (PATH, USERPROFILE, SystemRoot → DLL resolution), MERGED with the
// host's PUNKTFUNK_* vars. CreateProcessAsUserW would otherwise give the helper the *user's* env
// only, dropping PUNKTFUNK_ENCODER=nvenc / PUNKTFUNK_ZEROCOPY/… that the host runs with — so the
// helper would fall back to the software (H.264-only) encoder. We parse the user block, strip any
// PUNKTFUNK_* it has, append the host's, and pass the merged block.
let mut env_block: *mut core::ffi::c_void = std::ptr::null_mut();
let _ = CreateEnvironmentBlock(&mut env_block, Some(primary), false);
let merged_env = merged_env_block(env_block as *const u16);
if !env_block.is_null() {
let _ = DestroyEnvironmentBlock(env_block);
}
// Three pipes: stdout (helper→host AUs), stdin (host→helper control), stderr (helper→host logs).
let (out_r, out_w) = make_pipe().context("stdout pipe")?;
let (in_r, in_w) = make_pipe().context("stdin pipe")?;
let (err_r, err_w) = make_pipe().context("stderr pipe")?;
// The host keeps out_r / in_w / err_r — none inheritable; the child inherits out_w/in_r/err_w.
no_inherit(out_r);
no_inherit(in_w);
no_inherit(err_r);
let mut si = STARTUPINFOW {
cb: std::mem::size_of::<STARTUPINFOW>() as u32,
dwFlags: STARTF_USESTDHANDLES,
hStdInput: in_r,
hStdOutput: out_w,
hStdError: err_w,
..Default::default()
};
// WGC needs the interactive desktop.
let mut desktop: Vec<u16> = "winsta0\\default\0".encode_utf16().collect();
si.lpDesktop = PWSTR(desktop.as_mut_ptr());
let mut cmd: Vec<u16> = cmdline.encode_utf16().chain(std::iter::once(0)).collect();
let mut pi = PROCESS_INFORMATION::default();
let created = CreateProcessAsUserW(
Some(primary),
None,
Some(PWSTR(cmd.as_mut_ptr())),
None,
None,
true, // inherit handles (the child's std ends)
CREATE_UNICODE_ENVIRONMENT | CREATE_NO_WINDOW,
Some(merged_env.as_ptr() as *const core::ffi::c_void),
None,
&si,
&mut pi,
);
// Clean up regardless of outcome: the child now owns its inherited ends; close our copies.
let _ = CloseHandle(out_w);
let _ = CloseHandle(in_r);
let _ = CloseHandle(err_w);
let _ = CloseHandle(primary);
if let Err(e) = created {
let _ = CloseHandle(out_r);
let _ = CloseHandle(in_w);
let _ = CloseHandle(err_r);
return Err(e).context("CreateProcessAsUserW(wgc-helper)");
}
tracing::info!(pid = pi.dwProcessId, mode = %format!("{w}x{h}@{hz}"), "WGC helper spawned");
// The helper does the WGC capture + NVENC encode, but it runs under the user's UAC-FILTERED token
// (no SE_INC_BASE_PRIORITY), so it can't raise its OWN GPU scheduling-priority class — under a
// GPU-saturating game NVENC then gets starved (the "240→40 fps in-game collapse"). The SYSTEM host
// holds the privilege, so stamp the HIGH GPU priority class onto the child here, right after spawn
// (the process-level class applies to the GPU contexts the helper creates afterwards).
crate::capture::dxgi::set_child_gpu_priority_class(pi.hProcess);
// stderr → host tracing, line by line.
let err_handle = HandleReader(err_r);
std::thread::Builder::new()
.name("wgc-helper-log".into())
.spawn(move || {
let r = BufReader::new(err_handle);
for line in r.lines() {
match line {
Ok(l) if !l.trim().is_empty() => tracing::info!(target: "wgc_helper", "{l}"),
Ok(_) => {}
Err(_) => break,
}
}
})
.ok();
// stdout → parsed AUs. Bounded so a stalled relay applies backpressure (the pipe then fills and
// the helper blocks on write — the same backpressure the single-process channel gives).
let (tx, rx) = std::sync::mpsc::sync_channel::<RelayAu>(3);
let out_handle = HandleReader(out_r);
std::thread::Builder::new()
.name("wgc-helper-au".into())
.spawn(move || au_reader(out_handle, tx))
.ok();
Ok(HelperRelay {
proc: pi.hProcess,
thread: pi.hThread,
stdin_w: Mutex::new(in_w),
rx,
})
}
/// Parse the AU framing off the helper's stdout and forward each AU. Ends (returns) when the pipe
/// breaks (helper exit) or the channel's receiver is dropped (relay torn down).
fn au_reader(mut r: HandleReader, tx: SyncSender<RelayAu>) {
loop {
let mut hdr = [0u8; 4 + 4 + 8 + 1];
if r.read_exact(&mut hdr).is_err() {
break;
}
let magic = u32::from_le_bytes([hdr[0], hdr[1], hdr[2], hdr[3]]);
if magic != AU_MAGIC {
tracing::error!(
magic = format!("{magic:#x}"),
"WGC helper AU stream desync — aborting relay"
);
break;
}
let len = u32::from_le_bytes([hdr[4], hdr[5], hdr[6], hdr[7]]) as usize;
let pts_ns = u64::from_le_bytes([
hdr[8], hdr[9], hdr[10], hdr[11], hdr[12], hdr[13], hdr[14], hdr[15],
]);
let keyframe = hdr[16] != 0;
// Bound the allocation — a corrupt length must not OOM the host. 64 MiB is far above any real
// AU (a 5K keyframe is a few MB).
if len > 64 * 1024 * 1024 {
tracing::error!(len, "WGC helper AU length implausible — aborting relay");
break;
}
let mut data = vec![0u8; len];
if r.read_exact(&mut data).is_err() {
break;
}
if tx
.send(RelayAu {
data,
pts_ns,
keyframe,
})
.is_err()
{
break; // relay dropped
}
}
}
/// Minimal `Read` over a Win32 pipe HANDLE (the windows crate doesn't impl `Read` on HANDLE).
struct HandleReader(HANDLE);
// SAFETY: `HandleReader` owns a single pipe `HANDLE` (a process-global kernel handle value, valid from
// any thread). It is moved into the dedicated reader thread and used only there (and closed once on
// Drop), never shared — so transferring ownership across threads is sound.
unsafe impl Send for HandleReader {}
impl Read for HandleReader {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let mut read = 0u32;
// SAFETY: `self.0` is the live read end of an anonymous pipe owned by this `HandleReader`
// (closed only in Drop). `ReadFile` fills the caller-provided `buf` (writing at most `buf.len()`
// bytes) and stores the count in `read`; both outlive the synchronous call. A broken pipe
// surfaces as `Err` and is mapped to EOF below.
let ok = unsafe {
windows::Win32::Storage::FileSystem::ReadFile(self.0, Some(buf), Some(&mut read), None)
};
match ok {
Ok(()) => Ok(read as usize),
// A broken pipe (helper exited) reads as ERROR_BROKEN_PIPE → report EOF (0).
Err(_) => Ok(0),
}
}
}
impl Drop for HandleReader {
fn drop(&mut self) {
// SAFETY: `self.0` is the pipe `HANDLE` this `HandleReader` owns; `CloseHandle` (an FFI call
// taking the handle by value) is invoked exactly once here in Drop, so there is no double-close.
unsafe {
let _ = CloseHandle(self.0);
}
}
}
/// Is this process running as the LOCAL SYSTEM account? Used to decide whether the two-process
/// secure-desktop path applies (only SYSTEM can `WTSQueryUserToken` + capture the Winlogon desktop).
pub fn running_as_system() -> bool {
use windows::Win32::Security::{GetTokenInformation, TokenUser, TOKEN_QUERY, TOKEN_USER};
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
// SAFETY: `OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token)` opens the current-process
// token (the pseudo-handle is always valid) into `token`, which is closed once before each return.
// The first `GetTokenInformation` (null buffer) queries the required `len`; `buf` is then a
// `Vec<u8>` of exactly `len` bytes and the second call fills it, so `&*(buf.as_ptr() as *const
// TOKEN_USER)` reads a `TOKEN_USER` the kernel just wrote into a sufficiently-sized buffer (the
// variable-length SID it points at also lies within `buf`, which outlives the borrow).
// `is_local_system_sid` is this module's `unsafe fn`, given that in-buffer `PSID`. Safe on any thread.
unsafe {
let mut token = HANDLE::default();
if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token).is_err() {
return false;
}
let mut len = 0u32;
let _ = GetTokenInformation(token, TokenUser, None, 0, &mut len);
if len == 0 {
let _ = CloseHandle(token);
return false;
}
let mut buf = vec![0u8; len as usize];
let ok = GetTokenInformation(
token,
TokenUser,
Some(buf.as_mut_ptr() as *mut _),
len,
&mut len,
)
.is_ok();
let _ = CloseHandle(token);
if !ok {
return false;
}
let tu = &*(buf.as_ptr() as *const TOKEN_USER);
// The well-known LocalSystem SID is S-1-5-18.
is_local_system_sid(tu.User.Sid)
}
}
/// True iff `sid` is S-1-5-18 (LocalSystem).
unsafe fn is_local_system_sid(sid: windows::Win32::Security::PSID) -> bool {
use windows::Win32::Security::{
GetSidIdentifierAuthority, GetSidSubAuthority, GetSidSubAuthorityCount, IsValidSid,
};
if !IsValidSid(sid).as_bool() {
return false;
}
let auth = GetSidIdentifierAuthority(sid);
if auth.is_null() {
return false;
}
// NT Authority = {0,0,0,0,0,5}.
let a = (*auth).Value;
if a != [0, 0, 0, 0, 0, 5] {
return false;
}
let count = *GetSidSubAuthorityCount(sid);
if count != 1 {
return false;
}
*GetSidSubAuthority(sid, 0) == 18 // SECURITY_LOCAL_SYSTEM_RID
}
+7 -24
View File
@@ -6,8 +6,8 @@
//! //!
//! **Goal-1 stages 12** (`design/windows-host-rewrite.md` §2.2): stage 1 stood this up; stage 2 migrated the //! **Goal-1 stages 12** (`design/windows-host-rewrite.md` §2.2): stage 1 stood this up; stage 2 migrated the
//! genuinely-constant operator/dispatch knobs onto it (the dispatch-disagreement bug class: `idd_push`, //! genuinely-constant operator/dispatch knobs onto it (the dispatch-disagreement bug class: `idd_push`,
//! `capture_backend`, `encoder_pref`, `render_adapter`, `no_wgc`, the vdisplay backend select — plus the //! `encoder_pref`, `render_adapter`, the vdisplay backend select — plus the plan-named
//! plan-named `secure_dda`/`idd_depth`/`zerocopy`/`ten_bit`/`four_four_four` and the multi-site `perf`/`compositor`/ //! `idd_depth`/`zerocopy`/`ten_bit`/`four_four_four` and the multi-site `perf`/`compositor`/
//! `video_source`/`gamepad`). `SessionPlan` (stage 3) consumes it as the single owner of the //! `video_source`/`gamepad`). `SessionPlan` (stage 3) consumes it as the single owner of the
//! capture/topology/encoder decision. //! capture/topology/encoder decision.
//! //!
@@ -36,27 +36,17 @@ use std::sync::OnceLock;
/// derived `Debug` impl, so the parser can stay a single platform-neutral function. /// derived `Debug` impl, so the parser can stay a single platform-neutral function.
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct HostConfig { pub struct HostConfig {
/// `PUNKTFUNK_IDD_PUSH` — capture from the pf-vdisplay driver's shared ring (in-process Session-0 /// `PUNKTFUNK_IDD_PUSH` — IDD direct-push monitor mode (the per-session monitor + ring recreate and
/// capture; no WGC helper). **Value-aware** (`0`/`false`/`no`/`off`/empty ⇒ off, else on); unset ⇒ off. /// the discrete-render-GPU pin in [`crate::vdisplay::manager`]). IDD-push is the sole Windows capture
/// The installer's default `host.env` sets it on, so a fresh install runs the validated IDD-push path /// path (DXGI Desktop Duplication and the WGC relay were removed), so this should stay on — the
/// (it falls back to DDA if the driver can't attach — see [`crate::capture`]). NOT a bare presence flag /// installer's `host.env` sets it. **Value-aware** (`0`/`false`/`no`/`off`/empty ⇒ off, else on);
/// (so an operator can turn it OFF in `host.env` with `=0`, which a `var_os` presence check can't). /// unset ⇒ off. NOT a bare presence flag (so an operator can turn it OFF with `=0`).
pub idd_push: bool, pub idd_push: bool,
/// `PUNKTFUNK_ENCODER` — explicit encoder-backend override (lowercased; empty = auto-detect by GPU vendor). /// `PUNKTFUNK_ENCODER` — explicit encoder-backend override (lowercased; empty = auto-detect by GPU vendor).
pub encoder_pref: String, pub encoder_pref: String,
/// `PUNKTFUNK_NO_HELPER` — never spawn the user-session WGC helper.
pub no_helper: bool,
/// `PUNKTFUNK_FORCE_HELPER` — force the WGC helper even when not running as SYSTEM.
pub force_helper: bool,
/// `PUNKTFUNK_NO_WGC` — force the pure single-process DDA path (skip WGC and the two-process relay).
pub no_wgc: bool,
/// `PUNKTFUNK_CAPTURE` — explicit Windows capture-backend override (lowercased; `dda`/`dxgi` vs the WGC default).
pub capture_backend: String,
/// `PUNKTFUNK_RENDER_ADAPTER` — discrete render-GPU pin by description substring (`Some` even when empty: /// `PUNKTFUNK_RENDER_ADAPTER` — discrete render-GPU pin by description substring (`Some` even when empty:
/// the empty string still counts as "set" for the presence checks, and the value reader filters it). /// the empty string still counts as "set" for the presence checks, and the value reader filters it).
pub render_adapter: Option<String>, pub render_adapter: Option<String>,
/// `PUNKTFUNK_SECURE_DDA` — enable the experimental DDA-on-secure-desktop (Winlogon/UAC) mux leg.
pub secure_dda: bool,
/// `PUNKTFUNK_IDD_DEPTH` — IDD-push pipeline depth override (default 2; the call site clamps to its `OUT_RING`). /// `PUNKTFUNK_IDD_DEPTH` — IDD-push pipeline depth override (default 2; the call site clamps to its `OUT_RING`).
pub idd_depth: usize, pub idd_depth: usize,
/// `PUNKTFUNK_ZEROCOPY` — opt into the Windows D3D11 zero-copy encode path (presence semantics; see module docs). /// `PUNKTFUNK_ZEROCOPY` — opt into the Windows D3D11 zero-copy encode path (presence semantics; see module docs).
@@ -103,14 +93,7 @@ impl HostConfig {
encoder_pref: std::env::var("PUNKTFUNK_ENCODER") encoder_pref: std::env::var("PUNKTFUNK_ENCODER")
.unwrap_or_default() .unwrap_or_default()
.to_ascii_lowercase(), .to_ascii_lowercase(),
no_helper: flag("PUNKTFUNK_NO_HELPER"),
force_helper: flag("PUNKTFUNK_FORCE_HELPER"),
no_wgc: flag("PUNKTFUNK_NO_WGC"),
capture_backend: std::env::var("PUNKTFUNK_CAPTURE")
.unwrap_or_default()
.to_ascii_lowercase(),
render_adapter: val("PUNKTFUNK_RENDER_ADAPTER"), render_adapter: val("PUNKTFUNK_RENDER_ADAPTER"),
secure_dda: flag("PUNKTFUNK_SECURE_DDA"),
idd_depth: val("PUNKTFUNK_IDD_DEPTH") idd_depth: val("PUNKTFUNK_IDD_DEPTH")
.and_then(|s| s.parse::<usize>().ok()) .and_then(|s| s.parse::<usize>().ok())
.unwrap_or(2), .unwrap_or(2),
+11 -2
View File
@@ -11,7 +11,10 @@
//! lets a picker show the fingerprint and pre-pin a chosen host; //! lets a picker show the fingerprint and pre-pin a chosen host;
//! - `pair` — `required` or `optional`, so a client can tell up front whether it must run the PIN //! - `pair` — `required` or `optional`, so a client can tell up front whether it must run the PIN
//! pairing ceremony before it can stream; //! pairing ceremony before it can stream;
//! - `id` — the stable host uniqueid (dedup across IPs / re-advertises). //! - `id` — the stable host uniqueid (dedup across IPs / re-advertises);
//! - `mgmt` — the management API's TCP port (when it serves one), so a client can fetch the host's
//! game library (`GET /api/v1/library`, mTLS) on the SAME IP without assuming the default port.
//! Omitted by a host with no mgmt API (the standalone `punktfunk1-host`).
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use mdns_sd::{ServiceDaemon, ServiceInfo}; use mdns_sd::{ServiceDaemon, ServiceInfo};
@@ -30,7 +33,9 @@ pub struct Advert {
} }
/// Advertise the native host on the LAN. `fingerprint` is the host cert SHA-256 (lowercase hex); /// Advertise the native host on the LAN. `fingerprint` is the host cert SHA-256 (lowercase hex);
/// `require_pairing` tells a discovering client whether it must pair before it can stream. /// `require_pairing` tells a discovering client whether it must pair before it can stream;
/// `mgmt_port` is the management API's port (`Some` when this host serves one — the client browses
/// the library there over mTLS on the advertised IP), `None` for a host with no mgmt API.
pub fn advertise_native( pub fn advertise_native(
hostname: &str, hostname: &str,
ip: IpAddr, ip: IpAddr,
@@ -38,6 +43,7 @@ pub fn advertise_native(
fingerprint: &str, fingerprint: &str,
require_pairing: bool, require_pairing: bool,
uniqueid: &str, uniqueid: &str,
mgmt_port: Option<u16>,
) -> Result<Advert> { ) -> Result<Advert> {
let daemon = ServiceDaemon::new().context("create mDNS daemon")?; let daemon = ServiceDaemon::new().context("create mDNS daemon")?;
let host_name = format!("{hostname}.local."); let host_name = format!("{hostname}.local.");
@@ -54,6 +60,9 @@ pub fn advertise_native(
.into(), .into(),
); );
props.insert("id".into(), uniqueid.to_string()); props.insert("id".into(), uniqueid.to_string());
if let Some(mgmt) = mgmt_port {
props.insert("mgmt".into(), mgmt.to_string());
}
let service = ServiceInfo::new(NATIVE_SERVICE, hostname, &host_name, ip, port, props) let service = ServiceInfo::new(NATIVE_SERVICE, hostname, &host_name, ip, port, props)
.context("build native mDNS ServiceInfo")?; .context("build native mDNS ServiceInfo")?;
daemon daemon
+112 -4
View File
@@ -17,6 +17,10 @@ pub struct AppEntry {
pub compositor: Option<crate::vdisplay::Compositor>, pub compositor: Option<crate::vdisplay::Compositor>,
/// Command gamescope runs nested (gamescope entries only). /// Command gamescope runs nested (gamescope entries only).
pub cmd: Option<String>, pub cmd: Option<String>,
/// Store-qualified library id (`steam:570`, `epic:…`) for entries surfaced from the host's game
/// library ([`crate::library`]). When set, the launch path resolves + launches it against the
/// host's own library instead of running [`cmd`](Self::cmd). `None` for Desktop / apps.json entries.
pub library_id: Option<String>,
} }
fn config_path() -> Option<std::path::PathBuf> { fn config_path() -> Option<std::path::PathBuf> {
@@ -35,9 +39,18 @@ fn parse_compositor(s: &str) -> Option<crate::vdisplay::Compositor> {
} }
} }
/// The catalog: the user's `apps.json` if present, else defaults (Desktop, plus gamescope /// The GameStream catalog Moonlight sees in `/applist`: the operator base ([`base_catalog`] — Desktop +
/// entries when gamescope is installed). /// apps.json) with the host's auto-detected game library ([`append_library`]) layered on top, so a
/// Moonlight client sees the same Steam/Epic/GOG/Xbox titles the native clients do instead of just Desktop.
pub fn catalog() -> Vec<AppEntry> { pub fn catalog() -> Vec<AppEntry> {
let mut apps = base_catalog();
append_library(&mut apps);
apps
}
/// The operator base: the user's `apps.json` if present, else defaults (Desktop, plus gamescope
/// entries when gamescope is installed). The installed game library is layered on by [`append_library`].
fn base_catalog() -> Vec<AppEntry> {
if let Some(path) = config_path() { if let Some(path) = config_path() {
if let Ok(raw) = std::fs::read_to_string(&path) { if let Ok(raw) = std::fs::read_to_string(&path) {
match serde_json::from_str::<Value>(&raw) { match serde_json::from_str::<Value>(&raw) {
@@ -53,6 +66,7 @@ pub fn catalog() -> Vec<AppEntry> {
.and_then(|c| c.as_str()) .and_then(|c| c.as_str())
.and_then(parse_compositor), .and_then(parse_compositor),
cmd: it.get("cmd").and_then(|c| c.as_str()).map(String::from), cmd: it.get("cmd").and_then(|c| c.as_str()).map(String::from),
library_id: None,
}) })
}) })
.collect(); .collect();
@@ -72,6 +86,7 @@ pub fn catalog() -> Vec<AppEntry> {
title: "Desktop".into(), title: "Desktop".into(),
compositor: None, compositor: None,
cmd: None, cmd: None,
library_id: None,
}]; }];
if which("gamescope") { if which("gamescope") {
if which("steam") { if which("steam") {
@@ -80,6 +95,7 @@ pub fn catalog() -> Vec<AppEntry> {
title: "Steam".into(), title: "Steam".into(),
compositor: Some(crate::vdisplay::Compositor::Gamescope), compositor: Some(crate::vdisplay::Compositor::Gamescope),
cmd: Some("steam -gamepadui".into()), cmd: Some("steam -gamepadui".into()),
library_id: None,
}); });
} }
if which("vkcube") { if which("vkcube") {
@@ -88,23 +104,79 @@ pub fn catalog() -> Vec<AppEntry> {
title: "vkcube (test)".into(), title: "vkcube (test)".into(),
compositor: Some(crate::vdisplay::Compositor::Gamescope), compositor: Some(crate::vdisplay::Compositor::Gamescope),
cmd: Some("vkcube".into()), cmd: Some("vkcube".into()),
library_id: None,
}); });
} }
} }
apps apps
} }
/// The high half of the positive `i32` range — where library-derived GameStream ids live, kept clear of
/// the small Desktop/apps.json ids so the two never collide.
const LIBRARY_ID_BASE: u32 = 0x4000_0000;
/// Append the host's installed game library ([`crate::library::all_games`] — Steam/Epic/GOG/Xbox/custom)
/// to `apps`. Each title gets a STABLE GameStream `<ID>` derived from its store-qualified library id
/// (Moonlight caches appids, so a title keeps its id across host restarts), carries that library id so
/// the launch path resolves it against the host's own library, and is de-duplicated (by id) against the
/// base catalog and the other library entries. Titles with no launch recipe are skipped (un-startable).
fn append_library(apps: &mut Vec<AppEntry>) {
let mut used: std::collections::HashSet<u32> = apps.iter().map(|a| a.id).collect();
for g in crate::library::all_games() {
if g.launch.is_none() {
continue;
}
let mut id = stable_app_id(&g.id);
// Linear-probe within the library range on the (rare) hash collision — deterministic given the
// stable all_games() order, so a title keeps its id run to run.
while !used.insert(id) {
id = LIBRARY_ID_BASE | (id.wrapping_add(1) & 0x3FFF_FFFF);
}
apps.push(AppEntry {
id,
title: g.title,
compositor: None, // auto-detect the desktop session (Windows ignores the compositor)
cmd: None,
library_id: Some(g.id),
});
}
}
/// A STABLE GameStream `<ID>` for a store-qualified library id (`steam:570`): FNV-1a-32 folded into the
/// high half of the positive `i32` range ([`LIBRARY_ID_BASE`]). Deterministic across runs and clear of
/// the reserved small Desktop/apps.json ids.
fn stable_app_id(library_id: &str) -> u32 {
let mut h: u32 = 0x811c_9dc5;
for b in library_id.bytes() {
h ^= b as u32;
h = h.wrapping_mul(0x0100_0193);
}
LIBRARY_ID_BASE | (h & 0x3FFF_FFFF)
}
pub fn by_id(id: u32) -> Option<AppEntry> { pub fn by_id(id: u32) -> Option<AppEntry> {
catalog().into_iter().find(|a| a.id == id) catalog().into_iter().find(|a| a.id == id)
} }
/// Render the GameStream `/applist` XML. /// Box-art bytes for the GameStream `/appasset` cover proxy: resolve the Moonlight appid to its catalog
/// entry, then (for a library title) fetch its cover from the host's library. `(bytes, content-type)`,
/// or `None` for Desktop / apps.json entries (no art) or a fetch failure. Blocking (disk + network) —
/// call off the async runtime.
pub fn appasset_bytes(appid: u32) -> Option<(Vec<u8>, String)> {
let lib_id = by_id(appid)?.library_id?;
crate::library::fetch_box_art(&lib_id)
}
/// Render the GameStream `/applist` XML. `IsHdrSupported` reflects whether the host can actually deliver
/// HDR (HEVC Main10 / PQ) for a title — host-wide today ([`crate::gamestream::host_hdr_capable`]); when
/// true, Moonlight offers its per-app HDR toggle.
pub fn applist_xml() -> String { pub fn applist_xml() -> String {
let hdr = u8::from(crate::gamestream::host_hdr_capable());
let mut xml = let mut xml =
String::from("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\">\n"); String::from("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\">\n");
for app in catalog() { for app in catalog() {
xml.push_str(&format!( xml.push_str(&format!(
"<App>\n<IsHdrSupported>0</IsHdrSupported>\n<AppTitle>{}</AppTitle>\n<ID>{}</ID>\n</App>\n", "<App>\n<IsHdrSupported>{hdr}</IsHdrSupported>\n<AppTitle>{}</AppTitle>\n<ID>{}</ID>\n</App>\n",
xml_escape(&app.title), xml_escape(&app.title),
app.id app.id
)); ));
@@ -130,10 +202,46 @@ mod tests {
#[test] #[test]
fn default_catalog_has_desktop() { fn default_catalog_has_desktop() {
// catalog() = base (Desktop + apps.json) + the installed library; Desktop (id 1) is always present.
let apps = catalog(); let apps = catalog();
assert!(apps.iter().any(|a| a.id == 1 && a.title == "Desktop")); assert!(apps.iter().any(|a| a.id == 1 && a.title == "Desktop"));
} }
#[test]
fn stable_app_id_is_deterministic_and_in_library_range() {
// Same id every run (Moonlight caches appids), distinct per title, and always in the high
// half of the positive i32 range so it never collides with the small Desktop/apps.json ids.
let a = stable_app_id("steam:570");
let b = stable_app_id("steam:570");
let c = stable_app_id("steam:271590");
assert_eq!(a, b);
assert_ne!(a, c);
for id in [a, c] {
assert!(id >= LIBRARY_ID_BASE, "id {id:#x} below library base");
assert!(id <= 0x7FFF_FFFF, "id {id:#x} not a positive i32");
assert_ne!(id, 1, "must not collide with Desktop");
}
}
#[test]
fn append_library_dedups_against_base_ids() {
// A base app whose id happens to fall in the library range must not be clobbered by a library
// entry that hashes to it — append_library probes past any used id.
let mut apps = vec![AppEntry {
id: stable_app_id("steam:570"),
title: "Pinned".into(),
compositor: None,
cmd: None,
library_id: None,
}];
append_library(&mut apps);
let ids: Vec<u32> = apps.iter().map(|a| a.id).collect();
let mut uniq = ids.clone();
uniq.sort_unstable();
uniq.dedup();
assert_eq!(ids.len(), uniq.len(), "duplicate GameStream ids in catalog");
}
#[test] #[test]
fn applist_xml_is_wellformed_ish() { fn applist_xml_is_wellformed_ish() {
let xml = applist_xml(); let xml = applist_xml();
+20 -7
View File
@@ -48,13 +48,26 @@ pub const SCM_HEVC: u32 = 0x0000_0100;
pub const SCM_HEVC_MAIN10: u32 = 0x0000_0200; pub const SCM_HEVC_MAIN10: u32 = 0x0000_0200;
pub const SCM_AV1_MAIN8: u32 = 0x0001_0000; pub const SCM_AV1_MAIN8: u32 = 0x0001_0000;
pub const SCM_AV1_MAIN10: u32 = 0x0002_0000; pub const SCM_AV1_MAIN10: u32 = 0x0002_0000;
/// What we actually encode via NVENC: H.264, HEVC Main, AV1 Main 8-bit (= 65793). The /// The **SDR baseline** codec mask: H.264, HEVC Main, AV1 Main 8-bit (= 65793). HEVC Main10 (HDR) is
/// 10-bit flags are deliberately NOT advertised: Moonlight only selects Main10 profiles for /// layered on top of this at runtime by `serverinfo::codec_mode_support` when — and only when — the
/// HDR streaming, and our capture path is 8-bit SDR BGRx with no HDR metadata plumbing — /// host can actually deliver it ([`host_hdr_capable`]); it is never a static claim, because a non-HDR
/// advertising them would let clients enable an HDR mode we can't deliver. (The previous /// host (Linux, or a Windows host without the `PUNKTFUNK_10BIT` opt-in) must not invite a client into
/// placeholder 3843 = 0xF03 wrongly claimed HEVC Main10 + 4:4:4 and *no* AV1.) /// an HDR mode it can't produce. (The previous placeholder 3843 = 0xF03 wrongly claimed HEVC Main10 +
/// 4:4:4 and *no* AV1.) 4:4:4 stays off entirely: stock Moonlight is 4:2:0 and the Windows IDD-push
/// capturer can't yet deliver full-chroma frames (`crate::capture::capturer_supports_444`).
pub const SERVER_CODEC_MODE_SUPPORT: u32 = SCM_H264 | SCM_HEVC | SCM_AV1_MAIN8; pub const SERVER_CODEC_MODE_SUPPORT: u32 = SCM_H264 | SCM_HEVC | SCM_AV1_MAIN8;
/// Whether this host can deliver an **HDR** (HEVC Main10 / BT.2020 PQ) GameStream — the single gate
/// for advertising [`SCM_HEVC_MAIN10`] in serverinfo and `IsHdrSupported` per app, and for honoring a
/// client's `dynamicRangeMode` request. HDR capture+encode is **Windows-only** (the Linux host is
/// 8-bit, blocked upstream) and behind the operator's `PUNKTFUNK_10BIT` opt-in — the same policy gate
/// the native punktfunk/1 plane honors. When this is true the IDD-push capturer streams HEVC Main10 PQ
/// whenever the desktop is HDR, and a client HDR request makes the GameStream video path proactively
/// enable advanced color on the per-session virtual display so PQ flows even from an SDR desktop.
pub fn host_hdr_capable() -> bool {
cfg!(target_os = "windows") && crate::config::config().ten_bit
}
/// Stable host identity + advertised capabilities, shared across control-plane handlers. /// Stable host identity + advertised capabilities, shared across control-plane handlers.
pub struct Host { pub struct Host {
pub hostname: String, pub hostname: String,
@@ -225,7 +238,7 @@ pub fn serve(
tokio::try_join!( tokio::try_join!(
nvhttp::run(state.clone()), nvhttp::run(state.clone()),
crate::mgmt::run(state.clone(), mgmt, Some(np.clone()), stats.clone()), crate::mgmt::run(state.clone(), mgmt, Some(np.clone()), stats.clone()),
crate::punktfunk1::serve(native_opts, np, stats.clone()), crate::punktfunk1::serve(native_opts, native.mgmt_port, np, stats.clone()),
)?; )?;
} else { } else {
// Secure default: native punktfunk/1 + management API only (no GameStream surface). // Secure default: native punktfunk/1 + management API only (no GameStream surface).
@@ -236,7 +249,7 @@ pub fn serve(
); );
tokio::try_join!( tokio::try_join!(
crate::mgmt::run(state.clone(), mgmt, Some(np.clone()), stats.clone()), crate::mgmt::run(state.clone(), mgmt, Some(np.clone()), stats.clone()),
crate::punktfunk1::serve(native_opts, np, stats.clone()), crate::punktfunk1::serve(native_opts, native.mgmt_port, np, stats.clone()),
)?; )?;
} }
Ok(()) Ok(())
+26 -3
View File
@@ -13,8 +13,8 @@ use super::{serverinfo, AppState, LaunchSession, HTTPS_PORT, HTTP_PORT, RTSP_POR
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use axum::{ use axum::{
extract::{Query, State}, extract::{Query, State},
http::header, http::{header, StatusCode},
response::IntoResponse, response::{IntoResponse, Response},
routing::get, routing::get,
Extension, Router, Extension, Router,
}; };
@@ -64,6 +64,7 @@ fn router(state: Arc<AppState>, https: bool) -> Router {
.route("/serverinfo", get(h_serverinfo)) .route("/serverinfo", get(h_serverinfo))
.route("/pair", get(h_pair)) .route("/pair", get(h_pair))
.route("/applist", get(h_applist)) .route("/applist", get(h_applist))
.route("/appasset", get(h_appasset))
.route("/launch", get(h_launch)) .route("/launch", get(h_launch))
.route("/resume", get(h_resume)) .route("/resume", get(h_resume))
.route("/cancel", get(h_cancel)) .route("/cancel", get(h_cancel))
@@ -94,10 +95,32 @@ async fn h_applist(
tracing::warn!("applist rejected — client is not paired"); tracing::warn!("applist rejected — client is not paired");
return xml(error_xml()); return xml(error_xml());
} }
// One app for now: the headless desktop (the wlroots virtual output).
xml(super::apps::applist_xml()) xml(super::apps::applist_xml())
} }
/// Box-art cover proxy (`/appasset?appid=N&AssetType=2&AssetIdx=0`). Moonlight fetches per-app covers
/// from the HOST, so we resolve the appid to its library title and proxy the cover image bytes (Steam/
/// Epic CDN, etc.). 404 for Desktop / apps.json entries (no art) or any fetch failure — Moonlight then
/// shows its title-only placeholder. Paired clients only (same gate as `/applist`). The resolve+fetch is
/// blocking (disk + network), so it runs on a blocking thread off the async runtime.
async fn h_appasset(
State(st): State<Arc<AppState>>,
peer: Option<Extension<PeerCertFingerprint>>,
Query(q): Query<HashMap<String, String>>,
) -> Response {
if !peer_is_paired(&peer, &st) {
tracing::warn!("appasset rejected — client is not paired");
return StatusCode::FORBIDDEN.into_response();
}
let Some(appid) = q.get("appid").and_then(|s| s.parse::<u32>().ok()) else {
return StatusCode::BAD_REQUEST.into_response();
};
match tokio::task::spawn_blocking(move || super::apps::appasset_bytes(appid)).await {
Ok(Some((bytes, ctype))) => ([(header::CONTENT_TYPE, ctype)], bytes).into_response(),
_ => StatusCode::NOT_FOUND.into_response(),
}
}
async fn h_launch( async fn h_launch(
State(st): State<Arc<AppState>>, State(st): State<Arc<AppState>>,
peer: Option<Extension<PeerCertFingerprint>>, peer: Option<Extension<PeerCertFingerprint>>,
+42 -6
View File
@@ -350,19 +350,34 @@ fn stream_config(map: &HashMap<String, String>) -> Option<StreamConfig> {
let fps = parse_u("x-nv-video[0].maxFPS") let fps = parse_u("x-nv-video[0].maxFPS")
.filter(|&f| f > 0) .filter(|&f| f > 0)
.unwrap_or(60); .unwrap_or(60);
let bitrate_kbps = parse_u("x-nv-vqos[0].bw.maximumBitrateKbps").unwrap_or(20_000); // Bitrate: Moonlight caps the legacy `x-nv-vqos[0].bw.*` fields at 100 Mbps for old-GFE
// compatibility and carries the user's REAL (uncapped) configured bitrate in the moonlight-specific
// `x-ml-video.configuredBitrateKbps`. Read that first — exactly like Sunshine — so a 500 Mbps client
// setting isn't silently floored to 100. Fall back to the legacy max for clients that don't send it,
// then a conservative default; clamp to a sane ceiling (the RTSP ANNOUNCE is attacker-controlled).
const MAX_BITRATE_KBPS: u32 = 1_000_000; // 1 Gbps — well above Moonlight's 500 Mbps slider
let bitrate_kbps = parse_u("x-ml-video.configuredBitrateKbps")
.filter(|&b| b > 0)
.or_else(|| parse_u("x-nv-vqos[0].bw.maximumBitrateKbps").filter(|&b| b > 0))
.unwrap_or(20_000)
.min(MAX_BITRATE_KBPS);
// Client codec choice (moonlight-common-c SdpGenerator.c): 0=H264, 1=HEVC, 2=AV1. // Client codec choice (moonlight-common-c SdpGenerator.c): 0=H264, 1=HEVC, 2=AV1.
let codec = match map.get("x-nv-vqos[0].bitStreamFormat").map(|s| s.trim()) { let codec = match map.get("x-nv-vqos[0].bitStreamFormat").map(|s| s.trim()) {
Some("1") => Codec::H265, Some("1") => Codec::H265,
Some("2") => Codec::Av1, Some("2") => Codec::Av1,
_ => Codec::H264, _ => Codec::H264,
}; };
// 10-bit/HDR request flag. We never advertise the Main10 SCM bits, so a compliant // 10-bit/HDR request (Moonlight sets `dynamicRangeMode != 0` only when it both saw our Main10 SCM
// client can't ask — if one does anyway, stream 8-bit SDR rather than failing. // bit AND the user enabled HDR). Honor it only when the host can actually deliver Main10 (Windows +
if parse_u("x-nv-video[0].dynamicRangeMode").unwrap_or(0) != 0 { // PUNKTFUNK_10BIT, `host_hdr_capable`); when honored, the video path proactively enables advanced
// color on the virtual display so a PQ stream flows even from an SDR desktop. A request we can't
// honor degrades to 8-bit SDR (and a desktop that is ALREADY HDR still streams PQ regardless, since
// the IDD-push capturer follows the display).
let hdr_requested = parse_u("x-nv-video[0].dynamicRangeMode").unwrap_or(0) != 0;
let hdr = hdr_requested && crate::gamestream::host_hdr_capable();
if hdr_requested && !hdr {
tracing::warn!( tracing::warn!(
"client requested HDR/10-bit (dynamicRangeMode != 0) — not advertised/supported, \ "client requested HDR (dynamicRangeMode != 0) but host is not HDR-capable — streaming 8-bit SDR"
streaming 8-bit SDR"
); );
} }
// Parity floor the client asks for (protects small frames); clamp to a sane max. // Parity floor the client asks for (protects small frames); clamp to a sane max.
@@ -377,6 +392,7 @@ fn stream_config(map: &HashMap<String, String>) -> Option<StreamConfig> {
bitrate_kbps, bitrate_kbps,
codec, codec,
min_fec, min_fec,
hdr,
}) })
} }
@@ -490,6 +506,26 @@ mod tests {
} }
} }
/// Bitrate precedence: the moonlight-specific `x-ml-video.configuredBitrateKbps` (the user's real,
/// uncapped setting) wins over the legacy `x-nv-vqos[0].bw.maximumBitrateKbps` (which Moonlight floors
/// at 100 Mbps for old-GFE compat). Without this a 500 Mbps client streamed at 100.
#[test]
fn announce_prefers_configured_bitrate() {
// Real Moonlight shape: legacy max floored at 100 Mbps, configured carrying the true 500 Mbps.
let map = announce(&[
("x-nv-vqos[0].bw.maximumBitrateKbps", "100000"),
("x-ml-video.configuredBitrateKbps", "500000"),
]);
assert_eq!(stream_config(&map).unwrap().bitrate_kbps, 500_000);
// No configured field (older client) → fall back to the legacy max (the base announce's 40 Mbps).
assert_eq!(stream_config(&announce(&[])).unwrap().bitrate_kbps, 40_000);
// A zero configured value is ignored (falls back), and an absurd value is clamped to the ceiling.
let zero = announce(&[("x-ml-video.configuredBitrateKbps", "0")]);
assert_eq!(stream_config(&zero).unwrap().bitrate_kbps, 40_000);
let huge = announce(&[("x-ml-video.configuredBitrateKbps", "9000000")]);
assert_eq!(stream_config(&huge).unwrap().bitrate_kbps, 1_000_000);
}
/// Missing required video keys → no config (the PLAY handler then refuses to stream). /// Missing required video keys → no config (the PLAY handler then refuses to stream).
#[test] #[test]
fn announce_missing_required_keys() { fn announce_missing_required_keys() {
@@ -43,11 +43,33 @@ pub fn serverinfo_xml(host: &Host, https: bool, paired: bool) -> String {
) )
} }
/// The `<ServerCodecModeSupport>` mask to advertise. On the VAAPI (AMD/Intel) backend it reflects /// The `<ServerCodecModeSupport>` mask to advertise: the SDR baseline ([`base_codec_mode_support`]) plus
/// what the GPU can ACTUALLY encode (probed — AV1 is narrow, and an old iGPU might lack HEVC), so a /// the HEVC Main10 (HDR) bit when the host can actually deliver HDR ([`apply_hdr`] /
/// Moonlight client never negotiates a codec the encoder can't open. NVENC and Windows keep the /// [`crate::gamestream::host_hdr_capable`]). Without the Main10 bit Moonlight never offers its HDR
/// Moonlight-validated static superset. /// toggle; with it, enabling HDR client-side negotiates Main10 and the IDD-push path streams BT.2020 PQ.
fn codec_mode_support() -> u32 { fn codec_mode_support() -> u32 {
apply_hdr(
base_codec_mode_support(),
crate::gamestream::host_hdr_capable(),
)
}
/// Add the HEVC Main10 (HDR) bit to `base` when `hdr` and HEVC is advertised — pure so the
/// HDR-layering is unit-testable without a GPU. (HDR streaming uses HEVC Main10; AV1 Main10 is left
/// off until the GameStream AV1 path is live-confirmed.)
fn apply_hdr(base: u32, hdr: bool) -> u32 {
if hdr && base & super::SCM_HEVC != 0 {
base | super::SCM_HEVC_MAIN10
} else {
base
}
}
/// The **SDR baseline** mask. On the VAAPI (AMD/Intel) backend it reflects what the GPU can ACTUALLY
/// encode (probed — AV1 is narrow, and an old iGPU might lack HEVC), so a Moonlight client never
/// negotiates a codec the encoder can't open. NVENC and the GPU-less software path keep the
/// Moonlight-validated static superset. HDR (Main10) is layered on by [`codec_mode_support`].
fn base_codec_mode_support() -> u32 {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
if crate::encode::linux_zero_copy_is_vaapi() { if crate::encode::linux_zero_copy_is_vaapi() {
if let Some(m) = probed_mask(crate::encode::vaapi_codec_support()) { if let Some(m) = probed_mask(crate::encode::vaapi_codec_support()) {
@@ -108,6 +130,22 @@ mod tests {
); );
} }
#[test]
fn apply_hdr_adds_main10_only_when_capable_and_hevc() {
// HDR-capable + HEVC advertised → Main10 added.
assert_eq!(
apply_hdr(SCM_H264 | SCM_HEVC | SCM_AV1_MAIN8, true),
SCM_H264 | SCM_HEVC | SCM_AV1_MAIN8 | SCM_HEVC_MAIN10
);
// Not HDR-capable → baseline unchanged (no HDR claim).
assert_eq!(
apply_hdr(SCM_H264 | SCM_HEVC | SCM_AV1_MAIN8, false),
SCM_H264 | SCM_HEVC | SCM_AV1_MAIN8
);
// HDR-capable but a GPU with no HEVC at all → no Main10 (you can't do Main10 without HEVC).
assert_eq!(apply_hdr(SCM_H264, true), SCM_H264);
}
#[test] #[test]
fn serverinfo_xml_carries_codec_mask() { fn serverinfo_xml_carries_codec_mask() {
let host = Host { let host = Host {
+47 -7
View File
@@ -28,6 +28,10 @@ pub struct StreamConfig {
pub codec: Codec, pub codec: Codec,
/// Client's `x-nv-vqos[0].fec.minRequiredFecPackets` — parity floor per FEC block. /// Client's `x-nv-vqos[0].fec.minRequiredFecPackets` — parity floor per FEC block.
pub min_fec: u8, pub min_fec: u8,
/// Client requested HDR (`dynamicRangeMode != 0`) AND the host can deliver it ([`host_hdr_capable`]).
/// Drives the capturer's proactive advanced-color enable; the encoder picks Main10 from the captured
/// (P010) frame format. Always `false` on a non-HDR host, so the SDR path is unchanged.
pub hdr: bool,
} }
/// Slot for the persistent screen capturer, shared with the control plane and reused across /// Slot for the persistent screen capturer, shared with the control plane and reused across
@@ -137,7 +141,15 @@ fn run(
let launch_here = compositor != crate::vdisplay::Compositor::Gamescope; let launch_here = compositor != crate::vdisplay::Compositor::Gamescope;
#[cfg(any(windows, target_os = "linux"))] #[cfg(any(windows, target_os = "linux"))]
if launch_here { if launch_here {
if let Some(cmd) = app // A library title (Steam/Epic/GOG/Xbox/custom, surfaced in /applist) carries its
// store-qualified id — resolve + launch it against the host's OWN library (the client can
// only pick an existing title, never inject a command). An apps.json entry instead carries
// an operator-typed `cmd`. Library id wins when both are set.
if let Some(lib_id) = app.and_then(|a| a.library_id.as_deref()) {
if let Err(e) = crate::library::launch_gamestream_library(lib_id) {
tracing::warn!(library_id = lib_id, error = %e, "gamestream: could not launch library title");
}
} else if let Some(cmd) = app
.and_then(|a| a.cmd.as_deref()) .and_then(|a| a.cmd.as_deref())
.filter(|c| !c.trim().is_empty()) .filter(|c| !c.trim().is_empty())
{ {
@@ -213,6 +225,17 @@ fn open_gs_virtual_source(
let compositor = if let Some(c) = app.and_then(|a| a.compositor) { let compositor = if let Some(c) = app.and_then(|a| a.compositor) {
c c
} else { } else {
// Windows has a single virtual-display backend (pf-vdisplay); `vdisplay::open` ignores the
// compositor arg there, so short-circuit the Linux session-detection state machine with a
// placeholder — mirrors `punktfunk1::resolve_compositor`. Without this, the Linux `detect()`
// below bails on Windows ("could not detect compositor … XDG_CURRENT_DESKTOP=''"), which
// killed the GameStream video thread → black screen (the native plane was already guarded).
#[cfg(target_os = "windows")]
{
crate::vdisplay::Compositor::Kwin
}
#[cfg(not(target_os = "windows"))]
{
let active = crate::vdisplay::detect_active_session(); let active = crate::vdisplay::detect_active_session();
crate::vdisplay::apply_session_env(&active); crate::vdisplay::apply_session_env(&active);
let c = crate::vdisplay::compositor_for_kind(active.kind) let c = crate::vdisplay::compositor_for_kind(active.kind)
@@ -221,6 +244,7 @@ fn open_gs_virtual_source(
.context("detect compositor")?; .context("detect compositor")?;
crate::vdisplay::apply_input_env(c); crate::vdisplay::apply_input_env(c);
c c
}
}; };
let mut vd = crate::vdisplay::open(compositor).context("open virtual display")?; let mut vd = crate::vdisplay::open(compositor).context("open virtual display")?;
// Carry the resolved launch command on the backend instance (per-session) rather than a // Carry the resolved launch command on the backend instance (per-session) rather than a
@@ -233,11 +257,13 @@ fn open_gs_virtual_source(
refresh_hz: cfg.fps, refresh_hz: cfg.fps,
}) })
.context("create virtual output at client resolution")?; .context("create virtual output at client resolution")?;
// want_hdr=false: GameStream HDR is not negotiated into StreamConfig here (the default WGC backend // HDR: pass the negotiated `cfg.hdr` (client asked for HDR AND the host can deliver it). On the
// still auto-detects HDR from the output colorspace; only the opt-in IDD-push path streams SDR). // Windows IDD-push path this proactively enables advanced color on the virtual display so a Main10
// PQ stream flows even from an SDR desktop; an already-HDR desktop streams PQ regardless (the
// capturer follows the display). No-op on Linux (8-bit, and `cfg.hdr` is always false there).
let capturer = capture::capture_virtual_output( let capturer = capture::capture_virtual_output(
vout, vout,
capture::OutputFormat::resolve(false), capture::OutputFormat::resolve(cfg.hdr),
crate::session_plan::CaptureBackend::resolve(), crate::session_plan::CaptureBackend::resolve(),
) )
.context("capture virtual output")?; .context("capture virtual output")?;
@@ -245,6 +271,19 @@ fn open_gs_virtual_source(
Ok((capturer, compositor)) Ok((capturer, compositor))
} }
/// The encoder bit depth implied by the captured frame's pixel format: a 10-bit (HDR) source — the
/// Windows IDD-push capturer's `P010`/`Rgb10a2` when the desktop is HDR — opens NVENC as HEVC Main10
/// (BT.2020 PQ); everything else is 8-bit. The encoder backends already key the real profile off the
/// `format`, so this just keeps the `bit_depth` argument honest (the old hard-coded `8` mislabeled an
/// HDR stream that the format had already promoted to 10-bit).
fn gs_bit_depth(format: crate::capture::PixelFormat) -> u8 {
use crate::capture::PixelFormat;
match format {
PixelFormat::P010 | PixelFormat::Rgb10a2 => 10,
_ => 8,
}
}
/// One frame's packets, handed from the encode thread to the send thread. /// One frame's packets, handed from the encode thread to the send thread.
type PacketBatch = Vec<Vec<u8>>; type PacketBatch = Vec<Vec<u8>>;
@@ -430,9 +469,10 @@ fn stream_body(
cfg.fps, cfg.fps,
cfg.bitrate_kbps as u64 * 1000, cfg.bitrate_kbps as u64 * 1000,
frame.is_cuda(), frame.is_cuda(),
8, // GameStream/Moonlight path: 8-bit (its own codec negotiation) // 8-bit SDR, or 10-bit when the captured frame is HDR (P010) — see `gs_bit_depth`.
gs_bit_depth(frame.format),
// GameStream/Moonlight stays 4:2:0 — stock Moonlight clients can't decode 4:4:4, and the // GameStream/Moonlight stays 4:2:0 — stock Moonlight clients can't decode 4:4:4, and the
// protocol has no chroma negotiation. 4:4:4 is punktfunk/1-native only. // Windows IDD-push capturer can't yet deliver full-chroma frames. 4:4:4 is punktfunk/1-native only.
encode::ChromaFormat::Yuv420, encode::ChromaFormat::Yuv420,
) )
.context("open video encoder for stream")?; .context("open video encoder for stream")?;
@@ -562,7 +602,7 @@ fn stream_body(
cfg.fps, cfg.fps,
cfg.bitrate_kbps as u64 * 1000, cfg.bitrate_kbps as u64 * 1000,
frame.is_cuda(), frame.is_cuda(),
8, gs_bit_depth(frame.format),
encode::ChromaFormat::Yuv420, // GameStream stays 4:2:0 encode::ChromaFormat::Yuv420, // GameStream stays 4:2:0
) )
.context("reopen encoder after rebuild")?; .context("reopen encoder after rebuild")?;
+88
View File
@@ -1116,6 +1116,63 @@ fn fetch_json(url: &str) -> Option<serde_json::Value> {
serde_json::from_str(&body).ok() serde_json::from_str(&body).ok()
} }
/// Fetch one image URL for the GameStream `/appasset` cover proxy, as `(bytes, content-type)`. Handles
/// `data:` URLs (Lutris inlines art that way) by decoding inline, and `http(s)` URLs by a bounded GET
/// (8 MiB cap so a hostile/huge art URL can't balloon host memory). `None` on any non-image scheme,
/// network/decoder error, or empty body. Blocking (ureq) — call off the async runtime.
fn fetch_image(url: &str) -> Option<(Vec<u8>, String)> {
use base64::Engine as _;
use std::io::Read as _;
if let Some(rest) = url.strip_prefix("data:") {
// data:[<mediatype>][;base64],<payload>
let (meta, data) = rest.split_once(',')?;
let ctype = meta
.split(';')
.next()
.filter(|s| !s.is_empty())
.unwrap_or("image/jpeg")
.to_string();
let bytes = if meta.contains(";base64") {
base64::engine::general_purpose::STANDARD
.decode(data)
.ok()?
} else {
data.as_bytes().to_vec()
};
return (!bytes.is_empty()).then_some((bytes, ctype));
}
if !(url.starts_with("http://") || url.starts_with("https://")) {
return None;
}
let agent = ureq::AgentBuilder::new()
.timeout(std::time::Duration::from_secs(10))
.build();
let resp = agent.get(url).call().ok()?;
let ctype = resp
.header("Content-Type")
.unwrap_or("image/jpeg")
.to_string();
let mut bytes = Vec::new();
resp.into_reader()
.take(8 * 1024 * 1024)
.read_to_end(&mut bytes)
.ok()?;
(!bytes.is_empty()).then_some((bytes, ctype))
}
/// Resolve + fetch the best box-art cover for a library id (the GameStream `/appasset` proxy — Moonlight
/// fetches per-app covers from the HOST, not the CDN, so we proxy the bytes). Tries the portrait (tall
/// capsule Moonlight wants) → header → hero → logo, returning the first that fetches as
/// `(bytes, content-type)`. Resolves the id against the host's OWN library. Blocking — call off the
/// async runtime (e.g. `spawn_blocking`).
pub fn fetch_box_art(id: &str) -> Option<(Vec<u8>, String)> {
let g = all_games().into_iter().find(|g| g.id == id)?;
[g.art.portrait, g.art.header, g.art.hero, g.art.logo]
.into_iter()
.flatten()
.find_map(|url| fetch_image(&url))
}
/// Make a protocol-relative URL (`//host/...`, common in GOG + MS catalog responses) absolute https. /// Make a protocol-relative URL (`//host/...`, common in GOG + MS catalog responses) absolute https.
fn abs_url(u: &str) -> String { fn abs_url(u: &str) -> String {
u.strip_prefix("//") u.strip_prefix("//")
@@ -1487,6 +1544,25 @@ pub fn launch_gamestream_command(cmd: &str) -> Result<()> {
} }
} }
/// Launch a library title chosen from the **GameStream `/applist`** (the store-qualified id is carried
/// on the `AppEntry`, resolved from the numeric Moonlight appid). Windows spawns it into the interactive
/// user session ([`launch_title`]); Linux resolves its shell command ([`launch_command`]) and runs it
/// into the live session ([`launch_gamestream_command`]). The id is resolved against the host's OWN
/// library, so a client can only ever pick an existing title — never inject a command.
#[cfg(any(windows, target_os = "linux"))]
pub fn launch_gamestream_library(id: &str) -> Result<()> {
#[cfg(windows)]
{
launch_title(id)
}
#[cfg(target_os = "linux")]
{
let cmd = launch_command(id)
.ok_or_else(|| anyhow::anyhow!("library id '{id}' has no launch recipe"))?;
launch_gamestream_command(&cmd)
}
}
/// The full library: every store's titles merged + the custom entries, sorted by title. /// The full library: every store's titles merged + the custom entries, sorted by title.
pub fn all_games() -> Vec<GameEntry> { pub fn all_games() -> Vec<GameEntry> {
let mut games = SteamProvider.list(); let mut games = SteamProvider.list();
@@ -1608,6 +1684,18 @@ mod tests {
); );
} }
#[test]
fn fetch_image_decodes_data_url() {
// "Hi" base64 == "SGk=" — the data: branch is pure (no network), so it's deterministic.
let (bytes, ctype) = fetch_image("data:image/png;base64,SGk=").expect("data url decodes");
assert_eq!(bytes, b"Hi");
assert_eq!(ctype, "image/png");
// A non-image scheme is rejected (no launcher art ever points at file://, but be defensive).
assert!(fetch_image("file:///etc/passwd").is_none());
// Empty payload → None (never serve a 0-byte cover).
assert!(fetch_image("data:image/png;base64,").is_none());
}
#[test] #[test]
fn custom_entry_maps_to_game_entry() { fn custom_entry_maps_to_game_entry() {
let g: GameEntry = CustomEntry { let g: GameEntry = CustomEntry {
+24 -36
View File
@@ -56,9 +56,6 @@ mod spike;
mod stats_recorder; mod stats_recorder;
mod vdisplay; mod vdisplay;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "windows/wgc_helper.rs"]
mod wgc_helper;
#[cfg(target_os = "windows")]
#[path = "windows/win_adapter.rs"] #[path = "windows/win_adapter.rs"]
mod win_adapter; mod win_adapter;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
@@ -392,35 +389,6 @@ fn real_main() -> Result<()> {
paired_store: None, paired_store: None,
}) })
} }
// USER-session WGC helper (Windows two-process secure-desktop design): capture the EXISTING
// SudoVDA via WGC + NVENC, stream AUs on stdout to the SYSTEM host. Spawned by the host
// (CreateProcessAsUser), not run by hand. See design/archive/windows-secure-desktop.md.
#[cfg(target_os = "windows")]
Some("wgc-helper") => {
let get = |flag: &str| {
args.iter()
.skip_while(|a| *a != flag)
.nth(1)
.map(String::as_str)
};
let (width, height, fps) = get("--mode")
.and_then(|m| {
let p: Vec<u32> = m.split('x').filter_map(|s| s.parse().ok()).collect();
(p.len() == 3).then(|| (p[0], p[1], p[2]))
})
.unwrap_or((1920, 1080, 60));
wgc_helper::run(wgc_helper::HelperOptions {
target_id: get("--target-id").and_then(|s| s.parse().ok()).unwrap_or(0),
gdi_name: get("--gdi").unwrap_or("").to_string(),
width,
height,
fps,
bitrate_kbps: get("--bitrate")
.and_then(|s| s.parse().ok())
.unwrap_or(20000),
bit_depth: get("--bit-depth").and_then(|s| s.parse().ok()).unwrap_or(8),
})
}
// Windows service control: install/uninstall/start/stop/status + the SCM `run` entry point. // Windows service control: install/uninstall/start/stop/status + the SCM `run` entry point.
// Replaces the ad-hoc launch chain — `service install` registers an auto-start SYSTEM service // Replaces the ad-hoc launch chain — `service install` registers an auto-start SYSTEM service
// that launches the host into the active interactive session. // that launches the host into the active interactive session.
@@ -504,6 +472,10 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, punktfunk1::NativeServ
let mut native_port: u16 = 9777; // the native plane always runs now let mut native_port: u16 = 9777; // the native plane always runs now
let mut open = false; let mut open = false;
let mut gamestream = false; let mut gamestream = false;
// Did the operator pin the mgmt bind themselves? If not, we LAN-expose the read surface below so
// paired clients can browse the game library out of the box (the bearer admin surface stays
// loopback-gated in `mgmt::require_auth` regardless of the bind).
let mut mgmt_bind_explicit = false;
let mut i = 0; let mut i = 0;
while i < args.len() { while i < args.len() {
let arg = args[i].as_str(); let arg = args[i].as_str();
@@ -517,7 +489,8 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, punktfunk1::NativeServ
"--mgmt-bind" => { "--mgmt-bind" => {
opts.bind = next()? opts.bind = next()?
.parse() .parse()
.map_err(|_| anyhow::anyhow!("bad --mgmt-bind (want IP:PORT)"))? .map_err(|_| anyhow::anyhow!("bad --mgmt-bind (want IP:PORT)"))?;
mgmt_bind_explicit = true;
} }
"--mgmt-token" => { "--mgmt-token" => {
let token = next()?; let token = next()?;
@@ -558,9 +531,20 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, punktfunk1::NativeServ
if opts.token.is_none() { if opts.token.is_none() {
opts.token = Some(crate::mgmt_token::load_or_generate()?); opts.token = Some(crate::mgmt_token::load_or_generate()?);
} }
// Default the mgmt listener to ALL interfaces (not just loopback) so a paired native client can
// fetch the game library over mTLS with no operator step — the whole point of "browse works by
// default". This only LAN-exposes the read-only cert allowlist; the bearer-token admin surface
// is confined to loopback peers in `mgmt::require_auth`, so binding wide adds no admin exposure.
// An operator who pinned `--mgmt-bind` (e.g. `127.0.0.1:47990` to restore loopback-only) keeps it.
if !mgmt_bind_explicit {
opts.bind = std::net::SocketAddr::from(([0, 0, 0, 0], mgmt::DEFAULT_PORT));
}
let native = punktfunk1::NativeServe { let native = punktfunk1::NativeServe {
port: native_port, port: native_port,
require_pairing: !open, require_pairing: !open,
// Advertise the mgmt port over mDNS so clients learn where to browse the library (rather than
// assuming the default). `opts.bind.port()` is the real port even if the operator moved it.
mgmt_port: opts.bind.port(),
}; };
Ok((opts, native, gamestream)) Ok((opts, native, gamestream))
} }
@@ -675,9 +659,13 @@ USAGE:
punktfunk-host spike [OPTIONS] capture→encode→file pipeline spike (dev tool) punktfunk-host spike [OPTIONS] capture→encode→file pipeline spike (dev tool)
SERVE OPTIONS: SERVE OPTIONS:
--mgmt-bind <IP:PORT> management API address (default: 127.0.0.1:47990) --mgmt-bind <IP:PORT> management API address (default: 0.0.0.0:47990 — paired clients
--mgmt-token <TOKEN> bearer token for the management API (or PUNKTFUNK_MGMT_TOKEN); reach the read-only surface, incl. the game library, over mTLS;
required when --mgmt-bind is not loopback the bearer admin API stays loopback-only. Pin 127.0.0.1:47990 to
bind loopback only)
--mgmt-token <TOKEN> bearer token for the management API (or PUNKTFUNK_MGMT_TOKEN); the
admin endpoints it guards are honored only from a loopback peer
(the co-located web console), never over the LAN
--gamestream (--moonlight) ALSO run the GameStream/Moonlight-compat planes (nvhttp pairing, --gamestream (--moonlight) ALSO run the GameStream/Moonlight-compat planes (nvhttp pairing,
RTSP, ENet control, _nvstream mDNS). OFF by default — they carry RTSP, ENet control, _nvstream mDNS). OFF by default — they carry
inherent on-path weaknesses (plain-HTTP pairing + legacy GCM nonce inherent on-path weaknesses (plain-HTTP pairing + legacy GCM nonce
+101 -10
View File
@@ -9,15 +9,20 @@
//! and a copy is checked in at `api/openapi.json` (a test fails if it drifts, like the //! and a copy is checked in at `api/openapi.json` (a test fails if it drifts, like the
//! cbindgen header). //! cbindgen header).
//! //!
//! Security: binds loopback by default, serves HTTPS with the host's identity cert, and requires //! Security: serves HTTPS with the host's identity cert and requires auth on every `/api/v1` route
//! auth on every `/api/v1` route except `/api/v1/health` — **always**, even on loopback. A paired //! except `/api/v1/health` — **always**, even on loopback. The listener binds **all interfaces by
//! native client authenticates by its mTLS cert; everyone else by a bearer token (`--mgmt-token` / //! default** so a paired native client can reach the read-only surface (host/status/clients and the
//! `PUNKTFUNK_MGMT_TOKEN`, else auto-generated + persisted to `~/.config/punktfunk/mgmt-token`). The //! **game library**) over the LAN with no operator step — authenticated by its mTLS cert (the
//! OpenAPI document and docs UI are served unauthenticated (the spec is public — it lives in this repo). //! `cert_may_access` allowlist). The **bearer-token admin surface** (pairing, unpair, session
//! control, library mutation, stats) is honored **only from a loopback peer**, so it is never
//! LAN-exposed: the web console BFF — the sole token holder (`--mgmt-token` / `PUNKTFUNK_MGMT_TOKEN`,
//! else auto-generated + persisted to `~/.config/punktfunk/mgmt-token`) — always connects over
//! loopback. Restore the old loopback-only listener with `--mgmt-bind 127.0.0.1:47990`. The OpenAPI
//! document and docs UI are served unauthenticated (the spec is public — it lives in this repo).
use crate::encode::Codec; use crate::encode::Codec;
use crate::gamestream::{ use crate::gamestream::{
tls::{serve_https, PeerCertFingerprint}, tls::{serve_https, PeerAddr, PeerCertFingerprint},
AppState, APP_VERSION, AUDIO_PORT, CONTROL_PORT, GFE_VERSION, RTSP_PORT, VIDEO_PORT, AppState, APP_VERSION, AUDIO_PORT, CONTROL_PORT, GFE_VERSION, RTSP_PORT, VIDEO_PORT,
}; };
use crate::stats_recorder::{Capture, CaptureMeta, StatsStatus}; use crate::stats_recorder::{Capture, CaptureMeta, StatsStatus};
@@ -474,8 +479,11 @@ where
// Auth // Auth
// --------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------
/// Auth gate on the `/api/v1` routes: a paired client cert (mTLS) or the bearer token — required /// Auth gate on the `/api/v1` routes: a paired client cert (mTLS, from anywhere) or the bearer token
/// always (the host runs with a token by construction). `/api/v1/health` stays open for probes. /// (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
/// ([`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.
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
@@ -493,8 +501,25 @@ async fn require_auth(State(st): State<Arc<MgmtState>>, req: Request, next: Next
return next.run(req).await; return next.run(req).await;
} }
} }
// Otherwise require the bearer token (the web console / admin). `run` always passes a token, so // Otherwise require the bearer token (the web console / admin) — but only from a LOOPBACK peer.
// no-token means a misconfigured caller (e.g. a test constructing `app` directly) — deny. // The token authorizes the full admin surface, so confining it to loopback keeps that surface off
// the LAN even though the listener now binds all interfaces by default (so paired clients can
// browse the library). The web console BFF — the sole token holder — always connects over
// loopback, so nothing first-party is affected; a LAN caller must use a paired client cert and is
// limited to the read-only allowlist above. (No PeerAddr ⇒ a non-`serve_https` caller, e.g. a unit
// test → treat as loopback so handler tests still authenticate by token.)
let from_loopback = req
.extensions()
.get::<PeerAddr>()
.is_none_or(|a| a.0.ip().is_loopback());
if !from_loopback {
return api_error(
StatusCode::UNAUTHORIZED,
"the admin API is loopback-only — a LAN client must present a paired client certificate",
);
}
// `run` always passes a token, so no-token means a misconfigured caller (e.g. a test constructing
// `app` directly) — deny.
let Some(expected) = st.token.as_deref() else { let Some(expected) = st.token.as_deref() else {
return api_error(StatusCode::UNAUTHORIZED, "authentication required"); return api_error(StatusCode::UNAUTHORIZED, "authentication required");
}; };
@@ -1605,6 +1630,72 @@ mod tests {
); );
} }
/// The bearer-token (admin) path is honored only from a LOOPBACK peer: the same token from a LAN
/// peer is rejected, so binding the listener to all interfaces (so paired clients can browse the
/// library by default) never LAN-exposes the admin surface. A paired *cert*, by contrast, reaches
/// the read-only allowlist from anywhere.
#[tokio::test]
async fn bearer_admin_is_loopback_only() {
let lan: SocketAddr = "192.168.1.50:54321".parse().unwrap();
let loopback: SocketAddr = "127.0.0.1:33333".parse().unwrap();
let bearer = |peer: SocketAddr| {
let mut req = get_req("/api/v1/stats/recordings"); // a bearer-only (admin) route
req.extensions_mut().insert(PeerAddr(peer));
req.headers_mut().insert(
axum::http::header::AUTHORIZATION,
axum::http::HeaderValue::from_static("Bearer test-secret"),
);
req
};
let app = test_app(test_state(), None);
// A valid bearer from a LAN peer → rejected on the admin API.
assert_eq!(
app.clone()
.oneshot(bearer(lan))
.await
.expect("infallible")
.status(),
StatusCode::UNAUTHORIZED,
"a bearer token from a LAN peer must be rejected on the admin API"
);
// The SAME token from a loopback peer (the web console BFF) → accepted.
assert_ne!(
app.clone()
.oneshot(bearer(loopback))
.await
.expect("infallible")
.status(),
StatusCode::UNAUTHORIZED,
"the bearer token must be accepted from a loopback peer"
);
// A paired cert from a LAN peer still reaches the read-only library (the feature this enables).
let np = Arc::new(
crate::native_pairing::NativePairing::load_with(
Some(
std::env::temp_dir()
.join(format!("pf-mgmt-lanlib-{}.json", std::process::id())),
),
None,
false,
)
.unwrap(),
);
let fp = "deadbeefcafe";
np.add("lan-client", fp).unwrap();
let app = test_app_native(test_state(), np);
let mut req = get_req("/api/v1/library");
req.extensions_mut().insert(PeerAddr(lan));
req.extensions_mut()
.insert(PeerCertFingerprint(Some(fp.to_string())));
assert_ne!(
app.clone().oneshot(req).await.expect("infallible").status(),
StatusCode::UNAUTHORIZED,
"a paired cert must reach the library from a LAN peer"
);
}
#[tokio::test] #[tokio::test]
async fn health_is_open_and_versioned() { async fn health_is_open_and_versioned() {
let app = test_app(test_state(), None); let app = test_app(test_state(), None);
+44 -504
View File
@@ -121,7 +121,8 @@ pub fn run(opts: Punktfunk1Options) -> Result<()> {
// (harmless — the loops' `is_armed()` gate is always false). The unified `serve` shares one // (harmless — the loops' `is_armed()` gate is always false). The unified `serve` shares one
// recorder across mgmt + both streaming paths instead. // recorder across mgmt + both streaming paths instead.
let stats = StatsRecorder::new(crate::stats_recorder::default_dir()); let stats = StatsRecorder::new(crate::stats_recorder::default_dir());
rt.block_on(serve(opts, np, stats)) // Standalone `punktfunk1-host` runs no management API, so advertise no `mgmt` port (0).
rt.block_on(serve(opts, 0, np, stats))
} }
fn fingerprint_hex(fp: &[u8; 32]) -> String { fn fingerprint_hex(fp: &[u8; 32]) -> String {
@@ -139,6 +140,9 @@ pub(crate) struct NativeServe {
/// insecure; `serve --open` turns it off (trusted single-user setups). Pairing is armed on /// insecure; `serve --open` turns it off (trusted single-user setups). Pairing is armed on
/// demand from the web console (arm → PIN); paired devices persist. /// demand from the web console (arm → PIN); paired devices persist.
pub require_pairing: bool, pub require_pairing: bool,
/// The management API's TCP port, advertised over mDNS so a client browses the game library on
/// the same host IP (the unified `serve` always runs the mgmt API, so this is its bind port).
pub mgmt_port: u16,
} }
/// Options for the native host when the unified `serve --native` runs it: real virtual capture, /// Options for the native host when the unified `serve --native` runs it: real virtual capture,
@@ -166,6 +170,7 @@ pub(crate) fn native_serve_opts(cfg: &NativeServe) -> Punktfunk1Options {
pub(crate) async fn serve( pub(crate) async fn serve(
opts: Punktfunk1Options, opts: Punktfunk1Options,
mgmt_port: u16,
np: Arc<NativePairing>, np: Arc<NativePairing>,
stats: Arc<StatsRecorder>, stats: Arc<StatsRecorder>,
) -> Result<()> { ) -> Result<()> {
@@ -198,6 +203,8 @@ pub(crate) async fn serve(
&fingerprint_hex(&fingerprint), &fingerprint_hex(&fingerprint),
opts.require_pairing, opts.require_pairing,
&h.uniqueid, &h.uniqueid,
// 0 = standalone `punktfunk1-host` (no mgmt API) → don't advertise an `mgmt` port.
(mgmt_port != 0).then_some(mgmt_port),
) )
.map_err(|e| tracing::warn!(error = %format!("{e:#}"), "native mDNS advertise failed (continuing)")) .map_err(|e| tracing::warn!(error = %format!("{e:#}"), "native mDNS advertise failed (continuing)"))
.ok(), .ok(),
@@ -755,14 +762,18 @@ async fn serve_session(
// opens a tiny encoder; it runs only when both opt-ins are set and is cached after the first. // opens a tiny encoder; it runs only when both opt-ins are set and is cached after the first.
let host_wants_444 = crate::config::config().four_four_four; let host_wants_444 = crate::config::config().four_four_four;
let client_supports_444 = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_444 != 0; let client_supports_444 = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_444 != 0;
let single_process = crate::session_plan::resolve_topology() // The active capturer must be able to deliver a full-chroma (RGB) source — the honest-downgrade
== crate::session_plan::SessionTopology::SingleProcess; // gate. Linux's portal capturer can; the Windows IDD-push path delivers subsampled NV12/P010
// today (full-chroma IDD-push capture is a follow-up), so it returns false there and the host
// negotiates 4:2:0. (Replaces the old `single_process` gate — single-process is now the only
// topology, and 4:4:4 routed to DDA, which was removed.)
let capture_supports_444 = crate::capture::capturer_supports_444();
// The GPU probe opens a real (tiny) encoder on first use, so run it off the reactor like the // The GPU probe opens a real (tiny) encoder on first use, so run it off the reactor like the
// compositor probe above (blocking probes → spawn_blocking). Short-circuit so it only runs when // compositor probe above (blocking probes → spawn_blocking). Short-circuit so it only runs when
// the cheap gates already pass. The result is cached process-wide (a negative latches until // the cheap gates already pass. The result is cached process-wide (a negative latches until
// restart — acceptable: a GPU either supports HEVC 4:4:4 or it doesn't, and a transient open // restart — acceptable: a GPU either supports HEVC 4:4:4 or it doesn't, and a transient open
// failure here is rare since the session's own encoder isn't open yet). // failure here is rare since the session's own encoder isn't open yet).
let gpu_supports_444 = if host_wants_444 && client_supports_444 && single_process { let gpu_supports_444 = if host_wants_444 && client_supports_444 && capture_supports_444 {
tokio::task::spawn_blocking(|| { tokio::task::spawn_blocking(|| {
crate::encode::can_encode_444(crate::encode::Codec::H265) crate::encode::can_encode_444(crate::encode::Codec::H265)
}) })
@@ -780,7 +791,7 @@ async fn serve_session(
chroma = ?chroma, chroma = ?chroma,
host_wants_444, host_wants_444,
client_supports_444, client_supports_444,
single_process, capture_supports_444,
"encode chroma" "encode chroma"
); );
@@ -2696,7 +2707,7 @@ fn session_watcher_loop(tx: std::sync::mpsc::Sender<SessionSwitch>, stop: Arc<At
} }
} }
/// All per-session inputs for [`virtual_stream`] / [`virtual_stream_relay`], bundled so the session entry /// All per-session inputs for [`virtual_stream`], bundled so the session entry
/// is one moved value instead of a 13-positional-argument `#[allow(too_many_arguments)]` signature /// is one moved value instead of a 13-positional-argument `#[allow(too_many_arguments)]` signature
/// (Goal-1 stage 4, plan §2.4). Everything is **owned** — the receivers move in (`virtual_stream` is their /// (Goal-1 stage 4, plan §2.4). Everything is **owned** — the receivers move in (`virtual_stream` is their
/// only consumer) — so the whole context moves into the stream thread and the borrow plumbing disappears. /// only consumer) — so the whole context moves into the stream thread and the borrow plumbing disappears.
@@ -2744,8 +2755,9 @@ struct SessionContext {
} }
fn virtual_stream(ctx: SessionContext) -> Result<()> { fn virtual_stream(ctx: SessionContext) -> Result<()> {
// This thread runs the capture+encode loop (single-process: Linux / synthetic / NO_WGC DDA) — or // This thread runs the capture+encode loop (single-process — the only topology: Linux portal /
// tail-calls the relay below. Elevate it so a CPU-heavy game can't deschedule our GPU submission. // synthetic, Windows in-process IDD-push). Elevate it so a CPU-heavy game can't deschedule our GPU
// submission.
boost_thread_priority(true); boost_thread_priority(true);
// Resolve the per-session capture / topology / encoder decision ONCE (Goal-1 stage 3): the deployed // Resolve the per-session capture / topology / encoder decision ONCE (Goal-1 stage 3): the deployed
// path now reads this typed `SessionPlan` instead of re-deriving from config at each dispatch site // path now reads this typed `SessionPlan` instead of re-deriving from config at each dispatch site
@@ -2753,14 +2765,6 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
// only per-session input — capture/topology/encoder are otherwise pure functions of `HostConfig`. // only per-session input — capture/topology/encoder are otherwise pure functions of `HostConfig`.
let plan = crate::session_plan::SessionPlan::resolve(ctx.bit_depth, ctx.chroma); let plan = crate::session_plan::SessionPlan::resolve(ctx.bit_depth, ctx.chroma);
tracing::info!(?plan, "resolved session plan"); tracing::info!(?plan, "resolved session plan");
// Windows two-process secure-desktop path: when the host runs as SYSTEM (required for the secure
// desktop + SendInput), WGC can't activate in-process, so we capture the normal desktop via a
// helper spawned in the user session and relay its AUs. (Single-process WGC/DDA is used as the
// user, and stays the path on Linux.) See design/archive/windows-secure-desktop.md.
#[cfg(target_os = "windows")]
if plan.topology == crate::session_plan::SessionTopology::TwoProcessRelay {
return virtual_stream_relay(ctx);
}
// Single-process path: unpack the context into the locals the loop below uses (names unchanged, so the // Single-process path: unpack the context into the locals the loop below uses (names unchanged, so the
// body is byte-for-byte the same; the receivers are now owned but `try_recv()` is identical). // body is byte-for-byte the same; the receivers are now owned but `try_recv()` is identical).
let SessionContext { let SessionContext {
@@ -2795,6 +2799,11 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
// host-lifetime VirtualDisplayManager (§2.5). It does NO monitor work, so it must precede the IDD-push // host-lifetime VirtualDisplayManager (§2.5). It does NO monitor work, so it must precede the IDD-push
// preempt below (which reaches the manager) — otherwise `vdm()` is called before init and panics. // preempt below (which reaches the manager) — otherwise `vdm()` is called before init and panics.
let mut vd = crate::vdisplay::open(compositor)?; let mut vd = crate::vdisplay::open(compositor)?;
// Per-client STABLE monitor identity (Phase 2): hand the backend the connecting client's cert
// fingerprint so a freshly CREATED virtual monitor gets this client's persistent id — Windows then
// reapplies the client's saved per-monitor config (DPI scaling) on reconnect. No-op on Linux backends
// and for anonymous/GameStream clients (no fingerprint → the driver auto-allocates).
vd.set_client_identity(endpoint::peer_fingerprint(&conn));
// IDD-push reconnect preempt (the dance now lives in the manager, Goal-1 §2.5): serialize setup so a // IDD-push reconnect preempt (the dance now lives in the manager, Goal-1 §2.5): serialize setup so a
// reconnect FLOOD can't run concurrent monitor create/teardown, STOP the prior session + WAIT for it // reconnect FLOOD can't run concurrent monitor create/teardown, STOP the prior session + WAIT for it
// to release its monitor (instead of tearing a monitor out from under a still-live session), and // to release its monitor (instead of tearing a monitor out from under a still-live session), and
@@ -2810,20 +2819,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
drop(_idd_setup_guard); drop(_idd_setup_guard);
// Windows single-process DDA path (PUNKTFUNK_NO_WGC=1): the SudoVDA virtual display, isolated as the // Windows: capture is live — launch the requested library title into the
// SOLE active output, goes into fullscreen independent-flip (one plane on one display) which Desktop
// Duplication cannot capture → the born-lost ACCESS_LOST storm we measured on the RTX4090+iGPU box
// (hook verified-firing, DPI=2, yet 100% DuplicateOutput1 E_ACCESSDENIED + born-lost). A tiny topmost
// layered overlay disqualifies independent-flip and forces DWM composition, which DDA CAN capture.
// (Apollo never hits this because it runs WITH a physical monitor attached — multi-display is already
// DWM-composited; we isolate to sole-display, so we must force composition ourselves.) Unlike the WGC
// relay path — where WGC owns the normal desktop and the overlay is secure-only — here DDA owns the
// normal desktop too, so it must run unconditionally. Held for the session; Drop tears it down.
// Best-effort; disable with PUNKTFUNK_FORCE_COMPOSED=0.
#[cfg(target_os = "windows")]
let _composed_flip = crate::capture::composed_flip::ForceComposedFlip::start();
// Windows: capture is live (and composition forced) — launch the requested library title into the
// interactive user session so it renders onto the captured desktop and grabs foreground. Linux // interactive user session so it renders onto the captured desktop and grabs foreground. Linux
// nests its launch in gamescope instead (the handshake `PUNKTFUNK_GAMESCOPE_APP` path). Best-effort: // nests its launch in gamescope instead (the handshake `PUNKTFUNK_GAMESCOPE_APP` path). Best-effort:
// a launch failure (no recipe for the kind, no interactive user) leaves the user on the desktop. // a launch failure (no recipe for the kind, no interactive user) leaves the user on the desktop.
@@ -3295,480 +3291,6 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
Ok(()) Ok(())
} }
/// Windows two-process video stream: the SYSTEM host creates the SudoVDA virtual output (and holds
/// its keepalive = the sole topology/isolation owner), spawns the WGC helper in the user session to
/// capture+encode the NORMAL desktop, and relays the helper's AUs onto the QUIC data plane via the
/// same send thread as the single-process path. A [`DesktopWatcher`](crate::capture::desktop_watch)
/// muxes the source: while the input desktop is Winlogon (UAC / lock / login — which WGC can't
/// capture), the host captures it with its OWN DDA encoder; back on Default it resumes the relay.
/// Every source switch latches a "wait for IDR" so the client's decoder resumes on a keyframe (the
/// two encoders keep independent infinite-GOP state). Reconfigure rebuilds the output + re-spawns the
/// helper at the new mode (and drops the stale-target DDA); keyframe requests forward to the active
/// source.
#[cfg(target_os = "windows")]
fn virtual_stream_relay(ctx: SessionContext) -> Result<()> {
use crate::capture::dxgi::WinCaptureTarget;
use crate::capture::wgc_relay::HelperRelay;
use crate::capture::Capturer; // trait methods (set_active/next_frame) on the concrete DuplCapturer
// Unpack the context (names unchanged so the body is identical). The relay doesn't yet send the
// source's 0xCE HDR metadata — the helper's in-band SEI carries it (a Windows follow-up) — so `conn`
// is held unused.
let SessionContext {
session,
mode,
seconds,
stop,
reconfig,
keyframe,
compositor,
bitrate_kbps,
bit_depth,
// The two-process WGC relay encodes 4:2:0 in v1 — the handshake's `single_process` gate already
// forced `chroma` to Yuv420 for this topology, so the helper + secure-desktop DDA stay 4:2:0.
chroma: _,
probe_rx,
probe_result_tx,
fec_target,
conn: _conn,
stats,
client_label,
launch,
} = ctx;
tracing::info!(
?mode,
bitrate_kbps,
bit_depth,
"punktfunk/1 two-process stream (SYSTEM host + user-session WGC helper)"
);
let mut vd = crate::vdisplay::open(compositor)?;
// Create the SudoVDA output + spawn a helper capturing it by GDI name. Returns the keepalive
// (held for the output's life — the sole isolation owner), the running relay, the capture target
// (so the host can also open DDA on it for the secure desktop), and the achieved refresh.
type Built = (Box<dyn Send>, HelperRelay, WinCaptureTarget, u32);
let build = |vd: &mut Box<dyn crate::vdisplay::VirtualDisplay>,
mode: punktfunk_core::Mode|
-> Result<Built> {
let vout = vd.create(mode).context("create virtual output")?;
let effective_hz = vout
.preferred_mode
.map(|(_, _, hz)| hz)
.filter(|&hz| hz > 0)
.unwrap_or(mode.refresh_hz);
let target = vout.win_capture.clone().ok_or_else(|| {
anyhow!("SudoVDA target not yet an active display (needs a WDDM GPU to activate it)")
})?;
// HDR is driven by the SudoVDA monitor's ACTUAL advanced-color state, not the handshake bit
// depth: the whole pipeline follows the monitor (WGC captures FP16 when HDR is on; NVENC forces
// Main10 + BT.2020 PQ from the 10-bit capture format regardless of the negotiated depth; the
// client auto-detects PQ from the HEVC VUI). So:
// - a negotiated 10-bit session PROACTIVELY enables HDR on the monitor (below), but
// - we must NEVER force HDR *off* here — that would wipe out a user's deliberate Windows HDR
// toggle on the virtual display on every build (the "HDR doesn't persist" bug). Leaving the
// monitor's state alone lets a user-enabled HDR session flow through end-to-end.
// The secure-desktop HDR drop (for the DDA leg) keys off the monitor's real state in the mux loop.
#[cfg(target_os = "windows")]
if bit_depth >= 10 {
// SAFETY: `set_advanced_color` is marked `unsafe` only because it drives the Win32 CCD API
// internally; it takes `target_id` by value (Copy `u32` — this session's live SudoVDA
// monitor's CCD target id) and sizes + owns every buffer it hands the OS on its own stack.
// We pass no pointers, so nothing must outlive the call and there is no aliasing; an
// unknown/absent target id simply returns false.
unsafe {
if crate::win_display::set_advanced_color(target.target_id, true) {
// Let the colorspace change settle before WGC creates its capture item / detects HDR.
std::thread::sleep(std::time::Duration::from_millis(250));
}
}
}
let relay = HelperRelay::spawn(
&target,
(mode.width, mode.height, effective_hz),
bitrate_kbps,
bit_depth,
)
.context("spawn WGC helper")?;
Ok((vout.keepalive, relay, target, effective_hz))
};
let (mut _keepalive, mut relay, mut target, mut effective_hz) = build(&mut vd, mode)?;
let mut cur_mode = mode;
// Capture is live (the WGC helper is relaying) — launch the requested library title into the
// interactive user session so it renders onto the captured desktop and grabs foreground.
// Best-effort: a failure (no recipe for the kind, no interactive user) leaves the user on the desktop.
if let Some(id) = launch.as_deref() {
if let Err(e) = crate::library::launch_title(id) {
tracing::warn!(launch_id = id, error = %e, "could not launch requested library title");
}
}
// O3.1: optionally observe the IDD-push ring alongside WGC (WGC = the presentation trigger) to
// confirm the 0257 driver pushes frames into a HOST-created ring. Diagnostic only; gated.
if std::env::var_os("PUNKTFUNK_IDD_PUSH_OBSERVE").is_some() {
crate::capture::idd_push::spawn_observer(
target.clone(),
Some((cur_mode.width, cur_mode.height, effective_hz)),
);
}
// The host's own DDA capturer+encoder for the SECURE (Winlogon) desktop, which WGC — and thus the
// helper — cannot capture. Opened lazily on the first secure transition (so a session that never
// hits a UAC/lock screen never pays for a second NVENC session), then kept for fast re-switch.
struct DdaPipe {
cap: Box<dyn crate::capture::Capturer>,
enc: Box<dyn crate::encode::Encoder>,
frame: crate::capture::CapturedFrame,
}
// Note: takes the dimensions as args rather than capturing `cur_mode` — `cur_mode` is reassigned
// on reconfig, and a closure holding a shared borrow of it for the whole fn would forbid that.
let open_dda =
|target: &WinCaptureTarget, w: u32, h: u32, hz: u32, hdr: bool| -> Result<DdaPipe> {
// The host already holds the real keepalive (sole isolation owner), so DDA gets a no-op one.
// `hdr` requests an FP16 DuplicateOutput1 so the secure desktop is captured in HDR (→ BT.2020
// PQ Main10) instead of black — legacy DuplicateOutput can't capture an HDR/FP16 desktop.
let mut cap = crate::capture::dxgi::DuplCapturer::open(
target.clone(),
Some((w, h, hz)),
Box::new(()),
// The relay's host encoder is GPU (NVENC/AMF/QSV unless software) — pass `gpu` in (Goal-1
// stage 5) so the DDA capturer doesn't re-derive it.
crate::capture::gpu_encode(),
hdr,
false, // the two-process relay path is 4:2:0 in v1
)
.context("open DDA for secure desktop")?;
cap.set_active(true);
let frame = cap.next_frame().context("DDA first frame")?;
let enc = crate::encode::open_video(
crate::encode::Codec::H265,
frame.format,
frame.width,
frame.height,
hz,
bitrate_kbps as u64 * 1000,
frame.is_cuda(),
bit_depth,
// Secure-desktop DDA on the two-process relay path: 4:2:0 in v1 (matches the helper).
crate::encode::ChromaFormat::Yuv420,
)
.context("open video encoder for DDA")?;
Ok(DdaPipe {
cap: Box::new(cap),
enc,
frame,
})
};
let perf = crate::config::config().perf;
let burst_cap = std::env::var("PUNKTFUNK_PACE_BURST_KB")
.ok()
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(128)
* 1024;
// Same encode|send split as the single-process path: this thread relays AUs, a dedicated send
// thread owns the Session and does FEC+seal+paced-send. The relay encodes in the helper process,
// so this path's FrameMsgs carry no cap/submit/encode split (those stages stay 0 in the sample);
// the send thread still emits fps/goodput/pacing/loss from `session.stats()`.
let send_stats = SendStats {
rec: stats,
width: mode.width,
height: mode.height,
fps: effective_hz,
codec: "hevc",
client: client_label,
bitrate_kbps,
};
let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::<FrameMsg>(3);
let send_thread = std::thread::Builder::new()
.name("punktfunk-send".into())
.spawn({
let stop = stop.clone();
move || {
send_loop(
session,
frame_rx,
probe_rx,
probe_result_tx,
stop,
perf,
burst_cap,
fec_target,
send_stats,
)
}
})
.context("spawn send thread")?;
// Test hook: PUNKTFUNK_SECURE_TEST_PERIOD_MS=N drives a square-wave secure/normal toggle every N ms
// instead of the real watcher — exercises the mid-session helper↔DDA mux without a live UAC/lock.
let secure_test_ms: Option<u128> = std::env::var("PUNKTFUNK_SECURE_TEST_PERIOD_MS")
.ok()
.and_then(|s| s.parse().ok())
.filter(|&n| n > 0);
// Switching to the host DDA on the secure (Winlogon) desktop is OPT-IN: DDA can't reliably capture
// the secure desktop's HDR independent-flip (it storms ACCESS_LOST → black), whereas the WGC helper
// STAYS LIVE through a lock/UAC. So by default the mux keeps WGC the whole time (no DesktopWatcher
// switch, no overlay). Enable the experimental DDA-on-secure path with PUNKTFUNK_SECURE_DDA=1.
let dda_secure = crate::config::config().secure_dda || secure_test_ms.is_some();
// The authoritative Default↔Winlogon signal (requires SYSTEM to read the Winlogon desktop name);
// only needed when the DDA-on-secure path is enabled.
let watcher = dda_secure.then(crate::capture::desktop_watch::DesktopWatcher::start);
// Force-composed-flip overlay (only with DDA-on-secure): keeps the secure desktop out of fullscreen
// independent-flip so DDA can duplicate it. Off by default to avoid touching the normal desktop.
let _composed_flip = dda_secure
.then(crate::capture::composed_flip::ForceComposedFlip::start)
.flatten();
let start = std::time::Instant::now();
let mut interval = std::time::Duration::from_secs_f64(1.0 / effective_hz.max(1) as f64);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(seconds as u64);
let mut sent: u64 = 0;
// Mux state: which source is live, the lazily-opened DDA pipe, a DDA pacing clock, and a
// "wait for the next IDR before forwarding" latch set on every source switch (the client's
// decoder must resume on a keyframe — the two encoders keep independent infinite-GOP state).
let mut dda: Option<DdaPipe> = None;
let mut on_secure = false;
let mut next = std::time::Instant::now();
let mut await_idr = false;
// Step 6 relaunch watchdog: how many times in a row the helper has died without producing a frame.
// A console disconnect/reconnect or a helper crash kills it; we respawn (the new helper picks up
// the now-active session via WTSGetActiveConsoleSessionId). Reset on the first relayed frame; only
// give up (end the stream) after a run of failures spanning a few seconds.
let mut helper_fails = 0u32;
const MAX_HELPER_FAILS: u32 = 20;
// Build a FrameMsg + hand it to the send thread; returns false if the send thread is gone (caller
// breaks the loop). Kept as a macro (not a closure) so each use borrows `frame_tx`/`sent`/`interval`
// at its own site without a long-lived capture, and `break 'outer` stays a literal at the call site
// (a `break 'outer` inside the macro body risks label-hygiene resolution failures).
macro_rules! forward {
($data:expr, $capture_ns:expr, $keyframe:expr) => {{
let flags = if $keyframe {
(FLAG_PIC | FLAG_SOF) as u32
} else {
FLAG_PIC as u32
};
let capture_ns = $capture_ns;
let encode_us = (now_ns().saturating_sub(capture_ns) / 1000) as u32;
let msg = FrameMsg {
data: $data,
capture_ns,
flags,
deadline: std::time::Instant::now() + interval,
encode_us,
cap_us: 0,
submit_us: 0,
wait_us: 0,
repeat: false,
was_measured: false,
};
let ok = frame_tx.send(msg).is_ok();
if ok {
sent += 1;
}
ok
}};
}
'outer: while !stop.load(Ordering::SeqCst) && std::time::Instant::now() < deadline {
// Mode switch: rebuild the output + re-spawn the helper at the new mode (drop the old relay +
// keepalive only after the new pair is up, so a failed rebuild keeps the current stream). The
// DDA pipe (on the old target) is dropped — it reopens on the next secure transition.
let mut want = None;
while let Ok(m) = reconfig.try_recv() {
want = Some(m);
}
if let Some(new_mode) = want {
tracing::info!(?new_mode, "two-process: rebuilding for mode switch");
match build(&mut vd, new_mode) {
Ok((ka, rl, tg, hz)) => {
relay = rl; // drops the old relay (kills old helper) ...
_keepalive = ka; // ... then releases the old output
target = tg;
effective_hz = hz;
cur_mode = new_mode;
dda = None; // old-target DDA is stale; reopen on next secure
interval = std::time::Duration::from_secs_f64(1.0 / hz.max(1) as f64);
}
Err(e) => {
tracing::error!(error = %format!("{e:#}"), ?new_mode,
"two-process mode-switch rebuild failed — staying on the current mode");
}
}
}
// Coalesce client decode-recovery keyframe requests and forward to the active source.
let mut want_kf = false;
while keyframe.try_recv().is_ok() {
want_kf = true;
}
// Source mux: capture the secure (Winlogon) desktop via the host's DDA, the normal desktop via
// the helper relay. On a switch, latch await_idr + force the now-active source to emit an IDR
// so the client resumes cleanly.
let secure = dda_secure
&& match secure_test_ms {
Some(p) => (start.elapsed().as_millis() / p) % 2 == 1,
None => watcher.as_ref().is_some_and(|w| w.is_secure()),
};
if secure != on_secure {
on_secure = secure;
await_idr = true;
tracing::info!(
to = if secure {
"secure(DDA)"
} else {
"normal(WGC relay)"
},
"two-process: source switch"
);
if secure {
// Capture the secure (Winlogon) desktop in its NATIVE colorspace. Don't try to drop the
// SudoVDA out of HDR for the DDA leg — display-config changes are denied on the secure
// desktop (the drop just churned + still went black). Instead, if the monitor is in HDR,
// open DDA in HDR (FP16 DuplicateOutput1 → BT.2020 PQ Main10); the normal-desktop DDA
// overlay/flip issues that drove us to WGC don't apply to the composed Winlogon UI.
// SAFETY: `advanced_color_enabled` is `unsafe` only because it queries the Win32 CCD
// API; it takes `target_id` by value (the live SudoVDA monitor's CCD target id) and
// allocates + owns every buffer it passes the OS internally. No caller pointer is
// involved, so nothing must outlive the call and there is no aliasing; a missing
// target id just yields false.
let hdr = unsafe { crate::win_display::advanced_color_enabled(target.target_id) };
dda = None; // reopen to capture the secure desktop
match open_dda(&target, cur_mode.width, cur_mode.height, effective_hz, hdr) {
Ok(mut p) => {
tracing::info!(hdr, "two-process: opened DDA for the secure desktop");
p.enc.request_keyframe();
dda = Some(p);
}
Err(e) => {
tracing::error!(error = %format!("{e:#}"),
"two-process: DDA open failed — secure desktop will freeze on last frame");
}
}
next = std::time::Instant::now();
} else {
// Returning to the normal desktop: RESUME from the still-alive WGC helper. Do NOT
// recreate the SudoVDA monitor or respawn the helper — build()'s vd.create() is an
// IOCTL_REMOVE+ADD of the monitor (the audible disconnect/connect chime + the
// teardown/recreate kernel stress that broke DDA, now applied to the mux). The monitor +
// helper persist for the WHOLE session; only the host-DDA leg opens (secure) and closes
// (normal). Apply the DDA learning here: reuse, don't tear down.
dda = None; // free the secure DDA encoder; the relay (helper) is the source again
while relay.try_recv().is_ok() {} // drop secure-dwell backlog
relay.request_keyframe(); // client decoder resumes on the helper's next IDR
// Nothing to restore: we no longer toggle the SudoVDA's HDR state for the DDA leg, so the
// monitor's colorspace is unchanged and the still-alive WGC helper just resumes.
next = std::time::Instant::now();
}
}
if want_kf {
if secure {
if let Some(d) = dda.as_mut() {
d.enc.request_keyframe();
}
} else {
relay.request_keyframe();
}
await_idr = true;
}
if secure {
// DDA capture+encode for the secure desktop, paced to the frame interval.
let Some(d) = dda.as_mut() else {
std::thread::sleep(interval);
continue;
};
if let Some(f) = d.cap.try_latest().context("DDA capture")? {
d.frame = f;
}
let capture_ns = now_ns();
d.enc.submit(&d.frame).context("DDA encoder submit")?;
next += interval;
while let Some(au) = d.enc.poll().context("DDA encoder poll")? {
if await_idr && !au.keyframe {
continue;
}
await_idr = false;
if !forward!(au.data, capture_ns, au.keyframe) {
break 'outer; // send thread gone
}
}
match next.checked_duration_since(std::time::Instant::now()) {
Some(dur) => std::thread::sleep(dur),
None => next = std::time::Instant::now(),
}
} else {
// Relay the helper's AUs for the normal desktop. Timeout → keep servicing the loop;
// Disconnected → the helper exited (step 6 adds the relaunch watchdog).
let au = match relay.recv_timeout(std::time::Duration::from_millis(500)) {
Ok(au) => au,
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
if stop.load(Ordering::SeqCst) {
break;
}
tracing::warn!("two-process: no AU from helper within 500ms");
continue;
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
// The helper exited (crash, or a console disconnect killed its session). REBUILD
// the whole output + helper (not just respawn on the old target): an abruptly-killed
// helper leaves the SudoVDA's DXGI output briefly unresolvable ("no DXGI output for
// target N yet"), and a console reconnect needs a fresh output in the new session —
// `build` recreates both. Back off so a hard-failing rebuild (e.g. no active session
// yet) doesn't spin; give up only after a sustained run of failures.
helper_fails += 1;
if helper_fails > MAX_HELPER_FAILS {
tracing::error!(
fails = helper_fails,
"two-process: WGC helper keeps dying — ending stream"
);
break;
}
std::thread::sleep(std::time::Duration::from_millis(500));
match build(&mut vd, cur_mode) {
Ok((ka, rl, tg, hz)) => {
tracing::warn!(
fails = helper_fails,
"two-process: WGC helper exited — rebuilt output + helper"
);
relay = rl;
_keepalive = ka;
target = tg;
effective_hz = hz;
dda = None; // old-target DDA is stale
interval = std::time::Duration::from_secs_f64(1.0 / hz.max(1) as f64);
await_idr = true; // resume on the new helper's opening IDR
}
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), fails = helper_fails,
"two-process: helper rebuild failed — will retry");
}
}
continue;
}
};
if await_idr && !au.keyframe {
continue; // skip stale deltas until the post-switch IDR
}
await_idr = false;
helper_fails = 0; // a frame flowed → the helper is healthy again
// The helper's pts_ns is on this machine's monotonic clock (same `now_ns()` source).
if !forward!(au.data, au.pts_ns, au.keyframe) {
break 'outer; // send thread gone
}
}
}
drop(frame_tx);
let _ = send_thread.join();
drop(watcher);
tracing::info!(sent, "punktfunk/1 two-process stream complete");
Ok(())
}
/// One mode's capture/encode pipeline: (capturer, encoder, first frame, frame interval). /// One mode's capture/encode pipeline: (capturer, encoder, first frame, frame interval).
/// Dropping the capturer tears down the PipeWire stream and the virtual output with it. /// Dropping the capturer tears down the PipeWire stream and the virtual output with it.
type Pipeline = ( type Pipeline = (
@@ -3800,6 +3322,23 @@ fn build_pipeline_with_retry(
// 30-60s to produce its first frame, and a first-connect timeout would tear down the warm // 30-60s to produce its first frame, and a first-connect timeout would tear down the warm
// session (forcing another cold start on reconnect). A genuinely permanent failure still fails // session (forcing another cold start on reconnect). A genuinely permanent failure still fails
// fast via `is_permanent_build_error`; only transient "no frame yet" retries consume the budget. // fast via `is_permanent_build_error`; only transient "no frame yet" retries consume the budget.
// IDD-push only: HOLD one monitor lease across all build attempts. A failed attempt's capturer
// drop releases ITS lease, but this held lease keeps the shared monitor Active (refs >= 1), so the
// next attempt's `vd.create` JOINS it (refcount++) instead of finding it Lingering and tripping the
// IDD-push reconnect PREEMPT (teardown + recreate). That preempt-per-retry was the REMOVE→ADD churn
// that exhausts the IddCx monitor-slot pool and wedges ADD at 0x80070490 — one ADD per cold start
// now, not one per attempt. Non-IDD-push backends (Linux portal, WGC) don't use the refcount manager
// and aren't churn-wedge-prone, so they keep create-per-attempt (a held lease there would allocate a
// second virtual output). Dropped when this fn returns — on success the Pipeline's own lease keeps
// the monitor Active; on failure refs falls to 0 → Lingering → linger-timeout teardown.
let _retry_hold = if matches!(plan.capture, crate::session_plan::CaptureBackend::IddPush) {
Some(
vd.create(mode)
.context("acquire virtual output for the session (retry-hold lease)")?,
)
} else {
None
};
const MAX_ATTEMPTS: u32 = 8; const MAX_ATTEMPTS: u32 = 8;
let mut backoff = std::time::Duration::from_millis(500); let mut backoff = std::time::Duration::from_millis(500);
for attempt in 1..=MAX_ATTEMPTS { for attempt in 1..=MAX_ATTEMPTS {
@@ -4332,6 +3871,7 @@ mod tests {
pairing_pin: None, pairing_pin: None,
paired_store: None, // unused: the shared `np` IS the store handle paired_store: None, // unused: the shared `np` IS the store handle
}, },
0, // no mgmt API in this test → advertise no `mgmt` mDNS port
np_host, np_host,
StatsRecorder::new( StatsRecorder::new(
std::env::temp_dir().join(format!("pf-approval-stats-{}", std::process::id())), std::env::temp_dir().join(format!("pf-approval-stats-{}", std::process::id())),
+9 -42
View File
@@ -26,12 +26,9 @@ pub enum CaptureBackend {
/// Linux: the xdg ScreenCast portal → PipeWire (the only Linux capture path). /// Linux: the xdg ScreenCast portal → PipeWire (the only Linux capture path).
Portal, Portal,
/// Windows: IDD direct-push — frames pulled straight from the pf-vdisplay driver's shared ring /// Windows: IDD direct-push — frames pulled straight from the pf-vdisplay driver's shared ring
/// (in-process, Session 0; no Desktop Duplication, no WGC helper). /// (in-process, Session 0; captures the secure desktop too). The sole Windows capture path —
/// DXGI Desktop Duplication (DDA) and the WGC two-process relay were removed.
IddPush, IddPush,
/// Windows: DXGI Desktop Duplication (`PUNKTFUNK_CAPTURE=dda|dxgi` or `PUNKTFUNK_NO_WGC`).
Dda,
/// Windows: Windows.Graphics.Capture (the composed-desktop default), with a DDA watchdog fallback.
Wgc,
} }
impl CaptureBackend { impl CaptureBackend {
@@ -42,20 +39,10 @@ impl CaptureBackend {
CaptureBackend::Portal CaptureBackend::Portal
} }
/// Windows precedence (identical to the pre-stage-3 `capture_virtual_output` branch order): /// Windows: IDD direct-push is the sole capture path (DDA + the WGC two-process relay were removed).
/// IDD-push wins; else an explicit `dda`/`dxgi` request or `PUNKTFUNK_NO_WGC` selects DDA; else WGC.
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub fn resolve() -> Self { pub fn resolve() -> Self {
let cfg = crate::config::config();
if cfg.idd_push {
CaptureBackend::IddPush CaptureBackend::IddPush
} else if matches!(cfg.capture_backend.as_str(), "dda" | "dxgi")
|| crate::capture::wgc_disabled()
{
CaptureBackend::Dda
} else {
CaptureBackend::Wgc
}
} }
#[cfg(not(any(target_os = "linux", target_os = "windows")))] #[cfg(not(any(target_os = "linux", target_os = "windows")))]
@@ -67,11 +54,9 @@ impl CaptureBackend {
/// How a session is structured across processes. /// How a session is structured across processes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SessionTopology { pub enum SessionTopology {
/// One process captures + encodes (Linux; Windows non-SYSTEM / IDD-push / `NO_WGC`). /// One process captures + encodes. The only topology: Linux (portal) and Windows (in-process
/// IDD-push in Session 0). The SYSTEM-host + user-session WGC relay was removed with DDA/WGC.
SingleProcess, SingleProcess,
/// SYSTEM host + a user-session WGC helper relay (the Windows normal-desktop path under SYSTEM,
/// where in-process WGC can't activate). See `virtual_stream_relay`.
TwoProcessRelay,
} }
/// The resolved encode backend (recorded for logging / stages 45; the per-session encoder open still /// The resolved encode backend (recorded for logging / stages 45; the per-session encoder open still
@@ -103,8 +88,8 @@ pub struct SessionPlan {
pub encoder: EncoderBackend, pub encoder: EncoderBackend,
/// Handshake-negotiated encode bit depth (8, or 10 = HEVC Main10). /// Handshake-negotiated encode bit depth (8, or 10 = HEVC Main10).
pub bit_depth: u8, pub bit_depth: u8,
/// The IDD-push HDR hint (`bit_depth >= 10`) — the want-HDR flag the capturer was passed before. /// The IDD-push HDR hint (`bit_depth >= 10`) — the want-HDR flag handed to the capturer so it
/// Non-IDD-push Windows backends ignore it and auto-detect HDR from the monitor; Linux is 8-bit. /// proactively enables advanced color on the virtual display. Linux is 8-bit (HDR blocked upstream).
pub hdr: bool, pub hdr: bool,
/// Handshake-negotiated chroma subsampling (4:2:0, or full-chroma 4:4:4 when the client + host + /// Handshake-negotiated chroma subsampling (4:2:0, or full-chroma 4:4:4 when the client + host +
/// GPU all support it). Resolved before the Welcome; `Yuv420` on every backend that declined it. /// GPU all support it). Resolved before the Welcome; `Yuv420` on every backend that declined it.
@@ -151,26 +136,8 @@ impl SessionPlan {
} }
} }
/// Process topology. On Windows this is the former `punktfunk1::should_use_helper` logic verbatim; on /// Process topology. Single-process is the only topology now: Linux (portal) and Windows (in-process
/// every other platform the session is always single-process. /// IDD-push in Session 0). The Windows SYSTEM-host + user-session WGC relay was removed with DDA/WGC.
#[cfg(target_os = "windows")]
pub(crate) fn resolve_topology() -> SessionTopology {
let cfg = crate::config::config();
// `NO_HELPER`/`NO_WGC` force single-process; IDD-push captures in-process in Session 0 (no helper);
// otherwise the helper runs when forced or when we're SYSTEM (in-process WGC can't activate there).
let helper = if cfg.no_helper || crate::capture::wgc_disabled() || cfg.idd_push {
false
} else {
cfg.force_helper || crate::capture::wgc_relay::running_as_system()
};
if helper {
SessionTopology::TwoProcessRelay
} else {
SessionTopology::SingleProcess
}
}
#[cfg(not(target_os = "windows"))]
pub(crate) fn resolve_topology() -> SessionTopology { pub(crate) fn resolve_topology() -> SessionTopology {
SessionTopology::SingleProcess SessionTopology::SingleProcess
} }
+9
View File
@@ -58,6 +58,12 @@ pub trait VirtualDisplay: Send {
/// sessions can't stomp each other's launch target. Default: no-op (backends that attach to an /// sessions can't stomp each other's launch target. Default: no-op (backends that attach to an
/// existing session / don't spawn a nested command ignore it; only gamescope's spawn path uses it). /// existing session / don't spawn a nested command ignore it; only gamescope's spawn path uses it).
fn set_launch_command(&mut self, _cmd: Option<String>) {} fn set_launch_command(&mut self, _cmd: Option<String>) {}
/// Set the connecting client's cert fingerprint so the backend can give that client a STABLE virtual
/// monitor identity across reconnects — Windows then reapplies the client's saved per-monitor config
/// (notably DPI scaling). Carried on the backend instance; set once before [`create`](Self::create).
/// Default: no-op — only the Windows pf-vdisplay backend uses it (Linux compositors own their virtual
/// output identity). `None` = anonymous/unpaired/GameStream → the backend's auto (slot-based) identity.
fn set_client_identity(&mut self, _fingerprint: Option<[u8; 32]>) {}
} }
/// Compositors punktfunk knows how to drive (plan §6). /// Compositors punktfunk knows how to drive (plan §6).
@@ -641,6 +647,9 @@ pub fn start_restore_worker() -> std::sync::Arc<()> {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[path = "vdisplay/linux/gamescope.rs"] #[path = "vdisplay/linux/gamescope.rs"]
mod gamescope; mod gamescope;
#[cfg(target_os = "windows")]
#[path = "vdisplay/windows/identity.rs"]
pub(crate) mod identity;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[path = "vdisplay/linux/kwin.rs"] #[path = "vdisplay/linux/kwin.rs"]
mod kwin; mod kwin;
@@ -0,0 +1,172 @@
//! Per-client → stable monitor-id map for pf-vdisplay (Phase 2: per-client display-config persistence).
//!
//! Windows keys per-monitor config — notably DPI **scaling** (`HKCU\Control Panel\Desktop\PerMonitorSettings`)
//! — on the monitor's EDID identity AND its OS device path (whose per-connector discriminator is the IddCx
//! `ConnectorIndex` → target UID). The pf-vdisplay driver seeds BOTH the EDID serial and the `ConnectorIndex`
//! from a single monitor `id`. So for Windows to REAPPLY a given client's saved scaling on reconnect, that
//! client must get the SAME `id` every time. This map assigns each client (keyed by its cert fingerprint) a
//! STABLE id and the host passes it as [`AddRequest::preferred_monitor_id`](pf_driver_proto::control::AddRequest).
//!
//! The id space is bounded to `1..=15` because the driver uses the id as the IddCx `ConnectorIndex`, which
//! must stay `< MaxMonitorsSupported` (16). When more than 15 distinct clients are remembered, the
//! LEAST-RECENTLY-USED entry is evicted and its id reused (that evicted client simply re-establishes its
//! scaling once on its next connect). The map persists to `%ProgramData%\punktfunk\pf-vdisplay-identity.json`
//! so ids — and therefore the client→config association — survive host restarts.
//!
//! Anonymous/TOFU and GameStream sessions have no fingerprint and resolve to id `0` (auto) upstream, never
//! reaching this map — they keep the driver's lowest-free slot behavior unchanged.
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
/// Max stable id. The driver uses the id as the IddCx `ConnectorIndex`, which must stay
/// `< MaxMonitorsSupported` (16) — so ids run `1..=15`.
const MAX_ID: u32 = 15;
#[derive(Serialize, Deserialize, Default)]
struct Store {
/// Monotonic most-recently-used counter (the entry with the highest `seen` is the MRU). Persisted so
/// the LRU ordering survives host restarts.
tick: u64,
entries: Vec<Entry>,
}
#[derive(Serialize, Deserialize)]
struct Entry {
/// Lower-hex client cert fingerprint (the map key).
fp: String,
/// The client's stable monitor id (`1..=15`).
id: u32,
/// MRU stamp (compared against [`Store::tick`]).
seen: u64,
}
/// Persistent fingerprint → stable-id map (see the module docs).
pub(crate) struct MonitorIdentityMap {
path: PathBuf,
store: Store,
}
impl MonitorIdentityMap {
/// Load the persisted map (empty on first run / unreadable / parse failure — a fresh map just
/// re-derives ids, costing a client one scaling re-set the first time).
pub(crate) fn load() -> Self {
let path = crate::gamestream::config_dir().join("pf-vdisplay-identity.json");
let mut store = std::fs::read(&path)
.ok()
.and_then(|b| serde_json::from_slice::<Store>(&b).ok())
.unwrap_or_default();
// SANITIZE a hand-edited / corrupt / cross-version file before trusting it: resolve()'s found-entry
// branch returns the stored id verbatim, so an out-of-range id (0 = the "auto" sentinel, or
// > MAX_ID) or a duplicate id/fp would flow straight into preferred_monitor_id. Drop out-of-range
// ids and dedup by BOTH fp and id (keeping the most-recently-seen on a clash) so no two fingerprints
// can map to the same id. (The driver also rejects a live-colliding id as a backstop.)
store.entries.sort_by_key(|e| std::cmp::Reverse(e.seen));
let mut seen_fp = std::collections::HashSet::new();
let mut seen_id = std::collections::HashSet::new();
store.entries.retain(|e| {
(1..=MAX_ID).contains(&e.id) && seen_fp.insert(e.fp.clone()) && seen_id.insert(e.id)
});
Self { path, store }
}
/// The stable id (`1..=15`) for the client fingerprint `fp`: its remembered id, or a freshly assigned
/// one (lowest free, else LRU-evict at the cap). Bumps the entry to MRU and persists.
pub(crate) fn resolve(&mut self, fp: [u8; 32]) -> u32 {
let key: String = fp.iter().map(|b| format!("{b:02x}")).collect();
self.store.tick = self.store.tick.wrapping_add(1);
let now = self.store.tick;
if let Some(e) = self.store.entries.iter_mut().find(|e| e.fp == key) {
e.seen = now;
let id = e.id;
self.persist();
return id;
}
// New client: prefer the lowest free id in 1..=MAX_ID; if all are taken, evict the LRU entry and
// reuse its id (the evicted client re-establishes its scaling once on its next connect).
let id = (1..=MAX_ID)
.find(|i| !self.store.entries.iter().any(|e| e.id == *i))
.unwrap_or_else(|| {
let lru = self
.store
.entries
.iter()
.enumerate()
.min_by_key(|(_, e)| e.seen)
.map(|(i, _)| i)
.expect("entries are non-empty whenever every id 1..=MAX_ID is taken");
let evicted = self.store.entries.remove(lru);
evicted.id
});
self.store.entries.push(Entry {
fp: key,
id,
seen: now,
});
self.persist();
id
}
/// Persist atomically (temp file + rename). Best-effort: a write failure just means a restart may
/// re-derive an id (one scaling re-set). Not a credential, so a plain (non-ACL'd) write is fine.
fn persist(&self) {
let Ok(bytes) = serde_json::to_vec_pretty(&self.store) else {
return;
};
if let Some(dir) = self.path.parent() {
let _ = std::fs::create_dir_all(dir);
}
let tmp = self.path.with_extension("json.tmp");
if std::fs::write(&tmp, &bytes).is_ok() {
let _ = std::fs::rename(&tmp, &self.path);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fp(n: u8) -> [u8; 32] {
let mut f = [0u8; 32];
f[0] = n;
f
}
#[test]
fn stable_across_calls_and_distinct_per_client() {
let mut m = MonitorIdentityMap {
path: std::env::temp_dir().join(format!("pf-id-test-{}.json", std::process::id())),
store: Store::default(),
};
let a1 = m.resolve(fp(1));
let b = m.resolve(fp(2));
let a2 = m.resolve(fp(1));
assert_eq!(a1, a2, "same client → same id");
assert_ne!(a1, b, "distinct clients → distinct ids");
assert!((1..=MAX_ID).contains(&a1) && (1..=MAX_ID).contains(&b));
let _ = std::fs::remove_file(&m.path);
}
#[test]
fn lru_eviction_reuses_an_id_at_the_cap() {
let mut m = MonitorIdentityMap {
path: std::env::temp_dir().join(format!("pf-id-lru-{}.json", std::process::id())),
store: Store::default(),
};
// Fill all 15 ids (clients 1..=15), then touch client 2 so client 1 is the LRU.
for n in 1..=15u8 {
m.resolve(fp(n));
}
let _ = m.resolve(fp(2));
// A 16th client evicts the LRU (client 1) and reuses its id; ids stay bounded.
let id16 = m.resolve(fp(16));
assert!((1..=MAX_ID).contains(&id16));
assert_eq!(m.store.entries.len(), 15, "cap holds at 15 entries");
assert!(m.store.entries.iter().all(|e| (1..=MAX_ID).contains(&e.id)));
let _ = std::fs::remove_file(&m.path);
}
}
@@ -59,8 +59,9 @@ pub(crate) trait VdisplayDriver: Send + Sync {
/// # Safety /// # Safety
/// Issues setup-API + `DeviceIoControl` calls; runs in the caller's apartment. /// Issues setup-API + `DeviceIoControl` calls; runs in the caller's apartment.
unsafe fn open(&self) -> Result<(OwnedHandle, u32)>; unsafe fn open(&self) -> Result<(OwnedHandle, u32)>;
/// ADD a virtual monitor at `mode`, pinning the IDD render GPU to `render_luid` first if `Some`. /// ADD a virtual monitor at `mode`, pinning the IDD render GPU to `render_luid` first if `Some`, and
/// Returns the REMOVE key + target id + the adapter LUID the driver actually used. /// requesting `preferred_monitor_id` (the host's per-client stable id; `0` = auto). Returns the REMOVE
/// key + target id + the adapter LUID the driver actually used.
/// ///
/// # Safety /// # Safety
/// `dev` must be the live control handle from [`open`](Self::open). /// `dev` must be the live control handle from [`open`](Self::open).
@@ -69,6 +70,7 @@ pub(crate) trait VdisplayDriver: Send + Sync {
dev: HANDLE, dev: HANDLE,
mode: Mode, mode: Mode,
render_luid: Option<LUID>, render_luid: Option<LUID>,
preferred_monitor_id: u32,
) -> Result<AddedMonitor>; ) -> Result<AddedMonitor>;
/// REMOVE the monitor identified by `key`. /// REMOVE the monitor identified by `key`.
/// ///
@@ -134,6 +136,10 @@ pub(crate) struct VirtualDisplayManager {
/// The current IDD-push session's stop flag; a new connection signals the prior one to release its /// The current IDD-push session's stop flag; a new connection signals the prior one to release its
/// monitor before the fresh one is created (was the `IDD_SESSION_STOP` global in `punktfunk1`). /// monitor before the fresh one is created (was the `IDD_SESSION_STOP` global in `punktfunk1`).
idd_session_stop: Mutex<Option<Arc<AtomicBool>>>, idd_session_stop: Mutex<Option<Arc<AtomicBool>>>,
/// Persistent per-client (cert-fingerprint) → stable monitor-id map. A monitor CREATE resolves the
/// connecting client's id here, so the client keeps the same EDID serial + IddCx ConnectorIndex across
/// reconnects and Windows reapplies its saved per-monitor config (DPI scaling). See [`super::identity`].
identity_map: Mutex<super::identity::MonitorIdentityMap>,
} }
static VDM: OnceLock<VirtualDisplayManager> = OnceLock::new(); static VDM: OnceLock<VirtualDisplayManager> = OnceLock::new();
@@ -149,6 +155,7 @@ pub(crate) fn init(driver: Box<dyn VdisplayDriver>) -> &'static VirtualDisplayMa
state: Mutex::new(MgrState::Idle), state: Mutex::new(MgrState::Idle),
setup_lock: Mutex::new(()), setup_lock: Mutex::new(()),
idd_session_stop: Mutex::new(None), idd_session_stop: Mutex::new(None),
identity_map: Mutex::new(super::identity::MonitorIdentityMap::load()),
}) })
} }
@@ -196,30 +203,40 @@ impl VirtualDisplayManager {
} }
/// Acquire the shared monitor for a new session: preempt-recreate under IDD-push, join a live one /// Acquire the shared monitor for a new session: preempt-recreate under IDD-push, join a live one
/// (refcount++), reuse a lingering one, or create one. The returned [`MonitorLease`] releases the /// (refcount++), reuse a lingering one, or create one. `client_fp` (the connecting client's cert
/// refcount on drop. /// fingerprint; `None` = anonymous/GameStream) gives a freshly CREATED monitor a STABLE per-client id
pub(crate) fn acquire(&'static self, mode: Mode) -> Result<VirtualOutput> { /// (so Windows reapplies that client's saved per-monitor config); JOIN and lingering-reuse keep the
/// existing monitor's id. The returned [`MonitorLease`] releases the refcount on drop.
pub(crate) fn acquire(
&'static self,
mode: Mode,
client_fp: Option<[u8; 32]>,
) -> Result<VirtualOutput> {
self.ensure_linger_timer(); self.ensure_linger_timer();
let mut state = self.state.lock().unwrap(); let mut state = self.state.lock().unwrap();
let dev = self.ensure_device()?; let dev = self.ensure_device()?;
// IDD-push: a new connection while a monitor is live is a single-client RECONNECT (the prior // IDD-push: a new connection while a monitor is LINGERING is a single-client RECONNECT (the
// client is gone). A REUSED IddCx swap-chain is DEAD, so joining it hands a black screen — // prior session fully released). A REUSED IddCx swap-chain is DEAD, so reusing it hands a black
// PREEMPT: tear the old monitor down (its key/topology are restored) and create a fresh one. The // screen — PREEMPT: tear the lingering monitor down (its key/topology are restored) and create a
// old session's lease is gen-stamped, so its later drop is a no-op and can't tear down the new one. // fresh one. The old session's lease is gen-stamped, so its later drop is a no-op.
if idd_push_mode() && matches!(*state, MgrState::Active { .. } | MgrState::Lingering { .. }) //
{ // ONLY Lingering, NOT Active: an Active monitor still has a lease held — that's the build-retry
if let MgrState::Active { mon, .. } | MgrState::Lingering { mon, .. } = // path (`build_pipeline_with_retry` holds one lease across all attempts) or a concurrent session,
std::mem::replace(&mut *state, MgrState::Idle) // NOT a reconnect. Preempting Active would tear a live session down AND churn REMOVE→ADD on every
// retry — the per-cold-start monitor churn that exhausts the IddCx slot pool and wedges ADD at
// 0x80070490. Active falls through to the JOIN path below (refcount++, no ADD).
if idd_push_mode() && matches!(*state, MgrState::Lingering { .. }) {
if let MgrState::Lingering { mon, .. } = std::mem::replace(&mut *state, MgrState::Idle)
{ {
tracing::info!( tracing::info!(
old_target = mon.target_id, old_target = mon.target_id,
"IDD-push reconnect — preempting the prior session, recreating a fresh monitor" "IDD-push reconnect — preempting the lingering monitor, recreating a fresh one"
); );
// SAFETY: `teardown` requires `dev` to be the live control handle; `dev` is the value // SAFETY: `teardown` requires `dev` to be the live control handle; `dev` is the value
// `ensure_device()` returned above (the device is cached in the `OnceLock` and never // `ensure_device()` returned above (the device is cached in the `OnceLock` and never
// closed for the manager's lifetime). `mon` was moved out of the prior `Active`/ // closed for the manager's lifetime). `mon` was moved out of the prior `Lingering`
// `Lingering` state by `mem::replace`, so it is exclusively owned here — no aliasing. // state by `mem::replace`, so it is exclusively owned here — no aliasing.
unsafe { self.teardown(dev, mon) }; unsafe { self.teardown(dev, mon) };
// Let the OS finish the ASYNC monitor departure before the next ADD; a back-to-back // Let the OS finish the ASYNC monitor departure before the next ADD; a back-to-back
// REMOVE→ADD races the teardown and the ADD IOCTL is rejected under reconnect churn. // REMOVE→ADD races the teardown and the ADD IOCTL is rejected under reconnect churn.
@@ -264,7 +281,7 @@ impl VirtualDisplayManager {
// SAFETY: `create_monitor` requires `dev` to be the live control handle; `dev` is the // SAFETY: `create_monitor` requires `dev` to be the live control handle; `dev` is the
// handle `ensure_device()` returned above (cached in the `OnceLock`, never closed for the // handle `ensure_device()` returned above (cached in the `OnceLock`, never closed for the
// manager's lifetime), and we hold the `state` lock. // manager's lifetime), and we hold the `state` lock.
MgrState::Idle => unsafe { self.create_monitor(dev, mode)? }, MgrState::Idle => unsafe { self.create_monitor(dev, mode, client_fp)? },
MgrState::Active { .. } => unreachable!("handled above"), MgrState::Active { .. } => unreachable!("handled above"),
}; };
let out = self.output_for(&mon); let out = self.output_for(&mon);
@@ -291,12 +308,26 @@ impl VirtualDisplayManager {
/// ///
/// # Safety /// # Safety
/// `dev` must be the live control handle. /// `dev` must be the live control handle.
unsafe fn create_monitor(&'static self, dev: HANDLE, mode: Mode) -> Result<Monitor> { unsafe fn create_monitor(
&'static self,
dev: HANDLE,
mode: Mode,
client_fp: Option<[u8; 32]>,
) -> Result<Monitor> {
// Resolve the connecting client's STABLE per-client monitor id (so Windows reapplies its saved
// per-monitor config — DPI scaling — on reconnect); `None`/anonymous → 0 = the driver
// auto-allocates the lowest-free id (the original slot-based behavior).
let preferred_id = client_fp
.map(|fp| self.identity_map.lock().unwrap().resolve(fp))
.unwrap_or(0);
// SAFETY: `create_monitor`'s own `# Safety` contract guarantees `dev` is the live control // SAFETY: `create_monitor`'s own `# Safety` contract guarantees `dev` is the live control
// handle; we forward it unchanged to `add_monitor`, whose precondition is exactly that. // handle; we forward it unchanged to `add_monitor`, whose precondition is exactly that.
// `resolve_render_pin()` returns an `Option<LUID>` by value (plain `Copy`), so no borrowed // `resolve_render_pin()` returns an `Option<LUID>` by value (plain `Copy`), so no borrowed
// memory crosses the call. // memory crosses the call.
let added = unsafe { self.driver.add_monitor(dev, mode, resolve_render_pin())? }; let added = unsafe {
self.driver
.add_monitor(dev, mode, resolve_render_pin(), preferred_id)?
};
// Mandatory keepalive: ping inside the watchdog window or the driver tears all displays down. // Mandatory keepalive: ping inside the watchdog window or the driver tears all displays down.
// The pinger reaches the singleton for both the device + the driver — no raw-handle smuggle. // The pinger reaches the singleton for both the device + the driver — no raw-handle smuggle.
@@ -510,25 +541,62 @@ impl VirtualDisplayManager {
let prev = self.idd_session_stop.lock().unwrap().replace(stop); let prev = self.idd_session_stop.lock().unwrap().replace(stop);
if let Some(prev_stop) = prev { if let Some(prev_stop) = prev {
prev_stop.store(true, Ordering::SeqCst); prev_stop.store(true, Ordering::SeqCst);
self.wait_for_monitor_released(Duration::from_secs(3)); if !self.wait_for_monitor_released(Duration::from_secs(3)) {
// TIMEOUT: the prior session is STILL Active (a wedged/slow teardown). `acquire`'s preempt
// is now Lingering-only (so build-retries JOIN the held monitor instead of churning
// REMOVE→ADD), which means the upcoming `_retry_hold` acquire would JOIN this stuck monitor
// and reuse its DEAD IddCx swap-chain → a full-session black screen with no self-heal until
// this session disconnects. Force-preempt it HERE instead. This runs at most ONCE per
// session (we hold `setup_lock`), so — unlike preempting inside `acquire` — it does not
// reintroduce the per-retry churn. The next `acquire` then sees `Idle` and creates a fresh
// monitor; the stale session's gen-stamped lease release is a no-op.
if let Some(dev) = self.device_handle() {
let taken = {
let mut state = self.state.lock().unwrap();
match std::mem::replace(&mut *state, MgrState::Idle) {
MgrState::Active { mon, .. } => Some(mon),
// Raced to Lingering/Idle between the wait and here — restore + nothing stuck.
other => {
*state = other;
None
}
}
};
if let Some(mon) = taken {
tracing::warn!(
old_target = mon.target_id,
"IDD-push setup: force-preempting the stuck-Active prior monitor (its IddCx swap-chain is dead)"
);
// SAFETY: `teardown` requires `dev` to be the live control handle; `dev` is the
// cached process-lifetime `OwnedHandle` from `device_handle()` (the `Some` checked
// above). `mon` was moved out of the `Active` state under the `state` lock, so it is
// exclusively owned here — no aliasing.
unsafe { self.teardown(dev, mon) };
// Let the OS finish the ASYNC departure before the next ADD (mirrors the acquire()
// Lingering-preempt settle).
thread::sleep(Duration::from_millis(400));
}
}
}
} }
guard guard
} }
/// Wait (up to `timeout`) for the active monitor to be RELEASED (the MGR is no longer `Active`). /// Wait (up to `timeout`) for the active monitor to be RELEASED (the MGR is no longer `Active`).
/// Used by the IDD-push reconnect preempt: after signalling the old session to stop, wait here so it /// Used by the IDD-push reconnect preempt: after signalling the old session to stop, wait here so it
/// tears its monitor down cleanly before we acquire a fresh one. /// tears its monitor down cleanly before we acquire a fresh one. Returns `true` if it released, `false`
pub(crate) fn wait_for_monitor_released(&self, timeout: Duration) { /// on timeout (the prior session is still `Active` — the caller force-preempts it).
pub(crate) fn wait_for_monitor_released(&self, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout; let deadline = Instant::now() + timeout;
loop { loop {
if !matches!(*self.state.lock().unwrap(), MgrState::Active { .. }) { if !matches!(*self.state.lock().unwrap(), MgrState::Active { .. }) {
return; return true;
} }
if Instant::now() >= deadline { if Instant::now() >= deadline {
tracing::warn!( tracing::warn!(
"IDD-push preempt: prior session didn't release the monitor within {timeout:?} — proceeding" "IDD-push preempt: prior session didn't release the monitor within {timeout:?} — force-preempting"
); );
return; return false;
} }
thread::sleep(Duration::from_millis(25)); thread::sleep(Duration::from_millis(25));
} }
@@ -75,6 +75,65 @@ unsafe fn ioctl(h: HANDLE, code: u32, input: &[u8], output: &mut [u8]) -> Result
Ok(returned) Ok(returned)
} }
/// Reap the ghost (NOT-present) "punktfunk" virtual-monitor device nodes that `IddCxMonitorDeparture`
/// leaves behind. Each departed monitor leaves a not-present "Generic Monitor (punktfunk)" PDO that keeps
/// pinning an OS VidPN target against the IddCx adapter's fixed monitor-slot budget; once ~16 accumulate,
/// `IOCTL_ADD` wedges at 0x80070490 (`ERROR_NOT_FOUND`) and every session black-screens until a manual
/// reset/reboot. Removing the not-present PDOs frees the slots — the in-process equivalent of
/// `reset-pf-vdisplay.ps1` step 2 (proven on-box). Best-effort + idempotent: only NOT-present nodes
/// (`Status != OK`) are removed, so the LIVE session's monitor (`Status OK`) is never touched; any
/// failure is logged and swallowed. Returns the number removed.
fn reap_ghost_monitors() -> u32 {
// Mirrors reset-pf-vdisplay.ps1 step 2. powershell is always present for the SYSTEM service; the
// matched tokens ('OK', 'punktfunk', the InstanceId) are locale-invariant, so this is safe on a
// non-English box (unlike a .ps1 *file* read in the machine codepage).
const REAP_PS: &str = "$ErrorActionPreference='SilentlyContinue'; \
$g = Get-PnpDevice -Class Monitor | Where-Object { $_.Status -ne 'OK' -and $_.FriendlyName -match 'punktfunk' }; \
$n = 0; foreach ($d in $g) { pnputil /remove-device $d.InstanceId *> $null; if ($LASTEXITCODE -eq 0) { $n++ } }; \
Write-Output $n";
// Resolve powershell by full path — the LocalSystem service's PATH is not guaranteed to include
// System32 — with a bare-name fallback.
let ps = std::env::var("SystemRoot")
.map(|r| format!(r"{r}\System32\WindowsPowerShell\v1.0\powershell.exe"))
.unwrap_or_else(|_| "powershell.exe".to_string());
match std::process::Command::new(&ps)
.args([
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
REAP_PS,
])
.output()
{
Ok(o) => {
let n = String::from_utf8_lossy(&o.stdout)
.trim()
.parse::<u32>()
.unwrap_or(0);
if n > 0 {
tracing::warn!(
reaped = n,
"pf-vdisplay: reaped ghost (not-present) virtual-monitor nodes — IddCx slot-exhaustion prevention"
);
}
n
}
Err(e) => {
tracing::warn!(error = %e, "pf-vdisplay: ghost-monitor reap could not spawn powershell");
0
}
}
}
/// True if `e`'s chain carries the IddCx monitor-slot-exhaustion wedge HRESULT (0x80070490,
/// `ERROR_NOT_FOUND`) — the `IOCTL_ADD` failure that ghost-PDO accumulation produces. The hex code is
/// locale-invariant (the OS message text is not), so we match on it.
fn is_slot_exhaustion_wedge(e: &anyhow::Error) -> bool {
format!("{e:#}").contains("0x80070490")
}
/// Pin the pf-vdisplay IddCx's RENDER GPU to `luid` (the analogue of Apollo's `SetRenderAdapter`). No /// Pin the pf-vdisplay IddCx's RENDER GPU to `luid` (the analogue of Apollo's `SetRenderAdapter`). No
/// output buffer. Issued on the driver handle BEFORE `IOCTL_ADD` to steer which GPU the new target /// output buffer. Issued on the driver handle BEFORE `IOCTL_ADD` to steer which GPU the new target
/// renders on — on a multi-adapter box this stops DXGI from reparenting the virtual output onto a /// renders on — on a multi-adapter box this stops DXGI from reparenting the virtual output onto a
@@ -193,6 +252,12 @@ impl VdisplayDriver for PfVdisplayDriver {
} else { } else {
tracing::warn!("pf-vdisplay IOCTL_CLEAR_ALL failed on startup (continuing)"); tracing::warn!("pf-vdisplay IOCTL_CLEAR_ALL failed on startup (continuing)");
} }
// CLEAR_ALL only departs the driver's own (in-process) monitor list; it can NOT remove the
// OS-side not-present "Generic Monitor (punktfunk)" PDOs that a previous host-run's monitor
// departures left behind. Reap those here so a fresh host start begins with a clean IddCx
// monitor-slot budget — prevents the 0x80070490 slot-exhaustion wedge from carrying across
// restarts (the reason a restart's CLEAR_ALL alone never recovered it before).
reap_ghost_monitors();
Ok(( Ok((
// SAFETY: `device` is the valid handle from `open_device`, still owned here and NOT closed // SAFETY: `device` is the valid handle from `open_device`, still owned here and NOT closed
// on this success path (the error paths above close it and return). `from_raw_handle`'s // on this success path (the error paths above close it and return). `from_raw_handle`'s
@@ -208,6 +273,7 @@ impl VdisplayDriver for PfVdisplayDriver {
dev: HANDLE, dev: HANDLE,
mode: Mode, mode: Mode,
render_luid: Option<LUID>, render_luid: Option<LUID>,
preferred_monitor_id: u32,
) -> Result<AddedMonitor> { ) -> Result<AddedMonitor> {
let session_id = next_session_id(); let session_id = next_session_id();
let add = control::AddRequest { let add = control::AddRequest {
@@ -215,7 +281,7 @@ impl VdisplayDriver for PfVdisplayDriver {
width: mode.width, width: mode.width,
height: mode.height, height: mode.height,
refresh_hz: mode.refresh_hz, refresh_hz: mode.refresh_hz,
_reserved: 0, preferred_monitor_id,
}; };
// SET_RENDER_ADAPTER (opt-in; pf-vdisplay IMPLEMENTS it). Non-fatal on failure: the driver reports // SET_RENDER_ADAPTER (opt-in; pf-vdisplay IMPLEMENTS it). Non-fatal on failure: the driver reports
// its real render LUID in the shared header, so the host binds correctly even if this is ignored. // its real render LUID in the shared header, so the host binds correctly even if this is ignored.
@@ -238,8 +304,42 @@ impl VdisplayDriver for PfVdisplayDriver {
// borrows the local `AddRequest` (alive across this synchronous call) as the input bytes, and // borrows the local `AddRequest` (alive across this synchronous call) as the input bytes, and
// `out` is a stack `[u8; size_of::<AddReply>()]` whose length bounds the kernel's write — both // `out` is a stack `[u8; size_of::<AddReply>()]` whose length bounds the kernel's write — both
// buffers outlive the call. // buffers outlive the call.
unsafe { ioctl(dev, control::IOCTL_ADD, bytemuck::bytes_of(&add), &mut out) } let add_res = unsafe { ioctl(dev, control::IOCTL_ADD, bytemuck::bytes_of(&add), &mut out) };
.with_context(|| { let add_res = match add_res {
Err(e) if is_slot_exhaustion_wedge(&e) => {
// The IddCx monitor-slot pool is exhausted by accumulated ghost (departed-but-not-present)
// virtual-monitor PDOs → ADD failed 0x80070490. Reap the ghosts in-process and retry ONCE
// so the wedge SELF-HEALS instead of hard-failing every session until a manual reset/reboot
// (the long-standing failure mode). pnputil removal is synchronous; a brief settle lets the
// OS recompute the adapter's monitor budget before the retry.
let reaped = reap_ghost_monitors();
tracing::warn!(
reaped,
"pf-vdisplay ADD wedged (0x80070490 ERROR_NOT_FOUND) — reaped ghost monitor nodes, retrying ADD"
);
// pnputil removal is durable (the ghosts are gone permanently), but the OS reclaims the
// IddCx VidPN-target slots via ASYNC PnP teardown that can lag the synchronous pnputil
// return. Retry the ADD a few times (300 ms apart, NO re-reap — the ghosts are already
// removed) to ride out that variable reclaim latency rather than guess one magic settle.
// ~1.5 s worst case, only on the rare wedge path.
let mut res = Err(anyhow::anyhow!("pf-vdisplay ADD retry loop did not run"));
for _ in 0..5 {
std::thread::sleep(std::time::Duration::from_millis(300));
// SAFETY: identical to the first IOCTL_ADD above — `dev` is the live control handle
// (`add_monitor`'s contract), and `bytemuck::bytes_of(&add)` + `&mut out` borrow locals
// that outlive this synchronous call.
res = unsafe {
ioctl(dev, control::IOCTL_ADD, bytemuck::bytes_of(&add), &mut out)
};
if res.is_ok() {
break;
}
}
res
}
other => other,
};
add_res.with_context(|| {
format!( format!(
"pf-vdisplay ADD {}x{}@{}", "pf-vdisplay ADD {}x{}@{}",
mode.width, mode.height, mode.refresh_hz mode.width, mode.height, mode.refresh_hz
@@ -261,6 +361,25 @@ impl VdisplayDriver for PfVdisplayDriver {
reply.target_id, reply.target_id,
luid.LowPart luid.LowPart
); );
// 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
// the id it actually used. A mismatch means this session fell back to an auto id, so Windows won't
// reapply this client's saved per-monitor config (scaling) until it gets its stable id back.
if preferred_monitor_id != 0 {
if reply.resolved_monitor_id == preferred_monitor_id {
tracing::info!(
monitor_id = preferred_monitor_id,
"pf-vdisplay: per-client monitor id honored (stable identity → saved config persists)"
);
} else {
tracing::warn!(
preferred = preferred_monitor_id,
resolved = reply.resolved_monitor_id,
"pf-vdisplay: preferred monitor id NOT honored (live-id collision, or a pre-Phase-2 \
driver) — per-client config persistence degraded to auto identity this session"
);
}
}
if let Some(pin) = render_luid { if let Some(pin) = render_luid {
if luid.LowPart == pin.LowPart && luid.HighPart == pin.HighPart { if luid.LowPart == pin.LowPart && luid.HighPart == pin.HighPart {
tracing::info!("pf-vdisplay ADD render adapter matches the pinned GPU (pin took)"); tracing::info!("pf-vdisplay ADD render adapter matches the pinned GPU (pin took)");
@@ -309,14 +428,19 @@ impl VdisplayDriver for PfVdisplayDriver {
} }
} }
/// The Windows pf-vdisplay virtual-display backend. A marker — the lifecycle lives in the shared /// The Windows pf-vdisplay virtual-display backend. Near-stateless — the lifecycle lives in the shared
/// [`VirtualDisplayManager`](super::manager::VirtualDisplayManager). /// [`VirtualDisplayManager`](super::manager::VirtualDisplayManager); it only carries the connecting
pub struct PfVdisplayDisplay; /// client's fingerprint so the manager can assign a STABLE per-client monitor id (config persistence).
pub struct PfVdisplayDisplay {
/// The connecting client's cert fingerprint (`None` = anonymous/GameStream → the manager's auto id).
/// Set by [`set_client_identity`](VirtualDisplay::set_client_identity) before `create`.
client_fp: Option<[u8; 32]>,
}
impl PfVdisplayDisplay { impl PfVdisplayDisplay {
pub fn new() -> Result<Self> { pub fn new() -> Result<Self> {
super::manager::init(Box::new(PfVdisplayDriver)).open_backend()?; super::manager::init(Box::new(PfVdisplayDriver)).open_backend()?;
Ok(Self) Ok(Self { client_fp: None })
} }
} }
@@ -325,8 +449,12 @@ impl VirtualDisplay for PfVdisplayDisplay {
"pf-vdisplay" "pf-vdisplay"
} }
fn set_client_identity(&mut self, fingerprint: Option<[u8; 32]>) {
self.client_fp = fingerprint;
}
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> { fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
super::manager::vdm().acquire(mode) super::manager::vdm().acquire(mode, self.client_fp)
} }
} }
+3 -2
View File
@@ -226,7 +226,8 @@ fn web_setup(args: &[String]) -> Result<()> {
bail!("web launcher missing: {}", cmd.display()); bail!("web launcher missing: {}", cmd.display());
} }
register_web_task(&cmd)?; register_web_task(&cmd)?;
// 4. firewall: inbound TCP 3000 // 4. firewall: inbound TCP 3000. The console serves HTTPS (HTTP/1.1 over TLS) with the host's
// identity cert. (No UDP/HTTP-3: browsers won't use QUIC against a self-signed/no-SAN cert.)
if !run_quiet( if !run_quiet(
"netsh", "netsh",
&[ &[
@@ -251,7 +252,7 @@ fn web_setup(args: &[String]) -> Result<()> {
std::thread::sleep(std::time::Duration::from_secs(1)); std::thread::sleep(std::time::Duration::from_secs(1));
} }
run_quiet("schtasks", &["/run", "/tn", WEB_TASK]); run_quiet("schtasks", &["/run", "/tn", WEB_TASK]);
println!("web console set up + started (http://<host-ip>:3000)"); println!("web console set up + started (https://<host-ip>:3000)");
Ok(()) Ok(())
} }
@@ -5,9 +5,8 @@
//! activation, and each store's auth/entitlement context resolve — the process must run in the //! activation, and each store's auth/entitlement context resolve — the process must run in the
//! interactive session under the **logged-in user's** token, not SYSTEM and not session 0. //! interactive session under the **logged-in user's** token, not SYSTEM and not session 0.
//! //!
//! This is the same `WTSGetActiveConsoleSessionId → WTSQueryUserToken → DuplicateTokenEx → //! This is the standard `WTSGetActiveConsoleSessionId → WTSQueryUserToken → DuplicateTokenEx →
//! CreateProcessAsUserW(winsta0\\default)` primitive the WGC helper relay uses //! CreateProcessAsUserW(winsta0\\default)` primitive, used for the library launch path
//! ([`crate::capture::wgc_relay`]), factored out for the library launch path
//! ([`crate::library::launch_title`]). //! ([`crate::library::launch_title`]).
//! //!
//! IMPORTANT — use the **user** token (`WTSQueryUserToken`), NOT a session-retargeted SYSTEM token //! IMPORTANT — use the **user** token (`WTSQueryUserToken`), NOT a session-retargeted SYSTEM token
@@ -36,7 +35,7 @@ use windows::Win32::System::Threading::{
/// ///
/// Fire-and-forget: the launched game/launcher outlives this call, so the host does not track the /// Fire-and-forget: the launched game/launcher outlives this call, so the host does not track the
/// child — its handles are closed before returning (the process keeps running). The environment is /// child — its handles are closed before returning (the process keeps running). The environment is
/// the user's block merged with the host's `PUNKTFUNK_*`/`RUST_LOG` (same merge the WGC helper uses), /// the user's block merged with the host's `PUNKTFUNK_*`/`RUST_LOG` (see [`merged_env_block`]),
/// so `host.env` settings propagate. /// so `host.env` settings propagate.
/// ///
/// Requires the host to run as SYSTEM (`WTSQueryUserToken` needs `SE_TCB`). Fails when no interactive /// Requires the host to run as SYSTEM (`WTSQueryUserToken` needs `SE_TCB`). Fails when no interactive
@@ -75,7 +74,7 @@ unsafe fn spawn_inner(cmdline: &str, workdir: Option<&Path>) -> Result<u32> {
// with the host's PUNKTFUNK_*/RUST_LOG vars — same shared helper the WGC helper + service spawns use. // with the host's PUNKTFUNK_*/RUST_LOG vars — same shared helper the WGC helper + service spawns use.
let mut env_block: *mut core::ffi::c_void = std::ptr::null_mut(); let mut env_block: *mut core::ffi::c_void = std::ptr::null_mut();
let _ = CreateEnvironmentBlock(&mut env_block, Some(primary), false); let _ = CreateEnvironmentBlock(&mut env_block, Some(primary), false);
let merged_env = crate::capture::wgc_relay::merged_env_block(env_block as *const u16); let merged_env = merged_env_block(env_block as *const u16);
if !env_block.is_null() { if !env_block.is_null() {
let _ = DestroyEnvironmentBlock(env_block); let _ = DestroyEnvironmentBlock(env_block);
} }
@@ -124,3 +123,48 @@ unsafe fn spawn_inner(cmdline: &str, workdir: Option<&Path>) -> Result<u32> {
let _ = CloseHandle(pi.hThread); let _ = CloseHandle(pi.hThread);
Ok(pid) Ok(pid)
} }
/// Build the environment block for a process launched into the interactive session: the target
/// session's block (`user_block`, from `CreateEnvironmentBlock`) with this process's `PUNKTFUNK_*`
/// vars overlaid, so the child runs with the SAME settings this process has
/// (`PUNKTFUNK_ENCODER=nvenc`, `PUNKTFUNK_ZEROCOPY`, …) instead of the target shell's. Returns a
/// UTF-16, double-null-terminated block suitable for `CREATE_UNICODE_ENVIRONMENT`. Shared by the
/// interactive library launch (here) and the Windows service launching the host into the active
/// session ([`crate::service`]).
///
/// # Safety
/// `user_block` must be either null or a valid pointer to a UTF-16, double-null-terminated
/// environment block (the `CreateEnvironmentBlock` output), readable for its whole length.
pub(crate) unsafe fn merged_env_block(user_block: *const u16) -> Vec<u16> {
// Parse the user block ("VAR=VALUE\0" … "\0") into entries.
let mut entries: Vec<String> = Vec::new();
if !user_block.is_null() {
let mut p = user_block;
loop {
let mut len = 0isize;
while *p.offset(len) != 0 {
len += 1;
}
if len == 0 {
break; // the trailing empty string = end of block
}
let slice = std::slice::from_raw_parts(p, len as usize);
entries.push(String::from_utf16_lossy(slice));
p = p.offset(len + 1);
}
}
// Overlay "our" settings — PUNKTFUNK_* and RUST_LOG — dropping whatever the target block had.
let is_ours = |k: &str| k.starts_with("PUNKTFUNK_") || k == "RUST_LOG";
entries.retain(|e| !is_ours(e.split('=').next().unwrap_or("")));
for (k, v) in std::env::vars().filter(|(k, _)| is_ours(k)) {
entries.push(format!("{k}={v}"));
}
// Serialize back to a UTF-16 double-null-terminated block.
let mut block: Vec<u16> = Vec::new();
for e in entries {
block.extend(e.encode_utf16());
block.push(0);
}
block.push(0);
block
}
+9 -8
View File
@@ -3,12 +3,12 @@
//! for the ad-hoc PsExec / VBS / scheduled-task launch chain used during bring-up. //! for the ad-hoc PsExec / VBS / scheduled-task launch chain used during bring-up.
//! //!
//! Why a supervisor and not just "run the host as a service": the host must run **as SYSTEM in the //! Why a supervisor and not just "run the host as a service": the host must run **as SYSTEM in the
//! interactive session** (session 1+). Desktop Duplication of the secure (Winlogon/UAC/lock) desktop //! interactive session** (session 1+). Capturing the secure (Winlogon/UAC/lock) desktop and
//! and `SendInput` both need SYSTEM; capture and injection both need the *interactive* session, which //! `SendInput` both need SYSTEM; capture and injection both need the *interactive* session, which
//! a plain session-0 service is not in. So this service (itself in session 0) never captures — it //! a plain session-0 service is not in. So this service (itself in session 0) never captures — it
//! duplicates its own LocalSystem token, retargets it to the active console session, and //! duplicates its own LocalSystem token, retargets it to the active console session, and
//! `CreateProcessAsUserW`s the host there. This is the Sunshine/Apollo model. The host in turn spawns //! `CreateProcessAsUserW`s the host there. This is the Sunshine/Apollo model. The host captures the
//! the WGC helper into the *user* session (see `capture::wgc_relay`) — two nested launches. //! virtual display in-process via IDD direct-push (no helper process).
//! //!
//! Subcommands (Windows only): //! Subcommands (Windows only):
//! ```text //! ```text
@@ -230,8 +230,9 @@ fn run_service() -> Result<()> {
let _ = SESSION_EVENT.set(session_owned); let _ = SESSION_EVENT.set(session_owned);
// The control handler captures nothing — it reaches the events through the statics, so it stays // The control handler captures nothing — it reaches the events through the statics, so it stays
// `Fn + Send + 'static`. Session lock/unlock are handled inside the host (DesktopWatcher), so we // `Fn + Send + 'static`. Lock/unlock is handled by the in-process IDD-push capture (the driver
// only flag console connect/disconnect/logon — the events that change the active session. // composes the secure desktop into the ring), so we only flag console connect/disconnect/logon —
// the events that change the active session.
let handler = move |control| -> ServiceControlHandlerResult { let handler = move |control| -> ServiceControlHandlerResult {
match control { match control {
ServiceControl::Stop | ServiceControl::Preshutdown | ServiceControl::Shutdown => { ServiceControl::Stop | ServiceControl::Preshutdown | ServiceControl::Shutdown => {
@@ -517,10 +518,10 @@ unsafe fn spawn_host(
.context("SetTokenInformation(TokenSessionId)")?; .context("SetTokenInformation(TokenSessionId)")?;
// 2) The session's environment block, merged with this process's PUNKTFUNK_*/RUST_LOG (so the // 2) The session's environment block, merged with this process's PUNKTFUNK_*/RUST_LOG (so the
// host runs with host.env's settings, not a bare block). Same merge the WGC helper uses. // host runs with host.env's settings, not a bare block). Same merge the interactive launch uses.
let mut env_block: *mut c_void = std::ptr::null_mut(); let mut env_block: *mut c_void = std::ptr::null_mut();
let _ = CreateEnvironmentBlock(&mut env_block, Some(primary), false); let _ = CreateEnvironmentBlock(&mut env_block, Some(primary), false);
let merged = crate::capture::wgc_relay::merged_env_block(env_block as *const u16); let merged = crate::interactive::merged_env_block(env_block as *const u16);
if !env_block.is_null() { if !env_block.is_null() {
let _ = DestroyEnvironmentBlock(env_block); let _ = DestroyEnvironmentBlock(env_block);
} }
@@ -1,346 +0,0 @@
//! USER-session WGC helper (Windows) — part of the two-process secure-desktop design
//! (design/archive/windows-secure-desktop.md).
//!
//! WGC won't activate under the SYSTEM account, but the host must run as SYSTEM for the secure
//! desktop. So the SYSTEM host spawns THIS helper in the interactive user session
//! (`CreateProcessAsUserW`) to do the WGC capture + NVENC encode that needs the user token, and the
//! helper ships the encoded Annex-B access units back over its **stdout** pipe (which the host
//! inherits + reads). The host relays them on the live QUIC session while the normal desktop is up,
//! and switches to its own DDA encoder on the secure desktop. The helper captures the SAME SudoVDA
//! output **by GDI name only** — it never creates a virtual output / touches display topology (a
//! second topology owner would re-trigger the ACCESS_LOST born-lost storm).
//!
//! Wire framing on stdout, per AU: `[u32 len LE][u64 pts_ns LE][u8 keyframe][len bytes data]`.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use crate::capture::{dxgi::WinCaptureTarget, wgc::WgcCapturer, Capturer};
use crate::encode::{self, Codec};
use anyhow::{Context, Result};
use std::io::{Read, Write};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
pub struct HelperOptions {
pub target_id: u32,
pub gdi_name: String,
pub width: u32,
pub height: u32,
pub fps: u32,
pub bitrate_kbps: u32,
/// Negotiated encode bit depth (8, or 10 = HEVC Main10). HDR auto-upgrades to 10 from the
/// captured frame's `Rgb10a2` format regardless.
pub bit_depth: u8,
}
/// AU framing magic + version, so the host can resync / detect a helper crash on its stdout stream.
const AU_MAGIC: u32 = 0x5046_4155; // "PFAU"
/// Control byte the host writes on our stdin to force the next frame to be an IDR. Must match
/// `wgc_relay::CTL_KEYFRAME`.
const CTL_KEYFRAME: u8 = 0x01;
pub fn run(opts: HelperOptions) -> Result<()> {
tracing::info!(
target_id = opts.target_id,
gdi = %opts.gdi_name,
mode = format!("{}x{}@{}", opts.width, opts.height, opts.fps),
"WGC helper starting (user session)"
);
// This thread does WGC capture + video-processor convert + NVENC submit — the GPU-submitting hot
// path. Elevate its OS priority so a CPU-heavy game can't deschedule it and delay submission (which
// would leave our HIGH GPU priority with nothing queued to prioritise). Apollo's capture thread is
// likewise CRITICAL.
crate::punktfunk1::boost_thread_priority(true);
// Capture the EXISTING SudoVDA output by GDI name / target id — do NOT create one (the host owns
// the virtual output + its isolate/restore; a second topology owner breaks DDA recovery).
let target = WinCaptureTarget {
adapter_luid: 0,
gdi_name: opts.gdi_name.clone(),
target_id: opts.target_id,
};
let mut cap =
WgcCapturer::open(target, Some((opts.width, opts.height, opts.fps))).context("WGC open")?;
cap.set_active(true);
// O3 present-trigger experiment: spawn a thread that PRESENTS a D3D swapchain to the virtual
// display (a present SOURCE), testing whether that — unlike WGC's READ — makes the OS assign the
// driver's IddCx swap-chain (so the driver's run_core runs + can push). Gated; diagnostic.
if std::env::var_os("PUNKTFUNK_PRESENT_TRIGGER").is_some() {
let (w, h) = (opts.width, opts.height);
std::thread::Builder::new()
.name("pf-present-trigger".into())
.spawn(move || {
tracing::info!("present-trigger: starting D3D present loop on the virtual display");
// SAFETY: `present_trigger` is unsafe only for its Win32/D3D11 FFI; it has no caller
// preconditions (it creates and exclusively owns its own window, device, and swapchain on
// this dedicated thread), so the call is sound.
if let Err(e) = unsafe { present_trigger(w, h) } {
tracing::warn!("present-trigger error: {e:#}");
}
})
.ok();
}
// First frame establishes the real dimensions + whether the desktop is HDR (the encoder derives
// Main10/HDR from the frame's PixelFormat::Rgb10a2). Then open NVENC on the capture device.
let first = cap.next_frame().context("first WGC frame")?;
let (w, h) = (first.width, first.height);
let mut enc = encode::open_video(
Codec::H265,
first.format,
w,
h,
opts.fps,
opts.bitrate_kbps as u64 * 1000,
false, // not cuda
opts.bit_depth, // 8, or 10 = Main10 (HDR auto-upgrades from the Rgb10a2 frame regardless)
// The two-process WGC relay helper encodes 4:2:0 in v1 (4:4:4 over the relay is a follow-up);
// the host gates 4:4:4 to the single-process topology.
encode::ChromaFormat::Yuv420,
)
.context("open NVENC")?;
// Control channel: the host writes a single byte on our stdin to force an IDR (client decode
// recovery), mirroring `enc.request_keyframe()` in the single-process path. A reader thread sets
// a flag the encode loop checks; stdin EOF (host gone) just stops the thread.
let kf = Arc::new(AtomicBool::new(false));
{
let kf = kf.clone();
std::thread::Builder::new()
.name("wgc-helper-ctl".into())
.spawn(move || {
let mut stdin = std::io::stdin();
let mut byte = [0u8; 1];
while let Ok(n) = stdin.read(&mut byte) {
if n == 0 {
break; // host closed our stdin
}
if byte[0] == CTL_KEYFRAME {
kf.store(true, Ordering::Relaxed);
}
}
})
.ok();
}
// Binary stdout — lock it once + write framed AUs. A short write / broken pipe means the host
// (parent) went away → exit cleanly so the host's relaunch watchdog can respawn us.
let stdout = std::io::stdout();
let mut out = stdout.lock();
// FIXED-CADENCE encode loop (mirrors the single-process `punktfunk1::virtual_stream` loop). The
// host runs as SYSTEM and relays our AUs; to deliver a STEADY `fps` to the client (the "fixed 240"
// goal) we must NOT gate on WGC's content-driven FrameArrived — `WgcCapturer::next_frame` blocks up
// to its ~8 ms static-repeat timeout when the desktop is quiet, capping a barely-changing desktop
// ~125 fps regardless of the GPU. Instead we pace to `1/fps` and take the FRESHEST frame with the
// non-blocking `try_latest`, repeating the last one when nothing newer arrived. Depth-1: NVENC's
// `poll` (lock_bitstream) blocks until the just-submitted frame is encoded, so exactly one frame is
// in flight per iteration. A deeper pipeline was measured to only stack latency under a
// GPU-saturating game (the encodes serialize on the contended GPU anyway) — the in-game lever is
// the GPU scheduling priority the SYSTEM host stamps on us, not pipeline depth.
let interval = std::time::Duration::from_secs_f64(1.0 / opts.fps.max(1) as f64);
let perf = crate::config::config().perf;
let mut frames = 0u64;
let mut repeats = 0u64; // frames where no newer capture had arrived (duplicate re-encode)
let mut cap_ns = 0u64; // time in try_latest (capture + video-processor convert)
let mut encode_ns = 0u64; // time blocked in lock_bitstream
let mut write_ns = 0u64; // time writing the AU to the stdout pipe (relay backpressure)
let mut window = std::time::Instant::now();
// `frame` is held across iterations and repeated when `try_latest` has nothing newer, so a static
// desktop still clocks `fps`. The capturer's held-set / output ring keep its texture alive across
// the repeat; reassigning `frame` on a fresh capture drops the prior one (already drained by poll).
let mut frame = first;
let mut next = std::time::Instant::now();
loop {
if kf.swap(false, Ordering::Relaxed) {
enc.request_keyframe();
}
// Freshest captured frame, or repeat the last (no new composition: static desktop / between a
// game's presents). Non-blocking, so the cadence is OURS, not WGC's event rate.
let t0 = std::time::Instant::now();
match cap.try_latest().context("WGC try_latest")? {
Some(f) => frame = f,
None => repeats += 1,
}
if perf {
cap_ns += t0.elapsed().as_nanos() as u64;
}
enc.submit(&frame).context("encoder submit")?;
// Drain the just-submitted frame. NVENC's poll blocks in lock_bitstream until it's encoded, so
// this returns exactly one AU (then None) — depth-1, no accumulation.
loop {
let p0 = std::time::Instant::now();
let polled = enc.poll().context("encoder poll")?;
if perf {
encode_ns += p0.elapsed().as_nanos() as u64;
}
let Some(au) = polled else { break };
let w0 = std::time::Instant::now();
let wrote = write_au(&mut out, &au);
if perf {
write_ns += w0.elapsed().as_nanos() as u64;
}
if wrote.is_err() {
tracing::info!("WGC helper: stdout closed (host gone) — exiting");
return Ok(());
}
}
// Pace to this frame's due time. If we're already past it (encode couldn't keep up under a
// GPU-saturating game), skip the sleep and re-baseline so we don't spiral into catch-up.
next += interval;
match next.checked_duration_since(std::time::Instant::now()) {
Some(d) => std::thread::sleep(d),
None => next = std::time::Instant::now(),
}
if perf {
frames += 1;
let since = window.elapsed();
if since.as_secs() >= 2 {
let secs = since.as_secs_f64();
let per = |ns: u64| format!("{:.2}", ns as f64 / frames as f64 / 1e6);
tracing::info!(
fps = format!("{:.1}", frames as f64 / secs),
repeats,
cap_ms = per(cap_ns),
encode_ms = per(encode_ns),
write_ms = per(write_ns),
"WGC helper perf (fixed-cadence depth-1; encode_ms=lock_bitstream; repeats=duplicated frames)"
);
frames = 0;
repeats = 0;
cap_ns = 0;
encode_ns = 0;
write_ns = 0;
window = std::time::Instant::now();
}
}
}
}
fn write_au(out: &mut impl Write, au: &encode::EncodedFrame) -> std::io::Result<()> {
out.write_all(&AU_MAGIC.to_le_bytes())?;
out.write_all(&(au.data.len() as u32).to_le_bytes())?;
out.write_all(&au.pts_ns.to_le_bytes())?;
out.write_all(&[au.keyframe as u8])?;
out.write_all(&au.data)?;
out.flush()
}
/// O3 present-trigger experiment (see the gated call in `run`). Creates a small swapchain-backed
/// window on the virtual display (the CCD-isolated primary) and presents continuously — an active
/// present SOURCE on the display — to test whether that makes the OS assign the driver's IddCx
/// swap-chain (which WGC's read does not). Runs forever on its own thread.
///
/// # Safety
/// Win32/D3D11 FFI; called once on a dedicated helper thread.
unsafe fn present_trigger(disp_w: u32, disp_h: u32) -> Result<()> {
use windows::core::{w, Interface};
use windows::Win32::Foundation::{HMODULE, HWND, LPARAM, LRESULT, WPARAM};
use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_HARDWARE;
use windows::Win32::Graphics::Direct3D11::{
D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11RenderTargetView,
ID3D11Texture2D, D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_SDK_VERSION,
};
use windows::Win32::Graphics::Dxgi::Common::{DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_SAMPLE_DESC};
use windows::Win32::Graphics::Dxgi::{
IDXGIAdapter, IDXGIDevice, IDXGIFactory2, DXGI_PRESENT, DXGI_SWAP_CHAIN_DESC1,
DXGI_SWAP_EFFECT_FLIP_DISCARD, DXGI_USAGE_RENDER_TARGET_OUTPUT,
};
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
use windows::Win32::UI::WindowsAndMessaging::{
CreateWindowExW, DefWindowProcW, DispatchMessageW, PeekMessageW, RegisterClassW,
ShowWindow, MSG, PM_REMOVE, SW_SHOWNOACTIVATE, WNDCLASSW, WS_EX_NOACTIVATE, WS_EX_TOPMOST,
WS_POPUP, WS_VISIBLE,
};
unsafe extern "system" fn wndproc(h: HWND, m: u32, wp: WPARAM, lp: LPARAM) -> LRESULT {
DefWindowProcW(h, m, wp, lp)
}
let hinst: HMODULE = GetModuleHandleW(None)?;
let cls = w!("pfPresentTrigger");
let wc = WNDCLASSW {
lpfnWndProc: Some(wndproc),
hInstance: hinst.into(),
lpszClassName: cls,
..Default::default()
};
RegisterClassW(&wc);
// Small window at the top-left of the (primary = virtual) display so it barely obscures the
// captured desktop; topmost + no-activate so it doesn't steal focus.
let win_w = disp_w.min(96) as i32;
let win_h = disp_h.min(96) as i32;
let hwnd: HWND = CreateWindowExW(
WS_EX_TOPMOST | WS_EX_NOACTIVATE,
cls,
w!("pf-present"),
WS_POPUP | WS_VISIBLE,
0,
0,
win_w,
win_h,
None,
None,
Some(hinst.into()),
None,
)?;
let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE);
let mut device: Option<ID3D11Device> = None;
let mut context: Option<ID3D11DeviceContext> = None;
D3D11CreateDevice(
None,
D3D_DRIVER_TYPE_HARDWARE,
HMODULE::default(),
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
None,
D3D11_SDK_VERSION,
Some(&mut device),
None,
Some(&mut context),
)?;
let device = device.context("present-trigger d3d11 device")?;
let context = context.context("present-trigger d3d11 context")?;
let dxgi_dev: IDXGIDevice = device.cast()?;
let adapter: IDXGIAdapter = dxgi_dev.GetAdapter()?;
let factory: IDXGIFactory2 = adapter.GetParent()?;
let scd = DXGI_SWAP_CHAIN_DESC1 {
Width: win_w as u32,
Height: win_h as u32,
Format: DXGI_FORMAT_B8G8R8A8_UNORM,
SampleDesc: DXGI_SAMPLE_DESC {
Count: 1,
Quality: 0,
},
BufferUsage: DXGI_USAGE_RENDER_TARGET_OUTPUT,
BufferCount: 2,
SwapEffect: DXGI_SWAP_EFFECT_FLIP_DISCARD,
..Default::default()
};
let swapchain = factory.CreateSwapChainForHwnd(&device, hwnd, &scd, None, None)?;
tracing::info!("present-trigger: swapchain created on the virtual display; presenting");
let mut frame = 0u32;
loop {
let mut msg = MSG::default();
while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() {
let _ = DispatchMessageW(&msg);
}
let back: ID3D11Texture2D = swapchain.GetBuffer(0)?;
let mut rtv: Option<ID3D11RenderTargetView> = None;
device.CreateRenderTargetView(&back, None, Some(&mut rtv))?;
let rtv = rtv.context("present-trigger rtv")?;
let c = (frame % 120) as f32 / 120.0;
context.ClearRenderTargetView(&rtv, &[c, 0.1, 0.2, 1.0]);
let _ = swapchain.Present(1, DXGI_PRESENT(0));
frame = frame.wrapping_add(1);
}
}
+15
View File
@@ -92,6 +92,21 @@ systemctl --user enable --now punktfunk-web
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p' journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
``` ```
### Console login password
The console is password-protected. On first start `punktfunk-web-init` generates a random login
password and saves it to `~/.config/punktfunk/web-password` (as `PUNKTFUNK_UI_PASSWORD=…`). Read it
back at any time — from the init service's journal, or straight from the file:
```sh
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
sed -n 's/^PUNKTFUNK_UI_PASSWORD=//p' ~/.config/punktfunk/web-password
```
To set your own password, edit that file (`PUNKTFUNK_UI_PASSWORD=<your-password>`) and restart the
console: `systemctl --user restart punktfunk-web`. Forgot it? This is the recovery path linked from
the console login screen — see [Forgot your Password?](/docs/forgot-password).
## Good to know ## Good to know
- **gamescope 3.16.22 or newer is required.** Older versions can deadlock during capture. Bazzite's - **gamescope 3.16.22 or newer is required.** Older versions can deadlock during capture. Bazzite's
+24
View File
@@ -111,6 +111,30 @@ journalctl --user -u punktfunk-host -f # watch a client connect
The host now listens on `9777` (native punktfunk/1) + the GameStream ports, and advertises over The host now listens on `9777` (native punktfunk/1) + the GameStream ports, and advertises over
mDNS. It requires **PIN pairing** by default (secure on a LAN); pair once from your client. mDNS. It requires **PIN pairing** by default (secure on a LAN); pair once from your client.
### Web console
The console (status, paired devices, arm pairing) ships as `punktfunk-web` — enable it, then open
`http://<host-ip>:3000`:
```sh
systemctl --user enable --now punktfunk-web
```
#### Console login password
The console is password-protected. On first start `punktfunk-web-init` generates a random login
password and saves it to `~/.config/punktfunk/web-password` (as `PUNKTFUNK_UI_PASSWORD=…`). Read it
back at any time — from the init service's journal, or straight from the file:
```sh
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
sed -n 's/^PUNKTFUNK_UI_PASSWORD=//p' ~/.config/punktfunk/web-password
```
To set your own password, edit that file (`PUNKTFUNK_UI_PASSWORD=<your-password>`) and restart the
console: `systemctl --user restart punktfunk-web`. Forgot it? This is the recovery path linked from
the console login screen — see [Forgot your Password?](/docs/forgot-password).
## 4. Connect a client ## 4. Connect a client
From any [client](/docs/clients) — `punktfunk-client --discover` finds the host on the LAN. On From any [client](/docs/clients) — `punktfunk-client --discover` finds the host on the LAN. On
+60
View File
@@ -0,0 +1,60 @@
---
title: Forgot your Password?
description: Where the punktfunk web console login password lives — and how to read or reset it — on each host platform.
---
The punktfunk **web console** (status, paired devices, PIN pairing) is protected by a login
password. That password is generated — or, on Windows, chosen — when the console is first set up, and
it lives on the **host**. So if you can't get past the login screen, you recover or change it on the
host machine itself, not from the browser.
> This is **only** the web console login. It is **not** your client/device pairing — if a client
> won't connect, that's [Pairing](/docs/pairing), not this password.
## Find your host
Jump to your host platform for exactly where the password lives and how to read or reset it:
| Host | Where the password lives | Section |
|------|--------------------------|---------|
| **Ubuntu — GNOME** | `~/.config/punktfunk/web-password` | [Console login password](/docs/ubuntu-gnome#console-login-password) |
| **Ubuntu — KDE Plasma** | `~/.config/punktfunk/web-password` | [Console login password](/docs/ubuntu-kde#console-login-password) |
| **Fedora — KDE Plasma** | `~/.config/punktfunk/web-password` | [Console login password](/docs/fedora-kde#console-login-password) |
| **Bazzite — gamescope** | `~/.config/punktfunk/web-password` | [Console login password](/docs/bazzite#console-login-password) |
| **SteamOS (host)** | `~/.config/punktfunk/web.env` | [Console login password](/docs/steamos-host#console-login-password) |
| **Windows host** | `%ProgramData%\punktfunk\web-password` | [Console login password](/docs/windows-host#console-login-password) |
## The short version
**Linux packages (apt / RPM / Bazzite).** The password is generated on first start and saved to
`~/.config/punktfunk/web-password`. Read it back:
```sh
# from the init service's journal (printed once, when it was generated):
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
# …or straight from the file:
sed -n 's/^PUNKTFUNK_UI_PASSWORD=//p' ~/.config/punktfunk/web-password
```
Change it by editing that file (`PUNKTFUNK_UI_PASSWORD=<your-password>`) and restarting the console:
`systemctl --user restart punktfunk-web`.
**SteamOS / Steam Deck.** Same idea, but the installer writes it to `~/.config/punktfunk/web.env`
and prints it at the end of the install run:
```sh
sed -n 's/^PUNKTFUNK_UI_PASSWORD=//p' ~/.config/punktfunk/web.env
```
Edit that file and `systemctl --user restart punktfunk-web` to change it.
**Windows.** You pick the password during install (a secure random default is pre-filled and shown
on the installer's final page). It lives in `%ProgramData%\punktfunk\web-password`. To change it,
edit the file and restart the **PunktfunkWeb** task — in an **elevated** PowerShell:
```powershell
notepad "$env:ProgramData\punktfunk\web-password" # set PUNKTFUNK_UI_PASSWORD=<your-password>
schtasks /End /TN PunktfunkWeb; schtasks /Run /TN PunktfunkWeb
```
Still stuck? See [Troubleshooting](/docs/troubleshooting).
+2
View File
@@ -23,7 +23,9 @@
"---Configuration---", "---Configuration---",
"configuration", "configuration",
"host-cli", "host-cli",
"---Troubleshooting---",
"troubleshooting", "troubleshooting",
"forgot-password",
"---Project---", "---Project---",
"roadmap", "roadmap",
"channels", "channels",
+14
View File
@@ -91,6 +91,20 @@ By default the host **requires PIN pairing** (secure). Two ways to pair:
On a trusted home LAN you can instead install with `--open` and skip pairing entirely. On a trusted home LAN you can instead install with `--open` and skip pairing entirely.
### Console login password
The installer generates a random console login password and writes it to
`~/.config/punktfunk/web.env` (as `PUNKTFUNK_UI_PASSWORD=…`); it's also printed at the end of the
install run (step 2). Read it back with:
```sh
sed -n 's/^PUNKTFUNK_UI_PASSWORD=//p' ~/.config/punktfunk/web.env
```
To set your own password, edit that file and restart the console:
`systemctl --user restart punktfunk-web`. Forgot it? This is the recovery path linked from the
console login screen — see [Forgot your Password?](/docs/forgot-password).
## 4. Verify ## 4. Verify
```sh ```sh
+15
View File
@@ -107,6 +107,21 @@ systemctl --user enable --now punktfunk-web
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p' journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
``` ```
#### Console login password
The console is password-protected. On first start `punktfunk-web-init` generates a random login
password and saves it to `~/.config/punktfunk/web-password` (as `PUNKTFUNK_UI_PASSWORD=…`). Read it
back at any time — from the init service's journal, or straight from the file:
```sh
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
sed -n 's/^PUNKTFUNK_UI_PASSWORD=//p' ~/.config/punktfunk/web-password
```
To set your own password, edit that file (`PUNKTFUNK_UI_PASSWORD=<your-password>`) and restart the
console: `systemctl --user restart punktfunk-web`. Forgot it? This is the recovery path linked from
the console login screen — see [Forgot your Password?](/docs/forgot-password).
To run the host automatically at boot — including on a **headless** machine with no monitor — see To run the host automatically at boot — including on a **headless** machine with no monitor — see
[Running as a Service](/docs/running-as-a-service). [Running as a Service](/docs/running-as-a-service).
+15
View File
@@ -80,6 +80,21 @@ systemctl --user enable --now punktfunk-web
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p' journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
``` ```
#### Console login password
The console is password-protected. On first start `punktfunk-web-init` generates a random login
password and saves it to `~/.config/punktfunk/web-password` (as `PUNKTFUNK_UI_PASSWORD=…`). Read it
back at any time — from the init service's journal, or straight from the file:
```sh
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
sed -n 's/^PUNKTFUNK_UI_PASSWORD=//p' ~/.config/punktfunk/web-password
```
To set your own password, edit that file (`PUNKTFUNK_UI_PASSWORD=<your-password>`) and restart the
console: `systemctl --user restart punktfunk-web`. Forgot it? This is the recovery path linked from
the console login screen — see [Forgot your Password?](/docs/forgot-password).
To run it at boot — including fully **headless**, with KWin brought up automatically and no login — To run it at boot — including fully **headless**, with KWin brought up automatically and no login —
see [Running as a Service](/docs/running-as-a-service); the headless appliance is built around KDE. see [Running as a Service](/docs/running-as-a-service); the headless appliance is built around KDE.
+31 -15
View File
@@ -1,11 +1,11 @@
--- ---
title: "Windows Host" 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 10/11 PC** 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
on the fly, so you need **no second monitor and no dummy HDMI plug**, and capture keeps working even on on the fly, so you need **no second monitor and no dummy HDMI plug**, and capture keeps working even on
the secure desktop (UAC prompts, the lock screen). the secure desktop (UAC prompts, the lock screen).
@@ -32,7 +32,7 @@ the secure desktop (UAC prompts, the lock screen).
## Install ## Install
Download the signed `punktfunk-host-setup-<ver>.exe` from the Download the signed `punktfunk-host-setup-<ver>.exe` from the
[package registry](https://git.unom.io/unom/-/packages) and run it. The installer: [latest release](https://git.unom.io/unom/punktfunk/releases) and run it. The installer:
- drops the host into `C:\Program Files\punktfunk` and registers + starts the **`PunktfunkHost`** - drops the host into `C:\Program Files\punktfunk` and registers + starts the **`PunktfunkHost`**
service, service,
@@ -51,10 +51,24 @@ Packaging internals live in
### Web console & pairing ### Web console & pairing
The installer also sets up the **web management console** (status, paired devices, the PIN pairing The installer also sets up the **web management console** (status, paired devices, the PIN pairing
flow): it bundles the console plus its own runtime and runs it as the **`PunktfunkWeb`** service on flow): it bundles the console plus its own runtime and runs it as the **`PunktfunkWeb`** task on
**`http://<this-PC>:3000`**, starting at boot. During setup you choose the console **login password** **`http://<this-PC>:3000`**, starting at boot.
(pre-filled with a secure random default and shown again on the final page); change it later in
`%ProgramData%\punktfunk\web-password`. #### Console login password
During setup you choose the console **login password** — it's pre-filled with a secure random default
and shown again on the installer's final page. It's stored in `%ProgramData%\punktfunk\web-password`
(as `PUNKTFUNK_UI_PASSWORD=…`), readable only by Administrators and SYSTEM.
To change it, edit that file and restart the console task. In an **elevated** PowerShell:
```powershell
notepad "$env:ProgramData\punktfunk\web-password" # set PUNKTFUNK_UI_PASSWORD=<your-password>
schtasks /End /TN PunktfunkWeb; schtasks /Run /TN PunktfunkWeb
```
Forgot it? This is the recovery path linked from the console login screen — see
[Forgot your Password?](/docs/forgot-password).
The host **requires PIN pairing** by default (secure on a LAN). To connect the first time, open the The host **requires PIN pairing** by default (secure on a LAN). To connect the first time, open the
console from any browser on the LAN, log in, go to **Devices → arm pairing**, and enter the PIN on console from any browser on the LAN, log in, go to **Devices → arm pairing**, and enter the PIN on
@@ -84,14 +98,14 @@ Sunshine and Apollo use. Service registration, firewall rules, and the superviso
### 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,
the C ABI), the QUIC control plane, the GameStream wire logic, the management API, and the per-frame the C ABI), the QUIC control plane, the GameStream wire logic, the management API, and the per-frame
pipeline orchestration are all shared with the Linux host. The Windows host is a set of pipeline orchestration are all shared with the Linux host. The Windows host is a set of
`#[cfg(windows)]` backends behind the same traits the Linux host uses: `#[cfg(windows)]` backends behind the same traits the Linux host uses:
| Subsystem | Linux backend | Windows backend | | Subsystem | Linux backend | Windows backend |
|---|---|---| |---|---|---|
| **Capture** | xdg ScreenCast portal → PipeWire (dmabuf) | **Windows.Graphics.Capture** + **Desktop Duplication** (secure desktop), with a zero-copy path straight from the virtual-display driver; FP16/10-bit when the desktop is HDR | | **Capture** | xdg ScreenCast portal → PipeWire (dmabuf) | **IDD direct-push** — the `pf-vdisplay` driver copies finished frames into a host-owned shared GPU texture ring that the host consumes in-process (no Desktop Duplication, no Windows.Graphics.Capture); FP16/10-bit when the desktop is HDR |
| **Virtual display** | KWin / Mutter / Sway / gamescope | **pf-vdisplay** signed IDD — create a `WxH@Hz` monitor per session, capture it, tear it down | | **Virtual display** | KWin / Mutter / Sway / gamescope | **pf-vdisplay** signed IDD — create a `WxH@Hz` monitor per session, capture it, tear it down |
| **Encode** | NVENC (CUDA) / VAAPI (AMD·Intel) / software | **NVENC** (NVIDIA) · **AMF** (AMD) · **QSV** (Intel) · software H.264; HEVC Main10 / BT.2020 PQ for HDR | | **Encode** | NVENC (CUDA) / VAAPI (AMD·Intel) / software | **NVENC** (NVIDIA) · **AMF** (AMD) · **QSV** (Intel) · software H.264; HEVC Main10 / BT.2020 PQ for HDR |
| **Input — mouse/keyboard** | libei / wlr protocols | **SendInput** (Win32 VK + absolute mouse) | | **Input — mouse/keyboard** | libei / wlr protocols | **SendInput** (Win32 VK + absolute mouse) |
@@ -99,11 +113,13 @@ pipeline orchestration are all shared with the Linux host. The Windows host is a
| **Audio capture** | PipeWire sink-monitor | **WASAPI loopback** | | **Audio capture** | PipeWire sink-monitor | **WASAPI loopback** |
| **Virtual mic** | PipeWire `Audio/Source` | WASAPI virtual mic | | **Virtual mic** | PipeWire `Audio/Source` | WASAPI virtual mic |
The virtual display uses **pf-vdisplay**, punktfunk's own all-Rust **Indirect Display Driver (IDD)** The virtual display is **pf-vdisplay**, Punktfunk's own all-Rust **Indirect Display Driver (IDD)**. The
the host pushes finished frames straight into it, so you get a real virtual display with no physical host creates a shared GPU texture ring and the driver pushes finished frames straight into it — a real
monitor or dummy plug. The installer bundles and stages the (self-signed) driver; if it isn't virtual display at the client's exact `WxH@Hz`, with no physical monitor and no dummy plug, captured
installed, the host falls back to capturing an existing monitor, losing the per-client native-resolution in-process from Session 0 so the secure desktop streams too. There is **no** Desktop Duplication or
output. Windows.Graphics.Capture path: IDD direct-push is the only capture path. The signed driver is bundled
and staged by the installer and is **required** — without it the host can't create a session (there is
no monitor-capture fallback).
### HDR ### HDR
+2 -2
View File
@@ -96,11 +96,11 @@ systemctl --user enable --now punktfunk-host
# Management web console (pairing + status) — pulled in by default (the host RPM Recommends it; # Management web console (pairing + status) — pulled in by default (the host RPM Recommends it;
# `--no-install-recommends` / headless-only boxes can skip it). Enable it and read the login password: # `--no-install-recommends` / headless-only boxes can skip it). Enable it and read the login password:
systemctl --user enable --now punktfunk-web systemctl --user enable --now punktfunk-web
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p' # then open http://<host-ip>:3000 journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p' # then open https://<host-ip>:3000
``` ```
Pair a stock Moonlight client (mDNS-discovered), or connect the native punktfunk/1 client — via the Pair a stock Moonlight client (mDNS-discovered), or connect the native punktfunk/1 client — via the
web console at `http://<host-ip>:3000` or directly. web console at `https://<host-ip>:3000` or directly.
> ⚠️ **COPR caveat:** COPR's mock chroot has no `bun`, so a COPR build produces only > ⚠️ **COPR caveat:** COPR's mock chroot has no `bun`, so a COPR build produces only
> `punktfunk` + `punktfunk-client` — **not** `punktfunk-web`. For the console on a COPR/bootc host, > `punktfunk` + `punktfunk-client` — **not** `punktfunk-web`. For the console on a COPR/bootc host,
+12 -9
View File
@@ -30,7 +30,7 @@ license=('MIT OR Apache-2.0')
makedepends=('rust' 'cargo' 'clang' 'cmake' 'nasm' 'pkgconf' 'git' makedepends=('rust' 'cargo' 'clang' 'cmake' 'nasm' 'pkgconf' 'git'
'gtk4' 'libadwaita' 'sdl3' 'ffmpeg' 'pipewire' 'wayland' 'libxkbcommon' 'opus' 'libei') 'gtk4' 'libadwaita' 'sdl3' 'ffmpeg' 'pipewire' 'wayland' 'libxkbcommon' 'opus' 'libei')
# Opt-in punktfunk-web: only then is bun (build tool; the console runs on plain nodejs) required. # Opt-in punktfunk-web: only then is bun (the build tool AND the vendored runtime) required.
if [ "${PF_WITH_WEB:-0}" = 1 ]; then if [ "${PF_WITH_WEB:-0}" = 1 ]; then
pkgname+=('punktfunk-web') pkgname+=('punktfunk-web')
makedepends+=('bun') # `bun-bin` from the AUR if bun isn't in your configured repos makedepends+=('bun') # `bun-bin` from the AUR if bun isn't in your configured repos
@@ -51,7 +51,8 @@ build() {
# 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
# Management web console (opt-in): the node-server .output bundle (built with bun, run with node). # Management web console (opt-in): the Nitro `bun`-preset .output bundle (Bun.serve TLS),
# built AND run with bun.
if [ "${PF_WITH_WEB:-0}" = 1 ]; then if [ "${PF_WITH_WEB:-0}" = 1 ]; then
( cd web && bun install --frozen-lockfile && bun run build ) ( cd web && bun install --frozen-lockfile && bun run build )
fi fi
@@ -138,19 +139,21 @@ package_punktfunk-client() {
} }
package_punktfunk-web() { package_punktfunk-web() {
pkgdesc="punktfunk management web console (Nitro/Node SSR) — pairing + status in the browser" pkgdesc="punktfunk management web console (Nitro SSR on bun, HTTPS/HTTP-1.1 over TLS) — pairing + status in the browser"
arch=('any') # bun is the runtime (Bun.serve), and it's a native binary we vendor, so this package is
# Runtime is plain node (the .output is portable JS — bun was only the build tool). Auto-wired to # arch-specific (not 'any'). Auto-wired to the host's mgmt token + identity cert via the systemd
# the host's mgmt token via the systemd --user units; enable with `systemctl --user enable --now punktfunk-web`. # --user units; enable with `systemctl --user enable --now punktfunk-web`. No nodejs/bun dependency.
depends=('nodejs')
local R; R="$(_repo)" local R; R="$(_repo)"
# Pre-built node-server bundle (from build()) + a PATH-stable launcher (matches the .deb/.rpm). # Pre-built bun-preset bundle (from build()) + a PATH-stable launcher (matches the .deb/.rpm).
install -d "$pkgdir/usr/share/punktfunk-web/.output" install -d "$pkgdir/usr/share/punktfunk-web/.output"
cp -r "$R/web/.output/server" "$pkgdir/usr/share/punktfunk-web/.output/server" cp -r "$R/web/.output/server" "$pkgdir/usr/share/punktfunk-web/.output/server"
cp -r "$R/web/.output/public" "$pkgdir/usr/share/punktfunk-web/.output/public" cp -r "$R/web/.output/public" "$pkgdir/usr/share/punktfunk-web/.output/public"
# Vendor the build env's bun into a private dir so it never collides with a
# system-wide bun on PATH.
install -Dm0755 "$(command -v bun)" "$pkgdir/usr/lib/punktfunk-web/bun"
install -d "$pkgdir/usr/bin" install -d "$pkgdir/usr/bin"
printf '%s\n' '#!/bin/sh' 'exec /usr/bin/node /usr/share/punktfunk-web/.output/server/index.mjs "$@"' \ printf '%s\n' '#!/bin/sh' 'exec /usr/lib/punktfunk-web/bun /usr/share/punktfunk-web/.output/server/index.mjs "$@"' \
> "$pkgdir/usr/bin/punktfunk-web-server" > "$pkgdir/usr/bin/punktfunk-web-server"
chmod 0755 "$pkgdir/usr/bin/punktfunk-web-server" chmod 0755 "$pkgdir/usr/bin/punktfunk-web-server"
# systemd USER units: the console runs per-user; web-init generates the login password on first start. # systemd USER units: the console runs per-user; web-init generates the login password on first start.
+4 -3
View File
@@ -14,8 +14,9 @@ scripts. On a **Steam Deck used as a client you want `punktfunk-client`** (it's
A third member, **`punktfunk-web`** (the browser management console — pairing + status), is A third member, **`punktfunk-web`** (the browser management console — pairing + status), is
**opt-in**: build it by setting `PF_WITH_WEB=1`, which requires **`bun`** at build time (`bun-bin` **opt-in**: build it by setting `PF_WITH_WEB=1`, which requires **`bun`** at build time (`bun-bin`
from the AUR if it isn't in your repos; the console then runs on plain `nodejs`). A default from the AUR if it isn't in your repos). bun is also the **runtime** — the console serves HTTPS
`makepkg` builds only host+client with no JS tooling — mirroring the RPM spec's `%bcond_with web`. (HTTP/1.1 over TLS) via `Bun.serve`, so the package vendors the bun binary (no `nodejs` dependency). A
default `makepkg` builds only host+client with no JS tooling — mirroring the RPM spec's `%bcond_with web`.
> **Host encode: NVENC on NVIDIA, VAAPI on AMD/Intel** (`PUNKTFUNK_ENCODER=auto` picks one). The host > **Host encode: NVENC on NVIDIA, VAAPI on AMD/Intel** (`PUNKTFUNK_ENCODER=auto` picks one). The host
> now has a VAAPI encoder + zero-copy dmabuf path alongside NVENC/CUDA, so `punktfunk-host` works on > now has a VAAPI encoder + zero-copy dmabuf path alongside NVENC/CUDA, so `punktfunk-host` works on
@@ -41,7 +42,7 @@ cp /usr/share/punktfunk/host.env.bazzite ~/.config/punktfunk/host.env # gamesc
systemctl --user enable --now punktfunk-host systemctl --user enable --now punktfunk-host
# Web console (if you installed the punktfunk-web package): enable it + read the login password. # Web console (if you installed the punktfunk-web package): enable it + read the login password.
systemctl --user enable --now punktfunk-web systemctl --user enable --now punktfunk-web
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p' # open http://<host-ip>:3000 journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p' # open https://<host-ip>:3000
``` ```
NVENC/EGL come from the NVIDIA driver: `sudo pacman -S --needed nvidia-utils`. Arch's stock NVENC/EGL come from the NVIDIA driver: `sudo pacman -S --needed nvidia-utils`. Arch's stock
`ffmpeg` already has NVENC built in — no RPM-Fusion-style swap needed (unlike Fedora). `ffmpeg` already has NVENC built in — no RPM-Fusion-style swap needed (unlike Fedora).
+1 -1
View File
@@ -223,7 +223,7 @@ systemctl --user enable --now punktfunk-host
# Management web console (pairing + status), if you installed punktfunk-web (it ships in the Gitea # Management web console (pairing + status), if you installed punktfunk-web (it ships in the Gitea
# RPM registry / bootc image — COPR can't build it; see ../rpm/README.md). Read the login password: # RPM registry / bootc image — COPR can't build it; see ../rpm/README.md). Read the login password:
systemctl --user enable --now punktfunk-web systemctl --user enable --now punktfunk-web
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p' # then open http://<host-ip>:3000 journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p' # then open https://<host-ip>:3000
``` ```
Check health and logs: Check health and logs:
+5 -4
View File
@@ -36,10 +36,11 @@ parallelism with `CARGO_BUILD_JOBS` in the spec's `%build`.
## The web console subpackage (`punktfunk-web`) ## The web console subpackage (`punktfunk-web`)
The spec can also build the management web console as a noarch `punktfunk-web` subpackage, but it's The spec can also build the management web console as a `punktfunk-web` subpackage, but it's
gated behind `%bcond_with web` and **OFF by default** — building the Nitro/Node SSR bundle needs gated behind `%bcond_with web` and **OFF by default** — building (and now *running*) the Nitro
`bun`, which COPR's mock chroot does not provide. So a stock COPR build produces only `punktfunk` console needs `bun`, which COPR's mock chroot does not provide. The package vendors the build env's
+ `punktfunk-client`. bun binary (the console serves HTTPS — HTTP/1.1 over TLS — via `Bun.serve`), so it is arch-specific, not noarch.
A stock COPR build produces only `punktfunk` + `punktfunk-client`.
Two ways to get the console: Two ways to get the console:
- **Recommended:** install it from the Gitea RPM registry (`packaging/rpm/README.md`, Option A), - **Recommended:** install it from the Gitea RPM registry (`packaging/rpm/README.md`, Option A),
+1 -1
View File
@@ -45,7 +45,7 @@ sudo usermod -aG input "$USER" # virtual gamepads (re-login to take eff
mkdir -p ~/.config/punktfunk mkdir -p ~/.config/punktfunk
cp /usr/share/punktfunk-host/host.env.example ~/.config/punktfunk/host.env # then edit cp /usr/share/punktfunk-host/host.env.example ~/.config/punktfunk/host.env # then edit
systemctl --user enable --now punktfunk-host systemctl --user enable --now punktfunk-host
# Web console — enable it and read the auto-generated login password (then open http://<host-ip>:3000): # Web console — enable it and read the auto-generated login password (then open https://<host-ip>:3000):
systemctl --user enable --now punktfunk-web systemctl --user enable --now punktfunk-web
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p' journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
``` ```
+50 -18
View File
@@ -1,13 +1,15 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Build the punktfunk-web .deb — the management web console (Nitro/Node SSR + React). # Build the punktfunk-web .deb — the management web console (Nitro SSR on bun + React).
# #
# Architecture: all — the .output is pre-built JS (no compiled binary, so NO dpkg-shlibdeps). # Runtime is BUN: the console is built with Nitro's `bun` preset + a custom Bun.serve entry that
# Runtime is apt-native: Depends on nodejs (>= 20). The host's punktfunk-host .deb Recommends this, # serves HTTPS (HTTP/1.1 over TLS) with the host's identity cert (web/nitro-entry/bun-https.mjs). Bun
# so a default `apt install punktfunk-host` pulls the console too. It is auto-wired to the host's # isn't in apt, so we VENDOR a bun binary into the package — which makes the
# mgmt token via the systemd --user units (no env editing on a packaged install). # package per-arch (amd64/arm64), NOT `all`. The host's punktfunk-host .deb Recommends this, so a
# default `apt install punktfunk-host` pulls the console too; it is auto-wired to the host's mgmt
# token + identity cert via the systemd --user units (no env editing on a packaged install).
# #
# Usage: VERSION=0.0.1~ci42.gdeadbee bash packaging/debian/build-web-deb.sh # Usage: VERSION=0.0.1~ci42.gdeadbee [DEB_ARCH=amd64] [BUN_BIN=/path/to/bun] bash packaging/debian/build-web-deb.sh
# Output: dist/punktfunk-web_<version>_all.deb # Output: dist/punktfunk-web_<version>_<arch>.deb
set -euo pipefail set -euo pipefail
VERSION="${VERSION:?set VERSION (e.g. 0.0.1 or 0.0.1~ci42.gdeadbee)}" VERSION="${VERSION:?set VERSION (e.g. 0.0.1 or 0.0.1~ci42.gdeadbee)}"
@@ -15,14 +17,23 @@ PKG="punktfunk-web"
ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)" ROOTDIR="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$ROOTDIR" cd "$ROOTDIR"
# Per-arch: vendor bun for the target Debian arch. Map deb arch → bun's release arch tag.
DEB_ARCH="${DEB_ARCH:-$(dpkg --print-architecture)}"
BUN_VERSION="${BUN_VERSION:-1.3.14}" # pinned bun build vendored into the package
case "$DEB_ARCH" in
amd64) BUN_ARCH=x64 ;;
arm64) BUN_ARCH=aarch64 ;;
*) echo "ERROR: unsupported DEB_ARCH=$DEB_ARCH (want amd64 or arm64)" >&2; exit 1 ;;
esac
# Build the console if not already built (.output is gitignored — CI builds it each run). # Build the console if not already built (.output is gitignored — CI builds it each run).
if [ ! -f web/.output/server/index.mjs ]; then if [ ! -f web/.output/server/index.mjs ]; then
echo "==> building web console" echo "==> building web console"
(cd web && bun install --frozen-lockfile && bun run build) (cd web && bun install --frozen-lockfile && bun run build)
fi fi
# The build MUST be the node-server preset (runnable by apt-native node) — never bun. # The build MUST be the bun preset (our Bun.serve TLS entry) — node can't run Bun.serve.
if grep -rq 'Bun\.serve' web/.output/server/index.mjs 2>/dev/null; then if ! grep -rq 'Bun\.serve' web/.output/server/index.mjs 2>/dev/null; then
echo "ERROR: web/.output contains Bun.serve — wrong nitro preset (need 'node-server')" >&2 echo "ERROR: web/.output has no Bun.serve — wrong nitro preset (need 'bun' + the custom entry)" >&2
exit 1 exit 1
fi fi
@@ -30,6 +41,24 @@ STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT trap 'rm -rf "$STAGE"' EXIT
SHAREDIR="$STAGE/usr/share/$PKG" SHAREDIR="$STAGE/usr/share/$PKG"
DOCDIR="$STAGE/usr/share/doc/$PKG" DOCDIR="$STAGE/usr/share/doc/$PKG"
LIBDIR="$STAGE/usr/lib/$PKG"
# --- vendor the bun runtime --------------------------------------------------
# Honor a pre-fetched bun (CI may cache it) via BUN_BIN; else download the pinned release.
mkdir -p "$LIBDIR"
if [ -n "${BUN_BIN:-}" ]; then
echo "==> vendoring bun from BUN_BIN=$BUN_BIN"
install -m0755 "$BUN_BIN" "$LIBDIR/bun"
else
url="https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/bun-linux-${BUN_ARCH}.zip"
echo "==> downloading bun $BUN_VERSION ($BUN_ARCH) from $url"
tmp="$(mktemp -d)"
curl -fsSL "$url" -o "$tmp/bun.zip"
unzip -q "$tmp/bun.zip" -d "$tmp"
install -m0755 "$tmp/bun-linux-${BUN_ARCH}/bun" "$LIBDIR/bun"
rm -rf "$tmp"
fi
"$LIBDIR/bun" --version
# --- file layout ------------------------------------------------------------- # --- file layout -------------------------------------------------------------
mkdir -p "$SHAREDIR/.output" mkdir -p "$SHAREDIR/.output"
@@ -39,7 +68,9 @@ cp -r web/.output/public "$SHAREDIR/.output/public"
install -d "$STAGE/usr/bin" install -d "$STAGE/usr/bin"
cat > "$STAGE/usr/bin/punktfunk-web-server" <<'WRAP' cat > "$STAGE/usr/bin/punktfunk-web-server" <<'WRAP'
#!/bin/sh #!/bin/sh
exec /usr/bin/node /usr/share/punktfunk-web/.output/server/index.mjs "$@" # The console runs on the vendored bun (Bun.serve TLS); bun lives privately under
# /usr/lib/punktfunk-web so it never collides with a system-wide bun on PATH.
exec /usr/lib/punktfunk-web/bun /usr/share/punktfunk-web/.output/server/index.mjs "$@"
WRAP WRAP
chmod 0755 "$STAGE/usr/bin/punktfunk-web-server" chmod 0755 "$STAGE/usr/bin/punktfunk-web-server"
install -Dm0644 scripts/punktfunk-web.service "$STAGE/usr/lib/systemd/user/punktfunk-web.service" install -Dm0644 scripts/punktfunk-web.service "$STAGE/usr/lib/systemd/user/punktfunk-web.service"
@@ -71,18 +102,19 @@ install -d "$STAGE/DEBIAN"
cat > "$STAGE/DEBIAN/control" <<EOF cat > "$STAGE/DEBIAN/control" <<EOF
Package: $PKG Package: $PKG
Version: $VERSION Version: $VERSION
Architecture: all Architecture: $DEB_ARCH
Maintainer: unom <noreply@anthropic.com> Maintainer: unom <noreply@anthropic.com>
Installed-Size: $INSTALLED_KB Installed-Size: $INSTALLED_KB
Section: net Section: net
Priority: optional Priority: optional
Homepage: https://git.unom.io/unom/punktfunk Homepage: https://git.unom.io/unom/punktfunk
Depends: nodejs (>= 20) Description: punktfunk management web console (Nitro SSR on bun + React)
Description: punktfunk management web console (Nitro/Node SSR + React)
The browser console for a punktfunk streaming host: status, paired devices, and the The browser console for a punktfunk streaming host: status, paired devices, and the
SPAKE2 PIN pairing flow every client needs. Runs as a systemd --user service on port SPAKE2 PIN pairing flow every client needs. Runs as a systemd --user service on port
3000, login-gated (a password generated on first start), proxying the host's loopback 3000 over HTTPS (HTTP/1.1 over TLS, with the host's own identity cert), login-gated (a
HTTPS management API with a bearer token injected server-side (never sent to the browser). password generated on first start), proxying the host's loopback HTTPS management API
with a bearer token injected server-side (never sent to the browser). Bundles its own
bun runtime (no system nodejs/bun dependency).
. .
Auto-wired to the host on a packaged install: it sources the host's Auto-wired to the host on a packaged install: it sources the host's
~/.config/punktfunk/mgmt-token and a generated login password — no env editing. Enable ~/.config/punktfunk/mgmt-token and a generated login password — no env editing. Enable
@@ -98,14 +130,14 @@ if [ "$1" = "configure" ]; then
echo "A login password is generated on first start — read it with:" echo "A login password is generated on first start — read it with:"
echo " journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'" echo " journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'"
echo " (or: sed -n 's/^PUNKTFUNK_UI_PASSWORD=//p' ~/.config/punktfunk/web-password)" echo " (or: sed -n 's/^PUNKTFUNK_UI_PASSWORD=//p' ~/.config/punktfunk/web-password)"
echo "Then open http://<host-ip>:3000" echo "Then open https://<host-ip>:3000 (self-signed host cert — trust it once)"
fi fi
exit 0 exit 0
EOF EOF
chmod 0755 "$STAGE/DEBIAN/postinst" chmod 0755 "$STAGE/DEBIAN/postinst"
mkdir -p dist mkdir -p dist
OUT="dist/${PKG}_${VERSION}_all.deb" OUT="dist/${PKG}_${VERSION}_${DEB_ARCH}.deb"
dpkg-deb --root-owner-group --build "$STAGE" "$OUT" >/dev/null dpkg-deb --root-owner-group --build "$STAGE" "$OUT" >/dev/null
echo "built $OUT" echo "built $OUT"
dpkg-deb -I "$OUT" | sed -n 's/^/ /p' | grep -E 'Version|Installed-Size|Depends' || true dpkg-deb -I "$OUT" | sed -n 's/^/ /p' | grep -E 'Version|Installed-Size|Depends' || true
+1 -1
View File
@@ -9,7 +9,7 @@
# Output: dist/punktfunk-<version>-<release>.<arch>.rpm (+ the -debuginfo/-debugsource subpkgs) # Output: dist/punktfunk-<version>-<release>.<arch>.rpm (+ the -debuginfo/-debugsource subpkgs)
set -euo pipefail set -euo pipefail
PF_VERSION="${PF_VERSION:-0.3.0}" # canary base; keep one minor ahead of the latest stable release PF_VERSION="${PF_VERSION:-0.5.0}" # canary base; keep one minor ahead of the latest stable release
PF_RELEASE="${PF_RELEASE:-1}" PF_RELEASE="${PF_RELEASE:-1}"
# PF_WITH_WEB=1 builds the punktfunk-web subpackage too (needs `bun` on PATH — present in the CI # PF_WITH_WEB=1 builds the punktfunk-web subpackage too (needs `bun` on PATH — present in the CI
# builder image, not in a plain mock chroot). Default off so a bare `rpmbuild`/COPR still works. # builder image, not in a plain mock chroot). Default off so a bare `rpmbuild`/COPR still works.
+34 -21
View File
@@ -42,11 +42,11 @@ ExclusiveArch: x86_64
# Recommends). Drop it from the auto-Requires, mirroring the Debian package's NVIDIA filter. # Recommends). Drop it from the auto-Requires, mirroring the Debian package's NVIDIA filter.
%global __requires_exclude ^libcuda\\.so.*$ %global __requires_exclude ^libcuda\\.so.*$
# Management web console subpackage (punktfunk-web). OFF by default: building the Nitro/Node SSR # Management web console subpackage (punktfunk-web). OFF by default: building the Nitro SSR bundle
# bundle needs `bun`, which a plain rpmbuild / COPR mock chroot does NOT have. CI's builder image # (and running it) needs `bun`, which a plain rpmbuild / COPR mock chroot does NOT have. CI's builder
# (ci/fedora-rpm.Dockerfile) DOES have bun and builds with `--with web`, so the Gitea RPM registry # image (ci/fedora-rpm.Dockerfile) DOES have bun and builds with `--with web`, so the Gitea RPM
# carries punktfunk-web. COPR (no bun) builds host+client only — use the Gitea registry for the # registry carries punktfunk-web. COPR (no bun) builds host+client only — use the Gitea registry for
# console, or enable bun + `--with web` in the COPR project. Mirrors the Debian punktfunk-web .deb. # the console, or enable bun + `--with web` in the COPR project. Mirrors the Debian punktfunk-web .deb.
%bcond_with web %bcond_with web
# --- Build toolchain --------------------------------------------------------- # --- Build toolchain ---------------------------------------------------------
@@ -135,19 +135,19 @@ virtual output at exactly this client's resolution and refresh rate — no scali
%if %{with web} %if %{with web}
%package web %package web
Summary: punktfunk management web console (Nitro/Node SSR + React) Summary: punktfunk management web console (Nitro SSR on bun + React)
BuildArch: noarch # Runtime is BUN (the console uses Nitro's `bun` preset + a Bun.serve TLS entry — node can't
# Runtime is plain node (the .output is portable JS — bun is only the build tool). Fedora 41+ # run it). Bun isn't in Fedora repos, so we VENDOR a bun binary into the package, which makes this
# ships nodejs >= 20, which the node-server build needs. # subpackage arch-specific (it can no longer be noarch). No system nodejs/bun dependency.
Requires: nodejs
%description web %description web
The browser console for a punktfunk streaming host: status, paired devices, and the SPAKE2 The browser console for a punktfunk streaming host: status, paired devices, and the SPAKE2
PIN pairing flow every client needs. Runs as a systemd --user service on port 3000, login-gated PIN pairing flow every client needs. Runs as a systemd --user service on port 3000 over HTTPS
(a password generated on first start), proxying the host's loopback HTTPS management API with a (HTTP/1.1 over TLS, with the host's own identity cert), login-gated (a password generated on first
bearer token injected server-side (never sent to the browser). Auto-wired to the host on a start), proxying the host's loopback HTTPS management API with a bearer token injected server-side
packaged install it sources the host's mgmt token and a generated login password, no env (never sent to the browser). Auto-wired to the host on a packaged install it sources the host's
editing. Enable with `systemctl --user enable --now punktfunk-web`. mgmt token, identity cert, and a generated login password, no env editing. Bundles its own bun
runtime. Enable with `systemctl --user enable --now punktfunk-web`.
%endif %endif
%prep %prep
@@ -157,17 +157,24 @@ editing. Enable with `systemctl --user enable --now punktfunk-web`.
# Release build of the host + client binaries (the workspace also has the core lib). # Release build of the host + client binaries (the workspace also has the core lib).
# cargo fetches crates over the network; COPR build hosts allow this. # cargo fetches crates over the network; COPR build hosts allow this.
export RUSTFLAGS="%{?build_rustflags}" export RUSTFLAGS="%{?build_rustflags}"
# Use the toolchain baked into the builder image as-is, ignoring rust-toolchain.toml. The toml
# floats `channel = "stable"` and requests rustfmt/clippy (lint-only — not needed for a build); when
# a newer stable lands upstream, that combination makes rustup try to UPDATE the baked, minimal-
# profile `stable` toolchain in place, and the in-image OverlayFS rejects the staging rename with
# EXDEV ("Invalid cross-device link"), failing %build. RUSTUP_TOOLCHAIN bypasses the toml so rustup
# neither re-resolves the channel nor adds components — it just builds with what's installed.
export RUSTUP_TOOLCHAIN=stable
# Stamp the exact NVR into the binary for --version / mgmt /health provenance (build.rs reads it). # Stamp the exact NVR into the binary for --version / mgmt /health provenance (build.rs reads it).
export PUNKTFUNK_BUILD_VERSION="%{version}-%{release}" export PUNKTFUNK_BUILD_VERSION="%{version}-%{release}"
# --locked: reproducible from (commit + Cargo.lock), matching the .deb build path. # --locked: reproducible from (commit + Cargo.lock), matching the .deb build path.
cargo build --release --locked -p punktfunk-host -p punktfunk-client-linux cargo build --release --locked -p punktfunk-host -p punktfunk-client-linux
%if %{with web} %if %{with web}
# Management web console: build the Nitro/Node SSR bundle (node-server preset) with bun. The # Management web console: build the Nitro SSR bundle with bun (the `bun` preset + our Bun.serve
# .output is portable JS run at runtime by plain node; bun is only the build tool (CI image). # TLS entry). bun is both the build tool AND the runtime (vendored in %%install below).
(cd web && bun install --frozen-lockfile && bun run build) (cd web && bun install --frozen-lockfile && bun run build)
if grep -q 'Bun\.serve' web/.output/server/index.mjs; then if ! grep -q 'Bun\.serve' web/.output/server/index.mjs; then
echo "ERROR: web build is a bun bundle (Bun.serve) need the node-server preset" >&2 echo "ERROR: web build is not a bun bundle need the 'bun' preset + custom entry" >&2
exit 1 exit 1
fi fi
%endif %endif
@@ -247,10 +254,14 @@ install -Dm0644 api/openapi.json %{buildroot}%{_datadir}/%{name
install -d %{buildroot}%{_datadir}/punktfunk-web/.output install -d %{buildroot}%{_datadir}/punktfunk-web/.output
cp -r web/.output/server %{buildroot}%{_datadir}/punktfunk-web/.output/server cp -r web/.output/server %{buildroot}%{_datadir}/punktfunk-web/.output/server
cp -r web/.output/public %{buildroot}%{_datadir}/punktfunk-web/.output/public cp -r web/.output/public %{buildroot}%{_datadir}/punktfunk-web/.output/public
# PATH-stable launcher (matches the .deb's /usr/bin/punktfunk-web-server). # Vendor the bun runtime (the build env's bun — the CI rpm image) into
# a private libexec dir so it never collides with a system-wide bun on PATH. This is why the web
# subpackage is arch-specific (above): bun is a native binary.
install -Dm0755 "$(command -v bun)" %{buildroot}%{_libexecdir}/punktfunk-web/bun
# PATH-stable launcher (matches the .deb's /usr/bin/punktfunk-web-server) — runs on the vendored bun.
cat > %{buildroot}%{_bindir}/punktfunk-web-server <<'WRAP' cat > %{buildroot}%{_bindir}/punktfunk-web-server <<'WRAP'
#!/bin/sh #!/bin/sh
exec /usr/bin/node /usr/share/punktfunk-web/.output/server/index.mjs "$@" exec /usr/libexec/punktfunk-web/bun /usr/share/punktfunk-web/.output/server/index.mjs "$@"
WRAP WRAP
chmod 0755 %{buildroot}%{_bindir}/punktfunk-web-server chmod 0755 %{buildroot}%{_bindir}/punktfunk-web-server
# systemd --user units: the console runs per-user; web-init generates the login password. # systemd --user units: the console runs per-user; web-init generates the login password.
@@ -286,6 +297,8 @@ install -Dm0644 web/web.env.example %{buildroot}%{_datadir}/punkt
%files web %files web
%license LICENSE-MIT LICENSE-APACHE THIRD-PARTY-NOTICES.txt %license LICENSE-MIT LICENSE-APACHE THIRD-PARTY-NOTICES.txt
%{_bindir}/punktfunk-web-server %{_bindir}/punktfunk-web-server
%dir %{_libexecdir}/punktfunk-web
%{_libexecdir}/punktfunk-web/bun
%dir %{_datadir}/punktfunk-web %dir %{_datadir}/punktfunk-web
%{_datadir}/punktfunk-web/.output %{_datadir}/punktfunk-web/.output
%{_datadir}/punktfunk-web/web-init.sh %{_datadir}/punktfunk-web/web-init.sh
+11
View File
@@ -65,6 +65,16 @@ read it from `%ProgramData%\punktfunk\web-password`.
- **Virtual gamepads need no prerequisite.** The DualSense / DualShock 4 / Xbox 360 (XUSB) UMDF drivers - **Virtual gamepads need no prerequisite.** The DualSense / DualShock 4 / Xbox 360 (XUSB) UMDF drivers
are **bundled** in the installer (the *Install the virtual gamepad drivers* task) and are **bundled** in the installer (the *Install the virtual gamepad drivers* task) and
`pnputil`-installed. **ViGEmBus is no longer used.** `pnputil`-installed. **ViGEmBus is no longer used.**
- **The streaming microphone uses VB-CABLE**, bundled + silently installed by the installer (the *Install
VB-CABLE virtual audio* task). The host writes the client's mic into VB-CABLE's input; its `CABLE
Output` capture endpoint surfaces as a host mic. A Windows audio device can only be created by a
**kernel-mode** driver (no UMDF path exists), so unlike our self-signed UMDF drivers we cannot ship our
own — VB-CABLE is a vendor-signed cable that loads with no test-signing. It is **donationware** by
VB-Audio, redistributed under VB-Audio's bundling grant (only the single base cable); see
`licenses/VB-CABLE-NOTICE.txt`. The package binary is **not** in the repo — supply it to the packer via
`-VbCableDir` / `$env:VBCABLE_DIR` (the extracted official package, containing `VBCABLE_Setup_x64.exe`).
Absent → the installer is built without it and the host falls back to auto-installing the Steam
Streaming pair. *(Endgame: attestation-sign our own MIT virtual-audio driver to drop this dependency.)*
## Files here ## Files here
@@ -74,6 +84,7 @@ read it from `%ProgramData%\punktfunk\web-password`.
| `pack-host-installer.ps1` | Orchestrator: cert + sign exe, **build + sign the drivers from source**, stage them + FFmpeg + the **web console** (`.output` + bun) + the HDR layer, run ISCC, sign setup.exe. | | `pack-host-installer.ps1` | Orchestrator: cert + sign exe, **build + sign the drivers from source**, stage them + FFmpeg + the **web console** (`.output` + bun) + the HDR layer, run ISCC, sign setup.exe. |
| `build-pf-vdisplay.ps1` | Build pf-vdisplay from source (the `drivers/` workspace) + clear FORCE_INTEGRITY + sign `.dll`/`.cat` + export `.cer`. | | `build-pf-vdisplay.ps1` | Build pf-vdisplay from source (the `drivers/` workspace) + clear FORCE_INTEGRITY + sign `.dll`/`.cat` + export `.cer`. |
| `build-gamepad-drivers.ps1` | Sign + catalog the gamepad drivers (`pf-dualsense` + `pf-xusb`) from the same workspace build (`-SkipBuild`), one shared cert. | | `build-gamepad-drivers.ps1` | Sign + catalog the gamepad drivers (`pf-dualsense` + `pf-xusb`) from the same workspace build (`-SkipBuild`), one shared cert. |
| `install-vbcable.ps1` | On-target: seed VB-Audio's cert into `TrustedPublisher`, silently install the bundled VB-CABLE (`-i -h`). Run by the installer's *Install VB-CABLE virtual audio* task; idempotent + always exits 0 (non-fatal). |
| `clear-force-integrity.ps1` | Clear the `/INTEGRITYCHECK` PE bit so a self-signed driver loads (reused by every driver build). | | `clear-force-integrity.ps1` | Clear the `/INTEGRITYCHECK` PE bit so a self-signed driver loads (reused by every driver build). |
| `stage-pf-vdisplay.ps1` | Stage the just-built pf-vdisplay bundle + fetch/verify the **pinned** nefcon release. | | `stage-pf-vdisplay.ps1` | Stage the just-built pf-vdisplay bundle + fetch/verify the **pinned** nefcon release. |
| `../../scripts/windows/web-run.cmd` | The `PunktfunkWeb` task action: loads the mgmt token + login password env, runs the bundled `bun` on the Nitro server (`:3000`). | | `../../scripts/windows/web-run.cmd` | The `PunktfunkWeb` task action: loads the mgmt token + login password env, runs the bundled `bun` on the Nitro server (`:3000`). |
+1
View File
@@ -0,0 +1 @@
target
@@ -133,9 +133,13 @@ unsafe fn add(request: WDFREQUEST) {
complete(request, STATUS_INVALID_PARAMETER); complete(request, STATUS_INVALID_PARAMETER);
return; return;
} }
let Some((target_id, luid_low, luid_high)) = let Some((monitor_id, target_id, luid_low, luid_high)) = crate::monitor::create_monitor(
crate::monitor::create_monitor(req.session_id, req.width, req.height, req.refresh_hz) req.session_id,
else { req.width,
req.height,
req.refresh_hz,
req.preferred_monitor_id,
) else {
complete(request, STATUS_NOT_FOUND); complete(request, STATUS_NOT_FOUND);
return; return;
}; };
@@ -143,7 +147,7 @@ unsafe fn add(request: WDFREQUEST) {
adapter_luid_low: luid_low, adapter_luid_low: luid_low,
adapter_luid_high: luid_high, adapter_luid_high: luid_high,
target_id, target_id,
_reserved: 0, resolved_monitor_id: monitor_id,
}; };
// SAFETY: `request` is the framework WDFREQUEST. // SAFETY: `request` is the framework WDFREQUEST.
unsafe { write_output_complete(request, &reply) }; unsafe { write_output_complete(request, &reply) };
@@ -7,7 +7,7 @@
use std::sync::Mutex; use std::sync::Mutex;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use wdk_sys::iddcx; use wdk_sys::{WDFOBJECT, call_unsafe_wdf_function_binding, iddcx};
/// One resolution with the refresh rates it supports. /// One resolution with the refresh rates it supports.
#[derive(Clone)] #[derive(Clone)]
@@ -69,10 +69,23 @@ unsafe impl Send for MonitorObject {}
/// heavy per-monitor resources on device removal is instead done explicitly ([`cleanup_for_device_removal`]). /// heavy per-monitor resources on device removal is instead done explicitly ([`cleanup_for_device_removal`]).
pub static MONITOR_MODES: Mutex<Vec<MonitorObject>> = Mutex::new(Vec::new()); pub static MONITOR_MODES: Mutex<Vec<MonitorObject>> = Mutex::new(Vec::new());
/// Lock [`MONITOR_MODES`], recovering the guard on poison instead of failing. DEFENSIVE ONLY: this driver
/// workspace builds with `panic = "abort"` (packaging/windows/drivers/Cargo.toml), so a panic while the
/// lock is held aborts the process WITHOUT unwinding — `MutexGuard::drop` never runs, the poison flag is
/// never set, and `.lock()` can never return `Err`. The `into_inner()` arm is therefore currently
/// unreachable; it is retained to consolidate the lock pattern and to stay correct if the panic strategy
/// ever becomes `unwind` (the guarded data is a plain `Vec` with no cross-field invariant a half-completed
/// panic could corrupt, so recovering the guard is sound). NOTE: this does NOT explain the observed ADD
/// 0x80070490 wedge — that is ghost-monitor slot-budget exhaustion (the arrival-failure `WdfObjectDelete`
/// teardown above + the host-side reap), not lock poisoning.
fn lock_monitors() -> std::sync::MutexGuard<'static, Vec<MonitorObject>> {
MONITOR_MODES.lock().unwrap_or_else(|e| e.into_inner())
}
/// True if any virtual monitor currently exists — the host-gone watchdog only reaps when there's /// True if any virtual monitor currently exists — the host-gone watchdog only reaps when there's
/// something to reap (see [`crate::control::start_watchdog`]). /// something to reap (see [`crate::control::start_watchdog`]).
pub fn has_monitors() -> bool { pub fn has_monitors() -> bool {
MONITOR_MODES.lock().map(|l| !l.is_empty()).unwrap_or(false) !lock_monitors().is_empty()
} }
/// Depart every monitor that has existed at least `grace` — the host-gone watchdog reap /// Depart every monitor that has existed at least `grace` — the host-gone watchdog reap
@@ -85,9 +98,7 @@ pub fn reap_orphaned(grace: Duration) -> usize {
Option<iddcx::IDDCX_MONITOR>, Option<iddcx::IDDCX_MONITOR>,
Option<crate::swap_chain_processor::SwapChainProcessor>, Option<crate::swap_chain_processor::SwapChainProcessor>,
)> = { )> = {
let Ok(mut lock) = MONITOR_MODES.lock() else { let mut lock = lock_monitors();
return 0;
};
let mut taken = Vec::new(); let mut taken = Vec::new();
let mut i = 0; let mut i = 0;
while i < lock.len() { while i < lock.len() {
@@ -138,7 +149,8 @@ pub fn display_info(
// Compute in u64 then saturate the u32 rational numerators: the old u32 `refresh*(h+4)^2` overflows // Compute in u64 then saturate the u32 rational numerators: the old u32 `refresh*(h+4)^2` overflows
// for a large mode (e.g. 8K@240), which panics→aborts the extern-"C" mode DDI in a debug build. // for a large mode (e.g. 8K@240), which panics→aborts the extern-"C" mode DDI in a debug build.
// Identical for every real mode; only an absurd (also now bounds-rejected) mode saturates. // Identical for every real mode; only an absurd (also now bounds-rejected) mode saturates.
let clock_rate: u64 = u64::from(refresh_rate) * u64::from(height + 4) * u64::from(height + 4) + 1000; let clock_rate: u64 =
u64::from(refresh_rate) * u64::from(height + 4) * u64::from(height + 4) + 1000;
let clock_rate_u32 = u32::try_from(clock_rate).unwrap_or(u32::MAX); let clock_rate_u32 = u32::try_from(clock_rate).unwrap_or(u32::MAX);
let mut si = pod_init!(wdk_sys::DISPLAYCONFIG_VIDEO_SIGNAL_INFO); let mut si = pod_init!(wdk_sys::DISPLAYCONFIG_VIDEO_SIGNAL_INFO);
si.pixelRate = clock_rate; si.pixelRate = clock_rate;
@@ -264,9 +276,7 @@ pub fn set_swap_chain_processor(
object: iddcx::IDDCX_MONITOR, object: iddcx::IDDCX_MONITOR,
proc: crate::swap_chain_processor::SwapChainProcessor, proc: crate::swap_chain_processor::SwapChainProcessor,
) -> Option<crate::swap_chain_processor::SwapChainProcessor> { ) -> Option<crate::swap_chain_processor::SwapChainProcessor> {
let Ok(mut lock) = MONITOR_MODES.lock() else { let mut lock = lock_monitors();
return Some(proc);
};
if let Some(m) = lock.iter_mut().find(|m| m.object == Some(object)) { if let Some(m) = lock.iter_mut().find(|m| m.object == Some(object)) {
m.swap_chain_processor.replace(proc) m.swap_chain_processor.replace(proc)
} else { } else {
@@ -290,15 +300,17 @@ pub fn take_swap_chain_processor(
.take() .take()
} }
/// `IOCTL_ADD`: create + arrive a virtual monitor at `width`x`height`@`refresh`. Returns the OS /// `IOCTL_ADD`: create + arrive a virtual monitor at `width`x`height`@`refresh` for `session_id`, naming it
/// `(target_id, adapter_luid_low, adapter_luid_high)` for the [`AddReply`](pf_driver_proto::control::AddReply), /// by `preferred_id` (the host's per-client stable id; `0` = auto-allocate). Returns the resolved
/// or `None` on failure (no adapter yet / IddCx error). /// `(monitor_id, target_id, adapter_luid_low, adapter_luid_high)` for the
/// [`AddReply`](pf_driver_proto::control::AddReply), or `None` on failure (no adapter yet / IddCx error).
pub fn create_monitor( pub fn create_monitor(
session_id: u64, session_id: u64,
width: u32, width: u32,
height: u32, height: u32,
refresh: u32, refresh: u32,
) -> Option<(u32, u32, i32)> { preferred_id: u32,
) -> Option<(u32, u32, u32, i32)> {
let adapter = crate::adapter::adapter()?; let adapter = crate::adapter::adapter()?;
// Single identity per session (E1): if the host re-ADDs a still-live `session_id` (it shouldn't), depart // Single identity per session (E1): if the host re-ADDs a still-live `session_id` (it shouldn't), depart
// the stale monitor first, so one session maps to exactly one monitor (no duplicate EDID/target lingers). // the stale monitor first, so one session maps to exactly one monitor (no duplicate EDID/target lingers).
@@ -307,7 +319,9 @@ pub fn create_monitor(
.map(|l| l.iter().any(|m| m.session_id == session_id)) .map(|l| l.iter().any(|m| m.session_id == session_id))
.unwrap_or(false) .unwrap_or(false)
{ {
dbglog!("[pf-vd] create_monitor: session {session_id} already live — departing the stale monitor"); dbglog!(
"[pf-vd] create_monitor: session {session_id} already live — departing the stale monitor"
);
remove_monitor(session_id); remove_monitor(session_id);
} }
let mut modes = vec![Mode { let mut modes = vec![Mode {
@@ -317,17 +331,17 @@ pub fn create_monitor(
}]; }];
modes.extend(default_modes()); modes.extend(default_modes());
// Register the (pending) monitor so the mode DDIs can find it by EDID-serial id before arrival, under a // Register the (pending) monitor so the mode DDIs can find it by EDID-serial id before arrival. The id
// REUSED id (the lowest not currently live). Reclaiming the id on REMOVE — instead of a monotonic // seeds the EDID serial + IddCx ConnectorIndex + ContainerId — i.e. the monitor's OS IDENTITY. Honor the
// counter — keeps the connector index / EDID serial / container GUID bounded, so IddCx reuses the same // host's per-client `preferred_id` when it is valid + not currently live, so a given client gets a
// OS target slot on a fresh ADD rather than leaving a ghost monitor node behind (the slot-exhaustion // STABLE identity across reconnects (→ Windows reapplies its saved per-monitor DPI scaling); else fall
// wedge: sustained ADD/REMOVE churn eventually makes ADD fail 0x80070490 ERROR_NOT_FOUND). Allocated // back to the lowest-free id (auto — the original slot-based behavior). A bounded reused id (vs a
// under the lock with the push so two concurrent ADDs can't pick the same id. // monotonic counter) keeps IddCx reusing the same OS target slot rather than leaving a ghost monitor
// node behind (the slot-exhaustion wedge). Allocated under the lock with the push so two concurrent ADDs
// can't pick the same id.
let id = { let id = {
let Ok(mut lock) = MONITOR_MODES.lock() else { let mut lock = lock_monitors();
return None; let id = resolve_id(&lock, preferred_id);
};
let id = alloc_monitor_id(&lock);
lock.push(MonitorObject { lock.push(MonitorObject {
object: None, object: None,
id, id,
@@ -379,7 +393,8 @@ pub fn create_monitor(
return None; return None;
} }
let monitor = create_out.MonitorObject; let monitor = create_out.MonitorObject;
if let Ok(mut lock) = MONITOR_MODES.lock() { {
let mut lock = lock_monitors();
if let Some(m) = lock.iter_mut().find(|m| m.id == id) { if let Some(m) = lock.iter_mut().find(|m| m.id == id) {
m.object = Some(monitor); m.object = Some(monitor);
} }
@@ -391,6 +406,24 @@ pub fn create_monitor(
let st = unsafe { wdk_iddcx::IddCxMonitorArrival(monitor, &mut arrival_out) }; let st = unsafe { wdk_iddcx::IddCxMonitorArrival(monitor, &mut arrival_out) };
dbglog!("[pf-vd] IddCxMonitorArrival(id={id}) -> {st:#x}"); dbglog!("[pf-vd] IddCxMonitorArrival(id={id}) -> {st:#x}");
if !wdk_iddcx::nt_success(st) { if !wdk_iddcx::nt_success(st) {
// Arrival failed on a monitor we already CREATED. It must be torn down with `WdfObjectDelete`:
// `IddCxMonitorDeparture` is only valid for an ARRIVED monitor, so departing here would be a
// no-op that LEAKS the IddCx monitor object and permanently pins its slot against the adapter's
// `MaxMonitorsSupported` budget — the leak that, asymmetric with the create-failure path just
// above (which only reclaims the id, having no object to delete), accelerates the ADD 0x80070490
// wedge. Reclaim the id FIRST (drop the `MONITOR_MODES` entry that still holds this handle) so a
// concurrent `clear_all`/`reap_orphaned` can't grab + depart the handle we're about to delete,
// THEN delete the object — `monitor` is a local copy of the handle, valid across both.
dbglog!(
"[pf-vd] IddCxMonitorArrival(id={id}) FAILED — reclaiming the id + deleting the created monitor"
);
remove_by_id(id);
// SAFETY: `monitor` is the just-created (not-yet-arrived) IddCx monitor handle, now owned solely
// here (its `MONITOR_MODES` entry was just removed); `WdfObjectDelete` takes a `WDFOBJECT` (a raw
// handle cast, as in the swap-chain / device-cleanup teardowns).
unsafe {
call_unsafe_wdf_function_binding!(WdfObjectDelete, monitor as WDFOBJECT);
}
return None; return None;
} }
@@ -399,14 +432,15 @@ pub fn create_monitor(
arrival_out.OsAdapterLuid.LowPart, arrival_out.OsAdapterLuid.LowPart,
arrival_out.OsAdapterLuid.HighPart, arrival_out.OsAdapterLuid.HighPart,
); );
if let Ok(mut lock) = MONITOR_MODES.lock() { {
let mut lock = lock_monitors();
if let Some(m) = lock.iter_mut().find(|m| m.id == id) { if let Some(m) = lock.iter_mut().find(|m| m.id == id) {
m.target_id = target_id; m.target_id = target_id;
m.adapter_luid_low = luid_low; m.adapter_luid_low = luid_low;
m.adapter_luid_high = luid_high; m.adapter_luid_high = luid_high;
} }
} }
Some((target_id, luid_low, luid_high)) Some((id, target_id, luid_low, luid_high))
} }
/// `IOCTL_REMOVE`: depart + drop the monitor for `session_id`. Returns true if one was removed. /// `IOCTL_REMOVE`: depart + drop the monitor for `session_id`. Returns true if one was removed.
@@ -415,9 +449,7 @@ pub fn remove_monitor(session_id: u64) -> bool {
// (which RAII-joins its worker thread) only AFTER the lock guard is released — joining a worker // (which RAII-joins its worker thread) only AFTER the lock guard is released — joining a worker
// while holding `MONITOR_MODES` would head-block the whole control plane / risk a self-deadlock. // while holding `MONITOR_MODES` would head-block the whole control plane / risk a self-deadlock.
let (monitor, processor) = { let (monitor, processor) = {
let Ok(mut lock) = MONITOR_MODES.lock() else { let mut lock = lock_monitors();
return false;
};
let Some(pos) = lock.iter().position(|m| m.session_id == session_id) else { let Some(pos) = lock.iter().position(|m| m.session_id == session_id) else {
return false; return false;
}; };
@@ -441,9 +473,7 @@ pub fn clear_all() {
Option<iddcx::IDDCX_MONITOR>, Option<iddcx::IDDCX_MONITOR>,
Option<crate::swap_chain_processor::SwapChainProcessor>, Option<crate::swap_chain_processor::SwapChainProcessor>,
)> = { )> = {
let Ok(mut lock) = MONITOR_MODES.lock() else { let mut lock = lock_monitors();
return;
};
lock.drain(..) lock.drain(..)
.map(|mut m| (m.object, m.swap_chain_processor.take())) .map(|mut m| (m.object, m.swap_chain_processor.take()))
.collect() .collect()
@@ -467,9 +497,7 @@ pub fn clear_all() {
/// though the per-devnode WUDFHost (`ProcessSharingDisabled`) would also reap them when it exits. /// though the per-devnode WUDFHost (`ProcessSharingDisabled`) would also reap them when it exits.
pub fn cleanup_for_device_removal() { pub fn cleanup_for_device_removal() {
let mut drained: Vec<Option<crate::swap_chain_processor::SwapChainProcessor>> = { let mut drained: Vec<Option<crate::swap_chain_processor::SwapChainProcessor>> = {
let Ok(mut lock) = MONITOR_MODES.lock() else { let mut lock = lock_monitors();
return;
};
lock.drain(..) lock.drain(..)
.map(|mut m| m.swap_chain_processor.take()) .map(|mut m| m.swap_chain_processor.take())
.collect() .collect()
@@ -483,8 +511,20 @@ pub fn cleanup_for_device_removal() {
/// Drop a pending entry by id (create failed before arrival). /// Drop a pending entry by id (create failed before arrival).
fn remove_by_id(id: u32) { fn remove_by_id(id: u32) {
if let Ok(mut lock) = MONITOR_MODES.lock() { lock_monitors().retain(|m| m.id != id);
lock.retain(|m| m.id != id); }
/// Resolve the id to name a new monitor by: honor the host's `preferred` per-client id when it is in the
/// valid range (`1..=15`, so the IddCx `ConnectorIndex` = id stays `< MaxMonitorsSupported` = 16) AND not
/// currently live (two live monitors MUST have distinct ids/connectors); otherwise fall back to
/// [`alloc_monitor_id`] (auto, lowest-free). NEVER auto-departs a colliding live monitor — that would tear
/// down an unrelated concurrent client — so the live-uniqueness invariant is preserved even against a host
/// bug. `preferred == 0` (anonymous/TOFU/GameStream) always falls through to auto. Caller holds `MONITOR_MODES`.
fn resolve_id(modes: &[MonitorObject], preferred: u32) -> u32 {
if (1..=15).contains(&preferred) && !modes.iter().any(|m| m.id == preferred) {
preferred
} else {
alloc_monitor_id(modes)
} }
} }
+97
View File
@@ -0,0 +1,97 @@
<#
.SYNOPSIS
Silently install the bundled VB-Audio Virtual Cable (the punktfunk virtual microphone) on the host.
.DESCRIPTION
punktfunk pipes the streaming client's microphone into a virtual audio cable's render endpoint; the
cable's capture endpoint ("CABLE Output") then surfaces as a host microphone that games/apps record
from (see crates/punktfunk-host/src/audio/windows/wasapi_mic.rs). On a headless host there is no real
audio output, so a virtual cable is required. We bundle the OFFICIAL base VB-CABLE package (VB-Audio,
https://vb-cable.com) and install it unattended:
1. If a "CABLE Input"/"CABLE Output" endpoint already exists, do nothing (idempotent).
2. Pre-seed VB-Audio's Authenticode signing certificate (read from the bundled signed driver) into
LocalMachine\TrustedPublisher, so the kernel-driver-publisher prompt is suppressed and the
install is fully silent (required for the SYSTEM/Session-0 service install).
3. Run the official silent installer: VBCABLE_Setup_x64.exe -i -h (arm64: the same exe name in the
arm64 package; x86 falls back to VBCABLE_Setup.exe).
4. Wait briefly for the audio subsystem to register the new endpoint.
VB-CABLE is donationware by VB-Audio Software, redistributed here under VB-Audio's bundling grant
(https://vb-audio.com/Services/licensing.htm); see {app}\licenses\VB-CABLE-NOTICE.txt. Only the base
single cable is bundled (A+B / C+D are not redistributable).
Best-effort: any failure is logged and returns a non-zero exit, but the caller (the installer) treats
it as non-fatal - the host still runs (mic passthrough then needs a manually-installed cable, and the
host falls back to auto-installing the Steam Streaming pair).
.PARAMETER Dir
The staged VB-CABLE package directory (contains VBCABLE_Setup_x64.exe + the signed driver files).
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$Dir
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
function Test-CablePresent {
# An active render OR capture endpoint named "CABLE ..." means VB-CABLE is already installed.
$eps = Get-PnpDevice -Class AudioEndpoint -ErrorAction SilentlyContinue |
Where-Object { $_.Status -eq 'OK' -and $_.FriendlyName -match 'CABLE (Input|Output|In)' }
return [bool]$eps
}
if (Test-CablePresent) {
Write-Host 'VB-CABLE already installed (CABLE endpoint present) - skipping.'
exit 0
}
if (-not (Test-Path -LiteralPath $Dir)) { throw "VB-CABLE package dir not found: $Dir" }
# Pick the silent installer for this architecture. The x64 package ships both; arm64 ships an arm64
# VBCABLE_Setup_x64.exe (VB-Audio's naming); fall back to the 32-bit setup if that's all that's staged.
$setup = $null
foreach ($name in @('VBCABLE_Setup_x64.exe', 'VBCABLE_Setup.exe')) {
$p = Join-Path $Dir $name
if (Test-Path -LiteralPath $p) { $setup = $p; break }
}
if (-not $setup) { throw "no VBCABLE_Setup*.exe under $Dir" }
Write-Host "VB-CABLE silent installer: $setup"
# --- pre-seed VB-Audio's signing cert into LocalMachine\TrustedPublisher (unattended driver install) ---
# Read the Authenticode signer from a bundled signed file (prefer a driver .sys/.cat; fall back to the
# setup exe). Importing it into TrustedPublisher makes Windows install the signed driver with no prompt.
try {
$signed = Get-ChildItem -LiteralPath $Dir -Recurse -Include '*.sys', '*.cat', '*.exe' -ErrorAction SilentlyContinue |
ForEach-Object { Get-AuthenticodeSignature -LiteralPath $_.FullName -ErrorAction SilentlyContinue } |
Where-Object { $_.Status -eq 'Valid' -and $_.SignerCertificate } |
Select-Object -First 1
if ($signed -and $signed.SignerCertificate) {
$store = New-Object System.Security.Cryptography.X509Certificates.X509Store('TrustedPublisher', 'LocalMachine')
$store.Open('ReadWrite')
$store.Add($signed.SignerCertificate)
$store.Close()
Write-Host "seeded VB-Audio cert into LocalMachine\TrustedPublisher (subject=$($signed.SignerCertificate.Subject))"
}
else {
Write-Warning 'no valid Authenticode signer found in the VB-CABLE package - the driver-publisher prompt may appear (install may stall under SYSTEM)'
}
}
catch {
Write-Warning "could not pre-seed the VB-Audio cert: $($_.Exception.Message)"
}
# --- run the official silent install: -i (install) -h (hidden) -----------------------------------
# VB-Audio documents these switches; the process returns before the endpoint is fully registered.
$proc = Start-Process -FilePath $setup -ArgumentList '-i', '-h' -Wait -PassThru -WindowStyle Hidden
Write-Host "VBCABLE setup exit code: $($proc.ExitCode)"
# Give the audio subsystem time to enumerate the new endpoint, then verify.
for ($i = 0; $i -lt 10; $i++) {
Start-Sleep -Seconds 1
if (Test-CablePresent) { Write-Host 'VB-CABLE installed - CABLE endpoint present.'; exit 0 }
}
Write-Warning 'VB-CABLE setup ran but no CABLE endpoint appeared yet (a reboot may be required).'
# Non-fatal: the device often appears after the next session/reboot; the host retries mic open with backoff.
exit 0
@@ -0,0 +1,26 @@
VB-CABLE Virtual Audio Device — Attribution
===========================================
The punktfunk host installer bundles and silently installs VB-CABLE, the virtual
audio cable used as the streaming virtual microphone (the client's mic is written
into VB-CABLE's input, and its "CABLE Output" capture endpoint surfaces as a host
microphone that games and apps record from).
VB-CABLE is a product of VB-Audio Software.
Origin: https://vb-cable.com (https://vb-audio.com)
VB-CABLE is DONATIONWARE — all participations are welcome.
Please consider donating to VB-Audio if you find it useful:
https://vb-audio.com/Cable/
VB-CABLE is redistributed here, unmodified (the official base VB-CABLE package),
under VB-Audio's distribution grant for bundling the base cable with another
application; see VB-Audio's licensing terms:
https://vb-audio.com/Services/licensing.htm
Only the single base VB-CABLE is bundled. VB-CABLE A+B and C+D are not
redistributed. VB-Audio retains all rights to VB-CABLE; punktfunk claims no
ownership of it.
To remove VB-CABLE, use its own uninstaller (VBCABLE_Setup_x64.exe -u -h) or the
"VB-Audio Virtual Cable" entry in Windows "Apps & features"; uninstalling the
punktfunk host does not remove VB-CABLE.
+24
View File
@@ -28,6 +28,7 @@ param(
[string]$FfmpegDir = $env:FFMPEG_DIR, # bundle its bin\*.dll (amf-qsv build) [string]$FfmpegDir = $env:FFMPEG_DIR, # bundle its bin\*.dll (amf-qsv build)
[string]$WebDir = $env:WEB_OUTPUT_DIR, # built web .output tree -> bundle the mgmt console [string]$WebDir = $env:WEB_OUTPUT_DIR, # built web .output tree -> bundle the mgmt console
[string]$BunExe = $env:BUN_EXE, # portable bun.exe runtime for the console [string]$BunExe = $env:BUN_EXE, # portable bun.exe runtime for the console
[string]$VbCableDir = $env:VBCABLE_DIR, # official base VB-CABLE package -> bundle the virtual mic
[switch]$NoDriver, # build without the bundled pf-vdisplay driver [switch]$NoDriver, # build without the bundled pf-vdisplay driver
[switch]$NoSign # skip signing (local debug) [switch]$NoSign # skip signing (local debug)
) )
@@ -189,6 +190,29 @@ if (-not $NoDriver) {
Write-Host "==> built + staged gamepad UMDF drivers -> $gpStage" Write-Host "==> built + staged gamepad UMDF drivers -> $gpStage"
} }
# --- stage the official base VB-CABLE package (the streaming virtual microphone) --------------
# VB-CABLE is the virtual audio cable the host writes the client's mic into (its capture endpoint then
# surfaces as a host microphone). We bundle + silently install the OFFICIAL base VB-CABLE package
# (VB-Audio donationware, redistributed under VB-Audio's bundling grant - see the VB-CABLE notice added
# to the licenses payload). The package binary is NOT in the repo (it's a signed third-party blob,
# shipped intact); supply it via -VbCableDir / $env:VBCABLE_DIR pointing at the extracted official
# package (must contain VBCABLE_Setup_x64.exe). Absent -> installer built WITHOUT the bundled cable; the
# host then auto-installs the Steam Streaming pair as a fallback and mic passthrough needs a manual cable.
if ($VbCableDir -and (Test-Path $VbCableDir) -and (Get-ChildItem -Path $VbCableDir -Filter 'VBCABLE_Setup*.exe' -ErrorAction SilentlyContinue)) {
$vbStage = Join-Path $OutDir 'vbcable'
if (Test-Path $vbStage) { Remove-Item -Recurse -Force $vbStage }
New-Item -ItemType Directory -Force -Path $vbStage | Out-Null
Copy-Item (Join-Path $VbCableDir '*') $vbStage -Recurse -Force
# The on-target installer script (seeds VB-Audio's cert into TrustedPublisher, runs -i -h) ships
# alongside the package so it's extracted to the same {tmp}\vbcable dir.
Copy-Item (Join-Path $here 'install-vbcable.ps1') $vbStage -Force
$defines += "/DAudioCableStageDir=$vbStage"
# Attribution: VB-Audio's bundling grant requires we surface VB-CABLE's origin + donationware status.
Copy-Item (Join-Path $here 'licenses\VB-CABLE-NOTICE.txt') -Destination $licStage -Force
Write-Host "==> bundling VB-CABLE (virtual mic) from $VbCableDir -> $vbStage"
}
else { Write-Host "no -VbCableDir/`$env:VBCABLE_DIR (or no VBCABLE_Setup*.exe in it) -> installer built WITHOUT the bundled VB-CABLE virtual mic" }
# --- stage the FFmpeg shared DLLs (AMD/Intel AMF/QSV build) ------------------------------------ # --- stage the FFmpeg shared DLLs (AMD/Intel AMF/QSV build) ------------------------------------
# A host built with --features amf-qsv link-imports avcodec/avutil/swscale/... so the shared DLLs # A host built with --features amf-qsv link-imports avcodec/avutil/swscale/... so the shared DLLs
# MUST sit next to the exe (it won't start otherwise). Bundle them from $FfmpegDir\bin - the same # MUST sit next to the exe (it won't start otherwise). Bundle them from $FfmpegDir\bin - the same
+23 -1
View File
@@ -41,6 +41,12 @@
#ifdef GamepadStageDir #ifdef GamepadStageDir
#define WithGamepad #define WithGamepad
#endif #endif
; AudioCableStageDir (the official base VB-CABLE package + install-vbcable.ps1) is optional - present
; when the VB-CABLE package was supplied to the packer. It is the streaming virtual microphone; on a
; headless host (no real audio output) a virtual cable is required for mic + desktop-audio passthrough.
#ifdef AudioCableStageDir
#define WithAudioCable
#endif
; FfmpegBin (a dir of FFmpeg shared DLLs) is optional - present when the host is built with ; FfmpegBin (a dir of FFmpeg shared DLLs) is optional - present when the host is built with
; --features amf-qsv (the AMD/Intel AMF/QSV encode backend link-imports the FFmpeg libs). ; --features amf-qsv (the AMD/Intel AMF/QSV encode backend link-imports the FFmpeg libs).
#ifdef FfmpegBin #ifdef FfmpegBin
@@ -93,6 +99,9 @@ Name: "installdriver"; Description: "Install the pf-vdisplay virtual display dri
#ifdef WithGamepad #ifdef WithGamepad
Name: "installgamepad"; Description: "Install the virtual gamepad drivers (DualSense / DualShock 4 / Xbox 360 - no ViGEmBus needed)" Name: "installgamepad"; Description: "Install the virtual gamepad drivers (DualSense / DualShock 4 / Xbox 360 - no ViGEmBus needed)"
#endif #endif
#ifdef WithAudioCable
Name: "installaudiocable"; Description: "Install VB-CABLE virtual audio (microphone passthrough - VB-Audio donationware, www.vb-cable.com)"
#endif
#ifdef WithVkLayer #ifdef WithVkLayer
Name: "installhdrlayer"; Description: "Install the HDR Vulkan layer (lets Vulkan games like Doom use HDR on the virtual display)" Name: "installhdrlayer"; Description: "Install the HDR Vulkan layer (lets Vulkan games like Doom use HDR on the virtual display)"
#endif #endif
@@ -132,6 +141,10 @@ Source: "{#StageDir}\*"; DestDir: "{tmp}\pfvdisplay"; Flags: deleteafterinstall
; The built-from-source UMDF gamepad drivers + install-gamepad-drivers.ps1, extracted to {tmp}, removed after. ; The built-from-source UMDF gamepad drivers + install-gamepad-drivers.ps1, extracted to {tmp}, removed after.
Source: "{#GamepadStageDir}\*"; DestDir: "{tmp}\gamepad"; Flags: deleteafterinstall recursesubdirs createallsubdirs; Tasks: installgamepad Source: "{#GamepadStageDir}\*"; DestDir: "{tmp}\gamepad"; Flags: deleteafterinstall recursesubdirs createallsubdirs; Tasks: installgamepad
#endif #endif
#ifdef WithAudioCable
; The official base VB-CABLE package + install-vbcable.ps1, extracted to {tmp}, removed after install.
Source: "{#AudioCableStageDir}\*"; DestDir: "{tmp}\vbcable"; Flags: deleteafterinstall recursesubdirs createallsubdirs; Tasks: installaudiocable
#endif
#ifdef WithVkLayer #ifdef WithVkLayer
; The HDR Vulkan implicit layer (cdylib + its JSON manifest) laid into {app}\vklayer and registered ; The HDR Vulkan implicit layer (cdylib + its JSON manifest) laid into {app}\vklayer and registered
; below. The manifest's library_path is ".\pf_vkhdr_layer.dll" (relative to the JSON), so the two ; below. The manifest's library_path is ".\pf_vkhdr_layer.dll" (relative to the JSON), so the two
@@ -160,6 +173,15 @@ Filename: "{app}\punktfunk-host.exe"; Parameters: "driver install --gamepad --di
StatusMsg: "Installing the virtual gamepad drivers..."; \ StatusMsg: "Installing the virtual gamepad drivers..."; \
Flags: runhidden waituntilterminated; Tasks: installgamepad Flags: runhidden waituntilterminated; Tasks: installgamepad
#endif #endif
#ifdef WithAudioCable
; Silently install the bundled VB-CABLE (the streaming virtual microphone). Best-effort: install-vbcable.ps1
; always exits 0 (a missing cable just disables mic passthrough; the host falls back + retries), so a
; cable hiccup never fails the whole install.
Filename: "powershell.exe"; \
Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{tmp}\vbcable\install-vbcable.ps1"" -Dir ""{tmp}\vbcable"""; \
StatusMsg: "Installing VB-CABLE virtual audio (microphone passthrough)..."; \
Flags: runhidden waituntilterminated; Tasks: installaudiocable
#endif
; Register (or re-point, on upgrade - idempotent) the SYSTEM service from its FINAL {app} location: ; Register (or re-point, on upgrade - idempotent) the SYSTEM service from its FINAL {app} location:
; service install records current_exe() as the SCM binPath, so it must run from {app}, not {tmp}. ; service install records current_exe() as the SCM binPath, so it must run from {app}, not {tmp}.
Filename: "{app}\punktfunk-host.exe"; Parameters: "service install"; WorkingDir: "{app}"; \ Filename: "{app}\punktfunk-host.exe"; Parameters: "service install"; WorkingDir: "{app}"; \
@@ -180,7 +202,7 @@ Filename: "{app}\punktfunk-host.exe"; Parameters: "service uninstall"; Flags: ru
; Stop + remove the PunktfunkWeb task and its firewall rule (leaves %ProgramData%\punktfunk config, ; Stop + remove the PunktfunkWeb task and its firewall rule (leaves %ProgramData%\punktfunk config,
; like the host uninstall does). ; like the host uninstall does).
Filename: "powershell.exe"; \ Filename: "powershell.exe"; \
Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""Stop-ScheduledTask -TaskName PunktfunkWeb -ErrorAction SilentlyContinue; Get-NetTCPConnection -LocalPort 3000 -State Listen -ErrorAction SilentlyContinue | ForEach-Object {{ Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }; Unregister-ScheduledTask -TaskName PunktfunkWeb -Confirm:$false -ErrorAction SilentlyContinue; Get-NetFirewallRule -Name 'PunktfunkWeb-TCP-3000' -ErrorAction SilentlyContinue | Remove-NetFirewallRule"""; \ Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""Stop-ScheduledTask -TaskName PunktfunkWeb -ErrorAction SilentlyContinue; Get-NetTCPConnection -LocalPort 3000 -State Listen -ErrorAction SilentlyContinue | ForEach-Object {{ Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }; Unregister-ScheduledTask -TaskName PunktfunkWeb -Confirm:$false -ErrorAction SilentlyContinue; Get-NetFirewallRule -DisplayName 'punktfunk web console (*' -ErrorAction SilentlyContinue | Remove-NetFirewallRule"""; \
Flags: runhidden waituntilterminated; RunOnceId: "PunktfunkWebCleanup" Flags: runhidden waituntilterminated; RunOnceId: "PunktfunkWebCleanup"
#endif #endif
+11 -4
View File
@@ -1,9 +1,10 @@
# punktfunk management web console — systemd USER unit (Nitro/Node SSR, port 3000). # punktfunk management web console — systemd USER unit (Nitro SSR on bun, port 3000, HTTPS).
# #
# Installed by the punktfunk-web .deb to /usr/lib/systemd/user/. AUTO-WIRED — no env editing: # Installed by the punktfunk-web .deb to /usr/lib/systemd/user/. AUTO-WIRED — no env editing:
# it sources the host's mgmt token + the generated login password, and points at the host's # it sources the host's mgmt token + the generated login password, serves HTTPS (HTTP/1.1 over TLS)
# loopback HTTPS mgmt API (self-signed cert → NODE_TLS_REJECT_UNAUTHORIZED for the proxy's only # with the host's own identity cert (~/.config/punktfunk/{cert,key}.pem), and points the /api proxy
# outbound hop, which is loopback). Enable per user: # at the host's loopback HTTPS mgmt API (self-signed cert → NODE_TLS_REJECT_UNAUTHORIZED for the
# proxy's only outbound hop, which is loopback). Enable per user:
# systemctl --user enable --now punktfunk-web # systemctl --user enable --now punktfunk-web
[Unit] [Unit]
Description=punktfunk management web console Description=punktfunk management web console
@@ -22,6 +23,12 @@ Environment=PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990
Environment=NODE_TLS_REJECT_UNAUTHORIZED=0 Environment=NODE_TLS_REJECT_UNAUTHORIZED=0
Environment=PORT=3000 Environment=PORT=3000
Environment=HOST=0.0.0.0 Environment=HOST=0.0.0.0
# Serve HTTPS (HTTP/1.1 over TLS) with the host's own identity cert; mark the
# session cookie Secure. The host's `serve` writes these PEMs; if absent at start the unit fails and
# Restart retries (same as the mgmt-token wait above) rather than silently serving plain HTTP.
Environment=PUNKTFUNK_UI_TLS_CERT=%h/.config/punktfunk/cert.pem
Environment=PUNKTFUNK_UI_TLS_KEY=%h/.config/punktfunk/key.pem
Environment=PUNKTFUNK_UI_SECURE=1
ExecStart=/usr/bin/punktfunk-web-server ExecStart=/usr/bin/punktfunk-web-server
Restart=on-failure Restart=on-failure
RestartSec=2 RestartSec=2
+4 -4
View File
@@ -22,10 +22,10 @@ is only the build environment; `punktfunk-host` is launched directly, not via `d
rebuild always matches the running OS. Encode is **VAAPI** on the Deck's AMD GPU (NVENC on NVIDIA), rebuild always matches the running OS. Encode is **VAAPI** on the Deck's AMD GPU (NVENC on NVIDIA),
auto-selected by `PUNKTFUNK_ENCODER=auto`. auto-selected by `PUNKTFUNK_ENCODER=auto`.
The web console is the one part that stays in the container at runtime: it's a Nitro **node-server** The web console is the one part that stays in the container at runtime: it's a Nitro **`bun`**
build (`bun` builds it; **`node` runs it**bun mis-resolves Nitro's externalized server deps like build (`bun` both builds **and runs** it — the bun-preset output uses `Bun.serve` with TLS,
`srvx` at request time), so its service does `distrobox enter pf2 -- … node .output/server/index.mjs`. serving HTTPS (HTTP/1.1 over TLS) with the host's identity cert), so its service does
Both `bun` and `nodejs` are provisioned in the container. `distrobox enter pf2 -- … bun .output/server/index.mjs`. `bun` is provisioned in the container.
## Scripts ## Scripts
+5 -5
View File
@@ -92,8 +92,8 @@ sudo apt-get install -y -qq --no-install-recommends \
nodejs >/dev/null nodejs >/dev/null
command -v rustc >/dev/null 2>&1 || command -v ~/.cargo/bin/rustc >/dev/null 2>&1 || \ command -v rustc >/dev/null 2>&1 || command -v ~/.cargo/bin/rustc >/dev/null 2>&1 || \
curl --proto =https --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path >/dev/null curl --proto =https --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path >/dev/null
# bun builds the web console; node runs it (the node-server preset; bun mis-resolves the Nitro # bun builds AND runs the web console now (the Nitro `bun` preset + our Bun.serve TLS entry —
# externalized server deps like srvx at request time). # bun-native output, so the old srvx mis-resolution that forced node no longer applies).
command -v bun >/dev/null 2>&1 || command -v ~/.bun/bin/bun >/dev/null 2>&1 || \ command -v bun >/dev/null 2>&1 || command -v ~/.bun/bin/bun >/dev/null 2>&1 || \
curl -fsSL https://bun.sh/install | bash >/dev/null curl -fsSL https://bun.sh/install | bash >/dev/null
' '
@@ -199,8 +199,8 @@ EOF
ok "punktfunk-host.service ($SERVE_ARGS)" ok "punktfunk-host.service ($SERVE_ARGS)"
if [ "$WITH_WEB" = 1 ]; then if [ "$WITH_WEB" = 1 ]; then
# The console is a Nitro/Node server run by bun; it lives in the build container (bun + node # The console is a Nitro server run by bun (Bun.serve, HTTPS — HTTP/1.1 over TLS — with the host's
# libs) and proxies to the host's loopback HTTPS mgmt API. # identity cert); it lives in the build container and proxies to the host's loopback HTTPS mgmt API.
cat > "$UNITS/punktfunk-web.service" <<EOF cat > "$UNITS/punktfunk-web.service" <<EOF
# Generated by scripts/steamdeck/install.sh — punktfunk web console (bun in the '$BOX' distrobox). # Generated by scripts/steamdeck/install.sh — punktfunk web console (bun in the '$BOX' distrobox).
[Unit] [Unit]
@@ -208,7 +208,7 @@ Description=punktfunk management web console
After=punktfunk-host.service After=punktfunk-host.service
[Service] [Service]
ExecStart=$DISTROBOX enter $BOX -- bash -lc 'cd $SRC/web; set -a; . $CONFIG/mgmt-token; . $CONFIG/web.env; set +a; export PUNKTFUNK_MGMT_URL=https://127.0.0.1:$MGMT_PORT NODE_TLS_REJECT_UNAUTHORIZED=0 PORT=$WEB_PORT HOST=0.0.0.0 NITRO_PORT=$WEB_PORT NITRO_HOST=0.0.0.0; exec node .output/server/index.mjs' ExecStart=$DISTROBOX enter $BOX -- bash -lc 'cd $SRC/web; set -a; . $CONFIG/mgmt-token; . $CONFIG/web.env; set +a; export PUNKTFUNK_MGMT_URL=https://127.0.0.1:$MGMT_PORT NODE_TLS_REJECT_UNAUTHORIZED=0 PORT=$WEB_PORT HOST=0.0.0.0 NITRO_PORT=$WEB_PORT NITRO_HOST=0.0.0.0 PUNKTFUNK_UI_TLS_CERT=$CONFIG/cert.pem PUNKTFUNK_UI_TLS_KEY=$CONFIG/key.pem PUNKTFUNK_UI_SECURE=1; exec bun .output/server/index.mjs'
Restart=on-failure Restart=on-failure
RestartSec=3 RestartSec=3
+17 -5
View File
@@ -4,21 +4,29 @@ rem
rem Lays out next to the installed payload: {app}\web\web-run.cmd, {app}\web\.output\... and rem Lays out next to the installed payload: {app}\web\web-run.cmd, {app}\web\.output\... and
rem {app}\bun\bun.exe (so %~dp0 = {app}\web\). Auto-wires the console the same way the Linux rem {app}\bun\bun.exe (so %~dp0 = {app}\web\). Auto-wires the console the same way the Linux
rem systemd unit does: it sources the host's mgmt bearer token + the console login password from rem systemd unit does: it sources the host's mgmt bearer token + the console login password from
rem %ProgramData%\punktfunk\, points the /api proxy at the host's loopback HTTPS mgmt API, and runs rem %ProgramData%\punktfunk\, points the /api proxy at the host's loopback HTTPS mgmt API, and serves
rem the (self-contained, no-node_modules) Nitro server on :3000 with the bundled bun. No env editing. rem the (self-contained, no-node_modules) Nitro console over HTTPS (HTTP/1.1 over TLS) on :3000 with the
rem bundled bun, using the host's OWN identity cert. No env editing.
setlocal EnableExtensions setlocal EnableExtensions
set "PFDATA=%ProgramData%\punktfunk" set "PFDATA=%ProgramData%\punktfunk"
set "TOKENFILE=%PFDATA%\mgmt-token" set "TOKENFILE=%PFDATA%\mgmt-token"
set "PWFILE=%PFDATA%\web-password" set "PWFILE=%PFDATA%\web-password"
set "CERTFILE=%PFDATA%\cert.pem"
set "KEYFILE=%PFDATA%\key.pem"
rem The host's `serve` writes the mgmt token on first run. Until it exists the proxy has no rem The host's `serve` writes the mgmt token + its identity cert/key on first run. Until they exist
rem credential, so fail and let the task's restart-on-failure retry (mirrors the Linux unit's rem we have no credential and no TLS material, so fail and let the task's restart-on-failure retry
rem Restart=on-failure waiting for the host to create it). rem (mirrors the Linux unit's Restart=on-failure waiting for the host to create them) rather than
rem silently downgrading to plain HTTP.
if not exist "%TOKENFILE%" ( if not exist "%TOKENFILE%" (
echo [punktfunk-web] mgmt token not present yet at "%TOKENFILE%" - waiting for the host service. echo [punktfunk-web] mgmt token not present yet at "%TOKENFILE%" - waiting for the host service.
exit /b 1 exit /b 1
) )
if not exist "%CERTFILE%" (
echo [punktfunk-web] host identity cert not present yet at "%CERTFILE%" - waiting for the host service.
exit /b 1
)
rem Both files are single KEY=VALUE lines (LF), written 0600/ACL'd: PUNKTFUNK_MGMT_TOKEN=... and rem Both files are single KEY=VALUE lines (LF), written 0600/ACL'd: PUNKTFUNK_MGMT_TOKEN=... and
rem PUNKTFUNK_UI_PASSWORD=... . Split on the first '=' and import each into the environment. rem PUNKTFUNK_UI_PASSWORD=... . Split on the first '=' and import each into the environment.
@@ -30,6 +38,10 @@ set "PORT=3000"
set "HOST=0.0.0.0" set "HOST=0.0.0.0"
set "PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990" set "PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990"
set "NODE_TLS_REJECT_UNAUTHORIZED=0" set "NODE_TLS_REJECT_UNAUTHORIZED=0"
rem Serve HTTPS (HTTP/1.1 over TLS) with the host's identity cert; mark the session cookie Secure.
set "PUNKTFUNK_UI_TLS_CERT=%CERTFILE%"
set "PUNKTFUNK_UI_TLS_KEY=%KEYFILE%"
set "PUNKTFUNK_UI_SECURE=1"
set "BUN=%~dp0..\bun\bun.exe" set "BUN=%~dp0..\bun\bun.exe"
set "SERVER=%~dp0.output\server\index.mjs" set "SERVER=%~dp0.output\server\index.mjs"
+15 -3
View File
@@ -1,7 +1,8 @@
# punktfunk web — management console (Nitro/Node server) configuration. # punktfunk web — management console (Nitro server on bun) configuration.
# Copy to `.env` (gitignored) or set these in the environment of `node .output/server/index.mjs`. # Copy to `.env` (gitignored) or set these in the environment of `bun .output/server/index.mjs`.
# NOTE: on a packaged install (the punktfunk-web .deb) you edit NOTHING — the systemd --user units # NOTE: on a packaged install (the punktfunk-web .deb) you edit NOTHING — the systemd --user units
# auto-wire these from the host's ~/.config/punktfunk/{mgmt-token,web-password}. See web.env.example. # auto-wire these from the host's ~/.config/punktfunk/{mgmt-token,web-password,cert.pem,key.pem}.
# See web.env.example.
# REQUIRED in production: the shared login password for the console. The built Nitro # REQUIRED in production: the shared login password for the console. The built Nitro
# server fails CLOSED (503 on every request) if this is unset, so a LAN-exposed server # server fails CLOSED (503 on every request) if this is unset, so a LAN-exposed server
@@ -27,6 +28,17 @@ NODE_TLS_REJECT_UNAUTHORIZED=0
# from PUNKTFUNK_UI_PASSWORD (changing the password then invalidates sessions). # from PUNKTFUNK_UI_PASSWORD (changing the password then invalidates sessions).
# PUNKTFUNK_UI_SECRET= # PUNKTFUNK_UI_SECRET=
# TLS: serve the console over HTTPS (HTTP/1.1 over TLS) using the HOST's own identity cert (the cert
# native clients already pin). Point these at the host's PEM files; BOTH set ⇒ HTTPS. Unset ⇒ plain
# HTTP (local dev only). (No HTTP/2 or HTTP/3: Bun.serve has no HTTP/2 server, and a browser won't
# speak HTTP/3/QUIC against this self-signed, no-SAN host cert.)
PUNKTFUNK_UI_TLS_CERT=/home/you/.config/punktfunk/cert.pem
PUNKTFUNK_UI_TLS_KEY=/home/you/.config/punktfunk/key.pem
# REQUIRED when serving over TLS: mark the session cookie Secure (browsers drop a Secure cookie over
# plain http://, so it is OFF by default; turn it ON whenever PUNKTFUNK_UI_TLS_* is set).
PUNKTFUNK_UI_SECURE=1
# The Bun server binds these (standard Nitro env): # The Bun server binds these (standard Nitro env):
# PORT=3000 # PORT=3000
# HOST=0.0.0.0 # HOST=0.0.0.0
+5 -2
View File
@@ -4,10 +4,11 @@ import "../src/styles.css";
// The console loads its brand typeface separately (in __root.tsx); do the same // The console loads its brand typeface separately (in __root.tsx); do the same
// here or every story falls back to system-ui and looks off. // here or every story falls back to system-ui and looks off.
import "@fontsource-variable/geist"; import "@fontsource-variable/geist";
import { useEffect } from "react";
import { definePreview } from "@storybook/react-vite"; import { definePreview } from "@storybook/react-vite";
import { MaterialProvider, defaultMaterialTheme } from "@unom/ui/material";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { defaultMaterialTheme, MaterialProvider } from "@unom/ui/material";
import Section from "@unom/ui/section";
import { useEffect } from "react";
// React Query is present so any query-backed component mounts without a real // React Query is present so any query-backed component mounts without a real
// host. Stories should feed mock data rather than fetch — retries are off so a // host. Stories should feed mock data rather than fetch — retries are off so a
@@ -51,11 +52,13 @@ export default definePreview({
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<MaterialProvider theme={defaultMaterialTheme}> <MaterialProvider theme={defaultMaterialTheme}>
<div className={dark ? "dark" : ""}> <div className={dark ? "dark" : ""}>
<Section maxWidth={false}>
<div <div
className={`min-h-screen bg-background text-foreground ${fullscreen ? "" : "p-6"}`} className={`min-h-screen bg-background text-foreground ${fullscreen ? "" : "p-6"}`}
> >
<Story /> <Story />
</div> </div>
</Section>
</div> </div>
</MaterialProvider> </MaterialProvider>
</QueryClientProvider> </QueryClientProvider>
+23 -8
View File
@@ -40,19 +40,30 @@ If the host runs with `--mgmt-token`, set it under **Settings → API token** (s
## Build & run (Nitro + Bun) ## Build & run (Nitro + Bun)
The console runs on **bun** (`Bun.serve` is a Bun API — node can't run it): Nitro's `bun` preset
plus a custom entry (`nitro-entry/bun-https.mjs`) that calls `Bun.serve({ tls })`, so it serves
**HTTPS (HTTP/1.1 over TLS)** with the **host's own identity cert** (the cert native clients already
pin). One trust anchor across the data plane, the mgmt API, and this console. (No HTTP/2 — `Bun.serve`
has no h2 server — and no HTTP/3, which a browser won't speak against this self-signed, no-SAN host
cert; a browser-trusted, SAN-matching cert + a fronting server would be needed, out of scope for a
LAN console.)
```sh ```sh
bun run build # → .output/ (Nitro server, `bun` preset, + .output/public assets) bun run build # → .output/ (Nitro `bun` preset + our Bun.serve TLS entry)
PORT=3000 HOST=0.0.0.0 \ PORT=3000 HOST=0.0.0.0 \
PUNKTFUNK_UI_PASSWORD=PUNKTFUNK_MGMT_TOKEN=\ PUNKTFUNK_UI_PASSWORD=PUNKTFUNK_MGMT_TOKEN=\
PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990 NODE_TLS_REJECT_UNAUTHORIZED=0 \ PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990 NODE_TLS_REJECT_UNAUTHORIZED=0 \
PUNKTFUNK_UI_TLS_CERT=~/.config/punktfunk/cert.pem \
PUNKTFUNK_UI_TLS_KEY=~/.config/punktfunk/key.pem PUNKTFUNK_UI_SECURE=1 \
bun run start # = bun run .output/server/index.mjs bun run start # = bun run .output/server/index.mjs
# (the mgmt API is HTTPS w/ the host's self-signed cert on loopback → the proxy's fetch needs # PUNKTFUNK_UI_TLS_* unset ⇒ plain HTTP (local dev); both set ⇒ HTTPS (HTTP/1.1 over TLS).
# NODE_TLS_REJECT_UNAUTHORIZED=0; it makes no other outbound TLS calls. See .env.example.) # NODE_TLS_REJECT_UNAUTHORIZED=0 is only for the proxy's loopback fetch to the host's self-signed
# mgmt cert; the console makes no other outbound TLS calls. See .env.example.
bun run lint # tsc --noEmit bun run lint # tsc --noEmit
``` ```
The built **Nitro Bun server** SSR-renders the app and is the only thing exposed on the LAN. The built **Nitro bun server** SSR-renders the app and is the only thing exposed on the LAN.
Run it on the same box as the host; it serves the console on `:3000` (or `$PORT`). Run it on the same box as the host; it serves the console over HTTPS on `:3000` (or `$PORT`).
## Auth (backend-for-frontend) ## Auth (backend-for-frontend)
@@ -62,10 +73,14 @@ Single-user, login-gated. Config via env (see `.env.example`):
**sealed session cookie** (h3 `useSession`, AES-GCM). `server/middleware/auth.ts` gates **sealed session cookie** (h3 `useSession`, AES-GCM). `server/middleware/auth.ts` gates
*every* request — pages redirect to `/login`, `/api` returns 401 — and **fails closed** *every* request — pages redirect to `/login`, `/api` returns 401 — and **fails closed**
(503) if `PUNKTFUNK_UI_PASSWORD` is unset, so a misconfigured LAN server admits no one. (503) if `PUNKTFUNK_UI_PASSWORD` is unset, so a misconfigured LAN server admits no one.
- The **management API stays loopback-only + token** — never LAN-exposed. The web server - The **bearer-token admin surface of the management API is loopback-only** — the host honors a
bearer token only from a loopback peer, so the admin API is never LAN-exposed. The web server
holds `PUNKTFUNK_MGMT_TOKEN` server-side and injects it when proxying `/api/**` holds `PUNKTFUNK_MGMT_TOKEN` server-side and injects it when proxying `/api/**`
`PUNKTFUNK_MGMT_URL` (`server/routes/api/[...].ts`). **The token never reaches the `PUNKTFUNK_MGMT_URL` (loopback; `server/routes/api/[...].ts`). **The token never reaches the
browser**; the browser only ever holds the session cookie. browser**; the browser only ever holds the session cookie. (The host *also* binds the
**read-only** surface — host status + the game library — to the LAN so paired native clients can
fetch it directly over mTLS; that path uses client certs, not the token, and never touches this
console.)
So: `browser ──password──▶ web server (session cookie) ──mgmt token, server-side──▶ mgmt API`. So: `browser ──password──▶ web server (session cookie) ──mgmt token, server-side──▶ mgmt API`.
Run the host with a matching token: `cargo run -rp punktfunk-host -- serve` + Run the host with a matching token: `cargo run -rp punktfunk-host -- serve` +
+17 -7
View File
@@ -1,15 +1,13 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.4.10/schema.json", "$schema": "https://biomejs.dev/schemas/2.5.1/schema.json",
"vcs": { "vcs": {
"enabled": false, "enabled": true,
"clientKind": "git", "clientKind": "git",
"useIgnoreFile": false "useIgnoreFile": true
}, },
"files": { "files": {
"ignoreUnknown": false, "ignoreUnknown": false,
"includes": [ "includes": ["**"]
"**"
]
}, },
"css": { "css": {
"parser": { "parser": {
@@ -30,7 +28,7 @@
"linter": { "linter": {
"enabled": true, "enabled": true,
"rules": { "rules": {
"recommended": true, "preset": "recommended",
"suspicious": { "suspicious": {
"noUnknownAtRules": "off", "noUnknownAtRules": "off",
"noArrayIndexKey": "off" "noArrayIndexKey": "off"
@@ -41,5 +39,17 @@
"formatter": { "formatter": {
"quoteStyle": "double" "quoteStyle": "double"
} }
},
"overrides": [
{
"includes": ["server/**", "nitro-entry/**"],
"linter": {
"rules": {
"correctness": {
"useHookAtTopLevel": "off"
} }
} }
}
}
]
}
+5 -1
View File
@@ -59,6 +59,9 @@
"pairing_native_devices": "Gekoppelte Geräte", "pairing_native_devices": "Gekoppelte Geräte",
"pairing_native_empty": "Noch keine Geräte gekoppelt.", "pairing_native_empty": "Noch keine Geräte gekoppelt.",
"pairing_native_unpair_confirm": "Dieses Gerät entkoppeln? Es muss sich erneut koppeln, um zu verbinden.", "pairing_native_unpair_confirm": "Dieses Gerät entkoppeln? Es muss sich erneut koppeln, um zu verbinden.",
"pairing_protocol": "Protokoll",
"pairing_protocol_native": "punktfunk/1",
"pairing_protocol_moonlight": "Moonlight",
"pairing_pending_title": "Warten auf Freigabe", "pairing_pending_title": "Warten auf Freigabe",
"pairing_pending_desc": "Diese Geräte haben versucht, sich zu verbinden. Eine Freigabe koppelt das Gerät sofort — ohne PIN.", "pairing_pending_desc": "Diese Geräte haben versucht, sich zu verbinden. Eine Freigabe koppelt das Gerät sofort — ohne PIN.",
"pairing_pending_approve": "Freigeben", "pairing_pending_approve": "Freigeben",
@@ -100,7 +103,8 @@
"common_cancel": "Abbrechen", "common_cancel": "Abbrechen",
"common_unauthorized": "Sitzung abgelaufen — Weiterleitung zur Anmeldung…", "common_unauthorized": "Sitzung abgelaufen — Weiterleitung zur Anmeldung…",
"login_title": "Anmelden", "login_title": "Anmelden",
"login_subtitle": "Gib das Verwaltungspasswort ein, um fortzufahren.", "login_subtitle": "Gib das Verwaltungspasswort ein, um fortzufahren. Du weißt nicht weiter?",
"login_docs_link": "Besuche die Dokumentation",
"login_password": "Passwort", "login_password": "Passwort",
"login_submit": "Anmelden", "login_submit": "Anmelden",
"login_error": "Falsches Passwort.", "login_error": "Falsches Passwort.",
+5 -1
View File
@@ -59,6 +59,9 @@
"pairing_native_devices": "Paired devices", "pairing_native_devices": "Paired devices",
"pairing_native_empty": "No devices paired yet.", "pairing_native_empty": "No devices paired yet.",
"pairing_native_unpair_confirm": "Unpair this device? It will need to pair again to connect.", "pairing_native_unpair_confirm": "Unpair this device? It will need to pair again to connect.",
"pairing_protocol": "Protocol",
"pairing_protocol_native": "punktfunk/1",
"pairing_protocol_moonlight": "Moonlight",
"pairing_pending_title": "Waiting for approval", "pairing_pending_title": "Waiting for approval",
"pairing_pending_desc": "These devices tried to connect. Approving pairs a device immediately — no PIN needed.", "pairing_pending_desc": "These devices tried to connect. Approving pairs a device immediately — no PIN needed.",
"pairing_pending_approve": "Approve", "pairing_pending_approve": "Approve",
@@ -100,7 +103,8 @@
"common_cancel": "Cancel", "common_cancel": "Cancel",
"common_unauthorized": "Session expired — redirecting to sign in…", "common_unauthorized": "Session expired — redirecting to sign in…",
"login_title": "Sign in", "login_title": "Sign in",
"login_subtitle": "Enter the management password to continue.", "login_subtitle": "Enter the management password to continue. Don't know what to do?",
"login_docs_link": "Visit the documentation",
"login_password": "Password", "login_password": "Password",
"login_submit": "Sign in", "login_submit": "Sign in",
"login_error": "Wrong password.", "login_error": "Wrong password.",
+69
View File
@@ -0,0 +1,69 @@
// Custom Nitro server entry for the punktfunk web console.
//
// It is the stock Nitro `bun` preset entry
// (node_modules/nitropack/dist/presets/bun/runtime/bun.mjs) plus **TLS**, so the console is served
// over **HTTPS (HTTP/1.1 over TLS)** using the HOST's own identity cert (the cert native clients
// already pin). One trust anchor across the data plane, the management API, and this console. Wired
// in via `entry:` in vite.config.ts on top of Nitro's `bun` preset (which bundles the handler in).
//
// NOTE on HTTP/2 + HTTP/3: NOT offered here, on purpose. `Bun.serve` has no HTTP/2 server, and
// HTTP/3 (which Bun *can* do) is useless to a browser against this cert: QUIC refuses any cert error,
// and the host identity cert is a CN-only, no-SAN, self-signed cert (correct for native fingerprint
// PINNING, rejected by browsers). So browsers stay on HTTP/1.1 regardless — advertising h3 would just
// dangle an `Alt-Svc` no browser can use. Real h2/h3 would need a browser-TRUSTED, SAN-matching cert
// (a local CA installed per device) fronted by a server that speaks them (e.g. Caddy) — deliberately
// out of scope for a LAN console; TLS (no cleartext login/session) is the win.
//
// Env (set by the launchers / the systemd unit — see web.env.example):
// PUNKTFUNK_UI_TLS_CERT / _KEY PEM file paths (the host's cert.pem / key.pem). BOTH set ⇒ HTTPS.
// Unset ⇒ plain HTTP (local dev only).
// PORT / HOST standard Nitro bind (3000 / 0.0.0.0).
import "#nitro-internal-pollyfills";
import wsAdapter from "crossws/adapters/bun";
import { useNitroApp } from "nitropack/runtime";
import { startScheduleRunner } from "nitropack/runtime/internal";
const nitroApp = useNitroApp();
const ws = import.meta._websocket
? wsAdapter(nitroApp.h3App.websocket)
: undefined;
// TLS from the host's identity cert (file PATHS → Bun.file, not PEM-in-env). Absent ⇒ plain HTTP.
const certPath = process.env.PUNKTFUNK_UI_TLS_CERT;
const keyPath = process.env.PUNKTFUNK_UI_TLS_KEY;
const tls =
certPath && keyPath
? { cert: Bun.file(certPath), key: Bun.file(keyPath) }
: undefined;
const server = Bun.serve({
port: process.env.NITRO_PORT || process.env.PORT || 3000,
host: process.env.NITRO_HOST || process.env.HOST,
idleTimeout:
Number.parseInt(process.env.NITRO_BUN_IDLE_TIMEOUT, 10) || undefined,
// `tls: undefined` ⇒ plain HTTP (dev); otherwise HTTPS over HTTP/1.1.
tls,
websocket: import.meta._websocket ? ws.websocket : undefined,
async fetch(req, server) {
if (import.meta._websocket && req.headers.get("upgrade") === "websocket") {
return ws.handleUpgrade(req, server);
}
const url = new URL(req.url);
let body;
if (req.body) {
body = await req.arrayBuffer();
}
return nitroApp.localFetch(url.pathname + url.search, {
host: url.hostname,
protocol: url.protocol,
headers: req.headers,
method: req.method,
redirect: req.redirect,
body,
});
},
});
console.log(`punktfunk web console listening on ${server.url} (tls=${!!tls})`);
if (import.meta._tasks) {
startScheduleRunner();
}
+1 -1
View File
@@ -11,9 +11,9 @@ import {
} from "h3"; } from "h3";
import { import {
isPublicPath, isPublicPath,
type SessionData,
sessionConfig, sessionConfig,
uiPassword, uiPassword,
type SessionData,
} from "../util/auth"; } from "../util/auth";
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
+2 -2
View File
@@ -1,12 +1,12 @@
// POST /_auth/login {password} — verify the shared password (constant-time), then seal an // POST /_auth/login {password} — verify the shared password (constant-time), then seal an
// authenticated session cookie. Public (allowlisted in the gate) so an unauthenticated user // authenticated session cookie. Public (allowlisted in the gate) so an unauthenticated user
// can actually log in. // can actually log in.
import { defineEventHandler, readBody, createError, useSession } from "h3"; import { createError, defineEventHandler, readBody, useSession } from "h3";
import { import {
type SessionData,
sessionConfig, sessionConfig,
timingSafeEqual, timingSafeEqual,
uiPassword, uiPassword,
type SessionData,
} from "../../util/auth"; } from "../../util/auth";
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
+1 -1
View File
@@ -1,6 +1,6 @@
// POST /_auth/logout — clear the session cookie. // POST /_auth/logout — clear the session cookie.
import { defineEventHandler, useSession } from "h3"; import { defineEventHandler, useSession } from "h3";
import { sessionConfig, type SessionData } from "../../util/auth"; import { type SessionData, sessionConfig } from "../../util/auth";
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const session = await useSession<SessionData>(event, sessionConfig()); const session = await useSession<SessionData>(event, sessionConfig());
+1 -1
View File
@@ -87,7 +87,7 @@ export function isPublicPath(pathname: string): boolean {
/** Validate a post-login redirect target: a same-origin path only. Rejects protocol- /** Validate a post-login redirect target: a same-origin path only. Rejects protocol-
* relative (`//evil.com`) and absolute URLs to prevent an open redirect. */ * relative (`//evil.com`) and absolute URLs to prevent an open redirect. */
export function safeNextPath(next: string | undefined): string { export function safeNextPath(next: string | undefined): string {
if (!next || !next.startsWith("/") || next.startsWith("//")) return "/"; if (!next?.startsWith("/") || next.startsWith("//")) return "/";
return next; return next;
} }
+4 -10
View File
@@ -6,7 +6,6 @@ import {
LibraryBig, LibraryBig,
Server, Server,
Settings, Settings,
Users,
} from "lucide-react"; } from "lucide-react";
import { motion, stagger } from "motion/react"; import { motion, stagger } from "motion/react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
@@ -23,17 +22,10 @@ const NAV = [
{ to: "/host", icon: Server, label: () => m.nav_host() }, { to: "/host", icon: Server, label: () => m.nav_host() },
{ to: "/library", icon: LibraryBig, label: () => m.nav_library() }, { to: "/library", icon: LibraryBig, label: () => m.nav_library() },
{ to: "/stats", icon: GaugeCircle, label: () => m.nav_stats() }, { to: "/stats", icon: GaugeCircle, label: () => m.nav_stats() },
{ to: "/clients", icon: Users, label: () => m.nav_clients() },
{ to: "/pairing", icon: KeyRound, label: () => m.nav_pairing() }, { to: "/pairing", icon: KeyRound, label: () => m.nav_pairing() },
{ to: "/settings", icon: Settings, label: () => m.nav_settings() }, { to: "/settings", icon: Settings, label: () => m.nav_settings() },
] as const; ] as const;
// Staggered entrance for the sidebar nav: each item fans in from the left a beat
// after the previous. Per-item delays (rather than a parent stagger) keep every
// item independent, so none can be left mid-orchestration / invisible.
const NAV_ENTER_DELAY = 0.08;
const NAV_ENTER_STEP = 0.06;
export function AppShell({ children }: { children: ReactNode }) { export function AppShell({ children }: { children: ReactNode }) {
// Read the locale so the whole shell re-renders on a language switch. // Read the locale so the whole shell re-renders on a language switch.
useLocale(); useLocale();
@@ -58,7 +50,7 @@ export function AppShell({ children }: { children: ReactNode }) {
variants={{ enter: {}, from: {} }} variants={{ enter: {}, from: {} }}
className="flex flex-col gap-1" className="flex flex-col gap-1"
> >
{NAV.map(({ to, icon: Icon, label }, i) => ( {NAV.map(({ to, icon: Icon, label }) => (
<MLink <MLink
key={to} key={to}
variants={{ variants={{
@@ -103,7 +95,7 @@ export function AppShell({ children }: { children: ReactNode }) {
<main className="flex-1"> <main className="flex-1">
{/* pb-24 leaves room for the fixed bottom nav on mobile. */} {/* pb-24 leaves room for the fixed bottom nav on mobile. */}
<div className="mx-auto max-w-5xl p-6 pb-24 sm:p-10 sm:pb-10"> <div className="mx-auto max-w-[1700px] p-6 pb-24 sm:p-10 sm:pb-10">
{children} {children}
</div> </div>
</main> </main>
@@ -138,10 +130,12 @@ export function AppShell({ children }: { children: ReactNode }) {
function LanguageSwitcher() { function LanguageSwitcher() {
const current = useLocale(); const current = useLocale();
return ( return (
// biome-ignore lint/a11y/useSemanticElements: an aria-labelled role="group" is the right pattern for this small control cluster — no single semantic element fits.
<div className="flex gap-1" role="group" aria-label="Language"> <div className="flex gap-1" role="group" aria-label="Language">
{locales.map((l: Locale) => ( {locales.map((l: Locale) => (
<button <button
key={l} key={l}
type="button"
onClick={() => changeLocale(l)} onClick={() => changeLocale(l)}
className={cn( className={cn(
"rounded px-2 py-1 text-xs uppercase transition-colors", "rounded px-2 py-1 text-xs uppercase transition-colors",
-40
View File
@@ -1,40 +0,0 @@
import { motion, useReducedMotion } from "motion/react";
import { Children, type ReactNode } from "react";
import { cn } from "@/lib/utils";
/**
* Page content wrapper that animates in on mount — so the content fans up into
* place every time you navigate or load a route (the route remounts, this
* remounts). Each direct child is staggered a beat after the previous (the same
* on-mount-delay pattern the sidebar nav uses). Honours prefers-reduced-motion.
*/
export function Section({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
const reduce = useReducedMotion();
return (
<div className={cn("flex flex-col gap-6", className)}>
{Children.map(children, (child, i) =>
reduce ? (
child
) : (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{
delay: 0.03 + i * 0.07,
duration: 0.42,
ease: [0.16, 1, 0.3, 1],
}}
>
{child}
</motion.div>
),
)}
</div>
);
}
+5 -2
View File
@@ -57,7 +57,7 @@ const TableHead = React.forwardRef<
<th <th
ref={ref} ref={ref}
className={cn( className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0", "h-10 px-card text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className, className,
)} )}
{...props} {...props}
@@ -71,7 +71,10 @@ const TableCell = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<td <td
ref={ref} ref={ref}
className={cn("p-2 align-middle [&:has([role=checkbox])]:pr-0", className)} className={cn(
"p-card py-2 align-middle [&:has([role=checkbox])]:pr-0",
className,
)}
{...props} {...props}
/> />
)); ));
+8
View File
@@ -1,7 +1,15 @@
import { type ClassValue, clsx } from "clsx"; import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { m } from "@/paraglide/messages";
/** shadcn/ui's class combiner: merge conditional classes, dedupe Tailwind conflicts. */ /** shadcn/ui's class combiner: merge conditional classes, dedupe Tailwind conflicts. */
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
} }
/** Seconds since a knock → a short relative label. */
export function fmtAge(secs: number): string {
if (secs < 10) return m.pairing_pending_age_just_now();
if (secs < 60) return m.pairing_pending_age_secs({ s: Math.floor(secs) });
return m.pairing_pending_age_mins({ min: Math.floor(secs / 60) });
}
-4
View File
@@ -1,4 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { SectionClients } from "@/sections/Clients";
export const Route = createFileRoute("/clients")({ component: SectionClients });
-36
View File
@@ -1,36 +0,0 @@
import { useQueryClient } from "@tanstack/react-query";
import type { FC } from "react";
import {
getListPairedClientsQueryKey,
useListPairedClients,
useUnpairClient,
} from "@/api/gen/clients/clients";
import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
import { ClientsView } from "./view";
export const SectionClients: FC = () => {
useLocale();
const qc = useQueryClient();
const clients = useListPairedClients();
const unpair = useUnpairClient();
const onUnpair = (fingerprint: string) => {
if (!confirm(m.clients_unpair_confirm())) return;
unpair.mutate(
{ fingerprint },
{
onSuccess: () =>
qc.invalidateQueries({ queryKey: getListPairedClientsQueryKey() }),
},
);
};
return (
<ClientsView
clients={clients}
onUnpair={onUnpair}
isUnpairing={unpair.isPending}
/>
);
};
-80
View File
@@ -1,80 +0,0 @@
import { Trash2 } from "lucide-react";
import type { FC } from "react";
import type { PairedClient } from "@/api/gen/model/pairedClient";
import { QueryState } from "@/components/query-state";
import { Section } from "@/components/section";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
export const ClientsView: FC<{
clients: Loadable<PairedClient[]>;
onUnpair: (fingerprint: string) => void;
isUnpairing: boolean;
}> = ({ clients, onUnpair, isUnpairing }) => {
const rows = clients.data ?? [];
return (
<Section>
<h1 className="text-2xl font-semibold">{m.clients_title()}</h1>
<QueryState
isLoading={clients.isLoading}
error={clients.error}
refetch={clients.refetch}
>
{rows.length === 0 ? (
<Card>
<CardContent className="p-8 text-center text-sm text-muted-foreground">
{m.clients_empty()}
</CardContent>
</Card>
) : (
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>{m.clients_name()}</TableHead>
<TableHead>{m.clients_fingerprint()}</TableHead>
<TableHead className="w-12" />
</TableRow>
</TableHeader>
<TableBody>
{rows.map((c) => (
<TableRow key={c.fingerprint}>
<TableCell className="font-medium">
{c.subject || "—"}
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{c.fingerprint.slice(0, 16)}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
aria-label={m.action_unpair()}
disabled={isUnpairing}
onClick={() => onUnpair(c.fingerprint)}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</QueryState>
</Section>
);
};
+6 -4
View File
@@ -1,8 +1,8 @@
import Section from "@unom/ui/section";
import { MonitorPlay, RefreshCw, Video, Volume2, ZapOff } from "lucide-react"; import { MonitorPlay, RefreshCw, Video, Volume2, ZapOff } from "lucide-react";
import type { FC, ReactNode } from "react"; import type { FC, ReactNode } from "react";
import type { RuntimeStatus } from "@/api/gen/model/runtimeStatus"; import type { RuntimeStatus } from "@/api/gen/model/runtimeStatus";
import { QueryState } from "@/components/query-state"; import { QueryState } from "@/components/query-state";
import { Section } from "@/components/section";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -18,7 +18,8 @@ export const DashboardView: FC<{
}> = ({ status, onStopSession, onRequestIdr, isStopping, isRequestingIdr }) => { }> = ({ status, onStopSession, onRequestIdr, isStopping, isRequestingIdr }) => {
const s = status.data; const s = status.data;
return ( return (
<Section> <Section maxWidth={false}>
<div className="flex flex-col gap-card">
<h1 className="text-2xl font-semibold">{m.status_title()}</h1> <h1 className="text-2xl font-semibold">{m.status_title()}</h1>
<QueryState <QueryState
isLoading={status.isLoading} isLoading={status.isLoading}
@@ -26,8 +27,8 @@ export const DashboardView: FC<{
refetch={status.refetch} refetch={status.refetch}
> >
{s && ( {s && (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-card">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-card sm:grid-cols-2 lg:grid-cols-4">
<StatCard <StatCard
icon={<Video className="size-4" />} icon={<Video className="size-4" />}
label={m.status_video()} label={m.status_video()}
@@ -117,6 +118,7 @@ export const DashboardView: FC<{
</div> </div>
)} )}
</QueryState> </QueryState>
</div>
</Section> </Section>
); );
}; };
+9 -5
View File
@@ -1,8 +1,8 @@
import Section from "@unom/ui/section";
import type { FC } from "react"; import type { FC } from "react";
import type { AvailableCompositor } from "@/api/gen/model/availableCompositor"; import type { AvailableCompositor } from "@/api/gen/model/availableCompositor";
import type { HostInfo } from "@/api/gen/model/hostInfo"; import type { HostInfo } from "@/api/gen/model/hostInfo";
import { QueryState } from "@/components/query-state"; import { QueryState } from "@/components/query-state";
import { Section } from "@/components/section";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { Loadable } from "@/lib/query"; import type { Loadable } from "@/lib/query";
@@ -14,7 +14,8 @@ export const HostView: FC<{
}> = ({ host, compositors }) => { }> = ({ host, compositors }) => {
const h = host.data; const h = host.data;
return ( return (
<Section> <Section maxWidth={false}>
<div className="flex flex-col gap-card">
<h1 className="text-2xl font-semibold">{m.nav_host()}</h1> <h1 className="text-2xl font-semibold">{m.nav_host()}</h1>
<QueryState <QueryState
@@ -23,7 +24,7 @@ export const HostView: FC<{
refetch={host.refetch} refetch={host.refetch}
> >
{h && ( {h && (
<div className="grid gap-4 lg:grid-cols-2"> <div className="grid gap-card lg:grid-cols-2">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{m.host_identity()}</CardTitle> <CardTitle>{m.host_identity()}</CardTitle>
@@ -41,7 +42,7 @@ export const HostView: FC<{
</dl> </dl>
</CardContent> </CardContent>
</Card> </Card>
<div className="space-y-4"> <div className="space-y-card">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{m.host_codecs()}</CardTitle> <CardTitle>{m.host_codecs()}</CardTitle>
@@ -62,7 +63,9 @@ export const HostView: FC<{
<dl className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm tabular-nums"> <dl className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm tabular-nums">
{Object.entries(h.ports).map(([k, v]) => ( {Object.entries(h.ports).map(([k, v]) => (
<div key={k} className="flex justify-between"> <div key={k} className="flex justify-between">
<dt className="text-muted-foreground uppercase">{k}</dt> <dt className="text-muted-foreground uppercase">
{k}
</dt>
<dd className="font-medium">{v as number}</dd> <dd className="font-medium">{v as number}</dd>
</div> </div>
))} ))}
@@ -117,6 +120,7 @@ export const HostView: FC<{
</QueryState> </QueryState>
</CardContent> </CardContent>
</Card> </Card>
</div>
</Section> </Section>
); );
}; };
+108
View File
@@ -0,0 +1,108 @@
import { Pencil, Trash2 } from "lucide-react";
import { type FC, useState } from "react";
import type { GameEntry } from "@/api/gen/model/gameEntry";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { m } from "@/paraglide/messages";
/**
* Display label for a store badge. Steam and custom keep their localized strings; every other store
* (lutris, heroic, epic, …) is a proper noun shown capitalized, so new providers surface correctly
* without a translation per store.
*/
function storeLabel(store: string): string {
switch (store) {
case "custom":
return m.library_store_custom();
case "steam":
return m.library_store_steam();
default:
return store.charAt(0).toUpperCase() + store.slice(1);
}
}
export interface GameCardProps {
game: GameEntry;
onEdit: () => void;
onDelete: () => void;
deleting: boolean;
}
/**
* A poster tile. The cover prefers the 2:3 portrait capsule; on a load error it
* falls back to the wide header, then to a text placeholder. Custom entries get
* edit/delete affordances.
*/
export const GameCard: FC<GameCardProps> = ({
game,
onEdit,
onDelete,
deleting,
}) => {
const isCustom = game.store === "custom";
// Track which sources have failed so the <img> can step down portrait → header → placeholder.
const [failed, setFailed] = useState<Record<string, boolean>>({});
const candidates = [game.art.portrait, game.art.header].filter(
(u): u is string => !!u && !failed[u],
);
const src = candidates[0];
return (
<Card className="group relative overflow-hidden">
<div className="relative aspect-[2/3] bg-muted">
{src ? (
<img
src={src}
alt={game.title}
loading="lazy"
className="size-full object-cover"
onError={() => setFailed((prev) => ({ ...prev, [src]: true }))}
/>
) : (
<div className="flex size-full items-center justify-center p-3 text-center text-sm font-medium text-muted-foreground">
{game.title}
</div>
)}
<div className="absolute left-2 top-2">
<Badge
variant={isCustom ? "secondary" : "outline"}
className="bg-background/80 backdrop-blur"
>
{storeLabel(game.store)}
</Badge>
</div>
{isCustom && (
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
<Button
variant="secondary"
size="icon"
className="size-7 bg-background/80 backdrop-blur"
aria-label={m.library_edit()}
onClick={onEdit}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="secondary"
size="icon"
className="size-7 bg-background/80 backdrop-blur"
aria-label={m.library_delete()}
disabled={deleting}
onClick={onDelete}
>
<Trash2 className="size-3.5 text-destructive" />
</Button>
</div>
)}
</div>
<div
className="truncate px-card pb-card pt-4 text-sm font-medium"
title={game.title}
>
{game.title}
</div>
</Card>
);
};
+205
View File
@@ -0,0 +1,205 @@
import { useQueryClient } from "@tanstack/react-query";
import { X } from "lucide-react";
import { type FC, type FormEvent, useState } from "react";
import {
getGetLibraryQueryKey,
useCreateCustomGame,
useUpdateCustomGame,
} from "@/api/gen/library/library";
import type { CustomInput } from "@/api/gen/model/customInput";
import type { GameEntry } from "@/api/gen/model/gameEntry";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { m } from "@/paraglide/messages";
import { customId } from "./helpers";
interface FormState {
title: string;
portrait: string;
hero: string;
header: string;
command: string;
}
const emptyForm: FormState = {
title: "",
portrait: "",
hero: "",
header: "",
command: "",
};
function formFrom(entry: GameEntry): FormState {
return {
title: entry.title,
portrait: entry.art.portrait ?? "",
hero: entry.art.hero ?? "",
header: entry.art.header ?? "",
command: entry.launch?.kind === "command" ? entry.launch.value : "",
};
}
/** Map the form to the API body — only attach `launch` when a command was given. */
function toInput(f: FormState): CustomInput {
const trim = (s: string) => {
const t = s.trim();
return t ? t : undefined;
};
const command = f.command.trim();
return {
title: f.title.trim(),
art: {
portrait: trim(f.portrait),
hero: trim(f.hero),
header: trim(f.header),
},
launch: command ? { kind: "command", value: command } : null,
};
}
/** What the form targets: an existing custom entry to edit, or "new" for a fresh add. */
export type FormTarget = GameEntry | "new";
/**
* Container: the add/edit form — owns the create + update mutations and derives the
* initial field state from the target. Kept entirely separate from the overview grid
* (own file, own queries) so the two concerns don't share a component.
*/
export const GameFormSection: FC<{
target: FormTarget;
onClose: () => void;
}> = ({ target, onClose }) => {
const qc = useQueryClient();
const create = useCreateCustomGame();
const update = useUpdateCustomGame();
const invalidate = () =>
qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
const onSubmit = async (data: CustomInput) => {
if (target === "new") await create.mutateAsync({ data }).then(invalidate);
else
await update.mutateAsync({ id: customId(target), data }).then(invalidate);
onClose();
};
return (
<GameForm
initial={target === "new" ? emptyForm : formFrom(target)}
mode={target === "new" ? "add" : "edit"}
onSubmit={onSubmit}
onCancel={onClose}
isSaving={create.isPending || update.isPending}
/>
);
};
/**
* The add/edit form card. Owns only its own field state (re-seeded per mount — the
* parent keys it by target); reports a ready-to-send `CustomInput` on submit.
*/
export const GameForm: FC<{
initial: FormState;
mode: "add" | "edit";
onSubmit: (data: CustomInput) => void;
onCancel: () => void;
isSaving: boolean;
}> = ({ initial, mode, onSubmit, onCancel, isSaving }) => {
const [form, setForm] = useState<FormState>(initial);
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
const data = toInput(form);
if (!data.title) return;
onSubmit(data);
};
return (
<Card className="max-w-xl">
<CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle>
{mode === "edit" ? m.library_edit_title() : m.library_add_title()}
</CardTitle>
<Button
variant="ghost"
size="icon"
aria-label={m.library_cancel()}
onClick={onCancel}
>
<X className="size-4" />
</Button>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="lib-title">{m.library_field_title()}</Label>
<Input
id="lib-title"
required
value={form.title}
onChange={(e) =>
setForm((f) => ({ ...f, title: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lib-portrait">{m.library_field_portrait()}</Label>
<Input
id="lib-portrait"
type="url"
inputMode="url"
value={form.portrait}
onChange={(e) =>
setForm((f) => ({ ...f, portrait: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lib-hero">{m.library_field_hero()}</Label>
<Input
id="lib-hero"
type="url"
inputMode="url"
value={form.hero}
onChange={(e) => setForm((f) => ({ ...f, hero: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lib-header">{m.library_field_header()}</Label>
<Input
id="lib-header"
type="url"
inputMode="url"
value={form.header}
onChange={(e) =>
setForm((f) => ({ ...f, header: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lib-command">{m.library_field_command()}</Label>
<Input
id="lib-command"
value={form.command}
onChange={(e) =>
setForm((f) => ({ ...f, command: e.target.value }))
}
/>
<p className="text-xs text-muted-foreground">
{m.library_field_command_help()}
</p>
</div>
<div className="flex gap-2">
<Button type="submit" disabled={isSaving || !form.title.trim()}>
{mode === "edit" ? m.library_save() : m.library_create()}
</Button>
<Button type="button" variant="outline" onClick={onCancel}>
{m.library_cancel()}
</Button>
</div>
</form>
</CardContent>
</Card>
);
};
+87
View File
@@ -0,0 +1,87 @@
import { useQueryClient } from "@tanstack/react-query";
import { motion, stagger } from "motion/react";
import type { FC } from "react";
import {
getGetLibraryQueryKey,
useDeleteCustomGame,
useGetLibrary,
} from "@/api/gen/library/library";
import type { GameEntry } from "@/api/gen/model/gameEntry";
import { QueryState } from "@/components/query-state";
import { Card, CardContent } from "@/components/ui/card";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
import { GameCard } from "./GameCard";
import { customId } from "./helpers";
/**
* Container: the library OVERVIEW — owns the listing query and per-card delete.
* Editing is escalated to the parent (it opens the separate add/edit form), so
* this subsection knows nothing about the form beyond firing `onEdit`.
*/
export const LibraryGridSection: FC<{ onEdit: (entry: GameEntry) => void }> = ({
onEdit,
}) => {
const qc = useQueryClient();
const library = useGetLibrary();
const remove = useDeleteCustomGame();
const onDelete = async (entry: GameEntry) => {
if (!confirm(m.library_delete_confirm())) return;
await remove
.mutateAsync({ id: customId(entry) })
.then(() => qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() }));
};
return (
<LibraryGrid
library={library}
onEdit={onEdit}
onDelete={onDelete}
isDeleting={remove.isPending}
/>
);
};
/** The poster grid (with empty + loading/error states). */
export const LibraryGrid: FC<{
library: Loadable<GameEntry[]>;
onEdit: (entry: GameEntry) => void;
onDelete: (entry: GameEntry) => void;
isDeleting: boolean;
}> = ({ library, onEdit, onDelete, isDeleting }) => {
const games = library.data ?? [];
return (
<QueryState
isLoading={library.isLoading}
error={library.error}
refetch={library.refetch}
>
{games.length === 0 ? (
<Card>
<CardContent className="p-8 text-center text-sm text-muted-foreground">
{m.library_empty()}
</CardContent>
</Card>
) : (
<div className="@container">
<motion.div
transition={{ delayChildren: stagger(0.1) }}
variants={{ enter: {}, from: {} }}
className="grid grid-cols-1 gap-card @sm:grid-cols-2 @md:grid-cols-2 @lg:grid-cols-3 @2xl:grid-cols-4 @4xl:grid-cols-5"
>
{games.map((game) => (
<GameCard
key={game.id}
game={game}
onEdit={() => onEdit(game)}
onDelete={() => onDelete(game)}
deleting={isDeleting}
/>
))}
</motion.div>
</div>
)}
</QueryState>
);
};
+8
View File
@@ -0,0 +1,8 @@
import type { GameEntry } from "@/api/gen/model/gameEntry";
/** The custom-CRUD path param is the raw id without the `custom:` prefix. */
export function customId(entry: GameEntry): string {
return entry.id.startsWith("custom:")
? entry.id.slice("custom:".length)
: entry.id;
}

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