Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5eea458b4 | ||
|
|
7f141eb9a4 | ||
|
|
06086de328 | ||
|
|
7b91afd721 | ||
|
|
0ed4b51104 | ||
|
|
3197a4e887 | ||
|
|
df74dd5aee | ||
|
|
b2020396c9 | ||
|
|
b20184462d | ||
|
|
537a1852ed | ||
|
|
5819cf054b | ||
|
|
62624c1daf | ||
|
|
7f6d1622ee | ||
|
|
5c1db4662f |
@@ -129,6 +129,16 @@ struct Args {
|
||||
/// host must composite the metadata cursor on its own; decode the dump and look for the
|
||||
/// pointer.
|
||||
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`
|
||||
/// seconds (default 4), print what's found, and exit. No connection is made.
|
||||
discover: Option<u64>,
|
||||
@@ -309,6 +319,7 @@ fn parse_args() -> Args {
|
||||
clock_resync: argv.iter().any(|a| a == "--clock-resync"),
|
||||
cursor_capture: argv.iter().any(|a| a == "--cursor-capture"),
|
||||
cursor_nochannel: argv.iter().any(|a| a == "--cursor-nochannel"),
|
||||
cursor_hold: argv.iter().any(|a| a == "--cursor-hold"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -900,13 +911,23 @@ async fn session(args: Args) -> Result<()> {
|
||||
}
|
||||
});
|
||||
let wiggle_conn = conn.clone();
|
||||
let hold = args.cursor_hold;
|
||||
tokio::spawn(async move {
|
||||
// Relative circles, forever: keeps the host pointer moving (and, on metadata-cursor
|
||||
// compositors, keeps cursor updates flowing) for the whole dump.
|
||||
// Relative circles: keeps the host pointer moving (and, on metadata-cursor
|
||||
// compositors, keeps cursor updates flowing) for the whole dump — unless
|
||||
// `--cursor-hold`, which primes and then stops so the pointer can be parked.
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
tracing::info!("cursor-capture: relative pointer wiggle running");
|
||||
tracing::info!(hold, "cursor-capture: relative pointer wiggle running");
|
||||
let prime_until = std::time::Instant::now() + std::time::Duration::from_secs(3);
|
||||
let mut t = 0.0f64;
|
||||
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 {
|
||||
kind: InputKind::MouseMove,
|
||||
_pad: [0; 3],
|
||||
|
||||
@@ -212,6 +212,47 @@ struct KeyedMutexGuard<'a> {
|
||||
/// (`frame_transport.rs`).
|
||||
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> {
|
||||
/// 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.
|
||||
@@ -385,13 +426,26 @@ pub struct IddPushCapturer {
|
||||
/// 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).
|
||||
composite_forced: bool,
|
||||
/// The cursor-quad blend pass (lazy; per capture device). `None` after a build failure —
|
||||
/// composite mode then degrades to pointer-less frames (warned once).
|
||||
/// The cursor-quad blend pass (lazy; per capture device). `None` before the first blend and
|
||||
/// after a failure dropped it; rebuilt on the next attempt that is not suppressed.
|
||||
cursor_blend: Option<cursor_blend::CursorBlendPass>,
|
||||
cursor_blend_failed: bool,
|
||||
/// Composite-blend failure state. `None` = healthy. A failure used to be TERMINAL — one warn,
|
||||
/// 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
|
||||
/// keep independent serial namespaces, so once crossed we never go back (see there).
|
||||
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)
|
||||
/// it was built for — rebuilt when the ring geometry changes.
|
||||
blend_scratch: Option<(
|
||||
@@ -401,10 +455,12 @@ pub struct IddPushCapturer {
|
||||
u32,
|
||||
DXGI_FORMAT,
|
||||
)>,
|
||||
/// The (serial, x, y, visible) of the LAST blended pointer — the composite-regen change
|
||||
/// key: pointer-only motion produces no driver publish (the declared hardware cursor
|
||||
/// doesn't dirty frames), so `try_consume` regenerates from the last slot when this moves.
|
||||
last_blend_key: Option<(u64, i32, i32, bool)>,
|
||||
/// What the LAST blend actually DREW — the composite-regen change key: pointer-only motion
|
||||
/// produces no driver publish (the declared hardware cursor doesn't dirty frames), so
|
||||
/// `try_consume` regenerates from the last slot when this changes. `None` = the frame carries
|
||||
/// no pointer (hidden or no shape yet), which is why a HIDDEN pointer's position is not part
|
||||
/// 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.
|
||||
last_slot: Option<usize>,
|
||||
/// The target's SDR-white scale (vs 80 nits) for HDR cursor compositing — refreshed on
|
||||
@@ -1211,10 +1267,17 @@ impl IddPushCapturer {
|
||||
/// poller meant pointer-less frames, not a degraded pointer.
|
||||
fn live_cursor(&mut self) -> Option<pf_frame::CursorOverlay> {
|
||||
if !self.cursor_shm_latched {
|
||||
if let Some(p) = &self.cursor_poll {
|
||||
if p.alive() {
|
||||
return p.read();
|
||||
}
|
||||
// Sample the heartbeat and the snapshot together, then drop the borrow so the watch
|
||||
// can take `&mut self`. `alive()` is liveness only — `watch_cursor_publishes` is what
|
||||
// tells a working poller apart from a wedged one.
|
||||
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
|
||||
// poller that somehow reports alive again cannot re-cross the serial namespaces.
|
||||
@@ -1255,17 +1318,91 @@ impl IddPushCapturer {
|
||||
);
|
||||
}
|
||||
|
||||
/// The (serial, x, y, visible) of the CURRENT live cursor — the composite-regen change key.
|
||||
/// `None` while no source has a shape yet.
|
||||
fn cursor_blend_key(&mut self) -> Option<(u64, i32, i32, bool)> {
|
||||
self.live_cursor().map(|o| (o.serial, o.x, o.y, o.visible))
|
||||
/// Watch the GDI poller's heartbeat and log the transitions. The poller is the ONLY
|
||||
/// full-fidelity shape source (the driver's query is alpha-only — `cursor_poll.rs`), so a
|
||||
/// poller that is alive but no longer publishing freezes the pointer in every frame at its
|
||||
/// last sampled shape and position. That state used to be completely silent: `alive()` stays
|
||||
/// 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
|
||||
/// 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
|
||||
/// to the pointer-less slot (scratch/pass creation failed — warned once). A hidden pointer
|
||||
/// blends nothing (the plain copy is the correct frame).
|
||||
/// to the pointer-less slot, which is the correct frame whenever nothing would be drawn.
|
||||
///
|
||||
/// **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
|
||||
/// D3D11 calls on the owning capture/encode thread's device + immediate context, called
|
||||
@@ -1274,6 +1411,18 @@ impl IddPushCapturer {
|
||||
&mut self,
|
||||
slot_tex: &ID3D11Texture2D,
|
||||
) -> 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
|
||||
// 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
|
||||
@@ -1325,13 +1474,7 @@ impl IddPushCapturer {
|
||||
self.blend_scratch = Some((t, v, self.width, self.height, fmt));
|
||||
}
|
||||
None => {
|
||||
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"
|
||||
);
|
||||
}
|
||||
self.note_blend_failure("scratch creation failed");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
@@ -1339,38 +1482,33 @@ impl IddPushCapturer {
|
||||
let (tex, srv, ..) = self.blend_scratch.as_ref().expect("just ensured");
|
||||
let (tex, srv) = (tex.clone(), srv.clone());
|
||||
self.context.CopyResource(&tex, slot_tex);
|
||||
// Blend the pointer (visible shapes only; hidden = the copy alone is the frame).
|
||||
// Through `live_cursor`, so a dead poller degrades to the shm section HERE too — this
|
||||
// is the path that actually draws the pointer in the composite model, and the one that
|
||||
// used to read the poller unconditionally.
|
||||
let overlay = self.live_cursor();
|
||||
self.last_blend_key = overlay.as_ref().map(|o| (o.serial, o.x, o.y, o.visible));
|
||||
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:#}"
|
||||
);
|
||||
}
|
||||
// Draw `ov` — resolved and keyed at the top, where a hidden pointer already took the
|
||||
// early-out, so reaching here means there IS something to blend.
|
||||
if self.cursor_blend.is_none() {
|
||||
match cursor_blend::CursorBlendPass::new(&self.device) {
|
||||
Ok(p) => self.cursor_blend = Some(p),
|
||||
Err(e) => {
|
||||
self.note_blend_failure(&format!("blend pass build failed: {e:#}"));
|
||||
}
|
||||
}
|
||||
if let Some(pass) = self.cursor_blend.as_mut() {
|
||||
// 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.
|
||||
let scale = if self.display_hdr {
|
||||
self.sdr_white_scale
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
if let Err(e) = pass.blend(&self.device, &self.context, &tex, &ov, scale) {
|
||||
if !self.cursor_blend_failed {
|
||||
self.cursor_blend_failed = true;
|
||||
tracing::warn!("cursor blend draw failed — pointer-less frames: {e:#}");
|
||||
}
|
||||
}
|
||||
if let Some(pass) = self.cursor_blend.as_mut() {
|
||||
// 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.
|
||||
let scale = if self.display_hdr {
|
||||
self.sdr_white_scale
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
match pass.blend(&self.device, &self.context, &tex, &ov, scale) {
|
||||
// One good draw retires the whole failure record: whatever broke has healed,
|
||||
// and the next failure should get the SHORT retry, not the escalated one.
|
||||
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:#}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2075,6 +2213,84 @@ mod tests {
|
||||
use super::stall::Stall;
|
||||
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.
|
||||
///
|
||||
/// `IDD_GENERATION` is a full `u32` while `FrameToken` carries 24 bits and `unpack` MASKS what it
|
||||
|
||||
@@ -68,6 +68,11 @@ pub(super) struct CursorPoller {
|
||||
/// while the secure desktop needs the software-cursor path to render (see
|
||||
/// `IddPushCapturer::poll_secure_desktop`).
|
||||
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<()>>,
|
||||
}
|
||||
|
||||
@@ -106,10 +111,12 @@ impl CursorPoller {
|
||||
let slot: Arc<Mutex<Option<pf_frame::CursorOverlay>>> = Arc::new(Mutex::new(None));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let secure = Arc::new(AtomicBool::new(false));
|
||||
let (slot_t, stop_t, secure_t) = (slot.clone(), stop.clone(), secure.clone());
|
||||
let ticks = Arc::new(AtomicU64::new(0));
|
||||
let (slot_t, stop_t, secure_t, ticks_t) =
|
||||
(slot.clone(), stop.clone(), secure.clone(), ticks.clone());
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("pf-cursor-poll".into())
|
||||
.spawn(move || run(target_id, rect, &slot_t, &stop_t, &secure_t))
|
||||
.spawn(move || run(target_id, rect, &slot_t, &stop_t, &secure_t, &ticks_t))
|
||||
.ok();
|
||||
if thread.is_none() {
|
||||
tracing::warn!("cursor poller thread spawn failed — cursor falls back to driver shm");
|
||||
@@ -118,6 +125,7 @@ impl CursorPoller {
|
||||
slot,
|
||||
stop,
|
||||
secure,
|
||||
ticks,
|
||||
thread,
|
||||
}
|
||||
}
|
||||
@@ -133,7 +141,14 @@ impl CursorPoller {
|
||||
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.
|
||||
/// Note this is liveness, NOT health: see [`Self::publishes`].
|
||||
pub(super) fn alive(&self) -> bool {
|
||||
self.thread.as_ref().is_some_and(|t| !t.is_finished())
|
||||
}
|
||||
@@ -155,6 +170,7 @@ fn run(
|
||||
slot: &Mutex<Option<pf_frame::CursorOverlay>>,
|
||||
stop: &AtomicBool,
|
||||
secure: &AtomicBool,
|
||||
ticks: &AtomicU64,
|
||||
) {
|
||||
// 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
|
||||
@@ -306,6 +322,8 @@ fn run(
|
||||
}
|
||||
});
|
||||
*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,7 +656,9 @@ impl IddPushCapturer {
|
||||
composite_cursor: composite_forced,
|
||||
composite_forced,
|
||||
cursor_blend: None,
|
||||
cursor_blend_failed: false,
|
||||
blend_fail: None,
|
||||
cursor_poll_watch: (0, std::time::Instant::now()),
|
||||
cursor_poll_stalled: false,
|
||||
cursor_shm_latched: false,
|
||||
blend_scratch: None,
|
||||
last_blend_key: None,
|
||||
|
||||
@@ -421,6 +421,34 @@ pub fn hw_cursor_capable() -> bool {
|
||||
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> {
|
||||
VDM.get().and_then(VirtualDisplayManager::device_handle)
|
||||
}
|
||||
|
||||
@@ -158,6 +158,226 @@ enum AdapterCycle {
|
||||
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`
|
||||
/// 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
|
||||
@@ -353,6 +573,12 @@ pub unsafe fn send_cursor_channel(
|
||||
dev: HANDLE,
|
||||
req: &control::SetCursorChannelRequest,
|
||||
) -> 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] = [];
|
||||
// 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.
|
||||
@@ -679,6 +905,26 @@ impl VdisplayDriver for PfVdisplayDriver {
|
||||
client_hdr: Option<punktfunk_core::quic::HdrMeta>,
|
||||
hw_cursor: bool,
|
||||
) -> 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();
|
||||
// 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
|
||||
@@ -824,7 +1070,14 @@ impl VdisplayDriver for PfVdisplayDriver {
|
||||
tracing::info!(
|
||||
target_id = reply.target_id,
|
||||
adapter_luid = %format_args!("{:#x}", luid.LowPart),
|
||||
wudf_pid = reply.wudf_pid,
|
||||
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,
|
||||
"pf-vdisplay monitor created {}x{}@{}",
|
||||
mode.width,
|
||||
|
||||
@@ -382,6 +382,23 @@ fn real_main() -> Result<()> {
|
||||
// driver to a stray second host started while the service sat idle.
|
||||
#[cfg(target_os = "windows")]
|
||||
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
|
||||
// monitor devnodes a previous host disabled for an Exclusive session and never
|
||||
// restored (crash/kill/power loss) — before any new session touches the topology.
|
||||
|
||||
+3
-12
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@punktfunk/host",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.3",
|
||||
"description": "TypeScript SDK for the punktfunk streaming host: typed management-API client + lifecycle event stream, built on Effect.",
|
||||
"type": "module",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
@@ -13,13 +13,7 @@
|
||||
"bugs": {
|
||||
"url": "https://git.unom.io/unom/punktfunk/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"punktfunk",
|
||||
"game-streaming",
|
||||
"automation",
|
||||
"sdk",
|
||||
"effect"
|
||||
],
|
||||
"keywords": ["punktfunk", "game-streaming", "automation", "sdk", "effect"],
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
@@ -35,10 +29,7 @@
|
||||
"bin": {
|
||||
"punktfunk-scripting": "./dist/runner-cli.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"files": ["dist", "README.md"],
|
||||
"publishConfig": {
|
||||
"registry": "https://git.unom.io/api/packages/unom/npm/"
|
||||
},
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { configDir } from "./config.js";
|
||||
import { SDK_VERSION } from "./version.js";
|
||||
|
||||
/** The `@punktfunk` package registry (Gitea's npm registry for the `unom` org). */
|
||||
export const REGISTRY = "https://git.unom.io/api/packages/unom/npm/";
|
||||
@@ -188,109 +187,6 @@ const runBun = (action: "add" | "remove", pkgs: string[], opts: PkgOpts): void =
|
||||
}
|
||||
};
|
||||
|
||||
/** The SDK version installed in a plugins tree, or undefined if it isn't installed at all. */
|
||||
export const installedSdkVersion = (
|
||||
dir = pluginsDirDefault(),
|
||||
): string | undefined => {
|
||||
try {
|
||||
const manifest = path.join(
|
||||
dir,
|
||||
"node_modules",
|
||||
"@punktfunk",
|
||||
"host",
|
||||
"package.json",
|
||||
);
|
||||
const v = (
|
||||
JSON.parse(fs.readFileSync(manifest, "utf8")) as { version?: string }
|
||||
).version;
|
||||
return typeof v === "string" ? v : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Bring the plugins tree's `@punktfunk/host` up to the version THIS runner was built from.
|
||||
*
|
||||
* **Why this exists.** The SDK is the seam every plugin registers through, but each plugin resolves
|
||||
* it from the plugins tree, and `bun.lock` pins it to an exact version with an integrity hash. No
|
||||
* user-facing flow re-resolves that pin: installing a plugin, reinstalling it, even updating it to a
|
||||
* newer release all leave the SDK where it is, because the plugin's `^0.1.x` range is already
|
||||
* satisfied. Measured on 2026-08-08 — publishing `@punktfunk/host@0.1.3` (the release that lets a
|
||||
* library scanner register `category`, so it stays out of the console nav) reached **no existing
|
||||
* install**, and the only thing that moved it was deleting the lockfile by hand over ssh. Shipping a
|
||||
* fix that needs an ssh session is not shipping a fix.
|
||||
*
|
||||
* The runner is the right owner: it is bundled from this same `sdk/` at the host's release commit
|
||||
* (`packaging/arch/PKGBUILD` builds `src/runner-cli.ts` into the punktfunk-scripting package), so
|
||||
* `SDK_VERSION` is by construction the SDK that matches the host now on disk. A host upgrade then
|
||||
* carries the SDK with it and nobody touches a runner.
|
||||
*
|
||||
* **Why the whole lockfile.** A targeted `bun add @punktfunk/host@<v>` at the root does NOT work
|
||||
* while plugins still declare the SDK in their own `dependencies` (they do, though none import it):
|
||||
* bun honours their locked resolution and gives each plugin a private nested copy, which then
|
||||
* SHADOWS the root — measured, 5 nested copies. A lockless resolve hoists one copy for everyone,
|
||||
* also measured. Once the plugins drop that spurious dependency this can become the targeted form.
|
||||
*
|
||||
* Safety: the plugins' own versions are pinned exactly in the root `package.json`, so a re-resolve
|
||||
* cannot move them; only shared transitive deps float within their declared ranges. The lockfile is
|
||||
* backed up first and restored if the install fails, and any failure is logged and swallowed — a
|
||||
* dependency refresh must never stop the plugins that are already working from loading.
|
||||
*/
|
||||
export const reconcileSharedSdk = (
|
||||
dir = pluginsDirDefault(),
|
||||
log: (line: string) => void = (l) => console.log(l),
|
||||
): void => {
|
||||
const have = installedSdkVersion(dir);
|
||||
// Nothing installed = no plugins yet; the first `bun add` resolves the current SDK on its own.
|
||||
if (have === undefined || have === SDK_VERSION) return;
|
||||
|
||||
const lock = path.join(dir, "bun.lock");
|
||||
const backup = `${lock}.pf-bak`;
|
||||
log(
|
||||
`[plugins] @punktfunk/host ${have} installed, this host ships ${SDK_VERSION} — refreshing`,
|
||||
);
|
||||
let restore = false;
|
||||
try {
|
||||
if (fs.existsSync(lock)) {
|
||||
fs.copyFileSync(lock, backup);
|
||||
fs.rmSync(lock);
|
||||
restore = true;
|
||||
}
|
||||
const res = Bun.spawnSync([process.execPath, "install", "--ignore-scripts"], {
|
||||
cwd: dir,
|
||||
stdio: ["inherit", "inherit", "inherit"],
|
||||
});
|
||||
if (!res.success) {
|
||||
throw new Error(`bun install exited ${res.exitCode ?? "?"}`);
|
||||
}
|
||||
const now = installedSdkVersion(dir);
|
||||
if (now !== SDK_VERSION) {
|
||||
// The install "succeeded" and still did not deliver the version — better to sit on the
|
||||
// known-good tree than to keep a half-resolved one.
|
||||
throw new Error(`still ${now ?? "absent"} after install`);
|
||||
}
|
||||
restore = false;
|
||||
if (fs.existsSync(backup)) fs.rmSync(backup);
|
||||
log(`[plugins] @punktfunk/host is now ${SDK_VERSION}`);
|
||||
} catch (e) {
|
||||
log(
|
||||
`[plugins] WARNING: could not refresh @punktfunk/host (${
|
||||
e instanceof Error ? e.message : e
|
||||
}) — plugins keep running against ${have}`,
|
||||
);
|
||||
if (restore && fs.existsSync(backup)) {
|
||||
try {
|
||||
fs.copyFileSync(backup, lock);
|
||||
fs.rmSync(backup);
|
||||
} catch {
|
||||
// The backup is still on disk under its own name; say so rather than pretend.
|
||||
log(`[plugins] the previous lockfile is at ${backup}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** Install one or more plugins by friendly name or package. */
|
||||
export const addPlugins = (names: string[], opts: PkgOpts = {}): void => {
|
||||
const pkgs = names.map((n) => resolvePackage(n, opts));
|
||||
|
||||
+1
-13
@@ -23,12 +23,7 @@
|
||||
// package that may live on somebody else's registry — but they are ordinary CLI flags too.
|
||||
import { Effect, Fiber } from "effect";
|
||||
import { installLogShipper } from "./log-ship.js";
|
||||
import {
|
||||
addPlugins,
|
||||
listInstalled,
|
||||
reconcileSharedSdk,
|
||||
removePlugins,
|
||||
} from "./plugins.js";
|
||||
import { addPlugins, listInstalled, removePlugins } from "./plugins.js";
|
||||
import { discoverUnits, runner } from "./runner.js";
|
||||
|
||||
const arg = (flag: string): string | undefined => {
|
||||
@@ -171,13 +166,6 @@ const keepAlive = setInterval(() => {}, 2 ** 31 - 1);
|
||||
// a plugin failing to load are the first ones out.
|
||||
const shipper = installLogShipper();
|
||||
|
||||
// Before any plugin loads: make the tree's shared SDK the one this runner was built from. A host
|
||||
// upgrade is the only moment that can deliver an SDK fix to already-installed plugins, and this is
|
||||
// that moment — see `reconcileSharedSdk`. Deliberately AFTER the log shipper so the operator can
|
||||
// read what it did from the console's Logs page, and BEFORE `runner()` so plugins import the
|
||||
// refreshed copy rather than the one they were started with.
|
||||
reconcileSharedSdk(options.pluginsDir);
|
||||
|
||||
const fiber = Effect.runFork(runner(options));
|
||||
let stopping = false;
|
||||
const shutdown = (signal: string) => {
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
* The version of this SDK, as a value the bundled runner can read about ITSELF.
|
||||
*
|
||||
* A constant rather than an import of `package.json`: `tsconfig.build.json` sets `rootDir: "src"`,
|
||||
* so reaching one directory up breaks the npm build, and the runner ships as a single bundled
|
||||
* `runner-cli.js` with no `package.json` beside it (`/usr/share/punktfunk-scripting/`), so there is
|
||||
* nothing to read at runtime either. Inlining it at build time is the only form that survives both.
|
||||
*
|
||||
* `version.test.ts` fails if this and `package.json` disagree, so the duplication cannot rot.
|
||||
*/
|
||||
export const SDK_VERSION = "0.1.4";
|
||||
@@ -1,90 +0,0 @@
|
||||
// `reconcileSharedSdk` runs on EVERY runner start, so its no-op path is the safety-critical one:
|
||||
// a false positive deletes a working lockfile and re-resolves the whole tree on a box that was
|
||||
// fine. These tests pin the decision, not the install (which needs a registry).
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { installedSdkVersion, reconcileSharedSdk } from "../src/plugins.js";
|
||||
import { SDK_VERSION } from "../src/version.js";
|
||||
|
||||
const dirs: string[] = [];
|
||||
|
||||
/** A plugins tree whose installed `@punktfunk/host` is `version` (omit for "not installed"). */
|
||||
const tree = (version?: string): string => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pf-reconcile-"));
|
||||
dirs.push(dir);
|
||||
fs.writeFileSync(path.join(dir, "package.json"), '{"private":true}\n');
|
||||
fs.writeFileSync(path.join(dir, "bun.lock"), "ORIGINAL-LOCK\n");
|
||||
if (version !== undefined) {
|
||||
const host = path.join(dir, "node_modules", "@punktfunk", "host");
|
||||
fs.mkdirSync(host, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(host, "package.json"),
|
||||
JSON.stringify({ name: "@punktfunk/host", version }),
|
||||
);
|
||||
}
|
||||
return dir;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
for (const d of dirs.splice(0)) fs.rmSync(d, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("installedSdkVersion", () => {
|
||||
test("reads the installed version, and is undefined when absent", () => {
|
||||
expect(installedSdkVersion(tree("0.1.2"))).toBe("0.1.2");
|
||||
expect(installedSdkVersion(tree())).toBeUndefined();
|
||||
});
|
||||
|
||||
test("is undefined rather than throwing on a corrupt manifest", () => {
|
||||
const dir = tree("0.1.2");
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "node_modules", "@punktfunk", "host", "package.json"),
|
||||
"{ not json",
|
||||
);
|
||||
expect(installedSdkVersion(dir)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconcileSharedSdk", () => {
|
||||
// The common case, every start, on every healthy box: touch nothing.
|
||||
test("is a silent no-op when the installed SDK already matches", () => {
|
||||
const dir = tree(SDK_VERSION);
|
||||
const lines: string[] = [];
|
||||
reconcileSharedSdk(dir, (l) => lines.push(l));
|
||||
expect(fs.readFileSync(path.join(dir, "bun.lock"), "utf8")).toBe(
|
||||
"ORIGINAL-LOCK\n",
|
||||
);
|
||||
expect(lines).toEqual([]);
|
||||
});
|
||||
|
||||
// A tree with no SDK has no plugins yet — the first `bun add` resolves the current one, so
|
||||
// there is nothing to refresh and nothing to log about.
|
||||
test("is a silent no-op when no SDK is installed at all", () => {
|
||||
const dir = tree();
|
||||
const lines: string[] = [];
|
||||
reconcileSharedSdk(dir, (l) => lines.push(l));
|
||||
expect(fs.readFileSync(path.join(dir, "bun.lock"), "utf8")).toBe(
|
||||
"ORIGINAL-LOCK\n",
|
||||
);
|
||||
expect(lines).toEqual([]);
|
||||
});
|
||||
|
||||
// The failure path matters as much as the happy one: this runs unattended at boot, and the
|
||||
// tree it just took the lockfile away from is the one the operator's plugins load from. The
|
||||
// install cannot succeed here (the fake package.json resolves nothing), so this exercises the
|
||||
// real rollback.
|
||||
test("restores the lockfile and keeps going when the refresh fails", () => {
|
||||
const dir = tree("0.0.1-not-a-real-version");
|
||||
const lines: string[] = [];
|
||||
expect(() => reconcileSharedSdk(dir, (l) => lines.push(l))).not.toThrow();
|
||||
expect(fs.readFileSync(path.join(dir, "bun.lock"), "utf8")).toBe(
|
||||
"ORIGINAL-LOCK\n",
|
||||
);
|
||||
expect(lines.join("\n")).toContain("WARNING");
|
||||
// And it names both versions, so the log says what it was trying to do.
|
||||
expect(lines.join("\n")).toContain("0.0.1-not-a-real-version");
|
||||
expect(fs.existsSync(path.join(dir, "bun.lock.pf-bak"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
// The one thing that keeps `SDK_VERSION` honest. The runner compares it against the SDK actually
|
||||
// installed in the plugins tree and reinstalls on a mismatch, so a stale constant would either
|
||||
// reinstall forever (constant behind) or never deliver a fix (constant ahead of a release).
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { SDK_VERSION } from "../src/version.js";
|
||||
|
||||
describe("SDK_VERSION", () => {
|
||||
test("matches package.json — bump both or neither", () => {
|
||||
// Read rather than import: `tsconfig.build.json` pins `rootDir: "src"`, so a JSON import of
|
||||
// the manifest would not compile for the npm build even though bun would run it fine.
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
|
||||
) as { version: string };
|
||||
expect(SDK_VERSION).toBe(pkg.version);
|
||||
});
|
||||
|
||||
test("is a plain semver triple", () => {
|
||||
// The runner compares it to an installed version string, so anything with a range operator
|
||||
// (`^0.1.3`) would never compare equal and would reinstall on every start.
|
||||
expect(SDK_VERSION).toMatch(/^\d+\.\d+\.\d+$/);
|
||||
});
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
// 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}` };
|
||||
}
|
||||
});
|
||||
@@ -26,15 +26,9 @@ import { m } from "@/paraglide/messages";
|
||||
* 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
|
||||
* `GET/PUT /__config` from the kit, and the console renders whatever schema comes back. The browser
|
||||
* never learns the plugin's port or secret — the console reads it server-side over loopback.
|
||||
*
|
||||
* 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.
|
||||
* `GET/PUT /__config` from the kit, and the console renders whatever schema comes back. Everything
|
||||
* goes through the existing session-gated `/plugin-ui/<id>/…` proxy, so there is **zero new host
|
||||
* surface** — the browser never learns the plugin's port or secret.
|
||||
*
|
||||
* 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
|
||||
@@ -57,7 +51,7 @@ export const SourceSettingsDialog: FC<{
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/plugin-config/${pluginId}`, {
|
||||
const res = await fetch(`/plugin-ui/${pluginId}/__config`, {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
@@ -83,7 +77,7 @@ export const SourceSettingsDialog: FC<{
|
||||
const save = async (value: JsonObject) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(`/api/plugin-config/${pluginId}`, {
|
||||
const res = await fetch(`/plugin-ui/${pluginId}/__config`, {
|
||||
method: "PUT",
|
||||
credentials: "same-origin",
|
||||
headers: { "content-type": "application/json" },
|
||||
|
||||
Reference in New Issue
Block a user