The freeze on context change is the lock/login rendering on a PHYSICAL monitor
instead of the captured SudoVDA display. Root cause: the legacy isolate_displays
(EnumDisplayDevices + ChangeDisplaySettings) found NOTHING to detach on this hybrid
box (4090 + AMD iGPU) — an iGPU-attached monitor isn't flagged ATTACHED_TO_DESKTOP
in the GDI enum, so it's never detached and the secure desktop lands on it while the
virtual output freezes. (Log: isolate ran, logged zero "detaching" lines.)
Add CCD-based isolation (QueryDisplayConfig(QDC_ONLY_ACTIVE_PATHS) + SetDisplayConfig)
— the API Apollo uses, which sees every active path. Deactivate all active paths
except the SudoVDA target's, leaving the virtual display the sole desktop so ALL
content (incl. Winlogon) renders to it. Runs alongside the legacy pass (now a no-op
fallback); the original topology is saved and restored on teardown before REMOVE.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The one major capture-API difference left vs Apollo: punktfunk used legacy
IDXGIOutput1::DuplicateOutput; Apollo uses IDXGIOutput5::DuplicateOutput1 with a
format list, the modern path that's more robust to overlay/format changes (a
candidate for the SudoVDA-on-hybrid 0x887A0026 churn). Add a duplicate_output()
helper used at all 3 duplication sites (open, reopen_duplication, try_reduplicate):
QI to IDXGIOutput5 and DuplicateOutput1, falling back to legacy DuplicateOutput.
DuplicateOutput1 requires per-monitor-v2 DPI awareness, so set that at process
start alongside the GPU-pref hook (matches Apollo).
Format list is BGRA8-only for now (SDR test): DuplicateOutput1 returns the first
format it can CONVERT to, so FP16-first would hand back FP16 even on SDR and trip
the HDR path. Real FP16/HDR capture (with IDXGIOutput6 colorspace detection) is the
follow-up once the churn is settled.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The win32u hook only works if it patches before DXGI caches the hybrid preference.
It was installed in DuplCapturer::open (first capture), but the SudoVDA
render-adapter selection creates a DXGI factory during virtual-display setup —
seconds earlier — so the preference was already cached and the hook had no effect
(churn persisted; log showed "render adapter chosen" at :02, "hook installed" at
:04). Call install_gpu_pref_hook() at the top of real_main(), before any command
runs, so it beats the first DXGI factory. (open() still calls it too; Once makes
the earliest call win.) Also fix the cosmetic function-cast-as-integer warning.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause of the ACCESS_LOST (0x887A0026) churn + context-change freeze, found
live: the box is a HYBRID system (RTX 4090 + AMD Radeon iGPU + SudoVDA). DXGI does
hybrid GPU-preference resolution and REPARENTS the SudoVDA output between adapters
(SET_RENDER_ADAPTER is ignored — the IDD lands on the iGPU 0x23664 while we
duplicate on the 4090 0x15768), which constantly invalidates Desktop Duplication.
Apollo runs fine on this same box because it hooks this away.
Port Apollo's hook: replace win32u.dll!NtGdiDdDDIGetCachedHybridQueryValue to always
report D3DKMT_GPU_PREFERENCE_STATE_UNSPECIFIED, so DXGI skips preference resolution
and never reparents the output → DDA stays on one adapter. Installed once before the
first DXGI factory/enumeration (DuplCapturer::open). We fully replace the function
(never call the original) so a 12-byte absolute-jmp prologue patch suffices — no
detour crate / C length-disassembler dependency, just VirtualProtect.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
recreate_dupl called reassert_isolation (a display-TOPOLOGY change via
isolate_displays) + attach_input_desktop on EVERY ACCESS_LOST rebuild — 200×
in a 6 s SDR session. A topology change itself invalidates the freshly-rebuilt
duplication, so the next acquire is ACCESS_LOST → recreate → reassert → a
self-feeding 0x887A0026 churn that freezes the stream and never recovers across
context changes (lock / login / post-login).
Gate both behind is_secure_desktop(): the heavy topology work runs only on the
actual Winlogon (secure/login) desktop — where a physical monitor can grab the
secure desktop off our virtual output. Routine churn, the lock screen, and
post-login are all on the normal desktop, so they take a light re-duplicate with
no topology meddling. Apollo isolates once at startup; its recovery just
re-duplicates — this matches that.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two freeze drivers found live on the RTX box (DDA-only, 5K@240 HDR SudoVDA):
Step 1 — the per-frame format-change check (31be4f2) mis-fired EVERY frame in HDR
(827+/session): self.hdr_fp16 is derived from the duplication ModeDesc (FP16
scanout mode), but legacy DuplicateOutput always hands back 8-bit BGRA, so the
acquired-texture format never equals hdr_fp16 → a rebuild storm (each rebuild
re-inits device+NVENC → freeze). Make the acquire check SIZE-only; a real
HDR<->SDR toggle still arrives as ACCESS_LOST → recreate_dupl re-detects it.
Step 3 — ACCESS_LOST (0x887A0026) churn: HDR overlay/MPO flips invalidate the
duplication continuously and the recovery loop had no rate limit (the 250ms
throttle guarded only the full rebuild, not the cheap try_reduplicate), so it
spun DuplicateOutput + up-to-16ms Acquire and starved the encode thread. Add a
last_recover throttle capping ALL recovery attempts to ~one per 5ms; between
attempts return None so the caller repeats the last frame, paced at the frame
interval (no busy-spin, encode thread keeps running).
Real FP16 HDR capture (DuplicateOutput1) + per-loss desktop-reisolation cleanup
are the next steps; validate this in SDR first.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DDA only re-read the duplication format/size on rebuild (recreate_dupl) and
initial open. A mid-stream HDR<->SDR flip (FP16<->BGRA — e.g. the SudoVDA output
dropping out of HDR for the secure desktop) or a resolution change that does NOT
raise ACCESS_LOST left hdr_fp16/width/height stale, so present_acquired copied
into a mismatched-format/size target — the secure-desktop "works once, then HDR
breaks" symptom. Re-read the acquired texture's desc every frame (as Apollo does)
and rebuild on a real change instead of presenting a mismatched frame; throttled
like the ACCESS_LOST path so a flapping toggle can't hammer DuplicateOutput.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A single test flag to bring up / validate DDA on its own and as the base for the
secure-desktop work. When set it (1) skips WGC in capture_virtual_output (forces
dxgi::DuplCapturer, same as PUNKTFUNK_CAPTURE=dda) and (2) makes should_use_helper
return false, so even a SYSTEM host bypasses the two-process WGC relay and captures
in-process with one DDA capturer for both the normal AND the secure desktop —
Apollo's model. All the WGC / relay code stays compiled; unset the flag to restore.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When the DDA-on-secure path is enabled (PUNKTFUNK_SECURE_DDA=1), the mux now
toggles the SudoVDA's advanced-color (HDR) state via the CCD API
(sudovda::set_advanced_color → DisplayConfigSetDeviceInfo +
DISPLAYCONFIG_SET_ADVANCED_COLOR_STATE): on entering the secure (Winlogon)
desktop it disables HDR so the lock/UAC renders SDR/composed (no fullscreen
independent-flip → DDA can duplicate it instead of storming ACCESS_LOST/black),
opens DDA fresh on the now-SDR output; on returning to normal it re-enables HDR
and rebuilds the helper so WGC re-detects the restored colorspace.
Also debounce the DesktopWatcher (publish a Default↔Winlogon change only after it
is stable ~80ms) so transient flaps during the transition don't thrash the mux.
Default (no flag) is unchanged: WGC stays live through a lock, no DDA switch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Under infinite GOP the punktfunk/1 plane has no periodic IDR — the only recovery
keyframe is one the client requests. But the reassembler drops unrecoverable AUs
silently (frames_dropped) and hands the decoder reference-missing delta frames
that libavcodec conceals and returns Ok for, so keying recovery off a decode
error mostly never fires under real loss → a long/permanent freeze.
Surface the data-plane pump's Session.frames_dropped to NativeClient via a shared
atomic (NativeClient::frames_dropped()), updated every pump iteration so it stays
current through a total-loss drought. The Linux and Windows client video loops
watch it and call request_keyframe() when it climbs, throttled to 100 ms (the
decode stays wedged for several frames until the IDR lands). macOS already does
this; client-rs doesn't decode.
Resolves reliability backlog #2.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two steady-state faults previously bubbled a bare `?` to conn.close / silently
muted the rest of a session. Recover in place instead.
#4 — capture loss (virtual_stream): a mid-session capture stall/disconnect
(`try_latest` Err: PipeWire/compositor thread ended, virtual output gone) ended
the whole session — and the native client has no reconnect path, so it had to
cold-restart the handshake. Now rebuild the pipeline IN PLACE at the current
mode via build_pipeline_with_retry (same primitive the mode/session switch uses),
force a keyframe, and only propagate when the bounded retry is exhausted. A
consecutive-rebuild cap stops a flapping source from looping the client through
endless cold IDRs. Track the live mode so a rebuild after a mode switch targets
the right mode (also fixes the session-switch rebuild using the stale mode).
#3 — native audio thread (audio_thread): broke the loop on ANY next_chunk Err,
spawned once per session and never restarted, so a transient 5 s quiet-sink
timeout permanently muted a multi-hour session. Make a quiet sink return an empty
chunk (not an Err) in both backends so only a genuinely dead capture thread is an
Err, and reopen-with-backoff (INJECTOR_REOPEN_BACKOFF) on death, keeping the Opus
encoder + monotonic seq. Documents the next_chunk contract; also makes the
GameStream audio sender survive quiet sinks for free.
Resolves reliability backlog #3 and #4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Regression fix. The DDA-on-secure mux + force-composed overlay + rebuild-on-switch
made the stream worse than just staying on WGC: DDA can't reliably capture the
secure desktop's HDR independent-flip (storms ACCESS_LOST → instant black), and
rebuilding the output on every Default↔Winlogon flip thrashed (frequent freezes).
Meanwhile the WGC helper STAYS LIVE through a lock/UAC.
So make the DDA-on-secure path OPT-IN (PUNKTFUNK_SECURE_DDA=1, or the test
toggle). By default the mux keeps WGC the whole session — the DesktopWatcher and
the force-composed overlay aren't even started — so a lock/UAC no longer black-
screens or freezes the stream. The DDA-secure machinery stays in the tree for
future experimentation behind the flag.
(Reverts the rebuild-on-every-switch change 984ccf9 via 6fa8905; this gates the
remaining switch.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Key insight (from the user): a fresh RECONNECT shows the secure desktop but the
live transition does not — so the difference is what a fresh session does that
the live switch skipped. A reconnect runs build() = REMOVE + fresh ADD of the
SudoVDA monitor + re-isolate + a fresh capturer; the live transition instead
reused the session-start output (created while on the NORMAL desktop), which goes
born-lost (ACCESS_LOST storm → black) on the secure desktop.
Fix: virtual_stream_relay now calls build() on EVERY source switch (both WGC→DDA
and DDA→WGC), then opens DDA on the new target for secure / uses the fresh helper
for normal. This makes each transition equivalent to the reconnect that works —
fixing both the WGC→DDA cutover (secure desktop now in the clean output state DDA
can duplicate) and the DDA→WGC cutover (a fresh helper's first frame is its
opening IDR, so await_idr clears immediately instead of waiting on a wedged
helper). Costs a ~1-2s rebuild per transition, acceptable for UAC/lock events.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The secure (Winlogon: UAC/lock/login) desktop presents via fullscreen
independent-flip/MPO — it scans out bypassing DWM composition, so DXGI Desktop
Duplication returns born-lost DXGI_ERROR_ACCESS_LOST (the client sees black; the
UAC only "flashes" during the brief composed transition). Confirmed live: stable
4090 LUID across the storm (NOT reparenting) on an FP16 HDR output, recovering
only when the screen changes.
Fix (non-input, no system-wide registry change): capture/composed_flip.rs keeps a
tiny click-through near-invisible TOPMOST LAYERED window alive on the current
input desktop. Any visible window on the output disqualifies independent-flip →
DWM composites → DDA can capture. A dedicated thread follows the input desktop
(Default↔Winlogon) and recreates the window there on each switch (a window is
bound to its desktop), re-asserting topmost + pumping messages every 200ms.
Started for the two-process stream's lifetime; gated by PUNKTFUNK_FORCE_COMPOSED
(default on, =0 to disable). Needs GENERIC_ALL on OpenInputDesktop for
DESKTOP_CREATEWINDOW (0x80070005 otherwise). Validated: overlay creates on the
Default desktop; live lock test pending.
Also includes SET_RENDER_ADAPTER (sudovda.rs, Apollo item #16): pins the IDD
render GPU to the NVENC GPU before ADD — issued + accepted live, though the
secure-desktop storm was proven to be independent-flip (stable LUID), not
reparenting, so it's correctness/hygiene here rather than this bug's fix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The GameStream video sender did one send() syscall per packet on Windows
(the #[cfg(not(target_os="linux"))] sendmmsg_all fallback), capping
throughput at high packet rates. Wire it to UDP Send Offload (the Windows
analogue of Linux GSO) so each paced 16-packet burst goes out in one
WSASendMsg(UDP_SEND_MSG_SIZE) syscall instead of 16, preserving the
microburst pacing.
Expose a reusable punktfunk_core::transport::send_uso_all (Windows-only)
that reuses the proven native-plane USO primitive (send_one_uso + the uso
on/off latch + uso_unsupported), with the same uniform-size guard and
≤512-segment chunking as UdpTransport::send_gso. It returns how many leading
packets it sent via USO; the GameStream sendmmsg_all sends any remainder
(USO off via PUNKTFUNK_GSO=0, a size-mixed burst, or a frame's short final
packet) with per-packet send. On-wire packet boundaries are unchanged.
Resolves#4 in docs/apollo-comparison.md. Linux build unaffected;
punktfunk-core type-checks for x86_64-pc-windows-msvc. Host Windows compile
deferred to CI / dev box.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A single DXGI cursor shape can need BOTH an alpha-blended layer AND a
screen-inverting (XOR) layer at once — a masked-color text I-beam (opaque
hot-spot + inverting bar) or a monochrome cursor mixing opaque and invert
pixels. The old path produced ONE BGRA image per shape and picked ONE blend
(cursor_invert) for the whole shape, so such mixed cursors rendered wrong
(masked-color opaque pixels forced through the invert blend; monochrome
(AND=1,XOR=1) invert pixels approximated as solid black).
Port Apollo/Sunshine's decomposition: convert_pointer_shape now returns a
CursorShape with optional alpha/xor layers; CursorCompositor holds tex_alpha
+ tex_xor and draw_layer renders each with its own blend (alpha = src-over,
HDR-scaled; XOR = inversion, unscaled — it operates on the framebuffer
reference). The CPU software path blends both layers too. Empty layers are
never uploaded or drawn. Removes the single cursor_invert flag.
Fixes#13 in docs/apollo-comparison.md. Independently reviewed (ship);
Windows-only code — compile verified by CI / dev VM.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign every MSIX build with one STABLE self-signed cert instead of a fresh per-build cert, so the
Trusted People import is a one-time, per-machine step that survives upgrades (a fresh cert each build
forced a re-import every time). The cert (CN=unom, SHA-1 CD1EFDEE…E941, valid to 2036) lives in the
MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD Actions secrets; its public half is checked in as
packaging/punktfunk-codesign.cer and published next to each .msix.
pack-msix.ps1 now always exports the signing cert's public .cer (extracted from a supplied pfx too,
not just the ephemeral-generated path) and warns if the cert subject != manifest Publisher (the
mismatch Add-AppxPackage would otherwise reject). Documents the path to a publicly-trusted
(no-import) cert: swap the two secrets + pass a matching -Publisher.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause (confirmed live: "black until I pressed a key, then the image came
back"): the secure desktop (lock/login/UAC) is STATIC, and DXGI Desktop
Duplication only emits a frame on CHANGE. On the normal→secure switch the
duplication is rebuilt (recreate_dupl / try_reduplicate), and we then SEEDED A
BLACK frame as last_present — which the static secure desktop never replaced
(no change-frame) until the user pressed a key. So we streamed black.
Fix: after rebuilding the duplication, CAPTURE the current desktop frame instead
of seeding black. A freshly-created duplication's first AcquireNextFrame returns
the full current desktop; grab it and present it. New `present_acquired` factors
the frame-processing out of `acquire`; both recovery paths now call it:
- recreate_dupl: after adopting the new duplication, acquire+present the real
frame (born-lost ACCESS_LOST / no-initial-frame → seed black as fallback and
let the 250ms-throttled caller retry — a brief flash, then real content).
- try_reduplicate: adopt-first, then capture its probe frame (was discarded).
Also (independently-correct safe fixes, per the adversarial review):
- DesktopWatcher computes the current desktop synchronously in start() before
returning, so a session that begins on the secure desktop (reconnect to a
locked box) doesn't relay one stale normal-desktop frame (the "flash").
- DuplCapturer::open reasserts SudoVDA isolation at open time (mirrors
recreate_dupl) — forces the secure desktop back onto the virtual output if a
lock/UAC re-attached a physical monitor.
- Instrumentation: dbg_black_seeds counter + a throttled warn when black is
seeded, and an info when a real secure-desktop frame is captured on recovery.
Pending: the user's real-lock smoke test on the 4090 (a headless PsExec
LockWorkStation runs as SYSTEM and can't lock an interactive session, so this
must be validated with an actual lock).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The binary had no windows_subsystem attribute, so it linked as a console (CUI) app and Windows
opened a console window alongside the WinUI window on every launch (incl. the MSIX). Add
#![cfg_attr(windows, windows_subsystem = "windows")] so the windowed/MSIX launch is window-free
(verified: the built exe's PE subsystem flips from WINDOWS_CUI=3 to WINDOWS_GUI=2). To keep the CLI
paths usable, main now calls AttachConsole(ATTACH_PARENT_PROCESS) at startup — it binds to an
existing parent console only (never creates one), so --headless/--discover still print to the
launching terminal while Explorer/MSIX launches stay console-free. Adds the Win32_System_Console
windows feature.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Package the Windows client as a signed MSIX (Start tile, clean install/uninstall) and publish it to
Gitea's generic registry, mirroring the host's .deb/.rpm and the Mac's DMG. Validated end-to-end on
the build VM: cargo build --release -> makeappx pack (16 payload files, 58 MB) -> signtool ->
Add-AppxPackage deploy -> framework-dependency resolution all green.
- packaging/AppxManifest.xml: full-trust Win32 app (Windows.FullTrustApplication + runFullTrust),
templated {VERSION}/{PUBLISHER}. windows-reactor packages cleanly despite being built "unpackaged"
because it calls MddBootstrapInitialize2 with OnPackageIdentity_NOOP — under MSIX identity the
bootstrapper no-ops and the App SDK resolves from the manifest's PackageDependency on
Microsoft.WindowsAppRuntime.2 (reactor pins MAJORMINOR 0x20000 = 2.0).
- packaging/pack-msix.ps1: assemble layout (exe + reactor/SDL3 auto-staged DLLs + resources.pri +
FFmpeg DLLs + tile assets), makeappx, signtool. Cert precedence: MSIX_CERT_PFX_B64 secret, else an
ephemeral self-signed cert whose .cer is published alongside (swap in a real cert later, no
manifest change).
- assets: tile/store logos rasterized from packaging/flatpak/io.unom.Punktfunk.svg.
- .gitea/workflows/windows-msix.yml: runs on the Windows runner on main pushes + win-v* tags +
dispatch. MSIX version is 4-part numeric — win-vX.Y.Z -> X.Y.Z.0, else 0.2.<run>.0. shell: pwsh +
CARGO_TARGET_DIR=C:\t like windows.yml.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The injector reattached the input desktop (OpenInputDesktop + SetThreadDesktop,
two syscalls) before EVERY event. Now it stays bound to its desktop and only
reattaches on a SendInput short write (the input desktop switched into UAC/lock)
+ retries once — Sunshine's model. No steady-state per-event overhead; still
follows the desktop across the secure boundary, serving both desktops.
Validated on the RTX 4090 (host as SYSTEM): client-rs --input-test injected for
~6s with no "blocked desktop" errors. Completes all 6 steps of the two-process
secure-desktop build; only a real-UAC user smoke test remains.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A WGC-helper exit (crash, or a console disconnect killing its session) used to
end the stream. Now virtual_stream_relay rebuilds the output + helper and resumes
on the new helper's opening IDR. Rebuild — not respawn-on-the-old-target —
because an abruptly-killed helper leaves the SudoVDA's DXGI output briefly
unresolvable ("no DXGI output for target N yet"), and a console reconnect needs
a fresh output in the new session; `build` (the same path reconfigure uses)
recreates both. Bounded: 500ms backoff per attempt, give up after
MAX_HELPER_FAILS (20) consecutive failures; the counter resets on the first
relayed frame.
Live-validated on the RTX 4090 (host as SYSTEM): force-killed the helper PID
mid-stream → exactly one "WGC helper exited — rebuilt output + helper fails=1" →
the stream recovered and client-rs decoded 645 HEVC Main-10 frames continuously
across the kill (an earlier respawn-on-stale-target attempt storm-failed with
"no DXGI output", which the rebuild fixes).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
With the BOM fixed (shell: pwsh), the build got far enough to compile audiopus_sys, which
does a CMake-from-source build of libopus. The runner's host workdir sits deep under
C:\Windows\System32\config\systemprofile\.cache\act\<hash>\hostexecutor\, so target\debug\build\
audiopus_sys-*\out\build\CMakeFiles\CMakeScratch\TryCompile-*\...\.tlog overran Windows' 260-char
MAX_PATH and MSBuild's tracker failed to create its .tlog (DirectoryNotFoundException -> MSB6003,
"CL.exe konnte nicht ausgeführt werden"). Pointing CARGO_TARGET_DIR at C:\t shortens every nested
build path well under the limit (fixes audiopus_sys + SDL3, both CMake-from-source).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PUNKTFUNK_SECURE_TEST_PERIOD_MS=N drives a square-wave secure/normal toggle in
virtual_stream_relay (instead of the real DesktopWatcher), to exercise the
mid-session helper↔DDA mux without a live UAC/lock. Gated behind the env var,
in the style of PUNKTFUNK_VIDEO_DROP / PUNKTFUNK_FEC_PCT.
Live-validated on the RTX 4090 (host as SYSTEM): with a 4s toggle the mux
switched secure(DDA)↔normal(WGC relay) cleanly 5× in one session and the client
decoded 308 HEVC Main-10 frames continuously across every switch — the
wait-for-IDR latch held with no decode break. The real Winlogon DDA capture is
pre-proven by the single-process secure path (a08df8c); the toggle exercises the
new surface (the mux). Doc updated with the validation + the SYSTEM-mode audio
caveat.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Windows PowerShell 5.1's Out-File -Encoding utf8 prepends a UTF-8 BOM, corrupting the first
GITHUB_ENV line so CARGO_WORKSPACE_DIR silently never got set -> windows-reactor build.rs panic
-> CI build failed (runs 8765/8768). pwsh 7 writes UTF-8 without a BOM. Installed PowerShell 7.6.2
MSI on the runner and put C:\Program Files\PowerShell\7 on the daemon wrapper PATH so jobs find
pwsh; switched all windows.yml steps to shell: pwsh. (Reproduced locally with CARGO_WORKSPACE_DIR
set: the build is green in 2m37s — the BOM was the only issue.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CreateProcessAsUserW gives the spawned helper the *user's* environment block, so
the host's PUNKTFUNK_ENCODER=nvenc (and ZEROCOPY/PERF/…) were dropped and the
helper fell back to the software (H.264-only) encoder — the client negotiated
H265 → "WGC helper exited". `merged_env_block` now parses the user block, strips
any PUNKTFUNK_* it carried, overlays this (host) process's PUNKTFUNK_* vars, and
passes the merged UTF-16 block.
Validated live on the RTX 4090 (host as SYSTEM): the helper spawns via
CreateProcessAsUserW, runs WGC with no hang (HDR FP16 BT.2020 PQ), opens NVENC
(D3D11 Main10), and relays AUs over the pipe — client-rs decoded 411 HEVC
Main-10 frames over the LAN. Step 4 (spawn + relay) complete.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`virtual_stream_relay` now muxes the AU source by input desktop. A DesktopWatcher
(SYSTEM-only Winlogon-name poll) drives it: the user-session WGC helper relay
feeds the normal (Default) desktop; the host's OWN DDA capturer+encoder — opened
lazily on the first secure transition, on the same SudoVDA target with a no-op
keepalive (the host still holds the real isolation owner) — captures the secure
(Winlogon: UAC/lock/login) desktop that WGC can't see. Every switch latches
"wait for IDR" and forces the now-active source to emit a keyframe (the two
encoders keep independent infinite-GOP state, so the client must resume on an
IDR); returning to the helper also drains its stale buffered AUs first.
Reconfigure drops the stale-target DDA; keyframe requests route to the live
source. Send path (FEC/seal/paced-send) unchanged.
Also: wgc_relay gains try_recv (drain on switch-back); open_dda takes dims as
args (avoids a closure borrow of the reassigned cur_mode); the forward! macro
returns bool with `break 'outer` at the call site (no in-macro label hygiene).
cfg-gated windows-only. Live validation (UAC switch over a session) pending.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The SYSTEM host now sources the normal-desktop video from a user-session WGC
helper instead of capturing in-process (WGC won't activate as SYSTEM). New
`capture/wgc_relay.rs`: `HelperRelay::spawn` launches `m3-host wgc-helper` in the
interactive user session via CreateProcessAsUserW (WTSQueryUserToken →
DuplicateTokenEx(TokenPrimary) → lpDesktop="winsta0\\default", CREATE_NO_WINDOW)
with three anonymous pipes — stdout (framed Annex-B AUs → parsed back to
RelayAu), stdin (control: force-keyframe), stderr (helper logs → host tracing).
The host holds the SudoVDA keepalive (sole isolation/topology owner); the helper
captures by GDI name only.
m3.rs: `virtual_stream` dispatches to the new `virtual_stream_relay` when
`should_use_helper()` (running as SYSTEM, or PUNKTFUNK_FORCE_HELPER; disable with
PUNKTFUNK_NO_HELPER). The relay loop feeds the existing send thread — same
FEC/seal/paced-send path. Reconfigure rebuilds the output + re-spawns the helper;
keyframe requests forward over the control pipe; helper pts_ns (same-machine
monotonic clock) is used directly as capture_ns. Disconnect ends the stream
(step 6 adds the relaunch watchdog).
wgc_helper.rs: reads the stdin control byte to request an IDR; --bit-depth flag
threaded through so SDR 10-bit (Main10) negotiation reaches the helper's encoder.
cfg-gated windows-only; Linux/macOS build unaffected. Step 5 (DesktopWatcher mux
to host DDA on the Winlogon secure desktop) is next.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re-add the paths filter (the trigger was never the problem — the runner was registered at the
wrong scope, so org-repo runs found 'no fitting runner' despite the runner showing idle). Document
in setup-windows-runner.ps1 that the registration token must be GLOBAL (Site Administration ->
Actions -> Runners), like the Linux runner. CARGO_WORKSPACE_DIR is set via GITHUB_ENV in a step
(the job-env ${{ github.workspace }} form didn't resolve, leaving it unset -> reactor build.rs
panic).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`m3-host wgc-helper --target-id N --gdi NAME --mode WxHxHz --bitrate K`: the
USER-session half of the two-process secure-desktop design
(docs/windows-secure-desktop.md). Opens WGC on the EXISTING SudoVDA output by
GDI name only (never creates a virtual output — a second topology owner re-trips
the ACCESS_LOST born-lost storm), encodes via NVENC, and ships framed Annex-B
AUs on stdout for the SYSTEM host to relay onto the live QUIC session:
`[u32 magic "PFAU"][u32 len][u64 pts_ns][u8 keyframe][data]`. tracing → stderr so
stdout stays the pure AU stream. cfg-gated windows-only; Linux build unaffected.
scripts/headless/win-build.cmd: the canonical box build script (sets
PUNKTFUNK_BUILD_VERSION so build.rs stamps the version + the NVENC LIB path).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirror apple.yml's shape — drop the job-level env + defaults blocks; set CARGO_WORKSPACE_DIR
from $GITHUB_WORKSPACE in a step (Gitea can't resolve github.workspace at job-env-eval time)
and use per-step shell: powershell.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The paths filter wasn't dispatching the run on the newly-added workflow (the runner is healthy
and 'declare successfully', but received no task). Match apple.yml: trigger on every push to main
+ PRs. Also set NO_COLOR in the daemon wrapper so runner.log is plain text (the ANSI spinner
garbled it).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Polls the input-desktop name (OpenInputDesktop + GetUserObjectInformationW(UOI_NAME)) on its own
thread → Default/Winlogon atomic; the authoritative normal-vs-secure signal for the capture mux +
input path (WTS notifications miss UAC). Not yet wired into the mux (needs the SYSTEM host + WGC
helper, steps 3-5 in docs/windows-secure-desktop.md). NOTE: detecting the secure desktop requires the
host to run as SYSTEM (a user-token process can't OpenInputDesktop the Winlogon desktop).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
runs-on: windows-amd64 (home-windows-1, host mode). Build + clippy(-D warnings) + fmt + test the
WinUI 3 client. The toolchain is baked into the runner's daemon env; the workflow only sets
CARGO_WORKSPACE_DIR=${{ github.workspace }} (windows-reactor's build.rs needs it). Triggers on
changes to the windows crate / core / Cargo / this workflow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Validated design for adding secure-desktop (UAC/lock/login) coverage on top of the shipped WGC
animation fix. Key verified constraint: WGC won't activate under SYSTEM (0x80070424) even with
thread-level ImpersonateLoggedOnUser, and DDA+SendInput on Winlogon need LOCAL_SYSTEM — so one
process can't do both. Architecture: SYSTEM host (QUIC + SudoVDA + DDA-secure + SendInput + AU mux)
+ a USER-session WGC helper (CreateProcessAsUser) that relays encoded Annex-B AUs over a named pipe;
the host muxes helper-AUs (normal desktop) vs its own DDA encoder (secure desktop), switched by a
desktop-name watcher. No shared GPU texture (rejected — MIC/keyed-mutex pain); just AU bytes.
docs/windows-secure-desktop.md has the ordered, box-testable steps.
The impersonate_active_user() in wgc.rs is kept as a harmless no-op (under a user-token process
WTSQueryUserToken fails → no impersonation → WGC works natively); it does NOT make WGC work under
SYSTEM (the two-process design uses a real user process for WGC instead). + Win32_System_RemoteDesktop.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two fixes after live setup on home-windows-1: register from $RunnerHome (act_runner writes
.runner relative to CWD, so it must run there — it had landed in the SSH home and the daemon
couldn't find it), and run the daemon under cmd-level redirect (>> runner.log 2>&1) so its native
stderr stays out of PowerShell's error stream. Runner is live: windows-amd64:host, SYSTEM
scheduled task, "declare successfully" against git.unom.io.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PowerShell 5.1 reads .ps1 in the system code page; an em-dash inside a string literal misparsed
(its bytes look like a quote) and the non-ASCII username in the daemon wrapper would have been
mangled. Drop the em-dash and copy rustup toolchains to C:\Users\Public\.rustup so the wrapper
carries no non-ASCII path. Prep validated: act_runner 1.0.8 + Node 20 + config generated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Windows analogue of scripts/ci/setup-macos-runner.sh: downloads act_runner (gitea-runner)
in host mode, bumps Node 20 via nvm4w (actions/checkout@v4), registers against git.unom.io with
labels windows-amd64:host, and installs a SYSTEM scheduled task that keeps the daemon alive
across reboots. The daemon's env wrapper hard-codes this box's MSVC/WinUI toolchain (cargo/rustup,
NASM, CMake, LLVM, FFmpeg, the ASCII CARGO_HOME SDL3's PCH needs) so the Windows workflow inherits
a working toolchain. Idempotent; token (from org unom -> Settings -> Actions -> Runners) not
persisted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The capture-architecture reset from the research: add a Windows.Graphics.Capture (WGC) backend that
captures the COMPOSED desktop — including the overlay/independent-flip/MPO planes DXGI Desktop
Duplication misses — which structurally fixes the frozen HDR animations + video (proven live: a WGC
frame decodes to the real 5120x1440 HDR content DDA freezes on). It reuses the whole pipeline
unchanged: the WGC frame's GPU texture → same scRGB→BT.2020-PQ shader → NVENC zero-copy; the OS
composites the cursor (IsCursorCaptureEnabled) so no manual cursor pass. crates/punktfunk-host/src/
capture/wgc.rs; find_output/make_device/HdrConverter/nudge_cursor_onto made pub(crate) for reuse.
Reliability findings + mitigations (live on the RTX 4090):
- WGC can't activate under the SYSTEM account (0x80070424) — it needs the interactive user token. The
host must run as the user for WGC (run.cmd: drop PsExec -s). DDA still needs SYSTEM for the secure
desktop — that token reconciliation (impersonation) is the remaining task.
- WGC's Direct3D11CaptureFramePool::CreateFreeThreaded intermittently HANGS on the headless SudoVDA
(IddCx) display, correlated with accumulated SudoVDA churn (failed REMOVEs leaving lingering
displays); clean-state opens reliably. Since it's a blocking hang, capture_virtual_output runs WGC
open on a watchdog thread with a 5s timeout and falls back to DDA on hang/error — the session is
NEVER left black: WGC when it opens (fixed animations), DDA otherwise. First-frame nudge added (WGC
fires FrameArrived on change; a static desktop otherwise never delivers the first frame).
- Default WGC; PUNKTFUNK_CAPTURE=dda forces DDA. DDA path unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The first cut was a flat stack of buttons. Reworked the chrome to match the windows-reactor
gallery's look:
- Mica backdrop on the window.
- A centred, scrollable, max-width column (`page()` helper) instead of full-width sprawl.
- Card surfaces (`border` + `ThemeRef::CardBackground`/`CardStroke`, rounded, padded) grouping
content, with all-caps section labels.
- Host rows are clickable cards: name (semibold) + address + a PIN/Open/Paired badge + chevron,
laid out with a grid so the badge/chevron sit right; tap to connect.
- Header row with title + Settings button; a ProgressRing while searching / connecting; settings
as grouped "Stream" / "Audio" cards; the pairing screen is a centred card.
Pure styling/layout — no logic change. Build + clippy + fmt green on x86_64-pc-windows-msvc.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The winit-commit docs claimed "Reactor rejected, no SwapChainPanel hatch" — that was wrong.
windows-rs PR #4499 added the SwapChainPanel widget; the client now uses WinUI 3 via
windows-reactor. Update CLAUDE.md M4, the bootstrap-doc status banner (reactor integration:
pinned git dep, CARGO_WORKSPACE_DIR, App-SDK build.rs, LL-hook stream input), and the
docs-site clients page (WinUI 3, launch-and-pick-a-host).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
windows-reactor exposes no raw key-down/up or pointer-position/wheel events (only keyboard
accelerators + pointer button-state), so the WinUI 3 stream page captures input below XAML via
WH_KEYBOARD_LL / WH_MOUSE_LL, installed on the UI thread when the stream page mounts and removed
on unmount (held keys/buttons flushed). The SwapChainPanel fills the window, so the pointer maps
through the client rect (Contain-fit into the negotiated mode); keys carry the native Windows VK
directly (the wire contract — no table needed). While captured, events inside the video area are
swallowed so Alt+Tab/Win reach the host; Ctrl+Alt+Shift+Q toggles capture; clicks on the title
bar (outside the client rect) pass through. Mouse buttons (L/M/R/X1/X2), vertical + horizontal
wheel, and absolute motion all forwarded. Build + clippy + fmt green on x86_64-pc-windows-msvc.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces the winit + raw-HWND-D3D11 shell with a native WinUI 3 UI via windows-reactor (a
declarative React-like framework backed by WinUI). The earlier "Reactor can't host a
swapchain" read was wrong — PR #4499 (merged 2026-06-01) added a SwapChainPanel widget with
`set_swap_chain` over `CreateSwapChainForComposition`. Builds + clippy + fmt green on
x86_64-pc-windows-msvc.
- Cargo: drop winit/raw-window-handle; add windows-reactor + the `windows` crate, both pinned
to the SAME windows-rs commit (b4129fcc) so the `IDXGISwapChain1` handed to `set_swap_chain`
satisfies reactor's `windows_core::Interface`. Reactor's build.rs downloads the Windows App
SDK NuGets + stages the bootstrap DLL/resources.pri — it requires `CARGO_WORKSPACE_DIR` set
(now in the VM build env); /temp + /winmd gitignored.
- present.rs: composition swapchain (B8G8R8A8 FLIP_SEQUENTIAL premultiplied) bound to the
SwapChainPanel; WARP fallback, runtime D3DCompile shaders, dynamic RGBA texture, Contain-fit
letterbox; driven by reactor's per-frame `on_rendering`.
- app.rs: the WinUI 3 shell — host list (live mDNS + saved + manual), settings (resolution/
refresh/mic combos+toggle), in-app SPAKE2 PIN pairing screen, and the stream page. Trust gate
mirrors the GTK client (pinned → silent, pair=optional → TOFU, else PIN); a pinned-fp
mismatch routes to re-pair. The session pump + decoded-frame handoff cross to the UI thread
via a Mutex side-channel + thread-locals (the SwapChainPanel sample's pattern).
- gamepad: `ctl` sender now `Arc<Mutex<…>>` so GamepadService is `Sync` (shared across the UI
and session-pump threads). main.rs: windowed = in-app UI; `--headless`/`--discover` keep the
CLI paths.
Not yet wired: raw stream keyboard/mouse input (next commit — reactor exposes no raw key/
pointer events, so it needs Win32 low-level hooks or Microsoft.UI.Xaml bindings). On-glass
validation pending a display (the dev VM is headless/GPU-less).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the SDL3 gamepad service (near-verbatim port of the GTK client's — SDL3 is
cross-platform) and wires it into the winit app: per-session capture (buttons/axes,
DualSense touchpad + motion 0xCC), feedback (rumble, lightbar, raw DualSense effects),
single-pad-forwarded model with auto pad-type from the physical controller. Built from
source on Windows (no system SDL3).
- gamepad.rs: GamepadService (app-lifetime SDL thread) attach/detach on session
connect/end; auto_pref resolves "Automatic" to the attached pad's type.
- app.rs: hold the service, attach on Connected, detach on Ended/Failed/close. Also
simplify the keydown path (drop the identical if/else arms).
- main.rs: start the service for the windowed path, resolve GamepadPref from settings +
the physical pad.
Build gotcha documented + fixed in the dev loop: SDL3's build-from-source MSVC
precompiled-header chokes on the `ü` in the dev box's username embedded in the cargo
registry path (MSB8084/C4828) — CARGO_HOME must be an ASCII path
(C:\Users\Public\.cargo). Unrelated to our code.
Docs: CLAUDE.md M4 + docs/windows-client-bootstrap.md status banner (winit-not-Reactor
rationale, CARGO_HOME gotcha, what's pending) + docs-site clients.md "Windows desktop
client (in development)". Crate is build + clippy + fmt + test green on
x86_64-pc-windows-msvc.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Builds on the prior headless scaffold (which was committed but never VM-built — its
audio.rs had two non-compiling wasapi calls). This makes the whole crate build + clippy
+ fmt + test green on x86_64-pc-windows-msvc and adds the windowed client.
- Fix audio.rs: `DeviceEnumerator::new()?.get_default_device(...)` (the free fn doesn't
exist) and the 3-arg `write_to_device` (wasapi 0.23). WASAPI shared-mode event-driven
render + mic capture now compile and link.
- present.rs: D3D11 renderer with WARP fallback (GPU-less dev box), runtime-compiled
fullscreen-triangle shaders, dynamic RGBA video-texture upload, Contain-fit letterbox
draw, and a flip-model swapchain on the window HWND.
- app.rs: winit 0.30 ApplicationHandler — present loop + Moonlight-style click-to-capture
input (keyboard via the physical-KeyCode→VK keymap, absolute mouse, wheel, F11), held
state flushed on release/focus-loss.
- keymap.rs: winit physical KeyCode → Windows VK (layout-independent positional mapping,
the analogue of the Linux client's evdev table).
- main.rs: windowed default + `--headless` counting mode, `--discover` (mDNS list),
`--pair PIN` (SPAKE2 ceremony), `--pin HEX`/known-host/TOFU trust, settings-backed
CLI defaults.
UI decision: winit + raw D3D11 (the bootstrap doc's sanctioned fallback), confirmed by a
research pass — windows-rs "Reactor" ships no SwapChainPanel / SetSwapChain escape hatch,
so it can't host the presenter; winit+WARP validates on the GPU-less VM. Native-chrome
host-list/settings GUI + D3D11VA hardware decode + 10-bit/HDR present are follow-ups.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The macOS sessionView branch was missing the .ignoresSafeArea() its iOS/tvOS
siblings have, so in fullscreen the stream was laid out in the safe area below the
notch; the aspect-fit video then scaled down to that smaller area and left black
borders. Add .ignoresSafeArea() so the stream fills the whole display including
behind the camera housing (a thin top-center strip occluded — normal fullscreen-
video behavior); at the display's native mode it's now a 1:1 fill. Inert in
windowed mode and on non-notched displays. NSPrefersDisplaySafeAreaCompatibilityMode
is deliberately not used (it shrinks the whole window with borders on all sides).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GCDeviceHaptics.createEngine returns a CHHapticEngine (the only controller-rumble
API on Apple platforms); starting it spins up CoreHaptics, which looks up the
system audio-analytics daemon over Mach. The App Sandbox denies that global-name
lookup and the framework's precondition turns the denial into a hard crash
("Process is sandboxed but com.apple.security.exception.mach-lookup.global-name
doesn't contain com.apple.audioanalyticsd") the moment a controller's rumble
engine starts.
Add the documented, App-Store-acceptable temporary-exception whitelisting exactly
that one service. Verified embedded into the signed binary (codesign -d
--entitlements) alongside the existing entitlements. macOS-only (iOS/tvOS reject
temporary-exception keys and don't need it). App Store: declare it in App Sandbox
Entitlement Usage Information.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "broken animations in HDR" was an encode-throughput cliff, not the ACCESS_LOST churn. Measured at
5120x1440@240 HEVC Main10 on the RTX 4090: forced 2-way split-encode = 7.6 ms/frame (~131 fps, well
over the 4.17 ms/240fps budget → choppy), while SINGLE engine = 2.8-3.9 ms/frame (~256-357 fps, fits
240). The split/merge overhead dominates for 10-bit; a single Ada NVENC engine already handles 5K@240
Main10 comfortably. So the split decision now forces DISABLE for Main10 (bit_depth >= 10), keeping the
existing forced-2 only for 8-bit above 1 Gpix/s. PUNKTFUNK_SPLIT_ENCODE still overrides. Added a
split-mode log line.
Validated live on the 4090: encode_us_p50 7.6 ms → 3.9 ms at 5K240 HDR with no env override.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>