A WGC-helper exit (crash, or a console disconnect killing its session) used to
end the stream. Now virtual_stream_relay rebuilds the output + helper and resumes
on the new helper's opening IDR. Rebuild — not respawn-on-the-old-target —
because an abruptly-killed helper leaves the SudoVDA's DXGI output briefly
unresolvable ("no DXGI output for target N yet"), and a console reconnect needs
a fresh output in the new session; `build` (the same path reconfigure uses)
recreates both. Bounded: 500ms backoff per attempt, give up after
MAX_HELPER_FAILS (20) consecutive failures; the counter resets on the first
relayed frame.
Live-validated on the RTX 4090 (host as SYSTEM): force-killed the helper PID
mid-stream → exactly one "WGC helper exited — rebuilt output + helper fails=1" →
the stream recovered and client-rs decoded 645 HEVC Main-10 frames continuously
across the kill (an earlier respawn-on-stale-target attempt storm-failed with
"no DXGI output", which the rebuild fixes).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PUNKTFUNK_SECURE_TEST_PERIOD_MS=N drives a square-wave secure/normal toggle in
virtual_stream_relay (instead of the real DesktopWatcher), to exercise the
mid-session helper↔DDA mux without a live UAC/lock. Gated behind the env var,
in the style of PUNKTFUNK_VIDEO_DROP / PUNKTFUNK_FEC_PCT.
Live-validated on the RTX 4090 (host as SYSTEM): with a 4s toggle the mux
switched secure(DDA)↔normal(WGC relay) cleanly 5× in one session and the client
decoded 308 HEVC Main-10 frames continuously across every switch — the
wait-for-IDR latch held with no decode break. The real Winlogon DDA capture is
pre-proven by the single-process secure path (a08df8c); the toggle exercises the
new surface (the mux). Doc updated with the validation + the SYSTEM-mode audio
caveat.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CreateProcessAsUserW gives the spawned helper the *user's* environment block, so
the host's PUNKTFUNK_ENCODER=nvenc (and ZEROCOPY/PERF/…) were dropped and the
helper fell back to the software (H.264-only) encoder — the client negotiated
H265 → "WGC helper exited". `merged_env_block` now parses the user block, strips
any PUNKTFUNK_* it carried, overlays this (host) process's PUNKTFUNK_* vars, and
passes the merged UTF-16 block.
Validated live on the RTX 4090 (host as SYSTEM): the helper spawns via
CreateProcessAsUserW, runs WGC with no hang (HDR FP16 BT.2020 PQ), opens NVENC
(D3D11 Main10), and relays AUs over the pipe — client-rs decoded 411 HEVC
Main-10 frames over the LAN. Step 4 (spawn + relay) complete.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`virtual_stream_relay` now muxes the AU source by input desktop. A DesktopWatcher
(SYSTEM-only Winlogon-name poll) drives it: the user-session WGC helper relay
feeds the normal (Default) desktop; the host's OWN DDA capturer+encoder — opened
lazily on the first secure transition, on the same SudoVDA target with a no-op
keepalive (the host still holds the real isolation owner) — captures the secure
(Winlogon: UAC/lock/login) desktop that WGC can't see. Every switch latches
"wait for IDR" and forces the now-active source to emit a keyframe (the two
encoders keep independent infinite-GOP state, so the client must resume on an
IDR); returning to the helper also drains its stale buffered AUs first.
Reconfigure drops the stale-target DDA; keyframe requests route to the live
source. Send path (FEC/seal/paced-send) unchanged.
Also: wgc_relay gains try_recv (drain on switch-back); open_dda takes dims as
args (avoids a closure borrow of the reassigned cur_mode); the forward! macro
returns bool with `break 'outer` at the call site (no in-macro label hygiene).
cfg-gated windows-only. Live validation (UAC switch over a session) pending.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The SYSTEM host now sources the normal-desktop video from a user-session WGC
helper instead of capturing in-process (WGC won't activate as SYSTEM). New
`capture/wgc_relay.rs`: `HelperRelay::spawn` launches `m3-host wgc-helper` in the
interactive user session via CreateProcessAsUserW (WTSQueryUserToken →
DuplicateTokenEx(TokenPrimary) → lpDesktop="winsta0\\default", CREATE_NO_WINDOW)
with three anonymous pipes — stdout (framed Annex-B AUs → parsed back to
RelayAu), stdin (control: force-keyframe), stderr (helper logs → host tracing).
The host holds the SudoVDA keepalive (sole isolation/topology owner); the helper
captures by GDI name only.
m3.rs: `virtual_stream` dispatches to the new `virtual_stream_relay` when
`should_use_helper()` (running as SYSTEM, or PUNKTFUNK_FORCE_HELPER; disable with
PUNKTFUNK_NO_HELPER). The relay loop feeds the existing send thread — same
FEC/seal/paced-send path. Reconfigure rebuilds the output + re-spawns the helper;
keyframe requests forward over the control pipe; helper pts_ns (same-machine
monotonic clock) is used directly as capture_ns. Disconnect ends the stream
(step 6 adds the relaunch watchdog).
wgc_helper.rs: reads the stdin control byte to request an IDR; --bit-depth flag
threaded through so SDR 10-bit (Main10) negotiation reaches the helper's encoder.
cfg-gated windows-only; Linux/macOS build unaffected. Step 5 (DesktopWatcher mux
to host DDA on the Winlogon secure desktop) is next.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`m3-host wgc-helper --target-id N --gdi NAME --mode WxHxHz --bitrate K`: the
USER-session half of the two-process secure-desktop design
(docs/windows-secure-desktop.md). Opens WGC on the EXISTING SudoVDA output by
GDI name only (never creates a virtual output — a second topology owner re-trips
the ACCESS_LOST born-lost storm), encodes via NVENC, and ships framed Annex-B
AUs on stdout for the SYSTEM host to relay onto the live QUIC session:
`[u32 magic "PFAU"][u32 len][u64 pts_ns][u8 keyframe][data]`. tracing → stderr so
stdout stays the pure AU stream. cfg-gated windows-only; Linux build unaffected.
scripts/headless/win-build.cmd: the canonical box build script (sets
PUNKTFUNK_BUILD_VERSION so build.rs stamps the version + the NVENC LIB path).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Polls the input-desktop name (OpenInputDesktop + GetUserObjectInformationW(UOI_NAME)) on its own
thread → Default/Winlogon atomic; the authoritative normal-vs-secure signal for the capture mux +
input path (WTS notifications miss UAC). Not yet wired into the mux (needs the SYSTEM host + WGC
helper, steps 3-5 in docs/windows-secure-desktop.md). NOTE: detecting the secure desktop requires the
host to run as SYSTEM (a user-token process can't OpenInputDesktop the Winlogon desktop).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Validated design for adding secure-desktop (UAC/lock/login) coverage on top of the shipped WGC
animation fix. Key verified constraint: WGC won't activate under SYSTEM (0x80070424) even with
thread-level ImpersonateLoggedOnUser, and DDA+SendInput on Winlogon need LOCAL_SYSTEM — so one
process can't do both. Architecture: SYSTEM host (QUIC + SudoVDA + DDA-secure + SendInput + AU mux)
+ a USER-session WGC helper (CreateProcessAsUser) that relays encoded Annex-B AUs over a named pipe;
the host muxes helper-AUs (normal desktop) vs its own DDA encoder (secure desktop), switched by a
desktop-name watcher. No shared GPU texture (rejected — MIC/keyed-mutex pain); just AU bytes.
docs/windows-secure-desktop.md has the ordered, box-testable steps.
The impersonate_active_user() in wgc.rs is kept as a harmless no-op (under a user-token process
WTSQueryUserToken fails → no impersonation → WGC works natively); it does NOT make WGC work under
SYSTEM (the two-process design uses a real user process for WGC instead). + Win32_System_RemoteDesktop.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The capture-architecture reset from the research: add a Windows.Graphics.Capture (WGC) backend that
captures the COMPOSED desktop — including the overlay/independent-flip/MPO planes DXGI Desktop
Duplication misses — which structurally fixes the frozen HDR animations + video (proven live: a WGC
frame decodes to the real 5120x1440 HDR content DDA freezes on). It reuses the whole pipeline
unchanged: the WGC frame's GPU texture → same scRGB→BT.2020-PQ shader → NVENC zero-copy; the OS
composites the cursor (IsCursorCaptureEnabled) so no manual cursor pass. crates/punktfunk-host/src/
capture/wgc.rs; find_output/make_device/HdrConverter/nudge_cursor_onto made pub(crate) for reuse.
Reliability findings + mitigations (live on the RTX 4090):
- WGC can't activate under the SYSTEM account (0x80070424) — it needs the interactive user token. The
host must run as the user for WGC (run.cmd: drop PsExec -s). DDA still needs SYSTEM for the secure
desktop — that token reconciliation (impersonation) is the remaining task.
- WGC's Direct3D11CaptureFramePool::CreateFreeThreaded intermittently HANGS on the headless SudoVDA
(IddCx) display, correlated with accumulated SudoVDA churn (failed REMOVEs leaving lingering
displays); clean-state opens reliably. Since it's a blocking hang, capture_virtual_output runs WGC
open on a watchdog thread with a 5s timeout and falls back to DDA on hang/error — the session is
NEVER left black: WGC when it opens (fixed animations), DDA otherwise. First-frame nudge added (WGC
fires FrameArrived on change; a static desktop otherwise never delivers the first frame).
- Default WGC; PUNKTFUNK_CAPTURE=dda forces DDA. DDA path unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "broken animations in HDR" was an encode-throughput cliff, not the ACCESS_LOST churn. Measured at
5120x1440@240 HEVC Main10 on the RTX 4090: forced 2-way split-encode = 7.6 ms/frame (~131 fps, well
over the 4.17 ms/240fps budget → choppy), while SINGLE engine = 2.8-3.9 ms/frame (~256-357 fps, fits
240). The split/merge overhead dominates for 10-bit; a single Ada NVENC engine already handles 5K@240
Main10 comfortably. So the split decision now forces DISABLE for Main10 (bit_depth >= 10), keeping the
existing forced-2 only for 8-bit above 1 Gpix/s. PUNKTFUNK_SPLIT_ENCODE still overrides. Added a
split-mode log line.
Validated live on the 4090: encode_us_p50 7.6 ms → 3.9 ms at 5K240 HDR with no env override.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The HDR path produced a constant ACCESS_LOST churn during real desktop activity (window resize /
Start menu / DWM transitions): the duplication keeps getting invalidated but the OUTPUT stays valid
(probe passes — 0 born-lost over 72 rebuilds). The old recovery did a FULL rebuild (new device +
factory) on every loss, which re-inits NVENC + seeds black + was throttled to 4x/s → mostly-frozen,
re-init churn = "broken animations".
Now recovery is tiered (mirrors Sunshine): try_reduplicate() does a fresh DuplicateOutput on the
EXISTING device+output — no new device, so NO encoder re-init, NO black seed, gpu_copy/HDR
textures/last_present kept → frames resume immediately. Only a genuine output loss (secure-desktop
switch) or a dead device (DEVICE_REMOVED/RESET) falls back to the full, throttled recreate_dupl.
Both paths probe the new duplication and reject a born-lost one.
Validated synthetically (1080p60 + 5120x1440@240 HDR): pipeline stable, 0 churn, frames flow. The
real-desktop churn needs live validation (can't synthesize DWM animations). Secure-desktop "UI never
appears in-session" is a separate issue (output gone in-session; only a fresh monitor re-add works) —
still open.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- HDR cursor: sRGB→linear decode + scale to HDR graphics white (PUNKTFUNK_HDR_CURSOR_NITS, default
203 per BT.2408) in the FP16 cursor composite, so it's no longer ~2.5x too dim. SDR path unchanged;
the masked-color (I-beam) inversion blend left unscaled. Cursor cbuffer widened 16→32 + bound to PS.
(Validated live: cursor now correct brightness in HDR.)
- Secure-desktop recovery: recreate_dupl now PROBES the rebuilt duplication with a 50ms
AcquireNextFrame and only adopts it when live (Ok/WAIT_TIMEOUT); a born-lost one (immediate
ACCESS_LOST) is dropped so the caller repeats the last frame + retries. Plus reassert_isolation()
re-detaches physical displays on every recovery (re-routing the secure/HDR desktop to the virtual
output, the delta a fresh reconnect has). NOTE: the born-lost ACCESS_LOST storm in HDR is NOT yet
resolved by these — still under investigation (animations/secure-UI/cursor-trail in HDR remain).
- docs/windows-client-bootstrap.md: handoff for the native Windows Rust client (windows-rs Reactor +
WinUI 3 SwapChainPanel, D3D11VA decode, WASAPI audio, SDL3 input; ports crates/punktfunk-client-linux;
10-bit/HDR present; dev boxes + gotchas).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds true HDR (BT.2020 PQ) and 10-bit (HEVC Main10) streaming, negotiated so an
8-bit/SDR client is never sent a stream it can't decode, plus a robust fix for the
capture losing the stream across a secure-desktop transition.
Protocol (punktfunk-core/quic.rs):
- Hello gains `video_caps` (VIDEO_CAP_10BIT / VIDEO_CAP_HDR), Welcome gains `bit_depth`,
both as optional trailing bytes (back-compat). client-rs advertises 10-bit via
PUNKTFUNK_CLIENT_10BIT; the connector advertises 0 for now (in-band detection drives
the native clients). Regenerated punktfunk_core.h.
Windows host:
- 10-bit Main10: host enables it only when the client advertised VIDEO_CAP_10BIT AND
PUNKTFUNK_10BIT is set; threaded through open_video → NVENC (profile Main10,
pixelBitDepthMinus8).
- HDR: when the captured desktop is scRGB FP16 (R16G16B16A16_FLOAT, HDR on), copy it to
an FP16 surface, composite the cursor there, convert scRGB → BT.2020 PQ 10-bit
(R10G10B10A2) via a shader, and encode HEVC Main10 with the BT.2020/PQ colour VUI
(ABGR10 input). Fixes the freeze + cursor-trail that came from feeding FP16 into the
BGRA path. Reacts dynamically to the HDR toggle.
- Capture recovery: rebuild is now a single NON-BLOCKING attempt, throttled to ~4×/s,
repeating the last good frame between attempts (format-tagged last_present). During a
secure-desktop dwell SudoVDA's output is gone; the old blocking 12 s retry starved the
send loop for seconds so the client timed out and disconnected — now the session stays
fed (frozen) until the desktop returns. Also seeds a black frame on recovery.
Apple client (PunktfunkKit):
- Detects HDR in-band from the stream VUI (PQ transfer function), decodes to 10-bit P010,
and presents via an rgba16Float + BT.2020 PQ CAMetalLayer with EDR; SDR path unchanged.
Switches automatically on a mid-session HDR toggle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Windows host pegged the GPU 3D engine at ~97% during high-fps desktop streaming — measured (per-
process GPU-engine counters) as OUR process, not DWM. Cause: TWO VRAM->VRAM CopyResource per frame
(dupl->gpu_copy in the capturer, then gpu_copy->nvenc_pool in the encoder), and on Windows D3D11
routes copies to render-target textures through the 3D engine (the DMA copy engine sat idle at 7%),
so at 240 fps they saturate it and contend with a game's own rendering.
Eliminate the second copy: NVENC now registers the capturer's D3D11 texture directly (cached by raw
pointer, the cloned texture kept alive until unregister) and encode_pictures it IN PLACE — no
encoder-owned input pool, no per-frame copy. Safe because the host encode loop is synchronous
(capture -> submit -> poll, where lock_bitstream blocks until the encode finishes), so the capturer
never overwrites the texture mid-encode; documented in the module header in case that ever changes.
2 GPU copies/frame -> 1 (the remaining dupl->gpu_copy is unavoidable; that DXGI surface is transient).
Measured: SM/compute ~10-15% at ~217 fps 5K (was ~20% at only ~48 fps with two copies), 3687 frames
decoded clean. Windows-only; Linux/macOS unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Windows host couldn't sustain high-throughput / high-fps streams — two gaps vs the Linux host,
both found via live RTX 4090 measurement (PERF timing + nvidia-smi per-engine attribution):
- UDP Send Offload (USO). punktfunk-core's UdpTransport sent one packet per `send` syscall on
Windows (send_batch/send_gso were Linux-only), capping throughput at high packet rates. Add a
Windows `send_gso` override using `WSASendMsg` + `UDP_SEND_MSG_SIZE` (the Windows analogue of
Linux UDP GSO) via windows-sys — one syscall segments a coalesced <=512-segment super-buffer to
the connected peer. On by default with auto-fallback (PUNKTFUNK_GSO=0 disables, error latches
off); plugs into the existing paced send path. SO_SNDBUF (32MB) was already cross-platform.
- NVENC 2-way split-frame encoding. A single Ada NVENC session tops out ~0.8 Gpix/s, so 5K@240
(1.77 Gpix/s) took ~8 ms/frame -> a ~125 fps ceiling at high motion (the in-game stutter). Set
NV_ENC_INITIALIZE_PARAMS.splitEncodeMode = TWO_FORCED above ~1 Gpix/s (matching the Linux
libavcodec split_encode_mode path) to use both 4090 encoders — measured ~8 ms -> ~4 ms/frame at
throughput. Env override PUNKTFUNK_SPLIT_ENCODE; init-failure fallback disables it (e.g. H264).
Windows-only paths; Linux/macOS unaffected. Builds clean on x86_64-pc-windows-msvc.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Live-validated Mac <-> RTX 4090 at the display's native 5120x1440@240:
- Resolution: set_active_mode enumerates the IDD's advertised modes and sets the
requested resolution at the best supported refresh (keeps 5120x1440@240; no more
silent fallback to the 1080p OS default when an exact mode is briefly unavailable).
- Bitrate auto-cap: NVENC init probes and steps the average bitrate down to the GPU's
codec-level max so a high client bitrate connects (matches the Linux host; we do not
split NVENC sessions).
- Mouse cursor: DXGI duplication excludes the HW cursor; capture the pointer
shape/position (GetFramePointerShape) and GPU-composite it before NVENC. Color cursors
alpha-blend; masked-color (the text I-beam) uses an INV_DEST_COLOR inversion blend so
the caret inverts the screen and shows on any background (no black box); monochrome
handled too.
- Secure desktop (lock / login / UAC): run as SYSTEM in the interactive session, follow
the input desktop via SetThreadDesktop, and on the WinSta switch recreate the D3D11
device and re-resolve the virtual output's GDI name from the stable SudoVDA target id
(the name changes across the topology rebuild; the old failure hunted the stale
\\.\DISPLAYn and dropped). ACCESS_LOST / INVALID_CALL / device-removed are recoverable,
and a mid-stream resolution change is followed (capturer + NVENC re-init at the new
size). isolate_displays detaches other monitors so Winlogon renders to the virtual
output. One real session recovered 1012 desktop switches and completed cleanly.
Windows-only backends; Linux/macOS unaffected. Builds clean on x86_64-pc-windows-msvc.
Deployment (windowless SYSTEM launch via PsExec + hidden VBScript) documented in
docs/windows-host.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TOFU let anyone who could reach the host click "Trust" and stream, which defeats the point
on a LAN. Make SPAKE2 PIN pairing the default and only way to trust a NEW host; TOFU survives
as an explicit HOST opt-in (for fully trusted networks), advertised over mDNS so clients render
their trust UI from the host's policy rather than offering trust on faith.
Contract:
- Host advertises pair=required (default) or pair=optional. pair=required rejects unpaired
clients at the handshake; pair=optional accepts them (TOFU).
- Clients: a pinned host whose fingerprint matches connects silently; a pinned host whose
fingerprint CHANGED forces re-pairing via PIN (no re-trust shortcut); a NEW host is offered
TOFU only if it advertised pair=optional, otherwise PIN pairing is mandatory; a manually-typed
or unknown-policy host is always PIN.
Host (crates/punktfunk-host/src/main.rs):
- m3-host now REQUIRES pairing by default (was open by default). New --allow-tofu opts into
accepting unpaired clients + advertising pair=optional; pairing is always armed (PIN logged at
startup). serve --native was already secure-by-default (serve --open). The mDNS advert and the
accept loop already mapped require_pairing -> pair=required + reject; only the m3-host CLI
default + help text changed.
Clients honor the advertised policy:
- Android (MainActivity.kt): TOFU only for a discovered pair=optional host; manual/unknown -> PIN;
fp-change -> re-pair only (dropped the "Forget & re-TOFU" shortcut).
- Apple (HostDiscovery/SessionModel/ContentView/HostCards/HostStore): new allowsTofu
(pair==optional, distinct from unknown); connect() gates .awaitingTrust on it; unpinned
non-optional hosts route to the PIN sheet; "Forget Identity" re-pairs rather than re-TOFUs.
- Linux (app.rs/ui_hosts.rs/session.rs): ConnectRequest.pair_required -> pair_optional;
initiate_connect routes pinned/fp-changed/optional/else; manual + --connect unknown -> PIN; a
pinned connect rejected on trust grounds re-pairs.
Docs (CLAUDE.md, README.md, docs-site/content/docs/pairing.md): describe the gated model — PIN is
the default, TOFU an explicit opt-in with an impostor warning.
Verified: host cargo check/clippy/fmt clean; Android built + live (emulator -> home-worker-2):
a manual connect now opens the PIN dialog (no Trust button) and the PIN ceremony streams; Apple
swift build clean; Linux clippy -D warnings + fmt clean on the Linux box.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The stale code a default install/upgrade got was a TAG LEAK: deb.yml/rpm.yml shared
`tags: ['v*']` with the Apple-client release.yml, so the v0.1.0/v0.1.1 tags cut to ship
the macOS app ALSO published host packages versioned 0.1.1 — which outranks every rolling
0.0.1~ciN / 0.0.1-0.ciN build in both registries (dpkg/rpm version compares confirm), so
`apt install`/`rpm-ostree install` silently fetched ~99-commits-stale code while the READMEs
claimed auto-tracking. Two fixes:
- Decouple host publishing from Apple `v*` tags: deb.yml/rpm.yml now trigger on `host-v*`
only, so a client tag can never poison the host channel again.
- Bump the rolling base 0.0.1 -> 0.2.0 (deb `0.2.0~ciN`, rpm `0.2.0-0.ciN`): sits ABOVE the
stray 0.1.1 yet BELOW a future 0.2.0 tag, and still climbs monotonically by run number — so
`apt upgrade`/`rpm-ostree upgrade` genuinely move forward. Spec default + build scripts +
PKGBUILD pkgver bumped to match.
Build provenance (so a stale/shadowed host is detectable): build.rs stamps PUNKTFUNK_BUILD_VERSION
(set by CI = the full package version, e.g. 0.2.0~ci120.g802e98d; falls back to the crate version
for a plain `cargo build`) into the binary via rustc-env. Surfaced in `punktfunk-host --version`,
the startup log, and the mgmt /health + /host `version` field (was a hardcoded CARGO_PKG_VERSION).
Deliberately env-driven, not git-derived — the RPM builds from a git-archive tarball with no .git.
Version computed BEFORE the build in deb.yml; the spec %build exports it from %{version}-%{release}
(and gains --locked for reproducibility parity with the .deb path). Validated: plain build reports
0.0.1, env-stamped build reports 0.2.0~ci999.gdeadbee.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Validated live on an RTX 4090 (Windows 11) host streaming to the Rust
reference client over the LAN: SudoVDA virtual display → DXGI Desktop
Duplication (D3D11 zero-copy) → NVENC HEVC → punktfunk/1. 720p60 and
1080p60 both clean (181 / 177 frames, 0 mismatched, p50 1.6 / 3.45 ms
cross-machine), coexisting with Apollo. Two real-hardware bugs the
GPU-less VM couldn't surface:
- DXGI capturer: the SudoVDA virtual monitor's DXGI output is enumerated
under the GPU that *renders* it (the 4090, LUID 0x15df6), NOT under the
SudoVDA "adapter" LUID SudoVDA reports (0x23276). Restricting the output
search to that LUID found nothing → "adapter has no output named
\\.\DISPLAYn". Now search ALL adapters for the GDI name, bind the D3D11
device to whichever adapter exposes it (NVENC then shares that device),
with a settle-retry (the output appears a beat after display creation)
and topology logging.
- native_pairing / apps: keyed config paths off raw $HOME, which a Windows
service/scheduled-task context doesn't set → "HOME unset" hard-fail at
m3-host startup. Route both through gamestream::config_dir(), which falls
back to %APPDATA% on Windows (cert/paired/apps now under AppData\Roaming).
clippy -D warnings + build green on x86_64-pc-windows-msvc (default and
--features nvenc) and Linux (78/78 tests).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The mgmt API already always serves HTTPS (the host identity cert), but on a
loopback bind with no token it ran unauthenticated — any local process could
drive it. Make auth required ALWAYS:
- new mgmt_token::load_or_generate(): token precedence is --mgmt-token > env
PUNKTFUNK_MGMT_TOKEN > persisted ~/.config/punktfunk/mgmt-token > freshly
generated 32-byte hex, persisted 0600 in KEY=VALUE form (so the bundled web
console can source it directly as a systemd EnvironmentFile — one source of
truth). config_dir() made pub(crate).
- parse_serve() resolves the token via load_or_generate() when unset, so a bare
`serve` Just Works with auth on and no operator step.
- mgmt::run() drops the loopback no-token exemption and requires a token;
require_auth()'s unauthenticated fallback now returns 401. The paired-cert
(mTLS) branch is unchanged — Apple client + library auth unaffected.
- web /api proxy: 503 (legible) instead of forwarding an empty bearer.
- tests: test_app/test_app_native default a token, send() auto-attaches the
bearer; blank-token test asserts the new "no token" refusal. 80 pass.
- docs: mgmt module doc + host.env.example reflect always-on auth + auto-gen.
Compiles, clippy/fmt clean, openapi no drift. Part B (bundle the web console into
apt, auto-wired to this token) follows.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Steam `LibraryProvider` keyed off `$HOME` + Linux paths, so the game
library was empty on Windows. Add Windows discovery: the default Steam
install dirs under Program Files (`ProgramFiles(x86)`/`ProgramFiles`/
`ProgramW6432`), with games on other drives picked up via each root's
`libraryfolders.vdf` — whose Windows values are backslash-escaped, so
unescape `\\` → `\`. The existing root-scan/dedup logic is shared via a
new `steam_roots_existing` helper. The custom store (mgmt JSON CRUD) was
already cross-platform; only Steam auto-discovery was Linux-only.
Not yet covered: a non-default Steam install dir (the registry
`Valve\Steam\InstallPath`). Degrades gracefully — no Steam → empty list.
clippy -D warnings + library tests green on Windows and Linux.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`serve` gave Moonlight clients no audio on Windows: the GameStream audio
stream thread was Linux-only (a non-Linux stub errored). Widen the
stereo path to Windows — the encode/RTP/AES-CBC/hand-rolled-RS(4,2)-FEC
logic is platform-neutral and already live-validated byte-identical on
Linux, and it now runs over the WASAPI capturer + the (already
cross-platform) `opus` crate. The cfg gates go from `linux` to
`any(linux, windows)`; only the surround path stays Linux-only because
its libopus *multistream* encoder needs `audiopus_sys` (a Linux dep) —
on Windows a surround request fails cleanly with a "use stereo" error.
Linux stays byte-identical (the `SessionEncoder::Surround` variant and
its match arm keep `#[cfg(linux)]`, so Linux compiles exactly as before).
Verified: clippy -D warnings + host test suite green on both
x86_64-pc-windows-msvc (73/73) and Linux (78/78).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Windows host test suite hit two pre-existing portability failures
(the autonomous Windows bring-up never ran `cargo test` on the VM):
- `vdisplay::detect_active_session_*` asserted a non-empty XDG runtime
dir — a Linux concept with no Windows equivalent. Gate just that
assertion to Linux (keep the call so the fn stays used → no dead_code).
- `mgmt::openapi_document_is_complete_and_checked_in` did a byte compare
against the checked-in spec, which git may check out CRLF on Windows
while serde_json emits LF. Compare content with `\r` stripped.
Host suite now 73/73 on x86_64-pc-windows-msvc; Linux unchanged (78 ok).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire the host→client rumble path on Windows, the analogue of the Linux
uinput EV_FF read loop: a game's force-feedback on the virtual Xbox 360
pad is delivered by ViGEm's notification API (`request_notification` →
`spawn_thread`, gated by the crate's `unstable_xtarget_notification`
feature). A per-pad background thread stores the latest motor levels;
`pump_rumble` relays changes to the client on the universal 0xCA plane
(motors scaled 0..255 → 0..65535). Dropping the target aborts the
notification, so the thread exits with the session. Live verification
still needs a physical pad.
Also fix the Windows backends' clippy debt — these modules are cfg-
excluded from Linux CI, so `clippy -D warnings` never saw them, and the
VM's rustc 1.96 clippy is stricter on shared code than the CI image:
- dxgi: manual checked division → checked_div().map_or
- sendinput: `x = x | y` → `x |= y`
- sudovda: `.then(|| ptr)` → `.then_some(ptr)`
- m3 pick_compositor: drop the needless early return (match form)
- m3 resolve_compositor: Windows arm is a tail expr, not `return`
All Windows backends now build + clippy clean (default and --features
nvenc); Linux unaffected (fmt/clippy/check green).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
"Controller disconnected every few seconds" (Forza Horizon, held steady): the
virtual UHID DualSense emitted HID report 0x01 ONLY on state change, but a real
DualSense streams it continuously (~250 Hz). When the player holds the
wheel/throttle steady the client sends no wire events, so the host wrote nothing
and /dev/uhid went silent for seconds — the kernel hid-playstation driver / Proton
/ SDL treat that as an unplugged controller. (The uinput X-Box pad is immune:
evdev holds last-known state with no periodic-report requirement.)
Add DualSenseManager::heartbeat(max_gap): re-emit each live pad's CURRENT report
when it's been silent for max_gap (idempotent — a stale-but-correct frame, never a
phantom input; write_state bumps seq+timestamp). write() resets the per-pad timer,
so an actively-used pad emits no extra reports — the heartbeat only fills genuine
silence. PadBackend::heartbeat() drives it at an 8 ms gap (~125 Hz) for DualSense
(no-op for X-Box), called every input-thread tick (the loop already runs ≤4 ms).
GET_REPORT feature replies + the pad lifecycle were ruled out by the investigation
(pad is created once, never torn down mid-session). Compiles, clippy/fmt clean, 78
host tests pass. Verify on the box: held-idle DualSense stays present in evtest /
no SDL CONTROLLERDEVICEREMOVED; Forza no longer toasts "controller disconnected".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The `m3` audio_thread (desktop capture → Opus 48 kHz stereo 5 ms CBR →
AUDIO_MAGIC datagrams) now runs on Windows, fed by the WASAPI loopback
capturer. The `opus` crate vendors libopus via `audiopus_sys` + cmake
(no system lib / vcpkg), so it builds on MSVC — moved into a
`cfg(any(linux, windows))` deps table and widened the audio_thread cfg
to match (the stub now only covers other targets, e.g. macOS).
Build note: CMake 4 rejects libopus's old `cmake_minimum_required`;
set `CMAKE_POLICY_VERSION_MINIMUM=3.5` when building the host on Windows.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two parked follow-ups from the session-aware host work:
#3 — KWin/Mutter virtual output not set primary. The auto-detected desktop path
*is* "stream this desktop", but the per-session virtual output wasn't promoted to
primary, so KDE/GNOME panels + windows stayed on an unstreamed real output and the
streamed screen showed only wallpaper. apply_session_env now defaults
PUNKTFUNK_KWIN_VIRTUAL_PRIMARY / PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY on for the
auto path (explicit config still wins), so the streamed output becomes the sole
desktop.
#2 — input flaky after a mid-stream Gaming->Desktop switch. The xdg portal
(D-Bus-activated) and the systemd --user env still pointed at the old session, so
the host's RemoteDesktop portal opened against a half-stale env: it accepted
events but they didn't reach the compositor until a reconnect. New
vdisplay::settle_desktop_portal() pushes the live session env into the
systemd/D-Bus activation environment and (for KWin) restarts the portal so it
re-reads it, mirroring a fresh desktop login (and the existing wlroots portal
restart). Called from the mid-stream switch rebuild slot before the injector
reopens. GNOME uses Mutter's direct EIS, so it only gets the env push.
Compiles, clippy/fmt clean, 78 host tests pass. Live validation on the Bazzite
box next.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Windows GamepadManager via vigem-client (ViGEmBus) — the uinput-xpad analogue: one virtual Xbox 360 controller per client pad index, created lazily on first State. GameStream/Moonlight already uses the XInput conventions (low-16 button bits, sticks -32768..32767 +Y up, triggers 0..255), so the GamepadFrame->XGamepad mapping is 1:1. Replaces the non-Linux GamepadManager stub (same new/handle/pump_rumble API the m3 PadBackend drives, so no m3 change). Graceful when ViGEmBus is absent (gamepad disabled, session continues). Compiles clean on Windows + Linux; live-test needs the ViGEmBus driver + a physical pad. Rumble back-channel is a TODO (ViGEm notification API).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Zero-copy capture->encode on the GPU via the raw NVENC API (nvidia_video_codec_sdk sys + ENCODE_API; the safe wrapper is CUDA-only). Opens an NV_ENC_DEVICE_TYPE_DIRECTX session on the SAME ID3D11Device as the DXGI capturer (carried on the new FramePayload::D3d11), registers a pool of BGRA textures once, CopyResources each captured texture in and encode_picture; CBR/ULL, infinite GOP, P-only, forced-IDR for RFI. The DXGI capturer gains a D3D11 zero-copy output (selected, like the encoder, by PUNKTFUNK_ENCODER=nvenc) so capture+encode share textures.
OFF by default (the nvenc feature pulls the NVENC SDK + cudarc): the default Windows host links without it (openh264 path). cudarc builds toolkit-less via the SDK ci-check feature (dynamic-loading). At link time --features nvenc needs nvencodeapi.lib (NVENC SDK, or an import lib generated from the driver's nvEncodeAPI64.dll) on PUNKTFUNK_NVENC_LIB_DIR. Both default and --features nvenc builds validated to compile+link GPU-less on the VM (import lib generated from the driver DLL). Runtime needs a real NVIDIA GPU.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The punktfunk/1 control plane already compiled on Windows; these wire the last gaps so the host actually runs: config_dir falls back to %APPDATA% (HOME\.config when set), paired_path uses it, hostname from COMPUTERNAME, and resolve_compositor short-circuits the Linux session-detection on Windows (SudoVDA is the single backend; vdisplay::open ignores the compositor arg). Validated live on the VM: m3-host creates its identity, binds the QUIC endpoint (fingerprint logged), advertises mDNS (_punktfunk._udp, host from COMPUTERNAME), and accepts sessions. GPU-less validations green: m0 synthetic->openh264->core FEC loopback (120/120, 0 mismatches) and the m3 c_abi_connection_roundtrip control-plane test. Full session capture (SudoVDA->DXGI) + NVENC remain GPU-gated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Windows Capturer via DXGI Desktop Duplication: create a D3D11 device on the SudoVDA adapter (by LUID), find the matching output (by GDI name), DuplicateOutput, and per AcquireNextFrame copy the desktop into a CPU-readable staging texture -> tightly-packed BGRA (FramePayload::Cpu, feeds the openh264 software encoder GPU-lessly). Handles WAIT_TIMEOUT (reuse last frame) and ACCESS_LOST (re-duplicate). Adds FramePayload::D3d11(D3d11Frame) for the future NVENC zero-copy path, and a VirtualOutput.win_capture identity (adapter LUID + GDI name) carried out of the SudoVDA backend. Pure helpers (pack_luid/gdi_name_matches/depad_bgra) unit-tested on the VM; the live duplication path needs a real GPU + an activated SudoVDA monitor. Compiles clean on Windows + Linux.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Windows AudioCapturer via the wasapi crate (0.23): loopback the default render endpoint (Render device + Direction::Capture + shared mode => STREAMFLAGS_LOOPBACK) at 48 kHz stereo f32 with autoconvert, feeding the existing Opus path with no resampling. Dedicated COM-MTA thread owns the !Send WASAPI objects; interleaved f32 chunks leave over a bounded lossy channel; RAII Drop stops + joins. Bring-up handshake reports a missing endpoint as Err so a session continues without audio. open_audio_capture Windows factory arm + module. Init chain validated live on the VM (open succeeds; next_chunk waits on a silent system). Virtual mic deferred (no Windows virtual-audio endpoint). m3 audio_thread wiring + opus hoist land with the integration task.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Windows Encoder impl via the openh264 crate (statically-bundled, BSD-2): low-latency screen-content config (Baseline/no-B-frames, bitrate RC, BT.709 limited, near-infinite GOP + forced-IDR recovery via request_keyframe), packed CPU pixels (BGRx/BGRA/RGB/RGBA/RGBx/BGR) -> I420 -> AnnexB with in-band SPS/PPS each IDR. Synchronous: submit encodes immediately, poll hands back the one AU, flush is a no-op. Windows open_video factory selects it (PUNKTFUNK_ENCODER=software|nvenc|auto; NVENC arm lands later), H.264-only with a clear error otherwise, SW bitrate ceiling. Unit-tested live on the VM: synthetic BGRx -> AnnexB IDR + SPS NAL. Unblocks the GPU-less capture->encode->FEC->send pipeline. Compiles clean on Windows + Linux.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Windows InputInjector via SendInput (Win32 KeyboardAndMouse), mirroring the wlroots backend: absolute mouse (MOUSEEVENTF_VIRTUALDESK normalized to the virtual desktop), relative mouse, scancode keyboard (MapVirtualKeyExW + extended-key flagging), scroll (no sign flip — Windows wheel matches GameStream), buttons. Client already sends Windows VK codes (no keycode table). Reattaches the thread to the input desktop (OpenInputDesktop/SetThreadDesktop) to survive UAC/lock switches. New Backend::SendInput, the Windows auto-default in default_backend(), open() arm, windows-crate features. Compiles clean on Windows + Linux. Live injection validates with the in-session host run (SendInput is desktop-isolated from an SSH network logon).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Windows VirtualDisplay backend driving SudoVDA (the Apollo IDD) via its DeviceIoControl IOCTL protocol: open by interface GUID, ADD at the client's exact WxH@Hz (mode baked into the IOCTL, no EDID seeding), mandatory watchdog ping thread, QueryDisplayConfig name resolution, RAII Drop -> REMOVE. Wired behind the existing VirtualDisplay trait (open()/probe() Windows arms). Validated live on the GPU-less VM (standalone + via the trait, env-gated test): version 0.2.1, ADD 1920x1080@60 -> target, watchdog hold, REMOVE. Monitor activation into a WDDM path (-> capturable \\.\DisplayN) needs a real GPU and is deferred with capture/NVENC. docs/windows-host.md updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Gate the Linux-only bits so the host crate builds on MSVC (it already built on Linux + macOS): drm_sync/dmabuf_fence use DRM ioctls + libc (a linux-only target dep) and have no non-Linux callers; VirtualOutput.remote_fd is a PipeWire concept. The full dep tree (aws-lc-rs, quinn, rusty_enet, axum) builds clean on MSVC and the binary runs (openapi emits the spec) — only these 3 cfg-gates were needed. First step of the Windows host port (docs/windows-host.md).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two fixes from live Bazzite testing of the managed-Gaming + mid-stream work:
1. Input now FOLLOWS the active session. The host-lifetime injector was pinned to
the first backend it opened and only reopened on an inject FAILURE — but with
Feature A keeping the managed gamescope warm, its EIS socket stays alive, so a
switch to the KDE desktop + reconnect kept injecting into the idle gamescope
(input silently dead on KDE). injector_service_thread now compares the
resolved input backend (default_backend() ← PUNKTFUNK_INPUT_BACKEND, set per
connect by apply_input_env, and on a mid-stream switch) each event and reopens
when it changes. Fixes input on a Gaming->Desktop reconnect AND Feature B's
mid-stream input re-route, with no plumbing.
2. Debounced TV-restore no longer yanks you back to gaming. do_restore_tv_session
now checks detect_active_session(): if a desktop session is active (the user
switched), it tears down the idle managed gamescope but does NOT restart the
gaming autologin. Observed live: the restore fired and restarted
gamescope-session-plus@ogui-steam while the client was already on the KDE
desktop.
Also: document PUNKTFUNK_SESSION_WATCH (Feature B opt-in) in the Bazzite host.env
and correct the managed-default description. Compiles, clippy/fmt clean, 78 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The schedule_restore_tv_session assignment exceeded 100 cols; rustfmt wraps it.
The fix was made post-commit but only m3.rs was staged for 33ad903, so CI's
fmt --check failed on the committed unwrapped line. Stage the wrap.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Feature B: while streaming, follow a Gaming<->Desktop switch on the box without
a reconnect. A ~1s watcher thread (session_watcher_loop) self-baselines on the
live ActiveKind and, when it changes and stays changed for a 3s debounce (the
old/new compositors coexist briefly during a switch), sends a SessionSwitch to
the encode loop. The loop's new rebuild slot — taking precedence over a queued
mode change — retargets the process env (apply_session_env/apply_input_env) and
rebuilds the WHOLE backend in place at the SAME client mode (vdisplay::open +
build_pipeline_with_retry), reusing the proven mode-switch rebuild path: the
Session + send thread (QUIC control + UDP data plane + side planes) stay up, the
client sees a brief freeze then an IDR. Old pipeline kept on a rebuild failure
(transient vs permanent classified via is_permanent_build_error). Input
re-routes via the host-lifetime injector's lazy reopen against the new
PUNKTFUNK_INPUT_BACKEND.
Opt-in via PUNKTFUNK_SESSION_WATCH (off by default; never under an explicit
PUNKTFUNK_COMPOSITOR pin), so it lands inert and is promoted to default only
after live validation on a real Bazzite Gaming<->KDE flip. The watcher snapshots
the SessionEnv so only the encode thread writes process env.
Compiles, clippy/fmt clean, 78 host tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Feature A: in Gaming Mode, default to a host-managed gamescope at the CLIENT's
mode (tear the TV's autologin down on connect) instead of attaching to the
running TV session — so the client receives ITS resolution (capture == encode ==
client mode, fixing the InitializeEncoder size mismatch the attach path hit),
not the TV's 4K.
Reliability is the debounce: restore_managed_session() now SCHEDULES the TV
restore RESTORE_DEBOUNCE (5s) after the last disconnect via a host-lifetime
worker, instead of restoring immediately per-disconnect. A reconnect inside the
window cancels the pending restore and reuses the still-warm managed session
(create_managed_session clears PENDING_RESTORE at the top) — so a quick reconnect
(e.g. a controller hiccup) never triggers a gamescope stop/relaunch, which is the
per-connect churn that leaked NVIDIA GPU context on F44 (the black-screen
reconnect).
- vdisplay/gamescope.rs: PENDING_RESTORE + RESTORE_DEBOUNCE; schedule_restore_tv_session
(debounced), do_restore_tv_session (the actual restore, worker-driven),
start_restore_worker (100ms tick, RAII keepalive handle). create_managed_session
cancels the pending restore + reuse path unchanged.
- vdisplay.rs: apply_input_env flips gamescope to managed-DEFAULT; PUNKTFUNK_GAMESCOPE_ATTACH
(or an explicit _NODE) opts back to attach for couch-on-TV; _MANAGED forces managed.
restore_managed_session schedules; new start_restore_worker wrapper.
- m3.rs serve(): hold the restore worker for the host lifetime.
- bazzite host.env: document managed-default + the ATTACH opt-out.
Compiles, clippy-clean, 78 host tests pass. F44 single stop/start leak to be
verified live on the box.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bazzite/SteamOS boxes flip between Steam Gaming Mode (gamescope) and a
KDE/GNOME desktop. The host statically read PUNKTFUNK_COMPOSITOR /
XDG_CURRENT_DESKTOP once, so switching to Desktop Mode failed the stream, and
the gamescope managed-session path stopped+relaunched the autologin per connect
— leaking GPU context on F44 (reconnect → black screen).
Replace the static read with a runtime probe of the live session and route each
connect to the right backend, churn-free:
- vdisplay::detect_active_session() probes /proc for the running compositor of
our uid (gamescope|kwin_wayland|gnome-shell|sway, desktop outranks a leftover
gamescope) + scans the runtime dir for the live wayland-* socket. Returns an
ActiveKind + the SessionEnv (WAYLAND_DISPLAY/XDG_RUNTIME_DIR/DBUS/
XDG_CURRENT_DESKTOP) that targets it.
- apply_session_env() writes that into the process env per connect (host serves
one session at a time), so every backend (capture + input) opens against the
live session; apply_input_env() points input at the matching backend and
selects gamescope ATTACH (no managed restart) unless PUNKTFUNK_GAMESCOPE_MANAGED.
- resolve_compositor() (native path) auto-detects + applies; explicit
PUNKTFUNK_COMPOSITOR still wins (legacy/CI/forcing). detect() is now
active-aware for the GameStream/mgmt callers too.
- Bazzite host.env drops the static gamescope force; documents auto-detection
+ the optional overrides.
Result: Desktop Mode → KWin/Mutter virtual output at the client's mode
(churn-free, the reliable path); Gaming Mode → attach to the running gamescope
(no SIGSEGV/GPU leak on reconnect). Compiles + clippy-clean; 78 host tests pass.
Live validation on the Bazzite box pending (box offline).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On a Bazzite host that autologins into gaming mode on a physical display (the F44
default: gamescope-session-plus@ogui-steam on the TV), Steam — single-instance — is
held by that session, which renders to the TV's native mode. The host-managed session
then can't start its own Steam, so it captured the TV's 4K output instead of the
client's mode (stretched). On F43 the box wasn't in gaming mode, so the host's Steam
was the only one.
Fix: on connect, the host-managed gamescope path stops any running autologin
`gamescope-session-plus@*` unit (frees Steam) before launching its own session at the
client's mode; on client disconnect (`restore_tv_session`, called from serve_session
teardown) it stops our session and restarts the autologin one, so the TV returns to
gaming mode by default when no one is streaming. Stopping the `--user` unit sticks
(Relogin only fires on the full logind session ending — verified live), so no sddm
config change is needed. Cost: a Steam cold-start per connect, given single-instance.
No-op on non-Bazzite / headless boxes (nothing to stop → nothing to restore).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 1 of moving the library off a manual mgmt token: the management API now serves
over HTTPS with the host's persistent identity (the cert clients already pin) and
OPTIONAL client-cert auth. A request is authorized if EITHER the peer presented a
client certificate whose SHA-256 is in the punktfunk/1 paired store (the same trust the
QUIC data plane uses — so a paired native client needs no token), OR it carries the
bearer token (the web console / admin). `/health` stays open.
axum-server can't surface the peer cert to a handler, so `serve_https` runs the rustls
handshake itself (tokio-rustls), reads the verified peer certificate, and serves the
axum Router over hyper with the fingerprint attached to each request; `require_auth`
checks it against `NativePairing::is_paired`. The verifier reuses the GameStream
AcceptAnyClientCert, parameterized to make client auth optional (a browser with no cert
still completes the handshake and falls back to the token).
Validated live: paired cert → 200, unpaired cert / no creds / bad token → 401, bearer
→ 200, /health open. (Note: the API is now HTTPS with a self-signed cert — a browser
shows a one-time trust prompt; native clients pin by fingerprint.)
Next: Apple client presents its identity over mTLS (drops the token field); embed the
web console; enable HTTPS mgmt by default.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two DualSense (UHID) fixes surfaced live on the Bazzite host:
- Battery: serialize_state never set the input report's status byte (struct off 52 →
r[53]), so hid-playstation read battery capacity 0 and SteamOS warned "low battery"
even on a fully-charged pad. Set it to 0x0A (discharging, low nibble 0xA → 100 %) —
a virtual pad has no real cell. (Forwarding the client pad's real charge is a later
feature.) Regression assert added to the layout test.
- Rumble diagnostic: log the silent→active transition when forwarding a buzz on the
0xCA plane, so a live test can tell "host never receives rumble from the game"
(Steam Input / parse) apart from "client doesn't render it". Once per buzz, no spam.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Plan step 4 (plumbing + host behavior). A client can ask the host to launch a
library title on connect; the host resolves it against ITS OWN library and runs it
in the session — the client sends only the store-qualified id, never a command, so a
remote peer can't inject one.
- Protocol (quic.rs): `Hello.launch: Option<String>` (the GameEntry id). Appended
after `name`; when launch is present but name absent, a zero-length name placeholder
keeps the offset deterministic — so a Hello with neither field stays byte-identical
to the bitrate-era 26-byte form (test-asserted). Old peers ignore it; new hosts
decode None from old clients. Round-trip + back-compat + truncation tests.
- Host: `library::launch_command(id)` resolves id → command via the host's own library —
`steam_appid` → `steam steam://rungameid/<appid>` (appid validated as digits, the only
client-influenced part), `command` → the host-stored command verbatim (trusted, never
from the client). m3.rs sets PUNKTFUNK_GAMESCOPE_APP from it before bringup, exactly
as the GameStream /launch path does (one session at a time). Unit-tested incl. an
injection-attempt guard. Takes effect on the bare-spawn gamescope path; a no-op on a
shared desktop / attach-to-existing session.
- C ABI: `punktfunk_connect_ex4` adds `launch_id` (NULL = none); `_ex3` now delegates to
it. Threaded through NativeClient::connect → WorkerArgs → Hello.
- client-rs gains `--launch ID` (headless testing); client-linux passes None (no picker
yet). Header regenerated.
Next: the Apple library grid passes the picked id via punktfunk_connect_ex4.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A new `library` module + four mgmt endpoints surface the host's games to clients
(plan: "surface the user's games"). An adapter layer (`LibraryProvider`) so future
stores (Heroic/Epic, GOG, Lutris) slot in behind one uniform `GameEntry`.
- SteamProvider: reads the LOCAL Steam install — no Steam Web API key, no network.
Installed titles from steamapps/appmanifest_<appid>.acf; extra library folders
(incl. paths with spaces) from libraryfolders.vdf; candidate roots cover classic,
Flatpak and Deck layouts, canonicalized + deduped (the .steam/{steam,root}
symlinks all fold to one). Runtimes/redistributables (Proton, Steam Linux Runtime,
Steamworks Common, SteamVR) filtered out. Artwork = the public Steam CDN by appid
(portrait/hero/logo/header), fetched directly by the client.
- Custom store: ~/.config/punktfunk/library.json, write-then-rename persisted,
CRUD'd via the API — the "create custom entries via the admin web UI" requirement.
- API (under /api/v1, OpenAPI-documented + checked in): GET /library (all stores
merged, sorted), POST /library/custom, PUT/DELETE /library/custom/{id}.
- `punktfunk-host library` subcommand dumps the resolved library as JSON (diagnostic,
mirrors `openapi`).
Validated live against the real Steam library on the Bazzite box: 89 appmanifests →
78 games (11 tools filtered), correct titles/sort, and the CDN art URLs return 200.
5 unit tests for the VDF/ACF parsing, tool filter, art URLs, custom mapping.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause of the Mac "session ended" at 880 Mbps / 1.3 Gbps: the host requests a
bitrate NVENC can't express at any codec level and `avcodec_open2` returns EINVAL
("Invalid argument"), so the pipeline build fails after 4 identical retries and the
session dies at encoder init — before a single video packet (which is why the
client's UDP counters never moved). The ceiling is GPU/driver-specific: an RTX 4090
caps HEVC at ~800 Mbps (Level 6.2 High tier) and rejects above it, while an RTX
5070 Ti accepts 1.3 Gbps.
Rather than hard-cap every build to a conservative guess (which would needlessly
throttle capable cards), open_video now PROBES: open at the requested bitrate, and
step down (codec spec ceiling, then 0.75x to a 50 Mbps floor) ONLY when this GPU
returns EINVAL. Each GPU runs at its own real maximum — the 5070 Ti keeps 1.3 Gbps,
the 4090 lands at 800 Mbps and streams instead of dying. Non-EINVAL failures (no
GPU, bad mode, OOM) still surface immediately rather than being masked by retries.
Codec::max_bitrate_bps is now just the first step-down candidate, not a clamp.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Mac/iOS client's wall around ~380 Mbps on a 2.5 G path is the receive
drain, not the transport: a loopback speed-test pushes 380/600/1000 Mbps at
0.0% loss, but Darwin has no recvmmsg(2), so the macOS client was doing one
recv() syscall per packet — ~40-90k syscalls/s on one core. When the recv loop
can't drain fast enough the kernel socket buffer backs up and drops, which the
client sees as a sustained stream stalling/freezing in the 300-400 Mbps range
(and an immediate "session ended" when a 500 Mbps+ first keyframe bursts in).
- core/transport: flip recvmsg_x (the batched Darwin recv, ~30x fewer syscalls)
from opt-in to default ON, opt-out via PUNKTFUNK_RECVMSG_X=0. Keeps the
auto-fallback to the scalar loop on any unexpected syscall error. The Apple CI
swift-test loopback now exercises this path by default.
- packaging/kde host.env: enable PUNKTFUNK_GSO=1 — UDP segmentation offload on
the host send path (one sendmsg per ~64 packets), the dominant lever above
~1 Gbps. Already wired (send_sealed -> send_gso) with sendmmsg auto-fallback.
- apple SpeedTestSheet: lengthen the bandwidth probe 2 s -> 5 s so the measured
number stops swinging wildly (50 vs 900 Mbps on the same link) — long enough
for steady-state send + recv drain to settle. Matches host MAX_PROBE_MS.
- host capture: PUNKTFUNK_SYNTH_NOISE synthetic high-entropy source for
reproducible throughput testing of the encode->FEC->send->recv path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>