Compare commits

..
Author SHA1 Message Date
enricobuehler deef5e4382 fix(console): library source settings 404'd — the drawer still called the old plugin origin
ci / bun-nix (pull_request) Successful in 35s
ci / web (pull_request) Successful in 1m19s
ci / docs-site (pull_request) Successful in 1m18s
ci / rust-arm64 (pull_request) Successful in 3m22s
ci / rust (pull_request) Successful in 4m30s
Opening a library source's settings did nothing, for every library plugin. Confirmed on
`.21` against the running console:

    console origin :47992  /plugin-ui/lutris/__config -> 404
    plugin  origin :47993  /plugin-ui/lutris/__config -> 401

The drawer fetches a RELATIVE `/plugin-ui/<id>/__config`, so it resolves against the
console's own origin — where `middleware/auth.ts` answers 404 for `/plugin-ui/**`
unconditionally and by design. That refusal is the 2026-08-05 review's origin split
(H-3): plugin UIs moved to their own listener, and neither origin may serve the other's
paths. The drawer is the only consumer of `/plugin-ui` that is NOT an iframe — every
other caller builds an absolute URL from `pluginOriginFrom(uiConfig)` — so it was the
one thing the split broke, and nothing failed loudly enough to notice.

The fix is deliberately not to point the drawer at the plugin origin. That needs CORS
plus cross-site cookies, and it would put a plugin-controlled response inside a
credentialed cross-origin fetch — reopening exactly the hole the split closed. What
this drawer needs is DATA, not an embedded UI: `/api/plugin-config/<id>` reads the
plugin's `__config` server-side over loopback and returns the JSON same-origin, so no
plugin markup or script is ever served from the console origin and the per-boot secret
stays on the server, as with the `/plugin-ui` proxy.

`/api/**` is always session-gated (`isPublicPath`), so the new route inherits the gate
and answers 401 as JSON rather than redirecting to /login — which is what a `fetch`
needs and what the old path could never give it. It forwards only GET and PUT, reads
the body BEFORE the stale-credential retry (`readRawBody` drains the stream, so a
retried PUT would have saved `{}` over the operator's config), and passes the plugin's
own body through untouched so a 400's decode issue still reaches the operator.

Verified against the real built server: `/api/plugin-config/lutris` answers 401 — the
route resolves and is gated, and the BFF catch-all at `api/[...]` does not swallow it —
while `/plugin-ui/lutris/__config` still answers 404 on the console origin, i.e. the
split is intact. `/api/v1/status` still reaches the BFF. tsc clean, production build
clean, i18n 633 messages across en+de, biome clean on both touched files (the one
warning in SourceSettings.tsx pre-dates this change).
2026-08-08 14:17:58 +02:00
9 changed files with 163 additions and 621 deletions
+3 -24
View File
@@ -129,16 +129,6 @@ struct Args {
/// host must composite the metadata cursor on its own; decode the dump and look for the /// host must composite the metadata cursor on its own; decode the dump and look for the
/// pointer. /// pointer.
cursor_nochannel: bool, cursor_nochannel: bool,
/// `--cursor-hold` — with `--cursor-capture`/`--cursor-nochannel`, stop the relative wiggle
/// after a short priming burst instead of circling forever. The wiggle exists to keep a
/// damage-driven desktop publishing frames, but it also DRAGS the host pointer several hundred
/// pixels a second, which makes it impossible to hold the pointer over a chosen target — and
/// the shape under the pointer is the whole point when the question is "does the MONOCHROME
/// I-beam survive compositing?" (the arrow is a colour cursor and proves nothing about the
/// mono path). With this flag: prime for ~3 s so the pointer is un-suppressed and metadata is
/// flowing, then hold still so a `SetCursorPos` on the host can park it on a text field for
/// the rest of the dump.
cursor_hold: bool,
/// `--discover [SECS]` — browse the LAN for native (`_punktfunk._udp`) hosts for `SECS` /// `--discover [SECS]` — browse the LAN for native (`_punktfunk._udp`) hosts for `SECS`
/// seconds (default 4), print what's found, and exit. No connection is made. /// seconds (default 4), print what's found, and exit. No connection is made.
discover: Option<u64>, discover: Option<u64>,
@@ -319,7 +309,6 @@ fn parse_args() -> Args {
clock_resync: argv.iter().any(|a| a == "--clock-resync"), clock_resync: argv.iter().any(|a| a == "--clock-resync"),
cursor_capture: argv.iter().any(|a| a == "--cursor-capture"), cursor_capture: argv.iter().any(|a| a == "--cursor-capture"),
cursor_nochannel: argv.iter().any(|a| a == "--cursor-nochannel"), cursor_nochannel: argv.iter().any(|a| a == "--cursor-nochannel"),
cursor_hold: argv.iter().any(|a| a == "--cursor-hold"),
} }
} }
@@ -911,23 +900,13 @@ async fn session(args: Args) -> Result<()> {
} }
}); });
let wiggle_conn = conn.clone(); let wiggle_conn = conn.clone();
let hold = args.cursor_hold;
tokio::spawn(async move { tokio::spawn(async move {
// Relative circles: keeps the host pointer moving (and, on metadata-cursor // Relative circles, forever: keeps the host pointer moving (and, on metadata-cursor
// compositors, keeps cursor updates flowing) for the whole dump — unless // compositors, keeps cursor updates flowing) for the whole dump.
// `--cursor-hold`, which primes and then stops so the pointer can be parked.
tokio::time::sleep(std::time::Duration::from_secs(2)).await; tokio::time::sleep(std::time::Duration::from_secs(2)).await;
tracing::info!(hold, "cursor-capture: relative pointer wiggle running"); tracing::info!("cursor-capture: relative pointer wiggle running");
let prime_until = std::time::Instant::now() + std::time::Duration::from_secs(3);
let mut t = 0.0f64; let mut t = 0.0f64;
loop { loop {
if hold && std::time::Instant::now() >= prime_until {
tracing::info!(
"cursor-capture: wiggle primed and STOPPED (--cursor-hold) — the pointer \
now stays where the host puts it"
);
return;
}
let e = InputEvent { let e = InputEvent {
kind: InputKind::MouseMove, kind: InputKind::MouseMove,
_pad: [0; 3], _pad: [0; 3],
+54 -270
View File
@@ -212,47 +212,6 @@ struct KeyedMutexGuard<'a> {
/// (`frame_transport.rs`). /// (`frame_transport.rs`).
const WAIT_ABANDONED_HRESULT: i32 = 0x0000_0080; const WAIT_ABANDONED_HRESULT: i32 = 0x0000_0080;
/// First retry delay after a composite-blend failure — short enough that a transient device-loss
/// costs a few pointer-less frames rather than the rest of the session.
const BLEND_RETRY_MIN: Duration = Duration::from_millis(250);
/// Ceiling for the doubling retry: a genuinely broken device stops burning a frame-sized texture
/// allocation every quarter second, while still recovering within ~4 s if it ever comes back.
const BLEND_RETRY_MAX: Duration = Duration::from_secs(4);
/// How long the poller may publish NOTHING before the capturer calls it wedged. It polls at
/// `CursorPoller::INTERVAL` (4 ms), so this is ~250 missed publishes — far outside any scheduling
/// hiccup, and still fast enough to name the fault while a user is still looking at it.
const POLLER_STALL: Duration = Duration::from_secs(1);
/// The next retry delay after a composite-blend failure: [`BLEND_RETRY_MIN`] for the first, then
/// doubling per consecutive failure up to [`BLEND_RETRY_MAX`]. Free function so the escalation is
/// testable without a live D3D11 device (the `mono_planes_to_rgba` precedent — the arithmetic a
/// bug would hide in does not need the plumbing around it).
fn next_blend_backoff(prev: Option<Duration>) -> Duration {
prev.map_or(BLEND_RETRY_MIN, |b| (b * 2).min(BLEND_RETRY_MAX))
}
/// The composite-regen change key for an overlay: what a blend would DRAW — `(serial, x, y)` for a
/// visible pointer, `None` when nothing would be drawn. ONE definition, used by both the regen test
/// and the blend itself, because the two drifting apart is precisely the bug shape here: a key that
/// says "changed" while the drawn frame is identical re-encodes for nothing, and a key that says
/// "unchanged" while the pointer moved freezes it on screen.
fn blend_key_of(ov: Option<&pf_frame::CursorOverlay>) -> Option<(u64, i32, i32)> {
ov.filter(|o| o.visible).map(|o| (o.serial, o.x, o.y))
}
/// A composite-blend failure and its pending retry ([`IddPushCapturer::blend_fail`]).
struct BlendFail {
/// No blend is attempted before this instant.
retry_at: Instant,
/// The delay that produced `retry_at`; doubles per consecutive failure up to
/// [`BLEND_RETRY_MAX`].
backoff: Duration,
/// Consecutive failures without an intervening success — logged, so a session that is
/// permanently pointer-less is distinguishable from one that hiccupped once.
consecutive: u32,
}
impl<'a> KeyedMutexGuard<'a> { impl<'a> KeyedMutexGuard<'a> {
/// Acquire `mutex` at `key`, waiting up to `timeout_ms`. `None` if the acquire times out / errors /// Acquire `mutex` at `key`, waiting up to `timeout_ms`. `None` if the acquire times out / errors
/// (the caller skips the frame), so the guard is only ever held when the lock is genuinely held. /// (the caller skips the frame), so the guard is only ever held when the lock is genuinely held.
@@ -426,26 +385,13 @@ pub struct IddPushCapturer {
/// to a visible pointer is compositing here. Pins `composite_cursor` on — nothing may turn /// to a visible pointer is compositing here. Pins `composite_cursor` on — nothing may turn
/// it off (there is no channel to hand the pointer to). /// it off (there is no channel to hand the pointer to).
composite_forced: bool, composite_forced: bool,
/// The cursor-quad blend pass (lazy; per capture device). `None` before the first blend and /// The cursor-quad blend pass (lazy; per capture device). `None` after a build failure —
/// after a failure dropped it; rebuilt on the next attempt that is not suppressed. /// composite mode then degrades to pointer-less frames (warned once).
cursor_blend: Option<cursor_blend::CursorBlendPass>, cursor_blend: Option<cursor_blend::CursorBlendPass>,
/// Composite-blend failure state. `None` = healthy. A failure used to be TERMINAL — one warn, cursor_blend_failed: bool,
/// a sticky flag, and the session then streamed a pointer-less desktop for its whole life —
/// but the causes that actually occur (device loss, a transient allocation failure on the
/// frame-sized scratch) heal, and the pointer is the one thing a capture-model session cannot
/// do without. So a failure now only suppresses the blend until `retry_at`, doubling from
/// [`BLEND_RETRY_MIN`] to [`BLEND_RETRY_MAX`] while failures continue, and the first success
/// clears it.
blend_fail: Option<BlendFail>,
/// Sticky: [`Self::live_cursor`] has fallen back to the driver's shm section. The two sources /// Sticky: [`Self::live_cursor`] has fallen back to the driver's shm section. The two sources
/// keep independent serial namespaces, so once crossed we never go back (see there). /// keep independent serial namespaces, so once crossed we never go back (see there).
cursor_shm_latched: bool, cursor_shm_latched: bool,
/// Poller heartbeat watch: the last sampled publish count and when it last ADVANCED. A poller
/// that is `alive()` but wedged stops advancing it while never exiting — invisible before.
cursor_poll_watch: (u64, Instant),
/// Whether the wedged-poller warning has already been emitted for the CURRENT stall (cleared
/// when it resumes), so a permanently wedged poller warns once rather than every tick.
cursor_poll_stalled: bool,
/// The frame-sized blend scratch (slot copy + cursor quad): texture + SRV + (w, h, fmt) /// The frame-sized blend scratch (slot copy + cursor quad): texture + SRV + (w, h, fmt)
/// it was built for — rebuilt when the ring geometry changes. /// it was built for — rebuilt when the ring geometry changes.
blend_scratch: Option<( blend_scratch: Option<(
@@ -455,12 +401,10 @@ pub struct IddPushCapturer {
u32, u32,
DXGI_FORMAT, DXGI_FORMAT,
)>, )>,
/// What the LAST blend actually DREW — the composite-regen change key: pointer-only motion /// The (serial, x, y, visible) of the LAST blended pointer — the composite-regen change
/// produces no driver publish (the declared hardware cursor doesn't dirty frames), so /// key: pointer-only motion produces no driver publish (the declared hardware cursor
/// `try_consume` regenerates from the last slot when this changes. `None` = the frame carries /// doesn't dirty frames), so `try_consume` regenerates from the last slot when this moves.
/// no pointer (hidden or no shape yet), which is why a HIDDEN pointer's position is not part last_blend_key: Option<(u64, i32, i32, bool)>,
/// of the key — see [`Self::cursor_blend_key`].
last_blend_key: Option<(u64, i32, i32)>,
/// The ring slot of the last FRESH publish — the regen source. /// The ring slot of the last FRESH publish — the regen source.
last_slot: Option<usize>, last_slot: Option<usize>,
/// The target's SDR-white scale (vs 80 nits) for HDR cursor compositing — refreshed on /// The target's SDR-white scale (vs 80 nits) for HDR cursor compositing — refreshed on
@@ -1267,17 +1211,10 @@ impl IddPushCapturer {
/// poller meant pointer-less frames, not a degraded pointer. /// poller meant pointer-less frames, not a degraded pointer.
fn live_cursor(&mut self) -> Option<pf_frame::CursorOverlay> { fn live_cursor(&mut self) -> Option<pf_frame::CursorOverlay> {
if !self.cursor_shm_latched { if !self.cursor_shm_latched {
// Sample the heartbeat and the snapshot together, then drop the borrow so the watch if let Some(p) = &self.cursor_poll {
// can take `&mut self`. `alive()` is liveness only — `watch_cursor_publishes` is what if p.alive() {
// tells a working poller apart from a wedged one. return p.read();
let sampled = self }
.cursor_poll
.as_ref()
.filter(|p| p.alive())
.map(|p| (p.publishes(), p.read()));
if let Some((n, overlay)) = sampled {
self.watch_cursor_publishes(n);
return overlay;
} }
// The poller is gone (or never started) and we are about to read the shm — latch, so a // The poller is gone (or never started) and we are about to read the shm — latch, so a
// poller that somehow reports alive again cannot re-cross the serial namespaces. // poller that somehow reports alive again cannot re-cross the serial namespaces.
@@ -1318,91 +1255,17 @@ impl IddPushCapturer {
); );
} }
/// Watch the GDI poller's heartbeat and log the transitions. The poller is the ONLY /// The (serial, x, y, visible) of the CURRENT live cursor — the composite-regen change key.
/// full-fidelity shape source (the driver's query is alpha-only — `cursor_poll.rs`), so a /// `None` while no source has a shape yet.
/// poller that is alive but no longer publishing freezes the pointer in every frame at its fn cursor_blend_key(&mut self) -> Option<(u64, i32, i32, bool)> {
/// last sampled shape and position. That state used to be completely silent: `alive()` stays self.live_cursor().map(|o| (o.serial, o.x, o.y, o.visible))
/// true, the slot keeps returning its last snapshot, and nothing in the log distinguishes it
/// from a genuinely motionless pointer.
fn watch_cursor_publishes(&mut self, n: u64) {
let (last, since) = self.cursor_poll_watch;
if n != last {
self.cursor_poll_watch = (n, Instant::now());
if self.cursor_poll_stalled {
self.cursor_poll_stalled = false;
tracing::info!(
target_id = self.target_id,
"cursor poller resumed publishing — the pointer tracks again"
);
}
} else if !self.cursor_poll_stalled && since.elapsed() >= POLLER_STALL {
self.cursor_poll_stalled = true;
tracing::warn!(
target_id = self.target_id,
stalled_ms = since.elapsed().as_millis() as u64,
"cursor poller is ALIVE but has stopped publishing — the pointer is frozen at its \
last sampled shape/position (input-desktop reads failing every tick?)"
);
}
}
/// Is the composite blend currently suppressed by a failure's backoff?
fn blend_suppressed(&self) -> bool {
self.blend_fail
.as_ref()
.is_some_and(|f| Instant::now() < f.retry_at)
}
/// Record a composite-blend failure and arm the next retry (see [`BlendFail`]). Logs EVERY
/// escalation rather than only the first — a pointer-less capture-model session is a
/// user-visible fault, and the old warn-once left a permanently broken one indistinguishable
/// in the log from a single transient hiccup at startup.
fn note_blend_failure(&mut self, why: &str) {
let backoff = next_blend_backoff(self.blend_fail.as_ref().map(|f| f.backoff));
let consecutive = self.blend_fail.as_ref().map_or(1, |f| f.consecutive + 1);
self.blend_fail = Some(BlendFail {
retry_at: Instant::now() + backoff,
backoff,
consecutive,
});
tracing::warn!(
consecutive,
retry_in_ms = backoff.as_millis() as u64,
"cursor composite: {why} — frames stay pointer-less until the retry succeeds"
);
}
/// A blend succeeded: retire any failure record so the next one starts at the short backoff.
fn note_blend_success(&mut self) {
if let Some(f) = self.blend_fail.take() {
tracing::info!(
after_consecutive_failures = f.consecutive,
"cursor composite: blend recovered — the pointer is back in frames"
);
}
}
/// What a blend would DRAW this tick — `(serial, x, y)` for a visible pointer, `None` for a
/// hidden or not-yet-known one. Keyed on the drawn RESULT rather than on raw cursor state so
/// that a HIDDEN pointer moving — routine, because that is exactly what a game that grabbed
/// the pointer does — cannot force a frame regeneration on an otherwise idle desktop. The
/// visible⇄hidden transitions still change the key (`Some`⇄`None`), so the frame that must
/// gain or lose the pointer is still regenerated.
fn cursor_blend_key(&mut self) -> Option<(u64, i32, i32)> {
blend_key_of(self.live_cursor().as_ref())
} }
/// Composite the pointer for this convert: ensure the frame-sized blend scratch, copy the /// Composite the pointer for this convert: ensure the frame-sized blend scratch, copy the
/// slot into it, and alpha-blend the GDI poller's shape at its polled position. Returns the /// slot into it, and alpha-blend the GDI poller's shape at its polled position. Returns the
/// scratch (texture + SRV) the conversion should read INSTEAD of the slot; `None` degrades /// scratch (texture + SRV) the conversion should read INSTEAD of the slot; `None` degrades
/// to the pointer-less slot, which is the correct frame whenever nothing would be drawn. /// to the pointer-less slot (scratch/pass creation failed — warned once). A hidden pointer
/// /// blends nothing (the plain copy is the correct frame).
/// **There is NO scratch and NO copy when the pointer is hidden or unknown.** The full-frame
/// `CopyResource` below is the single largest cost of the composite model — a 4K FP16 ring
/// slot is 66 MB, so at 120 fps an unconditional copy is ~8 GB/s of write bandwidth — and it
/// buys nothing when the blend that follows draws nothing. A game that grabbed the pointer
/// hides it, so this early-out is what makes the capture model free in the state it spends
/// most of its life in.
/// ///
/// # Safety /// # Safety
/// D3D11 calls on the owning capture/encode thread's device + immediate context, called /// D3D11 calls on the owning capture/encode thread's device + immediate context, called
@@ -1411,18 +1274,6 @@ impl IddPushCapturer {
&mut self, &mut self,
slot_tex: &ID3D11Texture2D, slot_tex: &ID3D11Texture2D,
) -> Option<(ID3D11Texture2D, ID3D11ShaderResourceView)> { ) -> Option<(ID3D11Texture2D, ID3D11ShaderResourceView)> {
// Resolve WHAT WOULD BE DRAWN first, and record it as the applied key even when that is
// "nothing" — `try_consume`'s regen test compares against this, so an early-out must still
// leave the key describing the frame we are about to emit. Through `live_cursor`, so a
// dead poller degrades to the shm section here too.
let overlay = self.live_cursor();
self.last_blend_key = blend_key_of(overlay.as_ref());
let ov = overlay.filter(|o| o.visible)?;
// Blending is suppressed while a recent failure's backoff runs — skip the scratch and the
// copy too, not just the draw: with nothing to draw onto it, the copy is pure waste.
if self.blend_suppressed() {
return None;
}
// SAFETY: per the contract above, D3D11 calls on the owning thread's device + immediate // SAFETY: per the contract above, D3D11 calls on the owning thread's device + immediate
// context while the slot's keyed mutex is held. `CreateTexture2D`/`CreateShaderResourceView` // context while the slot's keyed mutex is held. `CreateTexture2D`/`CreateShaderResourceView`
// take a fully-initialized stack descriptor plus live out-params and are `.ok()`-checked before // take a fully-initialized stack descriptor plus live out-params and are `.ok()`-checked before
@@ -1474,7 +1325,13 @@ impl IddPushCapturer {
self.blend_scratch = Some((t, v, self.width, self.height, fmt)); self.blend_scratch = Some((t, v, self.width, self.height, fmt));
} }
None => { None => {
self.note_blend_failure("scratch creation failed"); if !self.cursor_blend_failed {
self.cursor_blend_failed = true;
tracing::warn!(
"cursor blend scratch creation failed — capture-model frames stay \
pointer-less this session"
);
}
return None; return None;
} }
} }
@@ -1482,33 +1339,38 @@ impl IddPushCapturer {
let (tex, srv, ..) = self.blend_scratch.as_ref().expect("just ensured"); let (tex, srv, ..) = self.blend_scratch.as_ref().expect("just ensured");
let (tex, srv) = (tex.clone(), srv.clone()); let (tex, srv) = (tex.clone(), srv.clone());
self.context.CopyResource(&tex, slot_tex); self.context.CopyResource(&tex, slot_tex);
// Draw `ov` — resolved and keyed at the top, where a hidden pointer already took the // Blend the pointer (visible shapes only; hidden = the copy alone is the frame).
// early-out, so reaching here means there IS something to blend. // Through `live_cursor`, so a dead poller degrades to the shm section HERE too — this
if self.cursor_blend.is_none() { // is the path that actually draws the pointer in the composite model, and the one that
match cursor_blend::CursorBlendPass::new(&self.device) { // used to read the poller unconditionally.
Ok(p) => self.cursor_blend = Some(p), let overlay = self.live_cursor();
Err(e) => { self.last_blend_key = overlay.as_ref().map(|o| (o.serial, o.x, o.y, o.visible));
self.note_blend_failure(&format!("blend pass build failed: {e:#}")); if let Some(ov) = overlay.filter(|o| o.visible) {
if self.cursor_blend.is_none() && !self.cursor_blend_failed {
match cursor_blend::CursorBlendPass::new(&self.device) {
Ok(p) => self.cursor_blend = Some(p),
Err(e) => {
self.cursor_blend_failed = true;
tracing::warn!(
"cursor blend pass build failed — capture-model frames stay \
pointer-less this session: {e:#}"
);
}
} }
} }
} if let Some(pass) = self.cursor_blend.as_mut() {
if let Some(pass) = self.cursor_blend.as_mut() { // FP16 ring = scRGB linear composition (HDR): linearize the sRGB shape and
// FP16 ring = scRGB linear composition (HDR): linearize the sRGB shape and // scale it to the target's SDR white so it matches the desktop around it.
// scale it to the target's SDR white so it matches the desktop around it. let scale = if self.display_hdr {
let scale = if self.display_hdr { self.sdr_white_scale
self.sdr_white_scale } else {
} else { 0.0
0.0 };
}; if let Err(e) = pass.blend(&self.device, &self.context, &tex, &ov, scale) {
match pass.blend(&self.device, &self.context, &tex, &ov, scale) { if !self.cursor_blend_failed {
// One good draw retires the whole failure record: whatever broke has healed, self.cursor_blend_failed = true;
// and the next failure should get the SHORT retry, not the escalated one. tracing::warn!("cursor blend draw failed — pointer-less frames: {e:#}");
Ok(()) => self.note_blend_success(), }
Err(e) => {
// Drop the pass so the block above rebuilds it: a device-loss failure is
// transient, but a pass built against the lost device never succeeds again.
self.cursor_blend = None;
self.note_blend_failure(&format!("blend draw failed: {e:#}"));
} }
} }
} }
@@ -2213,84 +2075,6 @@ mod tests {
use super::stall::Stall; use super::stall::Stall;
use super::*; use super::*;
/// A `CursorOverlay` at `(x, y)` with `serial`, visible or not. `rgba` is never read by the
/// key/backoff logic under test, so a 1×1 pixel keeps the fixtures honest about that.
fn overlay(serial: u64, x: i32, y: i32, visible: bool) -> pf_frame::CursorOverlay {
pf_frame::CursorOverlay {
x,
y,
w: 1,
h: 1,
rgba: std::sync::Arc::new(vec![0, 0, 0, 0]),
serial,
hot_x: 0,
hot_y: 0,
visible,
}
}
/// The regen key is what would be DRAWN, so a hidden pointer keys to `None` no matter where it
/// is. This is the whole point: a game that grabbed the pointer moves it constantly, and each
/// of those moves used to re-encode the last slot for a frame that is pixel-identical.
#[test]
fn a_hidden_pointer_has_no_blend_key_wherever_it_moves() {
assert_eq!(blend_key_of(None), None, "no overlay ⇒ nothing drawn");
assert_eq!(
blend_key_of(Some(&overlay(7, 10, 10, false))),
None,
"hidden ⇒ nothing drawn"
);
assert_eq!(
blend_key_of(Some(&overlay(7, 999, 999, false))),
blend_key_of(Some(&overlay(7, 10, 10, false))),
"a hidden pointer moving must NOT look like a change"
);
}
/// …but every transition that alters the drawn frame still changes the key, or the pointer
/// would freeze on screen (the failure mode opposite to the one above).
#[test]
fn every_visible_change_moves_the_blend_key() {
let shown = blend_key_of(Some(&overlay(7, 10, 10, true)));
assert_eq!(shown, Some((7, 10, 10)));
assert_ne!(
shown,
blend_key_of(Some(&overlay(7, 11, 10, true))),
"a visible pointer moving is a change"
);
assert_ne!(
shown,
blend_key_of(Some(&overlay(8, 10, 10, true))),
"a new shape at the same spot is a change"
);
assert_ne!(
shown,
blend_key_of(Some(&overlay(7, 10, 10, false))),
"visible → hidden must regenerate the frame that loses the pointer"
);
}
/// The retry escalates and then holds at the ceiling — it must never grow without bound (the
/// point of a ceiling is that a device which comes back is picked up within it).
#[test]
fn the_blend_retry_backoff_doubles_then_caps() {
let first = next_blend_backoff(None);
assert_eq!(first, BLEND_RETRY_MIN, "the first failure retries quickly");
assert_eq!(next_blend_backoff(Some(first)), first * 2, "then doubles");
// Walk it well past the cap and assert it PARKS there rather than overshooting.
let mut b = first;
for _ in 0..32 {
b = next_blend_backoff(Some(b));
}
assert_eq!(b, BLEND_RETRY_MAX, "escalation parks at the ceiling");
assert_eq!(
next_blend_backoff(Some(BLEND_RETRY_MAX)),
BLEND_RETRY_MAX,
"and stays there"
);
}
/// W14: the mint must stay inside the publish token's 24-bit generation field, and must skip 0. /// W14: the mint must stay inside the publish token's 24-bit generation field, and must skip 0.
/// ///
/// `IDD_GENERATION` is a full `u32` while `FrameToken` carries 24 bits and `unpack` MASKS what it /// `IDD_GENERATION` is a full `u32` while `FrameToken` carries 24 bits and `unpack` MASKS what it
@@ -68,11 +68,6 @@ pub(super) struct CursorPoller {
/// while the secure desktop needs the software-cursor path to render (see /// while the secure desktop needs the software-cursor path to render (see
/// `IddPushCapturer::poll_secure_desktop`). /// `IddPushCapturer::poll_secure_desktop`).
secure: Arc<AtomicBool>, secure: Arc<AtomicBool>,
/// Monotonic count of published snapshots — the poller's HEARTBEAT. It advances once per
/// successful poll (a failed `GetCursorInfo` `continue`s before the publish), so a thread that
/// is wedged on an input desktop it can no longer read stops advancing this while never
/// exiting. [`Self::alive`] cannot see that state: it only asks whether the thread finished.
ticks: Arc<AtomicU64>,
thread: Option<std::thread::JoinHandle<()>>, thread: Option<std::thread::JoinHandle<()>>,
} }
@@ -111,12 +106,10 @@ impl CursorPoller {
let slot: Arc<Mutex<Option<pf_frame::CursorOverlay>>> = Arc::new(Mutex::new(None)); let slot: Arc<Mutex<Option<pf_frame::CursorOverlay>>> = Arc::new(Mutex::new(None));
let stop = Arc::new(AtomicBool::new(false)); let stop = Arc::new(AtomicBool::new(false));
let secure = Arc::new(AtomicBool::new(false)); let secure = Arc::new(AtomicBool::new(false));
let ticks = Arc::new(AtomicU64::new(0)); let (slot_t, stop_t, secure_t) = (slot.clone(), stop.clone(), secure.clone());
let (slot_t, stop_t, secure_t, ticks_t) =
(slot.clone(), stop.clone(), secure.clone(), ticks.clone());
let thread = std::thread::Builder::new() let thread = std::thread::Builder::new()
.name("pf-cursor-poll".into()) .name("pf-cursor-poll".into())
.spawn(move || run(target_id, rect, &slot_t, &stop_t, &secure_t, &ticks_t)) .spawn(move || run(target_id, rect, &slot_t, &stop_t, &secure_t))
.ok(); .ok();
if thread.is_none() { if thread.is_none() {
tracing::warn!("cursor poller thread spawn failed — cursor falls back to driver shm"); tracing::warn!("cursor poller thread spawn failed — cursor falls back to driver shm");
@@ -125,7 +118,6 @@ impl CursorPoller {
slot, slot,
stop, stop,
secure, secure,
ticks,
thread, thread,
} }
} }
@@ -141,14 +133,7 @@ impl CursorPoller {
self.secure.load(Ordering::Relaxed) self.secure.load(Ordering::Relaxed)
} }
/// The heartbeat count (see [`Self::ticks`]). Compared against its own previous value by the
/// capturer — the ABSOLUTE value means nothing, only whether it is still moving.
pub(super) fn publishes(&self) -> u64 {
self.ticks.load(Ordering::Relaxed)
}
/// Whether the worker thread is (still) alive — `false` degrades the capturer to the shm read. /// Whether the worker thread is (still) alive — `false` degrades the capturer to the shm read.
/// Note this is liveness, NOT health: see [`Self::publishes`].
pub(super) fn alive(&self) -> bool { pub(super) fn alive(&self) -> bool {
self.thread.as_ref().is_some_and(|t| !t.is_finished()) self.thread.as_ref().is_some_and(|t| !t.is_finished())
} }
@@ -170,7 +155,6 @@ fn run(
slot: &Mutex<Option<pf_frame::CursorOverlay>>, slot: &Mutex<Option<pf_frame::CursorOverlay>>,
stop: &AtomicBool, stop: &AtomicBool,
secure: &AtomicBool, secure: &AtomicBool,
ticks: &AtomicU64,
) { ) {
// Physical-pixel coordinates on this thread regardless of the process's DPI awareness: // Physical-pixel coordinates on this thread regardless of the process's DPI awareness:
// `rect` comes from CCD (always physical), and a DPI-virtualized `GetCursorInfo` position // `rect` comes from CCD (always physical), and a DPI-virtualized `GetCursorInfo` position
@@ -322,8 +306,6 @@ fn run(
} }
}); });
*slot.lock().unwrap_or_else(|p| p.into_inner()) = overlay; *slot.lock().unwrap_or_else(|p| p.into_inner()) = overlay;
// Heartbeat AFTER the publish, so it counts snapshots the capturer can actually read.
ticks.fetch_add(1, Ordering::Relaxed);
} }
} }
@@ -656,9 +656,7 @@ impl IddPushCapturer {
composite_cursor: composite_forced, composite_cursor: composite_forced,
composite_forced, composite_forced,
cursor_blend: None, cursor_blend: None,
blend_fail: None, cursor_blend_failed: false,
cursor_poll_watch: (0, std::time::Instant::now()),
cursor_poll_stalled: false,
cursor_shm_latched: false, cursor_shm_latched: false,
blend_scratch: None, blend_scratch: None,
last_blend_key: None, last_blend_key: None,
@@ -421,34 +421,6 @@ pub fn hw_cursor_capable() -> bool {
m.driver_proto.load(Ordering::Relaxed) >= 5 m.driver_proto.load(Ordering::Relaxed) >= 5
} }
/// Is NO session currently streaming to a virtual display?
///
/// The safety question for anything that tears the adapter down — notably
/// [`crate::driver::clean_cursor_for_next_session`], whose `pnputil /restart-device` takes every
/// monitor on the adapter with it. Only [`SlotState::Active`] counts: that is a session with live
/// references, and destroying its monitor mid-stream is the cross-session damage worth refusing.
///
/// `Lingering`/`Pinned` slots deliberately do NOT count. They are keep-alive monitors with no
/// session attached, and a reconnect **already** preempts and recreates them — "a reused IddCx
/// swap-chain is dead" (see [`SlotState::Pinned`]) — so a device restart destroys nothing the
/// reconnect was not going to destroy anyway. Counting them was too conservative to be useful: the
/// case this gate exists for is exactly *disconnect from a desktop session, reconnect in capture
/// mode*, and the disconnected session's monitor is lingering at precisely that moment, so the
/// clean-up could never fire when it was most wanted (observed on `.173`, 2026-08-08).
pub fn no_active_sessions() -> bool {
match VDM.get() {
// Before the first backend open there is nothing to protect.
None => true,
Some(m) => !m
.state
.lock()
.unwrap_or_else(|e| e.into_inner())
.slots
.values()
.any(|s| matches!(s, SlotState::Active { .. })),
}
}
pub fn control_device_handle() -> Option<HANDLE> { pub fn control_device_handle() -> Option<HANDLE> {
VDM.get().and_then(VirtualDisplayManager::device_handle) VDM.get().and_then(VirtualDisplayManager::device_handle)
} }
@@ -158,226 +158,6 @@ enum AdapterCycle {
Refused(String), Refused(String),
} }
/// Restart the pf-vdisplay device to CLEAR a sticky IddCx hardware-cursor declare, so sessions that
/// do not want the host to own the pointer get the OS's own cursor compositing back (full fidelity,
/// zero host cost — no GDI poller, no per-frame blend, true XOR instead of our outline
/// approximation).
///
/// **Why this exists.** A hardware-cursor declare is irrevocable and ADAPTER-WIDE
/// (`pf-driver-proto` v6 note): once any desktop-mode session declares, DWM stops compositing the
/// pointer into EVERY later frame on that adapter, and every subsequent session — including
/// capture-latched ones that never asked for a cursor channel — has to self-composite. The state
/// lives in the driver's `DECLARED_TARGETS`, whose scope is the WUDFHost process, so recycling that
/// process clears it.
///
/// **Why `/restart-device` and not the [`reload_vdisplay_adapter`] cycle.** Measured on-glass
/// 2026-08-08 (`.173`): `pnputil /restart-device` returned in **0.07 s** with a NEW WUDFHost pid,
/// against ~6 s of sleeps for `Disable`+`Enable` — and, being designed for a device that is in use,
/// it does not hit the refusal that doc calls "the expected case here". It also repaired an adapter
/// found in `CM_PROB_FAILED_POST_START` (Code 43) in the same call.
///
/// ⚠⚠ **This is a ONCE-PER-BOOT lever, not a cheap one.** Measured on `.173` 2026-08-08: the first
/// `/restart-device` after a cold boot succeeds in 0.07 s; every later one in the same boot fails
/// with *"Das System muss neu gestartet werden, damit Konfigurationsvorgänge abgeschlossen
/// werden"*, and repeated attempts additionally push the devnode into `restart pending`. So this
/// can clean the adapter at host start-up and nowhere else — anything wanting to un-declare
/// mid-boot (e.g. giving a capture session back the lossless pointer after a desktop session) needs
/// a different mechanism to recycle the driver's WUDFHost process, which is where the declare
/// actually lives.
///
/// ⚠ It tears the adapter down, so it must run only when NO session holds a display — the host
/// start-up path. `PUNKTFUNK_CURSOR_CLEAN_START=0` disables it.
///
/// Returns `true` only when pnputil reported success. Best-effort: a failure just leaves the
/// adapter as it was (sessions then self-composite exactly as before).
/// The driver's WUDFHost pid, from the most recent ADD reply. `0` before any monitor was created.
static LAST_WUDF_PID: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
/// Clear a sticky hardware-cursor declare by recycling the driver's WUDFHost process.
///
/// The declare is irrevocable and adapter-wide, but its scope is the WUDFHost process
/// (`monitor.rs` `DECLARED_TARGETS`) — so killing that process drops it. WUDF respawns the host on
/// the next open, with a fresh adapter object.
///
/// **This is what makes un-declaring possible mid-boot.** `pnputil /restart-device` also works but
/// is a ONCE-PER-BOOT operation (see [`restart_device_for_clean_cursor`]); the start-up clean
/// spends it, leaving nothing for the desktop-session→reconnect case. Measured on `.173`
/// 2026-08-08: pid 3872 → 19932, `adapter_luid` 0x8ed607 → 0x1a8f6ca, `cursor_excluded` true →
/// **false**, next session streamed normally.
///
/// Same precondition as the device restart: no session may hold a display, because every monitor
/// on the adapter dies with the host.
fn recycle_wudfhost() -> bool {
let pid = LAST_WUDF_PID.load(std::sync::atomic::Ordering::Relaxed);
if pid == 0 {
tracing::info!("cursor: no driver host pid known yet — nothing to recycle");
return false;
}
// taskkill rather than OpenProcess/TerminateProcess: the host runs as SYSTEM, so it already has
// the rights, and shelling out keeps this off the unsafe-proof budget for a once-per-session
// maintenance action.
match std::process::Command::new(
std::env::var("SystemRoot")
.map(|r| format!(r"{r}\System32 askkill.exe"))
.unwrap_or_else(|_| "taskkill.exe".to_string()),
)
.args(["/PID", &pid.to_string(), "/F"])
.output()
{
Ok(o) if o.status.success() => {
tracing::info!(
pid,
"cursor: recycled the driver's WUDFHost — the hardware-cursor declare is gone"
);
LAST_WUDF_PID.store(0, std::sync::atomic::Ordering::Relaxed);
true
}
Ok(o) => {
tracing::warn!(
pid,
stderr = %String::from_utf8_lossy(&o.stderr).trim().replace('\n', " "),
"cursor: could not recycle the driver's WUDFHost — this session self-composites"
);
false
}
Err(e) => {
tracing::warn!(pid, error = %e, "cursor: taskkill spawn failed");
false
}
}
}
/// Has this host process DECLARED an IddCx hardware cursor since the adapter was last restarted?
/// Set by the ADD path when a session ASKS for a hardware cursor (the one place every declare
/// passes through); cleared when the declare is dropped. The host's own mirror of the
/// driver's `DECLARED_TARGETS` — cheaper than probing, and it only ever needs to be right about
/// "did WE dirty it", because a declare from an earlier BOOT is handled by the start-up clean.
static CURSOR_DECLARED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Give the NEXT session back the lossless cursor: if an earlier session on this host declared the
/// hardware cursor and this one does not want it, restart the device to clear the sticky declare.
///
/// This is the case the start-up clean cannot reach — **run a desktop-mode session, disconnect,
/// reconnect in capture mode**. Same host process, so the adapter is still dirty from the first
/// session and the capture session would self-composite the pointer for its whole life. Declaring
/// is one-way and adapter-wide (`pf-driver-proto` v6), so the only way back is a device restart —
/// 0.07 s, measured.
///
/// Must be called BEFORE this session creates its display, and only when nothing else holds one:
/// the restart takes every monitor on the adapter with it.
///
/// Returns `true` only when it actually restarted.
pub fn clean_cursor_for_next_session(session_wants_declare: bool) -> bool {
use std::sync::atomic::Ordering;
if session_wants_declare || !CURSOR_DECLARED.load(Ordering::Relaxed) {
return false;
}
// Gated deliberately — a device restart is NOT free. Windows puts the devnode into
// "restart pending" after repeated cycles, and `/restart-device` then refuses with "a system
// restart is pending for this device" until an actual reboot (hit on .173 2026-08-08 after ~6
// restarts in one afternoon, which is also what made the earlier runs look like a wiring bug:
// the call ran, the restart failed, and nothing logged the failure). So restart only when a
// declare is actually outstanding, never speculatively.
let previously_declared = true;
// Refuse only while another session is STREAMING — a keep-alive (lingering/pinned) monitor has
// no session attached and a reconnect recreates it regardless, so restarting the adapter costs
// it nothing. Gating on keep-alive too made this dead code in the one case it exists for: after
// a desktop session disconnects its monitor LINGERS, which is exactly when the next
// capture-mode connect needs the declare gone (observed on .173).
if !super::manager::no_active_sessions() {
tracing::info!(
"cursor: this session wants no hardware cursor and an earlier one declared, but a display is still held (live or keep-alive) — skipping the adapter restart, so the pointer stays host-composited for this session"
);
return false;
}
if recycle_wudfhost() {
// The cached control handle died with the host process. Retire it so the next
// `ensure_device` reopens against the respawned WUDFHost — without this the ADD that
// follows runs on a stale handle and the session comes up with no frames at all.
super::manager::invalidate_cached_device("cursor clean: recycled the driver host");
std::thread::sleep(std::time::Duration::from_millis(1500));
CURSOR_DECLARED.store(false, Ordering::Relaxed);
tracing::info!(
previously_declared,
"cursor: restarted the adapter for this capture-mode session — any hardware-cursor \
declare is gone, so the OS composites the pointer itself (full fidelity, no host \
blend). previously_declared=false only means the host-side hint was unset; the \
restart is idempotent either way"
);
return true;
}
false
}
pub fn restart_device_for_clean_cursor() -> bool {
if std::env::var("PUNKTFUNK_CURSOR_CLEAN_START").is_ok_and(|v| v == "0") {
tracing::info!(
"pf-vdisplay: cursor clean-start disabled (PUNKTFUNK_CURSOR_CLEAN_START=0) — a sticky \
hardware-cursor declare from an earlier boot will keep sessions self-compositing"
);
return false;
}
// `$LASTEXITCODE` is pre-seeded to 1 for the same reason `reload_vdisplay_adapter` does it: if
// pnputil never launches, a stale value must not read as success.
const PS: &str = "$ErrorActionPreference='SilentlyContinue'; \
$ad = Get-PnpDevice -Class Display | Where-Object { $_.FriendlyName -match 'punktfunk Virtual Display' } | Select-Object -First 1; \
if (-not $ad) { Write-Output 'ABSENT'; exit }; \
$pnp = ($env:SystemRoot + '\\System32\\pnputil.exe'); $LASTEXITCODE = 1; \
if (Test-Path $pnp) { $out = (& $pnp /restart-device $ad.InstanceId 2>&1 | Out-String) }; \
if ($LASTEXITCODE -eq 0) { Write-Output 'RESTARTED' } \
else { Write-Output ('FAILED ' + ($out -replace '\\s+', ' ')) }";
let ps = std::env::var("SystemRoot")
.map(|r| format!(r"{r}\System32\WindowsPowerShell\v1.0\powershell.exe"))
.unwrap_or_else(|_| "powershell.exe".to_string());
let out = match std::process::Command::new(&ps)
.args([
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
PS,
])
.output()
{
Ok(o) => String::from_utf8_lossy(&o.stdout).trim().to_string(),
Err(e) => {
tracing::warn!(error = %e, "pf-vdisplay: cursor clean-start could not spawn powershell");
return false;
}
};
match out.as_str() {
"RESTARTED" => {
tracing::info!(
"pf-vdisplay: restarted the adapter at start-up — any sticky hardware-cursor \
declare is cleared, so sessions without a cursor channel get the OS's own \
(full-fidelity, zero-cost) pointer compositing until one declares again"
);
true
}
"ABSENT" => false, // driver not installed — nothing to clean, and `open` reports that later
// Keep pnputil's own text. The failure that actually occurs is "a system restart is
// pending for this device" — no retry fixes it, and a bare exit code hid it for three runs.
other => {
tracing::warn!(
outcome = other,
// Two distinct wordings, both meaning "not until you reboot":
// "Für das Gerät steht ein Systemneustart aus" (device restart pending)
// "Das System muss neu gestartet werden, damit …" (config ops need a reboot)
// The second is what you actually hit, and it appears after the FIRST successful
// restart of a boot — see the doc on `restart_device_for_clean_cursor`.
needs_reboot = other.contains("Systemneustart")
|| other.contains("muss neu gestartet werden")
|| other.to_ascii_lowercase().contains("restart is pending")
|| other.to_ascii_lowercase().contains("must be restarted"),
"pf-vdisplay: cursor clean-start did not restart the adapter — sessions without a \
cursor channel will self-composite the pointer if an earlier declare is sticky"
);
false
}
}
}
/// Reload the pf-vdisplay ADAPTER device — the in-process equivalent of `reset-pf-vdisplay.ps1` /// Reload the pf-vdisplay ADAPTER device — the in-process equivalent of `reset-pf-vdisplay.ps1`
/// step 3. A crashed/killed WUDFHost can leave the devnode "started" yet HOSTLESS (PnP Status OK, no /// step 3. A crashed/killed WUDFHost can leave the devnode "started" yet HOSTLESS (PnP Status OK, no
/// WUDFHost process, zero device-interface instances) — a zombie no session can open until the stack /// WUDFHost process, zero device-interface instances) — a zombie no session can open until the stack
@@ -573,12 +353,6 @@ pub unsafe fn send_cursor_channel(
dev: HANDLE, dev: HANDLE,
req: &control::SetCursorChannelRequest, req: &control::SetCursorChannelRequest,
) -> Result<()> { ) -> Result<()> {
// THE declare point. The driver declares its IddCx hardware cursor when this channel arrives —
// not from the ADD request's `hw_cursor` flag, which is why recording the declare there (and,
// before that, in `capture_virtual_output`) left the flag false and the between-session clean
// silently inert. The log line that names this moment is "cursor channel delivered - driver
// declares the hardware cursor".
CURSOR_DECLARED.store(true, std::sync::atomic::Ordering::Relaxed);
let mut none: [u8; 0] = []; let mut none: [u8; 0] = [];
// SAFETY: per this fn's contract `dev` is the live control handle; `bytes_of(req)` borrows the // SAFETY: per this fn's contract `dev` is the live control handle; `bytes_of(req)` borrows the
// caller's request across this synchronous call; no output buffer. // caller's request across this synchronous call; no output buffer.
@@ -905,26 +679,6 @@ impl VdisplayDriver for PfVdisplayDriver {
client_hdr: Option<punktfunk_core::quic::HdrMeta>, client_hdr: Option<punktfunk_core::quic::HdrMeta>,
hw_cursor: bool, hw_cursor: bool,
) -> Result<AddedMonitor> { ) -> Result<AddedMonitor> {
// Give a capture-mode session the LOSSLESS pointer back: if an earlier session declared a
// hardware cursor and this one does not want it, recycle the driver's host process BEFORE
// this monitor is added. The ADD path is the only place guaranteed to see every session
// (the handshake call site this replaced sat in a `match (source, compositor)` arm that is
// not taken on this host, so it never ran).
// ⚠ DISABLED BY DEFAULT — opt in with PUNKTFUNK_CURSOR_RECYCLE=1.
//
// The MECHANISM is proven (recycling the driver host clears the declare: measured pid
// 3872→19932, adapter_luid 0x8ed607→0x1a8f6ca, cursor_excluded true→false, next session
// streamed fine). What is NOT solved is calling it from HERE: `invalidate_cached_device`
// takes the manager `device` mutex, which this ADD path already holds, so the session
// DEADLOCKS — observed on .173, the ADD stops after SET_RENDER_ADAPTER and the client gets
// "no frames received". Its own doc warns about exactly this.
//
// The fix is a call site that runs OUTSIDE the mutex and still on every session's path;
// the handshake site tried before is not reached on this host. Until then this stays off:
// a session that self-composites is the old behaviour, a deadlocked one is a regression.
if !hw_cursor && std::env::var("PUNKTFUNK_CURSOR_RECYCLE").is_ok_and(|v| v == "1") {
clean_cursor_for_next_session(false);
}
let session_id = next_session_id(); let session_id = next_session_id();
// The client display's volume rides into the monitor's EDID CTA HDR block; all-zero = // The client display's volume rides into the monitor's EDID CTA HDR block; all-zero =
// unknown → the driver keeps its built-in defaults (also what an un-upgraded driver, which // unknown → the driver keeps its built-in defaults (also what an un-upgraded driver, which
@@ -1070,14 +824,7 @@ impl VdisplayDriver for PfVdisplayDriver {
tracing::info!( tracing::info!(
target_id = reply.target_id, target_id = reply.target_id,
adapter_luid = %format_args!("{:#x}", luid.LowPart), adapter_luid = %format_args!("{:#x}", luid.LowPart),
wudf_pid = { wudf_pid = reply.wudf_pid,
// The declare lives in THIS process (monitor.rs `DECLARED_TARGETS`), so remember it:
// recycling it is the only way to un-declare that does not cost the once-per-boot
// device restart. Proven on .173 2026-08-08 — killing it gave a new host pid, a NEW
// adapter luid, and `cursor_excluded=false`, with the next session streaming fine.
LAST_WUDF_PID.store(reply.wudf_pid, std::sync::atomic::Ordering::Relaxed);
reply.wudf_pid
},
cursor_excluded = reply.cursor_excluded != 0, cursor_excluded = reply.cursor_excluded != 0,
"pf-vdisplay monitor created {}x{}@{}", "pf-vdisplay monitor created {}x{}@{}",
mode.width, mode.width,
-17
View File
@@ -382,23 +382,6 @@ fn real_main() -> Result<()> {
// driver to a stray second host started while the service sat idle. // driver to a stray second host started while the service sat idle.
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
vdisplay::manager::claim_instance_eagerly(); vdisplay::manager::claim_instance_eagerly();
// Clean-cursor start (design/windows-cursor-model-determinism.md §4.3): clear any
// sticky IddCx hardware-cursor declare left on the adapter by an EARLIER boot's
// desktop-mode session. That declare is irrevocable and adapter-wide, so without this
// every capture-latched session on the box self-composites the pointer for the rest of
// the adapter's life — paying a full-frame copy per visible-pointer frame and drawing
// our straight-alpha approximation of an XOR cursor — when the OS would otherwise
// composite it natively, for free, at full fidelity.
//
// It is NOT enough to wait for a reboot: with Fast Startup on (the Windows default) a
// shutdown+power-on is a hiberboot that RESTORES session 0 and its drivers, so the
// declare survives what the operator calls a reboot (measured: Kernel-Boot event id 27
// `0x1`, and `lsass`/`services` keeping their pre-"reboot" start times). Only a cold
// boot or a device restart actually clears it — and the device restart costs 0.07 s.
//
// Runs HERE, before any session holds a display: the restart tears the adapter down.
#[cfg(target_os = "windows")]
vdisplay::driver::restart_device_for_clean_cursor();
// Crash recovery for the experimental `pnp_disable_monitors` axis: re-enable any // Crash recovery for the experimental `pnp_disable_monitors` axis: re-enable any
// monitor devnodes a previous host disabled for an Exclusive session and never // monitor devnodes a previous host disabled for an Exclusive session and never
// restored (crash/kill/power loss) — before any new session touches the topology. // restored (crash/kill/power loss) — before any new session touches the topology.
@@ -0,0 +1,91 @@
// GET/PUT /api/plugin-config/<id> — a plugin's `__config`, readable from the CONSOLE origin.
//
// The Library section's "Game sources" settings drawer renders a form from a library plugin's
// `__config` (the kit's generic settings surface, so a scanner needs no SPA of its own). It fetched
// `/plugin-ui/<id>/__config` same-origin — and that stopped working the moment plugin UIs moved to
// their own origin (2026-08-05 review H-3): `middleware/auth.ts` answers 404 for `/plugin-ui/**` on
// the console origin, unconditionally and by design. The drawer is the only NON-IFRAME consumer of
// that path, so nothing else noticed, and settings silently failed to open for every library plugin.
//
// The fix is deliberately not "point the drawer at the plugin origin". That needs CORS plus
// cross-site cookies, and it would put a plugin-controlled response inside a credentialed
// cross-origin fetch — reopening the hole the split exists to close. What the drawer needs is DATA,
// not an embedded UI: this reads the JSON server-side over loopback and returns it same-origin, so
// no plugin HTML or JS is ever served from the console origin.
//
// Auth: `/api/**` is always session-gated (`isPublicPath`), so reaching here means a logged-in
// operator, and it answers 401 as JSON rather than redirecting — which is what a `fetch` needs. The
// plugin's per-boot secret stays server-side, exactly as in the `/plugin-ui` proxy.
import {
defineEventHandler,
getRouterParam,
readRawBody,
setResponseStatus,
} from "h3";
import {
bustCredential,
fetchUiCredential,
PLUGIN_ID_RE,
} from "../../../util/pluginProxy";
/** `GET` reads schema + current value; `PUT` validates and saves. Nothing else is forwarded. */
const ALLOWED = new Set(["GET", "PUT"]);
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, "id");
if (!id || !PLUGIN_ID_RE.test(id)) {
setResponseStatus(event, 404);
return { error: "not a valid plugin id" };
}
const method = event.method;
if (!ALLOWED.has(method)) {
setResponseStatus(event, 405);
return { error: "method not allowed" };
}
// Read the body BEFORE the retry below: `readRawBody` drains the stream, so a second attempt
// would forward an empty PUT and quietly save `{}` over the operator's config.
const body =
method === "PUT"
? ((await readRawBody(event, false)) as Uint8Array | undefined)
: undefined;
const attempt = async (bustCache: boolean): Promise<Response | null> => {
const cred = await fetchUiCredential(id, { bustCache });
if (!cred) return null;
try {
return await fetch(`http://127.0.0.1:${cred.port}/__config`, {
method,
headers: {
authorization: `Bearer ${cred.secret}`,
...(method === "PUT" ? { "content-type": "application/json" } : {}),
},
body: body as BodyInit | undefined,
});
} catch {
return null;
}
};
// A plugin's secret rotates when its process restarts, which happens well inside the credential
// cache's TTL — so a 401 here means "stale credential", not "denied". Same one-shot retry the
// `/plugin-ui` proxy does, for the same reason.
let res = await attempt(false);
if (res?.status === 401) {
bustCredential(id);
res = await attempt(true);
}
if (!res) {
setResponseStatus(event, 502);
return { error: `plugin ${id} is not reachable` };
}
setResponseStatus(event, res.status);
// Pass the plugin's own body through untouched: a 400 from `__config` carries the decode issue
// the drawer shows the operator, and rewriting it would throw away the only useful part.
const text = await res.text();
try {
return JSON.parse(text) as unknown;
} catch {
return { error: text || `plugin ${id} answered ${res.status}` };
}
});
+11 -5
View File
@@ -26,9 +26,15 @@ import { m } from "@/paraglide/messages";
* A library source's settings, rendered as a **generic form** from the plugin's own JSON Schema. * A library source's settings, rendered as a **generic form** from the plugin's own JSON Schema.
* *
* The point (design D7, closing G8): a scanner plugin ships no SPA at all. It serves * The point (design D7, closing G8): a scanner plugin ships no SPA at all. It serves
* `GET/PUT /__config` from the kit, and the console renders whatever schema comes back. Everything * `GET/PUT /__config` from the kit, and the console renders whatever schema comes back. The browser
* goes through the existing session-gated `/plugin-ui/<id>/…` proxy, so there is **zero new host * never learns the plugin's port or secret the console reads it server-side over loopback.
* surface** the browser never learns the plugin's port or secret. *
* That read goes through `/api/plugin-config/<id>` on the CONSOLE origin, not the `/plugin-ui/…`
* proxy this used to call. Plugin UIs live on their own origin (2026-08-05 review H-3) and the
* console origin now answers 404 for `/plugin-ui/**` by design, which broke this drawer for every
* library plugin it is the one consumer of that path that is not an iframe. What it needs is
* DATA, not an embedded UI, so it gets JSON same-origin and no plugin markup ever reaches the
* console origin.
* *
* Fields the derivation can't express fall back to a raw JSON editor. That fallback is what bounds * Fields the derivation can't express fall back to a raw JSON editor. That fallback is what bounds
* the risk of the whole approach: worst case the drawer is a validated textarea, and the PUT still * the risk of the whole approach: worst case the drawer is a validated textarea, and the PUT still
@@ -51,7 +57,7 @@ export const SourceSettingsDialog: FC<{
let cancelled = false; let cancelled = false;
(async () => { (async () => {
try { try {
const res = await fetch(`/plugin-ui/${pluginId}/__config`, { const res = await fetch(`/api/plugin-config/${pluginId}`, {
credentials: "same-origin", credentials: "same-origin",
}); });
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -77,7 +83,7 @@ export const SourceSettingsDialog: FC<{
const save = async (value: JsonObject) => { const save = async (value: JsonObject) => {
setSaving(true); setSaving(true);
try { try {
const res = await fetch(`/plugin-ui/${pluginId}/__config`, { const res = await fetch(`/api/plugin-config/${pluginId}`, {
method: "PUT", method: "PUT",
credentials: "same-origin", credentials: "same-origin",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },