Commit Graph

550 Commits

Author SHA1 Message Date
enricobuehler c808e517e3 ci(windows-drivers): commit driver workspace Cargo.lock
wdk-build requires a Cargo.lock next to the top-level Cargo.toml (it panics
otherwise — "a Cargo.lock file should exist..."). Generated on Linux
(resolution is platform-independent; only the build needs the WDK). Everything
else compiled on the runner — pf-vdisplay-proto, bindgen, wdk-build/sys/macros.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 08:36:30 +00:00
enricobuehler d6250e6ba8 feat(windows-drivers): driver workspace + wdk-probe on windows-drivers-rs (M1)
Stand up packaging/windows/drivers/ — the unified driver workspace on crates.io
windows-drivers-rs (wdk 0.4.1 / wdk-sys + wdk-build 0.5.1), retiring the dev-box
../../crates/wdk* path-deps. First member: wdk-probe, the smallest UMDF2 driver
(DriverEntry -> WdfDriverCreate -> EvtDeviceAdd -> WdfDeviceCreate) that
force-links the shared pf-vdisplay-proto ABI crate. It validates on the runner:
wdk-sys bindgen + WDF stub link against the WDK + LLVM, the cross-workspace
no_std proto path-dep, and the produced DLL's PE FORCE_INTEGRITY bit.

windows-drivers.yml gains a driver-build job: cargo build -p wdk-probe (pinning
Version_Number=10.0.26100.0) + a PE inspection that prints whether /INTEGRITYCHECK
is set — the M0 self-signed-load question.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 08:33:38 +00:00
enricobuehler 1a04627bc1 ci(windows-drivers): fix WDK verification paths (WDK installed fine)
The first provision run installed the WDK (iddcx headers + stampinf appeared) +
cargo-wdk, but the verification threw on two wrong checks: UMDF wdf.h lives at
Include\wdf\umdf\<ver>\ (not under the SDK-version dir), and inf2cat is x86-only
(the search filtered \x64\). Rewrite verification to enumerate the real layout
(wdf\umdf versions, km dir, iddcx versions, tool paths) and fail only on the
build-essential pieces (wdf.h + km + iddcx + cargo-wdk). Skip-check now keys off
iddcx presence (the reliable "WDK installed" signal), so a re-run skips the install.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 08:26:36 +00:00
enricobuehler 3120b8c32a ci(windows-drivers): provision WDK + cargo-wdk on the runner (rewrite M0)
The windows-amd64 runner has the base Windows SDK + MSVC + LLVM + Rust but NOT
the WDK (probed: km=False, no um/iddcx, no inf2cat/stampinf/devgen) or cargo-wdk,
so the all-Rust UMDF drivers can't build there yet. Adds an idempotent
provisioning script (scripts/ci/provision-windows-wdk.ps1: download wdksetup 26100
-> /q /norestart, cargo install --locked cargo-wdk, then verify km/wdf + iddcx
headers + inf2cat/stampinf + cargo-wdk) and a workflow_dispatch/push workflow that
runs it on the persistent runner (one-time; install persists).

cargo-wdk (not cargo-make) is windows-drivers-rs's current build+package tool
(cargo build -> stampinf/inf2cat/signtool). Driver builds must pin
Version_Number=10.0.26100.0 (the runner also has 10.0.28000.0, which lacks km/crt).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 08:20:55 +00:00
enricobuehler 1ce423cf81 ci(windows-drivers): probe runner driver toolchain + build proto (rewrite M0)
Stage-1 CI for the Windows-host rewrite: a probe job on the self-hosted
windows-amd64 runner that reports the driver toolchain (WDK Include km/ +
iddcx versions, inf2cat/stampinf/devgen/signtool, EWDK, LLVM/clang version,
cargo-make, installed Rust targets) so we know what's provisioned BEFORE
writing driver code, and builds+tests+lints pf-vdisplay-proto on MSVC to prove
the owned ABI crate compiles cross-OS and the CI wiring works. No RTX GPU needed
for any of this (only live NVENC encode needs one — that defers to the RTX box).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 06:51:59 +00:00
enricobuehler c20fef8afd feat(windows): pf-vdisplay-proto — owned host<->driver ABI crate (rewrite M0)
First foundation of the Windows-host rewrite (docs/windows-host-rewrite.md): a
self-contained, no_std + bytemuck crate that defines the host<->driver binary
contract ONCE — the control-plane IOCTLs (add/remove/set-render-adapter/ping/
get-info/clear-all) and the IDD-push frame transport (SharedHeader, the
(gen<<40|seq<<8|slot) FrameToken, the Global\pfvd-* name scheme, driver-status
codes). Previously these were hand-duplicated byte-for-byte across
idd_push.rs/frame_transport.rs and sudovda.rs/control.rs with only "must match"
comments; here const size-asserts + bytemuck round-trips make any drift a COMPILE
error.

Clean break from SudoVDA: a freshly-minted interface GUID (not e5bcc234), a
contiguous 0x900 op space (not the gappy 0x800/0x888/0x8FF), a u64 session id (not
the 16-byte GUID + pid-mangling), a single u32 protocol version. Self-contained
(no workspace inheritance, no Windows deps) so the out-of-workspace driver build
graph can path-dep it identically. 7 tests green on Linux; clippy + fmt clean.

Also lands the full rewrite plan in docs/windows-host-rewrite.md (decisions:
greenfield; IDD-push primary incl. secure desktop, WGC+DDA demoted to fallbacks;
unify drivers on windows-drivers-rs + solve /INTEGRITYCHECK; keep GameStream,
default secure).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 06:49:50 +00:00
enricobuehler c7ef0e411a feat(windows): pf-vdisplay IDD-push — HDR + pipelined zero-copy capture
HDR (display-driven, matching the WGC path):
- CTA-861.3 HDR EDID (BT.2020 primaries + HDR Static Metadata block) so Windows
  offers "Use HDR" on the virtual display. The host FOLLOWS the display's live
  advanced-color state, recreating the shared ring at the matching format
  (FP16 in HDR / BGRA in SDR) on a toggle — no freeze.
- Always emit Main10/BT.2020-PQ Rgb10a2 while the display is HDR; the client
  auto-detects PQ from the HEVC VUI (clients under-report VIDEO_CAP_10BIT).
  Generic HDR10 mastering SEI on every IDR.
- Generation-tagged `latest` (gen<<40|seq<<8|slot) + driver `is_stale` re-attach
  kill the toggle-time garbage frame and any stale-ring read.

Perf:
- Pipeline the encode loop (Capturer::pipeline_depth; IDD-push = 2): submit N+1
  before polling N so the convert/copy on the 3D engine overlaps the NVENC encode
  of N on the ASIC. PUNKTFUNK_IDD_DEPTH overrides (1 = synchronous).
- Rotating host output ring (OUT_RING) so the in-flight encode and the next
  convert never touch the same texture.
- HDR converts directly from the keyed-mutex slot's SRV into the output ring
  (drops the redundant slot->fp16 scratch copy); SDR copies the BGRA slot in.
  The slot mutex is held only across the convert/copy, not the encode.
  RING_LEN 3->6 for publish headroom.
- Capture-health diagnostic: new_fps vs repeat_fps under PUNKTFUNK_PERF (a low
  new_fps at a high send rate means the source isn't compositing, not an encode
  stall).

Validated live on the RTX box: 5120x1440@240 HDR streams; driver composes
~180 new fps, encode 240 fps @ ~4.3 ms p50.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 00:39:28 +02:00
enricobuehler 994cc071d7 feat(windows): bundle pf-vdisplay in the host installer; drop SudoVDA
Switch the Inno Setup installer's virtual-display driver from the vendored SudoVDA
C++ binary to our own all-Rust pf-vdisplay (validated streaming at 5120x1440@240).

- packaging/windows/pf-vdisplay/: vendored SIGNED driver (pf_vdisplay.dll/inf/cat +
  punktfunk-driver.cer, the same cert the gamepad drivers ship), built from
  vdisplay-driver/ via deploy-dev.ps1.
- install-pf-vdisplay.ps1 / stage-pf-vdisplay.ps1: mirror the SudoVDA scripts -
  trust cert -> gated ROOT\pf_vdisplay node via nefconc (NEVER devgen) -> pnputil
  /add-driver /install. Idempotent, best-effort (never aborts the install).
- punktfunk-host.iss + pack-host-installer.ps1: install the pf-vdisplay bundle
  under the existing installdriver task.
- Removed the vendored SudoVDA driver + install-sudovda.ps1 + stage-sudovda.ps1.
- README + windows-host.yml: SudoVDA -> pf-vdisplay.

The host's vdisplay/sudovda.rs backend is unchanged - it drives whichever driver
provides the {e5bcc234} interface, now pf-vdisplay. Live installer build/test on
the runner is the remaining step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 00:39:28 +02:00
enricobuehler 402affcf3e feat(windows): pf-vdisplay CLEAR_ALL — reap orphaned virtual monitors on startup
The "5-6 stale monitors that never tear down" failure (also seen with SudoVDA):
an orphan from a crashed/killed previous host lingers because the driver watchdog
is kept reset by a still-pinging new session, so it never fires for the orphan.

- Driver (pf-vdisplay control.rs): new IOCTL_CLEAR_ALL (0x804) -> tear down every
  monitor. A pf-vdisplay extension; SudoVDA returns invalid for it (ignored), so
  the host can issue it unconditionally.
- Host (vdisplay/sudovda.rs): send IOCTL_CLEAR_ALL once on startup (best-effort)
  to reap orphans before creating ours; and surface a failing keepalive PING (the
  old `let _ =` swallowed it, masking a lost control handle).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 00:36:21 +02:00
enricobuehler 53ee24ac31 feat(windows): pf-vdisplay — all-Rust IddCx virtual display (replaces SudoVDA)
P1 done: a pure-Rust UMDF2 IddCx driver, drop-in compatible with the host's
existing vdisplay/sudovda.rs control plane (the {e5bcc234} interface + the
SudoVDA IOCTL ABI), so the host drives it unchanged. Validated streaming on
glass at 5120x1440@240 — steady 240 fps, ~2.4 ms encode, clean teardown, full
parity with SudoVDA.

- Vendored wdf-umdf-sys / wdf-umdf bindgen crates (MIT, from virtual-display-rs)
  + the SDK-version build.rs fix that resolves the IddCxStub lib path by the WDK
  version actually containing um\x64\iddcx, not the max base SDK.
- pf-vdisplay crate: entry/callbacks/context/control/monitor/edid/
  swap_chain_processor. Our OWN 128-byte EDID (manufacturer PNK, product
  punktfunk — no SudoVDA bytes), a real swap-chain drain (faithful vdd port,
  required so DWM keeps compositing), the SudoVDA-compatible IOCTL control plane
  (ADD/REMOVE/PING/GET_WATCHDOG/GET_VERSION/SET_RENDER_ADAPTER) + a watchdog that
  tears down orphaned monitors when the host stops pinging.
- deploy-dev.ps1: stage + sign + stampinf (date.time DriverVer) + Inf2Cat +
  install, codifying the "bump DriverVer or pnputil keeps the old binary" gotcha.
- docs/windows-virtual-display-rust-port.md: investigation, the on-glass
  validation, and the two traps that cost time (Session-0 measurement +
  accumulated device-state needing a reboot).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 00:36:21 +02:00
enricobuehler e9a0026a4c feat(android): native mDNS discovery, host naming, touch mouse, stock selects
Discovery: replace the flaky per-OEM NsdManager with the same mdns-sd browse
the Linux/Windows clients use, in the Rust core over JNI and polled by Kotlin
(discovery.rs + nativeDiscovery{Start,Poll,Stop}); Kotlin keeps only the Wi-Fi
MulticastLock + permission UX. IPv4-only (the core can't dial a bare/scoped v6
literal); daemon + fold-thread cleanup on every failure path; field
sanitization so a rogue advert can't corrupt the picker snapshot. Discovery
now starts regardless of NEARBY_WIFI_DEVICES (raw multicast only needs the
MulticastLock) — a denial no longer kills it forever. ParseTxtTest replaced by
ParseRecordTest.

Hosts: hide already-saved hosts from the "Discovered" section (match by
fingerprint, else address:port — mirrors the Apple client); add an optional
Name field to the Add-host sheet and a Rename action on saved cards.

Input: touch -> absolute mouse "direct pointing" like the Apple client — the
host cursor follows the finger (new nativeSendPointerAbs -> MouseMoveAbs). Tap
= left click, two-finger tap = right click, two-finger drag = scroll,
tap-then-drag = left-drag, three-finger tap = HUD toggle.

Settings: revert the dropdowns to the stock ExposedDropdownMenuBox look (a
controller-focus UI will come separately); even out the Add-host field gaps.

Docs updated (CLAUDE.md, client READMEs, docs-site status).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:48:45 +02:00
enricobuehler a659dcc88b fix(web): bundle deps into the server (noExternals) — kill the 47k-file install
The Windows installer ballooned to 154 MB and installed forever because the node-server
bundle externalized the WHOLE @unom/ui dependency tree (payload, lexical, date-fns,
prismjs…) to .output/server/node_modules — 47,567 files / 730 MB copied into Program
Files. Set Nitro `noExternals: true` so every dependency is bundled + tree-shaken into the
server output: .output drops to ~75 files / 10 MB, and the bare external imports
(srvx, seroval…) bun couldn't resolve at runtime are gone — so the console runs on bun
(no node, no node_modules), which is the issue we previously worked around with node.

Windows installer now ships bun.exe + the ~75-file .output (was node.exe + a node_modules
forest) and runs `bun .output\server\index.mjs`:
- windows-host.yml: fetch a pinned portable bun (build tool AND shipped runtime); drop the
  node fetch + the .output/server install; smoke-boot under the bundled bun.
- pack-host-installer.ps1 / punktfunk-host.iss: -NodeExe -> -BunExe; stage {app}\bun\bun.exe.
- web-run.cmd / build-web.ps1: run/restart on bun; docs updated.

Net win everywhere: the Linux .deb shrinks (node still runs the self-contained output), and
the docker web image — which already ran `bun run .output/server/index.mjs` with only
.output copied — is fixed (the externals had no node_modules to resolve at runtime).

Validated locally: noExternals build = 75 files / 10 MB; node AND bun both serve /login
(200) + static assets (200) + gate /api (401).

(A true single binary via `bun build --compile` is blocked for now: Nitro serves public
assets from an import.meta-relative path `--compile` doesn't embed (/$bunfs/public); the
75-file payload is the clean result.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:19:32 +02:00
enricobuehler 917edb4660 fix(apple/ci): create the Simulator on demand; scope CI shots to iPhone+iPad
Diagnosed from the first run: only the iPad shots were produced. The runner
lacks an "iPhone 16 Pro Max" device, is headless (no window server -> the macOS
window capture's app window never appears), and the Tier-3 tvOS build-std slice
failed.

- screenshots.sh: shoot_sim now creates a throwaway Simulator (matching device
  type + newest available runtime) when the runner has no matching device, so
  the iPhone 6.9" shots are reproducible instead of skipped.
- apple.yml: scope the CI job to the two REQUIRED iOS sizes (iPhone 6.9" +
  iPad 13"), captured via `simctl io screenshot` (no Screen Recording grant
  needed). Drop macOS (headless runner has no window server) and tvOS (build-std
  slice) from CI — generate those locally with `tools/screenshots.sh macos tvos`.
  Faster, deterministic xcframework build (BUILD_IOS=1 only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:46:41 +02:00
enricobuehler 66d3e16822 ci(apple): use upload-artifact@v3 for screenshots (Gitea has no v4 backend)
Gitea's artifact storage identifies as GHES, which @actions/artifact v2+
(upload-artifact@v4) refuses outright. v3 uses the older artifact API Gitea
supports; the downloaded artifact is still a zip. (The capture itself already
worked — 5 macOS scenes were produced; only the v4 upload failed.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:41:14 +02:00
enricobuehler 1c5c241fa3 fix(windows-host): ISCC [Code] — don't put {tmp} inside a Pascal comment
ISCC aborted compiling the installer at the web-console [Code] section: a comment
`{ ... {tmp} is auto-cleaned. }` — Pascal `{ }` comments don't nest, so the `}` in
`{tmp}` closed the comment early and `is auto-cleaned. }` parsed as code ("Identifier
expected"). Reword to drop the brace. (All other {app}/{tmp} uses are `;` line-comments
or code strings, which are fine.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:21:41 +02:00
enricobuehler 93273a24b4 feat(apple): App Store screenshot harness + CI zip artifact
A DEBUG-only "shot mode" renders one mock-populated screen full-bleed
(PUNKTFUNK_SHOT_SCENE=<name> -> ScreenshotHostView instead of ContentView),
so the OS can screenshot the REAL, fully-rendered UI. tools/screenshots.sh
drives it: screencapture for the mac window, `simctl io booted screenshot`
for the iOS/iPad/tvOS Simulators, at exactly the App Store Connect sizes.

ImageRenderer was tried first and rejected: it can't rasterize this app's
chrome (NavigationStack, Form/TabView, Liquid-Glass/NSVisualEffect all render
black or the "can't render" placeholder). Capturing the live window/Simulator
avoids that. Only the stream hero is synthetic (StreamView needs a live
connection) - a synthwave frame + the real glass HUD, overridable via
PUNKTFUNK_SHOT_HERO.

CI: a new `screenshots` job in apple.yml builds the iOS (+ tvOS best-effort)
xcframework slices, runs the harness per platform best-effort, and attaches
the result as a single zip artifact (punktfunk-appstore-screenshots). It is
isolated from the build/test job and skipped on PRs, so a capture gap (missing
Simulator runtime, or no Screen Recording grant for the mac window capture)
never reds the core signal.

Generated PNGs (clients/apple/screenshots/) are gitignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 19:44:03 +02:00
enricobuehler c6b71c5ed5 ci(windows-host): bootstrap bun + supply @unom token for the web build
The first windows-host run with the bundled console failed at "bun not found": the
self-hosted runner executes as SYSTEM, so the dev user's bun (and its ~/.npmrc with the
@unom registry token) aren't on PATH. Make the web-build step self-sufficient:

- Install bun via bun.sh/install.ps1 when it isn't already present (checking PATH +
  the SYSTEM/Public profile locations first), like deb.yml bootstraps it.
- Write the private @unom registry mapping + auth token (REGISTRY_TOKEN) into the SYSTEM
  home .npmrc so `bun install` can fetch the @unom packages — kept out of the project
  tree and the shipped .output bundle (.output\server\.npmrc stays mapping-only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:39:23 +02:00
enricobuehler 6ee1a4c847 feat(windows-host): bundle + auto-run the web console in the installer
The Windows host installer shipped only the host exe + SudoVDA driver + FFmpeg, so a
fresh install had no web management console — required for basically every user (status,
paired devices, the PIN pairing flow). The console was only ever set up by hand on the
dev box (build-web.ps1 + a hand-made PunktfunkWeb task whose web-run.cmd wasn't even
committed). Bundle it into the same installer, mirroring the proven Linux punktfunk-web
deploy.

- windows-host.yml builds the Nitro node-server console (bun, deb.yml's shape) + fetches
  a pinned portable Node, smoke-boots it under node (/login == 200) to gate the build, and
  hands web/.output + node.exe to the pack script.
- pack-host-installer.ps1 gains -WebDir/-NodeExe and stages the .output tree, node, and
  the two new scripts into the non-WOW64-redirected build area.
- punktfunk-host.iss lays the payload into {app}\web\.output + {app}\node\node.exe, adds
  a wizard page for the console login password pre-filled with a crypto-random default
  (shown on the finish page; kept on upgrade), and runs web-setup.ps1.
- web-setup.ps1 writes the ACL'd %ProgramData%\punktfunk\web-password (Administrators +
  SYSTEM), registers the PunktfunkWeb scheduled task (boot, SYSTEM, restart-on-failure ->
  web-run.cmd -> node on :3000), opens inbound TCP 3000, and starts it. web-run.cmd
  sources the host's mgmt-token + the password and runs the bundled node.
- The console proxies the host's loopback mgmt API with the host's own
  %ProgramData%\punktfunk\mgmt-token (no host-code change). Uninstall removes the task +
  firewall rule.

Validated locally: bun build -> node-server bundle, node boot serves /login (200) and
gates /api (401). The Windows-only bits (ISCC compile, scheduled task, password page,
firewall) validate on the Windows runner CI + on-glass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:28:47 +02:00
enricobuehler 2bc988dcba docs(roadmap): add WAN access, VRR passthrough, desktop QoL items
WAN/anywhere access (NAT traversal + relay + QUIC migration), VRR/
adaptive-sync passthrough, and a desktop quality-of-life bullet
covering clipboard sync, multi-monitor, and virtual-webcam redirection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:35:41 +00:00
enricobuehler 5a38a6723e docs(roadmap): add surround & spatial (object) audio plan
Near-term 7.1 channel bed; moonshot object-based spatial audio via
Wine/Proton (where dynamic objects are currently discarded) with
client-side head-tracked spatialization.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:24:12 +00:00
enricobuehler 4d0513e153 docs(roadmap): add Magic multi-user support to planned
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:55:25 +00:00
enricobuehler 8b6b4a32fa feat(gamepad): pure-user-mode Windows DualShock 4 + Xbox 360 (drop ViGEm) + installer + multi-pad
Windows virtual gamepads now have zero external dependencies - ViGEmBus is removed.

- DualShock 4: Windows UMDF backend (inject/dualshock4_windows.rs + dualshock4_proto.rs),
  reusing the DualSense SwDeviceCreate game-detection identity fix. The one UMDF driver serves
  the DS5 or DS4 identity/descriptor/features/strings per a device_type byte the host stamps into
  shared memory. Driver also gains IOCTL_HID_GET_STRING and a 41-byte calibration feature.
- Xbox 360: a new UMDF2 XUSB companion driver (packaging/windows/xusb-driver/) 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. inject/gamepad_windows.rs is rewritten
  to drive it and the vigem-client dependency is removed. Xbox One folds to the 360 XInput path.
- Installer: vendor + pnputil-install the three UMDF drivers (packaging/windows/gamepad-drivers/
  + install-gamepad-drivers.ps1, wired into pack-host-installer.ps1 + punktfunk-host.iss).
- Multi-pad: the host stamps each pad index into the device Location (pszDeviceLocation); the
  driver reads it via WdfDeviceAllocAndQueryProperty to map its own *-shm-<index>, with
  UmdfHostProcessSharing=ProcessSharingDisabled giving each pad its own host (per-pad statics).

Validated live on the Windows host: Cyberpunk native DualSense detection, DS4 identity + descriptor,
XInputGetState + rumble round-trip, two pads -> two distinct XInput slots, and a full installer build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:35:03 +02:00
enricobuehler 7466f2b4b7 style(host): blank line before the uniq comment so rustfmt is clean
dualshock4.rs left `cargo fmt --all --check` red on main (it landed with the
Windows-host DualSense work): a standalone comment placed directly after a line
ending in a trailing comment gets absorbed and re-aligned to the trailing-comment
column. A blank line before the comment block keeps rustfmt happy — and the
comment readable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:52:28 +02:00
enricobuehler ffe9b6eaae ci(release): run tvOS on the canary track alongside iOS/macOS
The canary/stable split (6d370f7) gated the tvOS archive/upload — and its
xcframework slice — to vX.Y.Z tags, while moving iOS/macOS onto canary main
pushes. No tag has been cut since (both existing tags predate the split), so
tvOS stopped reaching TestFlight entirely while iOS/macOS kept shipping on canary.

Build the tvOS tier-3 slice unconditionally again (BUILD_TVOS=1; the nightly
-Zbuild-std std is cached on the self-hosted runner) and drop the tag gate on the
tvOS step so its if: matches the iOS / macOS App Store steps exactly — tvOS now
uploads on canary main pushes + stable tags + dispatch, same as the others.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:47:14 +02:00
enricobuehler 3820aab2c4 fix(apple): drive DualSense rumble over raw HID (CoreHaptics is silent on macOS)
GameController's CHHapticEngine never reaches the DualSense's motors on macOS — its
adaptive triggers and lightbar work, but rumble stays silent (a documented platform
gap). Drive the motors directly via the DualSense HID output report instead, the way
SDL and the Linux hid-playstation driver do — the same report that already rumbles
the pad on a Linux host. Confirmed live on macOS.

- DualSenseHID (macOS): opens the Sony DualSense via IOHIDManager and writes the USB
  (0x02, 48 bytes) and Bluetooth (0x31, 78 bytes + CRC32) output reports through
  IOHIDDeviceSetReport. Allowed under the App Sandbox by the existing device.usb +
  device.bluetooth entitlements; coexists with GameController (non-seized open).
  Flags mirror the kernel driver (COMPATIBLE_VIBRATION | HAPTICS_SELECT +
  COMPATIBLE_VIBRATION2); valid_flag1 = 0 so a rumble report leaves the
  GameController-managed lightbar / triggers / player LEDs untouched.
- RumbleRenderer routes a DualSense to the HID backend and keeps CoreHaptics for
  every other pad, fixing both live sessions and the test panel (shared renderer).
- CoreHaptics path reworked too: bake the target intensity + an explicit sharpness
  into the continuous event (the dynamic-parameter scaling is silent on controller
  engines) and tear down outside the inout access to fix a latent exclusivity hazard.

Adds a DEBUG-only Settings -> Controllers -> "Test Controller" panel (ControllerTestView
+ ControllerTester) that shows live input and fires rumble / adaptive triggers /
lightbar / player LEDs straight at the pad, with a readout of the active rumble backend
("DualSense HID - USB/Bluetooth"). Used to validate the fix.

Tests: DualSenseHIDTests pins the USB/BT report layout and the BT CRC32 (canonical
0xCBF43926 check vector). Debug + release build clean; gamepad suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 13:16:41 +02:00
enricobuehler 9e0cf50002 fix(windows): deploy-host reads build env from Machine scope
Reads PUNKTFUNK_NVENC_LIB_DIR/LIBCLANG_PATH/CMAKE_POLICY_VERSION_MINIMUM directly from
Machine scope into the process, so the build is correct even when the SSH/parent shell
predates setup-build-env.ps1 (env is inherited at spawn).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 10:48:29 +00:00
enricobuehler 5bc1359130 chore(windows): persistent build env + one-call host/web deploy scripts
scripts/windows/: setup-build-env.ps1 persists the NVENC build env (Machine scope:
PUNKTFUNK_NVENC_LIB_DIR, LIBCLANG_PATH, CMAKE_POLICY_VERSION_MINIMUM -- no FFMPEG_DIR, the
nvenc build doesn't link libavcodec). deploy-host.ps1 rebuilds --release --features nvenc and
restarts the PunktfunkHost service with .bak rollback on build/start failure. build-web.ps1
rebuilds the Nitro web console (bun build, node runtime) and restarts the PunktfunkWeb task.
README documents the flow -- a redeploy is now a single script call.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 10:47:40 +00:00
enricobuehler 2eb69759b9 fix(gamepad): working per-session SwDeviceCreate for the Windows DualSense
create_swdevice now succeeds. The two requirements (each E_INVALIDARG otherwise): the
enumerator name must have no underscore (use "punktfunk"), and the completion callback is
mandatory (the docs mark pCallback [in], not optional -- NULL is rejected). Back on the
typed windows-rs SwDeviceCreate (a raw-FFI diagnosis confirmed it's the OS, not the
binding), parameterized by pad index (instance pf_pad_<index>), waiting on the callback.
Per-session device: created on connect, SwDeviceClose'd on drop -- no leftovers, no phantom.

Live-verified on the RTX box: device materializes, the UMDF driver binds, SDL3 identifies it
as a PS5 ("DualSense Wireless Controller"), input flows; removed on disconnect. The
dualsense-windows-test CLI now cycles input + prints any 0x02 feedback for diagnosis.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 10:34:58 +00:00
enricobuehler 94b9a3028c ci(audit): ignore RUSTSEC-2023-0071 (rsa Marvin timing sidechannel)
cargo audit fails on the rsa "Marvin Attack" advisory, which has NO fixed release
(the constant-time rewrite is still unreleased upstream) and rsa is required for
GameStream/Moonlight pairing. The attack targets RSA *decryption* (PKCS#1 v1.5
padding oracle); the host uses rsa ONLY for PKCS#1 v1.5 signing/verifying
(gamestream/cert.rs + pairing.rs), never for decryption, so the vulnerable path is
not exercised. Add the documented .cargo/audit.toml ignore with the justification.

The 3 unmaintained warnings (audiopus_sys / paste / rustls-pemfile) are left visible
on purpose — `cargo audit` does not fail on them, and they carry a maintenance signal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 10:32:04 +00:00
enricobuehler 5852238665 feat(windows): AMD (AMF) + Intel (QSV) hardware encode on the Windows host
The Windows host was NVIDIA-only (NVENC) with an openh264 software fallback. Add
AMD AMF and Intel QSV via libavcodec — the Windows analogue of the Linux VAAPI
backend — so one installer serves all three GPU vendors.

- encode/ffmpeg_win.rs: new WinVendor{Amf,Qsv} encoder. System-memory NV12/P010
  readback (default, robust) + opt-in zero-copy D3D11 (PUNKTFUNK_ZEROCOPY: shares
  the capturer's ID3D11Device; AMF takes AV_PIX_FMT_D3D11, QSV derives a QSV frames
  ctx and maps) with a system fallback for the format-group mismatch the capturer's
  video-processor fallback can produce. HDR Main10 (P010 + BT.2020/PQ VUI; an
  Rgb10a2->P010 swscale covers the shader fallback).
- encode.rs: Codec::amf_name/qsv_name; open_video + windows_resolved_backend()
  resolve PUNKTFUNK_ENCODER=auto|nvenc|amf|qsv|sw via a DXGI adapter VendorId probe.
- capture/dxgi.rs: gpu_mode mirrors the resolved backend (D3D11 NV12/P010 for AMF/QSV).
- gamestream/serverinfo.rs: GPU-aware codec advertisement (windows_codec_support;
  AV1 gated to RDNA3+/Arc, like the VAAPI path).
- Cargo.toml: amf-qsv feature (optional ffmpeg-next in the windows target block).
- CI/installer: windows-host.yml sets FFMPEG_DIR + builds --features nvenc,amf-qsv;
  the Inno installer bundles the FFmpeg DLLs; host.env default nvenc -> auto.

CI-green target; AMF/QSV not yet on-glass validated (no AMD/Intel Windows box in the
lab) — NVENC stays live-validated. An adversarial-review pass caught + fixed real
FFI bugs (AV_PIX_FMT_P010 is a macro -> P010LE; windows-rs 0.62 GetImmediateContext/
GetDesc1 return Result; AV_HWFRAME_MAP_* is a bindgen enum with no BitOr).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 10:31:54 +00:00
enricobuehler fe2b49ccc4 feat(gamepad): SwDeviceCreate per-session devnode (best-effort) + windows self-test
DualSenseWindowsManager now SwDeviceCreate's the pf_dualsense devnode per session
(SwDeviceClose on drop), matching the Linux UHID pad's lifecycle. It's best-effort:
SwDeviceCreate currently hits an unresolved E_INVALIDARG when a completion callback is
passed (an underscore in the enumerator name was a second cause, fixed by using
"punktfunk"), so on failure the host keeps the section + data plane and falls back to
an out-of-band devnode (installer/devgen) — see docs/windows-dualsense-scoping.md.

Add a `dualsense-windows-test` host CLI that drives the manager (create devnode + push
a frame + hold), used to validate the path. Live on the RTX box: the manager creates
the section + pushes report 0x01 and a devnode serves it to a HID read (b1=0xC0,
b8=0x28) — the host-side data plane works end to end.

cargo check + clippy -D warnings clean on x86_64-pc-windows-msvc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 21:34:00 +00:00
enricobuehler 3875fe86db refactor(windows): trim the inert IOCTL channel from the DualSense driver
The host<->driver channel is the shared-memory section (hidclass blocks the device
stack and UMDF has no control device), so the first-attempt in-driver IOCTL channel
never fired. Remove it: the custom device interface, IOCTL_PFDS_SET_INPUT/GET_OUTPUT,
the output queue, and the on_set_input/complete_one_read/deliver_output helpers. The
driver keeps the HID handshake, the 8ms read timer fed from the shared section, and
on_output_report publishing the game's 0x02 to the section. Rebuilt + reloaded + the
channel still verifies both directions live on the RTX box.

Also list `pf_dualsense` as a second hardware id (alongside `root\pf_dualsense`) so the
host's SwDeviceCreate'd software device binds the same driver as a devgen one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 21:34:00 +00:00
enricobuehler 3548c70152 feat(gamepad): virtual DualSense on the Windows host (UMDF shm channel)
Wire the Windows UMDF DualSense driver into the host as a real pad backend, so a
client that requests a DualSense gets a genuine one on a Windows host (instead of
folding to Xbox 360).

- Extract the transport-independent DualSense contract (DsState + from_gamepad,
  serialize_state, parse_ds_output, DUALSENSE_RDESC, feature blobs, DS_* consts)
  out of the Linux-only UHID backend into inject/dualsense_proto.rs, shared by both
  platforms; dualsense.rs is now just the /dev/uhid plumbing.
- Add inject/dualsense_windows.rs: DualSenseWindowsManager mirroring the Linux
  DualSenseManager (same new/handle/apply_rich/pump/heartbeat surface) over a
  DsWinPad that creates the Global\pfds-shm-<idx> section (CreateFileMappingW +
  SDDL D:(A;;GA;;;WD) so WUDFHost can open it), writes serialize_state -> input
  slot, polls output_seq -> parse_ds_output -> rumble/hidout callbacks.
- Un-gate the seam: PadBackend::DualSenseWindows arm; pick_gamepad gains a
  windows flag (DualSense honored on linux||windows; DS4/Xbox One stay Linux-only).

Verified: Linux cargo test gamepad_resolution_precedence + clippy clean; Windows
cargo check + clippy -D warnings clean (on the RTX box). Device lifecycle still
uses an out-of-band devnode (devgen/installer); SwDeviceCreate per session is next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 20:36:53 +00:00
enricobuehler abae62ed64 feat(windows): Rust UMDF virtual DualSense driver + shared-memory host channel
A self-authored UMDF2 HID minidriver (packaging/windows/dualsense-driver) that
presents a virtual Sony DualSense (VID 054C/PID 0CE6) on Windows — adaptive
triggers / lightbar / rumble that ViGEm structurally cannot deliver.

Validated live on an RTX box (Win11 25H2, Secure Boot ON): the self-signed driver
loads, Steam recognizes it as a genuine DualSense, and a game's 0x02 output report
reaches the driver. The host<->driver channel is a named shared-memory section
(Global\pfds-shm-<idx>) the host creates and the driver maps from its timer: input
report 0x01 host->driver, output report 0x02 driver->host — input and output proven
both directions live. This bypasses hidclass, which gates both a custom device
interface and custom IOCTLs on the HID node, and UMDF has no control device.

Built in Rust on microsoft/windows-drivers-rs. The load wall was the PE
FORCE_INTEGRITY bit that wdk-build sets via /INTEGRITYCHECK (forces a CI-trusted
page-hash signature a self-signed cert cannot satisfy) — cleared post-build. See
packaging/windows/dualsense-driver/README.md for the build/sign/install recipe.

Deferred: SwDeviceCreate per-session device lifecycle; removing the inert in-driver
IOCTL-channel code; full on-glass session test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 20:36:39 +00:00
enricobuehler f03150d457 fix(docs): stop Scalar's global body bg from bleeding into the docs on client-nav
Scalar's /api reference injects a *global* `body { background-color:
var(--scalar-background-1) }` (via its linked stylesheet + a runtime
<style id=scalar-style>) that TanStack doesn't remove on a client-side route
change. After navigating /api -> /docs without a reload, that rule kept
painting the docs body: Scalar's stock gray (#0f0f0f) while .dark-mode lingered
on <body>, or transparent once the class was gone. A hard reload was fine
because the stylesheet was never loaded there.

Fix: give --scalar-background-1 a global fallback = --color-fd-background so any
non-API page paints its own surface while Scalar's sheet lingers; /api itself
overrides it via the higher-specificity body.{dark,light}-mode rule. Also strip
the leftover #scalar-style/#scalar-refs nodes and body mode-class when /api
unmounts so the DOM matches a fresh load. Verified light + dark via headless
CDP: post-nav docs body now equals a fresh reload (#141019 / #f0ebff).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:49:20 +00:00
enricobuehler 3c34030083 fix(docs): Scalar API ref uses brand bg + follows the docs light/dark toggle
Scalar puts .light-mode/.dark-mode on document.body and renders customCss
*before* its built-in theme preset in the same <style> tag, so a bare
.dark-mode override loses at equal specificity and the stock #0f0f0f gray
showed through. Scope the palette to body.{dark,light}-mode (0,1,1) so it beats
both the linked base sheet and the in-component preset, and add a full
light-lavender palette to match the docs light surface.

Drive Scalar's darkMode from the resolved Fumadocs theme (next-themes) instead
of hard-locking it on, so toggling the docs theme switch flips the API
reference too; the React wrapper's updateConfiguration effect live-swaps the
body mode class.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:36:32 +00:00
enricobuehler 6d370f7ed9 ci(release): split canary/stable tracks + unified Gitea Releases
A push to main publishes canary builds to canary channels (fast iteration,
unchanged); a single vX.Y.Z tag releases every platform at one version to the
stable channels and attaches all artifacts (.deb/.rpm/.msix/.apk/.aab/.dmg +
flatpak/decky/host-installer) to one Gitea Release. Collapses the
host-v*/win-v*/host-win-v* tag namespaces into v* — the channel split makes the
version-shadow bug structurally impossible (canary and stable are separate repos,
never a shared version line).

- scripts/ci/gitea-release.{sh,ps1}: one idempotent release helper
  (create-or-fetch + delete-before-upload), replacing 3 copy-pasted inline blocks
  and fixing their latent 409-on-reupload bug; prerelease flag auto-derived from
  the tag (an -rc tag won't shadow "Latest")
- channels: apt canary/stable distributions; rpm *-canary/base groups; flatpak
  canary/stable OSTree branches + a 2nd .Canary.flatpakref; generic-registry
  canary/ vs latest/ aliases; Play internal/alpha; Apple TestFlight vs notarized DMG
- android versionName threaded through gradle (versionCode stays run_number);
  Apple canary = TestFlight-only (no DMG/tvOS); canary base bumped to 0.3.0
- docs: new docs-site channels.md (subscribe table + cut-a-release runbook +
  box migration), refreshed ci.md workflow table + packaging READMEs

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 17:26:38 +00:00
enricobuehler 99ae1b9a17 feat(gamepad): add virtual Xbox One/Series + DualShock 4 pad types
Extends virtual-controller support beyond Xbox 360 + DualSense. Goal: a
physical Xbox One or PS4 pad on the client gets a near-native matching virtual
pad on the host, auto-resolved from the controller type.

Protocol/core:
- GamepadPref gains XboxOne (wire 3) + DualShock4 (wire 4); to_u8/from_u8/
  from_name/as_str + C ABI PUNKTFUNK_GAMEPAD_XBOXONE/_DUALSHOCK4 constants
  (compile-time guard ties them to the enum). Single-byte wire form is
  unchanged, so it's forward-compatible (older peers degrade to Auto).

Host (Linux):
- New UHID DualShock 4 backend (inject/dualshock4.rs) bound by hid-playstation:
  lightbar, touchpad, motion, rumble — DualSense minus adaptive triggers /
  player LEDs / mute. Reuses the DualSense pure state + button mapping; only the
  report byte layout, the real-DS4 HID descriptor, the GET_REPORT handshake
  (0x12 MAC mandatory; 0x02 calibration; 0xa3 firmware) and the touchpad
  resolution (1920x942) differ. Touchpad/motion ride the existing 0xCC plane,
  lightbar the 0xCD Led plane (deduped); rumble the universal 0xCA plane.
- Xbox One/Series is the uinput Xbox-360 backend parameterized with the One S
  USB identity (045e:02ea) for matching glyphs — XInput-identical otherwise.
- PadBackend dispatch + resolver handle both; off Linux the UHID pads and
  One/Series fold into Xbox 360. Windows-host DS4 (ViGEm) deferred.

Clients (auto-resolve physical pad -> virtual type, plus manual settings):
- Linux/Windows (SDL3): SDL_GAMEPAD_TYPE_PS4 -> DualShock 4, _XBOXONE ->
  Xbox One; PadInfo carries the resolved pref; DS4 touchpad/motion capture +
  lightbar already type-agnostic. Linux settings combo + label updated.
- Apple (GameController): GCDualShockGamepad/GCXboxGamepad detection, DS4
  touchpad capture, settings picker entries.
- Android (Kotlin): InputDevice VID/PID auto-detect (matching the other
  clients) + settings entries.
- probe: --gamepad help/aliases.

Also hardens the Android JNI boundary: wrap the teardown + poll-thread shims in
catch_unwind so a panic degrades to a logged no-op instead of aborting the app.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 13:34:44 +00:00
enricobuehler 7bc5deee7d fix(web): session-card button overflow + bottom-nav icon alignment
- Dashboard session card: the header stacks the title above the action buttons
  on narrow screens (flex-col -> sm:flex-row) and the button group wraps
  (flex-wrap), so "Request IDR" / "Stop session" no longer overflow the card.
- Mobile bottom nav: give each label a fixed two-line-tall centered box so a
  1- or 2-line label (labels vary by locale) keeps every tab icon at the same
  height.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 12:33:33 +00:00
enricobuehler e390718d13 fix(docs): load Scalar's stylesheet so the API reference isn't unstyled
@scalar/api-reference-react@0.9.47's entry imports createApiReference but does
NOT import its own style.css (nor inject it at runtime), so /api rendered with
no Scalar CSS at all. Import the sheet as a route-scoped <link> (?url +
head.links, same pattern as the root app.css) so it loads for SSR + the
client-side Vue mount. The brand customCss still themes on top.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 12:25:22 +00:00
enricobuehler 700b965c31 feat(web): drop material gloss, full punktfunk theme for Scalar, center mobile tabs
- console: remove @unom/ui's specular "material" gloss (drop UnomProviders +
  the material.css import) so components render flat like the marketing site;
  the violet brand + Geist stay.
- mobile bottom tab bar: center the labels (w-full text-center, leading-tight)
  and even out the per-tab layout.
- docs /api: roll the punktfunk dark-violet palette across the whole Scalar
  reference (surfaces/text/sidebar/links/buttons/method colours via the full
  --scalar-* token set), locked to dark (hideDarkModeToggle).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 12:19:51 +00:00
enricobuehler 56f724815e feat(web): unify console + docs on @unom/ui; host OpenAPI via Scalar
Move the management console (web/) off shadcn/ui to the shared @unom/ui
design system the marketing site + docs are built on, on the punktfunk
violet brand over dark chrome:

- Add @unom/ui/@unom/style/motion/radix-ui/zod + Geist; web/.npmrc maps the
  @unom scope (packages are public-read, so CI needs no npm auth).
- styles.css: one dark-violet palette (#141019/#1c1530, brand #6c5bf3 ->
  #a79ff8) exposed under BOTH the shadcn token names the routes use and
  @unom/ui's contract, so routes + components both resolve; pulls in
  @unom/ui's material gloss + easings.
- components/ui/* now back onto @unom/ui (AnimatedButton/InputText/Label/
  AnimatedCard); brand-mark/wordmark/logo replace the generic Radio icon in
  the shell + login.
- MaterialProvider (specular gloss) at the root. No UI sounds, like the site.

docs-site: new /api route renders the host management REST API as an
interactive Scalar reference (reads public/openapi.json, a snapshot of
docs/api/openapi.json), branded violet and linked from the top nav, the
docs sidebar, the landing page, and host-cli.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 12:00:46 +00:00
enricobuehler 43b480ada3 docs(steamos): reframe Steam Deck host page to SteamOS
- Rename steam-deck-host.md → steamos-host.md (nav + install table updated).
- Lead with the rationale: SteamOS host support targets the upcoming Steam
  Machine; the Steam Deck is the SteamOS device validated against today.
- Soften the WiFi note: ~250 Mbps was our testing on one device/network,
  not a universal ceiling — other SteamOS hardware/drivers/bands may do more.
- Generalize Deck-specific language to SteamOS devices throughout.
- Document --no-gamestream (secure native-only) + GameStream-compat caveat.
- decky README: drop stale `serve --native` (now just `serve`).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:33:49 +00:00
enricobuehler 5dbe3b00cc feat(steamdeck): --no-gamestream installer flag for a secure native-only SteamOS host
Completes the GameStream-opt-in posture (0de648f) on the SteamOS path: the installer keeps
Moonlight compat on by default (`serve --gamestream`, the Deck commonly streams to Moonlight),
but `--no-gamestream` now installs a secure native-only host with no GameStream on-path surface
(plain-HTTP pairing / legacy GCM nonce reuse — security-review #5/#9; native clients only).
Documented in the installer --help; the SteamOS host doc references it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:29:40 +00:00
enricobuehler 0de648f106 feat(host): GameStream/Moonlight compat is now opt-in (--gamestream) — secure native-only by default
Follows the security audit (#5/#9): the GameStream-compat plane carries inherent on-path weaknesses
that can't be fixed on the wire without breaking stock Moonlight — its pairing runs over plain HTTP
(#9, MITM-able during the pairing window) and its legacy control encryption can reuse GCM nonces (#5,
a passive eavesdropper can recover/forge input). The native punktfunk/1 plane (SPAKE2 PIN pairing +
per-direction AEAD nonces) has neither. So flip the default to secure-by-default:

- `serve`              → native punktfunk/1 plane + management API ONLY (no GameStream surface).
- `serve --gamestream` → ALSO the GameStream/Moonlight-compat planes (nvhttp pairing, RTSP, ENet
  control, _nvstream mDNS). Opt-in, logged with a trusted-LAN caveat. `--moonlight` is an alias.
- The native plane is now ALWAYS on in `serve` (`--native` is a kept-for-compat no-op); the unified
  GameStream+native host is `serve --gamestream`.

`gamestream::serve` gates the GameStream spawns (nvhttp/rtsp/control/mdns) on the flag; the native
plane + mgmt + native-pairing handle always run.

To avoid silently regressing validated Moonlight deployments, the explicit deployment configs PRESERVE
Moonlight via `--gamestream` (each documents dropping it for a secure native-only host): the Linux
systemd unit, the Steam Deck installer, and the Windows service default (DEFAULT_HOST_CMD). The bare
`serve` default (new/manual use) is secure.

Docs swept to match (host-cli, moonlight, quickstart, install, packaging READMEs, CLAUDE.md, README,
…): Moonlight setup now instructs `--gamestream`; native/console refs use bare `serve`. OpenAPI
regenerated (a stale "run `serve --native`" string). fmt + clippy clean; 94 host tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:19:40 +00:00
enricobuehler ed7dc3ed24 fix(security): remaining audit findings — mgmt admin gate, RTSP DoS bounds, FEC drop, ALPN, ct-compare
Addresses the lower-severity findings from docs/security-review.md (#4-#12). Each fix was
adversarially re-reviewed (5-agent pass); two review catches folded in (the Apple client's
GET /library cert path; an RTSP header-cap bypass + a spawn-panic counter leak).

- #4 [low] mgmt mTLS-paired-cert no longer grants full admin. A paired STREAMING cert authorizes
  only a read-only allowlist (GET /host,/compositors,/status,/clients,/native/clients,/library);
  every state-changing route and every PIN-exposing route (/pair, /native/pair) requires the
  operator's bearer token. New cert_auth_is_a_read_only_allowlist test. (/library kept on the
  allowlist — the native clients browse it cert-only; its mutations stay token-only.)
- #6 [low] RTSP pre-auth DoS bounds: a concurrent-connection cap (RAII slot guard), a per-read
  timeout (slow-loris), and Content-Length/header/message size caps — closing an unauthenticated
  slow-loris / memory-growth / thread-exhaustion vector on TCP 48010.
- #11 [info] A FEC reconstruction failure is now a counted drop (discard the block, keep the
  session) instead of being stream-fatal — a lossy link can't be torn down by one bad block.
- #10 [info] Fixed ALPN ("pkf1") on both native QUIC endpoints (defense-in-depth; a deliberate
  coordinated client+host upgrade — a new host rejects an ALPN-less old client).
- #8 [info] Constant-time GameStream pairing phase-4 hash compare (crypto::ct_eq).
- #7 [low] New VirtualDisplay::set_launch_command carries the launch command per-session on the
  GameStream path (no process-global env stomp under concurrent sessions); native path keeps the
  env under today's single-session model (documented; plumb per-session with concurrent sessions).
- #5 [low] Legacy GameStream GCM nonce reuse: documented as inherent to Nvidia's old-style control
  encryption (Apollo/Moonlight identical; key is client-known) — unfixable on the legacy wire; the
  real fix is V2 control-encryption negotiation. Code comment at control.rs.
- #9 [info] GameStream plain-HTTP pairing: documented (inherent to GFE compat; use punktfunk/1).
- #12 [low] Web global NODE_TLS_REJECT_UNAUTHORIZED: fix designed (undici dispatcher scoped to the
  loopback mgmt fetch) but DEFERRED — needs `bun add undici` in the web build env; reverted to keep
  the web working. Latent-only (the loopback mgmt fetch is the console's only outbound TLS).

fmt + clippy -D warnings clean; 94 host + core tests green; no C-ABI/OpenAPI drift. (The HDR
Steps 1-2 client work in the tree is the user's parallel WIP — deliberately NOT included here.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 09:50:24 +00:00
enricobuehler 4ddd65d732 feat(clients): HDR Steps 2-3 — apply mastering metadata + display capability-gate
Continues docs/hdr-pipeline-plan.md. Steps 0/1 + Step 2 (Windows/Android) already
landed in ffae2a3; this is Step 2 (Apple) + Step 3 (all clients). Client-only — no
core/host/ABI change (the 0xCE/next_hdr_meta/color_info surfaces shipped in Step 0).

Step 2 — clients APPLY the host's HDR metadata (each remaps from the wire form: ST.2086
G,B,R order, mastering luminance in 0.0001 cd/m2):
- Apple: connect via punktfunk_connect_ex5 (resurrects the previously-dead HDR pipeline);
  nextHdrMeta/colorInfo wrappers + HdrMeta SEI-blob builders; the pump drains nextHdrMeta
  -> VideoDecoder.setHdrMeta -> CVBufferSetAttachment of MasteringDisplayColorVolume (24B
  BE) + ContentLightLevelInfo (4B BE) on each HDR pixel buffer (correct for the
  itur_2100_PQ layer; CAEDRMetadata avoided as ambiguous there).

Step 3 — capability-gate: advertise HDR caps ONLY when the display can present it, so an
SDR display gets a proper BT.709 stream instead of PQ it would mis-tone-map; an HDR
display self-tone-maps from the Step-1/2 mastering metadata.
- Windows: present::display_supports_hdr() (DXGI any IDXGIOutput6 colour space == G2084),
  ANDed with the user HDR setting in session.rs; logs the SDR drop.
- Apple: NSScreen.maximumExtendedDynamicRangeColorComponentValue>1 (macOS) /
  UIScreen.main.potentialEDRHeadroom>1 (iOS) in SessionModel.
- Android: Settings.displaySupportsHdr (Display.getHdrCapabilities HDR10/HDR10+) passed
  through a new hdr_enabled jboolean on nativeConnect; session.rs gates the caps.

Validation: Android native (incl. the jboolean gate) builds + clippy clean via cargo-ndk;
fmt clean. Windows (MSVC), Apple (Swift) and the Kotlin side are CI/on-glass validated —
not compilable on the Linux dev box. Deferred to the RTX box: mid-session Reconfigure
SDR-downgrade on monitor move, and confirming the host emits SDR for an SDR client off an
HDR desktop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 09:46:58 +00:00
enricobuehler ffae2a31e4 feat: HDR Step-0 colour-metadata transport + security-audit hardening
Two strands, entangled in punktfunk1.rs, committed together (one builds-green tree).

HDR pipeline Step 0 — glass-to-glass colour-metadata transport (docs/hdr-pipeline-plan.md):
- Protocol/ABI: ColorInfo on the Welcome + a 0xCE HdrMeta datagram carry the source colour
  space + HDR10 static mastering metadata (quic.rs, abi.rs connect_ex5 fixing caps=0).
- New platform-independent, unit-tested HDR static-metadata helpers (hdr.rs): chromaticities
  (1/50000), mastering luminance (0.0001 cd/m2), MaxCLL/MaxFALL in HDR10/ST.2086 units.
- Capture/encode hooks (capture.rs, encode.rs set_hdr_meta) + Linux client / probe plumbing.

Security-audit hardening — top 3 from docs/security-review.md, each adversarially verified:
- #1 [HIGH] Secret file permissions. The host key.pem/cert.pem and both trust stores are now
  written owner-only: 0600 + dir 0700 on Unix (mirrors mgmt_token), best-effort
  SYSTEM/Administrators/OWNER-only icacls DACL on Windows (%ProgramData% is Users-readable).
  Closes a local key-disclosure -> host-impersonation gap. New gamestream::{create_private_dir,
  write_secret_file} + a 0600 regression test.
- #2 [HIGH] Native SPAKE2 PIN is single-use. The PIN is consumed the moment the host sends its
  key-confirmation (which lets the client test its one guess), before reading the proof, so any
  completed attempt -- right OR wrong -- disarms the window. A wrong PIN isn't observable
  host-side (the client aborts before sending its proof), so consuming on first attempt is what
  delivers the documented "one online guess" instead of an unbounded brute-force of the static
  4-digit PIN. Test verifies single-use.
- #3 [MEDIUM] RTSP packetSize is bounded ([64,2048] in stream_config) and VideoPacketizer::new
  uses saturating .max(1), killing a PRE-AUTH div-by-zero/underflow panic of the video thread.
  Tests for {0,15,16,17} + out-of-range rejection.

fmt + clippy -D warnings clean; full workspace test suite green (93 host tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 09:07:59 +00:00
enricobuehler 4ef4ddbf37 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	docs-site/content/docs/meta.json
2026-06-21 00:07:36 +00:00
enricobuehler 1e0d2e661f feat(host): Apollo-backlog hardening — cert gate, NVENC RFI, media QoS, async injector
A pass over the apollo-comparison backlog (re-verified against current code).
Lands four items end-to-end plus a Windows-DualSense scoping doc.

- #5/#92/#26 — GameStream paired-cert allow-list. tls.rs surfaces the verified
  peer cert to handlers (serve_https + PeerCertFingerprint, now shared with the
  mgmt API instead of duplicated); nvhttp gates /launch /resume /applist /cancel
  on AppState.paired and reports a real PairStatus; save_paired writes atomically
  (temp+rename). Closes the "mTLS accepts any client cert" hole. + regression test.

- #6/#51/#19/#22 — NVENC caps query -> reference-frame invalidation. nvenc.rs
  query_caps probes nvEncGetEncodeCaps (max dims / 10-bit / custom-VBV / RFI),
  rejecting over-range modes and degrading 10-bit->8-bit instead of an opaque
  InvalidParam. New Encoder::invalidate_ref_frames (default false -> caller
  keyframes); the Windows NVENC path implements real RFI (multi-ref DPB +
  nvEncInvalidateRefFrames, dedup + IDR-on-overflow). control.rs decodes the
  0x0301 lost-frame range (Apollo's IDX_INVALIDATE_REF_FRAMES) -> AppState.rfi_range
  -> encode loop, falling back to a keyframe. NOTE: the Windows NVENC impl is
  RTX-box/CI-pending (can't compile on Linux); adversarially reviewed vs the SDK.

- #43/#72 — media socket QoS + buffer growth. New punktfunk_core::transport::qos:
  grow_socket_buffers (factored out the native plane's 32MB SO_SNDBUF growth so the
  GameStream sockets reuse it) + set_media_qos (opt-in PUNKTFUNK_DSCP=1: DSCP CS5
  video / CS6 audio + Linux SO_PRIORITY, Apollo's scheme). Wired into UdpTransport
  and the GameStream video/audio sockets. Windows IP_TOS needs qWAVE (follow-up).

- #8/#45 — GameStream input injection off the ENet service thread. on_receive no
  longer injects inline (a slow inject head-blocked ENet keepalive/retransmit); it
  forwards to a dedicated injector thread. The hardened InjectorService moved from
  punktfunk1 into crate::inject (shared by both planes) + a coalesce step that sums
  adjacent relative-mouse/scroll deltas while preserving button/key/abs ordering.

Docs: re-verified apollo-comparison.md status (22 items already done/obsolete since
the snapshot) + windows-dualsense-scoping.md (ViGEm can't emulate a DualSense; real
DS5 on Windows needs a VHF virtual-HID driver — web-research pass pending).

fmt + clippy -D warnings clean; full workspace test suite green; no C-ABI/OpenAPI drift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 00:06:30 +00:00