Commit Graph

488 Commits

Author SHA1 Message Date
enricobuehler af6787c0bd fix(host/windows): honor the SudoVDA's real HDR state (stop wiping the user's HDR toggle)
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>
2026-06-16 21:37:04 +00:00
enricobuehler aabf9fbc83 Merge remote-tracking branch 'origin/dda-parity'
apple / swift (push) Successful in 55s
windows-msix / package (push) Successful in 1m8s
audit / cargo-audit (push) Failing after 1m10s
windows / build (push) Successful in 1m5s
android / android (push) Successful in 3m33s
ci / web (push) Successful in 40s
ci / docs-site (push) Successful in 37s
ci / bench (push) Successful in 4m32s
ci / rust (push) Successful in 8m37s
deb / build-publish (push) Successful in 3m21s
decky / build-publish (push) Successful in 28s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 3m37s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m58s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 22s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m31s
flatpak / build-publish (push) Failing after 3m2s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 7m2s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 4m22s
docker / deploy-docs (push) Successful in 21s
2026-06-16 18:55:52 +00:00
enricobuehler 0ce2e37faf refactor(host/windows): clean up DDA path + add a proper Windows service
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>
2026-06-16 18:44:15 +00:00
enricobuehler 6d611cf889 feat(host/windows): reference-counted SudoVDA monitor lifecycle (reuse on quick reconnect, teardown when idle)
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>
2026-06-16 17:53:21 +00:00
enricobuehler ca375c7ce8 fix(host/windows): WGC mux — reuse the SudoVDA monitor + helper across secure switches (no teardown/recreate)
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>
2026-06-16 17:27:50 +00:00
enricobuehler e8d885fb4f fix(host/windows): WGC relay — set SudoVDA color to match session bit depth at build (kill persisted HDR)
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>
2026-06-16 17:18:41 +00:00
enricobuehler d2e536d299 fix(host/windows): WGC relay — don't force HDR on SDR sessions across the secure mux
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>
2026-06-16 17:13:02 +00:00
enricobuehler f469dfcc76 chore(host/windows): clean up DDA capture — fix unused imports, quiet secure-desktop log, sane retry default
- 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>
2026-06-16 17:05:02 +00:00
enricobuehler dc734c711b fix(host/windows): re-sync thread desktop on EVERY recovery (symmetric enter/leave secure)
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>
2026-06-16 16:57:20 +00:00
enricobuehler 9a9214a2d8 fix(host/windows): gentle DDA recovery — stop the tight teardown/recreate loop
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>
2026-06-16 16:41:03 +00:00
enricobuehler 2f7c021cac fix(host/windows): per-session SudoVDA monitor GUID (stop overlapping-session monitor teardown)
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>
2026-06-16 16:20:26 +00:00
enricobuehler ce84861e3a fix(host/windows): DuplicateOutput1 retry wait 200ms (Apollo's value), env-tunable
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>
2026-06-16 16:07:52 +00:00
enricobuehler eb451d8bc6 fix(host/windows): retry DuplicateOutput1 to ride out the old-dup teardown race
User's insight, and it fits the evidence exactly: in duplicate_output the FIRST
DuplicateOutput1 (called microseconds after the caller releases the old
duplication via self.dupl=None) returns E_ACCESSDENIED, but the legacy
DuplicateOutput a beat later SUCCEEDS — the only difference is TIMING. The
kernel-side teardown of the just-released duplication is async, so the immediate
DuplicateOutput1 races it ('output still duplicated' -> E_ACCESSDENIED). We then
fell straight through to legacy DuplicateOutput, which 'succeeds' into a FRAGILE
dup that churns ACCESS_LOST/MODE_CHANGE every few ms on this cross-GPU IDD
(causing the post-login freeze + UAC-confirm drop).

Fix: retry DuplicateOutput1 up to 5x with escalating 2/4/8/16 ms waits before
falling back to legacy, so the teardown finishes and the ROBUST DuplicateOutput1
dup succeeds (no churn). Bounded (~30 ms worst case) so a genuine failure still
falls back quickly. This is exactly Apollo's 2x/200ms retry rationale.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 16:02:22 +00:00
enricobuehler 1e1e5ce9b5 fix(host/windows): Option-handle the multi-line dupl.GetFramePointerShape call too
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:41:41 +00:00
enricobuehler da43b5e8d3 fix(host/windows): release the old duplication before re-duplicating (THE born-lost bug)
DuplicateOutput1 returned E_ACCESSDENIED ~8815x even with PER_MONITOR_AWARE_V2
confirmed on the capture thread (thread_is_v2=true) — so DPI was NOT the cause.
The real cause: DXGI permits only ONE IDXGIOutputDuplication per output, and on
ACCESS_LOST you MUST release the old one before re-duplicating. Our recovery
(try_reduplicate / recreate_dupl) created the NEW duplication while the OLD
self.dupl was still alive → the output stayed held → DuplicateOutput1
E_ACCESSDENIED and the legacy fallback returned a BORN-LOST dup. It never
converged because there was always exactly one stale dup alive at creation
time. The initial open() works precisely because there's no prior dup; Apollo
is clean because it releases (dup.reset()) before every re-DuplicateOutput.

Fix: make self.dupl an Option and set it to None (drop → release the output)
BEFORE duplicate_output in try_reduplicate and before reopen_duplication in
recreate_dupl, then Some(new). acquire() gets a None-guard that synthesizes
ACCESS_LOST (routes into recovery) so a transient None can't panic. All
ReleaseFrame/AcquireNextFrame sites updated for the Option.

This is the documented DDA recovery requirement and the one thing that
distinguished our failing DuplicateOutput1 from Apollo's working one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:40:50 +00:00
enricobuehler c8fb4822a2 fix(host/windows): per-thread Per-Monitor-V2 DPI awareness so DuplicateOutput1 succeeds
The remaining born-lost ACCESS_LOST storm traces to ONE thing: our
IDXGIOutput5::DuplicateOutput1 returns E_ACCESSDENIED (0x80070005) ~4370x, so
we fall back to legacy DuplicateOutput, which yields a BORN-LOST duplication on
this hybrid box. Apollo's DuplicateOutput1 SUCCEEDS on the identical
desktop/output/4090-device → a working dup, clean capture.

Root cause: DuplicateOutput1 REQUIRES Per-Monitor-Aware-V2. At startup our
SetProcessDpiAwarenessContext(PER_MONITOR_AWARE_V2) FAILS with E_ACCESSDENIED
('already set' — a manifest/runtime locked the process to a lower awareness),
and GetAwarenessFromDpiAwarenessContext reports 2 for BOTH Per-Monitor V1 and
V2, so the earlier 'awareness=2' was misleading — the process is likely V1,
which DuplicateOutput1 rejects with E_ACCESSDENIED. (Legacy DuplicateOutput has
no V2 requirement, so it 'worked' but born-lost.)

Fix: SetThreadDpiAwarenessContext(PER_MONITOR_AWARE_V2) on the capture thread
in open() — a per-thread override that takes regardless of the process default,
so DuplicateOutput1 can succeed (the working dup Apollo gets). Logs set_ok +
thread_is_v2 (via AreDpiAwarenessContextsEqual) to confirm V2 actually applied.
Topology fixes (sole display, no MODE_CHANGE) and the recovery backstops stay.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:29:17 +00:00
enricobuehler c60a05dbe9 fix(host/windows): make SudoVDA the sole display via clean CCD (the IDD needs to be primary/composited)
Live result of the previous build: the MODE_CHANGE_IN_PROGRESS storm was FIXED
(0 occurrences) by dropping primary-promotion — but it exposed the regression
the review predicted: a non-primary EXTENDED SudoVDA is NOT DWM-composited on
this box, so DDA gets born-lost ACCESS_LOST (0x887a0026) + black frames. The
IDD genuinely must be the sole/primary/composited display here.

Apollo reaches that end state ('Virtual Desktop: 5120x1440', sole display) via
Windows AUTO-promoting the real WDDM display over the box's leftover 1024x768
basic display — but Windows does NOT auto-promote for us, leaving the IDD
extended. So make it sole explicitly, the clean way:
- create(): deactivate the other display(s) via the atomic CCD path
  (isolate_displays_ccd) by DEFAULT (opt out with PUNKTFUNK_NO_ISOLATE). Drop
  the legacy per-device GDI detach from the path (it misses iGPU-attached
  monitors and churns; kept #[allow(dead_code)] for reference).
- set_active_mode(): CDS_UPDATEREGISTRY only — set the mode in place, NO
  CDS_SET_PRIMARY / CDS_GLOBAL / DM_POSITION. A sole display is already primary,
  so there's nothing to contest → no MODE_CHANGE storm (that storm came from
  promoting primary at (0,0) WHILE the basic display was still active).

Net: sole SudoVDA → primary → composited → capturable, with no topology
contest. Keeps the prior MODE_CHANGE-as-transient handling + removed born-lost
escape as backstops.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:12:31 +00:00
enricobuehler 769fd96b87 fix(host/windows): stop SudoVDA MODE_CHANGE_IN_PROGRESS storm — don't force IDD primary by default
ROOT CAUSE (verified by multi-agent compare vs Apollo + adversarial review):
set_active_mode() applied the SudoVDA mode with CDS_UPDATEREGISTRY | CDS_GLOBAL
| CDS_SET_PRIMARY + DM_POSITION(0,0) — promoting the freshly-added IDD to
PRIMARY at the virtual-screen origin and persisting it globally. On this box
(baseline active display = a 1024x768 basic 'WinDisc') that primary-promotion
contests the existing display so the desktop topology never reaches a stable
fixed point → every DuplicateOutput/AcquireNextFrame during the unending
settle returns DXGI_ERROR_MODE_CHANGE_IN_PROGRESS (0x887A0025). Apollo, live
on this EXACT box with an empty config, never promotes primary and captures
the same SudoVDA at 5120x1440 with zero DXGI errors. (Ruled out earlier on the
live box: win32u hook, DPI, independent-flip/overlay, isolation, render pin.)

Fixes (subtractive, gated per adversarial review):
- sudovda.rs set_active_mode: default to CDS_UPDATEREGISTRY only (no primary
  promotion, no GLOBAL, no DM_POSITION) = Apollo-parity for the multi-display
  default. Promote to primary (CDS_GLOBAL|CDS_SET_PRIMARY+DM_POSITION) ONLY
  when PUNKTFUNK_ISOLATE_DISPLAYS=1 (sole display, where a blank extended IDD
  would otherwise yield no frames). Avoids regressing headless/isolated +
  mid-stream Reconfigure.
- dxgi.rs acquire: treat MODE_CHANGE_IN_PROGRESS (0x887A0025) as a TRANSIENT
  (Ok(None), repeat last frame, wait it out) instead of falling through to the
  fatal Err arm → cold-rebuild → create()→set_active_mode (which re-issued the
  mode change and amplified the storm).
- dxgi.rs acquire: remove the born-lost cold-rebuild escape — it re-created the
  SudoVDA (IOCTL REMOVE/ADD = the audible PnP chime the user heard) and never
  converged; now repeat last frame in-process (never tear the IDD down mid-
  session, like Apollo). Overlay + cheap-spin/HDR recovery left intact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 14:59:42 +00:00
enricobuehler 900089c44c fix(host/windows): don't pin SudoVDA render adapter by default (Apollo parity)
GROUND TRUTH from Apollo streaming live on this exact box (empty config):
captures the SudoVDA at 5120x1440@240 on the RTX 4090 with ZERO ACCESS_LOST /
born-lost / MODE_CHANGE -- clean, no overlay, no isolation, no render pin. That
disproves the independent-flip theory (a sole SudoVDA captures fine here) and
points at something WE do that Apollo doesn't.

The concrete culprit: we call SET_RENDER_ADAPTER, which this driver IGNORES
(logs 'render adapter DIFFERS from pinned add=0x23664 pinned=0x15768') and the
IDD ends up rendering on adapter 0x23664 while its DXGI output is enumerated
under the 4090 (0x15768) where we create the capture device -- a cross-GPU
mismatch that is the real source of the perpetual ACCESS_LOST +
MODE_CHANGE_IN_PROGRESS (0x887A0025) storm. Apollo never pins (empty config),
so its IDD stays on its natural adapter, aligned with capture.

Make the render pin OPT-IN (PUNKTFUNK_RENDER_ADAPTER=<name>); default to NOT
pinning, matching Apollo. The startup log now shows the resulting AddOut LUID
so we can confirm the IDD lands on the 4090.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 14:37:31 +00:00
enricobuehler cd72164db2 fix(host/windows): keep multi-display (Apollo parity) instead of sole-display isolation
CONFIRMED on the live RTX4090+iGPU box: hook fires+verified, DPI=2, overlay
running, yet the stream STILL freezes -- born-lost dropped but MODE_CHANGE_IN_
PROGRESS (0x887A0025) churn took over (2284x) and frames go stale. Root cause
is the topology itself: create() makes SudoVDA the SOLE active display
(CDS_SET_PRIMARY + isolate_displays + isolate_displays_ccd), and a sole display
on a hybrid box goes into fullscreen independent-flip / MPO that Desktop
Duplication cannot capture.

Apollo is rock solid on this EXACT box because it does the opposite: it keeps
the physical monitor ACTIVE and arranges the virtual display alongside it
(rearrangeVirtualDisplayForLowerRight, 'Do not change the primary'). Multi-
display is DWM-composited, so the output never independent-flips.

Make isolation OPT-IN (PUNKTFUNK_ISOLATE_DISPLAYS=1) and default to NOT
isolating -- match Apollo's multi-display topology. SudoVDA stays primary (so
it carries the shell -> frames) but other monitors stay active, which disables
independent-flip. reassert_isolation honors the same flag (re-isolating mid-
stream would itself trigger the storm). Keeps the overlay + born-lost escape
as belt-and-suspenders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 14:23:20 +00:00
enricobuehler 1bcb786382 fix(android): request NEARBY_WIFI_DEVICES at runtime so mDNS discovery works on real devices
apple / swift (push) Successful in 53s
ci / web (push) Successful in 30s
ci / docs-site (push) Successful in 38s
android / android (push) Successful in 3m23s
deb / build-publish (push) Successful in 2m4s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m31s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m8s
docker / deploy-docs (push) Successful in 5s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 7m58s
ci / rust (push) Successful in 4m4s
NsdManager service discovery needs NEARBY_WIFI_DEVICES on Android 13+. The app DECLARED it but
never REQUESTED it, so on a real device the permission stayed denied and discoverServices silently
found nothing — no prompt, no hosts. (It only worked on the emulator because the permission was
granted via `adb pm grant`.) Request it (mirroring the mic RECORD_AUDIO flow) when the connect
screen appears, and start/restart discovery once granted; on API < 33 discovery starts immediately
(the permission doesn't apply there). The advertised hosts the Apple clients already see will then
appear here too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:14:57 +02:00
enricobuehler 5f84c5785c fix(host/windows): force-composed-flip overlay in the single-process DDA path
CONFIRMED root cause via instrumented build: hook_hits=1+ (win32u hook fires,
verified-patched) and DPI awareness=2 (PER_MONITOR), yet the born-lost
ACCESS_LOST storm persists with 100% DuplicateOutput1 E_ACCESSDENIED. That
rules out reparenting (the hook works) and DPI -> it is fullscreen
independent-flip / MPO: the SudoVDA virtual display, isolated as the SOLE
active output, scans out one plane on one display, bypassing DWM composition,
so Desktop Duplication gets a born-lost duplication.

Apollo never hits this because it runs WITH a physical monitor attached
(multi-display is already DWM-composited); we isolate to sole-display, so we
must force composition ourselves. The fix already existed (ForceComposedFlip,
a tiny topmost layered overlay that disqualifies independent-flip) but was
only wired into the WGC relay path's secure branch, which PUNKTFUNK_NO_WGC=1
disables. Wire it into virtual_stream unconditionally (DDA owns the normal
desktop here, where the storm is). Held for the session; Drop tears it down;
PUNKTFUNK_FORCE_COMPOSED=0 disables.

Keeps the prior build's born-lost escape as a safety net.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 14:08:59 +00:00
enricobuehler 0c1afeefea fix(android): shrink the colored launcher-icon foreground to match the themed layer
apple / swift (push) Successful in 54s
ci / rust (push) Failing after 40s
ci / web (push) Successful in 27s
ci / docs-site (push) Successful in 28s
android / android (push) Successful in 5m41s
ci / bench (push) Successful in 4m28s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
deb / build-publish (push) Successful in 2m8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m27s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m38s
docker / deploy-docs (push) Successful in 18s
On the test phone's launcher the standard (colored) adaptive foreground rendered noticeably larger
than the themed (monochrome) layer — identical geometry, but the launcher insets/scales the two
differently — so the colored circles overflowed the circle mask. Shrink only the foreground group
(scale 0.105 → 0.073, re-centred) to match the correctly-sized monochrome; the monochrome layer is
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:08:26 +02:00
enricobuehler 63b63a4010 fix(host/windows): instrument + harden DDA against the born-lost ACCESS_LOST storm
The hybrid RTX4090+iGPU box storms DXGI_ERROR_ACCESS_LOST (0x887A0026) +
MODE_CHANGE_IN_PROGRESS (0x887A0025) ~3s after first frame: every rebuilt
duplication is born-lost (created OK, first AcquireNextFrame instantly
ACCESS_LOST), seeds black, retries forever. The steady-state m3 loop calls
try_latest()->acquire() which returns Ok(None) on every recovery, so the
cold-rebuild escape (MAX_CAPTURE_REBUILDS) was unreachable -> frozen stream.

Multi-agent root-cause + adversarial review point at the win32u GPU-pref hook
being ineffective (patched on the main thread, no FlushInstructionCache, never
verified) rather than the synthesis's independent-flip theory (Apollo has no
overlay yet is stable on this exact box).

This build instruments + applies the safe, high-probability fixes:
- Hook: FlushInstructionCache after the inline patch (cross-thread i-cache);
  read back the 12 patched bytes and error! if they didn't land; per-call hit
  counter (hybrid_hook_hits) logged after open -- hits==0 proves the hook is
  off DXGI's reparent path.
- DPI: log SetProcessDpiAwarenessContext result + effective awareness (need
  2=PER_MONITOR for DuplicateOutput1; explains the 100% E_ACCESSDENIED).
- SetThreadExecutionState(ES_CONTINUOUS|ES_DISPLAY_REQUIRED|ES_SYSTEM_REQUIRED)
  at capture open, restored on Drop -- stop IDD idle-invalidation (Apollo does
  this too).
- Born-lost escape: count consecutive born-lost rebuilds; on the NORMAL desktop
  (never the secure/Winlogon dwell) escalate to Err after ~5s so the m3 loop
  cold-rebuilds the whole pipeline instead of freezing on the last frame.

Diagnostic-forward: one test now tells us hook-hits + DPI awareness + whether
ExecutionState/desktop-sync alone fixes it, and the stream self-recovers
instead of wedging.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 14:02:55 +00:00
enricobuehler 18ec32d21e feat(android): adaptive launcher icon with Material You themed-icon support
apple / swift (push) Successful in 53s
ci / rust (push) Failing after 1m36s
ci / web (push) Successful in 36s
ci / docs-site (push) Successful in 36s
deb / build-publish (push) Successful in 2m4s
decky / build-publish (push) Successful in 10s
android / android (push) Successful in 3m14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
ci / bench (push) Successful in 4m46s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m16s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 7m46s
docker / deploy-docs (push) Successful in 19s
Replace the placeholder system icon with the Punktfunk brand mark (two overlapping violet circles,
from the shared logo in clients/apple/.../punktfunk_Logo.icon).

- drawable/ic_launcher_foreground.xml: the violet logo (3 exact paths) scaled + centered into the
  108dp adaptive-icon safe zone via a group transform.
- drawable/ic_launcher_monochrome.xml: single-tone silhouette for Android 13+ themed icons
  (Material You) — the launcher recolors it to the wallpaper.
- mipmap-anydpi-v26/ic_launcher{,_round}.xml: adaptive-icon (background + foreground + monochrome);
  dark-indigo background (@color/ic_launcher_background) so the violet pops.
- Manifest: android:icon=@mipmap/ic_launcher + roundIcon (was @android:drawable/sym_def_app_icon).

minSdk 31 → anydpi-v26 covers every device (no legacy PNG mipmaps needed). Verified on a physical
phone (Android 16): the icon renders centered + circle-masked; the themed-icon layer is wired.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:02:49 +02:00
enricobuehler 60bb9727d6 fix(host/windows): correct SetDisplayConfig slice signature + local DISPLAYCONFIG_PATH_ACTIVE
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 13:17:54 +00:00
enricobuehler 2ac1014e8e fix(host/windows): CCD-based display isolation (detach hybrid-attached monitors)
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>
2026-06-16 13:16:19 +00:00
enricobuehler 3237ca31cd feat(host/windows): capture via IDXGIOutput5::DuplicateOutput1 (Apollo's capture API)
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>
2026-06-16 13:00:37 +00:00
enricobuehler 7cfeddc770 fix(host/windows): install the GPU-preference hook at process start (before any DXGI)
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>
2026-06-16 12:39:50 +00:00
enricobuehler a01f8a2f58 feat(host/windows): port Apollo's win32u GPU-preference hook (fix hybrid-GPU DDA churn)
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>
2026-06-16 12:31:54 +00:00
enricobuehler 61fd75dc33 fix(host/windows): re-isolate/re-attach desktop ONLY on the secure desktop
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>
2026-06-16 12:07:16 +00:00
enricobuehler d11f2bf800 fix(host/windows): stop the DDA freeze — kill the HDR format-change storm + throttle ACCESS_LOST recovery
Two freeze drivers found live on the RTX box (DDA-only, 5K@240 HDR SudoVDA):

Step 1 — the per-frame format-change check (995db69) 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>
2026-06-16 11:54:23 +00:00
enricobuehler 995db69387 fix(host/windows): detect format/size change on the DDA acquire path
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>
2026-06-16 11:18:53 +00:00
enricobuehler 3d04ce92a1 feat(host/windows): PUNKTFUNK_NO_WGC — force single-process DDA everywhere
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>
2026-06-16 11:15:47 +00:00
enricobuehler 6ea52b0372 feat(host/windows): SDR-while-secure — drop SudoVDA out of HDR on Winlogon so DDA captures it
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>
2026-06-16 11:06:21 +00:00
enricobuehler be18797df8 feat(client): request a recovery keyframe on unrecoverable loss
apple / swift (push) Successful in 54s
windows-msix / package (push) Successful in 1m0s
windows / build (push) Successful in 54s
android / android (push) Successful in 2m30s
ci / web (push) Successful in 37s
ci / docs-site (push) Successful in 38s
ci / rust (push) Successful in 4m24s
deb / build-publish (push) Successful in 2m5s
decky / build-publish (push) Successful in 25s
ci / bench (push) Successful in 4m25s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 2m38s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m24s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 22s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m15s
flatpak / build-publish (push) Failing after 5m13s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 4m37s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 7m26s
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>
2026-06-16 11:04:42 +00:00
enricobuehler 55d5a4278f fix(host): self-heal capture loss + audio-thread death mid-session
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>
2026-06-16 11:04:42 +00:00
enricobuehler e8619c2362 fix(host/windows): keep WGC through the secure desktop by default (DDA-secure opt-in)
apple / swift (push) Successful in 56s
ci / rust (push) Failing after 1m32s
ci / web (push) Successful in 29s
android / android (push) Successful in 3m15s
ci / docs-site (push) Successful in 41s
deb / build-publish (push) Successful in 2m5s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m47s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m2s
docker / deploy-docs (push) Successful in 37s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m6s
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 3f191ba via 555ec2a; this gates the
remaining switch.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 10:47:48 +00:00
enricobuehler 555ec2a3b7 Revert "fix(host/windows): rebuild the output fresh on every WGC↔DDA source switch"
This reverts commit 3f191ba2ea.
2026-06-16 10:44:06 +00:00
enricobuehler 3f191ba2ea fix(host/windows): rebuild the output fresh on every WGC↔DDA source switch
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>
2026-06-16 10:40:55 +00:00
enricobuehler ef4786387e feat(host/windows): force-composed-flip overlay to capture the secure desktop
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>
2026-06-16 10:25:55 +00:00
enricobuehler 3e2888de26 docs(apollo): mark GSO #4 (GameStream Windows USO) done
windows-msix / package (push) Successful in 1m31s
android / android (push) Successful in 2m29s
windows / build (push) Successful in 1m3s
ci / web (push) Successful in 36s
apple / swift (push) Successful in 54s
ci / docs-site (push) Successful in 35s
ci / rust (push) Successful in 4m18s
deb / build-publish (push) Successful in 2m3s
decky / build-publish (push) Successful in 13s
ci / bench (push) Successful in 4m22s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 15s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 2m30s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m35s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 22s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m14s
flatpak / build-publish (push) Failing after 5m17s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 7m45s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 7m17s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 10:22:53 +00:00
enricobuehler 0324719b6e feat(host/windows): USO batched send for the GameStream video plane
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>
2026-06-16 10:21:33 +00:00
enricobuehler ba4e9a8672 docs(apollo): mark cursor #13 done, reclassify #21 as already-handled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
apple / swift (push) Successful in 54s
ci / rust (push) Failing after 1m21s
ci / web (push) Successful in 27s
ci / docs-site (push) Successful in 29s
android / android (push) Failing after 5m44s
ci / bench (push) Failing after 3m26s
decky / build-publish (push) Successful in 12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
deb / build-publish (push) Successful in 2m5s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m3s
docker / deploy-docs (push) Successful in 21s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 7m50s
#13 (two-pass alpha+XOR cursor) implemented in capture/dxgi.rs. #21
(composite moved cursor without a new desktop frame) is already handled:
DXGI returns S_OK for pointer-only updates so punktfunk recomposites in
present_acquired; the original premise (stutter via timeout) was incorrect.
Adds status banner + per-item resolution notes in Part 4 and Part 3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 09:49:20 +00:00
enricobuehler 6d7301ccf5 fix(windows): two-pass cursor compositing (alpha + XOR) in DXGI capture
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>
2026-06-16 09:48:34 +00:00
enricobuehler f44317fb33 feat(windows): stable code-signing cert for the MSIX (one-time per-machine trust)
apple / swift (push) Successful in 54s
windows-msix / package (push) Successful in 1m0s
windows / build (push) Successful in 55s
android / android (push) Failing after 56s
ci / web (push) Successful in 32s
ci / docs-site (push) Successful in 39s
ci / rust (push) Failing after 3m21s
deb / build-publish (push) Successful in 2m5s
decky / build-publish (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
ci / bench (push) Successful in 4m45s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 5m27s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 5m28s
docker / deploy-docs (push) Successful in 10s
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>
2026-06-16 09:17:30 +00:00
enricobuehler 7bf2899301 fix(host/windows): secure-desktop black screen — capture the real frame, don't seed black
apple / swift (push) Successful in 56s
android / android (push) Failing after 54s
ci / web (push) Successful in 39s
ci / docs-site (push) Successful in 31s
ci / rust (push) Failing after 2m15s
deb / build-publish (push) Successful in 2m4s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
ci / bench (push) Successful in 4m52s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 4m11s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 3m29s
docker / deploy-docs (push) Failing after 6s
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>
2026-06-16 09:15:33 +00:00
enricobuehler cbeece119f fix(windows): link the client as a GUI subsystem — no console window on launch
apple / swift (push) Successful in 56s
windows-msix / package (push) Successful in 1m0s
windows / build (push) Successful in 55s
ci / web (push) Successful in 33s
android / android (push) Failing after 57s
ci / docs-site (push) Successful in 39s
ci / rust (push) Failing after 3m24s
deb / build-publish (push) Successful in 2m7s
decky / build-publish (push) Successful in 10s
ci / bench (push) Successful in 4m37s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 14s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 2m32s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 4m8s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 3m34s
docker / deploy-docs (push) Failing after 17s
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>
2026-06-16 09:06:31 +00:00
enricobuehler 183ddd5fa1 docs: Apollo (Sunshine fork) vs punktfunk architecture map + transfer backlog
apple / swift (push) Successful in 54s
android / android (push) Failing after 36s
ci / web (push) Failing after 25s
ci / docs-site (push) Successful in 34s
ci / rust (push) Failing after 3m18s
ci / bench (push) Failing after 3m9s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Failing after 38s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m50s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m17s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 26s
docker / deploy-docs (push) Has been skipped
deb / build-publish (push) Successful in 7m46s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 5m2s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 6m39s
Map Apollo's architecture for future agents and compare against punktfunk,
with a deep-dive on the Windows host (the focus area). Produced by the
apollo-vs-punktfunk multi-agent workflow; every claim carries file:line into
both codebases.

Contents: Apollo architecture map + Apollo->punktfunk file index; subsystem
parity; a reference-grade Windows-host deep-dive (DXGI/WGC capture, cursor
compositing, HDR, NVENC-on-D3D11, SendInput/ViGEm, SudoVDA, SYSTEM/secure
desktop); and a prioritized 96-item improvement backlog (89 Windows-host,
24 high-severity). Top confirmed Windows gaps: GameStream TLS accepts any
client cert (verify_client_cert returns assertion()), no NVENC reference-frame
invalidation, SudoVDA watchdog ignores its ioctl result, absolute-mouse mapping
discards the virtual-desktop rect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 08:54:52 +00:00
enricobuehler bb11b2faf7 feat(windows): MSIX packaging + publish workflow for the WinUI client
apple / swift (push) Successful in 54s
ci / rust (push) Failing after 55s
windows-msix / package (push) Successful in 1m2s
ci / web (push) Successful in 31s
windows / build (push) Successful in 55s
ci / docs-site (push) Successful in 31s
android / android (push) Successful in 2m6s
deb / build-publish (push) Successful in 2m24s
decky / build-publish (push) Successful in 11s
ci / bench (push) Successful in 4m21s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 2m39s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m32s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m49s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 1m21s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 3m20s
docker / deploy-docs (push) Successful in 22s
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>
2026-06-16 08:45:43 +00:00