13 Commits

Author SHA1 Message Date
enricobuehler fbf3fea0c8 fix(packaging/windows): Inno [Code] comment brace-nesting trap broke ISCC
apple / swift (push) Successful in 1m17s
windows-host / package (push) Successful in 7m3s
apple / screenshots (push) Successful in 5m37s
android / android (push) Successful in 3m22s
ci / web (push) Successful in 48s
ci / rust (push) Successful in 1m18s
ci / bench (push) Successful in 4m59s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
ci / docs-site (push) Successful in 59s
deb / build-publish (push) Successful in 4m35s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 10m29s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 10m31s
docker / deploy-docs (push) Successful in 15s
The PublicFwParam doc comment contained a literal code-constant token; Inno's
{ } comments don't nest, so its closing brace ended the comment early and the
trailing text parsed as code ("'BEGIN' expected", compile aborted). Reworded to
avoid the literal braces + added a warning note. Verified: the [Code] section
has no other nested-brace-in-comment traps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:27:27 +00:00
enricobuehler c52ae119e1 fix: regenerate Cargo.lock for punktfunk-host's winresource build-dep
apple / swift (push) Successful in 1m10s
audit / cargo-audit (push) Successful in 1m12s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m8s
android / android (push) Successful in 3m49s
ci / web (push) Successful in 48s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m7s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 45s
ci / docs-site (push) Successful in 56s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 50s
release / apple (push) Successful in 8m51s
ci / rust (push) Successful in 5m51s
ci / bench (push) Successful in 4m51s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 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 3s
deb / build-publish (push) Successful in 4m46s
apple / screenshots (push) Successful in 5m34s
flatpak / build-publish (push) Successful in 5m33s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 10m52s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m43s
docker / deploy-docs (push) Failing after 18s
windows-host / package (push) Has been cancelled
The "Punktfunk Host" identity work added winresource to the host crate but
didn't update the lock, so every --locked CI job failed to resolve.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:17:31 +00:00
enricobuehler 5d7aabe8f0 fix(scripts/windows): keep deploy-host.ps1 pure ASCII (em-dash -> hyphen)
apple / swift (push) Successful in 1m8s
ci / rust (push) Failing after 50s
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 1m8s
android / android (push) Successful in 3m24s
deb / build-publish (push) Failing after 45s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
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 5s
windows-host / package (push) Failing after 6m18s
apple / screenshots (push) Successful in 5m35s
ci / bench (push) Successful in 4m41s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 3m25s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 3m26s
docker / deploy-docs (push) Successful in 17s
Two comment em-dashes I added tripped the installer-run ASCII guard (PS 5.1
mis-parses non-ASCII on non-UTF-8 locales).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:09:17 +00:00
enricobuehler f204a89cef perf(encode/windows): AMF quality=speed + bf=0; drop the useless poll spin
ci / rust (push) Failing after 48s
windows-host / package (push) Failing after 10s
apple / swift (push) Successful in 1m6s
ci / web (push) Successful in 51s
ci / docs-site (push) Successful in 1m8s
android / android (push) Successful in 3m20s
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 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
apple / screenshots (push) Successful in 5m10s
ci / bench (push) Successful in 4m43s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 3m25s
deb / build-publish (push) Failing after 44s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 3m21s
On-box A/B on the .173 Ryzen 7000 iGPU (720p60, real composition via input
injection — an idle virtual desktop composes ~1 fps and gives meaningless
encode timings): the encode-time-first `quality=speed` preset + explicit `bf=0`
cut host-side encode_us from ~36 ms to ~19.5 ms.

The blocking-poll idea from the prior commit was WRONG and is reverted to a
single non-blocking receive (default PUNKTFUNK_FFWIN_POLL_MS=0): libavcodec's
hevc_amf holds ~2 frames before releasing the oldest (needs frame N+2 to flush
N), so a spin between submits provably never yields the owed AU — verified with
a 150 ms cap pegging at exactly 150 ms across every usage preset and pipeline
depth. That ~2-frame buffer is inherent to the libavcodec wrapper, not host
scheduling; the real latency lever is a direct AMF SDK encoder (the AMF
analogue of the direct-NVENC path), tracked as the next AMD work item. The
env knob is retained for a future VCN/driver where a bounded spin can help.

Also measured and rejected: PUNKTFUNK_ZEROCOPY=1 on AMF is ~2x WORSE (68 ms vs
36 ms) — the D3D11 import path adds sync overhead beyond the readback it saves,
so the system-memory default stays. GPU-priority elevation is already
process-wide (dxgi.rs), so it covers the iGPU encode session with no change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:57:39 +00:00
enricobuehler 24fa018c70 chore(encode/windows): AMF forensics knobs — PUNKTFUNK_AMF_USAGE + PUNKTFUNK_FFWIN_POLL_MS
apple / swift (push) Successful in 1m6s
ci / web (push) Successful in 53s
deb / build-publish (push) Failing after 44s
windows-host / package (push) Failing after 10s
ci / rust (push) Failing after 49s
android / android (push) Successful in 3m33s
apple / screenshots (push) Successful in 5m18s
ci / docs-site (push) Successful in 57s
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 5m0s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 3m27s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 3m18s
The blocking poll landed but wait_us pegs at exactly the 2-frame-period cap:
AMF holds the AU ~2 frame periods regardless of retrieval. Field knobs to
bisect on-box (usage preset × poll cap) without rebuild cycles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:39:36 +00:00
enricobuehler 51a6ca7e02 fix(encode/windows): AMF latency — honor the loop's blocking-poll contract + preset polish
apple / swift (push) Successful in 1m6s
windows-drivers / driver-build (push) Successful in 1m34s
windows-drivers / probe-and-proto (push) Successful in 20s
ci / rust (push) Failing after 47s
ci / web (push) Successful in 52s
windows-host / package (push) Failing after 11s
ci / docs-site (push) Successful in 1m6s
android / android (push) Successful in 3m20s
deb / build-publish (push) Failing after 46s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 13s
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 43s
apple / screenshots (push) Successful in 5m11s
docker / deploy-docs (push) Successful in 19s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 3m27s
ci / bench (push) Successful in 4m43s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 3m24s
The session loop's pipeline deferral was designed around direct NVENC, whose
poll() BLOCKS in lock_bitstream; libavcodec's AMF wrapper is truly async
(EAGAIN until the ASIC finishes), so a single non-blocking receive quantized AU
retrieval to the submit cadence: +1–2 frame periods flat (~43 ms p50 at 720p60
on the Ryzen iGPU vs ~3.5 ms of actual encode). FfmpegWinEncoder now tracks
in-flight frames and, while an AU is owed, spin-polls with short sleeps bounded
to ~2 frame periods (an overloaded encoder degrades to next-tick pickup instead
of stalling capture). Also: quality=speed (latency-first, iGPU-class VCN),
explicit bf=0 (h264_amf defaults >0 on RDNA3+), AMF low-latency submission
mode (FFmpeg ≥6.1, ignored on older).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:32:41 +00:00
enricobuehler b9fde03f1e feat(security): finish Windows firewall Public opt-in wiring + vuln-disclosure + doc cleanup
Firewall (the service.rs core landed in efb1ba2): scope the web-console rule
(TCP 47992) to Domain+Private by default with a `--allow-public-network` opt-in
that deletes-then-re-adds the rule, and add the installer "Allow connections on
Public networks" task (unchecked) forwarding the flag to `service install` and
`web setup`. Default is now trusted-networks-only; Public is explicit.

Vulnerability disclosure: SECURITY.md (report to security@punktfunk.com, scope,
SLAs, safe harbor), a Gitea issue-template contact link, a README security line,
and a Reporting section on the docs Security page.

Docs: the Security page now documents the Private/Domain firewall default (and
how to fix a misclassified-Public network / opt in); removed internal design-doc
and CLAUDE.md links from the user-facing docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 14:08:17 +00:00
enricobuehler efb1ba26d7 fix(windows): opt-in pad-driver file logs + size-capped service log rotation
Two disk-write fixes:

- pf-xusb/pf-dualsense no longer write C:\Users\Public\pf*-driver.log
  unconditionally — the file log is now opt-in (debug builds, or the
  PFXUSB_DEBUG_LOG / PFDS_DEBUG_LOG system env var), mirroring the audit-§4.4
  fix pf-vdisplay already got: a release driver never writes the world-writable
  Public file (info-leak/DoS surface), and the per-report OUTPUT/SET_STATE hex
  dumps stop being a sustained per-rumble disk-write path during gameplay.
  OutputDebugStringA stays unconditional; the host's driver-silence WARN and
  the gamepad-driver-health failure-mode table now say the log is opt-in.

- service.log/host.log get one-generation rotation: at each (re)open a file
  over 10 MB is renamed to .old, so a crash-restart loop or a RUST_LOG=debug
  left in host.env can't grow the append-forever logs without bound. Rotation
  runs only before an open (never under a live appender — host.log's handle
  lacks FILE_SHARE_DELETE, so a racing rename harmlessly fails).

Windows CI compile/clippy pending (drivers workspace + host are not
Linux-cross-checkable); rides along with the next pad-driver redeploy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:03:32 +00:00
enricobuehler 1320e3dc66 fix(scripts/windows): deploy-host.ps1 builds all-vendor when an FFmpeg tree exists
windows-host / package (push) Failing after 20s
apple / swift (push) Successful in 1m9s
apple / screenshots (push) Successful in 5m26s
ci / rust (push) Failing after 48s
ci / web (push) Successful in 50s
ci / docs-site (push) Successful in 1m6s
android / android (push) Successful in 3m22s
deb / build-publish (push) Failing after 43s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 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 4m40s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 3m27s
docker / deploy-docs (push) Successful in 6s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 3m18s
The dev deploy built --features nvenc only, so a web-console GPU preference
pointing at an AMD/Intel adapter made every session die at encoder open
(NV_ENC_ERR_NO_ENCODE_DEVICE) — the exact "can't connect" just hit on the RTX
box's Ryzen iGPU. The script now enables amf-qsv when FFMPEG_DIR (machine env,
process env, or C:\Users\Public\ffmpeg) has a dev tree, and copies the FFmpeg
runtime DLLs next to the exe after a successful build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:52:55 +00:00
enricobuehler 1be83575b6 feat(host/windows): "Punktfunk Host" identity in Task Manager (icon + version info)
punktfunk-host.exe embedded no icon or version resources, so Task Manager and
Explorer showed a bare lowercase exe name with a generic icon. build.rs now
embeds the branded .ico + FileDescription "Punktfunk Host" / ProductName
"Punktfunk" via winresource (same pattern as the Windows client and the tray;
Linux packaging builds skip the block). The tray gets a matching "Punktfunk
Tray" description, and the SCM display name moves off lowercase
"punktfunk streaming host" to "Punktfunk Host" (applied idempotently by
`service install` on upgrade).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:52:55 +00:00
enricobuehler 4d1d20f832 chore: untrack CLAUDE.md
apple / swift (push) Successful in 1m14s
apple / screenshots (push) Successful in 5m45s
ci / rust (push) Successful in 1m15s
ci / web (push) Successful in 50s
ci / docs-site (push) Successful in 1m5s
android / android (push) Successful in 3m25s
deb / build-publish (push) Successful in 4m35s
ci / bench (push) Successful in 4m55s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (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 5s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 10m24s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 10m27s
docker / deploy-docs (push) Successful in 18s
Local per-box assistant instructions (incl. internal environment detail) don't
belong in the published tree; the file stays on disk, now gitignored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:22:23 +00:00
enricobuehler 6e875fea44 fix(apple/ios): the ACTUAL type-checker bomb was pointerSection's footer ternary chain
apple / swift (push) Successful in 1m15s
release / apple (push) Successful in 8m36s
apple / screenshots (push) Successful in 5m44s
ci / rust (push) Successful in 1m31s
ci / web (push) Successful in 56s
android / android (push) Successful in 10m1s
deb / build-publish (push) Successful in 4m33s
ci / bench (push) Successful in 4m52s
ci / docs-site (push) Successful in 1m24s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
decky / build-publish (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 10m20s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 10m19s
docker / deploy-docs (push) Failing after 14s
4f3cd24 split the wrong expression — act's log masking hid the real line number.
The unmasked retry pinpointed it: the pointerSection footer, a ten-segment
string + chain with an isPad ternary nesting four more, evaluated inside the
ViewBuilder. Moved the copy into a plain computed String built with +=
statements (linear to type-check); no text change. The two remaining 5-6
segment chains in Settings are compiled by the passing macOS slice, so they
are proven cheap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:17:01 +00:00
enricobuehler 4f3cd24036 fix(apple/ios): split streamModeSection — the inline iOS branch blew the type-checker budget
release / apple (push) Successful in 6m29s
ci / web (push) Successful in 1m2s
ci / docs-site (push) Successful in 1m13s
apple / swift (push) Successful in 1m5s
apple / screenshots (push) Successful in 4m5s
ci / bench (push) Successful in 4m36s
ci / rust (push) Successful in 11m23s
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 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
deb / build-publish (push) Successful in 4m26s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m45s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m37s
android / android (push) Successful in 9m37s
docker / deploy-docs (push) Successful in 19s
The Section's iOS content (resolution wheel + 3-way refresh rows + bitrate
rows) as ONE ViewBuilder expression hit "the compiler is unable to type-check
this expression in reasonable time" — failing exactly one build slice, the iOS
archive, so swift test (macOS) and the tvOS/macOS archives never saw it and the
0.6.0 iOS TestFlight upload soft-failed. Extracted iosResolutionWheel /
iosRefreshRows / bitrateRows; no behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:06:48 +00:00
24 changed files with 606 additions and 732 deletions
+9
View File
@@ -0,0 +1,9 @@
# Shown on the "new issue" chooser so security reports go to the private channel, not a public issue.
blank_issues_enabled: true
contact_links:
- name: 🔒 Report a security vulnerability
url: https://git.unom.io/unom/punktfunk/src/branch/main/SECURITY.md
about: >-
Found a security issue? Please report it privately by email to security@punktfunk.com — do not
open a public issue, so other users aren't exposed before a fix ships. See SECURITY.md for the
full policy.
+3
View File
@@ -31,3 +31,6 @@ xcuserdata/
# Python bytecode (e.g. clients/android/ci tooling)
__pycache__/
*.pyc
# Claude Code project instructions — local to each dev box, not part of the repo.
CLAUDE.md
-585
View File
@@ -1,585 +0,0 @@
# CLAUDE.md — punktfunk
Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protocol core
(`punktfunk-core`) exposed over a C ABI and native clients per platform. Full design:
[`design/implementation-plan.md`](design/implementation-plan.md). Status table: `README.md`.
## Where the work stands
- **Core (`punktfunk-core` + C ABI): complete and hardened.** FEC recovery, loopback-under-loss,
proptests, C ABI harness all green; 13 adversarial-review findings fixed +
regression-tested (`a913042`).
- **GameStream host: working end-to-end with a stock Moonlight client.** Validated live
on this box: pairing (persists across restarts), serverinfo/applist (app catalog from
`~/.config/punktfunk/apps.json` → each entry picks a compositor + nested command), RTSP, ENet
control, audio, and video at the **client's native resolution and refresh** — the host
creates a per-session virtual output via per-compositor `VirtualDisplay` backends:
**KWin** (`zkde_screencast stream_virtual_output`, needs KWin ≥ 6.5.6 headless; >60 Hz via
custom modes), **gamescope** (spawned headless at WxH@Hz, its PipeWire node captured, needs
gamescope ≥ 3.16.22 — older deadlocks on PipeWire ≥ 1.6), **Mutter** (D-Bus
`RecordVirtual` virtual monitor; validated live on headless GNOME Shell 50, zero-copy),
**Sway/wlroots** (`swaymsg create_output` + custom mode, xdpw portal capture with a
managed chooser config; validated live on sway 1.11, zero-copy).
Performance work landed and measured: GPU **zero-copy** on all paths (tiled dmabuf →
EGL/GL → CUDA; LINEAR dmabuf → **Vulkan bridge** → CUDA → NVENC), auto 2-way NVENC
split-encode above ~1 Gpix/s (5K@240), infinite GOP + RFI keyframes (killed the periodic
freeze), encode|send thread split with `sendmmsg` batching. Stable 240 fps at 5120×1440.
Input: mouse/keyboard (libei via RemoteDesktop portal on KWin/GNOME, gamescope's own EIS
socket, wlr protocols on Sway) and **gamepads** (uinput X-Box-360 pads + rumble
back-channel; validated live — pad created/destroyed with the session). Management REST API +
checked-in OpenAPI doc (`mgmt.rs`). **Web-console performance capture** (`stats_recorder.rs`,
design: [`design/stats-capture-plan.md`](design/stats-capture-plan.md)): the operator arms stats
recording from the web console, plays, stops, and reviews the run as graphs (per-stage latency
breakdown · fps new/repeat · goodput · loss/FEC). A shared `Arc<StatsRecorder>` ring (the hot-path
gate is a runtime `AtomicBool`, replacing the startup-only `PUNKTFUNK_PERF`) is fed by **both** the
native `virtual_stream` and the GameStream encode loop at their existing ~2 s/~1 s aggregation
boundary, and finished captures are saved as on-disk recordings
(`~/.config/punktfunk/captures/*.json`) browsable/exportable from the console's **Performance** page
(recharts). Endpoints `/api/v1/stats/*` (bearer-only). *Implemented; not yet on-glass validated.*
**Web-console log view** (`log_capture.rs`): a `tracing` layer tees DEBUG-and-up (independent of
`RUST_LOG`) into a 4096-entry in-memory ring, served cursor-paged at `GET /api/v1/logs`
(bearer-only) → the console's **Logs** page (follow/pause · level filter · search). The Windows
gamepad drivers now stamp attach/heartbeat marks into their shm sections and the host's
`DriverAttach` watcher turns silence into a one-shot diagnosis WARN (driver-store check + CM
devnode problem code) — failure-mode table: [`design/gamepad-driver-health.md`](design/gamepad-driver-health.md).
The Android client gained Settings → **Connected controllers** (device list + VID/PID + resolved
pad type + live input test) for the client end of the same chain. *Log view + driver health:
Linux-tested; Windows/Android sides CI/device-validation pending.*
- **Native protocol (`punktfunk/1`): full session planes, validated live.** QUIC
control plane (`punktfunk-core` `quic` feature: Hello{mode}/Welcome{full Config}/Start), data
plane = the hardened core `Session` over raw UDP with **GF(2¹⁶) Leopard FEC + AES-GCM**
(inexpressible in GameStream), host creates the native virtual output at the client's
requested mode. `punktfunk1-host` is a **persistent listener** (sessions back to back;
`--max-sessions`). QUIC datagrams carry the side planes, demuxed by first byte: input
0xC8 (incl. **gamepads** — incremental events accumulated into the uinput xpad), **Opus
audio** 0xC9 (48 kHz stereo, 5 ms, host→client), **rumble** 0xCA (host→client). **Trust:**
host serves its persistent identity (`~/.config/punktfunk/cert.pem`, shared with GameStream
pairing) and logs the SHA-256 fingerprint; clients pin it, established by a **SPAKE2 PIN pairing
ceremony** (host arms pairing and displays a 4-digit PIN; a PAKE binds both cert fingerprints so an
attacker gets one online guess, no offline dictionary attack) — PIN pairing is the default for new
hosts. **TOFU on first connect** (`endpoint::client_pinned`) stays as an explicit host opt-in
(`punktfunk1-host --allow-tofu` / `serve --open`, advertised as `pair=optional`) for fully trusted LANs;
clients only offer the TOFU "Trust" path for a host that advertised `pair=optional`, route every
other new host straight to the PIN ceremony, and on a pinned-fingerprint change force re-pairing
(no re-TOFU shortcut). Clients present persistent identities via QUIC client auth, the host stores
paired fingerprints (`punktfunk1-paired.json`) and gates sessions with `--require-pairing` (the
default; `--allow-tofu`/`--open` accept unpaired clients).
**LAN auto-discovery**: both `serve` and `punktfunk1-host` advertise the native service over
mDNS (`_punktfunk._udp`, `crate::discovery`) with TXT `proto`/`fp`(cert fingerprint to
pin)/`pair`(required|optional)/`id`; `punktfunk-probe --discover` lists hosts, Apple clients
browse the same service via NWBrowser (validated cross-LAN 2026-06-12).
**Mid-stream mode renegotiation**: `Reconfigure` on the still-open control stream — the
host rebuilds output+encoder at the new mode in ~90 ms while the data plane runs on
(validated live: one .h265 with 720p and 1080p segments). Measured on-box at 720p120: 1680/1680 frames, **p50 0.83 ms**
capture→…→reassembled; audio measured live (~200 pkts/s). A **wall-clock skew handshake**
(`ClockProbe`/`ClockEcho`, 8 NTP rounds after `Start`, `clock_offset_ns`) aligns the client to the
host clock, so that latency is now valid **cross-machine** (`skew_corrected=true`) — measured GNOME
box → dev box over the LAN: **p50 1.30 ms** (the 1.57 ms inter-box clock offset removed).
`punktfunk-probe` is the
working reference client (`--pin`, datagram counters, `--input-test` incl. gamepad).
The embeddable connector (`NativeClient`) exposes it all over the C ABI: `punktfunk_connect`
(pin/TOFU) + `next_au`/`next_audio`/`next_rumble`/`next_hidout`/`send_input`/
`send_rich_input`. **Client-negotiated virtual pad type**: the Hello carries a gamepad
preference byte (same trailing-byte back-compat pattern as the compositor), the Welcome
echoes the resolved backend — precedence: explicit client choice > `PUNKTFUNK_GAMEPAD`
env > uinput Xbox 360. Backends: **Xbox 360** (uinput on Linux / the pf-xusb UMDF driver on
Windows), **Xbox One/Series** (the same
XInput backend with the One/Series USB identity for matching glyphs — no extra game-visible
capability; impulse-trigger rumble is unreachable through a virtual pad), and the UHID
`hid-playstation` pads — **DualSense** (adaptive triggers, lightbar, touchpad, motion) and
**DualShock 4** (lightbar, touchpad, motion, rumble; DualSense minus adaptive triggers / player
LEDs / mute). DualSense and DualShock 4 each have a Linux (UHID `hid-playstation`) **and a Windows
(UMDF minidriver)** backend — `inject/windows/dualsense_windows.rs` + `inject/windows/dualshock4_windows.rs`, one
driver serving either identity per a `device_type` byte the host stamps into shared memory (the DS4
reuses the same SwDeviceCreate game-detection identity fix as the DualSense). One/Series stays
Linux-only and folds into Xbox 360 off it. Clients auto-resolve the type from the physical controller
(DS5→DualSense, DS4→DualShock 4, Xbox One→Xbox One). **Windows uses ZERO external gamepad
dependencies — ViGEmBus is gone.** Xbox 360 (XInput) runs on a UMDF2 **XUSB companion** driver
(`packaging/windows/drivers/pf-xusb/`, `inject/windows/gamepad_windows.rs`) that registers `GUID_DEVINTERFACE_XUSB`
and answers the buffered XInput IOCTLs from a shared section, so classic `XInputGetState`/`SetState`
work with no kernel bus driver (validated live: slot connected, state + rumble round-trip; Xbox One
folds to this 360 path). All three UMDF drivers (DualSense/DS4 + XUSB) are built from source in CI
(`packaging/windows/drivers/`) and installed by the Inno Setup installer via
`punktfunk-host.exe driver install --gamepad`. The gamepad drivers' **business logic is 100 % safe
Rust**: every raw shared-memory / sealed-channel / WDF-request operation lives behind
`pf-umdf-util` (the audited unsafe layer — `section::MappedView` checked accessors, the
`#![forbid(unsafe_code)]` `channel::ChannelClient` state machine, `wdf::Request` tokens), so a
memory-safety bug can only live in that one small crate. The whole drivers workspace is lint-gated
(`deny(unsafe_op_in_unsafe_fn)` + `deny(clippy::undocumented_unsafe_blocks)`) with a
`cargo clippy -D warnings` step in `windows-drivers.yml`; pf-vdisplay stays FFI-bound (D3D11/IddCx)
but every `unsafe {}` there now carries a `// SAFETY:` proof (unsafe-audited, not unsafe-free).
**Multi-pad ready**: the host stamps each pad's index into the device Location (`pszDeviceLocation`),
the driver reads it (`WdfDeviceAllocAndQueryProperty`) to map its own `*-shm-<index>`, and
`UmdfHostProcessSharing=ProcessSharingDisabled` gives each pad its own host (per-pad statics) —
validated live with 2 distinct XInput slots + 2 DualSense pads. (Client-side multi-pad forwarding is
the remaining piece.)
- **Windows host: implemented and shipping (all-vendor, x64-only, Windows 11 22H2+).** The OS floor
is HARD: pf-vdisplay is built against IddCx 1.10 (1.10 stub + HDR `*2` DDIs + FP16 caps, no runtime
downgrade) — on Windows 10 (incl. LTSC) / Win11 21H2 the driver installs but the device fails start
with Code 10 `STATUS_DEVICE_POWER_FAILURE` (field-reported 2026-07); the installer gates on
`MinVersion=10.0.22621`. `#[cfg(windows)]` backends
behind the same traits as Linux — **IDD-push capture** straight into the in-house all-Rust IddCx
**pf-vdisplay** virtual display (`capture/windows/idd_push.rs`, `vdisplay/windows/pf_vdisplay.rs`;
DXGI Desktop Duplication / WGC as fallbacks, `capture/windows/dxgi.rs`). The host↔driver frame
ring is a **sealed channel** (proto v2, `design/idd-push-security.md`): all shared objects
UNNAMED, handles `DuplicateHandle`d into the driver's WUDFHost and delivered as values over
`IOCTL_SET_FRAME_CHANNEL` (SY+BA-only control device) — only the two endpoint processes can ever
reach a frame (DDA's isolation property in user mode; adopt-on-success handle-ownership contract,
newest-delivery-wins re-attach). *Sealed channel: CI-pending + on-glass revalidation pending.*
The **gamepad SHM channels are sealed the same way** (gamepad proto v2,
`design/gamepad-channel-sealing.md`): the pad DATA sections (`XusbShm`/`PadShm`, now with a
driver-validated `pad_index`) are unnamed + handle-duplicated into the pad WUDFHost
(`gamepad_raii.rs` `PadChannel`); since the HID minidrivers have no control device, the handshake
runs over a tiny named bootstrap mailbox (`Global\pf…-boot-<i>`, pid + handle value only — tampering
is DoS-bounded). *Sealed pad channel: needs both pad drivers redeployed with the host, physical-pad
validation pending.* GPU encode (NVENC
`--features nvenc`; AMD/Intel `--features amf-qsv`), SendInput + the in-house UMDF gamepad drivers
(`inject/windows/`), WASAPI loopback + virtual mic (`audio/windows/wasapi_*`). **Keyboard wire
convention: US-positional VKs** (every first-party client sends the physical key's US-layout VK;
the Windows client derives it from the scancode, NOT the layout-resolved `vkCode`) — the Windows
injector resolves them via a fixed table mirroring the Linux `vk_to_evdev` (never through a
keyboard layout: the SYSTEM service thread's layout re-reads positions as characters — the
German y↔z / ö→ü scramble), while GameStream/Moonlight VKs are layout-semantic
(`KEY_FLAG_SEMANTIC_VK`, resolved under the foreground app's layout, Sunshine's model). Linux
renders positions under the session compositor's layout (libei) or the virtual keyboard's
uploaded keymap (Sway/wlroots — honors `XKB_DEFAULT_LAYOUT` et al., default US); the Android
client reads `KeyEvent.scanCode` first so a user-selected physical-keyboard layout can't
re-map keycodes semantically. Ships as a **signed
Inno Setup installer** that registers a `LocalSystem` SCM service launching into the interactive
session for secure-desktop (UAC/lock-screen) capture (`windows/service.rs`), bundles the
pf-vdisplay driver + the FFmpeg DLLs (+ VB-CABLE for the virtual mic), and is published by
`windows-host.yml`. **Encoder is GPU-aware** (`encode.rs` `open_video` + `windows_resolved_backend`):
`PUNKTFUNK_ENCODER=auto` (the host.env default) reads the **selected render adapter's** vendor →
**NVENC** (NVIDIA, direct SDK, `encode/windows/nvenc.rs`), **AMF** (AMD) / **QSV** (Intel) via libavcodec
(`encode/windows/ffmpeg_win.rs`, the Windows analogue of the Linux VAAPI backend — `WinVendor{Amf,Qsv}`,
system-memory NV12/P010 readback default + opt-in zero-copy D3D11 behind `PUNKTFUNK_ZEROCOPY` with a
system fallback), or software H.264 (`encode/sw.rs`, GPU-less). GameStream codec advertisement is
probed per-GPU on AMF/QSV (`windows_codec_support``serverinfo`, AV1 gated; cached per selected
GPU). **Multi-GPU is first-class** (`gpu.rs`): GPU inventory + a persisted auto/manual preference
(`<config>/gpu-settings.json`, stored by stable PCI identity — LUIDs are per-boot) exposed over
`GET /api/v1/gpus` + `PUT /api/v1/gpus/preference` and a web-console GPU card (Host page: list,
Automatic/Prefer, "In use · backend" badge). One selection — precedence **console preference >
`PUNKTFUNK_RENDER_ADAPTER` > max VRAM**, graceful fallback when the preferred GPU is absent —
feeds `win_adapter::resolve_render_adapter_luid` (capture ring + IddCx render pin), the encoder
vendor auto-detect (previously DXGI adapter 0 — wrong on hybrid boxes like NVIDIA dGPU + Intel
Arc iGPU), and the NVENC 4:4:4 probe; a preference change applies to the next session. On Linux a
matched manual preference picks the VAAPI render node / NVENC-vs-VAAPI auto choice (auto mode
unchanged). *Implemented + unit-tested; not yet on-glass validated on the hybrid box.* **HDR (10-bit)**: WGC
captures the HDR desktop as FP16/Rgb10a2 (DDA FP16 for the secure desktop), the encoder forces HEVC
Main10 + BT.2020 PQ (NVENC ABGR10/P010; AMF/QSV P010 + a swscale Rgb10a2→P010 fallback), the client
auto-detects PQ from the HEVC VUI — gated by `PUNKTFUNK_10BIT` + client `VIDEO_CAP_10BIT`; **Windows
host only** (the Linux host stays 8-bit, blocked upstream). **Vulkan-game HDR over the virtual
display**: NVIDIA/AMD Vulkan ICDs refuse to *advertise* an HDR color space for a surface on an IddCx
indirect display (so Vulkan games — Doom: The Dark Ages, id Tech, etc. — say "device does not support
HDR"), even though the ICD happily *accepts + presents* a forced HDR swapchain there. A tiny always-on
Vulkan **implicit layer** (`packaging/windows/pf-vkhdr-layer/`, `VK_LAYER_PUNKTFUNK_hdr_inject`)
injects the `HDR10_ST2084`/scRGB surface formats into `vkGetPhysicalDeviceSurfaceFormats[2]KHR`,
self-gated on the display's actual advanced-color state (no-op on SDR / real monitors); bundled +
HKLM-registered by the installer. **Live-validated: Doom: The Dark Ages enables HDR over the virtual
display.** **AMF/QSV is CI-green but not yet on-glass validated** (no AMD/Intel Windows box in the
lab); NVENC is live-validated. Newer/less battle-tested than the Linux host. Packaging: `packaging/windows/`.
- **Status tray (`crates/punktfunk-tray`, Windows + Linux).** A small per-user companion binary
showing the host service state at a glance (running / stopped / starting / degraded / failed +
streaming session in the tooltip) with one-click actions: open web console, approve-pairing
shortcut, start/stop/restart, open logs, exit. Status precedence is **service manager first**
(SCM / systemd user unit — a port-squatter can't fake Running), then the new **loopback-only
unauthenticated** `GET /api/v1/local/summary` (counts/booleans only — no PINs/fingerprints/names;
gated in `require_auth` by peer address, needed because `mgmt-token`/`cert.pem` are
SYSTEM/Admins-DACL'd on Windows so a non-elevated tray cannot bearer-auth). Windows:
`#![windows_subsystem = "windows"]` hidden-window + `Shell_NotifyIconW` (per-session `Local\`
mutex, TaskbarCreated re-add, `--quit` for the uninstaller), actions elevate per click via
`ShellExecuteW "runas"` on `punktfunk-host.exe service start|stop|restart` (new `service restart`
subcommand: stop → wait Stopped → start); installed by the Inno `trayicon` task (HKLM Run key).
Linux: ksni (SNI over zbus, `async-io`+`blocking` features), `systemctl --user` actions (no
polkit), `/etc/xdg/autostart` entry whose `--autostart` self-gates (silent exit unless
`~/.config/punktfunk` exists or the unit is enabled); deb/rpm/arch ship binary + autostart +
hicolor icons. Icons generated by `scripts/gen-tray-icons.py` (pure-stdlib; committed .ico/.png,
brand lens + status dot). *Linux live-validated on the headless KDE session (SNI registration,
stop/start transitions, menu-driven start, dbusmenu layout); Windows code MSVC-cross-type-checked
+ clippy-clean but real Windows CI build + on-glass validation pending.*
## What's left
1. **Native clients — decode + present: macOS stage 1 done, first light achieved
(2026-06-10).** PunktfunkKit compiles and is tested on macOS (AnnexB → VideoToolbox →
`AVSampleBufferDisplayLayer`, GCMouse/GCKeyboard capture, `PunktfunkClient` app shell);
validated live Mac ↔ this box at 720p60 — vkcube on glass, input injected via gamescope
EIS. The app speaks the full ABI v2 trust surface: Keychain-persisted client identity
presented on every connect, SPAKE2 PIN pairing UI (host-card context menu + the trust
prompt's "Pair with PIN instead…"), TOFU fingerprint prompt. **Gamepads (2026-06-11):**
controller discovery + selection in Settings (`GamepadManager` — exactly one pad
forwarded as pad 0, auto or pinned; pad TYPE auto-resolves from the physical
controller, user-overridable), capture incl. DualSense touchpad/motion
(`GamepadCapture`/`GamepadWire`; while streaming, EVERY element's
`preferredSystemGestureState` is claimed `.disabled` — share/create reaches the host as
select instead of screenshotting locally, PS/Home reaches the host as guide/`BTN_MODE` =
the Steam-overlay button — restored `.enabled` on unbind), feedback rendering (rumble → CoreHaptics; lightbar /
player LEDs / adaptive triggers → `GCDeviceLight`/`playerIndex`/
`GCDualSenseAdaptiveTrigger` via the table-driven `DualSenseTriggerEffect` parser).
Loopback-tested end to end (`PUNKTFUNK_TEST_FEEDBACK=1` scripted burst); DualSense
motion sign/scale derived, not yet live-verified. **Rumble renderer rewritten
(2026-07-02, `RumbleRenderer.swift`)** around "rumble is idempotent state, divergence
must be bounded": the old per-datagram infinite-duration CoreHaptics players could leak
one dropped async `stop` into a forever-buzzing motor (the stuck-rumble-after-menu bug)
— now finite self-expiring segments with seamless engine-timeline re-arm, newest-wins
dry drain of the 0xCA plane (was 1 datagram/8 ms), dedupe of the host's 500 ms state
refreshes, zero-immediate/ramp-throttled rebakes, escalation to `engine.stop()` on a
throwing player stop, and a 1.6 s staleness watchdog (`Policy.session`; the settings
test panel uses `.manual` = hold). Controller engines use **plain `makePlayer` — never
`makeAdvancedPlayer`**: the controller haptics server (gamecontrollerd) advertises
`adv players: 0`, and iOS 27 beta 2 hard-drops advanced-player loads (XPC decode fault →
CoreHaptics -4811/4097, rumble silently dead). Unit-tested (`RumbleTuningTests`);
stuck-rumble repro on-glass revalidation pending. **Gamepad UI (iOS/iPadOS + macOS,
2026-07-02 rework):** a connected pad swaps the home for a console-style launcher
(`Home/Gamepad*` + `Settings/GamepadSettingsView`) — host carousel with a trailing Add
Host tile (A connect · Y library · X settings · B back), a controller-navigable
settings screen (vertical `GamepadMenuList`, left/right steps values), an add-host
flow with an on-screen controller keyboard (`GamepadKeyboard` — no touch needed
anywhere), and the coverflow library, all over an animated aurora backdrop
(`GamepadScreenBackground`, TimelineView-driven drifting blobs — pure SwiftUI ON
PURPOSE: a .metal lib only reliably bundles in one of the two build systems (SPM vs
xcodeproj synced folders) these sources compile under). Input is the polled
`GamepadMenuInput` (handlers don't fire outside a stream; on (re)start it SNAPSHOTS
held buttons so a handoff press never double-fires), haptics dual-channel (device +
`MenuHaptics` on the pad). macOS: same screens, settings/add-host as sheets (no
fullScreenCover), NSScreen-based mode lists, scroll indicators `.never` (macOS
"always show scroll bars" overrides `.hidden`); launcher/settings/add-host/keyboard
render-verified live on this Mac via `PUNKTFUNK_FORCE_GAMEPAD_UI=1` (dev hook, forces
the mode without a pad). Controller-in-hand on-glass validation still pending on all
platforms. **Touch input (iOS/iPadOS, 2026-07-02):** a 3-way model in Settings —
**Trackpad** (default; the Android client's gesture vocabulary ported 1:1 in
`Input/TouchMouse.swift`: tap=click · two-finger tap=right-click · two-finger drag=scroll ·
tap-then-drag=held drag · three-finger tap=HUD toggle, relative ballistics with the same
px-based acceleration curve), **Direct pointer** (cursor jumps to the finger), **Touch
passthrough** (the previous always-on behavior — real wire touches). Latched per gesture
from `DefaultsKey.touchMode`; not yet on-glass validated. Tests: `swift test` in
`clients/apple` (unit + real-codec round trip),
`test-loopback.sh` (Swift client vs synthetic punktfunk1-hosts on loopback — runs on macOS;
includes the pairing ceremony + `--require-pairing` gate),
`RemoteFirstLightTests` (full pipeline over the LAN). See
[`clients/apple/README.md`](clients/apple/README.md). **Stage 2 presenter is now the DEFAULT**
(stage-1 is the Metal-unavailable / DEBUG fallback): explicit `VTDecompressionSession` decode →
`CAMetalLayer`, presented from the hosting view's **main-runloop `CADisplayLink`** (`renderTick` pops
the newest ready frame per vsync; macOS `displaySyncEnabled = false` is the real fullscreen-judder fix,
~11 ms p50). *(An off-main `CAMetalDisplayLink` and an off-main blocking-render present thread were
both tried and reverted — both measured slower on macOS and iPad.)* **HDR fixed**
(`design/apple-stage2-presenter.md`): the "too bright" bug was a missing reference-white anchor — the
fix keeps the PQ-passthrough shader and adds `CAEDRMetadata.hdr10(…, opticalOutputScale: 203)` +
`wantsExtendedDynamicRangeContent` on the layer (on all platforms; the old `#if os(macOS)` guard left
iOS/tvOS EDR half-engaged), routing the 0xCE mastering metadata to the layer (via `setHdrMeta`) instead
of a never-composited source buffer. **Mid-session SDR↔HDR** is handled: `render` reconciles the layer
per-frame from the decoded `frame.isHDR` (per-mode pixel format `bgra8`/`rgba16Float`), so a game
entering HDR mid-stream just reconfigures (last 0xCE grade cached + re-applied; pump drains 0xCE
unconditionally). **4:4:4 added**: decode format is a 2×2 `(chroma, HDR)` matrix
(`420v/x420/444v/x444`, all biplanar so the shaders are unchanged), advertised (`VIDEO_CAP_444`) only
behind a **hardware-required `VTDecompressionSession` probe** (`Stage444Probe`, validated on M3) with a
Settings opt-out + a bounded pump backstop for an undecodable 4:4:4 session. *HDR brightness + 4:4:4
still need on-glass validation (Windows-HDR / `PUNKTFUNK_444` host).* Next: glass-to-glass numbers via
`tools/latency-probe`.
**Linux stage 1 done, first light 2026-06-12** (`clients/linux`, binary
`punktfunk-client`): GTK4/libadwaita shell linking `punktfunk-core` directly (no C ABI;
`NativeClient` is now `Sync` — mutexed plane receivers), mDNS host list, TOFU + SPAKE2
PIN dialogs (identity shared with client-rs), FFmpeg software HEVC decode (LOW_DELAY,
slice threads) → `GtkGraphicsOffload`-wrapped picture, PipeWire playback (mic-player
jitter ring inverted), SDL3 gamepad capture + rumble/lightbar feedback, keyboard via
exact inverse of the host VK table, absolute mouse + 120-unit scroll. Validated live
against `serve` on this box: 1080p60, steady 60 fps, capture→decoded p50
≈6.4 ms (debug build). `--connect host[:port]` for scripting. **Swift-parity batch +
stage 1.5 (2026-06-12 evening)**: capture state machine (click-to-capture,
Ctrl+Alt+Shift+Q / focus-loss release, held-state flush), app-lifetime SDL gamepad
service (pad pin UI, auto type from the physical pad, DualSense touchpad/motion 0xCC +
raw-DS5-effects trigger/player-LED replay — needs a physical pad to live-verify), mic
uplink (validated live), per-host speed test, compositor pref, native-display mode
default, saved-hosts list, .deb + RPM-subpackage CI (deb.yml/rpm.yml). **VAAPI decode
→ DRM-PRIME dmabuf → `GdkDmabufTexture`** (BT.709 color state; Tier-1 zero-copy on
Intel/AMD, `PUNKTFUNK_DECODER=software|vaapi` override) with a proven fallback ladder —
no VAAPI device (NVIDIA) or mid-session VAAPI error → software decode. **First AMD test
(Steam Deck) hit a green-screen bug, fixed:** FFmpeg's VAAPI export uses
`SEPARATE_LAYERS`, so NV12 arrives as two single-plane layers (R8 luma + GR88 chroma,
one shared fd); the mapper took `layers[0]` only → GTK got a luma-only R8 texture, chroma
read as 0 → green field / red whites. Fix derives the combined fourcc from the decoder
`sw_format` (→ `DRM_FORMAT_NV12`) and flattens all planes across all layers (mpv's
pattern); a first-frame descriptor dump logs the real layout. Awaiting Steam Deck
reconfirm. Next: the stage-2 raw-Wayland
presenter (wp_presentation feedback, tearing-control, Vulkan Video on NVIDIA) —
**wgpu/winit rejected** (no dmabuf import / presentation feedback / shortcuts-inhibit).
**Windows stage 1 done 2026-06-15** (`clients/windows`, binary
`punktfunk-client`): pure-Rust **WinUI 3** UI via **windows-reactor** (a declarative React-like
framework backed by WinUI; PR #4499 added the `SwapChainPanel` widget + `set_swap_chain`). The
video is a **`SwapChainPanel`** bound to a **D3D11 composition swapchain**, presented from a
**dedicated render thread** (`render.rs`, 2026-07-02 rewrite — presenting never touches or is
stalled by the XAML thread): frame-latency-waitable swapchain + `SetMaximumFrameLatency(1)`
(≤1 queued present, newest-wins drain after the wait, so a stream faster than the display drops
backlog before any GPU work), **HiDPI-correct** (pixel-sized buffers + `SetMatrixTransform`
96/DPI — DIP-sized buffers were blurry at 125/150 %), Contain-fit letterbox, WARP fallback.
**FFmpeg decode with a D3D11VA hardware path on all vendors** (`gpu.rs` shares one D3D11 device
between decoder + presenter, adapter picked by console pref `PUNKTFUNK_ADAPTER` > the window's
monitor's adapter > default; `PUNKTFUNK_D3D_DEBUG=1` adds the debug layer): the decode pool is
**decoder-only bind, sized/aligned by libavcodec itself** (get_format returns `AV_PIX_FMT_D3D11`
and lets `hw_device_ctx` drive — three hand-built-frames-context strikes are why: NVIDIA rejects
`DECODER|SHADER_RESOURCE` arrays, `BindFlags=0` fails texture creation, and Intel rejects
non-128-aligned HEVC surfaces at the first `SubmitDecoderBuffers`), a DXVA **profile probe**
before the hwdevice commits software-vs-hardware up front (no burned first IDR), and the
presenter copies the decoded slice with ONE display-size-boxed `CopySubresourceRegion` (a planar
slice is a single subresource in D3D11 — the old two-copy D3D12-style code silently no-opped =
the black screen) into its sampleable NV12/P010 texture → per-plane SRVs + YUV→RGB shaders
(NV12/BT.709, P010/BT.2020-PQ). **Software CPU decode is the fallback** (auto-selected,
`DecoderPref` override, mid-session demotion + keyframe re-request) and now feeds the SAME
shaders (swscale → NV12/P010 planes → two dynamic plane textures) so hw/sw colour math is
identical. **HDR10**: the client advertises 10-bit/HDR (Settings toggle, gated on an HDR
display), detects PQ in-band (`transfer == SMPTE2084`), and flips the swapchain to
`R10G10B10A2` + ST.2084 with HDR10 metadata (0xCE mastering metadata plumbed). **WASAPI** render
+ mic capture, **SDL3** gamepads (rumble/lightbar/DualSense), `mdns-sd` discovery, and the full
trust surface — all **in-app**: a polished WinUI shell (host tiles w/ monogram + status pills,
`InfoBar` errors/hints, `ToggleSwitch` settings, status-chip stream HUD showing GPU/CPU decode +
HDR), host list (live mDNS + saved + manual), settings (resolution/refresh/decoder/bitrate/HDR/
mic), SPAKE2 PIN pairing screen, TOFU, pinned-fp-mismatch re-pair. **Live-validated 2026-07-02
on the hybrid laptop (Intel Arc Pro iGPU + RTX 3500 Ada) against the local Windows host**:
D3D11VA hardware decode 60 fps on BOTH vendors (headless, `PUNKTFUNK_ADAPTER`-forced; NVIDIA
0.2 ms decode, Intel 0.2 ms), software path, and the GUI on glass (real decoded desktop pixels,
GPU-decode HUD chip, ~18 ms capture→decoded p50 over loopback — dominated by the host's 60 Hz
virtual-display capture cadence). HDR-on-glass still pending. **Stream input** is Win32 low-level hooks (`WH_KEYBOARD_LL`/`WH_MOUSE_LL`) — reactor
exposes no raw key/pointer events; native Windows VK + absolute mouse (client-rect Contain-fit) +
wheel, Ctrl+Alt+Shift+Q capture toggle. `--headless`/`--discover` keep CLI paths. Builds + clippy
+ fmt green on **`x86_64-pc-windows-msvc` and `aarch64-pc-windows-msvc`** — the latter
**cross-compiled off the one x64 runner** (no ARM64 runner; the x64 MSVC toolset's ARM64 cross
compiler + a per-arch `FFMPEG_DIR` ARM64 tree, SDL3/libopus build-from-source cross-compile
cleanly), and both ship as signed MSIX (`windows-msix.yml` matrix → `..._x64.msix`/`..._arm64.msix`,
verified: ARM64 binaries + manifest arch). **windows-reactor is unpublished** (git
dep pinned to commit `a4f7b2cb`, bumped 2026-07-02 from `b4129fcc` for `on_pointer_entered`/
`on_pointer_exited` hover events — mechanical renames only: `SymbolGlyph``Symbol`,
`placeholder``placeholder_text`, TextBox `on_changed``on_text_changed`, ToggleSwitch
`on_changed``on_toggled`, `on_menu_item_clicked``on_item_clicked`, SwapChainPanel
`on_ready``on_mounted`; `windows` pinned to the SAME commit so `IDXGISwapChain1` unifies with
`set_swap_chain`). New-model runtime staging: reactor has NO build.rs anymore — the app's own
`build.rs` calls `windows_reactor_setup::as_framework_dependent()` (same-rev build-dep, stages
the bootstrap DLL + resources.pri that pack-msix expects) and `main` calls
`windows_reactor::bootstrap()` before `App` (packaged MSIX: a no-op, the manifest's
`Microsoft.WindowsAppRuntime.2` dependency resolves the runtime). `CARGO_WORKSPACE_DIR` is no
longer required (harmless where still set). Gotcha: `CARGO_HOME` must be an ASCII path
— the `ü` in the dev box's username breaks SDL3's MSVC precompiled-header build. **Parity/cleanup
batch (2026-07-02)**: `app.rs` split into per-screen `app/` modules (mod=root/router · hosts ·
connect · pair · speed · settings · licenses · stream · style; thread-driven state lives in ROOT
`use_async_state` and flows down as props — a child's own async-state write does NOT re-render it);
"Native display" now resolves the real monitor mode at connect (`MonitorFromWindow`
`EnumDisplaySettingsW`, was hardcoded 1080p60); per-host **speed test** (saved-host card button +
`--headless --speed-test`, probe burst → recommended ≈70 % bitrate applied in one tap; bitrate
setting is now a free-form NumberBox); **forget host** (ContentDialog confirm →
`KnownHosts::remove_by_fp`); settings gained forwarded-controller picker + gamepad type + host
compositor + capture-system-shortcuts — the previously-dead `Settings.compositor`/
`inhibit_shortcuts` are now honored (off = Alt+Tab/Alt+Esc/Ctrl+Esc/Win act locally);
**click-to-recapture** after a Ctrl+Alt+Shift+Q release with the HUD hint tracking capture state;
input hook caches lock geometry (no per-move `GetClientRect`), audio jitter-ring trims via
`drain`. Validated on the bare-metal RTX box: `--discover` (3 live LAN hosts), synthetic-host
loopback E2E (TOFU connect → clock skew → HEVC negotiate → shared-D3D11 + D3D11VA init → WASAPI →
session end; synthetic payload isn't decodable so decode output stays unvalidated), speed-test
E2E. The WinUI window itself CANNOT be launched from SSH (session-0 → WinAppSDK 0x80070005,
pre-existing; needs the console session, e.g. PsExec -i 1). **UX batch (2026-07-02 evening,
UIA-smoke-tested on the hybrid laptop)**: host tiles get the WinUI pointer-over fill
(`on_pointer_entered`/`exited` → root hover state → `ControlFillSecondary`), Settings is a stock
**NavigationView** sidebar (Windows-Settings pattern: Display/Video/Input/Audio/About panes,
built-in back arrow, section in root state; the section card is **keyed by section** — an
in-place diff across sections re-sets a reused ComboBox's items, clearing WinUI's selection,
but skips `selected_index` when the values compare equal → blank selection; the key forces a
remount — and the content column rides its own section-switch slide-up tween), new
**"Show the stats overlay (HUD)"** toggle
(`Settings::show_hud`, applies mid-stream via the 400 ms HUD re-render), the Add-host modal
slides up + fades in (root margin/opacity tween, same pattern as screen navigation), and a
self-initiated disconnect (Ctrl+Alt+Shift+D → `Ended(None)`) returns to the host list silently
instead of raising the error banner.
Next: **HDR on-glass validation** (Windows host with `PUNKTFUNK_10BIT` → the HDR laptop
display), then RAWINPUT relative-mouse pointer-lock.
**Android stage 1 done** (`clients/android`, Kotlin app + `native/` Rust JNI core linking
`punktfunk-core`; phone + Android TV): NDK `AMediaCodec` hardware HEVC decode → `SurfaceView` incl.
**HDR10** (Main10/BT.2020 PQ) with low-latency tuning + a live stats HUD (`decode.rs`/`stats.rs`),
Opus/AAudio audio + mic uplink (`audio.rs`/`mic.rs`), gamepad input with rumble/HID feedback
(`feedback.rs`), **native `mdns-sd` mDNS discovery** (`discovery.rs`, polled over JNI — the same
browse the Linux/Windows clients use, replacing the flaky per-OEM `NsdManager`; Kotlin keeps only
the `MulticastLock` + permission UX), SPAKE2 PIN pairing + TOFU (Keystore identity +
known-host store), Compose UI (Connect/Settings/Stream) with D-pad/controller focus nav. Built for
`arm64-v8a` + `x86_64`; published to Google Play (Internal Testing) via `android.yml`
(`ci/play-upload.py`). Touch input is the same 3-way model as iOS (2026-07-02): the existing
Trackpad/Direct mouse modes plus new **real multi-touch passthrough**
(`streamTouchPassthrough``nativeSendTouch` → wire TouchDown/Move/Up), a `TouchMode`
Settings dropdown replacing the old trackpad Boolean (migrated on load); not yet
on-device validated. Next: real-device gamepad/HDR live-verify, presenter/latency polish.
2. **Sub-frame pipelining**: overlap encode and transmit within a frame. Requires a direct
NVENC SDK wrapper (libavcodec only emits whole AUs) — the next big latency lever (~24 ms
at high res).
3. **punktfunk/1 protocol growth.** **Done:** unified host (`serve --gamestream` runs GameStream + the
punktfunk/1 QUIC host in one process; bare `serve` is the secure native-only default — GameStream is
opt-in, trusted-LAN only, security-review #5/#9) with native pairing driven over the mgmt API /
web console (`mod native_pairing`: arm-on-demand → display PIN, paired-device list).
**Done:** PIN pairing is the default, host-gated — the host requires pairing and advertises
`pair=required` unless opted out with `--allow-tofu`/`--open` (then `pair=optional`, accepts
unpaired clients); clients render TOFU only for a `pair=optional` host and force re-pairing on a
fingerprint change. **Done:** concurrent sessions — the accept loop spawns each session
(`--max-concurrent`, default 4, an NVENC bound), each with its own virtual output + encoder, sharing
the host-lifetime input/audio/mic services (shared-desktop multi-view on kwin/mutter/wlroots).
**Done:** delegated pairing approval (§8b-1) — an unpaired device shows up as a pending request in
the web console, one click approves + pins it. Next (see roadmap): gamescope multi-user isolation
(per-session input/audio = independent desktops); §8b-2 peer-push approval from a paired device's
own app.
4. **GameStream host polish**: HDR/10-bit (needs HDR capture + metadata plumbing; `av1_nvenc
-highbitdepth 1` already encodes Main10 from 8-bit input on this box),
reconnect-at-new-mode robustness. AV1 negotiation and surround audio are implemented
and unit/live-capture tested — both still need a live Moonlight confirmation (select
AV1 in a stock client; a real 5.1/7.1 listen incl. FEC under loss).
Box one-time setup is complete: udev rule + `input` group (gamepads validated live),
gamescope 3.16.22 installed system-wide (no PATH override), gnome-shell installed (Mutter
backend validated live). All three compositor backends are live-validated.
## Build / test / run
```sh
cargo build --workspace # green on Linux and macOS
cargo test --workspace # unit + loopback + proptest + C ABI harness (~100 tests)
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all --check
cargo run -p loss-harness # FEC loss-resilience sweep (no network needed)
bash crates/punktfunk-core/tests/c/run.sh # standalone C-ABI link + round-trip proof
```
Generated artifacts are **checked in** and CI fails on drift: `include/punktfunk_core.h`
(cbindgen from `punktfunk-core/src/abi.rs`) and `api/openapi.json` (regenerate with
`cargo run -p punktfunk-host -- openapi > api/openapi.json`; spec lives in `mgmt.rs`).
CI is Gitea Actions (`.gitea/workflows/`, guide: docs-site `ci.md`): `ci.yml` runs the
workspace checks inside the `git.unom.io/unom/punktfunk-rust-ci` image plus web/docs-site
build+typecheck; `docker.yml` builds+pushes the web/docs/rust-ci images (host and native
clients are deliberately NOT containerized); `apple.yml` builds the xcframework and runs
`swift build`/`swift test` on the `macos-arm64` host-mode runner (home-mac-mini-1,
provisioned by `scripts/ci/setup-macos-runner.sh`). Per-client/host release workflows:
`deb.yml`/`rpm.yml`/`flatpak.yml` (Linux client), `android.yml` (Google Play), `windows-msix.yml`
(Windows client), `windows-host.yml` (Windows host installer), `release.yml` (Apple notarized DMG +
TestFlight), `decky.yml` (Steam Deck plugin); Windows builds run on a self-hosted Windows runner
(`home-windows-runner-1`, vmid 210, `windows-amd64:host` label). The runner is reproducible and
**owned by `unom/infra`**, not this repo, since it's shared across unom Windows projects going
forward: `unom/infra`'s `windows-runner/` Packer template bakes a generic Windows 11 template (OS
install + OpenSSH Server + VS Build Tools/NASM/CMake/LLVM + the act_runner/Node/rustup base, no
registration) on Proxmox once; `proxmox/windows-runner/` (Terraform, `bpg/proxmox`) full-clones it
(agent-based IP discovery, no pre-provisioned DHCP reservation needed) and registers the instance
over SSH remote-exec — the same bake-once/clone-fast split `proxmox/unom-1` uses for the Linux CI
host, just without a native Windows cloud-init (registration goes over `remote-exec`/SSH instead of
`initialization{}`; WinRM was tried first but is deprecated in OpenTofu, so this moved to SSH via
Windows' in-box OpenSSH Server). punktfunk layers its own extras on top of that generic runner:
`scripts/ci/provision-windows-wdk.ps1` (WDK + cargo-wdk for the UMDF drivers) and
`scripts/ci/provision-windows-punktfunk-extras.ps1` (FFmpeg x64/ARM64 trees, Inno Setup, the
`aarch64-pc-windows-msvc` rustup target) — both idempotent, and both run automatically at the start
of every Windows CI job via the shared `scripts/ci/ensure-windows-toolchain.ps1` step (a fast no-op
once already provisioned), rather than a separate manually-dispatched provisioning workflow — that
avoided a real footgun once there could be more than one `windows-amd64` runner: a manually
dispatched provisioning workflow has no way to target a *specific* runner instance, so it could
land on an already-provisioned box instead of the one that actually needed it.
## Layout
```
crates/punktfunk-core/ protocol · FEC · crypto · quic (punktfunk/1 control plane, feature-gated)
crates/punktfunk-host/
gamestream/ Moonlight compat: nvhttp · pairing · rtsp · control · stream · gamepad · apps
vdisplay/linux/{kwin,gamescope,mutter,wlroots}.rs per-compositor client-sized virtual outputs
vdisplay/windows/{pf_vdisplay,manager,identity}.rs all-Rust IddCx virtual display (pf-vdisplay)
linux/zerocopy/{egl,cuda,vulkan}.rs dmabuf → CUDA → NVENC (tiled via EGL/GL, LINEAR via Vulkan)
inject/linux/{libei,wlr,gamepad,dualsense,dualshock4,steam_*}.rs Linux input (uinput xpad · UHID pads · virtual Deck)
inject/windows/{sendinput,gamepad_windows,dualsense_windows,dualshock4_windows}.rs Windows input (UMDF shared-mem pads)
encode/linux/{mod,vaapi}.rs · encode/windows/{nvenc,ffmpeg_win}.rs · encode/sw.rs per-GPU encoders (NVENC/CUDA · VAAPI · AMF/QSV) + GPU-less openh264
capture/{linux/,windows/{dxgi,idd_push}}.rs · audio/{linux/,windows/wasapi_*}.rs
windows/{service,install,interactive}.rs SCM service + in-binary driver/web install
capture.rs · encode.rs · audio.rs · gpu.rs · spike.rs · punktfunk1.rs · mgmt.rs · native_pairing.rs · stats_recorder.rs · library.rs
crates/punktfunk-tray/ per-user status tray (Win32 Shell_NotifyIcon · Linux ksni/SNI); icons via scripts/gen-tray-icons.py
clients/probe/ punktfunk/1 reference/probe client (headless test/measurement tool)
clients/linux/ native Linux client (GTK4/libadwaita · FFmpeg · PipeWire · SDL3)
clients/windows/ native Windows client (WinUI 3 via windows-reactor · D3D11 · WASAPI · SDL3)
clients/apple/ native macOS/iOS/tvOS client (Swift · VideoToolbox · GameController)
clients/android/ native Android client (Kotlin app + native/ Rust JNI core over punktfunk-core)
clients/decky/ Steam Deck Decky plugin
packaging/windows/drivers/{pf-vdisplay,pf-dualsense,pf-xusb}/ in-house UMDF drivers (built from source in CI)
packaging/windows/drivers/pf-umdf-util/ audited unsafe layer (safe shm + sealed-channel + WDF request primitives) — gamepad drivers' logic is 100% safe over it
web/ TanStack web console over the mgmt API (status · devices · pairing · GPU selection · performance graphs)
packaging/ apt(deb) · RPM/COPR · Arch/sysext · Flatpak · Bazzite bootc · Windows host installer (per-dir READMEs)
tools/{loss-harness,latency-probe}/ measurement (plan §10)
scripts/ 60-punktfunk.rules · punktfunk-host.service · host.env.example · headless/
include/punktfunk_core.h generated C header
```
## Design invariants — do not regress
- **One core, linked everywhere.** Protocol/FEC/crypto live only in `punktfunk-core`, behind a
stable, versioned C ABI. `tokio`/`quinn` exist only behind the `quic` feature (control
plane); **no async on the per-frame path** — native threads only.
- **Native client resolution, no scaling.** A session gets a virtual output at exactly the
client's WxH@Hz via the `VirtualDisplay` trait (`create(mode) → VirtualOutput { node_id,
remote_fd, preferred_mode, keepalive }`, RAII teardown). There is no cross-compositor
protocol for this — each compositor keeps its own backend.
- **FEC is the wall-breaker.** GF(2⁸) (≤255 shards/block, Moonlight-compatible) and GF(2¹⁶)
Leopard (≤65535 shards/block) — punktfunk/1 negotiates the latter, removing the ~1 Gbps
ceiling.
- **Core security hardening stays intact**: reassembler bounds attacker-controlled fields
before allocating (`ReassemblerLimits`); AES-GCM per-direction nonce salts + seq-as-AAD;
ABI `struct_size` checks. Regression tests exist — keep them green.
- **PipeWire consumer discipline**: our capture streams set `node.dont-reconnect` and tear
down promptly on negotiation timeout — one wedged link head-blocks the daemon's shared
work queue system-wide.
## Running on this box
Headless QEMU VM (Ubuntu 26.04, kernel 7.0), passthrough RTX 5070 Ti (driver 595 **open**
module — a kernel update silently drops it; reinstall `nvidia-driver-595-open`), no KMS
scanout → KWin `--drm` impossible; everything renders offscreen via `renderD128`.
```sh
# compositor session (shell 1, or the systemd unit in scripts/): full headless Plasma.
# The script sets XDG_MENU_PREFIX=plasma- & co. — without it plasmashell runs but the
# launcher menu is EMPTY (no apps, no System Settings).
bash scripts/headless/run-headless-kde.sh 1920x1080
# host (shell 2): bare `serve` is native-only (secure default); add --gamestream for Moonlight compat.
WAYLAND_DISPLAY=wayland-kde XDG_CURRENT_DESKTOP=KDE PUNKTFUNK_VIDEO_SOURCE=virtual \
PUNKTFUNK_ZEROCOPY=1 cargo run -rp punktfunk-host -- serve --gamestream
# punktfunk/1 native loopback test (no Moonlight needed; same env as serve, listener persists
# across sessions — bound it with --max-sessions):
cargo run -rp punktfunk-host -- punktfunk1-host --source virtual --seconds 10 --max-sessions 1
cargo run -rp punktfunk-probe -- --mode 1280x720x120 --out /tmp/a.h265 --input-test # + --pin HEX
```
Pinned crate facts: `ashpd` 0.13 + `pipewire` 0.9 (must match ashpd's) + `ffmpeg-next` 8.x
(`ffmpeg-sys-next` auto-detects the system FFmpeg, so it builds against **FFmpeg 7.x/libavcodec 61
or 8.x/libavcodec 62** — validated live on Ubuntu 26.04 (8) and Bazzite F43 (7.1); the zero-copy
FFI also link-needs `libGL`/`libgbm`/`libcuda` at build time). Env knobs: `PUNKTFUNK_VIDEO_SOURCE=virtual|portal`,
`PUNKTFUNK_COMPOSITOR=kwin|gamescope|mutter`, `PUNKTFUNK_ZEROCOPY=1|0` (Linux default: ON for
VAAPI/AMD/Intel with a one-shot CPU downgrade if the dmabuf offer never negotiates, OFF/opt-in for
NVENC), `PUNKTFUNK_VAAPI_LOW_POWER=1|0` (pin the VAAPI entrypoint; auto = full-feature then VDEnc
fallback for modern Intel), `PUNKTFUNK_NV12=0` (opt OUT of the default GPU RGB→NV12 convert on the
NVIDIA tiled zero-copy path), `PUNKTFUNK_INTRA_REFRESH=1` (opt-in NVENC intra-refresh loss recovery),
`PUNKTFUNK_PIN_CLOCKS=1` (opt-in NVML GPU clock floor, root-gated), `PUNKTFUNK_GAMESCOPE_APP=...`,
`PUNKTFUNK_INPUT_BACKEND=...`, `PUNKTFUNK_PERF=1` (per-stage timing), `PUNKTFUNK_VIDEO_DROP=N` (FEC
test — injects N% wire-packet loss on BOTH the GameStream and native video paths, no netem needed), `PUNKTFUNK_FEC_PCT=N`, `PUNKTFUNK_DSCP=1` (opt-in DSCP/SO_PRIORITY media QoS on the data +
GameStream video/audio sockets; no-op on the wire on Windows without a qWAVE policy),
`PUNKTFUNK_444=1` (full-chroma HEVC 4:4:4, see below).
**HEVC 4:4:4 (full chroma, Range Extensions)**: opt-in via `PUNKTFUNK_444`, negotiated like 10-bit —
the host emits 4:4:4 only when the client advertised `VIDEO_CAP_444` (wire bit `0x04` + ABI
`PUNKTFUNK_VIDEO_CAP_444`), the codec is HEVC, the session is single-process, **and** a GPU probe
(`encode::can_encode_444`, run before the Welcome) confirms support — else it resolves to 4:2:0 and
`Welcome::chroma_format` reflects the real value (honest downgrade; the client reads it via
`punktfunk_connection_chroma_format`). **punktfunk/1-native only** — GameStream/Moonlight stays 4:2:0
(stock clients can't decode 4:4:4). **NVENC is the implemented path**: Linux `hevc_nvenc` feeds a
swscale'd `yuv444p` (RGB-in is always 4:2:0 — verified on the RTX 5070 Ti — so the session forces CPU
RGB capture for 4:4:4); Windows NVENC keeps ARGB input + FREXT profile + `chromaFormatIDC=3` and the
DDA capturer delivers RGB. VAAPI / AMF / QSV **decline** (probe returns false — no validated 4:4:4
hardware in the lab; they'd produce 4:2:0). Software (openh264) is 4:2:0-only. Test with
`PUNKTFUNK_CLIENT_444=1 punktfunk-probe --out x.h265` then `ffprobe x.h265` (expect `pix_fmt yuv444p`).
*Linux NVENC mechanism validated on the RTX 5070 Ti (ffmpeg CLI); Windows NVENC + 10-bit-4:4:4 not yet
on-glass validated.*
## Conventions
- Rust 2021, `rustfmt` + `clippy -D warnings` clean before commit.
- Match the surrounding code's comment density and naming.
- Commit messages end with the Co-Authored-By trailer (see `git log`).
- `pkill` caution on this box: match exact comm names (`pkill -x gamescope-wl`,
`pkill -x punktfunk-host`) — `pkill -f` self-matches the invoking shell.
Generated
+1
View File
@@ -3027,6 +3027,7 @@ dependencies = [
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
"windows-service",
"winreg",
"winresource",
"x509-parser",
"xkbcommon",
]
+3 -1
View File
@@ -15,6 +15,9 @@ your local network.
💬 **Community: [Discord](https://discord.gg/kaPNvzMuGU)** — chat, support, and **Android beta
access** · **[r/Punktfunk](https://www.reddit.com/r/Punktfunk/)**.
🔒 **Security:** found a vulnerability? Report it privately to **security@punktfunk.com** — see
[SECURITY.md](SECURITY.md). Please don't open a public issue.
punktfunk pairs a **virtual-display streaming host** with native clients on every platform. It speaks
the existing **GameStream** protocol, so any [Moonlight](https://moonlight-stream.org/) client works
day one — and adds its own faster **`punktfunk/1`** protocol that breaks the ~1 Gbps FEC wall with a
@@ -138,7 +141,6 @@ clients/
web/ web console (TanStack) over the management API — status · devices · pairing · GPUs · performance · logs
packaging/ apt · rpm / COPR · Arch · Flatpak · Bazzite bootc image
docs-site/ public documentation site (Fumadocs) — https://docs.punktfunk.unom.io
design/ design notes & deep-dive plans (index: design/README.md)
include/punktfunk_core.h cbindgen-generated C header (checked in)
tools/ latency-probe · loss-harness (measurement)
```
+69
View File
@@ -0,0 +1,69 @@
# Security Policy
punktfunk is a low-latency desktop/game streaming stack. A host is effectively remote control of a
machine, so we take security reports seriously and appreciate responsible disclosure.
## Reporting a vulnerability
**Please report security issues privately by email to security@punktfunk.com.**
Do **not** open a public issue, pull request, or chat/forum post for a suspected vulnerability — that
exposes other users before a fix exists.
### What to include
The more of this you can give us, the faster we can act:
- The component and version (e.g. `punktfunk-host 0.6.0`, Windows or Linux, which client).
- The impact — what an attacker can do, and from what position (same LAN, a local service account,
admin, a paired client, …).
- Steps to reproduce, a proof-of-concept, or a crash/log if you have one.
- Any suggested fix or mitigation (optional).
## What to expect
We're a small team, so timelines are best-effort, but we commit to:
- **Acknowledge** your report within **3 business days**.
- Give an **initial assessment** (severity + whether we can reproduce) within about **7 days**.
- Keep you updated, and tell you when a fix ships.
- **Credit** you in the advisory / release notes when the fix is public — unless you'd rather stay
anonymous.
We practice **coordinated disclosure**: please give us reasonable time to release a fix before
publishing details. We aim to resolve valid issues within **90 days** and will agree a disclosure
date with you.
## Scope
In scope — the code in this repository:
- The host (`punktfunk-host`), its Windows drivers, and the protocol/crypto core (`punktfunk-core`).
- The native clients (Apple, Linux, Windows, Android), the web management console, and the management
API.
Known limits — documented behavior, not vulnerabilities (see
https://docs.punktfunk.unom.io/docs/security):
- **Admin/SYSTEM already on the host = out of scope.** An attacker who is already administrator or
SYSTEM on the host owns the machine regardless of punktfunk.
- **The virtual display is a real monitor** — any process already in the interactive desktop session
can capture it via the normal OS screen-capture APIs, exactly as it could a physical monitor.
- **GameStream/Moonlight compatibility** (`--gamestream`) uses legacy encryption and is documented as
opt-in, trusted-LAN-only.
- **Public-internet exposure is unsupported** — issues that only arise from exposing the host to the
WAN are expected; keep the host on a trusted LAN or a VPN.
If you're unsure whether something is in scope, report it anyway — we'd rather hear about it.
## Safe harbor
We consider good-faith security research that follows this policy to be authorized, and we won't
pursue legal action against researchers who:
- make a good-faith effort to avoid privacy violations, data loss, and service disruption,
- only test systems they own or have explicit permission to test,
- give us reasonable time to remediate before public disclosure,
- don't exfiltrate more data than needed to demonstrate the issue.
Thank you for helping keep punktfunk and its users safe.
@@ -7,13 +7,49 @@ import SwiftUI
extension SettingsView {
// MARK: - Sections (shared)
// NOTE: the Section content is deliberately split into the small named builders below as one
// inline expression the iOS branch (wheel + 3-way refresh + bitrate rows) blew Swift's
// type-checker budget ("unable to type-check this expression in reasonable time"), which
// failed exactly one slice: the iOS archive (macOS/tvOS never compile that branch).
@ViewBuilder var streamModeSection: some View {
Section {
#if os(iOS)
// Touch-first: a rotating wheel of common resolutions (this device's own mode first) and
// a segmented refresh-rate control the same family as the Clock/Timer pickers. The host
// renders a virtual output at exactly the chosen mode, so these are real pixel sizes. The
// last wheel row, "Custom", reveals width/height/refresh fields for an arbitrary mode.
iosResolutionWheel
iosRefreshRows
Button("Use this display's mode") { fillFromMainScreen() }
#elseif os(macOS)
HStack {
TextField("Resolution", value: $width, format: .number.grouping(.never))
Text("×")
TextField("", value: $height, format: .number.grouping(.never))
.labelsHidden()
}
TextField("Refresh rate (Hz)", value: $hz, format: .number.grouping(.never))
LabeledContent("") {
Button("Use this display's mode") { fillFromMainScreen() }
}
#endif
#if !os(tvOS)
bitrateRows
#endif
} header: {
Text("Stream mode")
} footer: {
Text("The host creates a virtual output at exactly this mode — "
+ "native resolution, no scaling. \(Self.bitrateFooter)")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
}
#if os(iOS)
// MARK: - Stream mode (iOS wheel)
/// Touch-first: a rotating wheel of common resolutions (this device's own mode first) the
/// same family as the Clock/Timer pickers. The host renders a virtual output at exactly the
/// chosen mode, so these are real pixel sizes. The last wheel row, "Custom", reveals
/// width/height/refresh fields for an arbitrary mode (see `iosRefreshRows`).
@ViewBuilder private var iosResolutionWheel: some View {
VStack(alignment: .leading, spacing: 4) {
Text("Resolution")
.font(.geist(15, relativeTo: .subheadline))
@@ -27,6 +63,10 @@ extension SettingsView {
.pickerStyle(.wheel)
.frame(maxHeight: 140)
}
}
/// Custom W×H(+Hz) fields, a segmented refresh picker, or a static single-rate row.
@ViewBuilder private var iosRefreshRows: some View {
if isCustomResolution {
// Arbitrary entry: type the exact width × height (and refresh) the host should drive.
HStack {
@@ -64,50 +104,7 @@ extension SettingsView {
Text("\(hz) Hz").foregroundStyle(.secondary)
}
}
Button("Use this display's mode") { fillFromMainScreen() }
#elseif os(macOS)
HStack {
TextField("Resolution", value: $width, format: .number.grouping(.never))
Text("×")
TextField("", value: $height, format: .number.grouping(.never))
.labelsHidden()
}
TextField("Refresh rate (Hz)", value: $hz, format: .number.grouping(.never))
LabeledContent("") {
Button("Use this display's mode") { fillFromMainScreen() }
}
#endif
#if !os(tvOS)
Toggle("Automatic bitrate", isOn: automaticBitrate)
if bitrateKbps != 0 {
HStack(spacing: 12) {
Slider(value: bitrateSlider, in: 0...1) {
Text("Bitrate")
}
Text(SpeedTestSheet.mbpsLabel(kbps: bitrateKbps))
.monospacedDigit()
.foregroundStyle(.secondary)
.frame(minWidth: 76, alignment: .trailing)
}
if bitrateKbps > 1_000_000 {
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.orange)
}
}
#endif
} header: {
Text("Stream mode")
} footer: {
Text("The host creates a virtual output at exactly this mode — "
+ "native resolution, no scaling. \(Self.bitrateFooter)")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
}
#if os(iOS)
// MARK: - Stream mode (iOS wheel)
/// Sentinel wheel tag for the "Custom" row. Real tags are "WxH" (digits + "x"), so this can't
/// collide with a resolution.
@@ -156,6 +153,29 @@ extension SettingsView {
}
#endif
#if !os(tvOS)
/// The automatic-bitrate toggle + manual slider (and the >1 Gbps warning) rows.
@ViewBuilder private var bitrateRows: some View {
Toggle("Automatic bitrate", isOn: automaticBitrate)
if bitrateKbps != 0 {
HStack(spacing: 12) {
Slider(value: bitrateSlider, in: 0...1) {
Text("Bitrate")
}
Text(SpeedTestSheet.mbpsLabel(kbps: bitrateKbps))
.monospacedDigit()
.foregroundStyle(.secondary)
.frame(minWidth: 76, alignment: .trailing)
}
if bitrateKbps > 1_000_000 {
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.orange)
}
}
}
#endif
@ViewBuilder var audioSection: some View {
Section {
Picker("Audio channels", selection: $audioChannels) {
@@ -204,35 +224,42 @@ extension SettingsView {
/// Touch-input model (iPhone + iPad) plus the iPad-only pointer-capture toggle: lock the
/// mouse/trackpad for relative movement (games) vs forward an absolute cursor position.
@ViewBuilder var pointerSection: some View {
let isPad = UIDevice.current.userInterfaceIdiom == .pad
Section {
Picker("Touch input", selection: $touchMode) {
Text("Trackpad").tag(TouchInputMode.trackpad.rawValue)
Text("Direct pointer").tag(TouchInputMode.pointer.rawValue)
Text("Touch passthrough").tag(TouchInputMode.touch.rawValue)
}
if isPad {
if UIDevice.current.userInterfaceIdiom == .pad {
Toggle("Capture pointer for games", isOn: $pointerCapture)
}
} header: {
Text("Touch & pointer")
} footer: {
Text("Trackpad: your finger nudges the host cursor like a laptop touchpad — tap to "
+ "click, two-finger tap for a right click, two-finger drag to scroll, "
+ "tap-then-drag to hold the button, three-finger tap for the stats overlay. "
+ "Direct pointer: the cursor jumps to your finger. Touch passthrough: real "
+ "multi-touch reaches the host, for apps that understand touch. Applies from "
+ "the next touch."
+ (isPad
? " Pointer capture locks a hardware mouse/trackpad for relative movement "
+ "(mouse-look); off keeps the pointer free and sends absolute positions. "
+ "The lock needs the stream full-screen and frontmost, and falls back "
+ "automatically (Stage Manager, Slide Over)."
: ""))
Text(pointerFooterText)
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
}
/// Footer copy for `pointerSection`, built in plain `+=` statements. Deliberately NOT one big
/// `+` chain (with a ternary) inside the ViewBuilder that single expression blew Swift's
/// type-checker budget and was what actually broke the iOS archive.
private var pointerFooterText: String {
var text = "Trackpad: your finger nudges the host cursor like a laptop touchpad — tap to "
text += "click, two-finger tap for a right click, two-finger drag to scroll, "
text += "tap-then-drag to hold the button, three-finger tap for the stats overlay. "
text += "Direct pointer: the cursor jumps to your finger. Touch passthrough: real "
text += "multi-touch reaches the host, for apps that understand touch. Applies from "
text += "the next touch."
if UIDevice.current.userInterfaceIdiom == .pad {
text += " Pointer capture locks a hardware mouse/trackpad for relative movement "
text += "(mouse-look); off keeps the pointer free and sends absolute positions. "
text += "The lock needs the stream full-screen and frontmost, and falls back "
text += "automatically (Stage Manager, Slide Over)."
}
return text
}
#endif
@ViewBuilder var compositorSection: some View {
+5
View File
@@ -243,3 +243,8 @@ nvenc = ["dep:nvidia-video-codec-sdk"]
# so the LGPL build suffices and keeps the bundled DLLs LGPL, not GPL) at build time and bundles the
# FFmpeg DLLs at runtime. Build the all-vendor GPU host with `--features nvenc,amf-qsv`.
amf-qsv = ["dep:ffmpeg-next"]
# Build-time icon/version-info embedding (build.rs; Windows dev/CI hosts only — Linux packaging
# builds of this crate never execute the winresource block).
[target.'cfg(windows)'.build-dependencies]
winresource = "0.1"
+17
View File
@@ -17,4 +17,21 @@ fn main() {
.unwrap_or_else(|| std::env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "unknown".into()));
println!("cargo:rustc-env=PUNKTFUNK_VERSION={version}");
println!("cargo:rerun-if-env-changed=PUNKTFUNK_BUILD_VERSION");
// Windows identity resources: the branded icon + version info. Task Manager / Explorer show a
// process by its version-info FileDescription — without one the host appears as a bare
// "punktfunk-host.exe" with no icon. Same winresource pattern as clients/windows and
// punktfunk-tray (cfg(windows) = build HOST, so Linux packaging builds skip it; CARGO_CFG_WINDOWS
// = TARGET).
#[cfg(windows)]
if std::env::var_os("CARGO_CFG_WINDOWS").is_some() {
let icon = "../../packaging/windows/branding/punktfunk.ico";
println!("cargo:rerun-if-changed={icon}");
winresource::WindowsResource::new()
.set_icon_with_id(icon, "1")
.set("FileDescription", "Punktfunk Host")
.set("ProductName", "Punktfunk")
.compile()
.expect("embed windows icon/version resources");
}
}
@@ -215,11 +215,24 @@ unsafe fn open_win_encoder(
let mut opts = Dictionary::new();
match vendor {
WinVendor::Amf => {
opts.set("usage", "ultralowlatency");
// Field-tuning override (ultralowlatency | lowlatency | lowlatency_high_quality |
// transcoding): AMF usage presets bundle driver-side pipeline behavior that varies by
// VCN generation/driver — measured on-box rather than assumed.
let usage =
std::env::var("PUNKTFUNK_AMF_USAGE").unwrap_or_else(|_| "ultralowlatency".into());
opts.set("usage", &usage);
opts.set("rc", "cbr");
opts.set("quality", "balanced");
// Streaming is latency-first: `speed` trims per-frame motion-estimation depth — the
// difference between ~encode-time and ~frame-budget on iGPU-class VCN (matches the
// low-latency preset choice on the NVENC path).
opts.set("quality", "speed");
opts.set("preanalysis", "false");
opts.set("enforce_hrd", "true");
// AMF low-latency submission mode (FFmpeg ≥ 6.1; unknown-option-ignored on older).
opts.set("latency", "true");
// Never B-frames: h264_amf defaults >0 on RDNA3+ HW that supports them, and each
// B-frame is a full frame period of added latency. (HEVC VCN has none; ignored there.)
opts.set("bf", "0");
// VPS/SPS/PPS on each IDR (clean mid-stream join) — HEVC/AV1 only; ignored elsewhere.
opts.set("header_insertion_mode", "idr");
}
@@ -292,14 +305,22 @@ pub fn probe_can_encode(vendor: WinVendor, codec: Codec) -> bool {
}
}
/// One `receive_packet` attempt, with the not-ready states kept distinct so the blocking poll
/// below can tell "still encoding" (retry) from "stream over" (stop).
enum PollOutcome {
Packet(EncodedFrame),
Again,
Eof,
}
/// Drain the encoder for one packet (shared poll logic, identical to the VAAPI/NVENC paths).
fn poll_encoder(enc: &mut encoder::video::Encoder, fps: u32) -> Result<Option<EncodedFrame>> {
fn poll_encoder(enc: &mut encoder::video::Encoder, fps: u32) -> Result<PollOutcome> {
let mut pkt = Packet::empty();
match enc.receive_packet(&mut pkt) {
Ok(()) => {
let data = pkt.data().map(|d| d.to_vec()).unwrap_or_default();
let pts = pkt.pts().unwrap_or(0).max(0) as u64;
Ok(Some(EncodedFrame {
Ok(PollOutcome::Packet(EncodedFrame {
data,
pts_ns: pts * 1_000_000_000 / fps as u64,
keyframe: pkt.is_key(),
@@ -309,9 +330,9 @@ fn poll_encoder(enc: &mut encoder::video::Encoder, fps: u32) -> Result<Option<En
if errno == ffmpeg::util::error::EAGAIN
|| errno == ffmpeg::util::error::EWOULDBLOCK =>
{
Ok(None)
Ok(PollOutcome::Again)
}
Err(ffmpeg::Error::Eof) => Ok(None),
Err(ffmpeg::Error::Eof) => Ok(PollOutcome::Eof),
Err(e) => Err(e).context("receive_packet"),
}
}
@@ -1100,6 +1121,9 @@ pub struct FfmpegWinEncoder {
bound_device: isize,
frame_idx: i64,
force_kf: bool,
/// Frames sent to libavcodec whose AUs haven't been received yet. `poll` blocks (bounded)
/// while this is non-zero — see the poll-contract note on [`Encoder::poll`] below.
in_flight: usize,
}
// Raw FFI pointers + COM objects; the encoder lives on a single thread (same contract as NVENC/VAAPI).
@@ -1161,6 +1185,7 @@ impl FfmpegWinEncoder {
bound_device: 0,
frame_idx: 0,
force_kf: false,
in_flight: 0,
})
}
@@ -1231,7 +1256,7 @@ impl Encoder for FfmpegWinEncoder {
self.frame_idx += 1;
let idr = self.force_kf;
self.force_kf = false;
match &captured.payload {
let submitted = match &captured.payload {
FramePayload::D3d11(f) => {
self.ensure_inner_d3d11(&f.device)?;
// If zero-copy is active but the capturer fell back to a format the NV12/P010 pool
@@ -1271,18 +1296,60 @@ impl Encoder for FfmpegWinEncoder {
}
}
}
};
if submitted.is_ok() {
self.in_flight += 1;
}
submitted
}
fn request_keyframe(&mut self) {
self.force_kf = true;
}
/// Poll for the next finished AU (single non-blocking `receive_packet`).
///
/// libavcodec's `hevc_amf`/`av1_amf` wrapper holds ~2 frames before releasing the oldest
/// (it needs frame N+2 submitted to flush N), so the encode→retrieve latency floors at
/// **~2 frame periods** — measured dead-stable at 36 ms p50 for 720p60 on the Ryzen 7000
/// iGPU across depth 1/2, every `usage` preset, and any spin (a spin between submits provably
/// never produces the owed AU — verified with a 150 ms cap pegging at exactly 150 ms). So the
/// buffer is inherent to the libavcodec path, NOT host scheduling: the real fix is a direct
/// AMF SDK encoder (the AMF analogue of `encode/windows/nvenc.rs`, whose delay=0 gives NVENC
/// its ~12 ms) — tracked as the next AMD latency lever. `PUNKTFUNK_FFWIN_POLL_MS` keeps a
/// bounded spin available for a future VCN/driver where the AU can land mid-spin (0 = off,
/// the default and correct choice on measured hardware).
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
match &mut self.inner {
Some(Inner::System(s)) => poll_encoder(&mut s.enc, self.fps),
Some(Inner::ZeroCopy(z)) => poll_encoder(&mut z.enc, self.fps),
None => Ok(None),
let fps = self.fps;
let enc = match &mut self.inner {
Some(Inner::System(s)) => &mut s.enc,
Some(Inner::ZeroCopy(z)) => &mut z.enc,
None => return Ok(None),
};
let cap_us = std::env::var("PUNKTFUNK_FFWIN_POLL_MS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.map(|ms| ms * 1000)
.unwrap_or(0); // default: no spin — the libavcodec AMF buffer can't be spun out
let deadline = (cap_us > 0 && self.in_flight > 0)
.then(|| std::time::Instant::now() + std::time::Duration::from_micros(cap_us));
loop {
match poll_encoder(enc, fps)? {
PollOutcome::Packet(au) => {
self.in_flight = self.in_flight.saturating_sub(1);
return Ok(Some(au));
}
PollOutcome::Eof => {
self.in_flight = 0; // flushed: nothing further is owed
return Ok(None);
}
PollOutcome::Again => match deadline {
Some(d) if std::time::Instant::now() < d => {
std::thread::sleep(std::time::Duration::from_micros(250));
}
_ => return Ok(None),
},
}
}
}
@@ -534,7 +534,9 @@ impl DriverAttach {
driver_log = self.driver_log,
"gamepad driver has not attached to the shared section — the virtual pad exists but no \
driver is serving it (games will not see it); an old (pre-sealed-channel) driver also \
reads as not-attached: update with punktfunk-host.exe driver install --gamepad"
reads as not-attached: update with punktfunk-host.exe driver install --gamepad \
(driver_log is only written by debug driver builds, or with the PFXUSB_DEBUG_LOG / \
PFDS_DEBUG_LOG system env var set + the device restarted)"
);
}
}
@@ -392,6 +392,21 @@ fn web_setup(args: &[String]) -> Result<()> {
register_web_task(&cmd)?;
// 4. firewall: inbound TCP 47992. 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.)
// Scoped to the same profiles as the streaming ports — Domain + Private by default, Public
// only with `--allow-public-network`. Delete any prior rule first so an upgrade re-scopes it
// instead of stacking a second (possibly all-profiles) rule behind the new one.
let fw_profile =
crate::service::firewall_profile_arg(crate::service::allow_public_network(args));
run_quiet(
"netsh",
&[
"advfirewall",
"firewall",
"delete",
"rule",
"name=punktfunk web console (TCP 47992)",
],
);
if !run_quiet(
"netsh",
&[
@@ -404,6 +419,7 @@ fn web_setup(args: &[String]) -> Result<()> {
"action=allow",
"protocol=TCP",
"localport=47992",
fw_profile,
],
) {
eprintln!("warning: could not add the firewall rule for TCP 47992");
+125 -7
View File
@@ -57,7 +57,7 @@ use windows::Win32::System::Threading::{
/// SCM service name (the key under HKLM\SYSTEM\CurrentControlSet\Services). Stable identity.
const SERVICE_NAME: &str = "PunktfunkHost";
const SERVICE_DISPLAY: &str = "punktfunk streaming host";
const SERVICE_DISPLAY: &str = "Punktfunk Host";
const SERVICE_DESCRIPTION: &str =
"Low-latency desktop/game streaming host. Launches the punktfunk host into the active session.";
@@ -130,6 +130,23 @@ fn host_log_path() -> PathBuf {
dir.join("host.log")
}
/// One-generation size cap for the append-forever logs: at each (re)open, a file over this size is
/// renamed to `<name>.old` (replacing the previous generation) — so a crash-restart loop or a
/// `RUST_LOG=debug` left in host.env can't grow them without bound.
const LOG_ROTATE_BYTES: u64 = 10 * 1024 * 1024;
/// Rotate `path` to `path.old` when it has outgrown [`LOG_ROTATE_BYTES`]. Only called right before
/// an open (service start for service.log, each host (re)launch for host.log) — never while a live
/// handle appends: renaming under an appender would silently redirect its writes into the `.old`
/// file. Best-effort; a failed rename just means one more un-rotated run.
fn rotate_if_large(path: &std::path::Path) {
if std::fs::metadata(path).is_ok_and(|m| m.len() >= LOG_ROTATE_BYTES) {
let mut old = path.as_os_str().to_owned();
old.push(".old");
let _ = std::fs::rename(path, std::path::Path::new(&old));
}
}
/// Initialise tracing to the service log file (the SCM gives the service no console/stderr). Falls
/// back to stderr if the file can't be opened. Called from `main()` only for `service run`.
/// Also tees into the in-memory log ring (`log_capture`), like the stderr path in `main()` — the
@@ -140,10 +157,12 @@ pub fn init_file_logging(filter: tracing_subscriber::EnvFilter) {
use tracing_subscriber::Layer;
let ring =
crate::log_capture::RingLayer.with_filter(tracing_subscriber::filter::LevelFilter::DEBUG);
let log_path = service_log_path();
rotate_if_large(&log_path);
match std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(service_log_path())
.open(log_path)
{
Ok(file) => {
tracing_subscriber::registry()
@@ -302,6 +321,10 @@ fn run_service() -> Result<()> {
.context("set RUNNING")?;
tracing::info!("punktfunk service started — supervising host in the active console session");
// Best-effort: warn if this network is Public (streaming ports are firewalled off there unless
// the operator opted in). Own thread — a slow `Get-NetConnectionProfile` never delays the host.
std::thread::spawn(warn_if_public_network);
load_host_env();
let result = supervise(stop, session);
@@ -545,8 +568,12 @@ unsafe fn spawn_host(
let _ = DestroyEnvironmentBlock(env_block);
}
// 3) Redirect the host's stdout+stderr to host.log (inheritable handle).
let log = open_log_handle(&host_log_path())?;
// 3) Redirect the host's stdout+stderr to host.log (inheritable handle). The previous child has
// exited by the time the supervise loop relaunches, so its handle can't be live here — safe
// to rotate. (A leaked orphan's handle lacks FILE_SHARE_DELETE, so the rename just fails.)
let host_log = host_log_path();
rotate_if_large(&host_log);
let log = open_log_handle(&host_log)?;
let mut si = STARTUPINFOW {
cb: std::mem::size_of::<STARTUPINFOW>() as u32,
@@ -683,7 +710,14 @@ fn install(args: &[String]) -> Result<()> {
if let Some(on) = gamestream {
apply_gamestream_choice(on);
}
add_firewall_rules();
// Firewall scope: Domain + Private by default; `--allow-public-network` opts into Public too.
// Persist the choice (so the startup warning respects an opt-in) and re-scope idempotently —
// remove any prior rules first so an upgrade tightens the scope instead of leaving a stale
// all-profiles rule behind the new one.
let allow_public = allow_public_network(args);
set_fw_public_marker(allow_public);
remove_firewall_rules();
add_firewall_rules(allow_public);
println!(
"\nInstalled. Config: {}\nLogs: {}\n\nStart now with: punktfunk-host service start",
@@ -839,8 +873,28 @@ fn apply_gamestream_choice(enable: bool) {
// ── firewall + sc helpers ────────────────────────────────────────────────────────────────────────
/// The `netsh` `profile=` scope for punktfunk's inbound rules. Default = **Domain + Private** — the
/// trusted-network profiles punktfunk is meant to run on; `allow_public` widens it to **all profiles
/// including Public** (untrusted networks like café/hotel Wi-Fi — opt-in only). Shared with the
/// web-console rule in `install.rs` so both surfaces scope the same way.
pub(crate) fn firewall_profile_arg(allow_public: bool) -> &'static str {
if allow_public {
"profile=any"
} else {
"profile=domain,private"
}
}
/// The `--allow-public-network` install opt-in (the installer's "Allow connections on Public
/// networks" task forwards it). Absent = the secure default (Domain + Private only).
pub(crate) fn allow_public_network(args: &[String]) -> bool {
args.iter().any(|a| a == "--allow-public-network")
}
/// Inbound firewall rules for the streaming ports (best-effort; logs but never fails the install).
fn add_firewall_rules() {
/// Scoped by [`firewall_profile_arg`]: Domain + Private by default, all profiles when `allow_public`.
fn add_firewall_rules(allow_public: bool) {
let profile = firewall_profile_arg(allow_public);
// (name suffix, protocol, ports)
let rules = [
("TCP", "TCP", "47984,47989,48010,47990"),
@@ -860,14 +914,22 @@ fn add_firewall_rules() {
"action=allow",
&format!("protocol={proto}"),
&format!("localport={ports}"),
profile,
],
);
if ok {
println!("Firewall rule added: {name} ({ports})");
println!("Firewall rule added: {name} ({ports}) [{profile}]");
} else {
eprintln!("warning: could not add firewall rule '{name}' (add it manually if needed)");
}
}
if !allow_public {
println!(
"Note: streaming ports are open on Private/Domain networks only. On a network Windows \
classifies as Public, clients won't connect — set that network to Private, or reinstall \
with the 'Allow connections on Public networks' option."
);
}
}
fn remove_firewall_rules() {
@@ -886,6 +948,62 @@ fn remove_firewall_rules() {
}
}
/// Marker file recording that the operator opted into opening the firewall on **Public** networks
/// (`--allow-public-network`). Its presence suppresses the startup Public-network warning (they made
/// an informed choice); absence = the secure default.
fn fw_public_marker() -> std::path::PathBuf {
crate::gamestream::config_dir().join("fw-allow-public")
}
/// Record (or clear) the Public-firewall opt-in marker to match this install's choice.
fn set_fw_public_marker(allow_public: bool) {
let path = fw_public_marker();
if allow_public {
let _ = std::fs::write(&path, b"1\n");
} else {
let _ = std::fs::remove_file(&path);
}
}
/// Best-effort: is any active network connection classified **Public** by Windows? Uses
/// `Get-NetConnectionProfile` (per-interface category: Public / Private / DomainAuthenticated).
/// `None` when it can't be determined — the caller then skips the warning.
fn active_network_is_public() -> Option<bool> {
let out = std::process::Command::new("powershell")
.args([
"-NoProfile",
"-NonInteractive",
"-Command",
"(Get-NetConnectionProfile).NetworkCategory",
])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8_lossy(&out.stdout);
Some(s.lines().any(|l| l.trim().eq_ignore_ascii_case("Public")))
}
/// One-shot startup diagnostic: if the operator did NOT opt into Public networks and this machine's
/// current network is classified Public, the streaming ports are firewalled off there — turn that
/// silent "clients can't connect" into an actionable WARN. Best-effort; meant to run on its own
/// thread so it never delays the host launch.
fn warn_if_public_network() {
if fw_public_marker().exists() {
return; // operator opted into Public — their informed choice, no warning
}
if active_network_is_public() == Some(true) {
tracing::warn!(
"this machine's current network is classified Public (an untrusted-network profile), so \
punktfunk's streaming ports are firewalled off here and clients on this network can't \
reach the host. Fix: set the network to Private (Windows Settings > Network > \
properties) — or, only for a network you trust, reinstall with the 'Allow connections \
on Public networks' option."
);
}
}
/// Run an `sc.exe` command, passing its output through (used by start/stop/status).
fn sc(args: &[&str]) -> Result<()> {
let status = std::process::Command::new("sc")
+3
View File
@@ -22,6 +22,9 @@ fn main() {
println!("cargo:rerun-if-changed={path}");
res.set_icon_with_id(path, id);
}
// Task Manager / Explorer identity (matches the host's "Punktfunk Host").
res.set("FileDescription", "Punktfunk Tray");
res.set("ProductName", "Punktfunk");
res.compile().expect("embed windows icon resources");
}
}
+1 -1
View File
@@ -36,7 +36,7 @@ existing `service()` poll. State machine, each transition logs exactly once:
|---|---|---|---|---|
| 1 | Driver package not installed | fresh box, installer's `driver install --gamepad` skipped/failed, package pruned | attach timeout → `pnputil /enum-drivers` misses `pf_xusb.inf`/`pf_dualsense.inf` | WARN `driver package NOT in the driver store — run: punktfunk-host.exe driver install --gamepad` |
| 2 | Package present but binding failed | certificate not in Root/TrustedPublisher, Memory Integrity (HVCI) rejects it, stale DriverVer kept the old binary | attach timeout → devnode problem code (28 = drivers not installed, 52 = signature rejected, 31/39 = load failure) | WARN with the CM problem code + hint |
| 3 | Driver bound but crashed / never started | WUDFHost crash, `WdfDeviceCreate`/queue failure inside the driver | attach timeout → devnode status shows `driver_loaded`/`started` flags; the driver's own log (`C:\Users\Public\pf*-driver.log`) has the failing WDF call | WARN referencing both |
| 3 | Driver bound but crashed / never started | WUDFHost crash, `WdfDeviceCreate`/queue failure inside the driver | attach timeout → devnode status shows `driver_loaded`/`started` flags; the driver's own log (`C:\Users\Public\pf*-driver.log`) has the failing WDF call — opt-in like pf-vdisplay's (debug builds, or `PFXUSB_DEBUG_LOG`/`PFDS_DEBUG_LOG` set system-wide + device restart) | WARN referencing both |
| 4 | `SwDeviceCreate` fails outright | not Administrator/SYSTEM, PnP wedged, `_` in enumerator (E_INVALIDARG) | existing error path (unchanged) | WARN `SwDeviceCreate failed; … devnode unavailable`, pad continues on the out-of-band fallback |
| 5 | `SwDeviceCreate` callback never fires | PnP service hung | **was silently mis-read as success** (zero-init `HRESULT(0)` + ignored `WaitForSingleObject` return). Fixed: `result` inits to `E_FAIL`, the wait result is checked | ERROR `enumeration callback never fired (10s) — PnP may be wedged` |
| 6 | Driver attached, then WUDFHost died mid-session | crash, killed | `driver_heartbeat` freezes (DS/DS4: timer-driven, so a freeze is conclusive; XUSB: only advances while a game polls, so absence is *not* an error) | field exists for a future stall check; not auto-warned yet (XUSB semantics make a generic rule false-positive-prone) |
+1 -1
View File
@@ -129,7 +129,7 @@ notes for context.
| Setting | Values | Meaning |
|---|---|---|
| `PUNKTFUNK_PERF` | `1` | Log per-stage timing (capture, encode, send) — handy when tuning latency. |
| `RUST_LOG` | `info` · `debug` · `trace` | Log verbosity. On Windows, logs land in `%ProgramData%\punktfunk\logs\`. |
| `RUST_LOG` | `info` · `debug` · `trace` | Log verbosity. On Windows, logs land in `%ProgramData%\punktfunk\logs\` (size-capped: a file over 10 MB is rotated to `.old` at the next service/host start, one generation kept). |
| `PUNKTFUNK_FFMPEG_DEBUG` | set | Verbose libavcodec/FFmpeg logging from the encoder. |
| `PUNKTFUNK_VIDEO_DROP` | `N` (percent) | Deliberately drop N% of video packets to exercise FEC recovery. **Testing only.** |
+2 -2
View File
@@ -19,8 +19,8 @@ punktfunk-host serve
Add `--gamestream` (alias `--moonlight`) to **also** run the GameStream/Moonlight-compatible planes
(nvhttp pairing, RTSP, ENet control, `_nvstream` mDNS) — required for stock [Moonlight](/docs/moonlight)
clients. This is **opt-in** because GameStream carries inherent on-path weaknesses (pairing over plain
HTTP; its legacy control encryption can reuse GCM nonces — security-review #5/#9), so enable it **only
on a trusted LAN**. The native plane is immune to those issues.
HTTP; its legacy control encryption can reuse GCM nonces), so enable it **only on a trusted LAN**. The
native plane is immune to those issues.
```sh
punktfunk-host serve --gamestream
+11 -5
View File
@@ -8,8 +8,8 @@ always-available host, run it as a service. There are two cases.
> The bundled unit `scripts/punktfunk-host.service` runs `serve --gamestream`, so it serves both the
> native `punktfunk/1` plane and stock [Moonlight](/docs/moonlight) clients. For a **secure native-only
> host** (no GameStream — its pairing runs over plain HTTP and its legacy encryption is weaker;
> security-review #5/#9), drop `--gamestream` from the unit's `ExecStart` and use bare `serve`.
> host** (no GameStream — its pairing runs over plain HTTP and its legacy encryption is weaker), drop
> `--gamestream` from the unit's `ExecStart` and use bare `serve`.
## A. A desktop you log into
@@ -101,9 +101,15 @@ registers + starts the service for you (`/VERYSILENT` for unattended). Upgrades
handled through Add/Remove Programs.
Prefer the CLI? Run `punktfunk-host service install` from an elevated prompt — see
[Windows service](https://git.unom.io/unom/punktfunk/src/branch/main/docs/windows-service.md). For
hardware encode you need a GPU — NVIDIA (NVENC), AMD (AMF), or Intel (QSV); the host falls back to
software H.264 without one.
[Windows Host](/docs/windows-host). For hardware encode you need a GPU — NVIDIA (NVENC), AMD (AMF), or
Intel (QSV); the host falls back to software H.264 without one.
> **Firewall scope.** The installer opens the streaming + console ports on **Private and Domain**
> networks only — not **Public**. If your LAN is (mis)classified Public, clients won't connect until
> you set it to Private (Windows Settings → Network), and the host logs a warning when it's on a Public
> network. For a trusted network Windows insists is Public, tick **"Allow connections on Public
> networks"** at install (or pass `--allow-public-network` to `service install`). See
> [Security & Safe Use](/docs/security) for the reasoning.
## Verifying
+30 -15
View File
@@ -54,12 +54,21 @@ If you want to stream from outside your home, tunnel in instead of opening up:
- **Don't** map a router port to the host. A port-forward turns "trusted LAN service" into
"internet-facing service" with none of the protections that implies.
A note for **portable machines**: the installer opens the streaming ports on the firewall for *all*
network profiles, including Public. That's convenient at home but means that if you take a laptop host
onto an untrusted network — a café, a hotel, a conference — other devices on that network can reach the
ports and attempt to pair. Pairing still protects you (an attacker who doesn't know the PIN can't get
in), but the safest habit is to stop the host service, or firewall it off, when you're on a network you
don't control.
A note on **Windows network profiles**: the installer opens the streaming and console ports only on
**Private and Domain** networks — the profiles Windows uses for networks you've marked as trusted. On a
network Windows classifies as **Public** (cafés, hotels, conferences — the default for unknown
networks), the ports stay **closed**, so a laptop host won't accept connections there. That's the safe
default, and it's the behavior you want on the move. Two things follow from it:
- **If your home network is *misclassified* as Public, clients won't connect.** Set it to Private
(Windows Settings → Network & internet → your network → **Private network**). The host also logs a
warning at startup when it detects it's on a Public network, so this doesn't fail silently.
- **If you have a trusted network that Windows insists on marking Public** (some headless or
no-gateway LAN setups), you can opt in during install — the **"Allow connections on Public
networks"** checkbox (off by default). Only do this for a network you actually trust.
Either way, pairing is what ultimately gates access — but keeping the host off untrusted networks is
the first line, and on the move the safest habit is still to stop the service when you don't need it.
## What actually protects you
@@ -109,9 +118,7 @@ We mitigate this deliberately:
full-system. (This is why punktfunk dropped ViGEmBus.)
- **Sealed internal channels.** The desktop-frame ring and the gamepad input/output channels are
passed between the host and its drivers as duplicated handles to unnamed objects, so another local
service can't open them by name to read your screen or forge controller input. (Details:
[`idd-push-security.md`](https://git.unom.io/unom/punktfunk/src/branch/main/design/idd-push-security.md)
and [`gamepad-channel-sealing.md`](https://git.unom.io/unom/punktfunk/src/branch/main/design/gamepad-channel-sealing.md).)
service can't open them by name to read your screen or forge controller input.
- **Secrets are locked down.** The management token, the host identity key, and the console password
are stored with Administrators/SYSTEM-only permissions.
@@ -142,12 +149,20 @@ applies: keep it on a trusted LAN or a VPN, require pairing, and don't expose it
- **Keep the host updated** — security fixes ship in new builds.
- **On portable hosts**, stop the service when you're on an untrusted network.
## For the technically curious
## Reporting a vulnerability
The deeper security design lives in the repository, and it's candid about residual limits:
Found a security issue? **Email [security@punktfunk.com](mailto:security@punktfunk.com).** Please
don't open a public issue, pull request, or chat post for a suspected vulnerability — that exposes
other users before a fix is available.
- [`design/idd-push-security.md`](https://git.unom.io/unom/punktfunk/src/branch/main/design/idd-push-security.md) — the sealed frame channel (why the Windows capture path is isolated), and its honest floor.
- [`design/gamepad-channel-sealing.md`](https://git.unom.io/unom/punktfunk/src/branch/main/design/gamepad-channel-sealing.md) — the sealed gamepad channel.
- [`design/security-review-2026-06-28.md`](https://git.unom.io/unom/punktfunk/src/branch/main/design/security-review-2026-06-28.md) and [`design/security-review.md`](https://git.unom.io/unom/punktfunk/src/branch/main/design/security-review.md) — the standing security reviews.
Helpful things to include:
Found a security issue? Please report it privately rather than opening a public issue.
- The component and version — e.g. `punktfunk-host 0.6.0`, Windows or Linux, and which client.
- The impact, and the attacker's position (same LAN, a paired client, a local service account,
admin, …).
- Steps to reproduce, a proof-of-concept, or a crash/log if you have one.
We acknowledge reports within **3 business days** and practice coordinated disclosure — we'll keep
you posted, agree a disclosure date, and credit you when the fix ships (unless you'd rather stay
anonymous). The full policy is in
[`SECURITY.md`](https://git.unom.io/unom/punktfunk/src/branch/main/SECURITY.md).
+1 -2
View File
@@ -4,8 +4,7 @@ description: "Where the work stands across the core, the host, and the native cl
---
A high-level view of where punktfunk stands. The ordered plan of work is on the
[Roadmap](/docs/roadmap), and milestone-level detail lives in
[`CLAUDE.md`](https://git.unom.io/unom/punktfunk/src/branch/main/CLAUDE.md).
[Roadmap](/docs/roadmap).
## Milestones at a glance
@@ -269,18 +269,45 @@ fn channel_cfg() -> ChannelConfig {
}
}
/// Whether the world-writable bring-up file log is enabled (resolved once). OPT-IN — debug builds,
/// or the `PFDS_DEBUG_LOG` (system-wide) env var — the same treatment pf-vdisplay got in audit
/// §4.4: a RELEASE driver never writes the Public file (info-leak/DoS surface), and the per-report
/// OUTPUT hex dumps stop being a sustained disk-write path during gameplay. DebugView can't see the
/// UMDF host across session 0, so the file stays the bring-up diagnostic when enabled.
fn file_log_enabled() -> bool {
use std::sync::OnceLock;
static ON: OnceLock<bool> = OnceLock::new();
*ON.get_or_init(|| cfg!(debug_assertions) || std::env::var_os("PFDS_DEBUG_LOG").is_some())
}
/// Process-lifetime append handle to the bring-up log, opened ONCE and shared via a `Mutex`
/// (pf-vdisplay's pattern) — no per-line open/close.
fn file_appender() -> Option<&'static std::sync::Mutex<std::fs::File>> {
use std::sync::OnceLock;
static APPENDER: OnceLock<Option<std::sync::Mutex<std::fs::File>>> = OnceLock::new();
APPENDER
.get_or_init(|| {
if !file_log_enabled() {
return None;
}
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open("C:\\Users\\Public\\pfds-driver.log")
.ok()
.map(std::sync::Mutex::new)
})
.as_ref()
}
fn log(s: &str) {
if let Ok(c) = std::ffi::CString::new(s) {
// SAFETY: c is a valid null-terminated string for the duration of the call.
unsafe { OutputDebugStringA(c.as_ptr().cast()) };
}
// Also append to a world-writable file — DebugView can't capture the UMDF host's output
// across session 0, so this is how we read driver-start diagnostics.
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open("C:\\Users\\Public\\pfds-driver.log")
if let Some(m) = file_appender()
&& let Ok(mut f) = m.lock()
{
let _ = writeln!(f, "{s}");
}
+33 -4
View File
@@ -104,16 +104,45 @@ fn channel_cfg() -> ChannelConfig {
}
}
/// Whether the world-writable bring-up file log is enabled (resolved once). OPT-IN — debug builds,
/// or the `PFXUSB_DEBUG_LOG` (system-wide) env var — the same treatment pf-vdisplay got in audit
/// §4.4: a RELEASE driver never writes the Public file (info-leak/DoS surface), and the per-rumble
/// SET_STATE hex dumps stop being a sustained disk-write path during gameplay. DebugView can't see
/// the UMDF host across session 0, so the file stays the bring-up diagnostic when enabled.
fn file_log_enabled() -> bool {
use std::sync::OnceLock;
static ON: OnceLock<bool> = OnceLock::new();
*ON.get_or_init(|| cfg!(debug_assertions) || std::env::var_os("PFXUSB_DEBUG_LOG").is_some())
}
/// Process-lifetime append handle to the bring-up log, opened ONCE and shared via a `Mutex`
/// (pf-vdisplay's pattern) — no per-line open/close.
fn file_appender() -> Option<&'static std::sync::Mutex<std::fs::File>> {
use std::sync::OnceLock;
static APPENDER: OnceLock<Option<std::sync::Mutex<std::fs::File>>> = OnceLock::new();
APPENDER
.get_or_init(|| {
if !file_log_enabled() {
return None;
}
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open("C:\\Users\\Public\\pfxusb-driver.log")
.ok()
.map(std::sync::Mutex::new)
})
.as_ref()
}
fn log(s: &str) {
if let Ok(c) = std::ffi::CString::new(s) {
// SAFETY: `c` is a valid NUL-terminated string for the duration of the call.
unsafe { OutputDebugStringA(c.as_ptr().cast()) };
}
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open("C:\\Users\\Public\\pfxusb-driver.log")
if let Some(m) = file_appender()
&& let Ok(mut f) = m.lock()
{
let _ = writeln!(f, "{s}");
}
+23 -3
View File
@@ -112,7 +112,9 @@ SetupIconFile={#BrandingDir}\punktfunk.ico
WizardImageFile={#BrandingDir}\wizard-image-*.bmp
WizardSmallImageFile={#BrandingDir}\wizard-small-*.bmp
UninstallDisplayName=punktfunk host {#MyAppVersion}
; The branded multi-size .ico (installed below) - the host exe embeds no icon resource.
; The branded multi-size .ico (installed below). The host exe now embeds the same icon + a
; "Punktfunk Host" FileDescription (build.rs winresource) for Task Manager/Explorer; the file
; copy stays as the uninstall-entry icon.
UninstallDisplayIcon={app}\punktfunk.ico
[Languages]
@@ -144,6 +146,11 @@ Name: "installhdrlayer"; Description: "Install the HDR Vulkan layer (lets Vulkan
; in host.env; a hand-customized value is left alone). Checked = the Moonlight-compatible unified
; host (the common Windows setup); unchecked = the secure native-only host (punktfunk clients only).
Name: "gamestream"; Description: "Enable GameStream (Moonlight) compatibility - lets stock Moonlight clients connect (uses legacy plain-HTTP pairing; for trusted LANs)"
; Firewall scope, forwarded as `--allow-public-network` to `service install` / `web setup`. Unchecked
; (default) = accept connections on Private + Domain networks only (the trusted-network profiles
; punktfunk is meant for). Check ONLY for a network you trust that Windows classifies as Public (e.g.
; some headless / no-gateway LAN setups) - it opens the streaming + console ports on Public too.
Name: "allowpublicfw"; Description: "Allow connections on Public networks (only for a trusted network Windows marks as Public)"; Flags: unchecked
Name: "startservice"; Description: "Start the punktfunk host service now (also starts on every boot)"
; The per-user status tray (punktfunk-tray.exe): shows running/stopped/failed at a glance and
; offers open-console / start / stop / restart without a terminal. HKLM Run = every user who signs
@@ -237,7 +244,7 @@ Filename: "powershell.exe"; \
; 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}.
; --gamestream=on|off carries the wizard's GameStream task choice into host.env's PUNKTFUNK_HOST_CMD.
Filename: "{app}\punktfunk-host.exe"; Parameters: "service install {code:GamestreamParam}"; WorkingDir: "{app}"; \
Filename: "{app}\punktfunk-host.exe"; Parameters: "service install {code:GamestreamParam}{code:PublicFwParam}"; WorkingDir: "{app}"; \
StatusMsg: "Registering the punktfunk host service..."; Flags: runhidden waituntilterminated
Filename: "{app}\punktfunk-host.exe"; Parameters: "service start"; WorkingDir: "{app}"; \
StatusMsg: "Starting the punktfunk host service..."; Flags: runhidden waituntilterminated; Tasks: startservice
@@ -245,7 +252,7 @@ Filename: "{app}\punktfunk-host.exe"; Parameters: "service start"; WorkingDir: "
; Provision the console AFTER the host service is up (so the mgmt token exists): write the ACL'd
; login password, register the PunktfunkWeb scheduled task (boot, SYSTEM, restart-on-failure),
; open TCP 47992, and start it. {code:WebSetupParams} appends -PasswordFile only on a fresh install.
Filename: "{app}\punktfunk-host.exe"; Parameters: "web setup {code:WebSetupParams}"; WorkingDir: "{app}"; \
Filename: "{app}\punktfunk-host.exe"; Parameters: "web setup {code:WebSetupParams}{code:PublicFwParam}"; WorkingDir: "{app}"; \
StatusMsg: "Setting up the punktfunk web console..."; Flags: runhidden waituntilterminated
#endif
; Launch the status tray as the SIGNED-IN user (not the elevated install user) right away, so the
@@ -289,6 +296,19 @@ begin
Result := '--gamestream=off';
end;
{ Firewall scope: the "allowpublicfw" task opens the streaming + console ports on Public networks too
(default = Private/Domain only). Forwarded to both `service install` and `web setup`. Returns a
LEADING SPACE so it concatenates after the preceding code-substitution param without a gap.
(Do NOT write a literal code-constant token in this comment: Inno's brace comments do not nest,
so its closing brace would end the comment early and break the [Code] parse.) }
function PublicFwParam(Param: String): String;
begin
if WizardIsTaskSelected('allowpublicfw') then
Result := ' --allow-public-network'
else
Result := '';
end;
#ifdef WithWeb
var
WebPwPage: TInputQueryWizardPage;
+27 -3
View File
@@ -41,6 +41,23 @@ foreach ($k in 'LIBCLANG_PATH','CMAKE_POLICY_VERSION_MINIMUM') {
else { Write-Warning "env $k not set (run setup-build-env.ps1)" }
}
# All-vendor build when an FFmpeg dev tree is available (BtbN lgpl-shared: include/ + lib/ + bin/):
# nvenc alone otherwise. Without amf-qsv a GPU preference pointing at an AMD/Intel adapter makes
# every session die at encoder open (NV_ENC_ERR_NO_ENCODE_DEVICE) - the exact "can't connect"
# field failure on hybrid boxes.
$features = 'nvenc'
if (-not $env:FFMPEG_DIR) {
$v = [Environment]::GetEnvironmentVariable('FFMPEG_DIR', 'Machine')
if ($v) { [Environment]::SetEnvironmentVariable('FFMPEG_DIR', $v, 'Process') }
elseif (Test-Path 'C:\Users\Public\ffmpeg\include') { $env:FFMPEG_DIR = 'C:\Users\Public\ffmpeg' }
}
if ($env:FFMPEG_DIR -and (Test-Path (Join-Path $env:FFMPEG_DIR 'include'))) {
$features = 'nvenc,amf-qsv'
Write-Host "env : FFMPEG_DIR=$env:FFMPEG_DIR (AMF/QSV enabled)"
} else {
Write-Warning "no FFMPEG_DIR dev tree - building NVENC-only (AMD/Intel GPU selection will not encode)"
}
# 1. stop the service so the .exe is writable
Write-Host "stopping $svc ..."
& sc.exe stop $svc | Out-Null
@@ -49,9 +66,9 @@ for ($i=0; $i -lt 30 -and (Svc-Running); $i++) { Start-Sleep 1 }
# 2. back up the current binary for rollback
if (Test-Path $exe) { Copy-Item $exe $bak -Force; Write-Host "backup : $bak" }
# 3. build (release + nvenc); build env is inherited from Machine scope (setup-build-env.ps1)
Write-Host "building: cargo build --release -p punktfunk-host --features nvenc"
& cmd.exe /c "call `"$vcvars`" >nul && cargo build --release -p punktfunk-host --features nvenc"
# 3. build (release); build env is inherited from Machine scope (setup-build-env.ps1)
Write-Host "building: cargo build --release -p punktfunk-host --features $features"
& cmd.exe /c "call `"$vcvars`" >nul && cargo build --release -p punktfunk-host --features $features"
$built = ($LASTEXITCODE -eq 0)
if (-not $built) {
@@ -61,6 +78,13 @@ if (-not $built) {
throw "build failed; previous binary restored and service restarted."
}
# 3b. the AMF/QSV backend link-imports the FFmpeg DLLs - lay them next to the exe (the installer
# does the same into {app}); idempotent, and harmless for the NVENC path.
if ($features -like '*amf-qsv*') {
Copy-Item (Join-Path $env:FFMPEG_DIR 'bin\*.dll') (Split-Path $exe) -Force
Write-Host "ffmpeg : runtime DLLs copied next to the exe"
}
# 4. start on the new binary and confirm it stays up
Write-Host "build OK - starting $svc ..."
& sc.exe start $svc | Out-Null