- Settings: flat list -> Display / Host / Audio / Overlay sections in outlined
cards (SettingsGroup + ToggleRow helpers) with section headers.
- ConnectScreen: connection errors now show in a filled errorContainer banner
(was plain red text lost in the layout), and a "Searching the local network..."
spinner appears while discovery is active but nothing's turned up yet.
Verified locally: ./gradlew :app:assembleDebug BUILD SUCCESSFUL.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Crash: DisposableEffect.onDispose called nativeClose(handle) (Box::from_raw frees
the SessionHandle) while the SurfaceView's surfaceDestroyed independently called
nativeStopVideo/Audio/Mic on the same handle -- whichever ran after the close
dereferenced freed memory (SIGSEGV: the consistent back-navigation crash). Add a
one-shot `closed` guard: onDispose marks it before freeing; surfaceDestroyed skips
the native calls once closed (backgrounding still stops the threads when it wins).
Polish:
- Branded Material You theme (Theme.kt): dynamic colour on Android 12+, punktfunk
brand violets as the pre-12 fallback, replacing the generic darkColorScheme().
- ConnectScreen: "Connecting..." was rendered in error-red with no spinner; now a
neutral spinner while connecting, red reserved for actual errors.
Verified locally: ./gradlew :app:assembleDebug BUILD SUCCESSFUL (both ABIs + the
Compose changes), debug APK assembles.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ndk's DataSpace derives Copy/PartialEq/Eq and impls Display (no Debug), so the
{ds:?} in the HDR dataspace log statements wouldn't compile under cargo-ndk.
Host clippy can't catch it — decode.rs is android-gated. Switch to {ds}.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Install failed with "GPG verification enabled, but no signatures found" on the
commit: the deploy step only ran build-update-repo (signs the summary). Add
`flatpak build-sign` to sign the commit objects too — clients with
gpg-verify=true verify the commit, so summary-only signing isn't enough.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirrors the Apple client's HDR path so the Android client can display HDR from a
Windows HDR host:
- nativeConnect now advertises VIDEO_CAP_10BIT | VIDEO_CAP_HDR (was 0), so the
host upgrades to a Main10 / BT.2020 PQ encode.
- decode.rs detects HDR reactively from the decoder's reported output colour
(color-transfer ST2084=6 / HLG=7, color-range) -- the AMediaCodec analogue of
VideoToolbox's format description on Apple -- and signals the Surface dataspace
(Bt2020[Itu]Pq / Bt2020[Itu]Hlg) so the compositor/display switch to HDR.
AMediaCodec decodes Main10 from the in-band SPS; no profile override needed.
Also fixes the Android build: set_frame_rate (added in e9de730) is gated on the
ndk `nativewindow` + `api-level-30` features, which weren't enabled -- so that
commit could not compile under cargo-ndk. Enable
features = ["media","audio","nativewindow","api-level-31"] (minSdk 31): covers
set_frame_rate (api-30), set_buffers_data_space + the DataSpace module (api-28),
and ANativeWindow (nativewindow).
Verified host-side: fmt --all + clippy --workspace (the caps advertise + JNI
surface). The android-gated decode + NDK gating verified against the ndk 0.9
sources; android.yml (cargo-ndk) is the compile gate, and real HDR display needs
an HDR device + Windows HDR host.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Apple is TestFlight-only (no App Store) — link the join URL; drop the App Store
placeholder. Add the live Google Play listing for io.unom.punktfunk.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per-device install steps in one place: Linux (Flatpak via flatpak.unom.io +
native apt/rpm/Arch), Steam Deck, Windows (signed MSIX from the registry),
macOS (notarized DMG from releases), and iOS/Android (store/beta links). Adds
it to the Connecting nav and cross-links clients.md, whose Linux/Flatpak bullet
now points at the hosted flatpak.unom.io repo instead of the bundle README.
Mobile store/TestFlight URLs are placeholders pending the public listings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stats HUD (mirrors the Apple client): the decode thread accumulates FPS, receive
throughput, and capture->client latency (p50/p95, skew-corrected) in Rust
(clients/android/native/src/stats.rs); nativeVideoStats drains a snapshot ~1 Hz
over JNI as a DoubleArray. StreamScreen renders a Compose overlay
(W*H@Hz / fps / Mb/s / latency, + dropped-under-loss), toggled by a Settings
switch (persisted, default on) or a 3-finger tap.
Performance (decode.rs):
- ANativeWindow_setFrameRate(refresh_hz): align display vsync to the stream rate
(no 60-in-120 judder); safe since minSdk 31 >= API 30.
- Raise the decode thread toward URGENT_DISPLAY (best-effort setpriority) so
background work can't preempt it under load.
- Codec low-latency hints KEY_PRIORITY=0 (realtime) + KEY_OPERATING_RATE.
Verified host-side: cargo build/clippy/fmt --workspace (the ungated stats + JNI
accessor). The android-gated decode.rs (NDK) and the Kotlin build only in CI
(android.yml: gradle + cargo-ndk) -- APIs verified against crate sources.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The CI added --default-branch=stable, so the repo ref is
app/io.unom.Punktfunk/x86_64/stable. build-bundle defaults to `master` when no
branch is given → "Refspec app/io.unom.Punktfunk/x86_64/master not found". Pass
`stable` explicitly in both flatpak.yml and the local build-flatpak.sh.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
stoppedHandler/resetHandler are non-optional closures on the CI SDK
((StoppedReason)->() and ()->()), so assigning nil fails to compile
(apple.yml). Assign no-op closures to disarm them before engine.stop()
-- same re-entrancy guard intent, type-correct.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The CI only shipped a single-file .flatpak bundle, which has no remote — users
couldn't `flatpak update`. Keep the bundle (Decky fallback) but also sign the
OSTree repo flatpak-builder already produces and publish it to a shared,
reusable unom-wide remote.
- flatpak.yml: pin --default-branch=stable; import the signing key and
build-update-repo --gpg-sign; generate unom.flatpakrepo + the app .flatpakref
+ index.html; rsync the repo to unom-1 and bring up a static Caddy container.
The step no-ops until FLATPAK_GPG_PRIVATE_KEY/DEPLOY_* exist (build stays green).
- packaging/flatpak/server/: compose.production.yml + Caddyfile (static file
server on :3230, mirrors docker.yml deploy-docs).
- unom-flatpak.gpg: committed public signing key (base64 -> GPGKey= in the descriptors).
- README: hosted repo is now the recommended install; documents the one-time
infra (edge Caddy vhost, infra port 3230, DNS, the GPG secret).
Edge Caddy vhost + infra port allowlist + the secret are applied out-of-band.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two bodies of work in one commit (the rename moved files the fixes also touched).
Naming/structure cleanup (pre-launch):
- Host modules m3.rs->punktfunk1.rs, m0.rs->spike.rs; CLI m3-host->punktfunk1-host,
m0->spike; bare `punktfunk-host` now prints help. Types M3Options/M3Source->
Punktfunk1Options/Punktfunk1Source.
- Clients consolidated out of crates/ into clients/: punktfunk-client-rs->
clients/probe (crate punktfunk-probe), client-linux->clients/linux,
client-windows->clients/windows, punktfunk-android->clients/android/native
(crate punktfunk-client-android; kept [lib] name=punktfunk_android so the JNI
contract is unchanged). crates/ now holds only core + host.
- Milestone codes M0-M4 purged from code/CLI/CLAUDE.md/README/docs/docs-site,
kept only in docs/implementation-plan.md. docs/m2-plan.md->
docs/gamestream-host-plan.md. CI/gradle/flatpak paths updated.
Client loss-recovery (video froze and never recovered after a brief drop):
- Export punktfunk_connection_frames_dropped through the C ABI (the core already
tracked it for the client keyframe-recovery loop; it was never reachable from
the ABI clients). Regenerated punktfunk_core.h.
- Apple (StreamPump + Stage2Pipeline) and Android (decode.rs) now poll
frames_dropped and request a keyframe when it climbs -- the same loss-driven
recovery Linux/Windows already had. Under infinite GOP the decoder silently
conceals reference-missing frames, so the decode-error trigger rarely fires.
Apple rumble robustness (worked then went spotty -- DualSense + Xbox):
- Add CHHapticEngine stopped/reset handlers (rebuild on app background / audio
interruption / server reset) and drop the permanent `broken` latch on a
transient drive failure; latch only when the controller truly has no haptics.
- Surface swallowed SDL set_rumble errors on Linux/Windows + diagnostic logging.
Verified: cargo build/clippy/fmt --workspace, C-ABI harness, header drift.
Not runnable on this box (verify in CI): Gitea workflows, gradle/Android,
flatpak, Swift/decky.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
r0adkll/upload-google-play hides real API errors behind "Unknown error
occurred." Proved the full upload sequence (insert edit -> upload bundle ->
track update -> validate) succeeds with the service account, so the failure was
r0adkll's opaque error handling and/or a base64-encoded SERVICE_ACCOUNT_JSON
secret.
clients/android/ci/play-upload.py does the same sequence with stdlib + openssl
(no pip), reuses the SERVICE_ACCOUNT_JSON secret, tolerates it being raw JSON or
base64, auto-retries commit with changesNotSentForReview, and prints Google's
actual error. Locally dry-run-validated against the live app (both secret forms).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Build a universal release APK alongside the AAB and push both to the public
generic registry (punktfunk-android/<run_number>/) before the Play upload, so
artifacts are downloadable even while the Play step is still failing. Matches
windows-msix.yml / deb.yml (REGISTRY_TOKEN, user enricobuehler).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
So Windows mic passthrough works without the user installing anything: when no virtual-mic
device is present, install Steam Remote Play's SteamStreamingMicrophone.inf (ships under
Steam\drivers\Windows10\{arch}\ next to the speakers INF Apollo uses) via DiInstallDriverW
loaded from newdev.dll — the same mechanism Apollo uses for Steam Streaming Speakers — then
re-find the device. Needs admin (the host runs as SYSTEM); best-effort and safe (no-op if
Steam absent / INF not found / PUNKTFUNK_NO_MIC_INSTALL), falling back to the manual-install
guidance (VB-Audio Cable) otherwise.
Not yet built/validated on the box (down); FFI cross-checked against windows-0.62. Whether
Steam ships SteamStreamingMicrophone.inf at that path is to be confirmed on the box.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The host received the client's mic uplink (0xCB Opus) but dropped it on Windows ("requires
Linux"). Windows has no user-mode way to CREATE a capture endpoint, so target an existing
virtual audio device and write the decoded mic PCM into its RENDER endpoint — the device's
CAPTURE endpoint then surfaces as a microphone host apps record from (the inverse of a
virtual cable). New audio::wasapi_mic::WasapiVirtualMic: finds the device by friendly-name
(Steam Streaming Microphone / VB-Audio CABLE Input / VoiceMeeter / "virtual", override with
PUNKTFUNK_MIC_DEVICE), opens a WASAPI shared event-driven RENDER client (48 kHz stereo f32,
autoconvert), and a dedicated COM thread writes a bounded (~80 ms drop-oldest) inject queue
with silence-fill. open_virtual_mic() gets a Windows arm; mic_service_thread (Opus decode →
push) now compiles for windows too (opus is already a windows dep). Clear error + install
guidance when no virtual device is present.
Linux/cross-platform side cargo-checks; the Windows path is built/validated when the box is
back (the wasapi render API was cross-checked against the docs + the existing capture path).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Apollo runs its capture thread at CRITICAL and its encoder thread at ABOVE_NORMAL; we set
none. Our GPU work is already HIGH priority, but the GPU scheduler can only favour commands
we've SUBMITTED — a normal-priority thread descheduled by a CPU-heavy game submits the
convert/encode late, so the HIGH GPU priority never bites (consistent with the measured
"NVENC engine idle yet the encode waits ~15 ms"). Raise the WGC helper's capture+encode
loop and the single-process capture+encode loop to THREAD_PRIORITY_HIGHEST, and the
transmit thread to ABOVE_NORMAL, via a cross-platform boost_thread_priority() (Windows-only
effect — the Linux host caps the game via gamescope so its threads aren't starved).
Not yet built/validated on the GPU box (it's down); the cross-platform side compiles
(cargo check) and the Windows calls are cross-checked against the windows-0.62 API.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When a client requests a bitrate above the GPU's HEVC/AV1 level ceiling, NVENC rejects
initialize_encoder. The old probe stepped the rate down by ×¾ each retry, undershooting
the real ceiling badly (a 1 Gbps request landed ~300 Mbps even with the level cap near
800). Replace it with a binary search over [floor, requested] that converges (±20 Mbps)
on the HIGHEST rate NVENC accepts and clamps to that — so the stream uses the full
codec-level bitrate. Factored the session open/config/init into try_open_session() for
the probe; split-encode rejection is disambiguated from a bitrate-cap rejection (retry
once with split disabled) and the floor fallback also tries split-disabled.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
NVENC defaulted to Main tier, whose per-level bitrate ceiling at 5K (HEVC Level 6.2
Main ≈ 240 Mbps) made initialize_encoder reject a high client bitrate; the existing
probe-and-step-down then silently dropped a ~1 Gbps request by ×¾ to ~240-320 Mbps —
visible color/motion compression on fast scenes. Set HIGH tier (≈800 Mbps for HEVC,
higher for AV1) + autoselect level so the requested bitrate goes through. `tier`/`level`
are u32 (HIGH=1, AUTOSELECT=0) shared across the HEVC/AV1 union offset; the step-down
remains as a safety net. Not yet built/validated on-box (box offline).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Windows host capped at ~60 fps with 35-40 ms latency on a GPU-heavy game:
the per-frame capture→encode path shared the 3D engine with the game and got
scheduled behind it. Rework to minimize 3D-engine work per frame:
- VideoConverter (D3D11 video processor): capture → NVENC-native NV12/P010 so
NVENC skips its internal RGB→YUV (a 3D/compute step). Wired into both DDA
(dxgi.rs) and WGC (wgc.rs). New PixelFormat::Nv12/P010 + NVENC YUV input.
- GPU scheduling hardening (Apollo-style): D3DKMTSetProcessSchedulingPriorityClass
HIGH, absolute SetGPUThreadPriority, SetMaximumFrameLatency(1).
- WGC SDR zero-copy (hold pool frames; no CopyResource). DDA keeps a fast
CopyResource to decouple its single-frame acquire/release from the async convert.
- Pipelined helper encode loop (PUNKTFUNK_ENCODE_DEPTH, default 1) + perf split
(cap_wait / encode / write).
Live on the RTX 4090: hard 60 fps ceiling removed (now scene-scaling 40-200+),
latency much reduced. Residual cap in GPU-pinned scenes is the irreducible RGB→YUV
convert (no fixed-function unit on NVIDIA — VideoProcessing engine reads 0%) waiting
behind an uncapped game under WDDM context time-slicing; Linux avoids it via
gamescope capping the game to the display refresh.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The plugin was a QAM launcher whose stream never appeared, with no
pairing. Three fixes, plus a headless --pair mode on the GTK client:
- Stream actually starts (MoonDeck's proven mechanism): gamescope only
focuses the process tree Steam launched via reaper, so a flatpak
spawned from the (root) backend is invisible. The frontend now
registers ONE hidden non-Steam shortcut pointing at bin/punktfunkrun.sh,
passes the host as the shortcut's Steam launch options, and starts it
with SteamClient.Apps.RunGame — gamescope then fullscreen-focuses it.
The wrapper execs `flatpak run io.unom.Punktfunk --connect <host>`.
- Fullscreen page: routerHook.addRoute("/punktfunk") — host list,
per-host Pair/Stream, and a settings section (resolution/refresh/
bitrate/gamepad/mic, written to client-gtk-settings.json).
- Pairing: a gamepad-navigable PIN keypad. The host shows the PIN; the
backend runs the SPAKE2 ceremony headlessly via the client's new
`--pair <PIN> --connect host` CLI mode (app.rs), persisting the host
as paired so the stream then connects silently. Same flatpak =>
shared identity store, verified live (ceremony against a real host).
- Backend (main.py): discover / pair / runner_info / get_settings /
set_settings / kill_stream; uses DECKY_USER_HOME so paths resolve to
the deck user's flatpak install regardless of the plugin's root flag.
CI (decky.yml) and the sideload packager now ship bin/punktfunkrun.sh.
The Steam-shortcut launch and headless-pairing env follow MoonDeck
exactly but need a Deck in Gaming Mode to fully confirm.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On the Steam Deck there was no way out of fullscreen — no F11 key, and the
header bar (with the fullscreen button) is hidden while fullscreen.
- Controller: a Moonlight-style escape chord (L1+R1+Start+Select) held
together leaves fullscreen and releases input capture. The gamepad
service latches the chord (fires once per press) and signals the stream
page over an async channel; four simultaneous buttons no game uses as a
deliberate combo, so it can't trigger during play.
- Keyboard: F11 already toggled fullscreen (checked before input
forwarding, so it works while captured) — now surfaced.
- Discoverability: entering fullscreen flashes a 4s hint listing both
exits (F11 · L1+R1+Start+Select).
The escape future is aborted on page-hidden so a stale session can't act
on the shared window.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The lock prune (4df35bd) stopped flatpak-builder full-cloning windows-rs
(disk-fill), but exposed the next layer: `cargo --offline --locked -p
punktfunk-client-linux` resolves the WHOLE workspace, so it still tried
to load the now-un-vendored windows-rs source for the
punktfunk-client-windows member (its windows-rs git deps are
cfg(windows)-gated, but cargo resolves all targets regardless) and
failed: "can't checkout ... you are in the offline mode".
Drop the Windows client from the workspace members inside the sandbox
build (sed on the copied Cargo.toml — the flatpak never compiles it) and
remove --locked (the lock no longer matches the reduced member set;
--offline still pins every crate to the vendored cargo-sources.json, so
the build stays reproducible). android stays — it has no git deps.
Verified locally: removing the member, `cargo build -p
punktfunk-client-linux --offline` Finishes with zero windows-rs
involvement; manifest YAML still valid.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cargo fmt --all over the merged connect() call-sites (the video_caps/
launch args landed without a fmt pass). Comment-alignment only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The flatpak CI was failing at "Downloading sources" with "No space left
on device": flatpak-cargo-generator walks the whole workspace Cargo.lock
and emits a `type: git` source for the windows-rs crates (windows +
windows-reactor + ~12 sub-crates, pinned by punktfunk-client-windows),
and flatpak-builder then FULL-clones that multi-GB repo — for a bundle
that only ever compiles `-p punktfunk-client-linux` and never touches a
windows-* crate.
New packaging/flatpak/prune-windows-lock.py writes a copy of Cargo.lock
with the windows-rs git packages stripped (matches on the `source =`
line, so a crate that merely lists a windows dependency is kept;
dependency-free so it also runs on the Deck's stock python). Both the CI
and build-flatpak.sh feed that pruned lock to the generator. The
committed Cargo.lock is untouched — cargo --offline only needs vendored
sources for the crates it actually builds, and the windows-rs crates are
not in the Linux client's dependency closure.
Verified locally: 14 crates pruned (507 -> 493 packages), zero windows-rs
`source =` lines remain, output parses as TOML, all Linux-client deps
(gtk4/ffmpeg-sys-next/sdl3/pipewire) intact.
This unblocks the flatpak build carrying the VAAPI green-screen fix
(a89b19a) for the Steam Deck.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First AMD test (Steam Deck, Mesa radeonsi) showed a mostly-green image
with red whites — the classic fingerprint of NV12 chroma read as 0.
Root cause (confirmed against FFmpeg/GTK/mpv source): FFmpeg's VAAPI
export uses VA_EXPORT_SURFACE_SEPARATE_LAYERS unconditionally, so an
NV12 surface comes back as TWO single-plane layers — layers[0]=R8
(luma), layers[1]=GR88 (chroma) — sharing one object/fd, the UV plane
reached via offset. map_dmabuf took layers[0] only and used its format
(R8) as the GTK fourcc, so GdkDmabufTexture got a luma-only texture
with the chroma plane dropped → chroma defaults to 0 → green field,
red highlights.
Fix (matches mpv's dmabuf_interop_gl flatten pattern):
- Derive the combined fourcc from the decoder's sw_format
(AVHWFramesContext.sw_format → NV12 → DRM_FORMAT_NV12), NOT from the
per-plane component formats. The frame format is absent from the
separate-layer descriptor and must be deduced from sw_format.
- Flatten every plane across every layer in declared order (Y then UV),
each with its own fd (objects[plane.object_index].fd), offset, pitch.
- One-time descriptor dump (objects/layers/formats/modifier) so a new
driver's real layout is visible in the logs.
- Unit test locks the DRM FourCC magic numbers (NV12=0x3231564e).
Software decode (swscale, reads colorspace from the VUI) was always
correct, which isolated the bug to this path. PUNKTFUNK_DECODER=software
is the immediate workaround on an un-rebuilt binary. Awaiting Steam Deck
reconfirm (no AMD VAAPI on the NVIDIA dev box to live-verify).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Light up the dormant 10-bit/HDR path end to end on the Windows client.
- core: NativeClient::connect gains a video_caps param threaded into the Hello. The Windows
client advertises VIDEO_CAP_10BIT | VIDEO_CAP_HDR; every other caller (the C ABI shim,
Linux, Android, host test connects) passes 0, so the 8-bit BT.709 path is unchanged. The
host already gates a Main10/PQ encode on these bits + PUNKTFUNK_10BIT.
- video.rs: a PQ frame (color_trc == SMPTE2084) converts 10-bit YUV → X2BGR10 (== DXGI
R10G10B10A2) with the BT.2020 matrix via sws_setColorspaceDetails; swscale applies only
the matrix + range, so the PQ-encoded samples pass through untouched.
- present.rs: on an HDR frame the swapchain flips in place (ResizeBuffers) to R10G10B10A2 +
DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020 + HDR10 metadata; the passthrough shader is
unchanged and the compositor maps PQ→display. Switched to ALPHA_MODE_IGNORE so the 10-bit
padding bits don't render transparent. SDR stays 8-bit B8G8R8A8.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The first real run on a display surfaced three issues the headless/dev-VM build never hit:
- Route each hook-using screen (hosts/pair/stream) as its own component() instead of
calling it with the shared cx. Calling hooks on the parent cx changed the hook order
when the screen flipped, tripping reactor's Rules-of-Hooks guard and aborting the moment
you navigated to the stream page.
- Mouse: replace the absolute path (which swallowed WM_MOUSEMOVE and so froze the OS cursor,
snapping the host pointer back to one point) with proper pointer lock — hide + ClipCursor
+ recentre, shipping relative MouseMove scaled by the Contain-fit factor. Ctrl+Alt+Shift+Q
now actually toggles capture: track modifier state from the hook's own event stream
(GetAsyncKeyState doesn't see keys we suppress in our own LL hook), and flush held
keys/buttons on release so nothing sticks on the host.
- Add the stats HUD overlay (mode · fps · Mb/s · capture→client/decode latency), mirroring
the Apple client. Stats live in root state and reach the stream page as a prop (a child's
own async-state update is pruned when props are unchanged), fed by a small poll thread.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The secure-desktop DDA leg went black with HDR on: legacy DuplicateOutput (the SDR-era
API) can't capture an FP16/HDR desktop, and dropping the SudoVDA out of HDR is denied on
the Winlogon desktop (so the SDR-drop attempt just churned and stayed black).
Instead capture HDR natively on the DDA path — the capturer already has the full
FP16→BT.2020 PQ→R10G10B10A2 conversion (hdr_fp16 path), it just never requested FP16.
Thread a want_hdr flag into duplicate_output: for an HDR session request
DuplicateOutput1 with FP16 first and retry it (5×) instead of bailing to the
HDR-incapable legacy fallback. The secure-desktop mux now reads the monitor's real HDR
state and opens DDA in HDR when set — no advanced-color toggling at all. The
normal-desktop DDA overlay/flip issues that pushed us to WGC don't apply to the composed
Winlogon UI. want_hdr is threaded through every (re)duplication incl. ACCESS_LOST recovery.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Keep HDR OFF for the DDA (secure-desktop) path rather than bailing to WGC: the DDA
capturer is SDR-only (BGRA8), so an HDR SudoVDA makes the Winlogon capture black.
On the secure transition, drop the monitor out of HDR and VERIFY it took (re-read
advanced_color_enabled, retry up to 6×200ms) before opening DDA — the CCD toggle can
transiently fail (rc=5) or lag. Restore HDR on return to the WGC normal-desktop leg.
Logs clearly if the drop can't be applied (e.g. denied on the Winlogon desktop).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
HDR streamed nothing and "didn't persist" because build() forced the SudoVDA's
advanced-color state to match the handshake bit_depth on every build — with an
8-bit-negotiated session (the common case: clients advertise no 10-bit cap) that
meant set_advanced_color(false) on every connect, wiping a user's deliberate
Windows HDR toggle on the virtual display.
But the whole pipeline already follows the monitor's REAL HDR state: WGC captures
FP16 when HDR is on, NVENC forces Main10 + BT.2020 PQ from the 10-bit capture
format regardless of the negotiated depth (encode/nvenc.rs), and the client
auto-detects PQ from the HEVC VUI. So the negotiated bit_depth must NOT drive the
monitor's colorspace.
- build(): only ever ENABLE HDR (proactively, for a negotiated 10-bit session);
never force it off. A user-enabled HDR session now persists and flows end-to-end.
- secure-desktop mux: gate the HDR→SDR drop (for the DDA leg) on the monitor's
ACTUAL advanced-color state at switch time, not bit_depth — so an HDR session
with an 8-bit handshake still drops correctly for Winlogon and restores after.
- sudovda: add advanced_color_enabled() reader (DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Final cleanup after the DDA-parity work, plus an end-user service to replace the
PsExec/VBS/scheduled-task launch chain.
Cleanup (behavior-preserving):
- sudovda.rs: drop the dead legacy GDI isolate_displays/restore_displays (CCD is
the sole isolation path), the always-empty Monitor.isolated field, and the
vestigial reassert_isolation + PUNKTFUNK_ISOLATE_DISPLAYS knob; fix stale comments.
- dxgi.rs: downgrade leftover debug warns/infos (DuplicateOutput1 retry, FALLBACKS,
hook-hits, AcquireNextFrame idle timeout) to debug!; remove the PUNKTFUNK_NO_CURSOR
per-frame test knob.
Windows service (src/service.rs, `punktfunk-host service`):
- SCM supervisor (windows-service crate) that duplicates its LocalSystem token,
retargets it to the active console session, and CreateProcessAsUserW's the host
there (Sunshine/Apollo model) — relaunching on exit and console session switch,
inside a kill-on-close job object so a service crash never orphans the host.
- install/uninstall/start/stop/status subcommands: one elevated `service install`
registers an auto-start LocalSystem service + firewall rules + a default host.env.
- Config moves to %ProgramData%\punktfunk\host.env; config_dir() now resolves to
%ProgramData%\punktfunk on Windows (replacing the APPDATA=C:\Users\Public hack),
with a PUNKTFUNK_CONFIG_DIR override. Logs land in %ProgramData%\punktfunk\logs\.
- merged_env_block (shared with the WGC helper) now also carries RUST_LOG.
- docs/windows-service.md + scripts/windows/host.env.example; windows-host.md updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
User: tearing down + recreating the monitor per session is wrong both ways — a
fixed GUID collides on overlapping sessions, but a per-session GUID makes a new
screen on every reconnect; host-lifetime would leave a phantom display for
physical-screen users. Correct model = rock-solid state machine.
Replace the per-session create/REMOVE with a host-level reference-counted
manager (global MGR):
- States: Idle / Active{refs} / Lingering{until}.
- Connect (acquire): Idle→create; Lingering→reuse (cancel teardown, reconfigure
if the mode changed) — the quick-reconnect reuse, no new screen/PnP chime;
Active→refs++ (concurrent / Reconfigure-overlap), reconfigure on a mode change.
- Disconnect (release, via the MonitorLease keepalive Drop): refs-- ; at 0 →
Lingering(now + PUNKTFUNK_MONITOR_LINGER_MS, default 10s).
- Background timer: Lingering past its deadline → REMOVE the monitor → Idle, so a
physical screen returns ~10s after streaming stops.
Eliminates BOTH the cross-session REMOVE collision (teardown only at refs==0 +
expired grace) and the new-screen-on-reconnect, without a persistent phantom
display. The control-device handle is opened once (host-level) — a handle, not a
screen. SudoVdaDisplay is now a marker; the old create() body is create_monitor.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
User: re-adding WGC brought back the teardown/recreate bug (audible disconnect/
connect on the secure<->normal switch). Cause: the secure->normal switch called
build() = vd.create() = IOCTL_REMOVE old SudoVDA monitor + IOCTL_ADD new one +
respawn the helper — the same teardown/recreate kernel stress we just eliminated
from DDA, now on the mux path.
Apply the same learning (reuse, don't tear down): the SudoVDA monitor and WGC
helper persist for the whole session; only the host-DDA leg opens (on secure)
and closes (on normal). On returning to normal, RESUME the still-alive helper
(drain its secure-dwell backlog + request a keyframe) instead of rebuilding.
The HDR-session colorspace restore (set_advanced_color(true) + helper rebuild)
is kept ONLY for bit_depth>=10 — an SDR session never changed the colorspace, so
it needs no rebuild at all. The secure switch already reuses the monitor
(open_dda on the existing target).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Re-test still broken: the WGC helper captured HDR FP16 BT.2020 PQ from the FIRST
frame (before any switch), feeding the 8-bit SDR encoder → broken normal-desktop
image. Root cause: the SudoVDA's advanced-color (HDR) state PERSISTS on the
monitor across sessions, so the 8-bit session inherited HDR left enabled by the
earlier broken toggle — and gating the per-switch toggles can't undo a state
that's already on at start.
Fix: in build() (runs on initial create + every mode-switch/return-from-secure
rebuild), force set_advanced_color(target, bit_depth>=10) BEFORE spawning the
WGC helper, with a 250ms settle if it changed. An 8-bit session now always
captures SDR via WGC (matching the encoder); 10-bit keeps HDR.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Re-enabling the WGC relay brought back a broken image on the secure->normal
switch. Log root cause: on returning to the normal desktop the relay called
set_advanced_color(target, true) to 'restore HDR', so the rebuilt WGC helper
captured HDR FP16 BT.2020 PQ while the session encoder is 8-bit SDR -> format
mismatch (the 'HDR gets restored when flipping back to WGC' bug).
Gate BOTH set_advanced_color toggles on bit_depth>=10. An SDR (8-bit) session
now stays SDR across WGC<->DDA switches (no HDR force, no needless topology
change); HDR sessions keep the drop-on-secure / restore-on-normal behavior.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Remove 4 unused imports (PCWSTR in composed_flip, anyhow macro + SizeInt32 in
wgc, Write in wgc_relay).
- DuplicateOutput1 retry defaults to N=1 (immediate legacy): on the secure
desktop DuplicateOutput1 is LOGON_UI-only so it always refuses, and the
release-before-reduplicate + gentle recovery keep the legacy dup stable;
retrying there only blocked. Still env-tunable (PUNKTFUNK_DUP_RETRY_N/_MS).
- Throttle the 'using legacy DuplicateOutput' warning (expected + once-per-gentle-
recovery on secure) so a lock dwell doesn't flood the log.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
User's observation: entering UAC/lock works instantly, but clicking OUT of it
breaks (with the disconnect sound) — Apollo's enter and leave are symmetric.
Root cause: attach_input_desktop() (SetThreadDesktop to the current input
desktop) was gated behind is_secure_desktop() in recreate_dupl, so:
- Default->Winlogon (enter): is_secure==true -> re-attach to Winlogon -> works.
- Winlogon->Default (leave): is_secure==false -> SKIP re-attach -> the capture
thread stays stuck on the now-gone Winlogon desktop -> every rebuild fails ->
no frames -> client timeout -> session ends -> SudoVDA removed (the disconnect
sound).
Fix: call attach_input_desktop() UNCONDITIONALLY on every rebuild (Apollo calls
syncThreadDesktop before every duplicate), so leaving secure re-attaches to the
returned desktop. reassert_isolation stays secure-only. Also stop leaking the
HDESK (CloseDesktop right after SetThreadDesktop, like Apollo) so calling it on
every recovery is safe.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per the user's insight: on the secure (Winlogon) desktop the duplication dies on
every independent-flip, and our tight recovery loop tore it down + recreated it
hundreds of times/sec — that release/recreate cycle is the real kernel stress,
and it stalled the send thread long enough that the client timed out ('display
disconnected'). Normal-desktop streaming is already solid (per-session GUID
killed the collision); this only changes the loss-recovery cadence.
Gentle recovery (user chose 'keep session alive'):
- cap the cheap re-duplicate to PUNKTFUNK_RECOVER_MS (default 250ms, was 5ms)
- cap the heavy new-device rebuild to PUNKTFUNK_REBUILD_MS (default 1500ms, was
250ms) — it's the costliest teardown, throttled hardest
- repeat the last frame between attempts (no busy-spin, no 8ms sleep)
~200/s -> ~4/s teardown/recreate during a secure dwell. The session survives
lock/UAC (frozen/laggy secure screen, then clean resume on unlock) instead of
churning the kernel into a disconnect. Both cadences env-tunable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
User observed: 'display disconnected' + freeze with NO context change, and
'first switch happy, subsequent slower, then chaos under stress'. Log shows the
cause: MONITOR_GUID was a FIXED constant, so overlapping sessions (a client
RECONNECTING after a freeze before the old session tore down, or concurrent
sessions) all map to the SAME SudoVDA monitor (same GUID -> IOCTL_ADD reuses
target 257). When the old session ends, its IOCTL_REMOVE tears the monitor down
OUT FROM UNDER the live session -> 'display disconnected' + the late
E_INVALIDARG/MODE_CHANGE failures (output vanished mid-session) -> cascade.
Fix: next_monitor_guid() returns a unique GUID per (process, session) [base GUID
with low 48-bit node = pid<<16 | session#]; create() threads it into AddParams
AND the keepalive (which REMOVEs by it). Each session now owns its own monitor;
one ending can't kill another. (The 200ms DuplicateOutput1 retry confirmed
working — 'succeeded on retry' logged; the residual failures were this
collision, not the race.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The old-dup kernel teardown takes ~200ms (Apollo waits exactly that), so the
previous 2-16ms retries were too short and still fell through to the churning
legacy dup. Bump to PUNKTFUNK_DUP_RETRY_MS (default 200) x PUNKTFUNK_DUP_RETRY_N
(default 6) so the robust DuplicateOutput1 dup wins the race. Env-tunable for
on-box dialing without a rebuild.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>