feat(host,web): experimental PnP monitor-devnode disable for Exclusive sessions
ci / web (push) Successful in 1m0s
ci / docs-site (push) Successful in 1m6s
apple / swift (push) Successful in 1m9s
decky / build-publish (push) Successful in 22s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 47s
windows-host / package (push) Failing after 3m40s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m54s
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m21s
flatpak / build-publish (push) Failing after 2m26s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 1m2s
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
release / apple (push) Failing after 19m24s
ci / web (push) Successful in 1m0s
ci / docs-site (push) Successful in 1m6s
apple / swift (push) Successful in 1m9s
decky / build-publish (push) Successful in 22s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 47s
windows-host / package (push) Failing after 3m40s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m54s
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m21s
flatpak / build-publish (push) Failing after 2m26s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 1m2s
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
release / apple (push) Failing after 19m24s
Second experiment against the connected-but-dark-head stutter (field-proven on the reporter's box: unplugging his standby HDMI TV removes a metronomic ~4 s double-jolt; DDC/CI is a dead end for TVs — measured on the lab LG, VCP 0xD6 gets no I2C ACK). An Exclusive isolate only removes physical monitors from the CCD topology; their PnP devnodes stay live, so every standby wake (auto input scan, Instant-On HPD cycling) still triggers the full Windows reaction: PnP arrival/removal, CCD re-evaluation, DWM invalidation — the suspected hiccup mechanism (Apollo #368's Device-Manager-refresh signature). - New `pnp_disable_monitors` display-policy axis (default off): orthogonal to presets like game_session/ddc_power_off, surfaced in GET/PUT /display/settings + the enforced list, carried through the layout transform. - windows/monitor_devnode.rs: after the isolate takes, disable exactly the deactivated monitors' devnodes — CCD target → monitor device path (DISPLAYCONFIG_TARGET_DEVICE_NAME) → PnP instance id → CM_Disable_DevNode with CM_DISABLE_PERSIST, so a hot-plug RE-ARRIVAL stays disabled (that persistence is the whole point). Teardown re-enables BEFORE the CCD restore (+300 ms re-arrival settle) so restored paths have their monitors back. Precise selection — co-installed third-party virtual displays are never touched. - Crash safety: instance ids journal to <config>/pnp-disabled-monitors.json before disabling; serve startup re-enables leftovers from a crashed host. Worst case is documented in the console help (Device Manager re-enable). - Web console: second Experimental-badged toggle (the DDC block refactored into a shared ExperimentalToggle), EN/DE strings, preset-switch carry. Verified: Linux 263 tests + clippy + fmt clean; Windows (RTX box) 220/220 + clippy clean; web tsc + production build clean; openapi.json regenerated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2543,6 +2543,10 @@
|
|||||||
"mode_conflict": {
|
"mode_conflict": {
|
||||||
"$ref": "#/components/schemas/ModeConflict"
|
"$ref": "#/components/schemas/ModeConflict"
|
||||||
},
|
},
|
||||||
|
"pnp_disable_monitors": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "EXPERIMENTAL (Windows): after an `Exclusive` isolate deactivates the physical monitors,\nadditionally DISABLE their PnP device nodes (persistently, so a standby monitor/TV whose\nhot-plug events re-arrive stays disabled) and re-enable them at restore. Targets the same\n\"connected-but-dark head\" periodic-stutter class as [`Self::ddc_power_off`], but at the\nWindows-reaction level: a disabled devnode's wake events trigger no PnP arrival, no CCD\nre-evaluation, no DWM invalidation. A crash-recovery journal re-enables leftovers on host\nstartup. Orthogonal to `preset` (like `game_session`); `#[serde(default)]` = off."
|
||||||
|
},
|
||||||
"preset": {
|
"preset": {
|
||||||
"$ref": "#/components/schemas/Preset"
|
"$ref": "#/components/schemas/Preset"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ mod log_capture;
|
|||||||
mod metronome;
|
mod metronome;
|
||||||
mod mgmt;
|
mod mgmt;
|
||||||
mod mgmt_token;
|
mod mgmt_token;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[path = "windows/monitor_devnode.rs"]
|
||||||
|
mod monitor_devnode;
|
||||||
mod native_pairing;
|
mod native_pairing;
|
||||||
mod pipeline;
|
mod pipeline;
|
||||||
mod punktfunk1;
|
mod punktfunk1;
|
||||||
@@ -199,6 +202,11 @@ 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();
|
||||||
|
// 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.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
monitor_devnode::startup_recover();
|
||||||
gamestream::serve(mgmt_opts, native, gamestream)
|
gamestream::serve(mgmt_opts, native, gamestream)
|
||||||
}
|
}
|
||||||
// Print the management API's OpenAPI document (for client codegen).
|
// Print the management API's OpenAPI document (for client codegen).
|
||||||
|
|||||||
@@ -1054,6 +1054,7 @@ fn display_settings_state() -> DisplaySettingsState {
|
|||||||
// EXPERIMENTAL, Windows-only in effect: acted on at the `exclusive` isolate
|
// EXPERIMENTAL, Windows-only in effect: acted on at the `exclusive` isolate
|
||||||
// (`vdisplay/windows/manager.rs`); stored-but-inert elsewhere.
|
// (`vdisplay/windows/manager.rs`); stored-but-inert elsewhere.
|
||||||
"ddc_power_off".into(),
|
"ddc_power_off".into(),
|
||||||
|
"pnp_disable_monitors".into(),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1263,6 +1264,7 @@ async fn set_display_layout(ApiJson(req): ApiJson<DisplayLayoutRequest>) -> Resp
|
|||||||
req.positions,
|
req.positions,
|
||||||
store.game_session(),
|
store.game_session(),
|
||||||
store.ddc_power_off(),
|
store.ddc_power_off(),
|
||||||
|
store.pnp_disable_monitors(),
|
||||||
);
|
);
|
||||||
if let Err(e) = store.set(policy) {
|
if let Err(e) = store.set(policy) {
|
||||||
return api_error(
|
return api_error(
|
||||||
@@ -2948,8 +2950,9 @@ mod tests {
|
|||||||
assert!(enforced.contains(&"mode_conflict"));
|
assert!(enforced.contains(&"mode_conflict"));
|
||||||
assert!(enforced.contains(&"identity"));
|
assert!(enforced.contains(&"identity"));
|
||||||
assert!(enforced.contains(&"layout"));
|
assert!(enforced.contains(&"layout"));
|
||||||
// The experimental DDC/CI panel-off axis is acted on (Windows exclusive-isolate path).
|
// The experimental DDC/CI + PnP-disable axes are acted on (Windows exclusive-isolate path).
|
||||||
assert!(enforced.contains(&"ddc_power_off"));
|
assert!(enforced.contains(&"ddc_power_off"));
|
||||||
|
assert!(enforced.contains(&"pnp_disable_monitors"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The display state/release endpoints are wired + auth-gated. On the test host no backend has
|
/// The display state/release endpoints are wired + auth-gated. On the test host no backend has
|
||||||
|
|||||||
@@ -234,6 +234,15 @@ pub struct DisplayPolicy {
|
|||||||
/// untouched.
|
/// untouched.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub ddc_power_off: bool,
|
pub ddc_power_off: bool,
|
||||||
|
/// EXPERIMENTAL (Windows): after an `Exclusive` isolate deactivates the physical monitors,
|
||||||
|
/// additionally DISABLE their PnP device nodes (persistently, so a standby monitor/TV whose
|
||||||
|
/// hot-plug events re-arrive stays disabled) and re-enable them at restore. Targets the same
|
||||||
|
/// "connected-but-dark head" periodic-stutter class as [`Self::ddc_power_off`], but at the
|
||||||
|
/// Windows-reaction level: a disabled devnode's wake events trigger no PnP arrival, no CCD
|
||||||
|
/// re-evaluation, no DWM invalidation. A crash-recovery journal re-enables leftovers on host
|
||||||
|
/// startup. Orthogonal to `preset` (like `game_session`); `#[serde(default)]` = off.
|
||||||
|
#[serde(default)]
|
||||||
|
pub pnp_disable_monitors: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn one() -> u32 {
|
fn one() -> u32 {
|
||||||
@@ -258,6 +267,7 @@ impl Default for DisplayPolicy {
|
|||||||
max_displays: 4,
|
max_displays: 4,
|
||||||
game_session: GameSession::default(),
|
game_session: GameSession::default(),
|
||||||
ddc_power_off: false,
|
ddc_power_off: false,
|
||||||
|
pnp_disable_monitors: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -318,6 +328,7 @@ impl EffectivePolicy {
|
|||||||
positions: BTreeMap<String, Position>,
|
positions: BTreeMap<String, Position>,
|
||||||
game_session: GameSession,
|
game_session: GameSession,
|
||||||
ddc_power_off: bool,
|
ddc_power_off: bool,
|
||||||
|
pnp_disable_monitors: bool,
|
||||||
) -> DisplayPolicy {
|
) -> DisplayPolicy {
|
||||||
DisplayPolicy {
|
DisplayPolicy {
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -334,6 +345,7 @@ impl EffectivePolicy {
|
|||||||
// Preserve the orthogonal axes (EffectivePolicy doesn't carry them).
|
// Preserve the orthogonal axes (EffectivePolicy doesn't carry them).
|
||||||
game_session,
|
game_session,
|
||||||
ddc_power_off,
|
ddc_power_off,
|
||||||
|
pnp_disable_monitors,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -454,6 +466,13 @@ impl DisplayPolicyStore {
|
|||||||
self.get().ddc_power_off
|
self.get().ddc_power_off
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The experimental PnP monitor-devnode-disable axis — orthogonal to the preset (like
|
||||||
|
/// [`Self::game_session`]), read directly off the stored policy (default off when
|
||||||
|
/// unconfigured).
|
||||||
|
pub fn pnp_disable_monitors(&self) -> bool {
|
||||||
|
self.get().pnp_disable_monitors
|
||||||
|
}
|
||||||
|
|
||||||
/// Persist + adopt a new policy (sanitized first). The in-memory value changes only if the disk
|
/// Persist + adopt a new policy (sanitized first). The in-memory value changes only if the disk
|
||||||
/// write succeeds, so a full disk can't leave memory and file disagreeing.
|
/// write succeeds, so a full disk can't leave memory and file disagreeing.
|
||||||
pub fn set(&self, policy: DisplayPolicy) -> Result<()> {
|
pub fn set(&self, policy: DisplayPolicy) -> Result<()> {
|
||||||
@@ -769,10 +788,12 @@ mod tests {
|
|||||||
let mut positions = BTreeMap::new();
|
let mut positions = BTreeMap::new();
|
||||||
positions.insert("1".to_string(), Position { x: 0, y: 0 });
|
positions.insert("1".to_string(), Position { x: 0, y: 0 });
|
||||||
positions.insert("7".to_string(), Position { x: 2560, y: 0 });
|
positions.insert("7".to_string(), Position { x: 2560, y: 0 });
|
||||||
let p = eff.with_manual_layout(positions, GameSession::Dedicated, true);
|
let p = eff.with_manual_layout(positions, GameSession::Dedicated, true, true);
|
||||||
// The orthogonal axes (game-session, DDC power-off) are preserved through the transform.
|
// The orthogonal axes (game-session, DDC power-off, PnP disable) are preserved through
|
||||||
|
// the transform.
|
||||||
assert_eq!(p.game_session, GameSession::Dedicated);
|
assert_eq!(p.game_session, GameSession::Dedicated);
|
||||||
assert!(p.ddc_power_off);
|
assert!(p.ddc_power_off);
|
||||||
|
assert!(p.pnp_disable_monitors);
|
||||||
// Preset drops to Custom so the explicit fields (incl. the layout) rule…
|
// Preset drops to Custom so the explicit fields (incl. the layout) rule…
|
||||||
assert_eq!(p.preset, Preset::Custom);
|
assert_eq!(p.preset, Preset::Custom);
|
||||||
// …every other behavior axis is preserved verbatim…
|
// …every other behavior axis is preserved verbatim…
|
||||||
@@ -797,8 +818,9 @@ mod tests {
|
|||||||
assert_eq!(p.keep_alive, KeepAlive::default());
|
assert_eq!(p.keep_alive, KeepAlive::default());
|
||||||
assert_eq!(p.topology, Topology::Auto);
|
assert_eq!(p.topology, Topology::Auto);
|
||||||
assert_eq!(p.version, 1);
|
assert_eq!(p.version, 1);
|
||||||
// A file written before the experimental DDC axis existed defaults it OFF.
|
// A file written before the experimental DDC/PnP axes existed defaults them OFF.
|
||||||
assert!(!p.ddc_power_off);
|
assert!(!p.ddc_power_off);
|
||||||
|
assert!(!p.pnp_disable_monitors);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -121,6 +121,9 @@ struct Monitor {
|
|||||||
/// How many physical panels acknowledged the EXPERIMENTAL DDC/CI off command at this monitor's
|
/// How many physical panels acknowledged the EXPERIMENTAL DDC/CI off command at this monitor's
|
||||||
/// isolate (`ddc_power_off` policy axis) — teardown wakes them after the CCD restore iff > 0.
|
/// isolate (`ddc_power_off` policy axis) — teardown wakes them after the CCD restore iff > 0.
|
||||||
ddc_panels_off: u32,
|
ddc_panels_off: u32,
|
||||||
|
/// PnP instance ids of monitor devnodes the EXPERIMENTAL `pnp_disable_monitors` axis disabled
|
||||||
|
/// at this monitor's isolate — teardown re-enables them BEFORE the CCD restore.
|
||||||
|
pnp_disabled: Vec<String>,
|
||||||
/// Generation stamp; a [`MonitorLease`] only releases if its gen still matches (stale-lease no-op).
|
/// Generation stamp; a [`MonitorLease`] only releases if its gen still matches (stale-lease no-op).
|
||||||
gen: u64,
|
gen: u64,
|
||||||
}
|
}
|
||||||
@@ -672,6 +675,7 @@ impl VirtualDisplayManager {
|
|||||||
}
|
}
|
||||||
let mut ccd_saved: Option<SavedConfig> = None;
|
let mut ccd_saved: Option<SavedConfig> = None;
|
||||||
let mut ddc_panels_off = 0u32;
|
let mut ddc_panels_off = 0u32;
|
||||||
|
let mut pnp_disabled: Vec<String> = Vec::new();
|
||||||
match &gdi_name {
|
match &gdi_name {
|
||||||
Some(n) => {
|
Some(n) => {
|
||||||
tracing::info!(backend = self.driver.name(), "target {} -> {n}", added.target_id);
|
tracing::info!(backend = self.driver.name(), "target {} -> {n}", added.target_id);
|
||||||
@@ -699,6 +703,19 @@ impl VirtualDisplayManager {
|
|||||||
// `Copy` target id by value and returns an owned `SavedConfig` (no borrowed memory
|
// `Copy` target id by value and returns an owned `SavedConfig` (no borrowed memory
|
||||||
// crosses), under the `state` lock — the sole topology mutator.
|
// crosses), under the `state` lock — the sole topology mutator.
|
||||||
ccd_saved = unsafe { isolate_displays_ccd(added.target_id) };
|
ccd_saved = unsafe { isolate_displays_ccd(added.target_id) };
|
||||||
|
// EXPERIMENTAL `pnp_disable_monitors` policy axis: AFTER the isolate took,
|
||||||
|
// additionally disable the deactivated monitors' PnP devnodes (persistent
|
||||||
|
// across hot-plug re-arrival) so a standby monitor/TV's periodic wake
|
||||||
|
// events no longer trigger the Windows reaction cascade — the suspected
|
||||||
|
// hiccup mechanism (rationale + crash journal in `windows/monitor_devnode.rs`).
|
||||||
|
if crate::vdisplay::policy::prefs().pnp_disable_monitors() {
|
||||||
|
if let Some(saved) = &ccd_saved {
|
||||||
|
pnp_disabled = crate::monitor_devnode::disable_for_deactivated(
|
||||||
|
saved,
|
||||||
|
added.target_id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Topology::Primary => {
|
Topology::Primary => {
|
||||||
// On a headless box the IDD auto-activates as the SOLE display, so a physical
|
// On a headless box the IDD auto-activates as the SOLE display, so a physical
|
||||||
@@ -757,6 +774,7 @@ impl VirtualDisplayManager {
|
|||||||
pinger: Some(pinger),
|
pinger: Some(pinger),
|
||||||
ccd_saved,
|
ccd_saved,
|
||||||
ddc_panels_off,
|
ddc_panels_off,
|
||||||
|
pnp_disabled,
|
||||||
gen: self.gen.fetch_add(1, Ordering::Relaxed),
|
gen: self.gen.fetch_add(1, Ordering::Relaxed),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -818,6 +836,13 @@ impl VirtualDisplayManager {
|
|||||||
}
|
}
|
||||||
// Re-attach detached display(s) BEFORE the REMOVE so the box is never left with zero displays.
|
// Re-attach detached display(s) BEFORE the REMOVE so the box is never left with zero displays.
|
||||||
if let Some(saved) = &mon.ccd_saved {
|
if let Some(saved) = &mon.ccd_saved {
|
||||||
|
// EXPERIMENTAL `pnp_disable_monitors` restore: re-enable the devnodes FIRST and let
|
||||||
|
// them re-arrive, so the CCD restore below re-activates paths whose monitors exist
|
||||||
|
// again (a disabled devnode would leave the restored path modeless/EDID-less).
|
||||||
|
if !mon.pnp_disabled.is_empty() {
|
||||||
|
crate::monitor_devnode::enable_instances(&mon.pnp_disabled);
|
||||||
|
thread::sleep(Duration::from_millis(300));
|
||||||
|
}
|
||||||
restore_displays_ccd(saved);
|
restore_displays_ccd(saved);
|
||||||
// EXPERIMENTAL `ddc_power_off` wake: the restore re-activated the physical paths, and
|
// EXPERIMENTAL `ddc_power_off` wake: the restore re-activated the physical paths, and
|
||||||
// returning signal alone wakes DPMS-off panels on most firmware — the explicit ON is
|
// returning signal alone wakes DPMS-off panels on most firmware — the explicit ON is
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
//! PnP monitor-devnode disable — the EXPERIMENTAL `pnp_disable_monitors` display-policy axis.
|
||||||
|
//!
|
||||||
|
//! An `Exclusive` isolate removes the physical monitors from the desktop TOPOLOGY (CCD), but their
|
||||||
|
//! PnP device nodes stay live — so a standby monitor/TV that periodically wakes its connection
|
||||||
|
//! (auto input scan, Instant-On HPD cycling, DP link events) still triggers the full Windows
|
||||||
|
//! reaction each time: PnP arrival/removal, CCD re-evaluation, DWM invalidation. Field evidence
|
||||||
|
//! (Apollo #368's Device-Manager refresh at every hitch; our own reporter's TV where unplugging the
|
||||||
|
//! cable removes a metronomic ~4 s double-jolt) says this reaction cascade is the expensive part.
|
||||||
|
//!
|
||||||
|
//! This module disables the deactivated monitors' devnodes for the stream's duration
|
||||||
|
//! (`CM_Disable_DevNode` with `CM_DISABLE_PERSIST`, so a devnode that hot-plug re-arrives STAYS
|
||||||
|
//! disabled — that persistence is the whole point) and re-enables them at teardown before the CCD
|
||||||
|
//! restore. Selection is precise: only the monitors on targets the isolate actually deactivated,
|
||||||
|
//! mapped CCD target → monitor device interface path (`DISPLAYCONFIG_TARGET_DEVICE_NAME`) → PnP
|
||||||
|
//! instance id — never "every monitor but ours", so co-installed third-party virtual displays are
|
||||||
|
//! untouched.
|
||||||
|
//!
|
||||||
|
//! Crash safety: instance ids are journaled to `<config>/pnp-disabled-monitors.json` BEFORE the
|
||||||
|
//! disable and cleared after a successful re-enable; [`startup_recover`] re-enables leftovers when
|
||||||
|
//! the host starts after a crash/kill. Worst case (host dies AND never restarts) the monitor stays
|
||||||
|
//! disabled until the user re-enables it in Device Manager — the web-console help text says so.
|
||||||
|
|
||||||
|
use windows::core::PCWSTR;
|
||||||
|
use windows::Win32::Devices::DeviceAndDriverInstallation::{
|
||||||
|
CM_Disable_DevNode, CM_Enable_DevNode, CM_Locate_DevNodeW, CM_DISABLE_PERSIST,
|
||||||
|
CM_LOCATE_DEVNODE_NORMAL, CM_LOCATE_DEVNODE_PHANTOM, CR_SUCCESS,
|
||||||
|
};
|
||||||
|
use windows::Win32::Devices::Display::{
|
||||||
|
DisplayConfigGetDeviceInfo, DISPLAYCONFIG_DEVICE_INFO_GET_TARGET_NAME,
|
||||||
|
DISPLAYCONFIG_TARGET_DEVICE_NAME,
|
||||||
|
};
|
||||||
|
use windows::Win32::Foundation::LUID;
|
||||||
|
|
||||||
|
/// The crash-recovery journal: PnP instance ids we disabled and have not yet re-enabled.
|
||||||
|
fn journal_path() -> std::path::PathBuf {
|
||||||
|
crate::gamestream::config_dir().join("pnp-disabled-monitors.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_journal() -> Vec<String> {
|
||||||
|
match std::fs::read(journal_path()) {
|
||||||
|
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
|
||||||
|
Err(_) => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist `ids` as the outstanding-disable set (union semantics handled by the callers). Failure
|
||||||
|
/// is logged, not fatal — the feature degrades to "no crash journal", not "no feature".
|
||||||
|
fn write_journal(ids: &[String]) {
|
||||||
|
let path = journal_path();
|
||||||
|
if ids.is_empty() {
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(dir) = path.parent() {
|
||||||
|
let _ = crate::gamestream::create_private_dir(dir);
|
||||||
|
}
|
||||||
|
if let Err(e) = std::fs::write(&path, serde_json::to_vec_pretty(&ids).unwrap_or_default()) {
|
||||||
|
tracing::warn!(error = %e, "PnP-disable: could not write the crash-recovery journal");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `\\?\DISPLAY#GSM83CD#5&367fb4cb&0&UID4352#{guid}` → `DISPLAY\GSM83CD\5&367fb4cb&0&UID4352`.
|
||||||
|
/// The standard device-interface-path → instance-id transform: strip the `\\?\` prefix and the
|
||||||
|
/// trailing `#{interface-class-guid}`, then `#` separators become `\`.
|
||||||
|
fn instance_id_from_interface_path(path: &str) -> Option<String> {
|
||||||
|
let rest = path.strip_prefix(r"\\?\")?;
|
||||||
|
let cut = rest.rfind("#{")?;
|
||||||
|
Some(rest[..cut].replace('#', "\\"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn utf16z(buf: &[u16]) -> String {
|
||||||
|
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
|
||||||
|
String::from_utf16_lossy(&buf[..len])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a CCD target to its monitor's (instance id, friendly name).
|
||||||
|
fn monitor_instance(adapter: LUID, target_id: u32) -> Option<(String, String)> {
|
||||||
|
let mut req = DISPLAYCONFIG_TARGET_DEVICE_NAME::default();
|
||||||
|
req.header.r#type = DISPLAYCONFIG_DEVICE_INFO_GET_TARGET_NAME;
|
||||||
|
req.header.size = std::mem::size_of::<DISPLAYCONFIG_TARGET_DEVICE_NAME>() as u32;
|
||||||
|
req.header.adapterId = adapter;
|
||||||
|
req.header.id = target_id;
|
||||||
|
// SAFETY: `req` is a properly-sized DISPLAYCONFIG_TARGET_DEVICE_NAME local whose header
|
||||||
|
// (type/size/adapterId/id) is fully initialised; the API writes only within the struct.
|
||||||
|
let rc = unsafe { DisplayConfigGetDeviceInfo(&mut req.header) };
|
||||||
|
if rc != 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let id = instance_id_from_interface_path(&utf16z(&req.monitorDevicePath))?;
|
||||||
|
Some((id, utf16z(&req.monitorFriendlyDeviceName)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply enable/disable to one PnP instance id. Returns whether the config action succeeded.
|
||||||
|
fn set_devnode(id: &str, disable: bool) -> bool {
|
||||||
|
let wide: Vec<u16> = id.encode_utf16().chain([0]).collect();
|
||||||
|
let mut devinst = 0u32;
|
||||||
|
// A disabled (or currently-departed) devnode may not be in the live tree — locate PHANTOM for
|
||||||
|
// the enable path so recovery still finds it; the disable path requires a present device.
|
||||||
|
let flags = if disable {
|
||||||
|
CM_LOCATE_DEVNODE_NORMAL
|
||||||
|
} else {
|
||||||
|
CM_LOCATE_DEVNODE_PHANTOM
|
||||||
|
};
|
||||||
|
// SAFETY: `wide` is a live NUL-terminated UTF-16 instance id outliving the call; `devinst` is
|
||||||
|
// a valid out-param.
|
||||||
|
let cr = unsafe { CM_Locate_DevNodeW(&mut devinst, PCWSTR(wide.as_ptr()), flags) };
|
||||||
|
if cr != CR_SUCCESS {
|
||||||
|
tracing::warn!(id, cr = cr.0, "PnP-disable: CM_Locate_DevNodeW failed");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// SAFETY: `devinst` is the devnode the locate above resolved; plain value flags.
|
||||||
|
let cr = unsafe {
|
||||||
|
if disable {
|
||||||
|
// PERSIST is the point: the standby monitor's hot-plug RE-ARRIVAL must stay disabled,
|
||||||
|
// otherwise every wake event recreates an enabled devnode and the churn is back.
|
||||||
|
CM_Disable_DevNode(devinst, CM_DISABLE_PERSIST)
|
||||||
|
} else {
|
||||||
|
CM_Enable_DevNode(devinst, 0)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if cr != CR_SUCCESS {
|
||||||
|
tracing::warn!(
|
||||||
|
id,
|
||||||
|
cr = cr.0,
|
||||||
|
disable,
|
||||||
|
"PnP-disable: CM_{}_DevNode failed",
|
||||||
|
if disable { "Disable" } else { "Enable" }
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disable the devnodes of every monitor the `Exclusive` isolate deactivated: all ACTIVE paths in
|
||||||
|
/// the pre-isolate snapshot whose target is not `keep_target_id`. Journals BEFORE disabling.
|
||||||
|
/// Returns the instance ids to re-enable at teardown.
|
||||||
|
pub fn disable_for_deactivated(
|
||||||
|
saved: &crate::win_display::SavedConfig,
|
||||||
|
keep_target_id: u32,
|
||||||
|
) -> Vec<String> {
|
||||||
|
const DISPLAYCONFIG_PATH_ACTIVE: u32 = 0x0000_0001;
|
||||||
|
let mut targets: Vec<(String, String)> = Vec::new();
|
||||||
|
for p in &saved.0 {
|
||||||
|
if p.targetInfo.id == keep_target_id || p.flags & DISPLAYCONFIG_PATH_ACTIVE == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match monitor_instance(p.targetInfo.adapterId, p.targetInfo.id) {
|
||||||
|
Some(hit) => {
|
||||||
|
if !targets.contains(&hit) {
|
||||||
|
targets.push(hit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => tracing::debug!(
|
||||||
|
target_id = p.targetInfo.id,
|
||||||
|
"PnP-disable: no monitor device name for deactivated target — skipping"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if targets.is_empty() {
|
||||||
|
tracing::info!("PnP-disable: no physical monitor devnodes to disable");
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
// Journal FIRST (union with any outstanding ids), so a crash between here and the disable
|
||||||
|
// over-recovers instead of leaking a disabled monitor.
|
||||||
|
let mut journal = read_journal();
|
||||||
|
for (id, _) in &targets {
|
||||||
|
if !journal.contains(id) {
|
||||||
|
journal.push(id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
write_journal(&journal);
|
||||||
|
let mut disabled = Vec::new();
|
||||||
|
for (id, name) in targets {
|
||||||
|
if set_devnode(&id, true) {
|
||||||
|
tracing::info!(id, monitor = name, "PnP-disable: monitor devnode disabled");
|
||||||
|
disabled.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
disabled
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-enable `ids` (teardown / recovery) and clear them from the journal.
|
||||||
|
pub fn enable_instances(ids: &[String]) -> u32 {
|
||||||
|
let mut ok = 0u32;
|
||||||
|
for id in ids {
|
||||||
|
if set_devnode(id, false) {
|
||||||
|
tracing::info!(id, "PnP-disable: monitor devnode re-enabled");
|
||||||
|
ok += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let journal: Vec<String> = read_journal()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|j| !ids.contains(j))
|
||||||
|
.collect();
|
||||||
|
write_journal(&journal);
|
||||||
|
ok
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Host-startup crash recovery: re-enable any devnodes a previous host disabled but never
|
||||||
|
/// restored (crash, kill, power loss). Call once, early in `serve`.
|
||||||
|
pub fn startup_recover() {
|
||||||
|
let leftovers = read_journal();
|
||||||
|
if leftovers.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tracing::warn!(
|
||||||
|
count = leftovers.len(),
|
||||||
|
"PnP-disable: found monitor devnodes a previous host left disabled — re-enabling"
|
||||||
|
);
|
||||||
|
enable_instances(&leftovers);
|
||||||
|
}
|
||||||
@@ -108,6 +108,11 @@
|
|||||||
"display_ddc_disabled": "Aus",
|
"display_ddc_disabled": "Aus",
|
||||||
"display_ddc_enabled": "Ein",
|
"display_ddc_enabled": "Ein",
|
||||||
"display_ddc_badge": "Monitore aus via DDC/CI",
|
"display_ddc_badge": "Monitore aus via DDC/CI",
|
||||||
|
"display_pnp": "Monitor-Geräte beim Streamen deaktivieren (PnP)",
|
||||||
|
"display_pnp_help": "Nur Windows, wirkt bei Topologie Exklusiv. Zusätzlich zum Entfernen der physischen Monitore vom Desktop deaktiviert der Host deren Windows-Geräteeinträge für die Dauer des Streams und aktiviert sie danach wieder. Ein Monitor oder Fernseher im Standby, der seine Verbindung immer wieder aufweckt (automatische Eingangssuche, Instant-On), kann den Stream so nicht mehr stören — Windows ignoriert seine Aufwach-Ereignisse vollständig, solange das Gerät deaktiviert ist. Stürzt der Host mitten im Stream ab, werden die Monitore beim nächsten Start wieder aktiviert; bis dahin lassen sie sich manuell im Geräte-Manager aktivieren.",
|
||||||
|
"display_pnp_disabled": "Aus",
|
||||||
|
"display_pnp_enabled": "Ein",
|
||||||
|
"display_pnp_badge": "Monitor-Geräte deaktiviert (PnP)",
|
||||||
"display_identity_shared": "Geteilt",
|
"display_identity_shared": "Geteilt",
|
||||||
"display_identity_per_client": "Pro Client",
|
"display_identity_per_client": "Pro Client",
|
||||||
"display_identity_per_client_mode": "Pro Client + Auflösung",
|
"display_identity_per_client_mode": "Pro Client + Auflösung",
|
||||||
|
|||||||
@@ -108,6 +108,11 @@
|
|||||||
"display_ddc_disabled": "Off",
|
"display_ddc_disabled": "Off",
|
||||||
"display_ddc_enabled": "On",
|
"display_ddc_enabled": "On",
|
||||||
"display_ddc_badge": "Monitors off via DDC/CI",
|
"display_ddc_badge": "Monitors off via DDC/CI",
|
||||||
|
"display_pnp": "Disable monitor devices while streaming (PnP)",
|
||||||
|
"display_pnp_help": "Windows only, takes effect with Exclusive topology. On top of removing your physical monitors from the desktop, the host disables their Windows device entries for the duration of the stream and re-enables them afterwards. A standby monitor or TV that keeps waking its connection (auto input scan, instant-on) can no longer interrupt the stream — Windows ignores its wake events entirely while the device is disabled. If the host crashes mid-stream, the monitors are re-enabled the next time it starts; until then you can re-enable them manually in Device Manager.",
|
||||||
|
"display_pnp_disabled": "Off",
|
||||||
|
"display_pnp_enabled": "On",
|
||||||
|
"display_pnp_badge": "Monitor devices disabled (PnP)",
|
||||||
"display_identity_shared": "Shared",
|
"display_identity_shared": "Shared",
|
||||||
"display_identity_per_client": "Per client",
|
"display_identity_per_client": "Per client",
|
||||||
"display_identity_per_client_mode": "Per client + resolution",
|
"display_identity_per_client_mode": "Per client + resolution",
|
||||||
|
|||||||
@@ -165,9 +165,11 @@ const DisplayForm: FC<{
|
|||||||
identity: effective.identity,
|
identity: effective.identity,
|
||||||
layout: effective.layout,
|
layout: effective.layout,
|
||||||
max_displays: effective.max_displays,
|
max_displays: effective.max_displays,
|
||||||
// Game-session + DDC are orthogonal to the preset — carry them through the Custom switch.
|
// Game-session + the experimental axes are orthogonal to the preset — carry them
|
||||||
|
// through the Custom switch.
|
||||||
game_session: draft.game_session ?? "auto",
|
game_session: draft.game_session ?? "auto",
|
||||||
ddc_power_off: draft.ddc_power_off ?? false,
|
ddc_power_off: draft.ddc_power_off ?? false,
|
||||||
|
pnp_disable_monitors: draft.pnp_disable_monitors ?? false,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
apply({ ...draft, preset: id as Preset });
|
apply({ ...draft, preset: id as Preset });
|
||||||
@@ -182,8 +184,9 @@ const DisplayForm: FC<{
|
|||||||
preset: "custom",
|
preset: "custom",
|
||||||
...p.fields,
|
...p.fields,
|
||||||
game_session: p.game_session ?? "auto",
|
game_session: p.game_session ?? "auto",
|
||||||
// The experimental DDC axis isn't part of a preset — keep the current setting.
|
// The experimental axes aren't part of a preset — keep the current settings.
|
||||||
ddc_power_off: draft.ddc_power_off ?? false,
|
ddc_power_off: draft.ddc_power_off ?? false,
|
||||||
|
pnp_disable_monitors: draft.pnp_disable_monitors ?? false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// A custom card is "current" when the in-force policy is a Custom one whose fields + game-session
|
// A custom card is "current" when the in-force policy is a Custom one whose fields + game-session
|
||||||
@@ -467,36 +470,34 @@ const DisplayForm: FC<{
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* EXPERIMENTAL: DDC/CI panel power-off — orthogonal like game-session (survives preset
|
{/* EXPERIMENTAL toggles — orthogonal like game-session (survive preset switches, apply
|
||||||
switches, applies immediately). Windows-only in effect, acted on at the Exclusive isolate. */}
|
immediately). Windows-only in effect, acted on at the Exclusive isolate. */}
|
||||||
<div className="border-t pt-4">
|
<ExperimentalToggle
|
||||||
<div className="space-y-3">
|
label={m.display_ddc()}
|
||||||
<div className="flex items-center gap-2">
|
help={m.display_ddc_help()}
|
||||||
<Label className="block">{m.display_ddc()}</Label>
|
value={draft.ddc_power_off ?? false}
|
||||||
<Badge variant="outline" className="text-amber-600 dark:text-amber-500">
|
offLabel={m.display_ddc_disabled()}
|
||||||
{m.display_experimental()}
|
onLabel={m.display_ddc_enabled()}
|
||||||
</Badge>
|
busy={busy}
|
||||||
</div>
|
onSet={(on) => {
|
||||||
<div className="flex flex-wrap gap-2">
|
const next = { ...draft, ddc_power_off: on };
|
||||||
{([false, true] as const).map((on) => (
|
setDraft(next);
|
||||||
<Button
|
apply(next);
|
||||||
key={String(on)}
|
}}
|
||||||
size="sm"
|
/>
|
||||||
variant={(draft.ddc_power_off ?? false) === on ? "default" : "outline"}
|
<ExperimentalToggle
|
||||||
disabled={busy}
|
label={m.display_pnp()}
|
||||||
onClick={() => {
|
help={m.display_pnp_help()}
|
||||||
const next = { ...draft, ddc_power_off: on };
|
value={draft.pnp_disable_monitors ?? false}
|
||||||
setDraft(next);
|
offLabel={m.display_pnp_disabled()}
|
||||||
apply(next);
|
onLabel={m.display_pnp_enabled()}
|
||||||
}}
|
busy={busy}
|
||||||
>
|
onSet={(on) => {
|
||||||
{on ? m.display_ddc_enabled() : m.display_ddc_disabled()}
|
const next = { ...draft, pnp_disable_monitors: on };
|
||||||
</Button>
|
setDraft(next);
|
||||||
))}
|
apply(next);
|
||||||
</div>
|
}}
|
||||||
<p className="max-w-prose text-xs text-muted-foreground">{m.display_ddc_help()}</p>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* What's in force right now */}
|
{/* What's in force right now */}
|
||||||
<div className="flex flex-wrap items-center gap-2 border-t pt-3">
|
<div className="flex flex-wrap items-center gap-2 border-t pt-3">
|
||||||
@@ -513,6 +514,9 @@ const DisplayForm: FC<{
|
|||||||
{(draft.ddc_power_off ?? false) && (
|
{(draft.ddc_power_off ?? false) && (
|
||||||
<Badge variant="outline">{m.display_ddc_badge()}</Badge>
|
<Badge variant="outline">{m.display_ddc_badge()}</Badge>
|
||||||
)}
|
)}
|
||||||
|
{(draft.pnp_disable_monitors ?? false) && (
|
||||||
|
<Badge variant="outline">{m.display_pnp_badge()}</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="max-w-prose text-xs text-muted-foreground">{m.display_pending_note()}</p>
|
<p className="max-w-prose text-xs text-muted-foreground">{m.display_pending_note()}</p>
|
||||||
@@ -535,6 +539,45 @@ const Field: FC<{ label: string; help?: string; children: ReactNode }> = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An Experimental-badged on/off policy toggle (the DDC/CI and PnP monitor axes) — rendered outside
|
||||||
|
* the Custom block like the game-session axis: survives preset switches and applies immediately.
|
||||||
|
*/
|
||||||
|
const ExperimentalToggle: FC<{
|
||||||
|
label: string;
|
||||||
|
help: string;
|
||||||
|
value: boolean;
|
||||||
|
offLabel: string;
|
||||||
|
onLabel: string;
|
||||||
|
busy: boolean;
|
||||||
|
onSet: (v: boolean) => void;
|
||||||
|
}> = ({ label, help, value, offLabel, onLabel, busy, onSet }) => (
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="block">{label}</Label>
|
||||||
|
<Badge variant="outline" className="text-amber-600 dark:text-amber-500">
|
||||||
|
{m.display_experimental()}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{([false, true] as const).map((on) => (
|
||||||
|
<Button
|
||||||
|
key={String(on)}
|
||||||
|
size="sm"
|
||||||
|
variant={value === on ? "default" : "outline"}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onSet(on)}
|
||||||
|
>
|
||||||
|
{on ? onLabel : offLabel}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="max-w-prose text-xs text-muted-foreground">{help}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
/** A [`Field`] whose control is a row of mutually-exclusive option buttons (topology / conflict / …). */
|
/** A [`Field`] whose control is a row of mutually-exclusive option buttons (topology / conflict / …). */
|
||||||
const Choice: FC<{
|
const Choice: FC<{
|
||||||
label: string;
|
label: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user