From 6e4dc9510129f1558060932ffb3929a36073a3af Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Thu, 22 Oct 2026 16:09:38 -0400 Subject: [PATCH 01/39] Fail the display task when the virtual display cannot appear --- installer/scripts/vdd-toggle.ps1 | 48 ++++++++++--- scripts/win/Test-VddToggle.ps1 | 114 ++++++++++++++++++++++++++----- 2 files changed, 137 insertions(+), 25 deletions(-) diff --git a/installer/scripts/vdd-toggle.ps1 b/installer/scripts/vdd-toggle.ps1 index 52c967bd..9abc3e89 100644 --- a/installer/scripts/vdd-toggle.ps1 +++ b/installer/scripts/vdd-toggle.ps1 @@ -7,10 +7,18 @@ # correct across driver versions, friendly-name changes, and PnP enumeration order. The # VirtualDrivers/Virtual-Display-Driver enumerates under ROOT\DISPLAY and reports a friendly # name containing "Virtual Display", so we match on either — a real monitor is never ROOT\. +# +# Enable succeeds only when the display can appear: the device has its driver, no problem code, +# is started, and its monitor has arrived. Otherwise the exit code says why, and the host reads +# it as the task's result (host/src/vdd_device.rs): +# 2 no device 3 no driver bound 4 not started 5 no monitor arrived +# 100 + N Windows problem code N (122: still disabled) param( [Parameter(Mandatory = $true)] [ValidateSet('enable', 'disable')] - [string]$Action + [string]$Action, + # How long an enabled device may take to start and add its monitor. + [int]$StartSeconds = 8 ) $ErrorActionPreference = 'Stop' @@ -22,21 +30,45 @@ $devices = @(Get-PnpDevice -Class Display -PresentOnly | Where-Object { if ($devices.Count -eq 0) { if ($Action -eq 'disable') { exit 0 } - throw 'The bundled Virtual Display Driver device is not present. Reinstall EternalMonitor with its display driver.' + [Console]::Error.WriteLine('The bundled Virtual Display Driver device is not present. Run EternalMonitor Setup again to reinstall it.') + exit 2 +} + +# Why an enabled device cannot show a display yet, or $null when it can. +function Get-EnableProblem($instance) { + $inf = (Get-PnpDeviceProperty -InstanceId $instance -KeyName 'DEVPKEY_Device_DriverInfPath').Data + if ($inf -notmatch '^oem[0-9]+\.inf$') { return @{Code=3; Text="$instance has no driver bound"} } + $device = Get-PnpDevice -InstanceId $instance + if ([int]$device.Problem -ne 0) { + return @{Code=100 + [int]$device.Problem; Text="$instance reports problem code $([int]$device.Problem)"} + } + if ($device.Status -ne 'OK') { return @{Code=4; Text="$instance has not started"} } + $children = @((Get-PnpDeviceProperty -InstanceId $instance -KeyName 'DEVPKEY_Device_Children').Data) + $monitor = $children | Where-Object { $_ } | Where-Object { (Get-PnpDevice -InstanceId $_).Status -eq 'OK' } + if (!$monitor) { return @{Code=5; Text="$instance started, but no monitor arrived"} } + return $null } foreach ($device in $devices) { # PnPUtil can report this root device as disconnected even when its # present devnode is disabled. The PnpDevice cmdlets address that devnode. if ($Action -eq 'enable') { + # A device the Disable task turned off reports code 22 until this runs. Enable-PnpDevice -InstanceId $device.InstanceId -Confirm:$false + $deadline = (Get-Date).AddSeconds($StartSeconds) + while (($problem = Get-EnableProblem $device.InstanceId) -and (Get-Date) -lt $deadline) { + Start-Sleep -Milliseconds 200 + } + if ($problem) { + [Console]::Error.WriteLine("Virtual display enable failed: $($problem.Text)") + exit $problem.Code + } } else { Disable-PnpDevice -InstanceId $device.InstanceId -Confirm:$false + $actual = Get-PnpDevice -InstanceId $device.InstanceId + if ([int]$actual.Problem -ne 22) { + throw "Virtual display disable failed: $($actual.InstanceId) reports $($actual.Problem)" + } } - $actual = Get-PnpDevice -InstanceId $device.InstanceId - $expectedProblem = if ($Action -eq 'enable') { 0 } else { 22 } - if ([int]$actual.Problem -ne $expectedProblem) { - throw "Virtual display $Action failed: $($actual.InstanceId) reports $($actual.Problem)" - } - Write-Output "Virtual display $Action completed: $($actual.InstanceId)" + Write-Output "Virtual display $Action completed: $($device.InstanceId)" } diff --git a/scripts/win/Test-VddToggle.ps1 b/scripts/win/Test-VddToggle.ps1 index 6973c38a..5f227df5 100644 --- a/scripts/win/Test-VddToggle.ps1 +++ b/scripts/win/Test-VddToggle.ps1 @@ -2,50 +2,130 @@ param([string]$TogglePath) $ErrorActionPreference = 'Stop' $toggle = if ($TogglePath) { $TogglePath } else { Join-Path $PSScriptRoot '..\..\installer\scripts\vdd-toggle.ps1' } -$global:EMVddTestState = @{ Calls=@(); Fail=$false; Ignore=$false; Devices=@( - [pscustomobject]@{InstanceId='PCI\REAL_GPU';HardwareIds=@('PCI\GPU');Problem=0}, - [pscustomobject]@{InstanceId='ROOT\DISPLAY\0001';HardwareIds=@('Root\OtherDriver');Problem=0}, - [pscustomobject]@{InstanceId='ROOT\DISPLAY\0007';HardwareIds=@('Root\MttVDD');Problem=22} -) } +function Reset-State { + $global:EMVddTestState = @{ Calls=@(); Fail=$false; Ignore=$false + # What the device reports once Enable-PnpDevice has run. + Enabled=@{Problem=0; Status='OK'; Monitor='OK'} + Devices=@( + [pscustomobject]@{InstanceId='PCI\REAL_GPU';HardwareIds=@('PCI\GPU');Problem=0;Status='OK';Inf='oem1.inf';Children=@()}, + [pscustomobject]@{InstanceId='ROOT\DISPLAY\0001';HardwareIds=@('Root\OtherDriver');Problem=0;Status='OK';Inf='oem2.inf';Children=@()}, + [pscustomobject]@{InstanceId='ROOT\DISPLAY\0007';HardwareIds=@('Root\MttVDD');Problem=22;Status='Error';Inf='oem33.inf';Children=@()}, + [pscustomobject]@{InstanceId='DISPLAY\MTT1337\1&2&3&UID256';HardwareIds=@('MONITOR\MTT1337');Problem=24;Status='Unknown';Inf='monitor.inf';Children=@()} + ) } +} function Get-PnpDevice { param($Class, [switch]$PresentOnly, $InstanceId) - $global:EMVddTestState.Devices | Where-Object { !$InstanceId -or $_.InstanceId -eq $InstanceId } + $global:EMVddTestState.Devices | Where-Object { + ($InstanceId -and $_.InstanceId -eq $InstanceId) -or (!$InstanceId -and $_.InstanceId -notlike 'DISPLAY\*') + } } function Get-PnpDeviceProperty { param($InstanceId, $KeyName) - [pscustomobject]@{Data=(Get-PnpDevice -InstanceId $InstanceId).HardwareIds} + $device = Get-PnpDevice -InstanceId $InstanceId + $data = switch ($KeyName) { + 'DEVPKEY_Device_HardwareIds' { $device.HardwareIds } + 'DEVPKEY_Device_DriverInfPath' { $device.Inf } + 'DEVPKEY_Device_Children' { $device.Children } + default { throw "Unexpected property $KeyName" } + } + [pscustomobject]@{Data=$data} } function Enable-PnpDevice { param($InstanceId, $Confirm) if ($global:EMVddTestState.Fail) { throw 'mock driver failure' } $global:EMVddTestState.Calls += "enable:$InstanceId" - if (!$global:EMVddTestState.Ignore) { (Get-PnpDevice -InstanceId $InstanceId).Problem = 0 } + if ($global:EMVddTestState.Ignore) { return } + $device = Get-PnpDevice -InstanceId $InstanceId + $enabled = $global:EMVddTestState.Enabled + $device.Problem = $enabled.Problem + $device.Status = $enabled.Status + $monitor = Get-PnpDevice -InstanceId 'DISPLAY\MTT1337\1&2&3&UID256' + if ($enabled.Monitor) { + $device.Children = @($monitor.InstanceId) + $monitor.Status = $enabled.Monitor + } } function Disable-PnpDevice { param($InstanceId, $Confirm) $global:EMVddTestState.Calls += "disable:$InstanceId" - (Get-PnpDevice -InstanceId $InstanceId).Problem = 22 + $device = Get-PnpDevice -InstanceId $InstanceId + $device.Problem = 22 + $device.Status = 'Error' + $device.Children = @() } function Expect-Failure([scriptblock]$Run, [string]$Message) { $caught = $null try { & $Run } catch { $caught = $_.Exception.Message } if (!$caught -or !$caught.Contains($Message)) { throw "Expected failure containing '$Message', got '$caught'" } } +# Runs one enable with a short start wait, and checks its exit code. +function Expect-Enable([int]$Code, [string]$Case) { + $global:LASTEXITCODE = 0 + & $toggle -Action enable -StartSeconds 0 2>$null | Out-Null + if ($LASTEXITCODE -ne $Code) { throw "$Case`: expected exit $Code, got $LASTEXITCODE" } +} try { - & $toggle -Action enable - & $toggle -Action disable + Reset-State + # The Disable task leaves the device at code 22; Enable turns it back on. + $global:LASTEXITCODE = 0 + & $toggle -Action enable | Out-Null + if ($LASTEXITCODE -ne 0) { throw "A disabled device was not re-enabled: exit $LASTEXITCODE" } + & $toggle -Action disable | Out-Null if (($global:EMVddTestState.Calls -join ',') -ne 'enable:ROOT\DISPLAY\0007,disable:ROOT\DISPLAY\0007') { throw 'The toggle selected another GPU or a different virtual driver' } + $vdd = Get-PnpDevice -InstanceId 'ROOT\DISPLAY\0007' + if ($vdd.Problem -ne 22) { throw 'Disable did not leave code 22' } + $global:EMVddTestState.Fail = $true Expect-Failure { & $toggle -Action enable } 'mock driver failure' $global:EMVddTestState.Fail = $false + + # Enable-PnpDevice returned, but Windows kept the device off. $global:EMVddTestState.Ignore = $true - Expect-Failure { & $toggle -Action enable } 'reports 22' - $global:EMVddTestState.Devices = @() - Expect-Failure { & $toggle -Action enable } 'not present' - & $toggle -Action disable - Write-Output 'VDD toggle checks passed: selection, failure, state verification, absent device' + Expect-Enable 122 'still disabled' + $global:EMVddTestState.Ignore = $false + + # Problem codes Windows reports once the device is on: driver error, start + # failure, unverified signature. + foreach ($code in 43, 31, 10, 52, 28) { + Reset-State + $global:EMVddTestState.Enabled = @{Problem=$code; Status='Error'; Monitor=$null} + Expect-Enable (100 + $code) "problem $code" + } + + Reset-State + $global:EMVddTestState.Enabled = @{Problem=0; Status='Unknown'; Monitor=$null} + Expect-Enable 4 'not started' + + Reset-State + $global:EMVddTestState.Enabled = @{Problem=0; Status='OK'; Monitor=$null} + Expect-Enable 5 'no monitor' + $global:EMVddTestState.Enabled = @{Problem=0; Status='OK'; Monitor='Error'} + Expect-Enable 5 'monitor with a problem' + + Reset-State + (Get-PnpDevice -InstanceId 'ROOT\DISPLAY\0007').Inf = $null + Expect-Enable 3 'no driver bound' + + # A monitor that arrives a moment after the device starts is waited for. + Reset-State + $global:EMVddTestState.Enabled = @{Problem=0; Status='OK'; Monitor='Unknown'} + function Start-Sleep { param($Milliseconds) (Get-PnpDevice -InstanceId 'DISPLAY\MTT1337\1&2&3&UID256').Status = 'OK' } + $global:LASTEXITCODE = 0 + & $toggle -Action enable -StartSeconds 5 | Out-Null + Remove-Item function:Start-Sleep + if ($LASTEXITCODE -ne 0) { throw "A monitor that arrived during the wait failed: exit $LASTEXITCODE" } + + Reset-State + $global:EMVddTestState.Devices = @($global:EMVddTestState.Devices[0]) + Expect-Enable 2 'absent device' + $global:LASTEXITCODE = 0 + & $toggle -Action disable | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'Disabling an absent device failed' } + Write-Output 'VDD toggle checks passed: selection, re-enable after code 22, still disabled, problem codes, not started, no monitor, late monitor, unbound driver, absent device' + # The failure cases leave their exit codes behind; CI ends with this one. + $global:LASTEXITCODE = 0 } finally { - Remove-Variable EMVddTestState -Scope Global + Remove-Variable EMVddTestState -Scope Global -ErrorAction SilentlyContinue } From 69cd3dfc31b8163b04221915a769eddc81f33e25 Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Thu, 22 Oct 2026 16:10:36 -0400 Subject: [PATCH 02/39] Say why the second screen did not appear --- host/Cargo.toml | 1 + host/src/capture/mod.rs | 73 ++++++-- host/src/control.rs | 4 + host/src/gui/page_stream.rs | 82 ++++++-- host/src/gui/tests.rs | 74 +++++++- host/src/lib.rs | 1 + host/src/vdd.rs | 52 ++++-- host/src/vdd_device.rs | 359 ++++++++++++++++++++++++++++++++++++ 8 files changed, 601 insertions(+), 45 deletions(-) create mode 100644 host/src/vdd_device.rs diff --git a/host/Cargo.toml b/host/Cargo.toml index dda4bd40..af396724 100644 --- a/host/Cargo.toml +++ b/host/Cargo.toml @@ -59,6 +59,7 @@ version = "0.58" features = [ "Win32_Foundation", "Win32_Devices_DeviceAndDriverInstallation", + "Win32_Devices_Display", "Win32_Security", "Win32_Media_Audio", "Win32_System_Com", diff --git a/host/src/capture/mod.rs b/host/src/capture/mod.rs index 6f9c8dee..81b8cfdb 100644 --- a/host/src/capture/mod.rs +++ b/host/src/capture/mod.rs @@ -17,6 +17,8 @@ use tracing::{info, warn}; use crate::control::SharedControl; #[cfg(windows)] use crate::control::{CaptureTarget, VddStatus}; +#[cfg(windows)] +use crate::vdd_device::DeviceState; #[cfg(windows)] pub mod dxgi; @@ -338,6 +340,27 @@ fn capture_is_current(shared: &SharedControl, generation: u64) -> bool { && crate::supervisor::generation_is_current(generation) } +/// How long to wait for the display after asking Windows to extend onto it. +#[cfg(windows)] +const VDD_EXTEND_TIMEOUT: Duration = Duration::from_secs(5); + +/// Say why the second screen did not appear: on the Stream page each time, +/// and in the log once per iPad session for each state, so diagnostics show +/// it without repeating every retry. +#[cfg(windows)] +fn report_device(shared: &SharedControl, device: DeviceState) { + static LOGGED: Mutex, DeviceState)>> = Mutex::new(Vec::new()); + let problem = device.problem(); + *shared.vdd_problem.lock() = Some(problem); + let key = (shared.session.lock().session_id(), device); + let mut logged = LOGGED.lock(); + logged.retain(|(session, _)| *session == key.0); + if !logged.contains(&key) { + logged.push(key); + warn!(vdd_device = %device, ?problem, "Virtual display device state"); + } +} + #[cfg(windows)] fn vdd_attach_timeout() -> Duration { std::env::var("ETERNAL_VDD_TIMEOUT_SECS") @@ -459,7 +482,8 @@ pub(crate) fn reconcile_virtual_display( return None; } startup_heartbeat(shared, crate::clock::host_now_us() / 1000); - if !crate::vdd::set_enabled(true, progress) { + *shared.vdd_problem.lock() = None; + if let Err(device) = crate::vdd::enable(progress) { if !capture_is_current(shared, generation) { return None; } @@ -467,22 +491,40 @@ pub(crate) fn reconcile_virtual_display( "Virtual display could not be enabled (installer task missing?) — \ capturing the primary display instead" ); + if let Some(device) = device { + report_device(shared, device); + // The task enabled a device that cannot show a display. + if device != DeviceState::Missing { + crate::vdd::set_enabled(false, progress); + } + } *shared.vdd_status.lock() = VddStatus::TaskFailed; return Some(CaptureTarget::PrimaryAuto); } - let result = wait_for_virtual_output( - &before, - vdd_attach_timeout(), - || { - if !capture_is_current(shared, generation) { - return None; - } - startup_heartbeat(shared, crate::clock::host_now_us() / 1000); - Some(enumerate_outputs()) - }, - Instant::now, - std::thread::sleep, - ); + let poll = || { + if !capture_is_current(shared, generation) { + return None; + } + startup_heartbeat(shared, crate::clock::host_now_us() / 1000); + Some(enumerate_outputs()) + }; + let wait = |timeout| { + wait_for_virtual_output(&before, timeout, poll, Instant::now, std::thread::sleep) + }; + let mut result = wait(vdd_attach_timeout()); + let mut device = None; + if matches!(result, AttachResult::TimedOut) { + let state = crate::vdd_device::query(); + device = Some(state); + // The monitor arrived, but Windows did not put the desktop on + // it: the saved layout for these displays mirrors or leaves + // it out. The user asked for an extra screen. + if state == DeviceState::Ready && crate::vdd_device::extend_desktop() { + info!("Virtual display was off the desktop — asked Windows to extend onto it"); + result = wait(VDD_EXTEND_TIMEOUT); + device = Some(crate::vdd_device::query()); + } + } if !capture_is_current(shared, generation) { return None; } @@ -502,6 +544,9 @@ pub(crate) fn reconcile_virtual_display( AttachResult::TimedOut => {} } warn!("Virtual display did not attach in time — capturing the primary display instead"); + if let Some(device) = device { + report_device(shared, device); + } // enable() succeeded but nothing attached: turn it back off so we don't strand a // half-enabled device as a phantom monitor. crate::vdd::set_enabled(false, progress); diff --git a/host/src/control.rs b/host/src/control.rs index f99e3fa3..c917d113 100644 --- a/host/src/control.rs +++ b/host/src/control.rs @@ -36,6 +36,9 @@ pub struct SharedControl { /// Live status of the managed virtual extended display, surfaced in the GUI so a tester /// can see when an Extended-display request silently fell back to the primary monitor. pub vdd_status: Arc>, + /// Why the second screen did not appear, when Windows said, so the GUI + /// can say what to do. Cleared at each attempt. + pub vdd_problem: Arc>>, /// Set while Windows blocks screen capture (lock screen, UAC, a display /// it will not duplicate); the GUI explains it instead of a frozen picture. pub capture_block: Arc>>, @@ -148,6 +151,7 @@ impl SharedControl { encoder_override: Arc::new(Mutex::new(None)), capture_target: Arc::new(Mutex::new(CaptureTarget::PrimaryAuto)), vdd_status: Arc::new(Mutex::new(VddStatus::Inactive)), + vdd_problem: Arc::new(Mutex::new(None)), capture_block: Arc::new(Mutex::new(None)), #[cfg(windows)] vdd_lifecycle: Arc::new(Mutex::new(())), diff --git a/host/src/gui/page_stream.rs b/host/src/gui/page_stream.rs index a40eda35..65bcc1fb 100644 --- a/host/src/gui/page_stream.rs +++ b/host/src/gui/page_stream.rs @@ -12,6 +12,7 @@ use super::{ use crate::capture::recovery::CaptureBlock; use crate::control::VddStatus; use crate::vdd::VddTaskHealth; +use crate::vdd_device::VddProblem; impl AnalyzerApp { pub(super) fn stream_page( @@ -100,13 +101,19 @@ impl AnalyzerApp { ui.add_space(GAP - 10.0); } #[allow(unused_mut)] // Debug builds may simulate a problem below. - let (mut capture_block, mut vdd_status, mut vdd_health) = ( + let (mut capture_block, mut vdd_status, mut vdd_health, mut vdd_problem) = ( *self.control.shared.capture_block.lock(), *self.control.shared.vdd_status.lock(), crate::vdd::task_health(), + *self.control.shared.vdd_problem.lock(), ); #[cfg(debug_assertions)] - simulate_problem(&mut capture_block, &mut vdd_status, &mut vdd_health); + simulate_problem( + &mut capture_block, + &mut vdd_status, + &mut vdd_health, + &mut vdd_problem, + ); if let Some((icon, title, body)) = capture_block_notice(capture_block, snap.pipeline_running) { @@ -115,7 +122,7 @@ impl AnalyzerApp { } // The user asked for the extended (virtual) display but it could not be brought up, // so capture silently fell back to the primary monitor. Say so. - if let Some(notice) = second_screen_notice(vdd_status, vdd_health) { + if let Some(notice) = second_screen_notice(vdd_status, vdd_health, vdd_problem) { let mut copy_logs = false; let mut retry = false; widgets::banner( @@ -123,7 +130,7 @@ impl AnalyzerApp { WARNING, Icon::Warning, SECOND_SCREEN_FAILED_TITLE, - notice.body, + ¬ice.body, 110.0 * (1 + notice.retry as u8 + notice.installer as u8) as f32, |ui| { copy_logs = @@ -593,6 +600,7 @@ fn simulate_problem( block: &mut Option, vdd: &mut VddStatus, health: &mut VddTaskHealth, + problem: &mut Option, ) { let Ok(name) = std::env::var("ETERNAL_SIMULATE_PROBLEM") else { return; @@ -609,6 +617,24 @@ fn simulate_problem( "driver-tasks-missing" => { (*vdd, *health) = (VddStatus::TaskFailed, VddTaskHealth::TasksMissing) } + "driver-not-installed" => { + (*vdd, *problem) = (VddStatus::TaskFailed, Some(VddProblem::NotInstalled)) + } + "driver-signature" => { + (*vdd, *problem) = (VddStatus::TaskFailed, Some(VddProblem::SignatureBlocked)) + } + "driver-error" => { + (*vdd, *problem) = ( + VddStatus::TaskFailed, + Some(VddProblem::DriverError(Some(43))), + ) + } + "display-did-not-appear" => { + (*vdd, *problem) = (VddStatus::TaskFailed, Some(VddProblem::NoDisplay)) + } + "display-not-extended" => { + (*vdd, *problem) = (VddStatus::Failed, Some(VddProblem::NotExtended)) + } _ => {} } } @@ -616,41 +642,71 @@ fn simulate_problem( /// What the second screen notice says and offers. #[derive(Debug, PartialEq, Eq)] pub(super) struct SecondScreenNotice { - pub body: &'static str, + pub body: String, pub retry: bool, pub installer: bool, } /// The notice for a second screen that could not start, by what the display -/// driver tasks report. `None` unless the second screen failed. +/// driver tasks and its device report. `None` unless the second screen failed. pub(super) fn second_screen_notice( status: VddStatus, health: VddTaskHealth, + problem: Option, ) -> Option { - let notice = |body, retry, installer| { + use VddStatus::{Failed, TaskFailed}; + let notice = |body: &str, retry, installer| { Some(SecondScreenNotice { - body, + body: body.to_string(), retry, installer, }) }; - match (status, health) { - (VddStatus::Failed | VddStatus::TaskFailed, VddTaskHealth::NotResponding) => notice( + match (status, health, problem) { + (Failed | TaskFailed, VddTaskHealth::NotResponding, _) => notice( "Windows' display driver stopped responding. Restart the PC to use the second screen.", false, false, ), - (VddStatus::Failed | VddStatus::TaskFailed, VddTaskHealth::TasksMissing) => notice( + (Failed | TaskFailed, VddTaskHealth::TasksMissing, _) => notice( "The second screen isn't installed. Download EternalMonitor-Setup.exe and run it again to add it, then click Try again.", true, true, ), - (VddStatus::TaskFailed, VddTaskHealth::Ok) => notice( + (Failed | TaskFailed, VddTaskHealth::Ok, Some(VddProblem::NotInstalled)) => notice( + "The second screen's display driver isn't installed. Download EternalMonitor-Setup.exe and run it again to reinstall it, then click Try again.", + true, + true, + ), + (Failed | TaskFailed, VddTaskHealth::Ok, Some(VddProblem::SignatureBlocked)) => notice( + "Windows couldn't verify the display driver's signature, so it won't start it (Windows code 52). Run EternalMonitor-Setup.exe again while connected to the internet to reinstall the driver, then click Try again. You don't need to turn off Secure Boot or Memory Integrity.", + true, + true, + ), + (Failed | TaskFailed, VddTaskHealth::Ok, Some(VddProblem::DriverError(code))) => { + let code = code.map_or(String::new(), |code| format!(" (Windows code {code})")); + notice( + &format!("The second screen's display driver stopped with an error{code}. Restart the PC, then run EternalMonitor-Setup.exe again to repair it."), + true, + true, + ) + } + (Failed | TaskFailed, VddTaskHealth::Ok, Some(VddProblem::NoDisplay)) => notice( + "The display driver is running, but Windows didn't add the second screen. Click Try again. If it keeps happening, restart the PC.", + true, + false, + ), + (Failed | TaskFailed, VddTaskHealth::Ok, Some(VddProblem::NotExtended)) => notice( + "Windows added the second screen but wouldn't extend your desktop onto it. Click Try again. If it keeps happening, restart the PC.", + true, + false, + ), + (TaskFailed, VddTaskHealth::Ok, None) => notice( "Part of the EternalMonitor install is missing or blocked. Download EternalMonitor-Setup.exe and run it again to repair it, then click Try again.", true, true, ), - (VddStatus::Failed, VddTaskHealth::Ok) => notice( + (Failed, VddTaskHealth::Ok, None) => notice( "Windows didn't add the second screen in time. Click Try again. If it keeps happening, run EternalMonitor-Setup.exe again to repair the display driver.", true, false, diff --git a/host/src/gui/tests.rs b/host/src/gui/tests.rs index 3b81d920..2aeaaaf7 100644 --- a/host/src/gui/tests.rs +++ b/host/src/gui/tests.rs @@ -323,26 +323,90 @@ fn the_second_screen_notice_says_which_driver_problem_applies() { VddTaskHealth::NotResponding, VddTaskHealth::TasksMissing, ] { - assert_eq!(second_screen_notice(status, health), None); + assert_eq!(second_screen_notice(status, health, None), None); } } for status in [VddStatus::Failed, VddStatus::TaskFailed] { // A hung driver needs a Windows restart; trying again cannot help. - let hung = second_screen_notice(status, VddTaskHealth::NotResponding).unwrap(); + let hung = second_screen_notice(status, VddTaskHealth::NotResponding, None).unwrap(); assert!(hung.body.contains("Restart the PC"), "{}", hung.body); assert!(!hung.retry && !hung.installer); // Missing driver tasks need the installer. - let missing = second_screen_notice(status, VddTaskHealth::TasksMissing).unwrap(); + let missing = second_screen_notice(status, VddTaskHealth::TasksMissing, None).unwrap(); assert!(missing.body.contains("isn't installed"), "{}", missing.body); assert!(missing.retry && missing.installer); } // Without a known driver problem the earlier wording stays. - let task = second_screen_notice(VddStatus::TaskFailed, VddTaskHealth::Ok).unwrap(); + let task = second_screen_notice(VddStatus::TaskFailed, VddTaskHealth::Ok, None).unwrap(); assert!(task.body.starts_with("Part of the EternalMonitor install")); assert!(task.installer); - let slow = second_screen_notice(VddStatus::Failed, VddTaskHealth::Ok).unwrap(); + let slow = second_screen_notice(VddStatus::Failed, VddTaskHealth::Ok, None).unwrap(); assert!(slow .body .starts_with("Windows didn't add the second screen in time")); assert!(slow.retry && !slow.installer); } + +#[test] +fn the_second_screen_notice_says_what_to_do_about_the_driver_device() { + use crate::control::VddStatus; + use crate::vdd::VddTaskHealth; + use crate::vdd_device::VddProblem; + let notice = |status, problem| { + second_screen_notice(status, VddTaskHealth::Ok, Some(problem)) + .unwrap_or_else(|| panic!("no notice for {status:?} {problem:?}")) + }; + for status in [VddStatus::Failed, VddStatus::TaskFailed] { + let missing = notice(status, VddProblem::NotInstalled); + assert!(missing.body.contains("isn't installed"), "{}", missing.body); + assert!(missing.body.contains("EternalMonitor-Setup.exe")); + assert!(missing.retry && missing.installer); + + // Code 52 says what Windows blocked, and that Secure Boot can stay on. + let signature = notice(status, VddProblem::SignatureBlocked); + assert!(signature.body.contains("signature"), "{}", signature.body); + assert!(signature.body.contains("code 52")); + assert!(signature + .body + .contains("don't need to turn off Secure Boot")); + assert!(signature.installer); + + let error = notice(status, VddProblem::DriverError(Some(43))); + assert!(error.body.contains("(Windows code 43)"), "{}", error.body); + assert!(error + .body + .contains("Restart the PC, then run EternalMonitor-Setup.exe again")); + assert!(error.installer); + let unstarted = notice(status, VddProblem::DriverError(None)); + assert!(!unstarted.body.contains("code"), "{}", unstarted.body); + + let none = notice(status, VddProblem::NoDisplay); + assert!( + none.body.contains("didn't add the second screen"), + "{}", + none.body + ); + assert!(none.retry && !none.installer); + let off_desktop = notice(status, VddProblem::NotExtended); + assert!( + off_desktop.body.contains("extend your desktop"), + "{}", + off_desktop.body + ); + } + // A hung or missing task still says so first: its fix comes before the device's. + let hung = second_screen_notice( + VddStatus::TaskFailed, + VddTaskHealth::NotResponding, + Some(VddProblem::NoDisplay), + ) + .unwrap(); + assert!(hung.body.contains("Restart the PC"), "{}", hung.body); + // Nothing failed: no notice, whatever was last reported. + for status in [VddStatus::Inactive, VddStatus::Active, VddStatus::Attaching] { + assert_eq!( + second_screen_notice(status, VddTaskHealth::Ok, Some(VddProblem::NoDisplay)), + None + ); + } +} diff --git a/host/src/lib.rs b/host/src/lib.rs index 72bf4e41..4b7a05fa 100644 --- a/host/src/lib.rs +++ b/host/src/lib.rs @@ -23,6 +23,7 @@ pub mod supervisor; pub mod transport; pub mod updates; pub mod vdd; +pub mod vdd_device; pub mod vdd_settings; #[cfg(windows)] diff --git a/host/src/vdd.rs b/host/src/vdd.rs index 75e13503..c6a10569 100644 --- a/host/src/vdd.rs +++ b/host/src/vdd.rs @@ -59,7 +59,8 @@ enum TaskOutcome { Done, TimedOut, Missing, - Failed, + /// With the device state the Enable task found, when it said. + Failed(Option), } #[cfg_attr(not(windows), allow(dead_code))] // Driver tasks exist only on Windows. @@ -70,7 +71,7 @@ impl TaskOutcome { } else if stderr.contains("0x80070002") { Self::Missing } else { - Self::Failed + Self::Failed(crate::vdd_device::from_task_stderr(stderr)) } } } @@ -151,7 +152,7 @@ impl TaskGate { self.stuck_at = Some(now); } TaskOutcome::Missing => self.health = VddTaskHealth::TasksMissing, - TaskOutcome::Failed => {} + TaskOutcome::Failed(_) => {} } } } @@ -261,7 +262,7 @@ fn run_task(task: &str, progress: &mut dyn FnMut()) -> TaskOutcome { Ok(child) => child, Err(error) => { warn!(task, %error, "Failed to invoke the VDD task"); - return TaskOutcome::Failed; + return TaskOutcome::Failed(None); } }; // vdd_task.ps1 waits up to 10 s for an earlier instance and 15 s for its own. @@ -305,7 +306,7 @@ fn run_task(task: &str, progress: &mut dyn FnMut()) -> TaskOutcome { } Err(error) => { warn!(task, error = %error, "Failed to read the VDD task result"); - TaskOutcome::Failed + TaskOutcome::Failed(None) } } } @@ -313,24 +314,44 @@ fn run_task(task: &str, progress: &mut dyn FnMut()) -> TaskOutcome { /// Complete one installer-owned driver action while capture reports startup progress. #[cfg_attr(not(windows), allow(dead_code))] // Only Windows capture asks. pub(crate) fn set_enabled(enabled: bool, progress: impl FnMut()) -> bool { - request(enabled, Purpose::Attempt, progress) + request(enabled, Purpose::Attempt, progress, &mut None) } -fn request(enabled: bool, purpose: Purpose, progress: impl FnMut()) -> bool { +/// Turn the virtual display on. On failure, what the Enable task found +/// wrong with the device, when it ran and said. +#[cfg_attr(not(windows), allow(dead_code))] // Only Windows capture asks. +pub(crate) fn enable(progress: impl FnMut()) -> Result<(), Option> { + let mut device = None; + if request(true, Purpose::Attempt, progress, &mut device) { + Ok(()) + } else { + Err(device) + } +} + +fn request( + enabled: bool, + purpose: Purpose, + progress: impl FnMut(), + device: &mut Option, +) -> bool { #[cfg(windows)] { TASKS.set_enabled(enabled, purpose, progress, |enabled, progress| { - let mut outcome = TaskOutcome::Failed; + let mut outcome = TaskOutcome::Failed(None); crate::startup::display::change(enabled, || { outcome = run_task(if enabled { TASK_ENABLE } else { TASK_DISABLE }, progress); outcome == TaskOutcome::Done }); + if let TaskOutcome::Failed(state) = outcome { + *device = state; + } outcome }) } #[cfg(not(windows))] { - let _ = (enabled, purpose, progress); + let _ = (enabled, purpose, progress, device); false } } @@ -340,7 +361,7 @@ fn request(enabled: bool, purpose: Purpose, progress: impl FnMut()) -> bool { /// it, and it waits for an Enable still running, within the task's bounds. /// True when the display is known to be off. pub fn disable() -> bool { - request(false, Purpose::Cleanup, || {}) + request(false, Purpose::Cleanup, || {}, &mut None) } /// Disable the virtual display after `host` ended abnormally, if it owned the @@ -402,7 +423,7 @@ mod tests { let mut gate = TaskGate::new(); let now = Instant::now(); gate.after(false, TaskOutcome::Done, now); - gate.after(true, TaskOutcome::Failed, now); + gate.after(true, TaskOutcome::Failed(None), now); // A failed task leaves the device state unknown, so disable runs again. assert_eq!(gate.before(false, Purpose::Attempt, now), Gate::Run); gate.after(false, TaskOutcome::Missing, now); @@ -463,7 +484,7 @@ mod tests { // A failed Disable leaves the state unknown, so quit tries again. let mut gate = TaskGate::new(); gate.after(true, TaskOutcome::Done, now); - gate.after(false, TaskOutcome::Failed, now); + gate.after(false, TaskOutcome::Failed(None), now); assert_eq!(gate.before(false, Purpose::Cleanup, now), Gate::Run); } @@ -632,7 +653,12 @@ mod tests { ); assert_eq!( TaskOutcome::from_stderr("VDD task failed: x, result=1"), - TaskOutcome::Failed + TaskOutcome::Failed(None) + ); + // The Enable task says what it found wrong with the device. + assert_eq!( + TaskOutcome::from_stderr("VDD task failed: EternalMonitor VDD Enable, result=5"), + TaskOutcome::Failed(Some(crate::vdd_device::DeviceState::NoMonitor)) ); } } diff --git a/host/src/vdd_device.rs b/host/src/vdd_device.rs new file mode 100644 index 00000000..4d930348 --- /dev/null +++ b/host/src/vdd_device.rs @@ -0,0 +1,359 @@ +//! What Windows reports for the bundled virtual display driver's device, and +//! what the Stream page tells the user to do about it. +//! +//! The Enable task used to succeed whenever the device reported no problem +//! code right after it was enabled. The display could still never appear, and +//! all the host could say was that it did not attach in time. The task now +//! says why it failed (installer/scripts/vdd-toggle.ps1), and the host reads +//! the device itself when the display does not appear. + +use std::fmt; + +/// cfgmgr32.h devnode status flags. +#[cfg_attr(not(windows), allow(dead_code))] // Read from Windows only. +const DN_STARTED: u32 = 0x8; +#[cfg_attr(not(windows), allow(dead_code))] // Read from Windows only. +const DN_HAS_PROBLEM: u32 = 0x400; + +/// The driver's hardware ID, as its INF and vdd-toggle.ps1 match it. +#[cfg_attr(not(windows), allow(dead_code))] // Read from Windows only. +const HARDWARE_ID: &str = r"Root\MttVDD"; + +/// The driver's device, as the Enable task or Configuration Manager reports it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeviceState { + /// No present device has the driver's hardware ID. + Missing, + /// The device exists without a driver bound to it. + NoDriver, + /// Windows reports this problem code. 22 is "disabled". + Problem(u32), + /// No problem code, but Windows has not started the device. + NotStarted, + /// The device started, but its monitor did not arrive. + NoMonitor, + /// The device and its monitor are running. + Ready, +} + +/// What the user can do about a second screen that did not appear. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VddProblem { + /// The driver or its device is gone: run Setup again. + NotInstalled, + /// Windows could not verify the driver's signature (code 52). + SignatureBlocked, + /// The driver failed, with Windows' problem code when there is one. + DriverError(Option), + /// The driver runs, but Windows added no screen for it. + NoDisplay, + /// Windows added the screen but kept the desktop off it. + NotExtended, +} + +impl DeviceState { + /// The Enable task's result, as vdd-toggle.ps1 exits. + pub fn from_task_result(code: i64) -> Option { + Some(match code { + 2 => Self::Missing, + 3 => Self::NoDriver, + 4 => Self::NotStarted, + 5 => Self::NoMonitor, + 101..=199 => Self::Problem((code - 100) as u32), + _ => return None, + }) + } + + /// The device as Configuration Manager reports it: its status flags and + /// problem number, whether a driver is bound, and whether its monitor + /// child is started (`None` without a child). + #[cfg_attr(not(windows), allow(dead_code))] // Read from Windows only. + fn from_devnode(status: u32, problem: u32, has_driver: bool, monitor: Option) -> Self { + if status & DN_HAS_PROBLEM != 0 { + Self::Problem(problem) + } else if !has_driver { + Self::NoDriver + } else if status & DN_STARTED == 0 { + Self::NotStarted + } else if monitor == Some(true) { + Self::Ready + } else { + Self::NoMonitor + } + } + + /// With more than one device, the one closest to showing a display. + #[cfg_attr(not(windows), allow(dead_code))] // Read from Windows only. + fn best(states: impl IntoIterator) -> Self { + let rank = |state: &Self| match state { + Self::Missing => 0, + Self::NoDriver => 1, + Self::Problem(_) => 2, + Self::NotStarted => 3, + Self::NoMonitor => 4, + Self::Ready => 5, + }; + states.into_iter().max_by_key(rank).unwrap_or(Self::Missing) + } + + /// What to tell the user once the display did not appear. A device that + /// is ready with its monitor means Windows kept the desktop off it. + pub fn problem(self) -> VddProblem { + match self { + // Not configured, reinstall needed, or the driver install failed. + Self::Missing | Self::NoDriver | Self::Problem(1 | 18 | 28) => VddProblem::NotInstalled, + Self::Problem(52) => VddProblem::SignatureBlocked, + Self::Problem(code) => VddProblem::DriverError(Some(code)), + Self::NotStarted => VddProblem::DriverError(None), + Self::NoMonitor => VddProblem::NoDisplay, + Self::Ready => VddProblem::NotExtended, + } + } +} + +/// Stable names for logs and diagnostics. No instance IDs or other detail. +impl fmt::Display for DeviceState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Missing => f.write_str("missing"), + Self::NoDriver => f.write_str("no_driver"), + Self::Problem(code) => write!(f, "problem_{code}"), + Self::NotStarted => f.write_str("not_started"), + Self::NoMonitor => f.write_str("no_monitor"), + Self::Ready => f.write_str("ready"), + } + } +} + +/// The Enable task's result from vdd_task.ps1's failure message, +/// "VDD task failed: , result=". +pub fn from_task_stderr(stderr: &str) -> Option { + let digits: String = stderr + .split("result=") + .nth(1)? + .chars() + .take_while(char::is_ascii_digit) + .collect(); + DeviceState::from_task_result(digits.parse().ok()?) +} + +/// Read the driver's device from Configuration Manager. +#[cfg(windows)] +pub fn query() -> DeviceState { + use windows::core::{w, PCWSTR}; + use windows::Win32::Devices::DeviceAndDriverInstallation::{ + CM_Get_Child, CM_Get_DevNode_Registry_PropertyW, CM_Get_DevNode_Status, + CM_Get_Device_ID_ListW, CM_Get_Device_ID_List_SizeW, CM_Locate_DevNodeW, + CM_DEVNODE_STATUS_FLAGS, CM_DRP_DRIVER, CM_DRP_HARDWAREID, CM_GETIDLIST_FILTER_ENUMERATOR, + CM_GETIDLIST_FILTER_PRESENT, CM_LOCATE_DEVNODE_NORMAL, CM_PROB, CR_BUFFER_SMALL, + CR_SUCCESS, + }; + + let status = |devinst: u32| { + let (mut status, mut problem) = (CM_DEVNODE_STATUS_FLAGS(0), CM_PROB(0)); + (unsafe { CM_Get_DevNode_Status(&mut status, &mut problem, devinst, 0) } == CR_SUCCESS) + .then_some((status.0, problem.0)) + }; + let property = |devinst: u32, which: u32| -> Option> { + let mut bytes = 0u32; + let result = + unsafe { CM_Get_DevNode_Registry_PropertyW(devinst, which, None, None, &mut bytes, 0) }; + if result != CR_BUFFER_SMALL || bytes == 0 { + return None; + } + let mut buffer = vec![0u16; bytes.div_ceil(2) as usize]; + (unsafe { + CM_Get_DevNode_Registry_PropertyW( + devinst, + which, + None, + Some(buffer.as_mut_ptr().cast()), + &mut bytes, + 0, + ) + } == CR_SUCCESS) + .then_some(buffer) + }; + + let flags = CM_GETIDLIST_FILTER_ENUMERATOR | CM_GETIDLIST_FILTER_PRESENT; + let mut len = 0u32; + if unsafe { CM_Get_Device_ID_List_SizeW(&mut len, w!("ROOT"), flags) } != CR_SUCCESS { + return DeviceState::Missing; + } + let mut ids = vec![0u16; len as usize]; + if unsafe { CM_Get_Device_ID_ListW(w!("ROOT"), &mut ids, flags) } != CR_SUCCESS { + return DeviceState::Missing; + } + let states = ids + .split(|&unit| unit == 0) + .filter(|id| !id.is_empty()) + .filter_map(|id| { + let id: Vec = id.iter().copied().chain(Some(0)).collect(); + let mut devinst = 0u32; + if unsafe { + CM_Locate_DevNodeW(&mut devinst, PCWSTR(id.as_ptr()), CM_LOCATE_DEVNODE_NORMAL) + } != CR_SUCCESS + { + return None; + } + let hardware_ids = property(devinst, CM_DRP_HARDWAREID)?; + hardware_ids + .split(|&unit| unit == 0) + .any(|hwid| String::from_utf16_lossy(hwid).eq_ignore_ascii_case(HARDWARE_ID)) + .then_some(devinst) + }) + .filter_map(|devinst| { + let (flags, problem) = status(devinst)?; + let mut child = 0u32; + let monitor = + (unsafe { CM_Get_Child(&mut child, devinst, 0) } == CR_SUCCESS).then(|| { + status(child).is_some_and(|(flags, _)| { + flags & DN_STARTED != 0 && flags & DN_HAS_PROBLEM == 0 + }) + }); + let has_driver = property(devinst, CM_DRP_DRIVER).is_some(); + Some(DeviceState::from_devnode( + flags, problem, has_driver, monitor, + )) + }); + DeviceState::best(states) +} + +#[cfg(not(windows))] +pub fn query() -> DeviceState { + DeviceState::Missing +} + +/// Ask Windows to extend the desktop across every connected display, as +/// Windows+P > Extend does. Windows remembers the choice for this set of +/// displays, which includes the virtual one, so it applies only while it is on. +#[cfg(windows)] +pub fn extend_desktop() -> bool { + use windows::Win32::Devices::Display::{SetDisplayConfig, SDC_APPLY, SDC_TOPOLOGY_EXTEND}; + unsafe { SetDisplayConfig(None, None, SDC_APPLY | SDC_TOPOLOGY_EXTEND) == 0 } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_enable_task_result_names_the_device_state() { + assert_eq!(DeviceState::from_task_result(0), None); + assert_eq!(DeviceState::from_task_result(1), None); + assert_eq!(DeviceState::from_task_result(2), Some(DeviceState::Missing)); + assert_eq!( + DeviceState::from_task_result(3), + Some(DeviceState::NoDriver) + ); + assert_eq!( + DeviceState::from_task_result(4), + Some(DeviceState::NotStarted) + ); + assert_eq!( + DeviceState::from_task_result(5), + Some(DeviceState::NoMonitor) + ); + assert_eq!( + DeviceState::from_task_result(122), + Some(DeviceState::Problem(22)) + ); + assert_eq!( + DeviceState::from_task_result(143), + Some(DeviceState::Problem(43)) + ); + // Task Scheduler's own codes are not device states. + assert_eq!(DeviceState::from_task_result(0x41301), None); + assert_eq!( + from_task_stderr("VDD task failed: EternalMonitor VDD Enable, result=152"), + Some(DeviceState::Problem(52)) + ); + assert_eq!( + from_task_stderr("VDD task failed: EternalMonitor VDD Enable, result=1"), + None + ); + assert_eq!(from_task_stderr("Previous VDD task timed out: x"), None); + } + + #[test] + fn devnode_status_maps_to_a_device_state() { + let started = DN_STARTED; + let problem = DN_HAS_PROBLEM; + assert_eq!( + DeviceState::from_devnode(problem, 22, true, None), + DeviceState::Problem(22) + ); + assert_eq!( + DeviceState::from_devnode(problem, 43, true, None), + DeviceState::Problem(43) + ); + assert_eq!( + DeviceState::from_devnode(problem, 28, false, None), + DeviceState::Problem(28) + ); + assert_eq!( + DeviceState::from_devnode(started, 0, false, None), + DeviceState::NoDriver + ); + assert_eq!( + DeviceState::from_devnode(0, 0, true, None), + DeviceState::NotStarted + ); + assert_eq!( + DeviceState::from_devnode(started, 0, true, None), + DeviceState::NoMonitor + ); + assert_eq!( + DeviceState::from_devnode(started, 0, true, Some(false)), + DeviceState::NoMonitor + ); + assert_eq!( + DeviceState::from_devnode(started, 0, true, Some(true)), + DeviceState::Ready + ); + } + + #[test] + fn the_device_closest_to_a_display_wins() { + assert_eq!(DeviceState::best([]), DeviceState::Missing); + assert_eq!( + DeviceState::best([DeviceState::Problem(43), DeviceState::Ready]), + DeviceState::Ready + ); + assert_eq!( + DeviceState::best([DeviceState::NoDriver, DeviceState::Problem(22)]), + DeviceState::Problem(22) + ); + } + + #[test] + fn each_device_state_says_what_to_do() { + use VddProblem::*; + for state in [ + DeviceState::Missing, + DeviceState::NoDriver, + DeviceState::Problem(1), + DeviceState::Problem(18), + DeviceState::Problem(28), + ] { + assert_eq!(state.problem(), NotInstalled, "{state}"); + } + assert_eq!(DeviceState::Problem(52).problem(), SignatureBlocked); + for code in [10, 22, 31, 39, 43] { + assert_eq!( + DeviceState::Problem(code).problem(), + DriverError(Some(code)) + ); + } + assert_eq!(DeviceState::NotStarted.problem(), DriverError(None)); + assert_eq!(DeviceState::NoMonitor.problem(), NoDisplay); + assert_eq!(DeviceState::Ready.problem(), NotExtended); + } + + #[test] + fn log_names_carry_no_instance_details() { + assert_eq!(DeviceState::Problem(43).to_string(), "problem_43"); + assert_eq!(DeviceState::NoMonitor.to_string(), "no_monitor"); + assert_eq!(DeviceState::Missing.to_string(), "missing"); + } +} From 789b011c94dff79502c00856946b0569e4113fc6 Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Thu, 22 Oct 2026 16:10:51 -0400 Subject: [PATCH 03/39] Draw the virtual display on the main screen's graphics card --- host/src/capture/mod.rs | 8 +++++++- host/src/vdd_settings.rs | 42 +++++++++++++++++++++++++++++++++++----- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/host/src/capture/mod.rs b/host/src/capture/mod.rs index 81b8cfdb..c1d201dc 100644 --- a/host/src/capture/mod.rs +++ b/host/src/capture/mod.rs @@ -457,6 +457,11 @@ pub(crate) fn reconcile_virtual_display( // vdd_settings.xml at enable time). Failure is non-fatal — the // driver's built-in modes apply. NEEDS_WINDOWS_VERIFY (M10). if crate::settings::SettingsFile::load().vdd_match_resolution { + let gpu = outputs + .iter() + .find(|output| output.is_primary) + .map(|output| output.adapter_name.as_str()) + .filter(|name| !name.is_empty()); if let Some(info) = shared.session.lock().client_info() { let modes = crate::vdd_settings::modes_for_client( info.screen_px.0, @@ -464,9 +469,10 @@ pub(crate) fn reconcile_virtual_display( info.refresh_hz, ); let path = std::path::Path::new(r"C:\VirtualDisplayDriver\vdd_settings.xml"); - match crate::vdd_settings::write_to(path, &modes) { + match crate::vdd_settings::write_to(path, &modes, gpu) { Ok(()) => info!( ?modes, + gpu = gpu.unwrap_or("chosen by the driver"), "Wrote virtual display mode list for the connected client" ), Err(error) => warn!( diff --git a/host/src/vdd_settings.rs b/host/src/vdd_settings.rs index 15aa32cf..ff7cb39d 100644 --- a/host/src/vdd_settings.rs +++ b/host/src/vdd_settings.rs @@ -7,6 +7,11 @@ //! document: one monitor, a global refresh rate, and a resolution list led by //! the client's native landscape mode. NEEDS_WINDOWS_VERIFY: the driver's //! acceptance of these exact modes is a hardware-runbook item. +//! +//! It also names the graphics card the driver renders on: the one driving the +//! main screen. Left unnamed, the driver picks the card with the most video +//! memory. On Intel + NVIDIA laptops that is the NVIDIA card, which drives no +//! screen, and in 0.3.1 diagnostics none of those laptops got the display. use std::io; use std::path::Path; @@ -59,12 +64,23 @@ pub fn modes_for_client(screen_px_w: u16, screen_px_h: u16, refresh_hz: u8) -> V } /// Render the settings document. Pure so tests can assert the exact bytes. -pub fn render(modes: &[VddMode]) -> String { +/// `gpu` is the DXGI adapter description the driver matches its render +/// adapter by; without it the driver chooses. +pub fn render(modes: &[VddMode], gpu: Option<&str>) -> String { let global_refresh = modes.first().map_or(60, |m| m.refresh_hz); let mut xml = String::with_capacity(512); xml.push_str("\n"); xml.push_str("\n"); xml.push_str(" \n 1\n \n"); + if let Some(gpu) = gpu { + let gpu = gpu + .replace('&', "&") + .replace('<', "<") + .replace('>', ">"); + xml.push_str(&format!( + " \n {gpu}\n \n" + )); + } xml.push_str(" \n"); xml.push_str(&format!( " {global_refresh}\n" @@ -86,13 +102,13 @@ pub fn render(modes: &[VddMode]) -> String { /// Write atomically (tmp + rename) so the driver never reads a torn file. /// Creates the directory if the driver hasn't made it yet. -pub fn write_to(path: &Path, modes: &[VddMode]) -> io::Result<()> { +pub fn write_to(path: &Path, modes: &[VddMode], gpu: Option<&str>) -> io::Result<()> { let parent = path.parent().ok_or_else(|| { io::Error::new(io::ErrorKind::InvalidInput, "settings path has no parent") })?; std::fs::create_dir_all(parent)?; let tmp = path.with_extension("xml.tmp"); - std::fs::write(&tmp, render(modes))?; + std::fs::write(&tmp, render(modes, gpu))?; std::fs::rename(&tmp, path) } @@ -153,7 +169,8 @@ mod tests { #[test] fn render_produces_the_driver_schema() { - let xml = render(&modes_for_client(1668, 2420, 120)); + let xml = render(&modes_for_client(1668, 2420, 120), None); + assert!(!xml.contains(""), "the driver chooses without a name"); assert!(xml.starts_with("")); assert!(xml.contains("")); assert!(xml.contains("1")); @@ -165,12 +182,27 @@ mod tests { assert!(xml.ends_with("\n")); } + #[test] + fn render_names_the_main_screens_graphics_card() { + let xml = render( + &modes_for_client(1668, 2420, 120), + Some("Intel(R) Iris(R) Xe Graphics"), + ); + assert!(xml.contains( + " \n Intel(R) Iris(R) Xe Graphics\n \n" + )); + // Before the modes, as in the driver's own file. + assert!(xml.find("") < xml.find("")); + let xml = render(&modes_for_client(1668, 2420, 60), Some("A&B ")); + assert!(xml.contains("A&B <GPU>")); + } + #[test] fn write_is_atomic_and_creates_the_directory() { let dir = std::env::temp_dir().join(format!("vdd-settings-test-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); let path = dir.join("nested").join("vdd_settings.xml"); - write_to(&path, &modes_for_client(1668, 2420, 120)).expect("write settings"); + write_to(&path, &modes_for_client(1668, 2420, 120), None).expect("write settings"); let written = std::fs::read_to_string(&path).expect("read back"); assert!(written.contains("2420")); assert!(!path.with_extension("xml.tmp").exists(), "tmp cleaned up"); From c74ae469d54615977ea739765699862a6df1c360 Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Thu, 22 Oct 2026 16:10:51 -0400 Subject: [PATCH 04/39] Name Windows 11 from its build number --- host/src/diagnostics.rs | 51 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/host/src/diagnostics.rs b/host/src/diagnostics.rs index 7983b08f..0887e8ee 100644 --- a/host/src/diagnostics.rs +++ b/host/src/diagnostics.rs @@ -112,14 +112,17 @@ fn windows_system_report() -> Vec { use windows::Win32::System::SystemInformation::{GlobalMemoryStatusEx, MEMORYSTATUSEX}; let os_key = "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion"; let cpu_key = "HARDWARE\\DESCRIPTION\\System\\CentralProcessor\\0"; + let product = registry_string(os_key, "ProductName"); + let build = registry_string(os_key, "CurrentBuildNumber"); let mut lines = vec![format!( - // ProductName can retain "Windows 10" on Windows 11. Preserve its source - // instead of presenting that legacy registry label as the detected OS. - "Windows registry_product={} installation_type={} display_version={} build={}.{}", - registry_string(os_key, "ProductName"), + // ProductName can retain "Windows 10" on Windows 11. Name the OS from + // the build, and keep the registry label beside it as the source. + "Windows os={} registry_product={} installation_type={} display_version={} build={}.{}", + windows_name(&product, &build), + product, registry_string(os_key, "InstallationType"), registry_string(os_key, "DisplayVersion"), - registry_string(os_key, "CurrentBuildNumber"), + build, registry_dword(os_key, "UBR") )]; lines.push(format!( @@ -143,6 +146,20 @@ fn windows_system_report() -> Vec { lines } +/// The Windows name for a registry ProductName and build number. Windows 11 +/// starts at build 22000 but kept "Windows 10" in ProductName. +#[cfg_attr(not(windows), allow(dead_code))] // Read from Windows only. +fn windows_name(product: &str, build: &str) -> String { + let windows_11 = build + .trim() + .parse::() + .is_ok_and(|build| build >= 22000); + match product.strip_prefix("Windows 10") { + Some(edition) if windows_11 => format!("Windows 11{edition}"), + _ => product.to_string(), + } +} + #[cfg(windows)] fn registry_string(key: &str, name: &str) -> String { use windows::{ @@ -199,3 +216,27 @@ fn registry_dword(key: &str, name: &str) -> String { value.to_string() } } + +#[cfg(test)] +mod tests { + use super::windows_name; + + #[test] + fn windows_11_is_named_from_its_build_not_its_registry_label() { + // The customer's PC: Windows 11 26H2 with the legacy label. + assert_eq!(windows_name("Windows 10 Home", "26300"), "Windows 11 Home"); + assert_eq!(windows_name("Windows 10 Pro", "22000"), "Windows 11 Pro"); + assert_eq!( + windows_name("Windows 10 Home China", "26200"), + "Windows 11 Home China" + ); + assert_eq!(windows_name("Windows 10 Pro", "19045"), "Windows 10 Pro"); + assert_eq!(windows_name("Windows 10 Pro", "21996"), "Windows 10 Pro"); + // Already right, or unreadable: left as Windows reports it. + assert_eq!(windows_name("Windows 11 Pro", "26100"), "Windows 11 Pro"); + assert_eq!( + windows_name("Windows 10 Home", "unavailable(2)"), + "Windows 10 Home" + ); + } +} From 6531b16786115d2d668f7f390424ae5706536fc1 Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Thu, 22 Oct 2026 16:10:51 -0400 Subject: [PATCH 05/39] Repair a preserved virtual display driver from Setup --- .github/workflows/ci.yml | 3 + installer/EternalMonitor.iss | 18 +++- installer/scripts/vdd-driver-repair.ps1 | 59 +++++++++++ installer/tests/Test-VddDriverRepair.ps1 | 129 +++++++++++++++++++++++ 4 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 installer/scripts/vdd-driver-repair.ps1 create mode 100644 installer/tests/Test-VddDriverRepair.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3bd1b84b..aefc276e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,6 +88,9 @@ jobs: - name: Verify virtual display driver binding checks shell: powershell run: ./installer/tests/Test-VddDriverVerification.ps1 + - name: Verify Setup repairs a preserved virtual display driver + shell: powershell + run: ./installer/tests/Test-VddDriverRepair.ps1 rust-macos: name: Rust (macOS, full workspace + E2E) diff --git a/installer/EternalMonitor.iss b/installer/EternalMonitor.iss index 13d8cfcf..7e0f6feb 100644 --- a/installer/EternalMonitor.iss +++ b/installer/EternalMonitor.iss @@ -76,6 +76,7 @@ Source: "scripts\vdd-tasks-setup.ps1"; DestDir: "{app}\scripts"; Flags: ignorev Source: "scripts\vdd-tasks-remove.ps1"; DestDir: "{app}\scripts"; Flags: ignoreversion Source: "scripts\vdd-driver-remove.ps1"; DestDir: "{app}\scripts"; Flags: ignoreversion Source: "scripts\vdd-driver-verify.ps1"; DestDir: "{app}\scripts"; Flags: ignoreversion +Source: "scripts\vdd-driver-repair.ps1"; DestDir: "{app}\scripts"; Flags: ignoreversion Source: "scripts\vdd-toggle.ps1"; DestDir: "{app}\scripts"; Flags: ignoreversion #endif @@ -97,7 +98,8 @@ Filename: "{sys}\netsh.exe"; Parameters: "advfirewall firewall add rule name=""E Filename: "{app}\driver\vdd-setup-x64.exe"; Parameters: "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART"; StatusMsg: "Installing the virtual display driver (this enables the extended screen)..."; Flags: waituntilterminated; Check: NeedsVddInstall; AfterInstall: RecordVddOwnership ; Register the enable/disable scheduled tasks and leave the virtual display OFF by default — ; EternalMonitor turns it on only while streaming to it, so there's no phantom monitor. -Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\scripts\vdd-tasks-setup.ps1"""; StatusMsg: "Configuring the on-demand virtual display..."; Flags: runhidden waituntilterminated; Check: VddInstallSucceeded; AfterInstall: VerifyVddDriver +; A preserved driver whose device was removed or broke is repaired first. +Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\scripts\vdd-tasks-setup.ps1"""; StatusMsg: "Configuring the on-demand virtual display..."; Flags: runhidden waituntilterminated; Check: VddInstallSucceeded; BeforeInstall: RepairVddDriver; AfterInstall: VerifyVddDriver #endif ; Launch the app at the end. Filename: "{app}\EternalMonitor-host.exe"; Description: "Launch EternalMonitor now"; Flags: nowait postinstall skipifsilent; Check: VddInstallSucceeded @@ -175,6 +177,20 @@ begin end; end; +procedure RepairVddDriver; +var + ExitCode: Integer; +begin + { Setup keeps an existing driver instead of running its setup again. Put + back a device that was removed in Device Manager or lost its driver. } + if not Exec(ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'), + '-NoProfile -ExecutionPolicy Bypass -File "' + ExpandConstant('{app}\scripts\vdd-driver-repair.ps1') + '"', + '', SW_HIDE, ewWaitUntilTerminated, ExitCode) or (ExitCode <> 0) then begin + VddInstallFailed := True; + RaiseException('Setup could not repair the virtual display driver. Uninstall "Virtual Display Driver" in Windows Settings > Apps > Installed apps, then run this setup again.'); + end; +end; + procedure RecordVddOwnership; var Directory, Version: String; diff --git a/installer/scripts/vdd-driver-repair.ps1 b/installer/scripts/vdd-driver-repair.ps1 new file mode 100644 index 00000000..91499076 --- /dev/null +++ b/installer/scripts/vdd-driver-repair.ps1 @@ -0,0 +1,59 @@ +# Puts back the Virtual Display Driver's device when its vendor install is still registered. +# Setup keeps an existing driver instead of running the vendor setup again, so a device removed +# in Device Manager, or left without its driver or with a driver error, stayed broken through +# every reinstall. This repeats the vendor's own install.bat steps with its installed files. +# Run elevated by the installer, before the scheduled tasks disable the device again. +$ErrorActionPreference = 'Stop' +$hardwareId = 'Root\MttVDD' +$classGuid = '4D36E968-E325-11CE-BFC1-08002BE10318' + +function Get-VddDevices { + @(Get-PnpDevice -Class Display -PresentOnly | Where-Object { + $ids = (Get-PnpDeviceProperty -InstanceId $_.InstanceId -KeyName 'DEVPKEY_Device_HardwareIds').Data + $ids -contains $hardwareId + }) +} + +# Bound to an installed driver package, and either off (code 22, between streams) or running. +function Test-Healthy($device) { + $inf = (Get-PnpDeviceProperty -InstanceId $device.InstanceId -KeyName 'DEVPKEY_Device_DriverInfPath').Data + ($inf -match '^oem[0-9]+\.inf$') -and ([int]$device.Problem -in @(0, 22)) +} + +$devices = @(Get-VddDevices) +$broken = @($devices | Where-Object { !(Test-Healthy $_) }) +if ($devices.Count -gt 0 -and $broken.Count -eq 0) { + Write-Output 'Virtual Display Driver device is installed.' + return +} + +$registration = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\VirtualDisplayDriver_is1', + 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\VirtualDisplayDriver_is1' -ErrorAction SilentlyContinue | + Where-Object { $_.InstallLocation } | Select-Object -First 1 +$nefcon = if ($registration) { Join-Path $registration.InstallLocation 'nefconw.exe' } +$inf = if ($registration) { Join-Path $registration.InstallLocation 'MttVDD.inf' } +if (!$nefcon -or !(Test-Path $nefcon) -or !(Test-Path $inf)) { + throw 'The Virtual Display Driver files are missing. Uninstall Virtual Display Driver in Windows Settings > Apps, then run EternalMonitor Setup again.' +} + +# nefconw is a window-less GUI program: wait for it explicitly. +function Invoke-Nefcon([string]$Arguments) { + $process = Start-Process -FilePath $nefcon -ArgumentList $Arguments -Wait -PassThru -WindowStyle Hidden + # 3010: done, takes full effect after a restart. + if ($process.ExitCode -notin @(0, 3010)) { + throw "Virtual Display Driver repair failed: nefconw $($Arguments.Split(' ')[0]) exited $($process.ExitCode)." + } +} + +# --remove-device-node also removes the driver; --install-driver adds it back. +if ($devices.Count -gt 0) { + Invoke-Nefcon "--remove-device-node --hardware-id $hardwareId --class-guid $classGuid" +} +Invoke-Nefcon "--create-device-node --hardware-id $hardwareId --class-name Display --class-guid $classGuid" +Invoke-Nefcon "--install-driver --inf-path `"$inf`"" + +$repaired = @(Get-VddDevices) +if ($repaired.Count -eq 0 -or @($repaired | Where-Object { !(Test-Healthy $_) }).Count -gt 0) { + throw 'Windows did not accept the repaired Virtual Display Driver. Restart the PC, then run EternalMonitor Setup again.' +} +Write-Output "Repaired the Virtual Display Driver device: $($repaired[0].InstanceId)" diff --git a/installer/tests/Test-VddDriverRepair.ps1 b/installer/tests/Test-VddDriverRepair.ps1 new file mode 100644 index 00000000..9885b21a --- /dev/null +++ b/installer/tests/Test-VddDriverRepair.ps1 @@ -0,0 +1,129 @@ +# Pure script checks for the Setup step that repairs a preserved Virtual Display Driver. +# PnP, the registry and the vendor's nefconw are replaced in this process; nothing is installed. +$ErrorActionPreference = 'Stop' +$script = Join-Path $PSScriptRoot '..\scripts\vdd-driver-repair.ps1' +$iss = Get-Content -Raw (Join-Path $PSScriptRoot '..\EternalMonitor.iss') +$root = Join-Path $env:TEMP ('em-driver-repair-test-' + [guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Force $root | Out-Null +$nefcon = Join-Path $root 'nefconw.exe' +$inf = Join-Path $root 'MttVDD.inf' +[IO.File]::WriteAllText($nefcon, 'test fixture; never executed') +[IO.File]::WriteAllText($inf, 'test fixture') + +function Reset-Fixture { + $global:EMRepair = @{ + Registration = [pscustomobject]@{InstallLocation=$root; DisplayVersion='25.05.03'} + Calls = @(); Exit = 0; Accept = $true + Devices = @([pscustomobject]@{InstanceId='PCI\GPU';HardwareIds=@('PCI\GPU');Inf='oem1.inf';Problem=0}) + } +} +function Add-Vdd([string]$Inf, [int]$Problem) { + $global:EMRepair.Devices += [pscustomobject]@{InstanceId='ROOT\DISPLAY\0000';HardwareIds=@('Root\MttVDD');Inf=$Inf;Problem=$Problem} +} +function Get-PnpDevice { + [CmdletBinding()] param([string]$Class,[switch]$PresentOnly) + if ($Class -ne 'Display' -or !$PresentOnly) { throw 'Must query present display devices' } + $global:EMRepair.Devices +} +function Get-PnpDeviceProperty { + [CmdletBinding()] param([string]$InstanceId,[string]$KeyName) + $device = $global:EMRepair.Devices | Where-Object { $_.InstanceId -eq $InstanceId } + $value = switch ($KeyName) { + 'DEVPKEY_Device_HardwareIds' { $device.HardwareIds } + 'DEVPKEY_Device_DriverInfPath' { $device.Inf } + default { throw "Unexpected property $KeyName" } + } + [pscustomobject]@{Data=$value} +} +function Get-ItemProperty { [CmdletBinding()] param([string[]]$Path) $global:EMRepair.Registration } +# Plays the vendor tool: removes, creates and binds the device as nefconw would. +function Start-Process { + [CmdletBinding()] param([string]$FilePath,[string]$ArgumentList,[switch]$Wait,[switch]$PassThru,[string]$WindowStyle) + if ($FilePath -ne $nefcon -or !$Wait -or !$PassThru) { throw "Unexpected process $FilePath" } + $verb = $ArgumentList.Split(' ')[0] + $global:EMRepair.Calls += $verb + if ($global:EMRepair.Exit -notin @(0, 3010)) { return [pscustomobject]@{ExitCode=$global:EMRepair.Exit} } + switch ($verb) { + '--remove-device-node' { + if ($ArgumentList -notmatch 'Root\\MttVDD' -or $ArgumentList -notmatch '4D36E968-E325-11CE-BFC1-08002BE10318') { throw "Bad remove: $ArgumentList" } + $global:EMRepair.Devices = @($global:EMRepair.Devices | Where-Object { $_.HardwareIds -notcontains 'Root\MttVDD' }) + } + '--create-device-node' { + if ($ArgumentList -notmatch '--hardware-id Root\\MttVDD --class-name Display --class-guid 4D36E968') { throw "Bad create: $ArgumentList" } + Add-Vdd $null 28 + } + '--install-driver' { + if ($ArgumentList -notmatch [regex]::Escape("--inf-path `"$inf`"")) { throw "Bad install: $ArgumentList" } + if ($global:EMRepair.Accept) { + $vdd = $global:EMRepair.Devices | Where-Object { $_.HardwareIds -contains 'Root\MttVDD' } + $vdd.Inf = 'oem40.inf'; $vdd.Problem = 0 + } + } + default { throw "Unexpected nefconw verb $verb" } + } + [pscustomobject]@{ExitCode=$global:EMRepair.Exit} +} +function Expect-Calls([string]$Expected, [string]$Case) { + $actual = $global:EMRepair.Calls -join ',' + if ($actual -ne $Expected) { throw "$Case`: expected nefconw [$Expected], ran [$actual]" } +} +function Expect-Failure([string]$Message, [string]$Case) { + $failure = $null + try { & $script | Out-Null } catch { $failure = $_.Exception.Message } + if (!$failure -or $failure -notlike "*$Message*") { throw "$Case`: expected '$Message', got '$failure'" } +} +$repair = '--remove-device-node,--create-device-node,--install-driver' +try { + # Between streams the device is off with code 22; running is fine too. + foreach ($problem in 22, 0) { + Reset-Fixture; Add-Vdd 'oem33.inf' $problem + & $script | Out-Null + Expect-Calls '' "healthy device, code $problem" + } + + # Removed in Device Manager: created again and bound to the driver. + Reset-Fixture + & $script | Out-Null + Expect-Calls '--create-device-node,--install-driver' 'missing device' + + # A driver error, a signature block, or no driver: replaced as the vendor install does. + foreach ($case in @(@{Inf='oem33.inf';Problem=43}, @{Inf='oem33.inf';Problem=52}, @{Inf=$null;Problem=28}, @{Inf=$null;Problem=0})) { + Reset-Fixture; Add-Vdd $case.Inf $case.Problem + & $script | Out-Null + Expect-Calls $repair "device with inf '$($case.Inf)' and code $($case.Problem)" + } + + # A restart-required result still counts, once the device is bound. + Reset-Fixture; $global:EMRepair.Exit = 3010 + & $script | Out-Null + Expect-Calls '--create-device-node,--install-driver' 'restart required' + + Reset-Fixture; $global:EMRepair.Exit = 5 + Expect-Failure 'nefconw --create-device-node exited 5' 'vendor tool failure' + + Reset-Fixture; $global:EMRepair.Accept = $false + Expect-Failure 'did not accept' 'driver not bound after repair' + + # Without the vendor's files there is nothing to repair from: say how to reinstall. + Reset-Fixture; $global:EMRepair.Registration = $null + Expect-Failure 'Uninstall Virtual Display Driver' 'no registration' + Expect-Calls '' 'no registration' + Reset-Fixture; Remove-Item $nefcon + Expect-Failure 'Uninstall Virtual Display Driver' 'missing vendor tool' + [IO.File]::WriteAllText($nefcon, 'test fixture; never executed') + + # Setup ships the step and runs it before the tasks disable the device, also when the + # existing driver is preserved. + if ($iss -notmatch 'Source: "scripts\\vdd-driver-repair\.ps1"') { throw 'Setup does not install the repair script' } + $tasks = ($iss -split "`n") | Where-Object { $_ -match 'vdd-tasks-setup\.ps1' -and $_ -match '^Filename:' } + if ($tasks -notmatch 'BeforeInstall: RepairVddDriver' -or $tasks -match 'Check: NeedsVddInstall') { + throw 'Setup does not repair a preserved driver before registering the tasks' + } + if ($iss -notmatch "(?s)procedure RepairVddDriver;.*vdd-driver-repair\.ps1.*ExitCode <> 0.*RaiseException") { + throw 'Setup ignores a failed repair' + } + Write-Output 'PASS: healthy, missing, driver error, signature, unbound, restart, tool failure, rejected, no vendor files, Setup wiring' +} finally { + Remove-Item $root -Recurse -Force + Remove-Variable EMRepair -Scope Global -ErrorAction SilentlyContinue +} From d90893161a8703d7eb559c572be093cab8671c73 Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Thu, 22 Oct 2026 16:10:51 -0400 Subject: [PATCH 06/39] Describe the second screen and Windows 11 fixes --- RELEASE_NOTES.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 04f64166..b8740f5f 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -92,6 +92,23 @@ Windows changes. - Turning on the extended display no longer triggers repeated stream restarts while Windows runs the display task. When that task hangs or is missing, the host mirrors the main screen immediately instead of waiting for it each time. +- When the second screen doesn't appear, the Windows Stream page says why and + what to do: the display driver isn't installed (run Setup again), Windows + couldn't verify the driver's signature, the driver stopped with an error + (with the Windows code: restart, then run Setup again), or the driver ran but + Windows added no screen. The display task no longer reports success when the + display cannot appear, and the device state is in the support diagnostics. + In 0.3.1 every case read "no display appeared in time". +- On laptops with Intel and NVIDIA graphics, the second screen is drawn by the + graphics card that drives the main screen. In 0.3.1 diagnostics none of these + laptops got the second screen. If Windows adds the screen but keeps the + desktop off it, EternalMonitor switches the layout to Extend. +- Running EternalMonitor-Setup.exe again repairs the virtual display driver + when its device was removed in Device Manager or shows a driver error. In + 0.3.1 Setup kept the existing driver and stopped with "Windows did not finish + installing the virtual display driver". +- Support diagnostics name Windows 11 from the build number. Windows 11 keeps + "Windows 10" in the registry, which 0.3.1 reported as the system. - With no iPad connected, the PC no longer encodes its screen. It reads the screen at most once a second, so an iPad that connects still sees the current desktop. From 17977a02daefe4328cc6aea2af9db166124eaa3e Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Thu, 22 Oct 2026 16:13:20 -0400 Subject: [PATCH 07/39] Check the virtual display device state on a real PC --- host/src/vdd_device.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/host/src/vdd_device.rs b/host/src/vdd_device.rs index 4d930348..ccc98478 100644 --- a/host/src/vdd_device.rs +++ b/host/src/vdd_device.rs @@ -350,6 +350,19 @@ mod tests { assert_eq!(DeviceState::Ready.problem(), NotExtended); } + /// Reads this PC's driver device through Configuration Manager: + /// `EM_EXPECT_VDD=problem_22 cargo test -p eternal-host --lib native_device_state -- --ignored` + #[cfg(windows)] + #[test] + #[ignore = "reads the virtual display device of this PC"] + fn native_device_state() { + let state = query(); + println!("vdd_device={state} problem={:?}", state.problem()); + if let Ok(expected) = std::env::var("EM_EXPECT_VDD") { + assert_eq!(state.to_string(), expected); + } + } + #[test] fn log_names_carry_no_instance_details() { assert_eq!(DeviceState::Problem(43).to_string(), "problem_43"); From b08df3fb000487577c8f4dd8550335aff2401635 Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Thu, 22 Oct 2026 17:04:30 -0400 Subject: [PATCH 08/39] Leave the display layout to the user when the desktop skips the second screen --- host/Cargo.toml | 1 - host/src/capture/mod.rs | 53 +++++++++++-------------------------- host/src/gui/page_stream.rs | 2 +- host/src/gui/tests.rs | 10 ++++++- host/src/vdd_device.rs | 9 ------- 5 files changed, 25 insertions(+), 50 deletions(-) diff --git a/host/Cargo.toml b/host/Cargo.toml index af396724..dda4bd40 100644 --- a/host/Cargo.toml +++ b/host/Cargo.toml @@ -59,7 +59,6 @@ version = "0.58" features = [ "Win32_Foundation", "Win32_Devices_DeviceAndDriverInstallation", - "Win32_Devices_Display", "Win32_Security", "Win32_Media_Audio", "Win32_System_Com", diff --git a/host/src/capture/mod.rs b/host/src/capture/mod.rs index c1d201dc..5e3a1fb8 100644 --- a/host/src/capture/mod.rs +++ b/host/src/capture/mod.rs @@ -18,7 +18,7 @@ use crate::control::SharedControl; #[cfg(windows)] use crate::control::{CaptureTarget, VddStatus}; #[cfg(windows)] -use crate::vdd_device::DeviceState; +use crate::vdd_device::{DeviceState, VddProblem}; #[cfg(windows)] pub mod dxgi; @@ -340,26 +340,10 @@ fn capture_is_current(shared: &SharedControl, generation: u64) -> bool { && crate::supervisor::generation_is_current(generation) } -/// How long to wait for the display after asking Windows to extend onto it. +/// How long the attempt after "kept your desktop off it" waits, while the +/// user picks Extend with Windows key + P or in Settings. #[cfg(windows)] -const VDD_EXTEND_TIMEOUT: Duration = Duration::from_secs(5); - -/// Say why the second screen did not appear: on the Stream page each time, -/// and in the log once per iPad session for each state, so diagnostics show -/// it without repeating every retry. -#[cfg(windows)] -fn report_device(shared: &SharedControl, device: DeviceState) { - static LOGGED: Mutex, DeviceState)>> = Mutex::new(Vec::new()); - let problem = device.problem(); - *shared.vdd_problem.lock() = Some(problem); - let key = (shared.session.lock().session_id(), device); - let mut logged = LOGGED.lock(); - logged.retain(|(session, _)| *session == key.0); - if !logged.contains(&key) { - logged.push(key); - warn!(vdd_device = %device, ?problem, "Virtual display device state"); - } -} +const VDD_EXTEND_TIMEOUT: Duration = Duration::from_secs(30); #[cfg(windows)] fn vdd_attach_timeout() -> Duration { @@ -488,7 +472,12 @@ pub(crate) fn reconcile_virtual_display( return None; } startup_heartbeat(shared, crate::clock::host_now_us() / 1000); - *shared.vdd_problem.lock() = None; + // After "kept your desktop off it" the user switches Windows to + // Extend while this attempt waits, so give them time to. + let timeout = match shared.vdd_problem.lock().take() { + Some(VddProblem::NotExtended) => VDD_EXTEND_TIMEOUT, + _ => vdd_attach_timeout(), + }; if let Err(device) = crate::vdd::enable(progress) { if !capture_is_current(shared, generation) { return None; @@ -514,23 +503,11 @@ pub(crate) fn reconcile_virtual_display( startup_heartbeat(shared, crate::clock::host_now_us() / 1000); Some(enumerate_outputs()) }; - let wait = |timeout| { - wait_for_virtual_output(&before, timeout, poll, Instant::now, std::thread::sleep) - }; - let mut result = wait(vdd_attach_timeout()); - let mut device = None; - if matches!(result, AttachResult::TimedOut) { - let state = crate::vdd_device::query(); - device = Some(state); - // The monitor arrived, but Windows did not put the desktop on - // it: the saved layout for these displays mirrors or leaves - // it out. The user asked for an extra screen. - if state == DeviceState::Ready && crate::vdd_device::extend_desktop() { - info!("Virtual display was off the desktop — asked Windows to extend onto it"); - result = wait(VDD_EXTEND_TIMEOUT); - device = Some(crate::vdd_device::query()); - } - } + let result = + wait_for_virtual_output(&before, timeout, poll, Instant::now, std::thread::sleep); + // A device that is ready with its monitor means Windows left the + // desktop off it. The display layout stays the user's to change. + let device = matches!(result, AttachResult::TimedOut).then(crate::vdd_device::query); if !capture_is_current(shared, generation) { return None; } diff --git a/host/src/gui/page_stream.rs b/host/src/gui/page_stream.rs index 65bcc1fb..90aab33d 100644 --- a/host/src/gui/page_stream.rs +++ b/host/src/gui/page_stream.rs @@ -697,7 +697,7 @@ pub(super) fn second_screen_notice( false, ), (Failed | TaskFailed, VddTaskHealth::Ok, Some(VddProblem::NotExtended)) => notice( - "Windows added the second screen but wouldn't extend your desktop onto it. Click Try again. If it keeps happening, restart the PC.", + "Windows added the second screen but kept your desktop off it. Click Try again, and while the second screen turns on, press Windows key + P and choose Extend, or choose Extend these displays in Settings > System > Display. EternalMonitor waits 30 seconds, and Windows remembers the choice.", true, false, ), diff --git a/host/src/gui/tests.rs b/host/src/gui/tests.rs index 2aeaaaf7..f550032e 100644 --- a/host/src/gui/tests.rs +++ b/host/src/gui/tests.rs @@ -387,12 +387,20 @@ fn the_second_screen_notice_says_what_to_do_about_the_driver_device() { none.body ); assert!(none.retry && !none.installer); + // The user changes the layout; EternalMonitor never does. let off_desktop = notice(status, VddProblem::NotExtended); assert!( - off_desktop.body.contains("extend your desktop"), + off_desktop.body.contains("kept your desktop off it"), "{}", off_desktop.body ); + assert!(off_desktop + .body + .contains("Windows key + P and choose Extend")); + assert!(off_desktop + .body + .contains("Extend these displays in Settings > System > Display")); + assert!(off_desktop.retry && !off_desktop.installer); } // A hung or missing task still says so first: its fix comes before the device's. let hung = second_screen_notice( diff --git a/host/src/vdd_device.rs b/host/src/vdd_device.rs index ccc98478..4147b991 100644 --- a/host/src/vdd_device.rs +++ b/host/src/vdd_device.rs @@ -224,15 +224,6 @@ pub fn query() -> DeviceState { DeviceState::Missing } -/// Ask Windows to extend the desktop across every connected display, as -/// Windows+P > Extend does. Windows remembers the choice for this set of -/// displays, which includes the virtual one, so it applies only while it is on. -#[cfg(windows)] -pub fn extend_desktop() -> bool { - use windows::Win32::Devices::Display::{SetDisplayConfig, SDC_APPLY, SDC_TOPOLOGY_EXTEND}; - unsafe { SetDisplayConfig(None, None, SDC_APPLY | SDC_TOPOLOGY_EXTEND) == 0 } -} - #[cfg(test)] mod tests { use super::*; From aee11b2c7e9a3d636dd82ce15d83e0892d0b6102 Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Thu, 22 Oct 2026 17:04:40 -0400 Subject: [PATCH 09/39] Pin the virtual display's graphics card whatever the resolution setting --- host/src/capture/dxgi.rs | 13 ++++ host/src/capture/mod.rs | 109 +++++++++++++++++++++-------- host/src/vdd_settings.rs | 146 ++++++++++++++++++++++++++++++++++++--- 3 files changed, 230 insertions(+), 38 deletions(-) diff --git a/host/src/capture/dxgi.rs b/host/src/capture/dxgi.rs index 6ffb5533..4f019f55 100644 --- a/host/src/capture/dxgi.rs +++ b/host/src/capture/dxgi.rs @@ -59,6 +59,19 @@ pub fn enumerate_outputs() -> Vec { enumerate_factory_outputs(&factory) } +/// The description of every DXGI adapter, as the virtual display driver +/// matches its configured render adapter against them. +pub fn adapter_names() -> Vec { + let Ok(factory) = (unsafe { CreateDXGIFactory1::() }) else { + return Vec::new(); + }; + (0..) + .map_while(|index| unsafe { factory.EnumAdapters1(index) }.ok()) + .filter_map(|adapter| unsafe { adapter.GetDesc1() }.ok()) + .map(|desc| utf16_to_string(&desc.Description)) + .collect() +} + fn enumerate_factory_outputs(factory: &IDXGIFactory1) -> Vec { let mut outputs = Vec::new(); diff --git a/host/src/capture/mod.rs b/host/src/capture/mod.rs index 5e3a1fb8..9f0c5ef9 100644 --- a/host/src/capture/mod.rs +++ b/host/src/capture/mod.rs @@ -345,6 +345,84 @@ fn capture_is_current(shared: &SharedControl, generation: u64) -> bool { #[cfg(windows)] const VDD_EXTEND_TIMEOUT: Duration = Duration::from_secs(30); +#[cfg(windows)] +const VDD_SETTINGS_PATH: &str = r"C:\VirtualDisplayDriver\vdd_settings.xml"; + +/// Before each enable: name the main screen's graphics card for the driver +/// to render on, and, when the user wants it, the iPad's modes. Without a +/// name the driver renders on the card with the most memory, which on +/// Intel + NVIDIA laptops drives no screen. Failure is non-fatal — the +/// driver's built-in choices apply. +#[cfg(windows)] +fn prepare_vdd_settings(shared: &SharedControl, outputs: &[OutputInfo]) { + let main_screen = outputs + .iter() + .find(|output| output.is_primary) + .map(|output| output.adapter_name.as_str()); + let gpu = match crate::vdd_settings::gpu_to_pin(main_screen, &dxgi::adapter_names()) { + Ok(gpu) => Some(gpu), + Err(reason) => { + info!( + reason, + main_screen, "Leaving the virtual display's graphics card to the driver" + ); + None + } + }; + let client = crate::settings::SettingsFile::load() + .vdd_match_resolution + .then(|| shared.session.lock().client_info()) + .flatten(); + let path = std::path::Path::new(VDD_SETTINGS_PATH); + if let Some(info) = client { + let modes = crate::vdd_settings::modes_for_client( + info.screen_px.0, + info.screen_px.1, + info.refresh_hz, + ); + match crate::vdd_settings::write_to(path, &modes, gpu) { + Ok(()) => info!( + ?modes, + gpu = gpu.unwrap_or("chosen by the driver"), + "Wrote virtual display mode list for the connected client" + ), + Err(error) => warn!( + %error, + "Could not write vdd_settings.xml — driver default modes apply" + ), + } + } else if let Some(gpu) = gpu { + // The modes in the file are the user's: change only the card. + match crate::vdd_settings::pin_gpu_in(path, gpu) { + Ok(true) => info!( + gpu, + "Pointed the virtual display at the main screen's graphics card" + ), + Ok(false) => {} + Err(error) => { + warn!(%error, gpu, "Could not name the graphics card in vdd_settings.xml") + } + } + } +} + +/// Say why the second screen did not appear: on the Stream page each time, +/// and in the log once per iPad session for each state, so diagnostics show +/// it without repeating every retry. +#[cfg(windows)] +fn report_device(shared: &SharedControl, device: DeviceState) { + static LOGGED: Mutex, DeviceState)>> = Mutex::new(Vec::new()); + let problem = device.problem(); + *shared.vdd_problem.lock() = Some(problem); + let key = (shared.session.lock().session_id(), device); + let mut logged = LOGGED.lock(); + logged.retain(|(session, _)| *session == key.0); + if !logged.contains(&key) { + logged.push(key); + warn!(vdd_device = %device, ?problem, "Virtual display device state"); + } +} + #[cfg(windows)] fn vdd_attach_timeout() -> Duration { std::env::var("ETERNAL_VDD_TIMEOUT_SECS") @@ -436,36 +514,7 @@ pub(crate) fn reconcile_virtual_display( } *shared.vdd_output.lock() = None; - // Best effort: match the virtual display's mode list to the - // connected iPad's panel BEFORE enabling the driver (it reads - // vdd_settings.xml at enable time). Failure is non-fatal — the - // driver's built-in modes apply. NEEDS_WINDOWS_VERIFY (M10). - if crate::settings::SettingsFile::load().vdd_match_resolution { - let gpu = outputs - .iter() - .find(|output| output.is_primary) - .map(|output| output.adapter_name.as_str()) - .filter(|name| !name.is_empty()); - if let Some(info) = shared.session.lock().client_info() { - let modes = crate::vdd_settings::modes_for_client( - info.screen_px.0, - info.screen_px.1, - info.refresh_hz, - ); - let path = std::path::Path::new(r"C:\VirtualDisplayDriver\vdd_settings.xml"); - match crate::vdd_settings::write_to(path, &modes, gpu) { - Ok(()) => info!( - ?modes, - gpu = gpu.unwrap_or("chosen by the driver"), - "Wrote virtual display mode list for the connected client" - ), - Err(error) => warn!( - %error, - "Could not write vdd_settings.xml — driver default modes apply" - ), - } - } - } + prepare_vdd_settings(shared, &outputs); let before: Vec = outputs.into_iter().map(|o| o.device_name).collect(); if !capture_is_current(shared, generation) { diff --git a/host/src/vdd_settings.rs b/host/src/vdd_settings.rs index ff7cb39d..cf6c00ed 100644 --- a/host/src/vdd_settings.rs +++ b/host/src/vdd_settings.rs @@ -73,13 +73,7 @@ pub fn render(modes: &[VddMode], gpu: Option<&str>) -> String { xml.push_str("\n"); xml.push_str(" \n 1\n \n"); if let Some(gpu) = gpu { - let gpu = gpu - .replace('&', "&") - .replace('<', "<") - .replace('>', ">"); - xml.push_str(&format!( - " \n {gpu}\n \n" - )); + xml.push_str(&gpu_element(gpu)); } xml.push_str(" \n"); xml.push_str(&format!( @@ -100,6 +94,71 @@ pub fn render(modes: &[VddMode], gpu: Option<&str>) -> String { xml } +fn escape(text: &str) -> String { + text.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +fn gpu_element(gpu: &str) -> String { + format!( + " \n {}\n \n", + escape(gpu) + ) +} + +/// The graphics card to name for the driver: the one driving the main +/// screen. The driver matches by description and takes the first match, so +/// a name two adapters share is not pinned; the error says why for the log. +pub fn gpu_to_pin<'a>( + main_screen: Option<&'a str>, + adapters: &[String], +) -> Result<&'a str, &'static str> { + let name = main_screen + .filter(|name| !name.is_empty()) + .ok_or("no adapter drives the main screen")?; + if adapters.iter().filter(|adapter| *adapter == name).count() > 1 { + return Err("two graphics cards have the same name"); + } + Ok(name) +} + +/// `xml` naming `gpu` as the render adapter, everything else as it was: +/// the existing name is replaced, or a `` element added at the top. +/// `None` when the document has no `` element to add it to. +pub fn with_gpu(xml: &str, gpu: &str) -> Option { + const OPEN: &str = ""; + const CLOSE: &str = ""; + if let Some(start) = xml.find(OPEN).map(|at| at + OPEN.len()) { + let end = start + xml[start..].find(CLOSE)?; + return Some(format!("{}{}{}", &xml[..start], escape(gpu), &xml[end..])); + } + let root = xml.find("")? + "".len(); + let line_end = xml[root..].find('\n').map_or(root, |at| root + at + 1); + let separator = if line_end == root { "\n" } else { "" }; + Some(format!( + "{}{separator}{}{}", + &xml[..line_end], + gpu_element(gpu), + &xml[line_end..] + )) +} + +/// Point the settings file the host does not otherwise write at `gpu`. +/// True when it changed. A missing file stays missing: without one the +/// driver offers its built-in modes, and a document holding only a GPU +/// would leave it none. +pub fn pin_gpu_in(path: &Path, gpu: &str) -> io::Result { + let current = std::fs::read_to_string(path)?; + let updated = with_gpu(¤t, gpu) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "no element"))?; + if updated == current { + return Ok(false); + } + replace(path, &updated)?; + Ok(true) +} + /// Write atomically (tmp + rename) so the driver never reads a torn file. /// Creates the directory if the driver hasn't made it yet. pub fn write_to(path: &Path, modes: &[VddMode], gpu: Option<&str>) -> io::Result<()> { @@ -107,8 +166,12 @@ pub fn write_to(path: &Path, modes: &[VddMode], gpu: Option<&str>) -> io::Result io::Error::new(io::ErrorKind::InvalidInput, "settings path has no parent") })?; std::fs::create_dir_all(parent)?; + replace(path, &render(modes, gpu)) +} + +fn replace(path: &Path, contents: &str) -> io::Result<()> { let tmp = path.with_extension("xml.tmp"); - std::fs::write(&tmp, render(modes, gpu))?; + std::fs::write(&tmp, contents)?; std::fs::rename(&tmp, path) } @@ -197,6 +260,73 @@ mod tests { assert!(xml.contains("A&B <GPU>")); } + /// The vendor's own file, as its installer leaves it (shortened). + const VENDOR: &str = "\r\n\r\n \r\n 1\r\n \r\n \r\n default\r\n \r\n\t\r\n\t\t60\r\n\t\r\n\t\r\n\t\ttrue\r\n\t\r\n\r\n"; + + #[test] + fn the_main_screens_card_is_pinned_unless_its_name_is_shared() { + let adapters = |names: &[&str]| names.iter().map(|n| n.to_string()).collect::>(); + let hybrid = adapters(&[ + "Intel(R) Iris(R) Xe Graphics", + "NVIDIA GeForce RTX 3050 Ti Laptop GPU", + ]); + assert_eq!( + gpu_to_pin(Some("Intel(R) Iris(R) Xe Graphics"), &hybrid), + Ok("Intel(R) Iris(R) Xe Graphics") + ); + // A 0.3.1 host listed "AMD Radeon 780M Graphics" twice; the driver + // would take whichever it enumerates first. + let twins = adapters(&["AMD Radeon 780M Graphics", "AMD Radeon 780M Graphics"]); + assert!(gpu_to_pin(Some("AMD Radeon 780M Graphics"), &twins).is_err()); + assert!(gpu_to_pin(None, &hybrid).is_err()); + assert!(gpu_to_pin(Some(""), &hybrid).is_err()); + } + + #[test] + fn with_matching_off_only_the_gpu_name_changes() { + let pinned = with_gpu(VENDOR, "Intel(R) Iris(R) Xe Graphics").unwrap(); + assert_eq!( + pinned, + VENDOR.replace( + "default", + "Intel(R) Iris(R) Xe Graphics" + ) + ); + // A file without a element gets one under the root. + let bare = "\n \n 1\n \n\n"; + assert_eq!( + with_gpu(bare, "A&B").unwrap(), + "\n \n A&B\n \n \n 1\n \n\n" + ); + assert_eq!(with_gpu("", "x"), None); + } + + #[test] + fn a_stale_pin_is_corrected_and_a_current_one_left_alone() { + let dir = std::env::temp_dir().join(format!("vdd-pin-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("vdd_settings.xml"); + // Pinned earlier to a card that no longer drives the main screen. + let stale = with_gpu(VENDOR, "AMD Radeon(TM) Graphics").unwrap(); + std::fs::write(&path, &stale).unwrap(); + assert!(pin_gpu_in(&path, "NVIDIA GeForce RTX 5080").unwrap()); + let written = std::fs::read_to_string(&path).unwrap(); + assert_eq!( + written, + with_gpu(VENDOR, "NVIDIA GeForce RTX 5080").unwrap() + ); + assert!( + !pin_gpu_in(&path, "NVIDIA GeForce RTX 5080").unwrap(), + "no rewrite" + ); + // No file: none is made, so the driver keeps its built-in modes. + let missing = dir.join("missing.xml"); + assert!(pin_gpu_in(&missing, "NVIDIA GeForce RTX 5080").is_err()); + assert!(!missing.exists()); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn write_is_atomic_and_creates_the_directory() { let dir = std::env::temp_dir().join(format!("vdd-settings-test-{}", std::process::id())); From af0f8a6ee9339d15db4595f061bb7bea6182bbee Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Thu, 22 Oct 2026 17:04:40 -0400 Subject: [PATCH 10/39] Repair only a driver Setup installed, and keep working devices --- installer/EternalMonitor.iss | 20 ++- installer/scripts/vdd-driver-repair.ps1 | 108 +++++++++---- installer/tests/Test-VddDriverRepair.ps1 | 194 ++++++++++++++--------- 3 files changed, 210 insertions(+), 112 deletions(-) diff --git a/installer/EternalMonitor.iss b/installer/EternalMonitor.iss index 7e0f6feb..6f0f0a62 100644 --- a/installer/EternalMonitor.iss +++ b/installer/EternalMonitor.iss @@ -180,15 +180,25 @@ end; procedure RepairVddDriver; var ExitCode: Integer; + Message: String; begin - { Setup keeps an existing driver instead of running its setup again. Put - back a device that was removed in Device Manager or lost its driver. } + { Setup keeps an existing driver instead of running its setup again. When + Setup installed it and no device works, put one back (exit codes in the + script). A driver another app installed is left alone. } if not Exec(ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'), '-NoProfile -ExecutionPolicy Bypass -File "' + ExpandConstant('{app}\scripts\vdd-driver-repair.ps1') + '"', - '', SW_HIDE, ewWaitUntilTerminated, ExitCode) or (ExitCode <> 0) then begin - VddInstallFailed := True; - RaiseException('Setup could not repair the virtual display driver. Uninstall "Virtual Display Driver" in Windows Settings > Apps > Installed apps, then run this setup again.'); + '', SW_HIDE, ewWaitUntilTerminated, ExitCode) then + ExitCode := -1; + if ExitCode = 0 then + exit; + case ExitCode of + 10: Message := 'Another app installed the virtual display driver, and none of its displays works. EternalMonitor does not change another app''s driver. Repair it in that app, or uninstall "Virtual Display Driver" in Windows Settings > Apps > Installed apps, then run this setup again.'; + 11: Message := 'The virtual display driver''s files are missing. Uninstall "Virtual Display Driver" in Windows Settings > Apps > Installed apps, then run this setup again.'; + else + Message := 'Setup could not repair the virtual display driver. Restart the PC and run this setup again. If it still fails, uninstall "Virtual Display Driver" in Windows Settings > Apps > Installed apps, then run this setup again.'; end; + VddInstallFailed := True; + RaiseException(Message); end; procedure RecordVddOwnership; diff --git a/installer/scripts/vdd-driver-repair.ps1 b/installer/scripts/vdd-driver-repair.ps1 index 91499076..3e664a51 100644 --- a/installer/scripts/vdd-driver-repair.ps1 +++ b/installer/scripts/vdd-driver-repair.ps1 @@ -1,8 +1,16 @@ -# Puts back the Virtual Display Driver's device when its vendor install is still registered. -# Setup keeps an existing driver instead of running the vendor setup again, so a device removed -# in Device Manager, or left without its driver or with a driver error, stayed broken through -# every reinstall. This repeats the vendor's own install.bat steps with its installed files. -# Run elevated by the installer, before the scheduled tasks disable the device again. +# Puts back the Virtual Display Driver's device when EternalMonitor installed the driver and no +# working device is left. Setup keeps an existing driver instead of running the vendor setup +# again, so a device removed in Device Manager, or left without its driver or with a driver +# error, stayed broken through every reinstall. Run elevated by the installer, before the +# scheduled tasks disable the device again. +# +# It changes nothing while any device works, and nothing for a driver another app installed. +# The driver package is staged first, and a replacement device must work before the broken +# ones are removed, so a failure leaves the devices as they were. A rerun after success does +# nothing. +# +# Exit codes, which Setup turns into its messages (installer/EternalMonitor.iss): +# 0 working or repaired 1 repair failed 10 another app installed the driver 11 files missing $ErrorActionPreference = 'Stop' $hardwareId = 'Root\MttVDD' $classGuid = '4D36E968-E325-11CE-BFC1-08002BE10318' @@ -20,40 +28,70 @@ function Test-Healthy($device) { ($inf -match '^oem[0-9]+\.inf$') -and ([int]$device.Problem -in @(0, 22)) } -$devices = @(Get-VddDevices) -$broken = @($devices | Where-Object { !(Test-Healthy $_) }) -if ($devices.Count -gt 0 -and $broken.Count -eq 0) { - Write-Output 'Virtual Display Driver device is installed.' - return -} +function Get-Working { @(Get-VddDevices | Where-Object { Test-Healthy $_ }) } -$registration = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\VirtualDisplayDriver_is1', - 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\VirtualDisplayDriver_is1' -ErrorAction SilentlyContinue | - Where-Object { $_.InstallLocation } | Select-Object -First 1 -$nefcon = if ($registration) { Join-Path $registration.InstallLocation 'nefconw.exe' } -$inf = if ($registration) { Join-Path $registration.InstallLocation 'MttVDD.inf' } -if (!$nefcon -or !(Test-Path $nefcon) -or !(Test-Path $inf)) { - throw 'The Virtual Display Driver files are missing. Uninstall Virtual Display Driver in Windows Settings > Apps, then run EternalMonitor Setup again.' +function Invoke-PnpUtil([string[]]$Arguments) { + pnputil.exe @Arguments | Out-Null + # 3010: done, takes effect after a restart. 259: staged, no device needed updating. + if ($LASTEXITCODE -notin @(0, 3010, 259)) { + throw "pnputil $($Arguments[0]) exited $LASTEXITCODE." + } } -# nefconw is a window-less GUI program: wait for it explicitly. -function Invoke-Nefcon([string]$Arguments) { - $process = Start-Process -FilePath $nefcon -ArgumentList $Arguments -Wait -PassThru -WindowStyle Hidden - # 3010: done, takes full effect after a restart. - if ($process.ExitCode -notin @(0, 3010)) { - throw "Virtual Display Driver repair failed: nefconw $($Arguments.Split(' ')[0]) exited $($process.ExitCode)." +try { + if (@(Get-Working).Count -gt 0) { + Write-Output 'Virtual Display Driver device is installed.' + exit 0 } -} -# --remove-device-node also removes the driver; --install-driver adds it back. -if ($devices.Count -gt 0) { - Invoke-Nefcon "--remove-device-node --hardware-id $hardwareId --class-guid $classGuid" -} -Invoke-Nefcon "--create-device-node --hardware-id $hardwareId --class-name Display --class-guid $classGuid" -Invoke-Nefcon "--install-driver --inf-path `"$inf`"" + # The ownership rule of vdd-driver-remove.ps1: Setup recorded this exact vendor install + # when it ran the vendor setup itself. + $marker = Join-Path $PSScriptRoot '..\driver\installed-by-eternalmonitor.txt' + $registration = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\VirtualDisplayDriver_is1', + 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\VirtualDisplayDriver_is1' -ErrorAction SilentlyContinue | + Where-Object { $_.InstallLocation -and $_.DisplayVersion } | Select-Object -First 1 + $owned = $registration -and (Test-Path $marker) -and + ([IO.File]::ReadAllText($marker) -eq ((Join-Path $registration.InstallLocation 'unins000.exe') + "`r`n" + $registration.DisplayVersion)) + if (!$owned) { + [Console]::Error.WriteLine('Another app installed the Virtual Display Driver. EternalMonitor leaves it alone.') + exit 10 + } + $inf = Join-Path $registration.InstallLocation 'MttVDD.inf' + $nefcon = Join-Path $registration.InstallLocation 'nefconw.exe' + if (!(Test-Path $inf) -or !(Test-Path $nefcon)) { + [Console]::Error.WriteLine("The Virtual Display Driver files are missing from $($registration.InstallLocation).") + exit 11 + } + + # Stage the package and bind it to matching devices. This alone repairs a device whose + # driver package was deleted, and nothing is removed if it fails. + Invoke-PnpUtil @('/add-driver', $inf, '/install') + if (@(Get-Working).Count -gt 0) { + Write-Output 'Repaired the Virtual Display Driver by reinstalling its driver package.' + exit 0 + } -$repaired = @(Get-VddDevices) -if ($repaired.Count -eq 0 -or @($repaired | Where-Object { !(Test-Healthy $_) }).Count -gt 0) { - throw 'Windows did not accept the repaired Virtual Display Driver. Restart the PC, then run EternalMonitor Setup again.' + # Still nothing works: add a device as the vendor install does, and bind it. + $broken = @(Get-VddDevices | ForEach-Object { $_.InstanceId }) + $process = Start-Process -FilePath $nefcon -WindowStyle Hidden -Wait -PassThru ` + -ArgumentList "--create-device-node --hardware-id $hardwareId --class-name Display --class-guid $classGuid" + $added = @(Get-VddDevices | Where-Object { $broken -notcontains $_.InstanceId }) + if ($process.ExitCode -ne 0 -or $added.Count -ne 1) { + foreach ($device in $added) { Invoke-PnpUtil @('/remove-device', $device.InstanceId) } + throw "Could not add a Virtual Display Driver device: nefconw exited $($process.ExitCode)." + } + $new = $added[0].InstanceId + # A failure here is judged by the device's state below, which undoes the new device. + try { Invoke-PnpUtil @('/add-driver', $inf, '/install') } catch {} + if (@(Get-Working | Where-Object { $_.InstanceId -eq $new }).Count -eq 0) { + Invoke-PnpUtil @('/remove-device', $new) + throw 'Windows did not start the new Virtual Display Driver device.' + } + # The new device works: only now remove the broken ones, by instance. + foreach ($id in $broken) { Invoke-PnpUtil @('/remove-device', $id) } + Write-Output "Repaired the Virtual Display Driver device: $new" + exit 0 +} catch { + [Console]::Error.WriteLine("Virtual Display Driver repair failed: $($_.Exception.Message)") + exit 1 } -Write-Output "Repaired the Virtual Display Driver device: $($repaired[0].InstanceId)" diff --git a/installer/tests/Test-VddDriverRepair.ps1 b/installer/tests/Test-VddDriverRepair.ps1 index 9885b21a..6a651c96 100644 --- a/installer/tests/Test-VddDriverRepair.ps1 +++ b/installer/tests/Test-VddDriverRepair.ps1 @@ -1,25 +1,38 @@ -# Pure script checks for the Setup step that repairs a preserved Virtual Display Driver. -# PnP, the registry and the vendor's nefconw are replaced in this process; nothing is installed. +# Pure script checks for the Setup step that repairs a Virtual Display Driver EternalMonitor +# installed. PnP, the registry, pnputil and the vendor's nefconw are replaced in this process, +# and the script runs from a scratch copy; nothing is installed or removed. +param([string]$RepairPath) $ErrorActionPreference = 'Stop' -$script = Join-Path $PSScriptRoot '..\scripts\vdd-driver-repair.ps1' +$source = if ($RepairPath) { $RepairPath } else { Join-Path $PSScriptRoot '..\scripts\vdd-driver-repair.ps1' } $iss = Get-Content -Raw (Join-Path $PSScriptRoot '..\EternalMonitor.iss') $root = Join-Path $env:TEMP ('em-driver-repair-test-' + [guid]::NewGuid().ToString('N')) -New-Item -ItemType Directory -Force $root | Out-Null -$nefcon = Join-Path $root 'nefconw.exe' -$inf = Join-Path $root 'MttVDD.inf' -[IO.File]::WriteAllText($nefcon, 'test fixture; never executed') -[IO.File]::WriteAllText($inf, 'test fixture') +New-Item -ItemType Directory -Force "$root\scripts", "$root\driver", "$root\vendor" | Out-Null +$script = "$root\scripts\vdd-driver-repair.ps1" +Copy-Item $source $script +$vendor = "$root\vendor" +$nefcon = "$vendor\nefconw.exe" +$inf = "$vendor\MttVDD.inf" +$marker = "$root\driver\installed-by-eternalmonitor.txt" function Reset-Fixture { + foreach ($file in $nefcon, $inf) { [IO.File]::WriteAllText($file, 'test fixture; never executed') } + [IO.File]::WriteAllText($marker, "$vendor\unins000.exe`r`n25.05.03") $global:EMRepair = @{ - Registration = [pscustomobject]@{InstallLocation=$root; DisplayVersion='25.05.03'} - Calls = @(); Exit = 0; Accept = $true + Registration = [pscustomobject]@{InstallLocation=$vendor; DisplayVersion='25.05.03'} + Calls = @(); Next = 1 + # The driver package in the Windows driver store, which binds to new devices. + InStore = $true; StageExit = 0 + # Whether a device the package binds to then starts. + NewStarts = $true Devices = @([pscustomobject]@{InstanceId='PCI\GPU';HardwareIds=@('PCI\GPU');Inf='oem1.inf';Problem=0}) } } function Add-Vdd([string]$Inf, [int]$Problem) { - $global:EMRepair.Devices += [pscustomobject]@{InstanceId='ROOT\DISPLAY\0000';HardwareIds=@('Root\MttVDD');Inf=$Inf;Problem=$Problem} + $id = 'ROOT\DISPLAY\{0:D4}' -f ($global:EMRepair.Next++) + $global:EMRepair.Devices += [pscustomobject]@{InstanceId=$id;HardwareIds=@('Root\MttVDD');Inf=$(if ($Inf) { $Inf } else { $null });Problem=$Problem} + $id } +function Get-Vdd { @($global:EMRepair.Devices | Where-Object { $_.HardwareIds -contains 'Root\MttVDD' }) } function Get-PnpDevice { [CmdletBinding()] param([string]$Class,[switch]$PresentOnly) if ($Class -ne 'Display' -or !$PresentOnly) { throw 'Must query present display devices' } @@ -36,94 +49,131 @@ function Get-PnpDeviceProperty { [pscustomobject]@{Data=$value} } function Get-ItemProperty { [CmdletBinding()] param([string[]]$Path) $global:EMRepair.Registration } -# Plays the vendor tool: removes, creates and binds the device as nefconw would. +# Binds the staged package to every driverless device, as Windows does. +function Bind-Driverless { + foreach ($device in Get-Vdd | Where-Object { !$_.Inf }) { + $device.Inf = 'oem40.inf' + $device.Problem = if ($global:EMRepair.NewStarts) { 0 } else { 43 } + } +} +function global:pnputil.exe { + $verb = $args[0] + $global:EMRepair.Calls += "pnputil $verb $(if ($verb -eq '/remove-device') { $args[1] })".Trim() + switch ($verb) { + '/add-driver' { + if ($args[1] -ne $inf -or $args[2] -ne '/install') { throw "Bad add-driver: $args" } + $global:LASTEXITCODE = $global:EMRepair.StageExit + if ($global:EMRepair.StageExit -in @(0, 3010)) { $global:EMRepair.InStore = $true; Bind-Driverless } + } + '/remove-device' { + $id = $args[1] + $global:EMRepair.Devices = @($global:EMRepair.Devices | Where-Object { $_.InstanceId -ne $id }) + $global:LASTEXITCODE = 0 + } + default { throw "Unexpected pnputil $verb" } + } +} +# nefconw 1.10.0. --remove-device-node looks up a compatible driver before it removes +# anything, so it fails while a device has no driver and the store has no package. function Start-Process { [CmdletBinding()] param([string]$FilePath,[string]$ArgumentList,[switch]$Wait,[switch]$PassThru,[string]$WindowStyle) if ($FilePath -ne $nefcon -or !$Wait -or !$PassThru) { throw "Unexpected process $FilePath" } $verb = $ArgumentList.Split(' ')[0] - $global:EMRepair.Calls += $verb - if ($global:EMRepair.Exit -notin @(0, 3010)) { return [pscustomobject]@{ExitCode=$global:EMRepair.Exit} } + $global:EMRepair.Calls += "nefconw $verb" + $exit = 0 switch ($verb) { - '--remove-device-node' { - if ($ArgumentList -notmatch 'Root\\MttVDD' -or $ArgumentList -notmatch '4D36E968-E325-11CE-BFC1-08002BE10318') { throw "Bad remove: $ArgumentList" } - $global:EMRepair.Devices = @($global:EMRepair.Devices | Where-Object { $_.HardwareIds -notcontains 'Root\MttVDD' }) - } '--create-device-node' { if ($ArgumentList -notmatch '--hardware-id Root\\MttVDD --class-name Display --class-guid 4D36E968') { throw "Bad create: $ArgumentList" } - Add-Vdd $null 28 + Add-Vdd $null 28 | Out-Null } - '--install-driver' { - if ($ArgumentList -notmatch [regex]::Escape("--inf-path `"$inf`"")) { throw "Bad install: $ArgumentList" } - if ($global:EMRepair.Accept) { - $vdd = $global:EMRepair.Devices | Where-Object { $_.HardwareIds -contains 'Root\MttVDD' } - $vdd.Inf = 'oem40.inf'; $vdd.Problem = 0 - } + '--remove-device-node' { + if (!$global:EMRepair.InStore -and (Get-Vdd | Where-Object { !$_.Inf })) { $exit = 1 } + else { $global:EMRepair.Devices = @($global:EMRepair.Devices | Where-Object { $_.HardwareIds -notcontains 'Root\MttVDD' }) } } + '--install-driver' { $global:EMRepair.InStore = $true; Bind-Driverless } default { throw "Unexpected nefconw verb $verb" } } - [pscustomobject]@{ExitCode=$global:EMRepair.Exit} + [pscustomobject]@{ExitCode=$exit} } -function Expect-Calls([string]$Expected, [string]$Case) { - $actual = $global:EMRepair.Calls -join ',' - if ($actual -ne $Expected) { throw "$Case`: expected nefconw [$Expected], ran [$actual]" } +function Invoke-Repair { + $global:LASTEXITCODE = 0 + try { & $script 2>$null | Out-Null } catch { $global:LASTEXITCODE = "threw: $($_.Exception.Message)" } + $LASTEXITCODE } -function Expect-Failure([string]$Message, [string]$Case) { - $failure = $null - try { & $script | Out-Null } catch { $failure = $_.Exception.Message } - if (!$failure -or $failure -notlike "*$Message*") { throw "$Case`: expected '$Message', got '$failure'" } +$failures = @() +function Expect([string]$Case, $Code, [string]$Calls, [scriptblock]$State = { $true }) { + $actual = Invoke-Repair + $ran = $global:EMRepair.Calls -join ', ' + if ("$actual" -ne "$Code" -or $ran -ne $Calls -or !(& $State)) { + $script:failures += "$Case`: exit $actual (want $Code), ran [$ran] (want [$Calls]), devices $((Get-Vdd | ForEach-Object { "$($_.InstanceId):$($_.Inf):$($_.Problem)" }) -join ' ')" + } } -$repair = '--remove-device-node,--create-device-node,--install-driver' +function Working { @(Get-Vdd | Where-Object { $_.Inf -and $_.Problem -in @(0, 22) }) } +$stage = 'pnputil /add-driver' try { # Between streams the device is off with code 22; running is fine too. foreach ($problem in 22, 0) { - Reset-Fixture; Add-Vdd 'oem33.inf' $problem - & $script | Out-Null - Expect-Calls '' "healthy device, code $problem" + Reset-Fixture; Add-Vdd 'oem33.inf' $problem | Out-Null + Expect "working device, code $problem" 0 '' } + # A working device next to a broken one: nothing is touched. + Reset-Fixture; $good = Add-Vdd 'oem33.inf' 22; Add-Vdd 'oem33.inf' 43 | Out-Null + Expect 'working and broken devices' 0 '' { @(Get-Vdd).Count -eq 2 } - # Removed in Device Manager: created again and bound to the driver. - Reset-Fixture - & $script | Out-Null - Expect-Calls '--create-device-node,--install-driver' 'missing device' - - # A driver error, a signature block, or no driver: replaced as the vendor install does. - foreach ($case in @(@{Inf='oem33.inf';Problem=43}, @{Inf='oem33.inf';Problem=52}, @{Inf=$null;Problem=28}, @{Inf=$null;Problem=0})) { - Reset-Fixture; Add-Vdd $case.Inf $case.Problem - & $script | Out-Null - Expect-Calls $repair "device with inf '$($case.Inf)' and code $($case.Problem)" - } - - # A restart-required result still counts, once the device is bound. - Reset-Fixture; $global:EMRepair.Exit = 3010 - & $script | Out-Null - Expect-Calls '--create-device-node,--install-driver' 'restart required' - - Reset-Fixture; $global:EMRepair.Exit = 5 - Expect-Failure 'nefconw --create-device-node exited 5' 'vendor tool failure' - - Reset-Fixture; $global:EMRepair.Accept = $false - Expect-Failure 'did not accept' 'driver not bound after repair' - - # Without the vendor's files there is nothing to repair from: say how to reinstall. + # A driver another app installed is never changed. + Reset-Fixture; Remove-Item $marker; Add-Vdd 'oem33.inf' 43 | Out-Null + Expect 'no ownership marker' 10 '' { @(Get-Vdd).Count -eq 1 } + Reset-Fixture; [IO.File]::WriteAllText($marker, "$vendor\unins000.exe`r`n24.12.24") + Expect 'vendor install changed since Setup recorded it' 10 '' Reset-Fixture; $global:EMRepair.Registration = $null - Expect-Failure 'Uninstall Virtual Display Driver' 'no registration' - Expect-Calls '' 'no registration' + Expect 'no vendor registration' 10 '' Reset-Fixture; Remove-Item $nefcon - Expect-Failure 'Uninstall Virtual Display Driver' 'missing vendor tool' - [IO.File]::WriteAllText($nefcon, 'test fixture; never executed') + Expect 'vendor files missing' 11 '' + + # Driverless device and the package gone from the driver store: staging the package + # binds it, and nothing is removed. nefconw's remove fails in this state. + Reset-Fixture; $global:EMRepair.InStore = $false; $kept = Add-Vdd $null 28 + Expect 'driverless device, package missing' 0 $stage { (Working).InstanceId -eq $kept } + # Removed in Device Manager, with or without its package. + foreach ($inStore in $true, $false) { + Reset-Fixture; $global:EMRepair.InStore = $inStore + Expect "missing device, package in store=$inStore" 0 "$stage, nefconw --create-device-node, $stage" { @(Working).Count -eq 1 } + } + # A device with a driver error is replaced, and removed only once the new one works. + Reset-Fixture; $old = Add-Vdd 'oem33.inf' 43 + Expect 'driver error' 0 "$stage, nefconw --create-device-node, $stage, pnputil /remove-device $old" { + @(Get-Vdd).Count -eq 1 -and (Working).InstanceId -ne $old } + # The replacement fails too: only the device this run added is removed. + Reset-Fixture; $old = Add-Vdd 'oem33.inf' 43; $global:EMRepair.NewStarts = $false + Expect 'replacement fails' 1 "$stage, nefconw --create-device-node, $stage, pnputil /remove-device ROOT\DISPLAY\0002" { + @(Get-Vdd).Count -eq 1 -and @(Get-Vdd)[0].InstanceId -eq $old -and @(Get-Vdd)[0].Problem -eq 43 } + # Windows rejects the package: nothing else happens. + Reset-Fixture; $old = Add-Vdd 'oem33.inf' 52; $global:EMRepair.StageExit = 5 + Expect 'package rejected' 1 $stage { @(Get-Vdd).Count -eq 1 -and @(Get-Vdd)[0].Problem -eq 52 } + # A restart-required result still counts once the device works. + Reset-Fixture; $global:EMRepair.StageExit = 3010 + Expect 'restart required' 0 "$stage, nefconw --create-device-node, $stage" + # A rerun after a repair changes nothing. + $global:EMRepair.Calls = @(); $global:EMRepair.StageExit = 0 + Expect 'rerun' 0 '' - # Setup ships the step and runs it before the tasks disable the device, also when the - # existing driver is preserved. - if ($iss -notmatch 'Source: "scripts\\vdd-driver-repair\.ps1"') { throw 'Setup does not install the repair script' } + # Setup ships the step, runs it before the tasks disable the device, also when the + # existing driver is preserved, and names each outcome. + if ($iss -notmatch 'Source: "scripts\\vdd-driver-repair\.ps1"') { $failures += 'Setup does not install the repair script' } $tasks = ($iss -split "`n") | Where-Object { $_ -match 'vdd-tasks-setup\.ps1' -and $_ -match '^Filename:' } if ($tasks -notmatch 'BeforeInstall: RepairVddDriver' -or $tasks -match 'Check: NeedsVddInstall') { - throw 'Setup does not repair a preserved driver before registering the tasks' + $failures += 'Setup does not repair a preserved driver before registering the tasks' } - if ($iss -notmatch "(?s)procedure RepairVddDriver;.*vdd-driver-repair\.ps1.*ExitCode <> 0.*RaiseException") { - throw 'Setup ignores a failed repair' + $code = [regex]::Match($iss, '(?s)procedure RepairVddDriver;.*?\nend;').Value + if ($code -notmatch 'vdd-driver-repair\.ps1' -or $code -notmatch "10: Message := 'Another app installed" -or + $code -notmatch '11: Message' -or $code -notmatch 'VddInstallFailed := True' -or $code -notmatch 'RaiseException') { + $failures += 'Setup does not report each repair failure' } - Write-Output 'PASS: healthy, missing, driver error, signature, unbound, restart, tool failure, rejected, no vendor files, Setup wiring' + if ($failures) { throw "Repair checks failed:`n " + ($failures -join "`n ") } + Write-Output 'PASS: working, working beside broken, not owned, changed, unregistered, files missing, driverless with package gone, missing device, driver error, failed replacement, rejected package, restart, rerun, Setup wiring' } finally { Remove-Item $root -Recurse -Force + Remove-Item function:global:pnputil.exe -ErrorAction SilentlyContinue Remove-Variable EMRepair -Scope Global -ErrorAction SilentlyContinue + $global:LASTEXITCODE = 0 } From 15b5afd42e04c152c6051b310b1c5e29230f1bc4 Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Thu, 22 Oct 2026 17:04:40 -0400 Subject: [PATCH 11/39] Describe the layout, graphics card and repair changes --- RELEASE_NOTES.md | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index b8740f5f..63c631fc 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -96,15 +96,21 @@ Windows changes. what to do: the display driver isn't installed (run Setup again), Windows couldn't verify the driver's signature, the driver stopped with an error (with the Windows code: restart, then run Setup again), or the driver ran but - Windows added no screen. The display task no longer reports success when the - display cannot appear, and the device state is in the support diagnostics. - In 0.3.1 every case read "no display appeared in time". -- On laptops with Intel and NVIDIA graphics, the second screen is drawn by the - graphics card that drives the main screen. In 0.3.1 diagnostics none of these - laptops got the second screen. If Windows adds the screen but keeps the - desktop off it, EternalMonitor switches the layout to Extend. -- Running EternalMonitor-Setup.exe again repairs the virtual display driver - when its device was removed in Device Manager or shows a driver error. In + Windows added no screen. When Windows adds the screen but keeps the desktop + off it, the page says to choose Extend with Windows key + P or in Display + settings, and the next try waits 30 seconds for it. EternalMonitor does not + change the display layout itself. The display task no longer reports success + when the display cannot appear, and the device state is in the support + diagnostics. In 0.3.1 every case read "no display appeared in time". +- The second screen is drawn by the graphics card that drives the main screen, + whether or not **Match the iPad's resolution** is on. Before, the driver + chose the card with the most memory, which on laptops with Intel and NVIDIA + graphics drives no screen; in 0.3.1 diagnostics none of those laptops got the + second screen. When two cards have the same name, the driver still chooses. +- Running EternalMonitor-Setup.exe again repairs a virtual display driver that + Setup installed when none of its devices works, for example after the device + was removed in Device Manager. Working devices are never removed, and a + driver another app installed is left alone with a message saying so. In 0.3.1 Setup kept the existing driver and stopped with "Windows did not finish installing the virtual display driver". - Support diagnostics name Windows 11 from the build number. Windows 11 keeps From e5971df095b80a5cca12c7d16e553ab1f3ecf3cd Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Thu, 22 Oct 2026 17:04:56 -0400 Subject: [PATCH 12/39] Show which adapter renders each display in the native device check --- host/src/vdd_device.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/host/src/vdd_device.rs b/host/src/vdd_device.rs index 4147b991..95749d12 100644 --- a/host/src/vdd_device.rs +++ b/host/src/vdd_device.rs @@ -349,6 +349,18 @@ mod tests { fn native_device_state() { let state = query(); println!("vdd_device={state} problem={:?}", state.problem()); + // DXGI lists a virtual display under the adapter that renders it. + for output in crate::capture::enumerate_outputs() { + println!( + "output {} {}x{} at {},{} on {}", + output.device_name, + output.width, + output.height, + output.left, + output.top, + output.adapter_name + ); + } if let Ok(expected) = std::env::var("EM_EXPECT_VDD") { assert_eq!(state.to_string(), expected); } From c3e54e72311d0bf9a5ab399f650eb1b3bfcbe664 Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Thu, 22 Oct 2026 17:42:21 -0400 Subject: [PATCH 13/39] Edit the driver's settings file by parsing it, and leave odd files alone --- Cargo.lock | 1 + host/Cargo.toml | 1 + host/src/vdd_settings.rs | 229 +++++++++++++++++++++++++++++++++++---- 3 files changed, 209 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8f712a29..b6737645 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1185,6 +1185,7 @@ dependencies = [ "pkg-config", "plist", "qrcode", + "quick-xml 0.41.0", "regex", "ring", "semver", diff --git a/host/Cargo.toml b/host/Cargo.toml index dda4bd40..3f685981 100644 --- a/host/Cargo.toml +++ b/host/Cargo.toml @@ -25,6 +25,7 @@ qrcode = { version = "0.14", default-features = false } serde = { version = "1", features = ["derive"] } serde_json = "1" plist = "1" +quick-xml = "0.41" semver = "1" ureq = { version = "3.4.2", default-features = false, features = ["rustls", "json"] } # SHA-256 of downloaded updates (already used by rustls) diff --git a/host/src/vdd_settings.rs b/host/src/vdd_settings.rs index cf6c00ed..994ab909 100644 --- a/host/src/vdd_settings.rs +++ b/host/src/vdd_settings.rs @@ -14,6 +14,7 @@ //! screen, and in 0.3.1 diagnostics none of those laptops got the display. use std::io; +use std::ops::Range; use std::path::Path; /// A display mode for the virtual display's mode list. @@ -123,35 +124,142 @@ pub fn gpu_to_pin<'a>( Ok(name) } -/// `xml` naming `gpu` as the render adapter, everything else as it was: -/// the existing name is replaced, or a `` element added at the top. -/// `None` when the document has no `` element to add it to. -pub fn with_gpu(xml: &str, gpu: &str) -> Option { - const OPEN: &str = ""; - const CLOSE: &str = ""; - if let Some(start) = xml.find(OPEN).map(|at| at + OPEN.len()) { - let end = start + xml[start..].find(CLOSE)?; - return Some(format!("{}{}{}", &xml[..start], escape(gpu), &xml[end..])); +/// Where a parsed settings document names its render adapter, and how to +/// make it name another one. +enum GpuSlot { + /// The text of the one ``, as a byte range. + Name(Range), + /// A self-closing `` or ``, to be written out. + Empty(Range, &'static str), + /// Right after the start tag of `` without a name, or of the root. + Insert(usize, &'static str), +} + +/// Find the render adapter's place by parsing `xml`. Anything the driver +/// could read differently from this host is refused: a name outside +/// ``, a second name or ``, markup inside the name, text or +/// elements outside the `` root, or a document that does not +/// parse. The driver matches `friendlyname` by local name anywhere. +fn gpu_slot(xml: &str) -> Result { + use quick_xml::events::Event; + let mut reader = quick_xml::Reader::from_str(xml); + reader.config_mut().trim_text(false); + let mut open: Vec> = Vec::new(); + let (mut root, mut closed, mut slot) = (None, false, None); + let (mut gpus, mut names, mut name_start) = (0, 0, 0); + loop { + let before = reader.buffer_position() as usize; + let event = reader + .read_event() + .map_err(|_| "the file is not well-formed XML")?; + let after = reader.buffer_position() as usize; + let in_name = open.last().is_some_and(|name| name == b"friendlyname"); + let outside = open.is_empty(); + match event { + Event::Start(_) | Event::Empty(_) if closed => { + return Err("there is markup after the root element") + } + Event::Start(_) | Event::Empty(_) | Event::CData(_) if in_name => { + return Err("the graphics card name contains markup") + } + Event::Start(ref tag) | Event::Empty(ref tag) => { + let empty = matches!(event, Event::Empty(_)); + let name = tag.local_name().as_ref().to_vec(); + match (open.len(), name.as_slice()) { + (0, b"vdd_settings") if !empty => root = Some(after), + (0, _) => return Err("the root element is not "), + (1, b"gpu") => { + gpus += 1; + slot = Some(if empty { + GpuSlot::Empty(before..after, "gpu") + } else { + GpuSlot::Insert(after, "gpu") + }); + } + (2, b"friendlyname") if open[1] == b"gpu" => { + names += 1; + name_start = after; + if empty { + slot = Some(GpuSlot::Empty(before..after, "friendlyname")); + } + } + (_, b"friendlyname") => return Err("a graphics card name is outside "), + (_, b"gpu") => return Err("a element is not directly in the root"), + _ => {} + } + if !empty { + open.push(name); + } + } + Event::End(_) => { + if in_name { + slot = Some(GpuSlot::Name(name_start..before)); + } + open.pop(); + closed = open.is_empty(); + } + Event::Text(text) if outside && !text.iter().all(u8::is_ascii_whitespace) => { + return Err("there is text outside the root element") + } + Event::CData(_) | Event::GeneralRef(_) if outside => { + return Err("there is text outside the root element") + } + Event::Eof => break, + _ => {} + } } - let root = xml.find("")? + "".len(); - let line_end = xml[root..].find('\n').map_or(root, |at| root + at + 1); - let separator = if line_end == root { "\n" } else { "" }; - Some(format!( - "{}{separator}{}{}", - &xml[..line_end], - gpu_element(gpu), - &xml[line_end..] - )) + let root = root + .filter(|_| closed) + .ok_or("there is no complete element")?; + if gpus > 1 || names > 1 { + return Err("it names more than one graphics card"); + } + Ok(slot.unwrap_or(GpuSlot::Insert(root, "vdd_settings"))) +} + +/// `xml` naming `gpu` as the render adapter, everything else byte for byte +/// as it was. The edit is checked by parsing the result. `Err` says why the +/// document was left alone. +fn gpu_edit(xml: &str, gpu: &str) -> Result { + let (bom, body) = match xml.strip_prefix('\u{feff}') { + Some(body) => ("\u{feff}", body), + None => ("", xml), + }; + let nl = if body.contains("\r\n") { "\r\n" } else { "\n" }; + let name = escape(gpu); + let element = format!("{name}"); + let (range, text) = match gpu_slot(body)? { + GpuSlot::Name(range) => (range, name.clone()), + GpuSlot::Empty(range, "gpu") => (range, format!("{element}")), + GpuSlot::Empty(range, _) => (range, element), + GpuSlot::Insert(at, "gpu") => (at..at, format!("{nl} {element}")), + GpuSlot::Insert(at, _) => ( + at..at, + format!("{nl} {nl} {element}{nl} "), + ), + }; + let edited = format!("{}{text}{}", &body[..range.start], &body[range.end..]); + match gpu_slot(&edited) { + Ok(GpuSlot::Name(range)) if edited[range.clone()] == name => Ok(format!("{bom}{edited}")), + _ => Err("the edited file did not parse as intended"), + } +} + +/// `xml` naming `gpu` as the render adapter, everything else as it was; +/// `None` when the document is not one this can edit safely. +pub fn with_gpu(xml: &str, gpu: &str) -> Option { + gpu_edit(xml, gpu).ok() } /// Point the settings file the host does not otherwise write at `gpu`. /// True when it changed. A missing file stays missing: without one the /// driver offers its built-in modes, and a document holding only a GPU -/// would leave it none. +/// would leave it none. A file that does not parse as expected is left +/// alone, and the error says why. pub fn pin_gpu_in(path: &Path, gpu: &str) -> io::Result { let current = std::fs::read_to_string(path)?; - let updated = with_gpu(¤t, gpu) - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "no element"))?; + let updated = gpu_edit(¤t, gpu) + .map_err(|reason| io::Error::new(io::ErrorKind::InvalidData, reason))?; if updated == current { return Ok(false); } @@ -169,9 +277,15 @@ pub fn write_to(path: &Path, modes: &[VddMode], gpu: Option<&str>) -> io::Result replace(path, &render(modes, gpu)) } +/// The new contents reach the disk before they replace the file, so an +/// interrupted write leaves the old file whole. fn replace(path: &Path, contents: &str) -> io::Result<()> { + use std::io::Write; let tmp = path.with_extension("xml.tmp"); - std::fs::write(&tmp, contents)?; + let mut file = std::fs::File::create(&tmp)?; + file.write_all(contents.as_bytes())?; + file.sync_all()?; + drop(file); std::fs::rename(&tmp, path) } @@ -301,6 +415,77 @@ mod tests { assert_eq!(with_gpu("", "x"), None); } + #[test] + fn the_card_goes_inside_a_compact_root() { + // Sol's case: no newline inside the root, one after it. + let compact = "1\n"; + assert_eq!( + with_gpu(compact, "X").as_deref(), + Some("\n \n X\n 1\n") + ); + } + + #[test] + fn a_gpu_element_without_a_name_gets_one() { + assert_eq!( + with_gpu("\n \n \n\n", "X").as_deref(), + Some("\n \n X\n \n\n") + ); + assert_eq!( + with_gpu("", "X").as_deref(), + Some("X") + ); + assert_eq!( + with_gpu( + "", + "X" + ) + .as_deref(), + Some("X") + ); + } + + #[test] + fn comments_attributes_line_endings_and_bom_are_kept() { + let commented = "\n\n\n \n A&B \n \n\n"; + assert_eq!( + with_gpu(commented, "Intel(R) Iris(R) Xe Graphics"), + Some(commented.replace(">A&B<", ">Intel(R) Iris(R) Xe Graphics<")) + ); + let crlf = "\r\n\t\r\n\t\r\n\r\n"; + assert_eq!( + with_gpu(crlf, "X").as_deref(), + Some("\r\n \r\n X\r\n \r\n\t\r\n\t\r\n\r\n") + ); + let bom = format!("\u{feff}{VENDOR}"); + assert_eq!(with_gpu(&bom, "X"), Some(bom.replace(">default<", ">X<"))); + } + + #[test] + fn a_file_that_does_not_parse_as_expected_is_left_alone() { + let dir = std::env::temp_dir().join(format!("vdd-pin-refuse-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("vdd_settings.xml"); + for document in [ + "", + "1", + "\nx\n", + "junk", + "x", + "ab", + "a", + "a", + "", + ] { + assert_eq!(with_gpu(document, "X"), None, "{document}"); + std::fs::write(&path, document).unwrap(); + assert!(pin_gpu_in(&path, "X").is_err(), "{document}"); + assert_eq!(std::fs::read_to_string(&path).unwrap(), document, "changed: {document}"); + } + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn a_stale_pin_is_corrected_and_a_current_one_left_alone() { let dir = std::env::temp_dir().join(format!("vdd-pin-test-{}", std::process::id())); From c8be1bcb8f8fab198fc833b6e67ec62dd7255893 Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Thu, 22 Oct 2026 17:45:40 -0400 Subject: [PATCH 14/39] Log how Windows sees the displays when the second screen fails --- host/Cargo.toml | 1 + host/src/capture/mod.rs | 78 ++++++-- host/src/diagnostics.rs | 4 +- host/src/lib.rs | 1 + host/src/vdd_snapshot.rs | 402 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 463 insertions(+), 23 deletions(-) create mode 100644 host/src/vdd_snapshot.rs diff --git a/host/Cargo.toml b/host/Cargo.toml index 3f685981..be32faed 100644 --- a/host/Cargo.toml +++ b/host/Cargo.toml @@ -60,6 +60,7 @@ version = "0.58" features = [ "Win32_Foundation", "Win32_Devices_DeviceAndDriverInstallation", + "Win32_Devices_Display", "Win32_Security", "Win32_Media_Audio", "Win32_System_Com", diff --git a/host/src/capture/mod.rs b/host/src/capture/mod.rs index 9f0c5ef9..9d0f6c7e 100644 --- a/host/src/capture/mod.rs +++ b/host/src/capture/mod.rs @@ -350,25 +350,27 @@ const VDD_SETTINGS_PATH: &str = r"C:\VirtualDisplayDriver\vdd_settings.xml"; /// Before each enable: name the main screen's graphics card for the driver /// to render on, and, when the user wants it, the iPad's modes. Without a -/// name the driver renders on the card with the most memory, which on -/// Intel + NVIDIA laptops drives no screen. Failure is non-fatal — the -/// driver's built-in choices apply. +/// name the driver renders on the card with the most memory. In 0.3.1 +/// diagnostics no Intel + NVIDIA laptop got the second screen; that card +/// choice is a suspect, not a confirmed cause. Failure is non-fatal — the +/// driver's built-in choices apply. Returns the card asked for, or why none. #[cfg(windows)] -fn prepare_vdd_settings(shared: &SharedControl, outputs: &[OutputInfo]) { +fn prepare_vdd_settings( + shared: &SharedControl, + outputs: &[OutputInfo], +) -> Result { let main_screen = outputs .iter() .find(|output| output.is_primary) .map(|output| output.adapter_name.as_str()); - let gpu = match crate::vdd_settings::gpu_to_pin(main_screen, &dxgi::adapter_names()) { - Ok(gpu) => Some(gpu), - Err(reason) => { - info!( - reason, - main_screen, "Leaving the virtual display's graphics card to the driver" - ); - None - } - }; + let pin = crate::vdd_settings::gpu_to_pin(main_screen, &dxgi::adapter_names()); + if let Err(reason) = pin { + info!( + reason, + main_screen, "Leaving the virtual display's graphics card to the driver" + ); + } + let gpu = pin.ok(); let client = crate::settings::SettingsFile::load() .vdd_match_resolution .then(|| shared.session.lock().client_info()) @@ -386,10 +388,13 @@ fn prepare_vdd_settings(shared: &SharedControl, outputs: &[OutputInfo]) { gpu = gpu.unwrap_or("chosen by the driver"), "Wrote virtual display mode list for the connected client" ), - Err(error) => warn!( - %error, - "Could not write vdd_settings.xml — driver default modes apply" - ), + Err(error) => { + warn!( + %error, + "Could not write vdd_settings.xml — driver default modes apply" + ); + return Err("the settings file could not be written"); + } } } else if let Some(gpu) = gpu { // The modes in the file are the user's: change only the card. @@ -400,10 +405,36 @@ fn prepare_vdd_settings(shared: &SharedControl, outputs: &[OutputInfo]) { ), Ok(false) => {} Err(error) => { - warn!(%error, gpu, "Could not name the graphics card in vdd_settings.xml") + warn!(%error, gpu, "Left vdd_settings.xml as it is; the driver chooses the graphics card"); + return Err("the settings file was left as it is"); } } } + pin.map(str::to_string) +} + +/// Log how Windows sees the displays, before the Disable task removes the +/// virtual one. One record per failure; see `vdd_snapshot`. +#[cfg(windows)] +fn log_failure_snapshot(device: DeviceState, pin: &Result) { + let snapshot = crate::vdd_snapshot::collect(device, pin.clone()); + warn!( + "Virtual display failure snapshot {}", + crate::vdd_snapshot::line(&snapshot) + ); +} + +/// Debug builds only, to check the failure path on a PC whose virtual +/// display works: `ETERNAL_SIMULATE_ATTACH_TIMEOUT=1` treats it as not +/// attached after the normal wait. +#[cfg(all(windows, debug_assertions))] +fn simulated_attach_timeout() -> bool { + std::env::var_os("ETERNAL_SIMULATE_ATTACH_TIMEOUT").is_some() +} + +#[cfg(all(windows, not(debug_assertions)))] +const fn simulated_attach_timeout() -> bool { + false } /// Say why the second screen did not appear: on the Stream page each time, @@ -514,7 +545,7 @@ pub(crate) fn reconcile_virtual_display( } *shared.vdd_output.lock() = None; - prepare_vdd_settings(shared, &outputs); + let pin = prepare_vdd_settings(shared, &outputs); let before: Vec = outputs.into_iter().map(|o| o.device_name).collect(); if !capture_is_current(shared, generation) { @@ -537,6 +568,7 @@ pub(crate) fn reconcile_virtual_display( ); if let Some(device) = device { report_device(shared, device); + log_failure_snapshot(device, &pin); // The task enabled a device that cannot show a display. if device != DeviceState::Missing { crate::vdd::set_enabled(false, progress); @@ -552,8 +584,11 @@ pub(crate) fn reconcile_virtual_display( startup_heartbeat(shared, crate::clock::host_now_us() / 1000); Some(enumerate_outputs()) }; - let result = + let mut result = wait_for_virtual_output(&before, timeout, poll, Instant::now, std::thread::sleep); + if simulated_attach_timeout() && matches!(result, AttachResult::Attached(_)) { + result = AttachResult::TimedOut; + } // A device that is ready with its monitor means Windows left the // desktop off it. The display layout stays the user's to change. let device = matches!(result, AttachResult::TimedOut).then(crate::vdd_device::query); @@ -578,6 +613,7 @@ pub(crate) fn reconcile_virtual_display( warn!("Virtual display did not attach in time — capturing the primary display instead"); if let Some(device) = device { report_device(shared, device); + log_failure_snapshot(device, &pin); } // enable() succeeded but nothing attached: turn it back off so we don't strand a // half-enabled device as a phantom monitor. diff --git a/host/src/diagnostics.rs b/host/src/diagnostics.rs index 0887e8ee..06a800ce 100644 --- a/host/src/diagnostics.rs +++ b/host/src/diagnostics.rs @@ -161,7 +161,7 @@ fn windows_name(product: &str, build: &str) -> String { } #[cfg(windows)] -fn registry_string(key: &str, name: &str) -> String { +pub(crate) fn registry_string(key: &str, name: &str) -> String { use windows::{ core::PCWSTR, Win32::System::Registry::{RegGetValueW, HKEY_LOCAL_MACHINE, RRF_RT_REG_SZ}, @@ -190,7 +190,7 @@ fn registry_string(key: &str, name: &str) -> String { } #[cfg(windows)] -fn registry_dword(key: &str, name: &str) -> String { +pub(crate) fn registry_dword(key: &str, name: &str) -> String { use windows::{ core::PCWSTR, Win32::System::Registry::{RegGetValueW, HKEY_LOCAL_MACHINE, RRF_RT_REG_DWORD}, diff --git a/host/src/lib.rs b/host/src/lib.rs index 4b7a05fa..acd9a334 100644 --- a/host/src/lib.rs +++ b/host/src/lib.rs @@ -25,6 +25,7 @@ pub mod updates; pub mod vdd; pub mod vdd_device; pub mod vdd_settings; +pub mod vdd_snapshot; #[cfg(windows)] pub mod gpu; diff --git a/host/src/vdd_snapshot.rs b/host/src/vdd_snapshot.rs new file mode 100644 index 00000000..3b7bfd14 --- /dev/null +++ b/host/src/vdd_snapshot.rs @@ -0,0 +1,402 @@ +//! One log record of how Windows sees the displays when the second screen +//! did not appear, written before the Disable task removes the virtual +//! display. It goes into the session log, so "Copy logs" and the support +//! diagnostics carry it. +//! +//! It holds graphics card names and IDs, display path numbers and flags, +//! and a few settings. Monitor device paths are read only to tell which +//! paths belong to the virtual display and are never written, and nothing +//! names the machine or the user. The record is bounded in size. + +use crate::vdd_device::DeviceState; + +const MAX_LINE: usize = 1800; +const MAX_ADAPTERS: usize = 8; +const MAX_TARGETS: usize = 16; +const MAX_NAME: usize = 48; + +/// The virtual display's monitor reports the driver's EDID ID in its path. +const VDD_MONITOR: &str = "MTT1337"; + +/// A DXGI adapter. +#[derive(Debug, Clone)] +pub struct Adapter { + pub luid: u64, + pub description: String, + pub vendor_id: u32, + pub device_id: u32, + pub driver: String, + pub outputs: u32, +} + +/// One display target from `QueryDisplayConfig(QDC_ALL_PATHS)`. +#[derive(Debug, Clone)] +pub struct Target { + pub adapter: u64, + pub id: u32, + /// Source adapter and id, for an active path. + pub source: Option<(u64, u32)>, + pub available: bool, + pub technology: i32, + /// Only to tell the virtual display's monitor; never logged. + pub monitor_path: String, +} + +#[derive(Debug, Clone)] +pub struct Snapshot { + pub device: DeviceState, + /// The graphics card the host asked the driver for, or why it asked for none. + pub pin: Result, + /// `HwSchMode`: 2 hardware-accelerated GPU scheduling on, 1 off. + pub hags: Option, + /// Whether the driver reads its settings from `C:\VirtualDisplayDriver`. + pub default_settings_path: Option, + pub adapters: Vec, + pub targets: Vec, +} + +/// Printable, without the separators this record uses, and short. +fn clean(text: &str) -> String { + text.chars() + .filter(|c| !c.is_control() && !matches!(c, '"' | '|' | '[' | ']' | '=')) + .take(MAX_NAME) + .collect::() + .trim() + .to_string() +} + +fn luid(value: u64) -> String { + format!("{:x}", value) +} + +/// The record's text, from data already read. +pub fn line(snapshot: &Snapshot) -> String { + let mut out = format!("device={}", snapshot.device); + match &snapshot.pin { + Ok(name) => { + let matched = snapshot.adapters.iter().any(|a| &a.description == name); + out += &format!(" pin=\"{}\" pin_matched={matched}", clean(name)); + } + Err(reason) => out += &format!(" pin=none pin_reason=\"{}\"", clean(reason)), + } + out += match snapshot.hags { + Some(2) => " hags=on", + Some(1) => " hags=off", + Some(_) => " hags=other", + None => " hags=unset", + }; + out += match snapshot.default_settings_path { + Some(true) => " settings_path=default", + Some(false) => " settings_path=other", + None => " settings_path=unknown", + }; + + let adapters: Vec = snapshot + .adapters + .iter() + .take(MAX_ADAPTERS) + .map(|a| { + format!( + "{} \"{}\" {:04x}:{:04x} driver={} outputs={}", + luid(a.luid), + clean(&a.description), + a.vendor_id, + a.device_id, + clean(&a.driver), + a.outputs + ) + }) + .collect(); + out += &format!(" adapters=[{}", adapters.join(" | ")); + if snapshot.adapters.len() > MAX_ADAPTERS { + out += &format!(" | +{} more", snapshot.adapters.len() - MAX_ADAPTERS); + } + out += "]"; + + let targets: Vec = snapshot + .targets + .iter() + .take(MAX_TARGETS) + .map(|t| { + let vdd = t.monitor_path.to_ascii_uppercase().contains(VDD_MONITOR); + let state = match t.source { + Some((adapter, id)) => format!("active src={}/{id}", luid(adapter)), + None if t.available => "inactive available".to_string(), + None => "inactive unavailable".to_string(), + }; + format!( + "{state} tgt={}/{} tech={} vdd={}", + luid(t.adapter), + t.id, + t.technology, + if vdd { "yes" } else { "no" } + ) + }) + .collect(); + out += &format!(" paths=[{}", targets.join(" | ")); + if snapshot.targets.len() > MAX_TARGETS { + out += &format!(" | +{} more", snapshot.targets.len() - MAX_TARGETS); + } + out += "]"; + + if out.len() > MAX_LINE { + let mut end = MAX_LINE; + while !out.is_char_boundary(end) { + end -= 1; + } + out.truncate(end); + out += " ..."; + } + out +} + +/// Read the adapters, display paths and settings now. +#[cfg(windows)] +pub fn collect(device: DeviceState, pin: Result) -> Snapshot { + let settings_key = r"SOFTWARE\MikeTheTech\VirtualDisplayDriver"; + let path = crate::diagnostics::registry_string(settings_key, "VDDPATH"); + Snapshot { + device, + pin, + hags: crate::diagnostics::registry_dword( + r"SYSTEM\CurrentControlSet\Control\GraphicsDrivers", + "HwSchMode", + ) + .parse() + .ok(), + default_settings_path: (!path.starts_with("unavailable")).then(|| { + path.trim_end_matches('\\') + .eq_ignore_ascii_case(r"C:\VirtualDisplayDriver") + }), + adapters: windows_impl::adapters(), + targets: windows_impl::targets(), + } +} + +#[cfg(windows)] +mod windows_impl { + use super::{Adapter, Target}; + use windows::core::Interface; + use windows::Win32::Devices::Display::{ + DisplayConfigGetDeviceInfo, GetDisplayConfigBufferSizes, QueryDisplayConfig, + DISPLAYCONFIG_DEVICE_INFO_GET_TARGET_NAME, DISPLAYCONFIG_MODE_INFO, + DISPLAYCONFIG_PATH_INFO, DISPLAYCONFIG_TARGET_DEVICE_NAME, QDC_ALL_PATHS, + }; + use windows::Win32::Foundation::{ERROR_SUCCESS, LUID}; + use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIDevice, IDXGIFactory1}; + + /// `DISPLAYCONFIG_PATH_ACTIVE`. + const PATH_ACTIVE: u32 = 1; + + fn value(luid: LUID) -> u64 { + ((luid.HighPart as u32 as u64) << 32) | luid.LowPart as u64 + } + + fn text(wide: &[u16]) -> String { + String::from_utf16_lossy(&wide[..wide.iter().position(|&c| c == 0).unwrap_or(wide.len())]) + } + + pub fn adapters() -> Vec { + let Ok(factory) = (unsafe { CreateDXGIFactory1::() }) else { + return Vec::new(); + }; + (0..) + .map_while(|index| unsafe { factory.EnumAdapters1(index) }.ok()) + .filter_map(|adapter| { + let desc = unsafe { adapter.GetDesc1() }.ok()?; + let driver = match unsafe { adapter.CheckInterfaceSupport(&IDXGIDevice::IID) } { + Ok(version) => { + let v = version as u64; + format!( + "{}.{}.{}.{}", + v >> 48, + (v >> 32) & 0xffff, + (v >> 16) & 0xffff, + v & 0xffff + ) + } + Err(_) => "unknown".to_string(), + }; + let outputs = (0..) + .take_while(|&index| unsafe { adapter.EnumOutputs(index) }.is_ok()) + .count() as u32; + Some(Adapter { + luid: value(desc.AdapterLuid), + description: text(&desc.Description), + vendor_id: desc.VendorId, + device_id: desc.DeviceId, + driver, + outputs, + }) + }) + .collect() + } + + /// Every active path, then each available target no active path uses. + pub fn targets() -> Vec { + let (mut paths, mut modes) = (0u32, 0u32); + if unsafe { GetDisplayConfigBufferSizes(QDC_ALL_PATHS, &mut paths, &mut modes) } + != ERROR_SUCCESS + { + return Vec::new(); + } + let mut path_info = vec![DISPLAYCONFIG_PATH_INFO::default(); paths as usize]; + let mut mode_info = vec![DISPLAYCONFIG_MODE_INFO::default(); modes as usize]; + if unsafe { + QueryDisplayConfig( + QDC_ALL_PATHS, + &mut paths, + path_info.as_mut_ptr(), + &mut modes, + mode_info.as_mut_ptr(), + None, + ) + } != ERROR_SUCCESS + { + return Vec::new(); + } + path_info.truncate(paths as usize); + let monitor_path = |path: &DISPLAYCONFIG_PATH_INFO| { + let mut name = DISPLAYCONFIG_TARGET_DEVICE_NAME::default(); + name.header.r#type = DISPLAYCONFIG_DEVICE_INFO_GET_TARGET_NAME; + name.header.size = std::mem::size_of::() as u32; + name.header.adapterId = path.targetInfo.adapterId; + name.header.id = path.targetInfo.id; + if unsafe { DisplayConfigGetDeviceInfo(&mut name.header) } == 0 { + text(&name.monitorDevicePath) + } else { + String::new() + } + }; + let mut targets: Vec = Vec::new(); + let (active, inactive): (Vec<&DISPLAYCONFIG_PATH_INFO>, Vec<_>) = path_info + .iter() + .partition(|path| path.flags & PATH_ACTIVE != 0); + for path in active { + targets.push(Target { + adapter: value(path.targetInfo.adapterId), + id: path.targetInfo.id, + source: Some((value(path.sourceInfo.adapterId), path.sourceInfo.id)), + available: path.targetInfo.targetAvailable.as_bool(), + technology: path.targetInfo.outputTechnology.0, + monitor_path: monitor_path(path), + }); + } + for path in inactive { + let (adapter, id) = (value(path.targetInfo.adapterId), path.targetInfo.id); + if !path.targetInfo.targetAvailable.as_bool() + || targets.iter().any(|t| t.adapter == adapter && t.id == id) + { + continue; + } + targets.push(Target { + adapter, + id, + source: None, + available: true, + technology: path.targetInfo.outputTechnology.0, + monitor_path: monitor_path(path), + }); + } + targets + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn snapshot() -> Snapshot { + Snapshot { + device: DeviceState::NoMonitor, + pin: Ok("Intel(R) Iris(R) Xe Graphics".into()), + hags: Some(2), + default_settings_path: Some(true), + adapters: vec![ + Adapter { + luid: 0xd2c1, + description: "Intel(R) Iris(R) Xe Graphics".into(), + vendor_id: 0x8086, + device_id: 0x46a6, + driver: "32.0.101.7092".into(), + outputs: 1, + }, + Adapter { + luid: 0x1_0000_e5f0, + description: "NVIDIA GeForce RTX 3050 Ti Laptop GPU".into(), + vendor_id: 0x10de, + device_id: 0x25a0, + driver: "32.0.16.1742".into(), + outputs: 0, + }, + ], + targets: vec![ + Target { + adapter: 0xd2c1, + id: 4352, + source: Some((0xd2c1, 0)), + available: true, + technology: i32::MIN, + monitor_path: r"\\?\DISPLAY#BOE0A1C#4&2b5bd9a1&0&UID8388688#{e6f07b5f}".into(), + }, + Target { + adapter: 0x2_0000_0101, + id: 256, + source: None, + available: true, + technology: 5, + monitor_path: r"\\?\DISPLAY#MTT1337#1&28a6823a&3&UID256#{e6f07b5f}".into(), + }, + ], + } + } + + #[test] + fn the_snapshot_is_one_line_with_each_part() { + assert_eq!( + line(&snapshot()), + "device=no_monitor pin=\"Intel(R) Iris(R) Xe Graphics\" pin_matched=true hags=on \ + settings_path=default adapters=[d2c1 \"Intel(R) Iris(R) Xe Graphics\" 8086:46a6 \ + driver=32.0.101.7092 outputs=1 | 10000e5f0 \"NVIDIA GeForce RTX 3050 Ti Laptop GPU\" \ + 10de:25a0 driver=32.0.16.1742 outputs=0] paths=[active src=d2c1/0 tgt=d2c1/4352 \ + tech=-2147483648 vdd=no | inactive available tgt=200000101/256 tech=5 vdd=yes]" + ); + let mut unpinned = snapshot(); + unpinned.pin = Err("two graphics cards have the same name"); + unpinned.hags = None; + unpinned.adapters.truncate(1); + let text = line(&unpinned); + assert!( + text.contains(" pin=none pin_reason=\"two graphics cards have the same name\""), + "{text}" + ); + assert!(text.contains(" hags=unset")); + let mut unmatched = snapshot(); + unmatched.pin = Ok("Intel(R) UHD Graphics".into()); + assert!(line(&unmatched).contains("pin_matched=false")); + } + + #[test] + fn monitor_paths_and_odd_names_stay_out_and_the_line_is_bounded() { + let mut odd = snapshot(); + odd.adapters[0].description = + "Card \"one\"\n| [x]=y with a very long name that keeps going and going".into(); + let text = line(&odd); + // Paths hold monitor serial-style instance IDs. + for secret in ["UID256", "UID8388688", "BOE0A1C", "28a6823a", "#", "\\\\?"] { + assert!(!text.contains(secret), "{secret} in {text}"); + } + assert!(!text.contains('\n')); + assert!( + text.contains("\"Card one xy with a very long name that keeps goi\""), + "{text}" + ); + + let mut many = snapshot(); + many.adapters = vec![many.adapters[1].clone(); 40]; + many.targets = vec![many.targets[1].clone(); 200]; + let text = line(&many); + assert!(text.len() <= MAX_LINE + 4, "{}", text.len()); + assert!(text.contains("| +32 more]"), "{text}"); + } +} From 1183d23b3a71fa302849f8f3a13224d1e603339f Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Thu, 22 Oct 2026 17:46:06 -0400 Subject: [PATCH 15/39] Count a device removal only once the device is gone --- installer/scripts/vdd-driver-repair.ps1 | 15 ++++++++++++--- installer/tests/Test-VddDriverRepair.ps1 | 12 +++++++++++- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/installer/scripts/vdd-driver-repair.ps1 b/installer/scripts/vdd-driver-repair.ps1 index 3e664a51..3243c5e0 100644 --- a/installer/scripts/vdd-driver-repair.ps1 +++ b/installer/scripts/vdd-driver-repair.ps1 @@ -38,6 +38,15 @@ function Invoke-PnpUtil([string[]]$Arguments) { } } +# pnputil can report success, 259 or a pending restart and leave the device in +# place, so a removal counts only once the device is gone. +function Remove-Device([string]$Id) { + Invoke-PnpUtil @('/remove-device', $Id) + if (@(Get-VddDevices | Where-Object { $_.InstanceId -eq $Id }).Count -gt 0) { + throw "Windows did not remove the Virtual Display Driver device $Id." + } +} + try { if (@(Get-Working).Count -gt 0) { Write-Output 'Virtual Display Driver device is installed.' @@ -77,18 +86,18 @@ try { -ArgumentList "--create-device-node --hardware-id $hardwareId --class-name Display --class-guid $classGuid" $added = @(Get-VddDevices | Where-Object { $broken -notcontains $_.InstanceId }) if ($process.ExitCode -ne 0 -or $added.Count -ne 1) { - foreach ($device in $added) { Invoke-PnpUtil @('/remove-device', $device.InstanceId) } + foreach ($device in $added) { Remove-Device $device.InstanceId } throw "Could not add a Virtual Display Driver device: nefconw exited $($process.ExitCode)." } $new = $added[0].InstanceId # A failure here is judged by the device's state below, which undoes the new device. try { Invoke-PnpUtil @('/add-driver', $inf, '/install') } catch {} if (@(Get-Working | Where-Object { $_.InstanceId -eq $new }).Count -eq 0) { - Invoke-PnpUtil @('/remove-device', $new) + Remove-Device $new throw 'Windows did not start the new Virtual Display Driver device.' } # The new device works: only now remove the broken ones, by instance. - foreach ($id in $broken) { Invoke-PnpUtil @('/remove-device', $id) } + foreach ($id in $broken) { Remove-Device $id } Write-Output "Repaired the Virtual Display Driver device: $new" exit 0 } catch { diff --git a/installer/tests/Test-VddDriverRepair.ps1 b/installer/tests/Test-VddDriverRepair.ps1 index 6a651c96..003638b3 100644 --- a/installer/tests/Test-VddDriverRepair.ps1 +++ b/installer/tests/Test-VddDriverRepair.ps1 @@ -24,6 +24,8 @@ function Reset-Fixture { InStore = $true; StageExit = 0 # Whether a device the package binds to then starts. NewStarts = $true + # pnputil reports removing a device that stays (259 or a pending restart). + RemoveSticks = $false Devices = @([pscustomobject]@{InstanceId='PCI\GPU';HardwareIds=@('PCI\GPU');Inf='oem1.inf';Problem=0}) } } @@ -67,6 +69,7 @@ function global:pnputil.exe { } '/remove-device' { $id = $args[1] + if ($global:EMRepair.RemoveSticks) { $global:LASTEXITCODE = 259; return } $global:EMRepair.Devices = @($global:EMRepair.Devices | Where-Object { $_.InstanceId -ne $id }) $global:LASTEXITCODE = 0 } @@ -147,6 +150,13 @@ try { Reset-Fixture; $old = Add-Vdd 'oem33.inf' 43; $global:EMRepair.NewStarts = $false Expect 'replacement fails' 1 "$stage, nefconw --create-device-node, $stage, pnputil /remove-device ROOT\DISPLAY\0002" { @(Get-Vdd).Count -eq 1 -and @(Get-Vdd)[0].InstanceId -eq $old -and @(Get-Vdd)[0].Problem -eq 43 } + # pnputil reports removing a broken device that stays: not a success. + Reset-Fixture; $old = Add-Vdd 'oem33.inf' 43; $global:EMRepair.RemoveSticks = $true + Expect 'broken device not removed' 1 "$stage, nefconw --create-device-node, $stage, pnputil /remove-device $old" { + @(Get-Vdd).Count -eq 2 -and @(Working).Count -eq 1 } + # The same when undoing a replacement that did not start. + Reset-Fixture; $old = Add-Vdd 'oem33.inf' 43; $global:EMRepair.NewStarts = $false; $global:EMRepair.RemoveSticks = $true + Expect 'failed replacement not removed' 1 "$stage, nefconw --create-device-node, $stage, pnputil /remove-device ROOT\DISPLAY\0002" # Windows rejects the package: nothing else happens. Reset-Fixture; $old = Add-Vdd 'oem33.inf' 52; $global:EMRepair.StageExit = 5 Expect 'package rejected' 1 $stage { @(Get-Vdd).Count -eq 1 -and @(Get-Vdd)[0].Problem -eq 52 } @@ -170,7 +180,7 @@ try { $failures += 'Setup does not report each repair failure' } if ($failures) { throw "Repair checks failed:`n " + ($failures -join "`n ") } - Write-Output 'PASS: working, working beside broken, not owned, changed, unregistered, files missing, driverless with package gone, missing device, driver error, failed replacement, rejected package, restart, rerun, Setup wiring' + Write-Output 'PASS: working, working beside broken, not owned, changed, unregistered, files missing, driverless with package gone, missing device, driver error, failed replacement, removal not done, rejected package, restart, rerun, Setup wiring' } finally { Remove-Item $root -Recurse -Force Remove-Item function:global:pnputil.exe -ErrorAction SilentlyContinue From cb8731ac91f23b58b1cc11384d9d283ce62e34f4 Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Thu, 22 Oct 2026 17:46:25 -0400 Subject: [PATCH 16/39] Mention the failure snapshot in the release notes --- RELEASE_NOTES.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 63c631fc..3d061d74 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -100,8 +100,9 @@ Windows changes. off it, the page says to choose Extend with Windows key + P or in Display settings, and the next try waits 30 seconds for it. EternalMonitor does not change the display layout itself. The display task no longer reports success - when the display cannot appear, and the device state is in the support - diagnostics. In 0.3.1 every case read "no display appeared in time". + when the display cannot appear. The support diagnostics and **Copy logs** + then hold the device state, the graphics cards and the display paths Windows + reports, without the PC's or the user's name. In 0.3.1 every case read "no display appeared in time". - The second screen is drawn by the graphics card that drives the main screen, whether or not **Match the iPad's resolution** is on. Before, the driver chose the card with the most memory, which on laptops with Intel and NVIDIA From 306e4af1b6a496e5778197f2c0541bed700ae09e Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Thu, 22 Oct 2026 18:08:04 -0400 Subject: [PATCH 17/39] Log the failure snapshot for every failed Enable --- host/src/capture/mod.rs | 20 +++++---- host/src/vdd.rs | 95 +++++++++++++++++++++++++++++++++++----- host/src/vdd_snapshot.rs | 23 ++++++++-- 3 files changed, 115 insertions(+), 23 deletions(-) diff --git a/host/src/capture/mod.rs b/host/src/capture/mod.rs index 9d0f6c7e..aa74447a 100644 --- a/host/src/capture/mod.rs +++ b/host/src/capture/mod.rs @@ -416,7 +416,7 @@ fn prepare_vdd_settings( /// Log how Windows sees the displays, before the Disable task removes the /// virtual one. One record per failure; see `vdd_snapshot`. #[cfg(windows)] -fn log_failure_snapshot(device: DeviceState, pin: &Result) { +fn log_failure_snapshot(device: crate::vdd::EnableReport, pin: &Result) { let snapshot = crate::vdd_snapshot::collect(device, pin.clone()); warn!( "Virtual display failure snapshot {}", @@ -558,7 +558,7 @@ pub(crate) fn reconcile_virtual_display( Some(VddProblem::NotExtended) => VDD_EXTEND_TIMEOUT, _ => vdd_attach_timeout(), }; - if let Err(device) = crate::vdd::enable(progress) { + if let Err(report) = crate::vdd::enable(progress) { if !capture_is_current(shared, generation) { return None; } @@ -566,13 +566,15 @@ pub(crate) fn reconcile_virtual_display( "Virtual display could not be enabled (installer task missing?) — \ capturing the primary display instead" ); - if let Some(device) = device { + if let Ok(device) = report { report_device(shared, device); - log_failure_snapshot(device, &pin); - // The task enabled a device that cannot show a display. - if device != DeviceState::Missing { - crate::vdd::set_enabled(false, progress); - } + } + // Every failed Enable, with or without a device state, and + // before the Disable below. + log_failure_snapshot(report, &pin); + // The task enabled a device that cannot show a display. + if report.is_ok_and(|device| device != DeviceState::Missing) { + crate::vdd::set_enabled(false, progress); } *shared.vdd_status.lock() = VddStatus::TaskFailed; return Some(CaptureTarget::PrimaryAuto); @@ -613,7 +615,7 @@ pub(crate) fn reconcile_virtual_display( warn!("Virtual display did not attach in time — capturing the primary display instead"); if let Some(device) = device { report_device(shared, device); - log_failure_snapshot(device, &pin); + log_failure_snapshot(Ok(device), &pin); } // enable() succeeded but nothing attached: turn it back off so we don't strand a // half-enabled device as a phantom monitor. diff --git a/host/src/vdd.rs b/host/src/vdd.rs index c6a10569..d20d7f12 100644 --- a/host/src/vdd.rs +++ b/host/src/vdd.rs @@ -63,8 +63,23 @@ enum TaskOutcome { Failed(Option), } +/// What a failed Enable can tell about the device: the state the task +/// found, or why there is none. +pub(crate) type EnableReport = Result; + #[cfg_attr(not(windows), allow(dead_code))] // Driver tasks exist only on Windows. impl TaskOutcome { + /// For an Enable task that ran and did not succeed. + fn report(self) -> EnableReport { + match self { + Self::Failed(Some(state)) => Ok(state), + Self::Failed(None) => Err("display task failed"), + Self::TimedOut => Err("display task timed out"), + Self::Missing => Err("display tasks missing"), + Self::Done => Err("display task finished"), + } + } + fn from_stderr(stderr: &str) -> Self { if stderr.contains("timed out") { Self::TimedOut @@ -231,6 +246,11 @@ pub const TASK_DISABLE: &str = "EternalMonitor VDD Disable"; #[cfg(windows)] fn run_task(task: &str, progress: &mut dyn FnMut()) -> TaskOutcome { use std::os::windows::process::CommandExt; + #[cfg(debug_assertions)] + if let Some(outcome) = simulated_enable_failure(task) { + warn!(task, ?outcome, "Simulating a failed VDD task"); + return outcome; + } let started = Instant::now(); // An Enable that timed out here can still be running in Task Scheduler; // each direction waits for the other first, so the last request wins. @@ -311,21 +331,50 @@ fn run_task(task: &str, progress: &mut dyn FnMut()) -> TaskOutcome { } } +/// Debug builds only, to check the failure paths on a PC whose tasks work: +/// `ETERNAL_SIMULATE_ENABLE_FAILURE=missing|timeout|failed` makes the Enable +/// task report that without running. +#[cfg(all(windows, debug_assertions))] +fn simulated_enable_failure(task: &str) -> Option { + if task != TASK_ENABLE { + return None; + } + match std::env::var("ETERNAL_SIMULATE_ENABLE_FAILURE") + .ok()? + .as_str() + { + "missing" => Some(TaskOutcome::Missing), + "timeout" => Some(TaskOutcome::TimedOut), + "failed" => Some(TaskOutcome::Failed(None)), + _ => None, + } +} + /// Complete one installer-owned driver action while capture reports startup progress. #[cfg_attr(not(windows), allow(dead_code))] // Only Windows capture asks. pub(crate) fn set_enabled(enabled: bool, progress: impl FnMut()) -> bool { request(enabled, Purpose::Attempt, progress, &mut None) } +/// Why an Enable the gate did not run failed. +#[cfg_attr(not(windows), allow(dead_code))] // Only Windows capture asks. +fn refused(health: VddTaskHealth) -> EnableReport { + Err(match health { + VddTaskHealth::TasksMissing => "display tasks missing", + VddTaskHealth::NotResponding => "display task not responding", + VddTaskHealth::Ok => "display task not run", + }) +} + /// Turn the virtual display on. On failure, what the Enable task found -/// wrong with the device, when it ran and said. +/// wrong with the device, or why it could not say. #[cfg_attr(not(windows), allow(dead_code))] // Only Windows capture asks. -pub(crate) fn enable(progress: impl FnMut()) -> Result<(), Option> { - let mut device = None; - if request(true, Purpose::Attempt, progress, &mut device) { +pub(crate) fn enable(progress: impl FnMut()) -> Result<(), EnableReport> { + let mut ran = None; + if request(true, Purpose::Attempt, progress, &mut ran) { Ok(()) } else { - Err(device) + Err(ran.map_or_else(|| refused(task_health()), TaskOutcome::report)) } } @@ -333,7 +382,7 @@ fn request( enabled: bool, purpose: Purpose, progress: impl FnMut(), - device: &mut Option, + ran: &mut Option, ) -> bool { #[cfg(windows)] { @@ -343,15 +392,13 @@ fn request( outcome = run_task(if enabled { TASK_ENABLE } else { TASK_DISABLE }, progress); outcome == TaskOutcome::Done }); - if let TaskOutcome::Failed(state) = outcome { - *device = state; - } + *ran = Some(outcome); outcome }) } #[cfg(not(windows))] { - let _ = (enabled, purpose, progress, device); + let _ = (enabled, purpose, progress, ran); false } } @@ -639,6 +686,34 @@ mod tests { assert_eq!(tasks.gate.lock().known_enabled, Some(false)); } + #[test] + fn enable_failures_without_a_device_state_say_why() { + use crate::vdd_device::DeviceState; + assert_eq!( + TaskOutcome::Failed(Some(DeviceState::Problem(43))).report(), + Ok(DeviceState::Problem(43)) + ); + assert_eq!( + TaskOutcome::Failed(None).report(), + Err("display task failed") + ); + assert_eq!( + TaskOutcome::TimedOut.report(), + Err("display task timed out") + ); + assert_eq!(TaskOutcome::Missing.report(), Err("display tasks missing")); + // The gate refused to run the task at all. + assert_eq!( + refused(VddTaskHealth::TasksMissing), + Err("display tasks missing") + ); + assert_eq!( + refused(VddTaskHealth::NotResponding), + Err("display task not responding") + ); + assert_eq!(refused(VddTaskHealth::Ok), Err("display task not run")); + } + #[test] fn task_errors_are_classified_from_the_script_output() { assert_eq!( diff --git a/host/src/vdd_snapshot.rs b/host/src/vdd_snapshot.rs index 3b7bfd14..58d2a4de 100644 --- a/host/src/vdd_snapshot.rs +++ b/host/src/vdd_snapshot.rs @@ -44,7 +44,8 @@ pub struct Target { #[derive(Debug, Clone)] pub struct Snapshot { - pub device: DeviceState, + /// The device state, or why there is none. + pub device: Result, /// The graphics card the host asked the driver for, or why it asked for none. pub pin: Result, /// `HwSchMode`: 2 hardware-accelerated GPU scheduling on, 1 off. @@ -71,7 +72,10 @@ fn luid(value: u64) -> String { /// The record's text, from data already read. pub fn line(snapshot: &Snapshot) -> String { - let mut out = format!("device={}", snapshot.device); + let mut out = match snapshot.device { + Ok(device) => format!("device={device}"), + Err(reason) => format!("device=unknown({})", clean(reason)), + }; match &snapshot.pin { Ok(name) => { let matched = snapshot.adapters.iter().any(|a| &a.description == name); @@ -152,7 +156,10 @@ pub fn line(snapshot: &Snapshot) -> String { /// Read the adapters, display paths and settings now. #[cfg(windows)] -pub fn collect(device: DeviceState, pin: Result) -> Snapshot { +pub fn collect( + device: Result, + pin: Result, +) -> Snapshot { let settings_key = r"SOFTWARE\MikeTheTech\VirtualDisplayDriver"; let path = crate::diagnostics::registry_string(settings_key, "VDDPATH"); Snapshot { @@ -308,7 +315,7 @@ mod tests { fn snapshot() -> Snapshot { Snapshot { - device: DeviceState::NoMonitor, + device: Ok(DeviceState::NoMonitor), pin: Ok("Intel(R) Iris(R) Xe Graphics".into()), hags: Some(2), default_settings_path: Some(true), @@ -371,6 +378,14 @@ mod tests { "{text}" ); assert!(text.contains(" hags=unset")); + // An Enable that failed without a device state says why. + let mut unknown = snapshot(); + unknown.device = Err("display tasks missing"); + assert!( + line(&unknown).starts_with("device=unknown(display tasks missing) pin="), + "{}", + line(&unknown) + ); let mut unmatched = snapshot(); unmatched.pin = Ok("Intel(R) UHD Graphics".into()); assert!(line(&unmatched).contains("pin_matched=false")); From f376c21f12a7ac03f250da04675424baabfed039 Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Thu, 22 Oct 2026 18:08:25 -0400 Subject: [PATCH 18/39] Leave the virtual display's own adapter out of the duplicate-name check --- host/src/capture/dxgi.rs | 13 -------- host/src/capture/mod.rs | 7 +++- host/src/vdd_snapshot.rs | 70 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 73 insertions(+), 17 deletions(-) diff --git a/host/src/capture/dxgi.rs b/host/src/capture/dxgi.rs index 4f019f55..6ffb5533 100644 --- a/host/src/capture/dxgi.rs +++ b/host/src/capture/dxgi.rs @@ -59,19 +59,6 @@ pub fn enumerate_outputs() -> Vec { enumerate_factory_outputs(&factory) } -/// The description of every DXGI adapter, as the virtual display driver -/// matches its configured render adapter against them. -pub fn adapter_names() -> Vec { - let Ok(factory) = (unsafe { CreateDXGIFactory1::() }) else { - return Vec::new(); - }; - (0..) - .map_while(|index| unsafe { factory.EnumAdapters1(index) }.ok()) - .filter_map(|adapter| unsafe { adapter.GetDesc1() }.ok()) - .map(|desc| utf16_to_string(&desc.Description)) - .collect() -} - fn enumerate_factory_outputs(factory: &IDXGIFactory1) -> Vec { let mut outputs = Vec::new(); diff --git a/host/src/capture/mod.rs b/host/src/capture/mod.rs index aa74447a..9a91fd23 100644 --- a/host/src/capture/mod.rs +++ b/host/src/capture/mod.rs @@ -363,7 +363,12 @@ fn prepare_vdd_settings( .iter() .find(|output| output.is_primary) .map(|output| output.adapter_name.as_str()); - let pin = crate::vdd_settings::gpu_to_pin(main_screen, &dxgi::adapter_names()); + // The virtual display's own adapter, listed while it is on, is not a card. + let names = crate::vdd_snapshot::render_adapter_names( + &crate::vdd_snapshot::adapters(), + &crate::vdd_snapshot::targets(), + ); + let pin = crate::vdd_settings::gpu_to_pin(main_screen, &names); if let Err(reason) = pin { info!( reason, diff --git a/host/src/vdd_snapshot.rs b/host/src/vdd_snapshot.rs index 58d2a4de..741cd899 100644 --- a/host/src/vdd_snapshot.rs +++ b/host/src/vdd_snapshot.rs @@ -42,6 +42,29 @@ pub struct Target { pub monitor_path: String, } +impl Target { + fn is_vdd(&self) -> bool { + self.monitor_path.to_ascii_uppercase().contains(VDD_MONITOR) + } +} + +/// The adapter names the driver can be pinned to, without the virtual +/// display's own adapter. While the virtual display is on, DXGI lists that +/// adapter under the name of the card rendering it, which would otherwise +/// read as two cards with one name. +pub fn render_adapter_names(adapters: &[Adapter], targets: &[Target]) -> Vec { + let vdd: Vec = targets + .iter() + .filter(|t| t.is_vdd()) + .flat_map(|t| std::iter::once(t.adapter).chain(t.source.map(|(adapter, _)| adapter))) + .collect(); + adapters + .iter() + .filter(|a| !vdd.contains(&a.luid)) + .map(|a| a.description.clone()) + .collect() +} + #[derive(Debug, Clone)] pub struct Snapshot { /// The device state, or why there is none. @@ -122,7 +145,7 @@ pub fn line(snapshot: &Snapshot) -> String { .iter() .take(MAX_TARGETS) .map(|t| { - let vdd = t.monitor_path.to_ascii_uppercase().contains(VDD_MONITOR); + let vdd = t.is_vdd(); let state = match t.source { Some((adapter, id)) => format!("active src={}/{id}", luid(adapter)), None if t.available => "inactive available".to_string(), @@ -175,11 +198,14 @@ pub fn collect( path.trim_end_matches('\\') .eq_ignore_ascii_case(r"C:\VirtualDisplayDriver") }), - adapters: windows_impl::adapters(), - targets: windows_impl::targets(), + adapters: adapters(), + targets: targets(), } } +#[cfg(windows)] +pub use windows_impl::{adapters, targets}; + #[cfg(windows)] mod windows_impl { use super::{Adapter, Target}; @@ -391,6 +417,44 @@ mod tests { assert!(line(&unmatched).contains("pin_matched=false")); } + #[test] + fn the_virtual_displays_own_adapter_is_not_a_second_card() { + // mainpc with the virtual display on: DXGI lists its adapter under + // the RTX 5080's name, and its path runs on that adapter. + let card = |luid, name: &str| Adapter { + luid, + description: name.into(), + vendor_id: 0, + device_id: 0, + driver: String::new(), + outputs: 0, + }; + let adapters = [ + card(0x11e00, "NVIDIA GeForce RTX 5080"), + card(0x13c3b, "AMD Radeon(TM) Graphics"), + card(0x66f8f4a0, "NVIDIA GeForce RTX 5080"), + ]; + let mut vdd = snapshot().targets[1].clone(); + (vdd.adapter, vdd.source) = (0x66f8f4a0, Some((0x66f8f4a0, 0))); + let monitor = Target { + adapter: 0x11e00, + source: Some((0x11e00, 0)), + ..snapshot().targets[0].clone() + }; + let names = render_adapter_names(&adapters, &[monitor.clone(), vdd]); + assert_eq!( + names, + ["NVIDIA GeForce RTX 5080", "AMD Radeon(TM) Graphics"] + ); + assert_eq!( + crate::vdd_settings::gpu_to_pin(Some("NVIDIA GeForce RTX 5080"), &names), + Ok("NVIDIA GeForce RTX 5080") + ); + // Two real cards with one name still are not pinned. + let twins = render_adapter_names(&adapters, &[monitor]); + assert!(crate::vdd_settings::gpu_to_pin(Some("NVIDIA GeForce RTX 5080"), &twins).is_err()); + } + #[test] fn monitor_paths_and_odd_names_stay_out_and_the_line_is_bounded() { let mut odd = snapshot(); From dec087b10087c63a5e2728d49454630566ff9927 Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Thu, 22 Oct 2026 18:11:15 -0400 Subject: [PATCH 19/39] Refuse settings files that are not well-formed XML 1.0 --- host/src/vdd_settings.rs | 115 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 113 insertions(+), 2 deletions(-) diff --git a/host/src/vdd_settings.rs b/host/src/vdd_settings.rs index 994ab909..53bc6f61 100644 --- a/host/src/vdd_settings.rs +++ b/host/src/vdd_settings.rs @@ -135,18 +135,59 @@ enum GpuSlot { Insert(usize, &'static str), } +/// A character XML 1.0 allows in a document. +fn xml_char(c: char) -> bool { + matches!(c, '\t' | '\n' | '\r') || (c >= ' ' && !matches!(c, '\u{fffe}' | '\u{ffff}')) +} + +/// An XML name, loosely: what the driver's parser and Expat accept. +fn xml_name(name: &[u8]) -> bool { + let Ok(name) = std::str::from_utf8(name) else { + return false; + }; + let mut chars = name.chars(); + chars + .next() + .is_some_and(|c| c.is_alphabetic() || c == '_' || c == ':') + && chars.all(|c| c.is_alphanumeric() || matches!(c, '_' | ':' | '-' | '.')) +} + +/// The tag's name and every attribute are well-formed: quoted, unique, +/// with known entities and allowed characters. +fn check_tag(tag: &quick_xml::events::BytesStart) -> Result<(), &'static str> { + if !xml_name(tag.name().as_ref()) { + return Err("an element name is not valid XML"); + } + for attribute in tag.attributes() { + let attribute = attribute.map_err(|_| "an attribute is not valid XML")?; + let value = attribute + .normalized_value(quick_xml::XmlVersion::Implicit1_0) + .map_err(|_| "an attribute is not valid XML")?; + if !xml_name(attribute.key.as_ref()) || !value.chars().all(xml_char) { + return Err("an attribute is not valid XML"); + } + } + Ok(()) +} + /// Find the render adapter's place by parsing `xml`. Anything the driver /// could read differently from this host is refused: a name outside /// ``, a second name or ``, markup inside the name, text or -/// elements outside the `` root, or a document that does not -/// parse. The driver matches `friendlyname` by local name anywhere. +/// elements outside the `` root, or a document that is not +/// well-formed XML 1.0 (bad attributes, misplaced declarations, characters +/// XML does not allow, unknown entities). The driver matches +/// `friendlyname` by local name anywhere. fn gpu_slot(xml: &str) -> Result { use quick_xml::events::Event; + if !xml.chars().all(xml_char) { + return Err("the file has characters XML does not allow"); + } let mut reader = quick_xml::Reader::from_str(xml); reader.config_mut().trim_text(false); let mut open: Vec> = Vec::new(); let (mut root, mut closed, mut slot) = (None, false, None); let (mut gpus, mut names, mut name_start) = (0, 0, 0); + let mut doctype = false; loop { let before = reader.buffer_position() as usize; let event = reader @@ -163,6 +204,7 @@ fn gpu_slot(xml: &str) -> Result { return Err("the graphics card name contains markup") } Event::Start(ref tag) | Event::Empty(ref tag) => { + check_tag(tag)?; let empty = matches!(event, Event::Empty(_)); let name = tag.local_name().as_ref().to_vec(); match (open.len(), name.as_slice()) { @@ -204,6 +246,34 @@ fn gpu_slot(xml: &str) -> Result { Event::CData(_) | Event::GeneralRef(_) if outside => { return Err("there is text outside the root element") } + // Only the very first thing in the file may be the declaration, + // and a DOCTYPE only once, before the root. + Event::Decl(_) if before != 0 => return Err("an XML declaration is out of place"), + Event::DocType(_) if doctype || root.is_some() => { + return Err("a DOCTYPE is out of place") + } + Event::DocType(_) => doctype = true, + Event::GeneralRef(ref reference) => { + let known = if reference.is_char_ref() { + matches!(reference.resolve_char_ref(), Ok(Some(c)) if xml_char(c)) + } else { + matches!( + reference.as_ref(), + b"lt" | b"gt" | b"amp" | b"apos" | b"quot" + ) + }; + if !known { + return Err("the file uses an entity XML does not define"); + } + } + Event::Text(ref text) if text.windows(3).any(|w| w == b"]]>") => { + return Err("the file is not well-formed XML") + } + Event::Comment(ref text) + if text.windows(2).any(|w| w == b"--") || text.ends_with(b"-") => + { + return Err("a comment is not well-formed XML") + } Event::Eof => break, _ => {} } @@ -486,6 +556,47 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn documents_expat_rejects_are_left_alone() { + let dir = std::env::temp_dir().join(format!("vdd-pin-strict-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("vdd_settings.xml"); + let name = "default"; + for document in [ + // Sol's four: each was rewritten, and Expat rejected the result. + format!("{name}\n"), + format!("{name}\n"), + format!("{name}\n\n"), + format!("{name}\u{1}\n"), + // More of the same kind. + format!("\n{name}"), + format!("{name}"), + format!("{name} "), + format!("{name}"), + format!("{name}"), + format!("{name}"), + format!("{name}]]>"), + format!("{name}<1count/>"), + ] { + assert_eq!(with_gpu(&document, "X"), None, "{document:?}"); + std::fs::write(&path, &document).unwrap(); + assert!(pin_gpu_in(&path, "X").is_err(), "{document:?}"); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + document, + "changed: {document:?}" + ); + } + // A name that is not valid XML is not written either. + let vendor = with_gpu(VENDOR, "Bad\u{1}Card"); + assert_eq!(vendor, None); + // A DOCTYPE in the prolog and references XML defines are fine. + let fine = format!("\n\n{name}A<\n"); + assert_eq!(with_gpu(&fine, "X"), Some(fine.replace(">default<", ">X<"))); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn a_stale_pin_is_corrected_and_a_current_one_left_alone() { let dir = std::env::temp_dir().join(format!("vdd-pin-test-{}", std::process::id())); From ab5ee5ac6ea2bdc1b43548ab90098d9ee0d623f9 Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Thu, 22 Oct 2026 18:12:08 -0400 Subject: [PATCH 20/39] Say only the display snapshot leaves out names --- RELEASE_NOTES.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 3d061d74..923a64ee 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -100,9 +100,10 @@ Windows changes. off it, the page says to choose Extend with Windows key + P or in Display settings, and the next try waits 30 seconds for it. EternalMonitor does not change the display layout itself. The display task no longer reports success - when the display cannot appear. The support diagnostics and **Copy logs** - then hold the device state, the graphics cards and the display paths Windows - reports, without the PC's or the user's name. In 0.3.1 every case read "no display appeared in time". + when the display cannot appear. After any failed attempt the support + diagnostics and **Copy logs** hold a display snapshot: the device state, the + graphics cards and the display paths Windows reports. The snapshot leaves out + the PC's name and the user's name; the rest of the log still contains them. In 0.3.1 every case read "no display appeared in time". - The second screen is drawn by the graphics card that drives the main screen, whether or not **Match the iPad's resolution** is on. Before, the driver chose the card with the most memory, which on laptops with Intel and NVIDIA From b493c5ec02491099e5e8cd520677e00ea4b63cad Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Thu, 22 Oct 2026 18:27:19 -0400 Subject: [PATCH 21/39] Bring the second screen to the iPad's mode when Windows restores another --- host/src/capture/mod.rs | 117 +++++++++++++++++++++++++++++++-------- host/src/vdd_device.rs | 37 +++++++++++++ host/src/vdd_settings.rs | 42 ++++++++++++++ 3 files changed, 172 insertions(+), 24 deletions(-) diff --git a/host/src/capture/mod.rs b/host/src/capture/mod.rs index 9a91fd23..c5c1ea8e 100644 --- a/host/src/capture/mod.rs +++ b/host/src/capture/mod.rs @@ -348,17 +348,24 @@ const VDD_EXTEND_TIMEOUT: Duration = Duration::from_secs(30); #[cfg(windows)] const VDD_SETTINGS_PATH: &str = r"C:\VirtualDisplayDriver\vdd_settings.xml"; +/// What the host asked the driver for before an enable: the graphics card +/// (or why none), and the mode the second screen should come up in (none +/// when the user keeps the driver's modes). +#[cfg(windows)] +struct VddRequest { + pin: Result, + mode: Option, +} + /// Before each enable: name the main screen's graphics card for the driver -/// to render on, and, when the user wants it, the iPad's modes. Without a +/// to render on, and, when the user wants it, the iPad's modes, or a +/// 1920x1080 default while the iPad's size is not known. Without a /// name the driver renders on the card with the most memory. In 0.3.1 /// diagnostics no Intel + NVIDIA laptop got the second screen; that card /// choice is a suspect, not a confirmed cause. Failure is non-fatal — the /// driver's built-in choices apply. Returns the card asked for, or why none. #[cfg(windows)] -fn prepare_vdd_settings( - shared: &SharedControl, - outputs: &[OutputInfo], -) -> Result { +fn prepare_vdd_settings(shared: &SharedControl, outputs: &[OutputInfo]) -> VddRequest { let main_screen = outputs .iter() .find(|output| output.is_primary) @@ -376,29 +383,36 @@ fn prepare_vdd_settings( ); } let gpu = pin.ok(); - let client = crate::settings::SettingsFile::load() - .vdd_match_resolution - .then(|| shared.session.lock().client_info()) - .flatten(); + let matching = crate::settings::SettingsFile::load().vdd_match_resolution; let path = std::path::Path::new(VDD_SETTINGS_PATH); - if let Some(info) = client { - let modes = crate::vdd_settings::modes_for_client( - info.screen_px.0, - info.screen_px.1, - info.refresh_hz, - ); + let mut mode = None; + if matching { + let client = shared.session.lock().client_info(); + let modes = client.map_or_else(crate::vdd_settings::default_modes, |info| { + crate::vdd_settings::modes_for_client( + info.screen_px.0, + info.screen_px.1, + info.refresh_hz, + ) + }); match crate::vdd_settings::write_to(path, &modes, gpu) { - Ok(()) => info!( - ?modes, - gpu = gpu.unwrap_or("chosen by the driver"), - "Wrote virtual display mode list for the connected client" - ), + Ok(()) => { + info!( + ?modes, + gpu = gpu.unwrap_or("chosen by the driver"), + "Wrote virtual display mode list for the connected client" + ); + mode = modes.first().copied(); + } Err(error) => { warn!( %error, "Could not write vdd_settings.xml — driver default modes apply" ); - return Err("the settings file could not be written"); + return VddRequest { + pin: Err("the settings file could not be written"), + mode, + }; } } } else if let Some(gpu) = gpu { @@ -411,11 +425,62 @@ fn prepare_vdd_settings( Ok(false) => {} Err(error) => { warn!(%error, gpu, "Left vdd_settings.xml as it is; the driver chooses the graphics card"); - return Err("the settings file was left as it is"); + return VddRequest { + pin: Err("the settings file was left as it is"), + mode, + }; } } } - pin.map(str::to_string) + VddRequest { + pin: pin.map(str::to_string), + mode, + } +} + +/// Bring a freshly attached second screen to the mode asked for, when +/// Windows restored another one for this set of displays, and log the mode +/// it ends up in, once per attach. Only the virtual display changes. +#[cfg(windows)] +fn settle_vdd_mode( + chosen: OutputInfo, + requested: Option, + mut heartbeat: impl FnMut(), +) -> OutputInfo { + let attached = format!("{}x{}", chosen.width, chosen.height); + let Some(mode) = crate::vdd_settings::mode_to_set(requested, (chosen.width, chosen.height)) + else { + info!( + mode = %attached, + requested = %requested.map_or("the driver's modes".to_string(), |m| m.to_string()), + "Second screen mode" + ); + return chosen; + }; + let set = crate::vdd_device::set_display_mode(&chosen.device_name, mode); + let mut now = chosen.clone(); + // Windows applies the change asynchronously to DXGI's view of the output. + for _ in 0..30 { + heartbeat(); + if let Some(output) = enumerate_outputs() + .into_iter() + .find(|output| output.device_name == chosen.device_name) + { + now = output; + } + if !set || (now.width, now.height) == (mode.width, mode.height) { + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + info!( + mode = %format!("{}x{}", now.width, now.height), + requested = %mode, + attached = %attached, + set, + "Second screen mode" + ); + now } /// Log how Windows sees the displays, before the Disable task removes the @@ -550,7 +615,8 @@ pub(crate) fn reconcile_virtual_display( } *shared.vdd_output.lock() = None; - let pin = prepare_vdd_settings(shared, &outputs); + let request = prepare_vdd_settings(shared, &outputs); + let pin = request.pin; let before: Vec = outputs.into_iter().map(|o| o.device_name).collect(); if !capture_is_current(shared, generation) { @@ -605,6 +671,9 @@ pub(crate) fn reconcile_virtual_display( match result { AttachResult::Cancelled => return None, AttachResult::Attached(chosen) => { + let chosen = settle_vdd_mode(chosen, request.mode, || { + startup_heartbeat(shared, crate::clock::host_now_us() / 1000) + }); info!( device = %chosen.device_name, width = chosen.width, diff --git a/host/src/vdd_device.rs b/host/src/vdd_device.rs index 95749d12..dc05987a 100644 --- a/host/src/vdd_device.rs +++ b/host/src/vdd_device.rs @@ -219,6 +219,43 @@ pub fn query() -> DeviceState { DeviceState::best(states) } +/// Set the mode of the virtual display `device` (a GDI name like +/// `\\.\DISPLAY5`), and save it for this set of displays. Only that +/// display changes. Tried with the refresh rate first, then without it. +#[cfg(windows)] +pub fn set_display_mode(device: &str, mode: crate::vdd_settings::VddMode) -> bool { + use windows::core::PCWSTR; + use windows::Win32::Graphics::Gdi::{ + ChangeDisplaySettingsExW, CDS_UPDATEREGISTRY, DEVMODEW, DISP_CHANGE_SUCCESSFUL, + DM_DISPLAYFREQUENCY, DM_PELSHEIGHT, DM_PELSWIDTH, + }; + let name: Vec = device.encode_utf16().chain(Some(0)).collect(); + [ + DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFREQUENCY, + DM_PELSWIDTH | DM_PELSHEIGHT, + ] + .into_iter() + .any(|fields| { + let devmode = DEVMODEW { + dmSize: std::mem::size_of::() as u16, + dmFields: fields, + dmPelsWidth: mode.width, + dmPelsHeight: mode.height, + dmDisplayFrequency: mode.refresh_hz, + ..Default::default() + }; + (unsafe { + ChangeDisplaySettingsExW( + PCWSTR(name.as_ptr()), + Some(&devmode), + None, + CDS_UPDATEREGISTRY, + None, + ) + }) == DISP_CHANGE_SUCCESSFUL + }) +} + #[cfg(not(windows))] pub fn query() -> DeviceState { DeviceState::Missing diff --git a/host/src/vdd_settings.rs b/host/src/vdd_settings.rs index 53bc6f61..3bc8e723 100644 --- a/host/src/vdd_settings.rs +++ b/host/src/vdd_settings.rs @@ -25,6 +25,12 @@ pub struct VddMode { pub refresh_hz: u32, } +impl std::fmt::Display for VddMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}x{}@{}", self.width, self.height, self.refresh_hz) + } +} + /// The modes to advertise for a client with the given native panel, best /// first. The iPad reports portrait-native pixels; the desktop wants /// landscape, so the long edge becomes the width. Odd dimensions are rounded @@ -64,6 +70,24 @@ pub fn modes_for_client(screen_px_w: u16, screen_px_h: u16, refresh_hz: u8) -> V modes } +/// The modes to offer when the iPad's screen size is not known yet. +pub fn default_modes() -> Vec { + vec![VddMode { + width: 1920, + height: 1080, + refresh_hz: 60, + }] +} + +/// The mode to set on the virtual display after it attached, when Windows +/// picked another one. The driver offers the iPad's mode first, but Windows +/// restores the mode it last saved for these displays: 800x600 when the +/// driver's own settings were in place the first time, which is how the +/// vendor installer leaves them. +pub fn mode_to_set(requested: Option, attached: (u32, u32)) -> Option { + requested.filter(|mode| (mode.width, mode.height) != attached) +} + /// Render the settings document. Pure so tests can assert the exact bytes. /// `gpu` is the DXGI adapter description the driver matches its render /// adapter by; without it the driver chooses. @@ -394,6 +418,24 @@ mod tests { ); } + #[test] + fn a_second_screen_at_another_size_is_set_to_the_requested_mode() { + let ipad = modes_for_client(1668, 2420, 120)[0]; + // Windows restored the 800x600 it saved the first time. + assert_eq!(mode_to_set(Some(ipad), (800, 600)), Some(ipad)); + assert_eq!(mode_to_set(Some(ipad), (2420, 1668)), None); + // Matching is off: the size is the user's. + assert_eq!(mode_to_set(None, (800, 600)), None); + assert_eq!( + default_modes(), + [VddMode { + width: 1920, + height: 1080, + refresh_hz: 60 + }] + ); + } + #[test] fn odd_dimensions_are_evened_for_420_encoders() { let modes = modes_for_client(1667, 2421, 60); From c90edf37fb420dc18a0a9caacb6068de638a2832 Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Thu, 22 Oct 2026 18:29:46 -0400 Subject: [PATCH 22/39] Turn the virtual display off beside startup, not before the first frame --- host/src/main.rs | 5 +- host/src/vdd.rs | 153 +++++++++++++++++++++++++++++++++++- host/tests/startup_order.rs | 8 +- 3 files changed, 162 insertions(+), 4 deletions(-) diff --git a/host/src/main.rs b/host/src/main.rs index b587fcba..a90a2c5c 100644 --- a/host/src/main.rs +++ b/host/src/main.rs @@ -68,8 +68,9 @@ fn main() -> Result<(), Box> { // Never leave a virtual display attached across runs. If a previous run crashed or was // force-killed while streaming the virtual extended display, the VDD device is still enabled // and shows up as a phantom monitor. Disabling it unconditionally at startup guarantees the - // managed display only ever exists while EternalMonitor is actively using it. - vdd::disable(); + // managed display only ever exists while EternalMonitor is actively using it. It runs beside + // startup: a slow or hung task used to hold a Mirror stream's first frame for up to 30 s. + vdd::disable_in_background(); // Belt-and-suspenders: a panic anywhere (GUI, supervisor, pipeline threads) must still tear // the virtual display down before the process unwinds, so a crash can't strand a phantom diff --git a/host/src/vdd.rs b/host/src/vdd.rs index d20d7f12..6d86108e 100644 --- a/host/src/vdd.rs +++ b/host/src/vdd.rs @@ -190,6 +190,10 @@ pub fn recheck_tasks() { struct DriverTasks { serial: parking_lot::Mutex<()>, gate: parking_lot::Mutex, + /// Disables running or waiting for their turn. A pipeline's own Disable + /// does not queue behind one: the display ends up off either way, and + /// a Mirror stream need not wait for it to show its first frame. + disabling: std::sync::atomic::AtomicUsize, } #[cfg_attr(not(windows), allow(dead_code))] // Driver tasks exist only on Windows. @@ -198,6 +202,7 @@ impl DriverTasks { Self { serial: parking_lot::Mutex::new(()), gate: parking_lot::Mutex::new(TaskGate::new()), + disabling: std::sync::atomic::AtomicUsize::new(0), } } @@ -205,19 +210,51 @@ impl DriverTasks { self.gate.lock().health } + #[cfg(test)] fn set_enabled( + &self, + enabled: bool, + purpose: Purpose, + progress: impl FnMut(), + run: impl FnOnce(bool, &mut dyn FnMut()) -> TaskOutcome, + ) -> bool { + self.set_enabled_on_turn(enabled, purpose, progress, || {}, run) + } + + /// As `set_enabled`, calling `on_turn` once this request holds the turn: + /// every request made after that queues behind it. + fn set_enabled_on_turn( &self, enabled: bool, purpose: Purpose, mut progress: impl FnMut(), + on_turn: impl FnOnce(), run: impl FnOnce(bool, &mut dyn FnMut()) -> TaskOutcome, ) -> bool { + use std::sync::atomic::Ordering::SeqCst; + struct Pending<'a>(Option<&'a std::sync::atomic::AtomicUsize>); + impl Drop for Pending<'_> { + fn drop(&mut self) { + if let Some(count) = self.0 { + count.fetch_sub(1, SeqCst); + } + } + } + if !enabled { + if purpose == Purpose::Attempt && self.disabling.load(SeqCst) > 0 { + tracing::debug!("A Disable is already pending; not waiting for it"); + return true; + } + self.disabling.fetch_add(1, SeqCst); + } + let _pending = Pending((!enabled).then_some(&self.disabling)); let _serial = loop { if let Some(guard) = self.serial.try_lock_for(Duration::from_millis(50)) { break guard; } progress(); }; + on_turn(); // The gate guard ends with this statement. Logging below reads the // health through the same mutex, which deadlocked when the guard // lived through the whole match. @@ -383,10 +420,20 @@ fn request( purpose: Purpose, progress: impl FnMut(), ran: &mut Option, +) -> bool { + request_on_turn(enabled, purpose, progress, || {}, ran) +} + +fn request_on_turn( + enabled: bool, + purpose: Purpose, + progress: impl FnMut(), + on_turn: impl FnOnce(), + ran: &mut Option, ) -> bool { #[cfg(windows)] { - TASKS.set_enabled(enabled, purpose, progress, |enabled, progress| { + TASKS.set_enabled_on_turn(enabled, purpose, progress, on_turn, |enabled, progress| { let mut outcome = TaskOutcome::Failed(None); crate::startup::display::change(enabled, || { outcome = run_task(if enabled { TASK_ENABLE } else { TASK_DISABLE }, progress); @@ -399,6 +446,7 @@ fn request( #[cfg(not(windows))] { let _ = (enabled, purpose, progress, ran); + on_turn(); false } } @@ -411,6 +459,30 @@ pub fn disable() -> bool { request(false, Purpose::Cleanup, || {}, &mut None) } +/// `disable` at startup, without holding up the first frame: the Disable +/// runs on its own thread, and this returns once it holds the turn, so +/// every later request (an Enable, the quit's Disable) still runs after it. +/// A hung task no longer keeps a Mirror stream black for up to 30 s. +pub fn disable_in_background() { + let (held, turn) = std::sync::mpsc::channel(); + let spawned = std::thread::Builder::new() + .name("vdd-startup-off".into()) + .spawn(move || { + let on_turn = move || { + let _ = held.send(()); + }; + request_on_turn(false, Purpose::Cleanup, || {}, on_turn, &mut None) + }); + match spawned { + Ok(_) => { + let _ = turn.recv(); + } + Err(_) => { + disable(); + } + } +} + /// Disable the virtual display after `host` ended abnormally, if it owned the /// display and no other host of this install runs. For the failure reporter. #[cfg(windows)] @@ -714,6 +786,85 @@ mod tests { assert_eq!(refused(VddTaskHealth::Ok), Err("display task not run")); } + /// Runs `set_enabled` on a thread; `None` if it has not returned in 2 s. + fn returns_within( + tasks: &std::sync::Arc, + enabled: bool, + purpose: Purpose, + runs: std::sync::Arc>>, + ) -> Option { + let (done_tx, done_rx) = std::sync::mpsc::channel(); + let tasks = std::sync::Arc::clone(tasks); + std::thread::spawn(move || { + let ran = tasks.set_enabled( + enabled, + purpose, + || {}, + |e, _| { + runs.lock().unwrap().push(e); + TaskOutcome::Done + }, + ); + let _ = done_tx.send(ran); + }); + done_rx.recv_timeout(Duration::from_secs(2)).ok() + } + + #[test] + fn a_mirror_start_does_not_wait_behind_the_startup_disable() { + use std::sync::mpsc::channel; + use std::sync::{Arc, Mutex}; + let tasks = Arc::new(DriverTasks::new()); + let runs = Arc::new(Mutex::new(Vec::new())); + // The startup Disable holds the turn: its task is hung. + let (started_tx, started_rx) = channel(); + let (release_tx, release_rx) = channel::<()>(); + let startup = { + let tasks = Arc::clone(&tasks); + std::thread::spawn(move || { + tasks.set_enabled( + false, + Purpose::Cleanup, + || {}, + |_, _| { + started_tx.send(()).unwrap(); + release_rx.recv().unwrap(); + TaskOutcome::Done + }, + ) + }) + }; + started_rx.recv().unwrap(); + // The Mirror pipeline's Disable returns at once and runs nothing. + assert_eq!( + returns_within(&tasks, false, Purpose::Attempt, Arc::clone(&runs)), + Some(true), + "the pipeline waited behind the startup Disable" + ); + // An Enable still waits its turn, then runs after the Disable. + let (enable_tx, enable_rx) = channel(); + { + let (tasks, runs) = (Arc::clone(&tasks), Arc::clone(&runs)); + std::thread::spawn(move || { + let _ = enable_tx.send(returns_within(&tasks, true, Purpose::Attempt, runs)); + }); + } + assert!( + enable_rx.recv_timeout(Duration::from_millis(300)).is_err(), + "the Enable did not wait" + ); + release_tx.send(()).unwrap(); + assert!(startup.join().unwrap()); + assert_eq!(enable_rx.recv().unwrap(), Some(true)); + assert_eq!(*runs.lock().unwrap(), vec![true]); + // With nothing pending, a pipeline Disable runs again. + assert_eq!( + returns_within(&tasks, false, Purpose::Attempt, Arc::clone(&runs)), + Some(true) + ); + assert_eq!(*runs.lock().unwrap(), vec![true, false]); + } + #[test] fn task_errors_are_classified_from_the_script_output() { assert_eq!( diff --git a/host/tests/startup_order.rs b/host/tests/startup_order.rs index de19d582..af877089 100644 --- a/host/tests/startup_order.rs +++ b/host/tests/startup_order.rs @@ -18,7 +18,8 @@ fn helpers_then_single_instance_then_the_display_reset() { // host's session log or start a second uploader on its outbox. "logging::MemoryLogWriter::start()", "eternal_host::telemetry::UploadGuard::start()", - "vdd::disable();", + // Beside startup, holding the turn before anything else can ask. + "vdd::disable_in_background();", "eternal_host::startup::install_failure_hooks();", ]; let positions: Vec = steps.iter().map(|step| at(step)).collect(); @@ -31,4 +32,9 @@ fn helpers_then_single_instance_then_the_display_reset() { main[..positions[0]].find("vdd::").is_none(), "main touches the virtual display before dispatching helper modes" ); + // The display reset no longer holds up startup. + assert!( + !main.contains(" vdd::disable();\n"), + "main waits for the startup Disable again" + ); } From 7c1da580b3e59547b422816723196af32bad9736 Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Thu, 22 Oct 2026 18:32:29 -0400 Subject: [PATCH 23/39] Say in Settings when the resolution match applies --- host/src/gui/page_settings.rs | 17 ++++++++++++----- host/src/gui/tests.rs | 13 +++++++++++++ host/tests/snapshots/settings.png | Bin 199036 -> 200504 bytes 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/host/src/gui/page_settings.rs b/host/src/gui/page_settings.rs index dcd4762b..f2ead01c 100644 --- a/host/src/gui/page_settings.rs +++ b/host/src/gui/page_settings.rs @@ -37,11 +37,7 @@ impl AnalyzerApp { widgets::setting_row( ui, "Match the iPad's resolution", - if extending { - "Makes the second screen exactly as sharp and smooth as the iPad's display." - } else { - "Used by Second screen. Makes it exactly as sharp and smooth as the iPad's display." - }, + vdd_match_description(extending), 64.0, |ui| { widgets::toggle(ui, &mut vdd_match, "Match the second screen to the iPad's resolution"); @@ -327,3 +323,14 @@ fn choice_label(label: &str) -> String { other => other.into(), } } + +/// What "Match the iPad's resolution" does, and when a change applies: the +/// driver reads the modes when the second screen starts, and restarting +/// the stream keeps the second screen that is already there. +pub(super) fn vdd_match_description(extending: bool) -> &'static str { + if extending { + "Makes the second screen exactly as sharp and smooth as the iPad's display. A change applies the next time the second screen starts, when the iPad connects again." + } else { + "Used by Second screen. Makes it exactly as sharp and smooth as the iPad's display. A change applies the next time the second screen starts." + } +} diff --git a/host/src/gui/tests.rs b/host/src/gui/tests.rs index f550032e..4e035a38 100644 --- a/host/src/gui/tests.rs +++ b/host/src/gui/tests.rs @@ -418,3 +418,16 @@ fn the_second_screen_notice_says_what_to_do_about_the_driver_device() { ); } } + +#[test] +fn the_resolution_match_says_when_a_change_applies() { + use super::page_settings::vdd_match_description; + for extending in [true, false] { + let text = vdd_match_description(extending); + assert!( + text.contains("applies the next time the second screen starts"), + "{text}" + ); + } + assert!(vdd_match_description(true).contains("when the iPad connects again")); +} diff --git a/host/tests/snapshots/settings.png b/host/tests/snapshots/settings.png index 7e6fa3813f7ef940dfae7276e94b5dec4ae418bd..8b007e79141eade6496f9982eb40459dc5dba317 100644 GIT binary patch delta 101944 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 X9?^3(>@KXsxdN~qec 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 GeeK^C*CkE> delta 101680 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 From ddec77510dd2c1fe6245f2f7df1a419d304e2e7e Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Thu, 22 Oct 2026 19:29:26 -0400 Subject: [PATCH 24/39] Use the sharpest offered size when the driver lacks the iPad's mode --- host/src/capture/mod.rs | 22 ++++++++++++++++++++-- host/src/vdd_device.rs | 33 +++++++++++++++++++++++++++++++++ host/src/vdd_settings.rs | 31 +++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/host/src/capture/mod.rs b/host/src/capture/mod.rs index c5c1ea8e..eb6d854f 100644 --- a/host/src/capture/mod.rs +++ b/host/src/capture/mod.rs @@ -457,7 +457,24 @@ fn settle_vdd_mode( ); return chosen; }; - let set = crate::vdd_device::set_display_mode(&chosen.device_name, mode); + let mut set = crate::vdd_device::set_display_mode(&chosen.device_name, mode); + let mut fallback = None; + if !set { + // The driver does not offer the iPad's mode: take the sharpest that fits. + let offered = crate::vdd_device::offered_modes(&chosen.device_name); + if let Some((width, height)) = crate::vdd_settings::best_offered(mode, &offered) + .filter(|&size| size != (chosen.width, chosen.height)) + { + let size = crate::vdd_settings::VddMode { + width, + height, + ..mode + }; + set = crate::vdd_device::set_display_mode(&chosen.device_name, size); + fallback = Some(size); + } + } + let target = fallback.unwrap_or(mode); let mut now = chosen.clone(); // Windows applies the change asynchronously to DXGI's view of the output. for _ in 0..30 { @@ -468,7 +485,7 @@ fn settle_vdd_mode( { now = output; } - if !set || (now.width, now.height) == (mode.width, mode.height) { + if !set || (now.width, now.height) == (target.width, target.height) { break; } std::thread::sleep(Duration::from_millis(100)); @@ -477,6 +494,7 @@ fn settle_vdd_mode( mode = %format!("{}x{}", now.width, now.height), requested = %mode, attached = %attached, + fallback = %fallback.map_or("none".to_string(), |m| m.to_string()), set, "Second screen mode" ); diff --git a/host/src/vdd_device.rs b/host/src/vdd_device.rs index dc05987a..75c207da 100644 --- a/host/src/vdd_device.rs +++ b/host/src/vdd_device.rs @@ -256,6 +256,39 @@ pub fn set_display_mode(device: &str, mode: crate::vdd_settings::VddMode) -> boo }) } +/// The sizes Windows offers for the display `device`. +#[cfg(windows)] +pub fn offered_modes(device: &str) -> Vec<(u32, u32)> { + use windows::core::PCWSTR; + use windows::Win32::Graphics::Gdi::{ + EnumDisplaySettingsW, DEVMODEW, ENUM_DISPLAY_SETTINGS_MODE, + }; + let name: Vec = device.encode_utf16().chain(Some(0)).collect(); + let mut sizes = Vec::new(); + for index in 0..1024 { + let mut devmode = DEVMODEW { + dmSize: std::mem::size_of::() as u16, + ..Default::default() + }; + if !unsafe { + EnumDisplaySettingsW( + PCWSTR(name.as_ptr()), + ENUM_DISPLAY_SETTINGS_MODE(index), + &mut devmode, + ) + } + .as_bool() + { + break; + } + let size = (devmode.dmPelsWidth, devmode.dmPelsHeight); + if !sizes.contains(&size) { + sizes.push(size); + } + } + sizes +} + #[cfg(not(windows))] pub fn query() -> DeviceState { DeviceState::Missing diff --git a/host/src/vdd_settings.rs b/host/src/vdd_settings.rs index 3bc8e723..1f792ace 100644 --- a/host/src/vdd_settings.rs +++ b/host/src/vdd_settings.rs @@ -88,6 +88,17 @@ pub fn mode_to_set(requested: Option, attached: (u32, u32)) -> Option Option<(u32, u32)> { + offered + .iter() + .copied() + .filter(|&(w, h)| w <= requested.width && h <= requested.height) + .max_by_key(|&(w, h)| u64::from(w) * u64::from(h)) +} + /// Render the settings document. Pure so tests can assert the exact bytes. /// `gpu` is the DXGI adapter description the driver matches its render /// adapter by; without it the driver chooses. @@ -436,6 +447,26 @@ mod tests { ); } + #[test] + fn without_the_ipads_mode_the_largest_size_that_fits_is_used() { + // The driver's own list, as its installer leaves it. + let vendor = [ + (800, 600), + (1366, 768), + (1920, 1080), + (2560, 1440), + (3840, 2160), + ]; + let ipad = |w, h| VddMode { + width: w, + height: h, + refresh_hz: 60, + }; + assert_eq!(best_offered(ipad(2048, 1536), &vendor), Some((1920, 1080))); + assert_eq!(best_offered(ipad(2752, 2064), &vendor), Some((2560, 1440))); + assert_eq!(best_offered(ipad(640, 480), &vendor), None); + } + #[test] fn odd_dimensions_are_evened_for_420_encoders() { let modes = modes_for_client(1667, 2421, 60); From 800e97c2b903d454fd6c0e06c0e30f8eafcee322 Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Thu, 22 Oct 2026 19:59:45 -0400 Subject: [PATCH 25/39] Describe the second screen size, startup and Settings changes --- RELEASE_NOTES.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 923a64ee..3f25fd2b 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -109,6 +109,18 @@ Windows changes. chose the card with the most memory, which on laptops with Intel and NVIDIA graphics drives no screen; in 0.3.1 diagnostics none of those laptops got the second screen. When two cards have the same name, the driver still chooses. +- The second screen comes up at the iPad's resolution. Windows restores the + size it last saved for the second screen, and in 0.3.1 diagnostics 4 of the 5 + PCs whose second screen appeared got a tiny, blurry 800×600 one. EternalMonitor + now sets the second screen's size itself, and only that display's: the + iPad's size, the sharpest size that fits when the driver doesn't offer it, or + 1920×1080 while the iPad's size isn't known. The log records the size once. +- A PC set to **Mirror my screen** shows the first picture without waiting for + the virtual display to be turned off at startup. A slow or stuck display task + held it for up to 19 s in 0.3.1, and about 2 s on a healthy PC. +- Settings says that **Match the iPad's resolution** applies the next time the + second screen starts, when the iPad connects again. Before, changing it + seemed to do nothing. - Running EternalMonitor-Setup.exe again repairs a virtual display driver that Setup installed when none of its devices works, for example after the device was removed in Device Manager. Working devices are never removed, and a From 8e5266158d5d99e39c507d3e82156688b3b32212 Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Thu, 22 Oct 2026 21:09:15 -0400 Subject: [PATCH 26/39] Edit only the settings files this host and the driver write --- host/src/vdd_settings.rs | 155 ++++++++++++++++---- host/tests/fixtures/README.md | 7 + host/tests/fixtures/vdd_settings-25.5.2.xml | 97 ++++++++++++ 3 files changed, 228 insertions(+), 31 deletions(-) create mode 100644 host/tests/fixtures/README.md create mode 100644 host/tests/fixtures/vdd_settings-25.5.2.xml diff --git a/host/src/vdd_settings.rs b/host/src/vdd_settings.rs index 1f792ace..78da1837 100644 --- a/host/src/vdd_settings.rs +++ b/host/src/vdd_settings.rs @@ -187,31 +187,50 @@ fn xml_name(name: &[u8]) -> bool { && chars.all(|c| c.is_alphanumeric() || matches!(c, '_' | ':' | '-' | '.')) } -/// The tag's name and every attribute are well-formed: quoted, unique, -/// with known entities and allowed characters. +/// Elements carry no attributes: neither this host nor the driver's own +/// file writes any, so a file with one is not a file this host edits. fn check_tag(tag: &quick_xml::events::BytesStart) -> Result<(), &'static str> { if !xml_name(tag.name().as_ref()) { return Err("an element name is not valid XML"); } - for attribute in tag.attributes() { - let attribute = attribute.map_err(|_| "an attribute is not valid XML")?; - let value = attribute - .normalized_value(quick_xml::XmlVersion::Implicit1_0) - .map_err(|_| "an attribute is not valid XML")?; - if !xml_name(attribute.key.as_ref()) || !value.chars().all(xml_char) { - return Err("an attribute is not valid XML"); - } + if !tag.attributes_raw().iter().all(u8::is_ascii_whitespace) { + return Err("an element has attributes, which this file never has"); } Ok(()) } -/// Find the render adapter's place by parsing `xml`. Anything the driver -/// could read differently from this host is refused: a name outside -/// ``, a second name or ``, markup inside the name, text or -/// elements outside the `` root, or a document that is not -/// well-formed XML 1.0 (bad attributes, misplaced declarations, characters -/// XML does not allow, unknown entities). The driver matches -/// `friendlyname` by local name anywhere. +/// The declaration this host writes or the driver ships: +/// ``, +/// with either quote. +fn xml_declaration(raw: &str) -> bool { + let attribute = + |name: &str, value: &str| [format!(" {name}=\"{value}\""), format!(" {name}='{value}'")]; + let optional = |name: &str, values: &[&str]| { + std::iter::once(String::new()) + .chain(values.iter().flat_map(|value| attribute(name, value))) + .collect::>() + }; + let encodings = optional("encoding", &["utf-8", "UTF-8"]); + let standalones = optional("standalone", &["yes", "no"]); + attribute("version", "1.0").iter().any(|version| { + encodings.iter().any(|encoding| { + standalones + .iter() + .any(|standalone| raw == format!("")) + }) + }) +} + +/// Find the render adapter's place by parsing `xml`. Only the shapes this +/// host writes and the driver ships are edited: the declaration above at +/// the start, comments, whitespace, elements without attributes, and text +/// with only predefined entities and legal character references. Anything +/// else is refused, including a DOCTYPE, processing instructions, CDATA and +/// characters XML does not allow; so is anything the driver could read +/// differently from this host: a name outside ``, a second name or +/// ``, markup inside the name, or text or elements outside the +/// `` root. The driver matches `friendlyname` by local name +/// anywhere. fn gpu_slot(xml: &str) -> Result { use quick_xml::events::Event; if !xml.chars().all(xml_char) { @@ -222,7 +241,6 @@ fn gpu_slot(xml: &str) -> Result { let mut open: Vec> = Vec::new(); let (mut root, mut closed, mut slot) = (None, false, None); let (mut gpus, mut names, mut name_start) = (0, 0, 0); - let mut doctype = false; loop { let before = reader.buffer_position() as usize; let event = reader @@ -235,7 +253,7 @@ fn gpu_slot(xml: &str) -> Result { Event::Start(_) | Event::Empty(_) if closed => { return Err("there is markup after the root element") } - Event::Start(_) | Event::Empty(_) | Event::CData(_) if in_name => { + Event::Start(_) | Event::Empty(_) if in_name => { return Err("the graphics card name contains markup") } Event::Start(ref tag) | Event::Empty(ref tag) => { @@ -278,16 +296,14 @@ fn gpu_slot(xml: &str) -> Result { Event::Text(text) if outside && !text.iter().all(u8::is_ascii_whitespace) => { return Err("there is text outside the root element") } - Event::CData(_) | Event::GeneralRef(_) if outside => { + Event::GeneralRef(_) if outside => { return Err("there is text outside the root element") } - // Only the very first thing in the file may be the declaration, - // and a DOCTYPE only once, before the root. - Event::Decl(_) if before != 0 => return Err("an XML declaration is out of place"), - Event::DocType(_) if doctype || root.is_some() => { - return Err("a DOCTYPE is out of place") + // Only the very first thing in the file may be the declaration. + Event::Decl(_) if before != 0 || !xml_declaration(&xml[before..after]) => { + return Err("the XML declaration is not one this file has") } - Event::DocType(_) => doctype = true, + Event::Decl(_) => {} Event::GeneralRef(ref reference) => { let known = if reference.is_char_ref() { matches!(reference.resolve_char_ref(), Ok(Some(c)) if xml_char(c)) @@ -309,8 +325,10 @@ fn gpu_slot(xml: &str) -> Result { { return Err("a comment is not well-formed XML") } + Event::Text(_) | Event::Comment(_) => {} Event::Eof => break, - _ => {} + // A DOCTYPE, processing instructions, CDATA, or anything newer. + _ => return Err("the file uses XML this host does not edit"), } } let root = root @@ -589,8 +607,8 @@ mod tests { } #[test] - fn comments_attributes_line_endings_and_bom_are_kept() { - let commented = "\n\n\n \n A&B \n \n\n"; + fn comments_line_endings_and_bom_are_kept() { + let commented = "\n\n\n \n A&B \n \n\n"; assert_eq!( with_gpu(commented, "Intel(R) Iris(R) Xe Graphics"), Some(commented.replace(">A&B<", ">Intel(R) Iris(R) Xe Graphics<")) @@ -664,12 +682,87 @@ mod tests { // A name that is not valid XML is not written either. let vendor = with_gpu(VENDOR, "Bad\u{1}Card"); assert_eq!(vendor, None); - // A DOCTYPE in the prolog and references XML defines are fine. - let fine = format!("\n\n{name}A<\n"); + // References XML defines are fine. + let fine = format!("\n{name}A<\n"); assert_eq!(with_gpu(&fine, "X"), Some(fine.replace(">default<", ">X<"))); let _ = std::fs::remove_dir_all(&dir); } + /// The driver's own vdd_settings.xml from Virtual-Display-Driver 25.5.2 + /// (MIT), byte for byte: single-quoted declaration, many comments. + const VENDOR_FILE: &str = include_str!("../tests/fixtures/vdd_settings-25.5.2.xml"); + + #[test] + fn only_the_shapes_this_host_and_the_driver_write_are_edited() { + let dir = std::env::temp_dir().join(format!("vdd-pin-allow-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("vdd_settings.xml"); + let name = "default"; + for document in [ + // Sol's four: each was rewritten. + format!("{name}\n"), + format!("\n{name}\n"), + format!("]>\n{name}\n"), + format!("\n{name}\n"), + // Everything outside the allow-list. + format!( + "\n{}", + "default" + ), + format!("\n{name}"), + format!("\n{name}"), + format!("{name}"), + format!("{name}"), + format!("\n{name}"), + format!( + "\n{name}" + ), + format!( + "\n{name}" + ), + format!("\n{name}"), + format!(" \n{name}"), + ] { + assert_eq!(with_gpu(&document, "X"), None, "{document:?}"); + std::fs::write(&path, &document).unwrap(); + assert!(pin_gpu_in(&path, "X").is_err(), "{document:?}"); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + document, + "changed: {document:?}" + ); + } + // Every declaration shape the allow-list names, and none. + for declaration in [ + "", + "\n", + "\n", + "\n", + "\n", + ] { + let document = format!("{declaration}{name}\n"); + assert_eq!( + with_gpu(&document, "X"), + Some(document.replace(">default<", ">X<")), + "{document:?}" + ); + } + // The driver's own file is edited in place, and so is this host's. + assert_eq!( + with_gpu(VENDOR_FILE, "Intel(R) Iris(R) Xe Graphics"), + Some(VENDOR_FILE.replace( + "default", + "Intel(R) Iris(R) Xe Graphics" + )) + ); + std::fs::write(&path, VENDOR_FILE).unwrap(); + assert!(pin_gpu_in(&path, "X").unwrap()); + let ours = render(&modes_for_client(1668, 2420, 120), Some("A")); + assert_eq!(with_gpu(&ours, "B"), Some(ours.replace(">A<", ">B<"))); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn a_stale_pin_is_corrected_and_a_current_one_left_alone() { let dir = std::env::temp_dir().join(format!("vdd-pin-test-{}", std::process::id())); diff --git a/host/tests/fixtures/README.md b/host/tests/fixtures/README.md new file mode 100644 index 00000000..767c2c01 --- /dev/null +++ b/host/tests/fixtures/README.md @@ -0,0 +1,7 @@ +# Test fixtures + +`vdd_settings-25.5.2.xml` is the default `vdd_settings.xml` from +[Virtual-Display-Driver](https://github.com/VirtualDrivers/Virtual-Display-Driver) +25.5.2 (`Virtual Display Driver (HDR)/vdd_settings.xml`), byte for byte. It is +MIT licensed by its authors. `host/src/vdd_settings.rs` tests that the host +edits this file in place. diff --git a/host/tests/fixtures/vdd_settings-25.5.2.xml b/host/tests/fixtures/vdd_settings-25.5.2.xml new file mode 100644 index 00000000..d70a01d7 --- /dev/null +++ b/host/tests/fixtures/vdd_settings-25.5.2.xml @@ -0,0 +1,97 @@ + + + + 1 + + + default + + + + 60 + 90 + 120 + 144 + 165 + 244 + + + + 800 + 600 + 30 + + + 1366 + 768 + 30 + + + 1920 + 1080 + 30 + + + 2560 + 1440 + 30 + + + 3840 + 2160 + 30 + + + + + + true + false + + false + + + + + + + false + false + RGB + + + + true + + 128 + + 128 + + true + + 2 + + + + + false + + false + + false + + + \ No newline at end of file From 2cba89b1cc3d1afc297c03829c272fd73a37df3b Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Mon, 26 Oct 2026 19:05:30 -0400 Subject: [PATCH 27/39] Pin the main screen's card when Windows lists it twice --- RELEASE_NOTES.md | 4 ++- host/src/capture/mod.rs | 4 +-- host/src/vdd_settings.rs | 74 ++++++++++++++++++++++++++++++++++------ host/src/vdd_snapshot.rs | 22 ++++++------ 4 files changed, 81 insertions(+), 23 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index cd7a4810..90ecd864 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -19,7 +19,9 @@ Not released yet. whether or not **Match the iPad's resolution** is on. Before, the driver chose the card with the most memory, which on laptops with Intel and NVIDIA graphics drives no screen; in 0.3.1 diagnostics none of those laptops got the - second screen. When two cards have the same name, the driver still chooses. + second screen. A card Windows lists twice under one name, as on some + laptops, is still named when only its first listing drives a screen; when + two cards with one name could, the driver still chooses. - The second screen comes up at the iPad's resolution. Windows restores the size it last saved for the second screen, and in 0.3.1 diagnostics 4 of the 5 PCs whose second screen appeared got a tiny, blurry 800×600 one. EternalMonitor diff --git a/host/src/capture/mod.rs b/host/src/capture/mod.rs index bdf41c13..d2c5889d 100644 --- a/host/src/capture/mod.rs +++ b/host/src/capture/mod.rs @@ -371,11 +371,11 @@ fn prepare_vdd_settings(shared: &SharedControl, outputs: &[OutputInfo]) -> VddRe .find(|output| output.is_primary) .map(|output| output.adapter_name.as_str()); // The virtual display's own adapter, listed while it is on, is not a card. - let names = crate::vdd_snapshot::render_adapter_names( + let cards = crate::vdd_snapshot::render_adapters( &crate::vdd_snapshot::adapters(), &crate::vdd_snapshot::targets(), ); - let pin = crate::vdd_settings::gpu_to_pin(main_screen, &names); + let pin = crate::vdd_settings::gpu_to_pin(main_screen, &cards); if let Err(reason) = pin { info!( reason, diff --git a/host/src/vdd_settings.rs b/host/src/vdd_settings.rs index 78da1837..336b35bb 100644 --- a/host/src/vdd_settings.rs +++ b/host/src/vdd_settings.rs @@ -144,17 +144,26 @@ fn gpu_element(gpu: &str) -> String { } /// The graphics card to name for the driver: the one driving the main -/// screen. The driver matches by description and takes the first match, so -/// a name two adapters share is not pinned; the error says why for the log. +/// screen. `adapters` are in DXGI order, as the driver enumerates them. The +/// driver matches by description and takes the first match, so a name +/// several adapters share is pinned only when the first of them is the one +/// with screens and the others have none: Windows lists some laptops' +/// integrated card twice. Otherwise the error says why for the log. pub fn gpu_to_pin<'a>( main_screen: Option<&'a str>, - adapters: &[String], + adapters: &[crate::vdd_snapshot::Adapter], ) -> Result<&'a str, &'static str> { let name = main_screen .filter(|name| !name.is_empty()) .ok_or("no adapter drives the main screen")?; - if adapters.iter().filter(|adapter| *adapter == name).count() > 1 { - return Err("two graphics cards have the same name"); + let mut named = adapters + .iter() + .filter(|adapter| adapter.description == name); + if let Some(first) = named.next() { + let mut rest = named.peekable(); + if rest.peek().is_some() && (first.outputs == 0 || rest.any(|a| a.outputs > 0)) { + return Err("two graphics cards have the same name"); + } } Ok(name) } @@ -538,12 +547,28 @@ mod tests { /// The vendor's own file, as its installer leaves it (shortened). const VENDOR: &str = "\r\n\r\n \r\n 1\r\n \r\n \r\n default\r\n \r\n\t\r\n\t\t60\r\n\t\r\n\t\r\n\t\ttrue\r\n\t\r\n\r\n"; + /// Adapters in DXGI order, as (description, vendor:device, outputs). + fn cards(list: &[(&str, (u32, u32), u32)]) -> Vec { + list.iter() + .enumerate() + .map( + |(index, &(name, (vendor_id, device_id), outputs))| crate::vdd_snapshot::Adapter { + luid: 0x1000 + index as u64, + description: name.into(), + vendor_id, + device_id, + driver: String::new(), + outputs, + }, + ) + .collect() + } + #[test] fn the_main_screens_card_is_pinned_unless_its_name_is_shared() { - let adapters = |names: &[&str]| names.iter().map(|n| n.to_string()).collect::>(); - let hybrid = adapters(&[ - "Intel(R) Iris(R) Xe Graphics", - "NVIDIA GeForce RTX 3050 Ti Laptop GPU", + let hybrid = cards(&[ + ("Intel(R) Iris(R) Xe Graphics", (0x8086, 0x46a6), 1), + ("NVIDIA GeForce RTX 3050 Ti Laptop GPU", (0x10de, 0x25a0), 0), ]); assert_eq!( gpu_to_pin(Some("Intel(R) Iris(R) Xe Graphics"), &hybrid), @@ -551,12 +576,41 @@ mod tests { ); // A 0.3.1 host listed "AMD Radeon 780M Graphics" twice; the driver // would take whichever it enumerates first. - let twins = adapters(&["AMD Radeon 780M Graphics", "AMD Radeon 780M Graphics"]); + let twins = cards(&[ + ("AMD Radeon 780M Graphics", (0x1002, 0x15bf), 0), + ("AMD Radeon 780M Graphics", (0x1002, 0x15bf), 0), + ]); assert!(gpu_to_pin(Some("AMD Radeon 780M Graphics"), &twins).is_err()); assert!(gpu_to_pin(None, &hybrid).is_err()); assert!(gpu_to_pin(Some(""), &hybrid).is_err()); } + /// An Intel HD 630 + GTX 1050 laptop on 0.3.2 listed its Intel card + /// twice with the virtual display off. Only the first drives the panel, + /// and the driver takes the first match, so the name is pinned. + #[test] + fn a_card_listed_twice_is_pinned_when_only_the_first_drives_a_screen() { + const INTEL: &str = "Intel(R) HD Graphics 630"; + let listed = |outputs: [u32; 2]| { + cards(&[ + (INTEL, (0x8086, 0x591b), outputs[0]), + ("NVIDIA GeForce GTX 1050", (0x10de, 0x1c8d), 0), + (INTEL, (0x8086, 0x591b), outputs[1]), + ]) + }; + assert_eq!(gpu_to_pin(Some(INTEL), &listed([1, 0])), Ok(INTEL)); + // The driver would find the one without screens first. + assert!(gpu_to_pin(Some(INTEL), &listed([0, 1])).is_err()); + // Both drive screens, or neither is known to: no telling which. + assert!(gpu_to_pin(Some(INTEL), &listed([1, 1])).is_err()); + assert!(gpu_to_pin(Some(INTEL), &listed([0, 0])).is_err()); + // The duplicate does not stop a pin to the other card. + assert_eq!( + gpu_to_pin(Some("NVIDIA GeForce GTX 1050"), &listed([1, 0])), + Ok("NVIDIA GeForce GTX 1050") + ); + } + #[test] fn with_matching_off_only_the_gpu_name_changes() { let pinned = with_gpu(VENDOR, "Intel(R) Iris(R) Xe Graphics").unwrap(); diff --git a/host/src/vdd_snapshot.rs b/host/src/vdd_snapshot.rs index 741cd899..9147936b 100644 --- a/host/src/vdd_snapshot.rs +++ b/host/src/vdd_snapshot.rs @@ -48,11 +48,11 @@ impl Target { } } -/// The adapter names the driver can be pinned to, without the virtual -/// display's own adapter. While the virtual display is on, DXGI lists that -/// adapter under the name of the card rendering it, which would otherwise -/// read as two cards with one name. -pub fn render_adapter_names(adapters: &[Adapter], targets: &[Target]) -> Vec { +/// The adapters the driver can be pinned to, in DXGI order, without the +/// virtual display's own adapter. While the virtual display is on, DXGI +/// lists that adapter under the name of the card rendering it, which would +/// otherwise read as two cards with one name. +pub fn render_adapters(adapters: &[Adapter], targets: &[Target]) -> Vec { let vdd: Vec = targets .iter() .filter(|t| t.is_vdd()) @@ -61,7 +61,7 @@ pub fn render_adapter_names(adapters: &[Adapter], targets: &[Target]) -> Vec = cards.iter().map(|a| a.description.as_str()).collect(); assert_eq!( names, ["NVIDIA GeForce RTX 5080", "AMD Radeon(TM) Graphics"] ); assert_eq!( - crate::vdd_settings::gpu_to_pin(Some("NVIDIA GeForce RTX 5080"), &names), + crate::vdd_settings::gpu_to_pin(Some("NVIDIA GeForce RTX 5080"), &cards), Ok("NVIDIA GeForce RTX 5080") ); - // Two real cards with one name still are not pinned. - let twins = render_adapter_names(&adapters, &[monitor]); + // Two cards with one name, neither known to drive a screen, still + // are not pinned. + let twins = render_adapters(&adapters, &[monitor]); assert!(crate::vdd_settings::gpu_to_pin(Some("NVIDIA GeForce RTX 5080"), &twins).is_err()); } From cb426fd2ccb9550d7bdf5d279ebe89550b2eedac Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Mon, 26 Oct 2026 19:06:28 -0400 Subject: [PATCH 28/39] Log the failure snapshot when the iPad ends an attach that never came --- host/src/capture/mod.rs | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/host/src/capture/mod.rs b/host/src/capture/mod.rs index d2c5889d..5b26b6e4 100644 --- a/host/src/capture/mod.rs +++ b/host/src/capture/mod.rs @@ -512,6 +512,21 @@ fn log_failure_snapshot(device: crate::vdd::EnableReport, pin: &Result bool { + waited >= CANCELLED_SNAPSHOT_AFTER && !logged.swap(true, std::sync::atomic::Ordering::Relaxed) +} + /// Debug builds only, to check the failure path on a PC whose virtual /// display works: `ETERNAL_SIMULATE_ATTACH_TIMEOUT=1` treats it as not /// attached after the normal wait. @@ -675,8 +690,20 @@ pub(crate) fn reconcile_virtual_display( startup_heartbeat(shared, crate::clock::host_now_us() / 1000); Some(enumerate_outputs()) }; + let waiting_since = Instant::now(); let mut result = wait_for_virtual_output(&before, timeout, poll, Instant::now, std::thread::sleep); + static CANCELLED_SNAPSHOT: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + if matches!(result, AttachResult::Cancelled) + && snapshot_cancelled_attach(waiting_since.elapsed(), &CANCELLED_SNAPSHOT) + { + info!( + waited_ms = waiting_since.elapsed().as_millis() as u64, + "Virtual display had not attached when the attempt ended" + ); + log_failure_snapshot(Ok(crate::vdd_device::query()), &pin); + } if simulated_attach_timeout() && matches!(result, AttachResult::Attached(_)) { result = AttachResult::TimedOut; } @@ -1100,6 +1127,20 @@ mod tests { assert!(matches!(result, AttachResult::Cancelled)); } + /// The iPad left 5 s into the wait on a PC whose display never came, as + /// it did every 11 s on a 0.3.2 hybrid laptop: one snapshot, not one per + /// retry, and none for a restart right after the Enable. + #[test] + fn a_cancelled_attach_logs_one_snapshot_per_launch() { + let logged = std::sync::atomic::AtomicBool::new(false); + assert!(!snapshot_cancelled_attach( + Duration::from_millis(300), + &logged + )); + assert!(snapshot_cancelled_attach(Duration::from_secs(5), &logged)); + assert!(!snapshot_cancelled_attach(Duration::from_secs(5), &logged)); + } + fn out(name: &str, left: i32, top: i32) -> OutputInfo { OutputInfo { adapter_index: 0, From 1c661ae3f5a82ce378c1d660502b3bda875e6a15 Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Mon, 26 Oct 2026 19:08:24 -0400 Subject: [PATCH 29/39] Check that a settings file 0.3.2 wrote gets the card name --- host/src/vdd_settings.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/host/src/vdd_settings.rs b/host/src/vdd_settings.rs index 336b35bb..45408441 100644 --- a/host/src/vdd_settings.rs +++ b/host/src/vdd_settings.rs @@ -630,6 +630,24 @@ mod tests { assert_eq!(with_gpu("", "x"), None); } + /// 0.3.2 rewrote the file on every connect without a `` element. + /// After an upgrade, with matching off, only the card is added. + #[test] + fn a_file_written_by_0_3_2_gets_the_card_and_keeps_its_modes() { + let v032 = "\n\n \n 1\n \n \n 60\n \n \n \n 2160\n 1620\n 60\n \n \n\n"; + assert_eq!( + with_gpu(v032, "Intel(R) HD Graphics 630").as_deref(), + Some( + v032.replacen( + "\n", + "\n \n Intel(R) HD Graphics 630\n \n", + 1 + ) + .as_str() + ) + ); + } + #[test] fn the_card_goes_inside_a_compact_root() { // Sol's case: no newline inside the root, one after it. From 4b440973c2eff491ef97ea6978cf64897290e108 Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Mon, 26 Oct 2026 19:45:24 -0400 Subject: [PATCH 30/39] Check a started virtual display in two PnP calls and wait at most 4 s for its monitor --- installer/scripts/vdd-toggle.ps1 | 19 +++++++++----- scripts/win/Test-VddToggle.ps1 | 45 +++++++++++++++++++++----------- 2 files changed, 42 insertions(+), 22 deletions(-) diff --git a/installer/scripts/vdd-toggle.ps1 b/installer/scripts/vdd-toggle.ps1 index 9abc3e89..f3e9b2d5 100644 --- a/installer/scripts/vdd-toggle.ps1 +++ b/installer/scripts/vdd-toggle.ps1 @@ -17,8 +17,9 @@ param( [Parameter(Mandatory = $true)] [ValidateSet('enable', 'disable')] [string]$Action, - # How long an enabled device may take to start and add its monitor. - [int]$StartSeconds = 8 + # How long an enabled device may take to start and add its monitor. Where the display works, + # the monitor is there when Enable-PnpDevice returns. + [int]$StartSeconds = 4 ) $ErrorActionPreference = 'Stop' @@ -34,17 +35,21 @@ if ($devices.Count -eq 0) { exit 2 } -# Why an enabled device cannot show a display yet, or $null when it can. +# Why an enabled device cannot show a display yet, or $null when it can. Each PnP call takes about +# half a second while the device starts, so the check makes two: the device's properties, then the +# device and its monitors together. function Get-EnableProblem($instance) { - $inf = (Get-PnpDeviceProperty -InstanceId $instance -KeyName 'DEVPKEY_Device_DriverInfPath').Data + $properties = @(Get-PnpDeviceProperty -InstanceId $instance -KeyName 'DEVPKEY_Device_DriverInfPath', 'DEVPKEY_Device_Children') + $inf = ($properties | Where-Object KeyName -eq 'DEVPKEY_Device_DriverInfPath').Data if ($inf -notmatch '^oem[0-9]+\.inf$') { return @{Code=3; Text="$instance has no driver bound"} } - $device = Get-PnpDevice -InstanceId $instance + $children = @(($properties | Where-Object KeyName -eq 'DEVPKEY_Device_Children').Data | Where-Object { $_ }) + $found = @(Get-PnpDevice -InstanceId (@($instance) + $children)) + $device = $found | Where-Object InstanceId -eq $instance if ([int]$device.Problem -ne 0) { return @{Code=100 + [int]$device.Problem; Text="$instance reports problem code $([int]$device.Problem)"} } if ($device.Status -ne 'OK') { return @{Code=4; Text="$instance has not started"} } - $children = @((Get-PnpDeviceProperty -InstanceId $instance -KeyName 'DEVPKEY_Device_Children').Data) - $monitor = $children | Where-Object { $_ } | Where-Object { (Get-PnpDevice -InstanceId $_).Status -eq 'OK' } + $monitor = $found | Where-Object { $_.InstanceId -ne $instance -and $_.Status -eq 'OK' } if (!$monitor) { return @{Code=5; Text="$instance started, but no monitor arrived"} } return $null } diff --git a/scripts/win/Test-VddToggle.ps1 b/scripts/win/Test-VddToggle.ps1 index 5f227df5..626fd7f1 100644 --- a/scripts/win/Test-VddToggle.ps1 +++ b/scripts/win/Test-VddToggle.ps1 @@ -13,33 +13,39 @@ function Reset-State { [pscustomobject]@{InstanceId='DISPLAY\MTT1337\1&2&3&UID256';HardwareIds=@('MONITOR\MTT1337');Problem=24;Status='Unknown';Inf='monitor.inf';Children=@()} ) } } +function Find-Device($InstanceId) { $global:EMVddTestState.Devices | Where-Object InstanceId -eq $InstanceId } +# Like the real cmdlets, each takes several instance IDs or keys at once. function Get-PnpDevice { - param($Class, [switch]$PresentOnly, $InstanceId) + param($Class, [switch]$PresentOnly, [string[]]$InstanceId) + $global:EMVddTestState.Calls += 'get' $global:EMVddTestState.Devices | Where-Object { - ($InstanceId -and $_.InstanceId -eq $InstanceId) -or (!$InstanceId -and $_.InstanceId -notlike 'DISPLAY\*') + ($InstanceId -and $InstanceId -contains $_.InstanceId) -or (!$InstanceId -and $_.InstanceId -notlike 'DISPLAY\*') } } function Get-PnpDeviceProperty { - param($InstanceId, $KeyName) - $device = Get-PnpDevice -InstanceId $InstanceId - $data = switch ($KeyName) { - 'DEVPKEY_Device_HardwareIds' { $device.HardwareIds } - 'DEVPKEY_Device_DriverInfPath' { $device.Inf } - 'DEVPKEY_Device_Children' { $device.Children } - default { throw "Unexpected property $KeyName" } + param($InstanceId, [string[]]$KeyName) + $global:EMVddTestState.Calls += 'property' + $device = Find-Device $InstanceId + foreach ($key in $KeyName) { + $data = switch ($key) { + 'DEVPKEY_Device_HardwareIds' { $device.HardwareIds } + 'DEVPKEY_Device_DriverInfPath' { $device.Inf } + 'DEVPKEY_Device_Children' { $device.Children } + default { throw "Unexpected property $key" } + } + [pscustomobject]@{KeyName=$key; Data=$data} } - [pscustomobject]@{Data=$data} } function Enable-PnpDevice { param($InstanceId, $Confirm) if ($global:EMVddTestState.Fail) { throw 'mock driver failure' } $global:EMVddTestState.Calls += "enable:$InstanceId" if ($global:EMVddTestState.Ignore) { return } - $device = Get-PnpDevice -InstanceId $InstanceId + $device = Find-Device $InstanceId $enabled = $global:EMVddTestState.Enabled $device.Problem = $enabled.Problem $device.Status = $enabled.Status - $monitor = Get-PnpDevice -InstanceId 'DISPLAY\MTT1337\1&2&3&UID256' + $monitor = Find-Device 'DISPLAY\MTT1337\1&2&3&UID256' if ($enabled.Monitor) { $device.Children = @($monitor.InstanceId) $monitor.Status = $enabled.Monitor @@ -48,7 +54,7 @@ function Enable-PnpDevice { function Disable-PnpDevice { param($InstanceId, $Confirm) $global:EMVddTestState.Calls += "disable:$InstanceId" - $device = Get-PnpDevice -InstanceId $InstanceId + $device = Find-Device $InstanceId $device.Problem = 22 $device.Status = 'Error' $device.Children = @() @@ -71,12 +77,21 @@ try { & $toggle -Action enable | Out-Null if ($LASTEXITCODE -ne 0) { throw "A disabled device was not re-enabled: exit $LASTEXITCODE" } & $toggle -Action disable | Out-Null - if (($global:EMVddTestState.Calls -join ',') -ne 'enable:ROOT\DISPLAY\0007,disable:ROOT\DISPLAY\0007') { + if ((($global:EMVddTestState.Calls | Where-Object { $_ -like '*:*' }) -join ',') -ne 'enable:ROOT\DISPLAY\0007,disable:ROOT\DISPLAY\0007') { throw 'The toggle selected another GPU or a different virtual driver' } $vdd = Get-PnpDevice -InstanceId 'ROOT\DISPLAY\0007' if ($vdd.Problem -ne 22) { throw 'Disable did not leave code 22' } + # Each PnP call costs about half a second while the device starts: a ready device is checked + # with two after Enable-PnpDevice. + Reset-State + & $toggle -Action enable | Out-Null + $calls = $global:EMVddTestState.Calls + $after = $calls[([array]::IndexOf($calls, 'enable:ROOT\DISPLAY\0007') + 1)..($calls.Count - 1)] + if (($after -join ',') -ne 'property,get') { throw "A ready device took these calls to check: $($after -join ',')" } + & $toggle -Action disable | Out-Null + $global:EMVddTestState.Fail = $true Expect-Failure { & $toggle -Action enable } 'mock driver failure' $global:EMVddTestState.Fail = $false @@ -123,7 +138,7 @@ try { $global:LASTEXITCODE = 0 & $toggle -Action disable | Out-Null if ($LASTEXITCODE -ne 0) { throw 'Disabling an absent device failed' } - Write-Output 'VDD toggle checks passed: selection, re-enable after code 22, still disabled, problem codes, not started, no monitor, late monitor, unbound driver, absent device' + Write-Output 'VDD toggle checks passed: selection, re-enable after code 22, two calls per check, still disabled, problem codes, not started, no monitor, late monitor, unbound driver, absent device' # The failure cases leave their exit codes behind; CI ends with this one. $global:LASTEXITCODE = 0 } finally { From f70b9b37f44549906f62fd2793ea589a7e7bf2e6 Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Mon, 26 Oct 2026 20:16:44 -0400 Subject: [PATCH 31/39] Let the host's attach wait decide when the virtual display's monitor is late --- installer/scripts/vdd-toggle.ps1 | 24 ++++++++++-------------- scripts/win/Test-VddToggle.ps1 | 28 ++++++++++++++-------------- 2 files changed, 24 insertions(+), 28 deletions(-) diff --git a/installer/scripts/vdd-toggle.ps1 b/installer/scripts/vdd-toggle.ps1 index f3e9b2d5..52fc1e6a 100644 --- a/installer/scripts/vdd-toggle.ps1 +++ b/installer/scripts/vdd-toggle.ps1 @@ -8,18 +8,15 @@ # VirtualDrivers/Virtual-Display-Driver enumerates under ROOT\DISPLAY and reports a friendly # name containing "Virtual Display", so we match on either — a real monitor is never ROOT\. # -# Enable succeeds only when the display can appear: the device has its driver, no problem code, -# is started, and its monitor has arrived. Otherwise the exit code says why, and the host reads -# it as the task's result (host/src/vdd_device.rs): -# 2 no device 3 no driver bound 4 not started 5 no monitor arrived -# 100 + N Windows problem code N (122: still disabled) +# Enable fails when the display cannot appear: no device, no driver bound, or a Windows problem +# code. The exit code says why, and the host reads it as the task's result (host/src/vdd_device.rs): +# 2 no device 3 no driver bound 100 + N Windows problem code N (122: still disabled) +# A device that has not started yet or has no monitor yet still succeeds: its monitor can arrive +# later, and the host waits for the display itself and reads the device's state if it never comes. param( [Parameter(Mandatory = $true)] [ValidateSet('enable', 'disable')] - [string]$Action, - # How long an enabled device may take to start and add its monitor. Where the display works, - # the monitor is there when Enable-PnpDevice returns. - [int]$StartSeconds = 4 + [string]$Action ) $ErrorActionPreference = 'Stop' @@ -60,11 +57,10 @@ foreach ($device in $devices) { if ($Action -eq 'enable') { # A device the Disable task turned off reports code 22 until this runs. Enable-PnpDevice -InstanceId $device.InstanceId -Confirm:$false - $deadline = (Get-Date).AddSeconds($StartSeconds) - while (($problem = Get-EnableProblem $device.InstanceId) -and (Get-Date) -lt $deadline) { - Start-Sleep -Milliseconds 200 - } - if ($problem) { + $problem = Get-EnableProblem $device.InstanceId + if ($problem -and $problem.Code -in 4, 5) { + Write-Output "Virtual display enabled; $($problem.Text) yet" + } elseif ($problem) { [Console]::Error.WriteLine("Virtual display enable failed: $($problem.Text)") exit $problem.Code } diff --git a/scripts/win/Test-VddToggle.ps1 b/scripts/win/Test-VddToggle.ps1 index 626fd7f1..0a67ee66 100644 --- a/scripts/win/Test-VddToggle.ps1 +++ b/scripts/win/Test-VddToggle.ps1 @@ -67,7 +67,7 @@ function Expect-Failure([scriptblock]$Run, [string]$Message) { # Runs one enable with a short start wait, and checks its exit code. function Expect-Enable([int]$Code, [string]$Case) { $global:LASTEXITCODE = 0 - & $toggle -Action enable -StartSeconds 0 2>$null | Out-Null + & $toggle -Action enable 2>$null | Out-Null if ($LASTEXITCODE -ne $Code) { throw "$Case`: expected exit $Code, got $LASTEXITCODE" } } try { @@ -109,36 +109,36 @@ try { Expect-Enable (100 + $code) "problem $code" } + # Not started or no monitor yet: the monitor can still arrive, so Enable succeeds and the + # host's own wait decides. 0.3.2 works on PCs whose monitor arrives late. Reset-State $global:EMVddTestState.Enabled = @{Problem=0; Status='Unknown'; Monitor=$null} - Expect-Enable 4 'not started' + Expect-Enable 0 'not started yet' Reset-State $global:EMVddTestState.Enabled = @{Problem=0; Status='OK'; Monitor=$null} - Expect-Enable 5 'no monitor' + Expect-Enable 0 'no monitor yet' $global:EMVddTestState.Enabled = @{Problem=0; Status='OK'; Monitor='Error'} - Expect-Enable 5 'monitor with a problem' + Expect-Enable 0 'monitor not ready yet' + if ((& $toggle -Action enable) -notmatch 'no monitor arrived yet') { throw 'A device without a monitor yet was not reported' } + + # No waiting: one check after Enable-PnpDevice, whatever it finds. + $global:EMVddTestState.Calls = @() + function Start-Sleep { throw 'Enable waited' } + & $toggle -Action enable | Out-Null + Remove-Item function:Start-Sleep Reset-State (Get-PnpDevice -InstanceId 'ROOT\DISPLAY\0007').Inf = $null Expect-Enable 3 'no driver bound' - # A monitor that arrives a moment after the device starts is waited for. - Reset-State - $global:EMVddTestState.Enabled = @{Problem=0; Status='OK'; Monitor='Unknown'} - function Start-Sleep { param($Milliseconds) (Get-PnpDevice -InstanceId 'DISPLAY\MTT1337\1&2&3&UID256').Status = 'OK' } - $global:LASTEXITCODE = 0 - & $toggle -Action enable -StartSeconds 5 | Out-Null - Remove-Item function:Start-Sleep - if ($LASTEXITCODE -ne 0) { throw "A monitor that arrived during the wait failed: exit $LASTEXITCODE" } - Reset-State $global:EMVddTestState.Devices = @($global:EMVddTestState.Devices[0]) Expect-Enable 2 'absent device' $global:LASTEXITCODE = 0 & $toggle -Action disable | Out-Null if ($LASTEXITCODE -ne 0) { throw 'Disabling an absent device failed' } - Write-Output 'VDD toggle checks passed: selection, re-enable after code 22, two calls per check, still disabled, problem codes, not started, no monitor, late monitor, unbound driver, absent device' + Write-Output 'VDD toggle checks passed: selection, re-enable after code 22, two calls per check, still disabled, problem codes, not started or no monitor yet succeed, no waiting, unbound driver, absent device' # The failure cases leave their exit codes behind; CI ends with this one. $global:LASTEXITCODE = 0 } finally { From 392e4c48030f730368a5eada4ff2fedf858def30 Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Mon, 26 Oct 2026 20:16:44 -0400 Subject: [PATCH 32/39] Change the driver's card name only when this host may, and log the card it renders on --- RELEASE_NOTES.md | 6 +- host/src/capture/mod.rs | 92 ++++++++---- host/src/vdd_settings.rs | 302 ++++++++++++++++++++++++++++++++++++++- host/src/vdd_snapshot.rs | 123 +++++++++++++++- 4 files changed, 486 insertions(+), 37 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 90ecd864..ded64510 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -10,7 +10,7 @@ Not released yet. off it, the page says to choose Extend with Windows key + P or in Display settings, and the next try waits 30 seconds for it. EternalMonitor does not change the display layout itself. The display task no longer reports success - when the display cannot appear. After any failed attempt the support + when the driver is missing or stopped with an error. After any failed attempt the support diagnostics and **Copy logs** hold a display snapshot: the device state, the graphics cards and the display paths Windows reports. The snapshot leaves out the PC's name and the user's name; the rest of the log still contains them. @@ -21,7 +21,9 @@ Not released yet. graphics drives no screen; in 0.3.1 diagnostics none of those laptops got the second screen. A card Windows lists twice under one name, as on some laptops, is still named when only its first listing drives a screen; when - two cards with one name could, the driver still chooses. + two cards with one name could, the driver still chooses. When another app + installed the driver and the user or that app named a card, EternalMonitor + leaves that name alone. The log records which card the driver used. - The second screen comes up at the iPad's resolution. Windows restores the size it last saved for the second screen, and in 0.3.1 diagnostics 4 of the 5 PCs whose second screen appeared got a tiny, blurry 800×600 one. EternalMonitor diff --git a/host/src/capture/mod.rs b/host/src/capture/mod.rs index 5b26b6e4..c5d52e06 100644 --- a/host/src/capture/mod.rs +++ b/host/src/capture/mod.rs @@ -348,6 +348,11 @@ const VDD_EXTEND_TIMEOUT: Duration = Duration::from_secs(30); #[cfg(windows)] const VDD_SETTINGS_PATH: &str = r"C:\VirtualDisplayDriver\vdd_settings.xml"; +/// In the app data folder: the graphics card this host last named in the +/// driver's settings file. +#[cfg(windows)] +const VDD_PIN_RECORD: &str = "vdd-pin.txt"; + /// What the host asked the driver for before an enable: the graphics card /// (or why none), and the mode the second screen should come up in (none /// when the user keeps the driver's modes). @@ -355,6 +360,8 @@ const VDD_SETTINGS_PATH: &str = r"C:\VirtualDisplayDriver\vdd_settings.xml"; struct VddRequest { pin: Result, mode: Option, + /// The adapters' LUIDs before the Enable, to find the driver's own. + before: Vec, } /// Before each enable: name the main screen's graphics card for the driver @@ -370,19 +377,19 @@ fn prepare_vdd_settings(shared: &SharedControl, outputs: &[OutputInfo]) -> VddRe .iter() .find(|output| output.is_primary) .map(|output| output.adapter_name.as_str()); + let adapters = crate::vdd_snapshot::adapters(); + let before = adapters.iter().map(|adapter| adapter.luid).collect(); // The virtual display's own adapter, listed while it is on, is not a card. - let cards = crate::vdd_snapshot::render_adapters( - &crate::vdd_snapshot::adapters(), - &crate::vdd_snapshot::targets(), - ); - let pin = crate::vdd_settings::gpu_to_pin(main_screen, &cards); - if let Err(reason) = pin { + let cards = crate::vdd_snapshot::render_adapters(&adapters, &crate::vdd_snapshot::targets()); + let wanted = crate::vdd_settings::gpu_to_pin(main_screen, &cards); + if let Err(reason) = wanted { info!( reason, main_screen, "Leaving the virtual display's graphics card to the driver" ); } - let gpu = pin.ok(); + let gpu = wanted.ok(); + let record = crate::settings::app_data_dir().map(|dir| dir.join(VDD_PIN_RECORD)); let matching = crate::settings::SettingsFile::load().vdd_match_resolution; let path = std::path::Path::new(VDD_SETTINGS_PATH); let mut mode = None; @@ -403,6 +410,11 @@ fn prepare_vdd_settings(shared: &SharedControl, outputs: &[OutputInfo]) -> VddRe "Wrote virtual display mode list for the connected client" ); mode = modes.first().copied(); + if let Some(Err(error)) = + record.map(|record| crate::vdd_settings::remember_pin(&record, gpu)) + { + warn!(%error, "Could not record the graphics card named for the virtual display"); + } } Err(error) => { warn!( @@ -412,29 +424,50 @@ fn prepare_vdd_settings(shared: &SharedControl, outputs: &[OutputInfo]) -> VddRe return VddRequest { pin: Err("the settings file could not be written"), mode, + before, }; } } - } else if let Some(gpu) = gpu { - // The modes in the file are the user's: change only the card. - match crate::vdd_settings::pin_gpu_in(path, gpu) { - Ok(true) => info!( - gpu, - "Pointed the virtual display at the main screen's graphics card" - ), - Ok(false) => {} - Err(error) => { - warn!(%error, gpu, "Left vdd_settings.xml as it is; the driver chooses the graphics card"); - return VddRequest { - pin: Err("the settings file was left as it is"), - mode, - }; - } + return VddRequest { + pin: wanted.map(str::to_string), + mode, + before, + }; + } + // The modes in the file are the user's: change only the card, and only + // a name this host may replace. + let owned = std::env::current_exe() + .ok() + .and_then(|exe| exe.parent().map(crate::vdd_settings::driver_owned)) + .unwrap_or(false); + let applied = match &record { + Some(record) => crate::vdd_settings::apply_pin(path, record, owned, gpu), + None => Err(std::io::Error::other("no app data folder")), + }; + match &applied { + Ok(crate::vdd_settings::PinOutcome::Named) => info!( + gpu, + "Pointed the virtual display at the main screen's graphics card" + ), + Ok(crate::vdd_settings::PinOutcome::Reset) => { + info!("Took back the graphics card this host named for the virtual display") + } + Ok(crate::vdd_settings::PinOutcome::Kept(reason)) => { + info!( + reason, + gpu, "Left the graphics card named in vdd_settings.xml" + ) + } + Ok(crate::vdd_settings::PinOutcome::Unchanged) => {} + Err(error) if gpu.is_some() => { + warn!(%error, gpu, "Left vdd_settings.xml as it is; the driver chooses the graphics card") } + Err(_) => {} } VddRequest { - pin: pin.map(str::to_string), + pin: crate::vdd_settings::pin_result(wanted, &applied), mode, + before, } } @@ -504,8 +537,8 @@ fn settle_vdd_mode( /// Log how Windows sees the displays, before the Disable task removes the /// virtual one. One record per failure; see `vdd_snapshot`. #[cfg(windows)] -fn log_failure_snapshot(device: crate::vdd::EnableReport, pin: &Result) { - let snapshot = crate::vdd_snapshot::collect(device, pin.clone()); +fn log_failure_snapshot(device: crate::vdd::EnableReport, request: &VddRequest) { + let snapshot = crate::vdd_snapshot::collect(device, request.pin.clone(), &request.before); warn!( "Virtual display failure snapshot {}", crate::vdd_snapshot::line(&snapshot) @@ -649,7 +682,6 @@ pub(crate) fn reconcile_virtual_display( *shared.vdd_output.lock() = None; let request = prepare_vdd_settings(shared, &outputs); - let pin = request.pin; let before: Vec = outputs.into_iter().map(|o| o.device_name).collect(); if !capture_is_current(shared, generation) { @@ -675,7 +707,7 @@ pub(crate) fn reconcile_virtual_display( } // Every failed Enable, with or without a device state, and // before the Disable below. - log_failure_snapshot(report, &pin); + log_failure_snapshot(report, &request); // The task enabled a device that cannot show a display. if report.is_ok_and(|device| device != DeviceState::Missing) { crate::vdd::set_enabled(false, progress); @@ -702,7 +734,7 @@ pub(crate) fn reconcile_virtual_display( waited_ms = waiting_since.elapsed().as_millis() as u64, "Virtual display had not attached when the attempt ended" ); - log_failure_snapshot(Ok(crate::vdd_device::query()), &pin); + log_failure_snapshot(Ok(crate::vdd_device::query()), &request); } if simulated_attach_timeout() && matches!(result, AttachResult::Attached(_)) { result = AttachResult::TimedOut; @@ -719,10 +751,12 @@ pub(crate) fn reconcile_virtual_display( let chosen = settle_vdd_mode(chosen, request.mode, || { startup_heartbeat(shared, crate::clock::host_now_us() / 1000) }); + let render = crate::vdd_snapshot::render_card_now(&request.before); info!( device = %chosen.device_name, width = chosen.width, height = chosen.height, + render = render.as_deref().unwrap_or("unknown"), "Virtual display attached — capturing it" ); *shared.vdd_output.lock() = Some(chosen.device_name.clone()); @@ -734,7 +768,7 @@ pub(crate) fn reconcile_virtual_display( warn!("Virtual display did not attach in time — capturing the primary display instead"); if let Some(device) = device { report_device(shared, device); - log_failure_snapshot(Ok(device), &pin); + log_failure_snapshot(Ok(device), &request); } // enable() succeeded but nothing attached: turn it back off so we don't strand a // half-enabled device as a phantom monitor. diff --git a/host/src/vdd_settings.rs b/host/src/vdd_settings.rs index 45408441..cb0da01f 100644 --- a/host/src/vdd_settings.rs +++ b/host/src/vdd_settings.rs @@ -158,7 +158,7 @@ pub fn gpu_to_pin<'a>( .ok_or("no adapter drives the main screen")?; let mut named = adapters .iter() - .filter(|adapter| adapter.description == name); + .filter(|adapter| same_card(&adapter.description, name)); if let Some(first) = named.next() { let mut rest = named.peekable(); if rest.peek().is_some() && (first.outputs == 0 || rest.any(|a| a.outputs > 0)) { @@ -399,6 +399,154 @@ pub fn pin_gpu_in(path: &Path, gpu: &str) -> io::Result { Ok(true) } +/// Whether two graphics card names are one card to the driver, which +/// compares them ignoring case. +pub fn same_card(a: &str, b: &str) -> bool { + a.to_lowercase() == b.to_lowercase() +} + +/// A name that leaves the card to the driver: none, empty or `default`. +fn driver_chooses(name: &str) -> bool { + let name = name.trim(); + name.is_empty() || name.eq_ignore_ascii_case("default") +} + +/// The graphics card the document names, `None` when it leaves the card to +/// the driver. `Err` when the document is not one this host edits. +pub fn named_gpu(xml: &str) -> Result, &'static str> { + let body = xml.strip_prefix('\u{feff}').unwrap_or(xml); + match gpu_slot(body)? { + GpuSlot::Name(range) => { + let text = quick_xml::escape::unescape(&body[range]) + .map_err(|_| "the graphics card name is not valid XML text")?; + Ok((!driver_chooses(&text)).then(|| text.trim().to_string())) + } + _ => Ok(None), + } +} + +/// What to do to the settings file's graphics card name. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PinChange { + /// Name this card. + Write(String), + /// Take back this host's earlier name: the driver chooses again. + Reset, + /// Nothing to change. + Leave, + /// Another app or the user named a card; it stays, and the driver + /// renders there. + Refuse(&'static str), +} + +/// Why a card someone else named stays. +pub const NAMED_ELSEWHERE: &str = "the settings file names a card this host did not"; + +/// Whose name the file may hold: this host replaces a name only when it +/// installed the driver (`owned`), when the file leaves the card to the +/// driver, or when the name is the one it wrote last (`ours`). Other apps +/// share this driver and its file. With no card to name (`wanted` is +/// `None`), this host's own earlier name is taken back, so the file never +/// keeps a pin the log says was not made. +pub fn pin_change( + named: Option<&str>, + wanted: Option<&str>, + owned: bool, + ours: Option<&str>, +) -> PinChange { + let is_ours = |name: &str| ours.is_some_and(|ours| same_card(ours, name)); + match (wanted, named) { + (Some(card), Some(name)) if same_card(card, name) => PinChange::Leave, + (Some(_), Some(name)) if !owned && !is_ours(name) => PinChange::Refuse(NAMED_ELSEWHERE), + (Some(card), _) => PinChange::Write(card.to_string()), + (None, Some(name)) if is_ours(name) => PinChange::Reset, + (None, _) => PinChange::Leave, + } +} + +/// What `apply_pin` did. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PinOutcome { + Named, + Unchanged, + Reset, + Kept(&'static str), +} + +/// Whether Setup installed the virtual display driver beside the app in +/// `app_dir`; it records that in this marker (installer/EternalMonitor.iss). +pub fn driver_owned(app_dir: &Path) -> bool { + app_dir + .join("driver") + .join("installed-by-eternalmonitor.txt") + .is_file() +} + +/// Remember the card this host last wrote into the settings file, or that +/// it wrote none. +pub fn remember_pin(record: &Path, gpu: Option<&str>) -> io::Result<()> { + match gpu { + Some(gpu) => { + if let Some(parent) = record.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(record, gpu) + } + None => match std::fs::remove_file(record) { + Err(error) if error.kind() != io::ErrorKind::NotFound => Err(error), + _ => Ok(()), + }, + } +} + +/// Make the settings file at `path` name `wanted`, changing only the card, +/// under the rules of `pin_change`. `record` holds the name this host +/// wrote last. A missing file stays missing, and one that does not parse +/// as expected is left alone; the error says why. +pub fn apply_pin( + path: &Path, + record: &Path, + owned: bool, + wanted: Option<&str>, +) -> io::Result { + let invalid = |reason| io::Error::new(io::ErrorKind::InvalidData, reason); + let current = std::fs::read_to_string(path)?; + let named = named_gpu(¤t).map_err(invalid)?; + let ours = std::fs::read_to_string(record).ok(); + let ours = ours + .as_deref() + .map(str::trim) + .filter(|name| !name.is_empty()); + let (name, outcome) = match pin_change(named.as_deref(), wanted, owned, ours) { + PinChange::Leave => return Ok(PinOutcome::Unchanged), + PinChange::Refuse(reason) => return Ok(PinOutcome::Kept(reason)), + PinChange::Write(card) => (card, PinOutcome::Named), + PinChange::Reset => ("default".to_string(), PinOutcome::Reset), + }; + let updated = gpu_edit(¤t, &name).map_err(invalid)?; + // Recorded first, so a name this host wrote is always one it knows. + remember_pin( + record, + (outcome == PinOutcome::Named).then_some(name.as_str()), + )?; + replace(path, &updated)?; + Ok(outcome) +} + +/// What the snapshot reports as asked of the driver after `apply_pin`: the +/// card the file names for this host, or why it names none. +pub fn pin_result( + wanted: Result<&str, &'static str>, + applied: &io::Result, +) -> Result { + match (wanted, applied) { + (Err(reason), _) => Err(reason), + (Ok(_), Ok(PinOutcome::Kept(reason))) => Err(reason), + (Ok(_), Err(_)) => Err("the settings file was left as it is"), + (Ok(card), Ok(_)) => Ok(card.to_string()), + } +} + /// Write atomically (tmp + rename) so the driver never reads a torn file. /// Creates the directory if the driver hasn't made it yet. pub fn write_to(path: &Path, modes: &[VddMode], gpu: Option<&str>) -> io::Result<()> { @@ -604,6 +752,9 @@ mod tests { // Both drive screens, or neither is known to: no telling which. assert!(gpu_to_pin(Some(INTEL), &listed([1, 1])).is_err()); assert!(gpu_to_pin(Some(INTEL), &listed([0, 0])).is_err()); + // The driver matches names ignoring case, so a listing in another + // case is the same card. + assert!(gpu_to_pin(Some("intel(r) hd graphics 630"), &listed([0, 1])).is_err()); // The duplicate does not stop a pin to the other card. assert_eq!( gpu_to_pin(Some("NVIDIA GeForce GTX 1050"), &listed([1, 0])), @@ -648,6 +799,155 @@ mod tests { ); } + #[test] + fn a_name_is_replaced_only_when_this_host_may() { + use PinChange::*; + const INTEL: &str = "Intel(R) HD Graphics 630"; + const OTHER: &str = "NVIDIA GeForce GTX 1050"; + let write = || Write(INTEL.to_string()); + // The driver chooses: this host may name a card, owned or not. + for owned in [false, true] { + assert_eq!(pin_change(None, Some(INTEL), owned, None), write()); + } + // A card another app or the user named stays on a driver Setup did + // not install, and is replaced on one it did. + assert_eq!( + pin_change(Some(OTHER), Some(INTEL), false, None), + Refuse(NAMED_ELSEWHERE) + ); + assert_eq!( + pin_change(Some(OTHER), Some(INTEL), false, Some(INTEL)), + Refuse(NAMED_ELSEWHERE) + ); + assert_eq!(pin_change(Some(OTHER), Some(INTEL), true, None), write()); + // This host's own earlier name, in any case, is replaced. + assert_eq!( + pin_change( + Some("nvidia geforce gtx 1050"), + Some(INTEL), + false, + Some(OTHER) + ), + write() + ); + // Already named, ignoring case. + assert_eq!( + pin_change(Some("intel(r) hd graphics 630"), Some(INTEL), false, None), + Leave + ); + // No card to name: this host's earlier name is taken back, anyone + // else's stays. + assert_eq!(pin_change(Some(INTEL), None, false, Some(INTEL)), Reset); + assert_eq!(pin_change(Some(INTEL), None, true, None), Leave); + assert_eq!(pin_change(None, None, false, Some(INTEL)), Leave); + } + + #[test] + fn the_named_card_is_read_back_as_the_driver_reads_it() { + let file = |name: &str| VENDOR.replace("default", name); + assert_eq!(named_gpu(VENDOR), Ok(None)); + assert_eq!(named_gpu(&file(" DEFAULT ")), Ok(None)); + assert_eq!(named_gpu(&file("")), Ok(None)); + assert_eq!(named_gpu(&file("A&B")), Ok(Some("A&B".to_string()))); + assert_eq!(named_gpu(""), Ok(None)); + assert!(named_gpu("").is_err()); + } + + #[test] + fn apply_pin_records_its_own_name_and_takes_it_back() { + let dir = std::env::temp_dir().join(format!("vdd-pin-own-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let (path, record) = ( + dir.join("vdd_settings.xml"), + dir.join("app").join("vdd-pin.txt"), + ); + let named = || named_gpu(&std::fs::read_to_string(&path).unwrap()).unwrap(); + const INTEL: &str = "Intel(R) HD Graphics 630"; + + // The vendor's file, on a driver another app installed. + std::fs::write(&path, VENDOR).unwrap(); + assert_eq!( + apply_pin(&path, &record, false, Some(INTEL)).unwrap(), + PinOutcome::Named + ); + assert_eq!(named().as_deref(), Some(INTEL)); + assert_eq!(std::fs::read_to_string(&record).unwrap(), INTEL); + assert_eq!( + apply_pin(&path, &record, false, Some(INTEL)).unwrap(), + PinOutcome::Unchanged + ); + // The pin is refused later: the name this host wrote goes, and so + // does the record. + assert_eq!( + apply_pin(&path, &record, false, None).unwrap(), + PinOutcome::Reset + ); + assert_eq!(named(), None); + assert!(std::fs::read_to_string(&path) + .unwrap() + .contains("default")); + assert!(!record.exists()); + + // A card the user or another app named stays, and is reported. + let theirs = VENDOR.replace("default", "NVIDIA GeForce GTX 1050"); + std::fs::write(&path, &theirs).unwrap(); + let applied = apply_pin(&path, &record, false, Some(INTEL)); + assert_eq!( + applied.as_ref().unwrap(), + &PinOutcome::Kept(NAMED_ELSEWHERE) + ); + assert_eq!(std::fs::read_to_string(&path).unwrap(), theirs); + assert_eq!(pin_result(Ok(INTEL), &applied), Err(NAMED_ELSEWHERE)); + assert_eq!( + apply_pin(&path, &record, false, None).unwrap(), + PinOutcome::Unchanged + ); + assert_eq!(std::fs::read_to_string(&path).unwrap(), theirs); + // Setup installed this driver: the main screen's card wins. + assert_eq!( + apply_pin(&path, &record, true, Some(INTEL)).unwrap(), + PinOutcome::Named + ); + assert_eq!(named().as_deref(), Some(INTEL)); + assert_eq!( + pin_result(Ok(INTEL), &Ok(PinOutcome::Named)), + Ok(INTEL.to_string()) + ); + + // A missing file stays missing. + std::fs::remove_file(&path).unwrap(); + let applied = apply_pin(&path, &record, true, Some(INTEL)); + assert!(applied.is_err() && !path.exists()); + assert_eq!( + pin_result(Ok(INTEL), &applied), + Err("the settings file was left as it is") + ); + assert_eq!( + pin_result( + Err("no adapter drives the main screen"), + &Ok(PinOutcome::Unchanged) + ), + Err("no adapter drives the main screen") + ); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn only_setups_marker_makes_the_driver_this_hosts() { + let dir = std::env::temp_dir().join(format!("vdd-owned-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("driver")).unwrap(); + assert!(!driver_owned(&dir)); + std::fs::write( + dir.join("driver").join("installed-by-eternalmonitor.txt"), + "x", + ) + .unwrap(); + assert!(driver_owned(&dir)); + std::fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn the_card_goes_inside_a_compact_root() { // Sol's case: no newline inside the root, one after it. diff --git a/host/src/vdd_snapshot.rs b/host/src/vdd_snapshot.rs index 9147936b..93a66af1 100644 --- a/host/src/vdd_snapshot.rs +++ b/host/src/vdd_snapshot.rs @@ -65,12 +65,39 @@ pub fn render_adapters(adapters: &[Adapter], targets: &[Target]) -> Vec .collect() } +/// The card the driver renders the virtual display on, as DXGI shows it: +/// the driver's own adapter is listed under that card's name. It is the +/// adapter holding the virtual display's path, or, before a monitor +/// arrives, the one adapter that was not there before the Enable +/// (`before`, by LUID). `None` when neither tells. +pub fn driver_render_card<'a>( + adapters: &'a [Adapter], + targets: &[Target], + before: &[u64], +) -> Option<&'a str> { + let on_path = targets + .iter() + .filter(|t| t.is_vdd()) + .flat_map(|t| std::iter::once(t.adapter).chain(t.source.map(|(adapter, _)| adapter))) + .find_map(|luid| adapters.iter().find(|a| a.luid == luid)); + let new = || { + let mut new = adapters.iter().filter(|a| !before.contains(&a.luid)); + match (new.next(), new.next()) { + (Some(adapter), None) if !before.is_empty() => Some(adapter), + _ => None, + } + }; + on_path.or_else(new).map(|a| a.description.as_str()) +} + #[derive(Debug, Clone)] pub struct Snapshot { /// The device state, or why there is none. pub device: Result, /// The graphics card the host asked the driver for, or why it asked for none. pub pin: Result, + /// The card the driver renders on (`driver_render_card`). + pub render: Option, /// `HwSchMode`: 2 hardware-accelerated GPU scheduling on, 1 off. pub hags: Option, /// Whether the driver reads its settings from `C:\VirtualDisplayDriver`. @@ -101,11 +128,22 @@ pub fn line(snapshot: &Snapshot) -> String { }; match &snapshot.pin { Ok(name) => { - let matched = snapshot.adapters.iter().any(|a| &a.description == name); + let matched = snapshot + .adapters + .iter() + .any(|a| crate::vdd_settings::same_card(&a.description, name)); out += &format!(" pin=\"{}\" pin_matched={matched}", clean(name)); } Err(reason) => out += &format!(" pin=none pin_reason=\"{}\"", clean(reason)), } + match (&snapshot.render, &snapshot.pin) { + (Some(card), Ok(pin)) => { + let same = crate::vdd_settings::same_card(card, pin); + out += &format!(" render=\"{}\" render_is_pin={same}", clean(card)); + } + (Some(card), Err(_)) => out += &format!(" render=\"{}\"", clean(card)), + (None, _) => out += " render=unknown", + } out += match snapshot.hags { Some(2) => " hags=on", Some(1) => " hags=off", @@ -177,17 +215,21 @@ pub fn line(snapshot: &Snapshot) -> String { out } -/// Read the adapters, display paths and settings now. +/// Read the adapters, display paths and settings now. `before` holds the +/// adapters' LUIDs from before the Enable. #[cfg(windows)] pub fn collect( device: Result, pin: Result, + before: &[u64], ) -> Snapshot { let settings_key = r"SOFTWARE\MikeTheTech\VirtualDisplayDriver"; let path = crate::diagnostics::registry_string(settings_key, "VDDPATH"); + let (adapters, targets) = (adapters(), targets()); Snapshot { device, pin, + render: driver_render_card(&adapters, &targets, before).map(str::to_string), hags: crate::diagnostics::registry_dword( r"SYSTEM\CurrentControlSet\Control\GraphicsDrivers", "HwSchMode", @@ -198,11 +240,17 @@ pub fn collect( path.trim_end_matches('\\') .eq_ignore_ascii_case(r"C:\VirtualDisplayDriver") }), - adapters: adapters(), - targets: targets(), + adapters, + targets, } } +/// `driver_render_card` read now. +#[cfg(windows)] +pub fn render_card_now(before: &[u64]) -> Option { + driver_render_card(&adapters(), &targets(), before).map(str::to_string) +} + #[cfg(windows)] pub use windows_impl::{adapters, targets}; @@ -343,6 +391,7 @@ mod tests { Snapshot { device: Ok(DeviceState::NoMonitor), pin: Ok("Intel(R) Iris(R) Xe Graphics".into()), + render: None, hags: Some(2), default_settings_path: Some(true), adapters: vec![ @@ -388,7 +437,7 @@ mod tests { fn the_snapshot_is_one_line_with_each_part() { assert_eq!( line(&snapshot()), - "device=no_monitor pin=\"Intel(R) Iris(R) Xe Graphics\" pin_matched=true hags=on \ + "device=no_monitor pin=\"Intel(R) Iris(R) Xe Graphics\" pin_matched=true render=unknown hags=on \ settings_path=default adapters=[d2c1 \"Intel(R) Iris(R) Xe Graphics\" 8086:46a6 \ driver=32.0.101.7092 outputs=1 | 10000e5f0 \"NVIDIA GeForce RTX 3050 Ti Laptop GPU\" \ 10de:25a0 driver=32.0.16.1742 outputs=0] paths=[active src=d2c1/0 tgt=d2c1/4352 \ @@ -415,6 +464,61 @@ mod tests { let mut unmatched = snapshot(); unmatched.pin = Ok("Intel(R) UHD Graphics".into()); assert!(line(&unmatched).contains("pin_matched=false")); + // The driver compares names ignoring case. + let mut cased = snapshot(); + cased.pin = Ok("intel(r) iris(r) xe graphics".into()); + cased.render = Some("Intel(R) Iris(R) Xe Graphics".into()); + let text = line(&cased); + assert!( + text.contains( + "pin_matched=true render=\"Intel(R) Iris(R) Xe Graphics\" render_is_pin=true" + ), + "{text}" + ); + let mut elsewhere = snapshot(); + elsewhere.render = Some("NVIDIA GeForce RTX 3050 Ti Laptop GPU".into()); + assert!(line(&elsewhere).contains("render_is_pin=false")); + elsewhere.pin = Err("no adapter drives the main screen"); + assert!( + line(&elsewhere).contains(" render=\"NVIDIA GeForce RTX 3050 Ti Laptop GPU\" hags=") + ); + } + + /// The driver's own adapter carries the render card's name: found by + /// the virtual display's path, or as the one adapter new since the + /// Enable when no monitor has arrived. + #[test] + fn the_render_card_is_the_drivers_adapter() { + let card = |luid, name: &str| Adapter { + luid, + description: name.into(), + vendor_id: 0, + device_id: 0, + driver: String::new(), + outputs: 0, + }; + // mainpc with the driver named to its AMD iGPU. + let adapters = [ + card(0x11e00, "NVIDIA GeForce RTX 5080"), + card(0x13c3b, "AMD Radeon(TM) Graphics"), + card(0x66f8f4a0, "AMD Radeon(TM) Graphics"), + ]; + let mut vdd = snapshot().targets[1].clone(); + (vdd.adapter, vdd.source) = (0x66f8f4a0, None); + let before = [0x11e00, 0x13c3b]; + assert_eq!( + driver_render_card(&adapters, &[vdd.clone()], &[]), + Some("AMD Radeon(TM) Graphics") + ); + // No monitor yet, so no path: the adapter that is new. + assert_eq!( + driver_render_card(&adapters, &[], &before), + Some("AMD Radeon(TM) Graphics") + ); + // Nothing new, nothing known before, or more than one new: no telling. + assert_eq!(driver_render_card(&adapters[..2], &[], &before), None); + assert_eq!(driver_render_card(&adapters, &[], &[]), None); + assert_eq!(driver_render_card(&adapters, &[], &[0x11e00]), None); } #[test] @@ -457,6 +561,15 @@ mod tests { assert!(crate::vdd_settings::gpu_to_pin(Some("NVIDIA GeForce RTX 5080"), &twins).is_err()); } + /// Prints the card the driver renders on while the virtual display is + /// on: `cargo test -p eternal-host --lib native_render_card -- --ignored` + #[cfg(windows)] + #[test] + #[ignore = "reads this PC's adapters and display paths"] + fn native_render_card() { + println!("render={:?}", render_card_now(&[])); + } + #[test] fn monitor_paths_and_odd_names_stay_out_and_the_line_is_bounded() { let mut odd = snapshot(); From edccfa591b026f88aca7d149ea16d1c8d381caf7 Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Mon, 26 Oct 2026 20:22:28 -0400 Subject: [PATCH 33/39] Join the Enable output before matching it in the toggle test --- scripts/win/Test-VddToggle.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/win/Test-VddToggle.ps1 b/scripts/win/Test-VddToggle.ps1 index 0a67ee66..c51d2c85 100644 --- a/scripts/win/Test-VddToggle.ps1 +++ b/scripts/win/Test-VddToggle.ps1 @@ -120,7 +120,7 @@ try { Expect-Enable 0 'no monitor yet' $global:EMVddTestState.Enabled = @{Problem=0; Status='OK'; Monitor='Error'} Expect-Enable 0 'monitor not ready yet' - if ((& $toggle -Action enable) -notmatch 'no monitor arrived yet') { throw 'A device without a monitor yet was not reported' } + if (((& $toggle -Action enable) -join ' ') -notmatch 'no monitor arrived yet') { throw 'A device without a monitor yet was not reported' } # No waiting: one check after Enable-PnpDevice, whatever it finds. $global:EMVddTestState.Calls = @() From 732c19ba56a2d7cd4fdb6af735f06ce3102cf3e7 Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Mon, 26 Oct 2026 21:22:45 -0400 Subject: [PATCH 34/39] Name the card, settle the mode and log failure snapshots in the second screen worker The worker now does what the removed attach block did: just before each Enable it names the main screen's graphics card and writes the modes (prepare_vdd_settings), it waits 30 s for Extend after "kept your desktop off it", it brings an attached display to the iPad's mode and logs the card the driver renders on, and it reports the device and logs a display snapshot while the display is still on: when the Enable task fails (turning off a device it enabled that cannot show a display), when no display attaches, when a mode change ends a long start, and when the second screen shows no picture (no_frames, duplication_failed). --- docs/decisions.md | 7 +- host/src/capture/mod.rs | 30 +-- host/src/capture/second_screen.rs | 372 +++++++++++++++++++++++++++--- host/src/transport/mod.rs | 2 +- 4 files changed, 357 insertions(+), 54 deletions(-) diff --git a/docs/decisions.md b/docs/decisions.md index f83f533c..4b8bd45a 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -135,7 +135,12 @@ triggers via `schtasks`, avoiding a per-toggle UAC prompt. once per 10 s on its own. - Before enabling, the host writes `vdd_settings.xml` so the virtual display offers the iPad's native landscape resolution and refresh. There - is an opt-out in Settings. + is an opt-out in Settings. The worker also names the main screen's + graphics card there just before each Enable, the driver's only way to pick + a card, and replaces only a name it may: on a driver Setup installed, an + empty or `default` one, or its own last one. A start that fails, and a + second screen that shows no picture, log a display snapshot while the + display is still on, with the card the driver rendered on. - A first-party signed display driver remains a long-term goal. ## Input relay diff --git a/host/src/capture/mod.rs b/host/src/capture/mod.rs index 6ba1ae2e..18d6f2db 100644 --- a/host/src/capture/mod.rs +++ b/host/src/capture/mod.rs @@ -18,7 +18,7 @@ use crate::control::SharedControl; #[cfg(any(windows, test))] use crate::control::{CaptureTarget, VddStatus}; #[cfg(windows)] -use crate::vdd_device::{DeviceState, VddProblem}; +use crate::vdd_device::DeviceState; #[cfg(windows)] pub mod dxgi; @@ -341,11 +341,6 @@ fn capture_is_current(shared: &SharedControl, generation: u64) -> bool { && crate::supervisor::generation_is_current(generation) } -/// How long the attempt after "kept your desktop off it" waits, while the -/// user picks Extend with Windows key + P or in Settings. -#[cfg(windows)] -const VDD_EXTEND_TIMEOUT: Duration = Duration::from_secs(30); - #[cfg(windows)] const VDD_SETTINGS_PATH: &str = r"C:\VirtualDisplayDriver\vdd_settings.xml"; @@ -536,26 +531,31 @@ fn settle_vdd_mode( } /// Log how Windows sees the displays, before the Disable task removes the -/// virtual one. One record per failure; see `vdd_snapshot`. +/// virtual one. One record per failure; see `vdd_snapshot`. `request` is +/// what the worker asked of the driver before its Enable. #[cfg(windows)] -fn log_failure_snapshot(device: crate::vdd::EnableReport, request: &VddRequest) { - let snapshot = crate::vdd_snapshot::collect(device, request.pin.clone(), &request.before); +fn log_failure_snapshot(device: crate::vdd::EnableReport, request: Option<&VddRequest>) { + let (pin, before) = request.map_or((Err("no settings prepared"), &[][..]), |request| { + (request.pin.clone(), &request.before[..]) + }); + let snapshot = crate::vdd_snapshot::collect(device, pin, before); warn!( "Virtual display failure snapshot {}", crate::vdd_snapshot::line(&snapshot) ); } -/// An attach the iPad or a restart ended after this long without a display -/// is worth a snapshot: on PCs where it works, the display attaches within +/// A start that a mode change ended after this long without a display is +/// worth a snapshot: on PCs where it works, the display attaches within /// milliseconds of the Enable task. #[cfg(any(windows, test))] const CANCELLED_SNAPSHOT_AFTER: Duration = Duration::from_secs(2); /// Whether a cancelled attach that waited `waited` logs a snapshot: once per -/// launch, since the iPad redials and the same attempt repeats. In 0.3.2 the -/// iPad's own 10 s limit ended every attempt on PCs whose display never -/// attached, so no failure snapshot was ever written. +/// launch (`logged`). In 0.3.2 the iPad's own 10 s limit ended every attempt +/// on PCs whose display never attached, so no failure snapshot was ever +/// written. The iPad no longer ends a start, which times out and logs its +/// own snapshot; the user choosing another mode while it waits still does. #[cfg(any(windows, test))] fn snapshot_cancelled_attach(waited: Duration, logged: &std::sync::atomic::AtomicBool) -> bool { waited >= CANCELLED_SNAPSHOT_AFTER && !logged.swap(true, std::sync::atomic::Ordering::Relaxed) @@ -635,7 +635,7 @@ pub(crate) fn reconcile_virtual_display( prove_timeout_ms = second_screen::PROVE_TIMEOUT.as_millis() as u64, "The virtual display showed no picture — mirroring the primary display instead" ); - second_screen::send(shared, second_screen::Command::Release); + second_screen::send(shared, second_screen::Command::Fallback(reason)); return Some(CaptureTarget::PrimaryAuto); } let connected = shared.client_connected(); diff --git a/host/src/capture/second_screen.rs b/host/src/capture/second_screen.rs index 3ee99a94..f264011a 100644 --- a/host/src/capture/second_screen.rs +++ b/host/src/capture/second_screen.rs @@ -31,6 +31,10 @@ use crate::control::{CaptureTarget, SharedControl, VddStatus}; #[cfg(any(windows, test))] use super::{wait_for_virtual_output, AttachResult, OutputInfo}; +#[cfg(any(windows, test))] +use crate::vdd::EnableReport; +#[cfg(any(windows, test))] +use crate::vdd_device::{DeviceState, VddProblem}; /// How long the second screen stays on after its iPad leaves. A USB redial /// comes about 1 s after the tunnel closes, and an iPad returning from the @@ -65,6 +69,12 @@ const WAIT_STEP: Duration = Duration::from_millis(100); #[cfg(any(windows, test))] const STOPPED_WAIT: Duration = Duration::from_secs(6); +/// How long the attempt after "kept your desktop off it" waits, while the +/// user picks Extend with Windows key + P or in Settings. The iPad has the +/// mirror meanwhile, so it stays connected. +#[cfg(any(windows, test))] +pub(crate) const EXTEND_TIMEOUT: Duration = Duration::from_secs(30); + /// How long quitting waits for the worker to exit after the last Enable. const QUIT_JOIN: Duration = Duration::from_secs(5); @@ -316,7 +326,11 @@ pub(crate) enum Command { Attach, /// Turn off the display this host turned on, unless a start claimed it /// again since. + #[cfg_attr(not(any(windows, test)), allow(dead_code))] // Sent by capture's reconcile. Release, + /// The display showed no picture, for this reason: log how Windows sees + /// it while it is still on, then release it. + Fallback(&'static str), } /// The one thread that turns the virtual display on and off, and its queue. @@ -328,6 +342,9 @@ pub(crate) struct Worker { /// [`Queue::quitting`], so no Enable runs or starts once it has. enable: Mutex<()>, thread: Mutex>>, + /// A cancelled start logged its snapshot; see `snapshot_cancelled_attach`. + #[cfg(any(windows, test))] + cancelled_snapshot: std::sync::atomic::AtomicBool, } #[derive(Default)] @@ -391,7 +408,7 @@ fn without_driver(shared: &SharedControl, command: Command) { ); fail_start(shared, "no_driver"); } - Command::Release => *shared.vdd_output.lock() = None, + Command::Release | Command::Fallback(_) => *shared.vdd_output.lock() = None, } } @@ -414,7 +431,10 @@ fn spawn_worker(shared: &SharedControl) -> std::io::Result<()> { .spawn(move || { run( &worker, - &WindowsDriver { shared: &worker }, + &WindowsDriver { + shared: &worker, + request: Default::default(), + }, super::vdd_attach_timeout(), true, ) @@ -481,6 +501,10 @@ pub(crate) fn run( match command { Command::Attach => attach(shared, driver, attach_timeout), Command::Release => release(shared, driver), + Command::Fallback(reason) => { + driver.report(Failure::Unproven(reason)); + release(shared, driver); + } } } } @@ -490,13 +514,47 @@ pub(crate) fn run( #[cfg(any(windows, test))] pub(crate) trait Driver { fn outputs(&self) -> Vec; - /// Prepare the driver's settings for the connected iPad. - fn prepare(&self); + /// Prepare the driver's settings for the connected iPad, just before the + /// Enable: the graphics card to render on and the modes. `outputs` are + /// the displays before it. + fn prepare(&self, outputs: &[OutputInfo]); fn set_enabled(&self, enabled: bool) -> bool; + /// Turn the display on. On failure, what the Enable task found wrong + /// with the device, or why it could not say. + fn enable(&self) -> Result<(), EnableReport> { + if self.set_enabled(true) { + Ok(()) + } else { + Err(Err("display task failed")) + } + } + /// Bring a display that just attached to the mode asked for, and return + /// it as it ends up. + fn settle(&self, chosen: OutputInfo) -> OutputInfo { + chosen + } + /// Say why the display did not appear or showed no picture, while it is + /// still on. + fn report(&self, _failure: Failure) {} fn now(&self) -> Instant; fn sleep(&self, duration: Duration); } +/// Why a second screen failed, for [`Driver::report`]. +#[cfg(any(windows, test))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Failure { + /// The Enable task failed. + Enable(EnableReport), + /// Enabled, but no display attached in time. + AttachTimeout, + /// A mode change ended a start that had waited this long for the + /// display. + Cancelled(Duration), + /// The display attached but showed no picture. + Unproven(&'static str), +} + /// However [`attach`] ends, its claim ends with it: a start that did not /// finish leaves the status for the next tick to claim again. #[cfg(any(windows, test))] @@ -553,26 +611,38 @@ pub(crate) fn attach(shared: &SharedControl, driver: &impl Driver, timeout: Dura driver.sleep(WAIT_STEP); } let outputs = driver.outputs(); - driver.prepare(); + driver.prepare(&outputs); let before: Vec = outputs .into_iter() .map(|output| output.device_name) .collect(); + // After "kept your desktop off it" the user switches Windows to Extend + // while this attempt waits, so give them time to. + let timeout = match shared.vdd_problem.lock().take() { + Some(VddProblem::NotExtended) => timeout.max(EXTEND_TIMEOUT), + _ => timeout, + }; let Some(enabled) = enable(shared, driver) else { info!("Quitting before the virtual display was turned on — leaving it off"); return; }; - if !enabled { + if let Err(report) = enabled { warn!( second_screen_fallback = "enable_failed", "Virtual display could not be enabled (installer task missing?) — \ capturing the primary display instead" ); + driver.report(Failure::Enable(report)); + // The task enabled a device that cannot show a display. + if report.is_ok_and(|device| device != DeviceState::Missing) { + disable(shared, driver); + } let mut status = shared.vdd_status.lock(); *status = VddStatus::TaskFailed; shared.second_screen.lock().failure = Some("enable_failed"); return; } + let waiting_since = driver.now(); let result = wait_for_virtual_output( &before, timeout, @@ -580,6 +650,11 @@ pub(crate) fn attach(shared: &SharedControl, driver: &impl Driver, timeout: Dura || driver.now(), |duration| driver.sleep(duration), ); + #[cfg(windows)] + let result = match result { + AttachResult::Attached(_) if super::simulated_attach_timeout() => AttachResult::TimedOut, + result => result, + }; match result { AttachResult::Cancelled if quitting(shared) => { info!("Quitting while the virtual display started — the quit turns it off"); @@ -589,9 +664,14 @@ pub(crate) fn attach(shared: &SharedControl, driver: &impl Driver, timeout: Dura // records it as ours, so turn it off, after the gap like every // change: the mirror is already streaming. info!("Display mode changed while the virtual display started — turning it off"); + let waited = driver.now().duration_since(waiting_since); + if super::snapshot_cancelled_attach(waited, &shared.vdd_worker.cancelled_snapshot) { + driver.report(Failure::Cancelled(waited)); + } disable(shared, driver); } AttachResult::Attached(chosen) => { + let chosen = driver.settle(chosen); info!( device = %chosen.device_name, width = chosen.width, @@ -606,6 +686,7 @@ pub(crate) fn attach(shared: &SharedControl, driver: &impl Driver, timeout: Dura timeout_ms = timeout.as_millis() as u64, "Virtual display did not attach in time — capturing the primary display instead" ); + driver.report(Failure::AttachTimeout); // Enable succeeded but nothing attached: turn it back off so no // half-enabled device lingers as a phantom monitor. disable(shared, driver); @@ -619,12 +700,12 @@ pub(crate) fn attach(shared: &SharedControl, driver: &impl Driver, timeout: Dura /// The only Enable: never once quitting has begun, and quitting waits for /// one in flight. None when quitting. #[cfg(any(windows, test))] -fn enable(shared: &SharedControl, driver: &impl Driver) -> Option { +fn enable(shared: &SharedControl, driver: &impl Driver) -> Option> { let _enable = shared.vdd_worker.enable.lock(); if quitting(shared) { return None; } - let enabled = driver.set_enabled(true); + let enabled = driver.enable(); shared.second_screen.lock().note_toggle(driver.now()); Some(enabled) } @@ -690,6 +771,8 @@ fn wait_for_toggle_gap( #[cfg(windows)] struct WindowsDriver<'a> { shared: &'a SharedControl, + /// What the last `prepare` asked of the driver, for the snapshots. + request: std::cell::RefCell>, } #[cfg(windows)] @@ -698,38 +781,61 @@ impl Driver for WindowsDriver<'_> { super::enumerate_outputs() } - // Best effort: match the virtual display's mode list to the connected - // iPad's panel BEFORE enabling the driver (it reads vdd_settings.xml at - // enable time). Failure is non-fatal — the driver's built-in modes apply. - fn prepare(&self) { - if !crate::settings::SettingsFile::load().vdd_match_resolution { - return; - } - let Some(info) = self.shared.session.lock().client_info() else { - return; - }; - let modes = crate::vdd_settings::modes_for_client( - info.screen_px.0, - info.screen_px.1, - info.refresh_hz, - ); - let path = std::path::Path::new(r"C:\VirtualDisplayDriver\vdd_settings.xml"); - match crate::vdd_settings::write_to(path, &modes) { - Ok(()) => info!( - ?modes, - "Wrote virtual display mode list for the connected client" - ), - Err(error) => warn!( - %error, - "Could not write vdd_settings.xml — driver default modes apply" - ), - } + fn prepare(&self, outputs: &[OutputInfo]) { + *self.request.borrow_mut() = Some(super::prepare_vdd_settings(self.shared, outputs)); } fn set_enabled(&self, enabled: bool) -> bool { crate::vdd::set_enabled(enabled, || {}) } + fn enable(&self) -> Result<(), EnableReport> { + crate::vdd::enable(|| {}) + } + + fn settle(&self, chosen: OutputInfo) -> OutputInfo { + let request = self.request.borrow(); + let chosen = super::settle_vdd_mode(chosen, request.as_ref().and_then(|r| r.mode), || {}); + let before = request.as_ref().map_or(&[][..], |r| &r.before[..]); + let render = crate::vdd_snapshot::render_card_now(before); + info!( + device = %chosen.device_name, + render = render.as_deref().unwrap_or("unknown"), + "Virtual display renders on this graphics card" + ); + chosen + } + + fn report(&self, failure: Failure) { + let request = self.request.borrow(); + let device = match failure { + Failure::Enable(report) => { + if let Ok(device) = report { + super::report_device(self.shared, device); + } + report + } + Failure::AttachTimeout => { + // A device that is ready with its monitor means Windows left + // the desktop off it. The display layout stays the user's. + let device = crate::vdd_device::query(); + super::report_device(self.shared, device); + Ok(device) + } + Failure::Cancelled(waited) => { + info!( + waited_ms = waited.as_millis() as u64, + "Virtual display had not attached when the attempt ended" + ); + Ok(crate::vdd_device::query()) + } + // It attached, so the device is fine; the snapshot shows the + // card and the paths it got. + Failure::Unproven(_) => Ok(crate::vdd_device::query()), + }; + super::log_failure_snapshot(device, request.as_ref()); + } + fn now(&self) -> Instant { Instant::now() } @@ -807,9 +913,20 @@ mod tests { } } + /// What the worker asked of a [`FakeDriver`], in order. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum Call { + Prepare, + Enable, + Disable, + Settle, + Report(Failure), + } + /// A driver on a paused clock. Enable takes `enable_takes`, and the /// display attaches `attach_after` later, or never. `on_sleep` sees the /// clock each time the host waits, as the transport's ticks would. + /// With `enable_fails`, the Enable task reports that instead. struct FakeDriver<'a> { start: Instant, elapsed: Cell, @@ -818,6 +935,8 @@ mod tests { on_since: Cell>, toggles: RefCell>, on_sleep: &'a dyn Fn(Duration), + enable_fails: Option, + calls: RefCell>, } impl<'a> FakeDriver<'a> { @@ -834,8 +953,14 @@ mod tests { on_since: Cell::new(None), toggles: RefCell::new(Vec::new()), on_sleep, + enable_fails: None, + calls: RefCell::new(Vec::new()), } } + + fn calls(&self) -> Vec { + self.calls.borrow().clone() + } } impl Driver for FakeDriver<'_> { @@ -852,12 +977,40 @@ mod tests { outputs } - fn prepare(&self) {} + fn prepare(&self, outputs: &[OutputInfo]) { + assert!(outputs.iter().any(|output| output.is_primary)); + self.calls.borrow_mut().push(Call::Prepare); + } + + fn enable(&self) -> Result<(), EnableReport> { + match self.enable_fails { + Some(report) => { + self.sleep(self.enable_takes); + self.calls.borrow_mut().push(Call::Enable); + self.on_since.set(None); + Err(report) + } + None if self.set_enabled(true) => Ok(()), + None => Err(Err("display task failed")), + } + } + + fn settle(&self, chosen: OutputInfo) -> OutputInfo { + self.calls.borrow_mut().push(Call::Settle); + chosen + } + + fn report(&self, failure: Failure) { + self.calls.borrow_mut().push(Call::Report(failure)); + } fn set_enabled(&self, enabled: bool) -> bool { if enabled { self.sleep(self.enable_takes); } + self.calls + .borrow_mut() + .push(if enabled { Call::Enable } else { Call::Disable }); self.toggles .borrow_mut() .push((self.elapsed.get(), enabled)); @@ -1253,7 +1406,7 @@ mod tests { fn outputs(&self) -> Vec { vec![output("primary", true)] } - fn prepare(&self) {} + fn prepare(&self, _: &[OutputInfo]) {} fn set_enabled(&self, enabled: bool) -> bool { if enabled { let _ = self.entered.send(()); @@ -1396,6 +1549,11 @@ mod tests { let driver = FakeDriver::new(secs(3.0), Some(Duration::ZERO), &on_sleep); run(&shared, &driver, ATTACH_TIMEOUT, false); assert_eq!(*driver.toggles.borrow(), vec![(Duration::ZERO, false)]); + // The snapshot is taken while the display is still on. + assert_eq!( + driver.calls(), + [Call::Report(Failure::Unproven(reason)), Call::Disable] + ); assert_eq!(next_step(&shared, Instant::now()), Step::Nothing); } } @@ -1459,7 +1617,7 @@ mod tests { fn outputs(&self) -> Vec { vec![output("primary", true)] } - fn prepare(&self) { + fn prepare(&self, _: &[OutputInfo]) { if self.hold_prepare { let _ = self.entered.send("prepare"); let _ = self.go.recv(); @@ -1635,4 +1793,144 @@ mod tests { assert_eq!(*shared.vdd_status.lock(), VddStatus::Active); assert_eq!(next_step(&shared, driver.now()), Step::Switch); } + + /// PR #72's attach work, in the worker: the card is named just before + /// the Enable, a display that attaches is brought to the iPad's mode, + /// and one that never does is reported while it is still on. + #[test] + fn the_worker_names_the_card_before_the_enable_and_reports_a_timeout_while_on() { + let shared = second_screen_selected(); + connect_viewer(&shared); + assert_eq!(next_step(&shared, Instant::now()), Step::Attach); + let on_sleep = |_| {}; + let driver = FakeDriver::new(secs(3.0), Some(secs(0.5)), &on_sleep); + attach(&shared, &driver, ATTACH_TIMEOUT); + assert_eq!(driver.calls(), [Call::Prepare, Call::Enable, Call::Settle]); + assert_eq!(*shared.vdd_status.lock(), VddStatus::Active); + + let shared = second_screen_selected(); + connect_viewer(&shared); + assert_eq!(next_step(&shared, Instant::now()), Step::Attach); + let driver = FakeDriver::new(secs(3.0), None, &on_sleep); + attach(&shared, &driver, ATTACH_TIMEOUT); + assert_eq!( + driver.calls(), + [ + Call::Prepare, + Call::Enable, + Call::Report(Failure::AttachTimeout), + Call::Disable + ] + ); + assert_eq!(remembered_failure(&shared), Some("attach_timeout")); + } + + /// The Enable task's report reaches the snapshot. A device it enabled + /// that cannot show a display is turned back off, after the gap; a + /// missing device or a task that could not say is left alone. + #[test] + fn a_failed_enable_is_reported_and_an_enabled_broken_device_turned_off() { + let on_sleep = |_| {}; + for (report, turned_off) in [ + (Ok(DeviceState::Problem(43)), true), + (Ok(DeviceState::NoDriver), true), + (Ok(DeviceState::Missing), false), + (Err("display tasks missing"), false), + ] { + let shared = second_screen_selected(); + connect_viewer(&shared); + assert_eq!(next_step(&shared, Instant::now()), Step::Attach); + let driver = FakeDriver { + enable_fails: Some(report), + ..FakeDriver::new(secs(3.0), None, &on_sleep) + }; + attach(&shared, &driver, ATTACH_TIMEOUT); + let mut expected = vec![ + Call::Prepare, + Call::Enable, + Call::Report(Failure::Enable(report)), + ]; + if turned_off { + expected.push(Call::Disable); + let toggles = driver.toggles.borrow().clone(); + assert!(toggles[0].0 - secs(3.0) >= MIN_TOGGLE_GAP, "{toggles:?}"); + } + assert_eq!(driver.calls(), expected, "{report:?}"); + assert_eq!(*shared.vdd_status.lock(), VddStatus::TaskFailed); + assert_eq!(remembered_failure(&shared), Some("enable_failed")); + } + } + + /// "Windows added the second screen but kept your desktop off it": the + /// iPad keeps the mirror, so after Try again the start waits 30 s for + /// the user to choose Extend. Any other failure waits the usual time. + #[test] + fn after_kept_off_the_desktop_the_next_start_waits_for_extend() { + let on_sleep = |_| {}; + for (problem, waits) in [ + (Some(VddProblem::NotExtended), EXTEND_TIMEOUT), + (Some(VddProblem::NoDisplay), ATTACH_TIMEOUT), + (None, ATTACH_TIMEOUT), + ] { + let shared = second_screen_selected(); + *shared.vdd_problem.lock() = problem; + connect_viewer(&shared); + assert_eq!(next_step(&shared, Instant::now()), Step::Attach); + let driver = FakeDriver::new(secs(3.0), None, &on_sleep); + attach(&shared, &driver, ATTACH_TIMEOUT); + let toggles = driver.toggles.borrow().clone(); + let waited = toggles[1].0 - toggles[0].0; + assert!( + waited >= waits && waited < waits + secs(1.0), + "{problem:?}: {toggles:?}" + ); + assert_eq!(*shared.vdd_problem.lock(), None, "cleared at each attempt"); + } + // Extend chosen 20 s into the wait: the second screen appears. + let shared = second_screen_selected(); + *shared.vdd_problem.lock() = Some(VddProblem::NotExtended); + connect_viewer(&shared); + assert_eq!(next_step(&shared, Instant::now()), Step::Attach); + let driver = FakeDriver::new(secs(3.0), Some(secs(20.0)), &on_sleep); + attach(&shared, &driver, ATTACH_TIMEOUT); + assert_eq!(*shared.vdd_status.lock(), VddStatus::Active); + } + + /// A mode change that ends a start the display has kept waiting logs a + /// snapshot, once per launch; a quick one does not. + #[test] + fn a_long_cancelled_start_is_reported_once() { + let shared = second_screen_selected(); + let cancel_after = |at: f64| { + // Each run has its own clock: forget the last run's toggle. + *shared.second_screen.lock() = State::default(); + *shared.capture_target.lock() = CaptureTarget::VirtualExtended; + *shared.vdd_status.lock() = VddStatus::WaitingForClient; + connect_viewer(&shared); + assert_eq!(next_step(&shared, Instant::now()), Step::Attach); + let switch = Once::at(secs(at)); + let on_sleep = |elapsed: Duration| { + switch.run(elapsed, || { + *shared.capture_target.lock() = CaptureTarget::PrimaryAuto + }) + }; + let driver = FakeDriver::new(secs(3.0), None, &on_sleep); + attach(&shared, &driver, ATTACH_TIMEOUT); + driver.calls() + }; + assert_eq!( + cancel_after(3.5), + [Call::Prepare, Call::Enable, Call::Disable] + ); + let calls = cancel_after(6.0); + assert!( + matches!(calls[2], Call::Report(Failure::Cancelled(waited)) if waited >= secs(3.0)), + "{calls:?}" + ); + assert_eq!(calls[3], Call::Disable); + assert_eq!( + cancel_after(6.0), + [Call::Prepare, Call::Enable, Call::Disable] + ); + } } diff --git a/host/src/transport/mod.rs b/host/src/transport/mod.rs index 623f03e4..9867439d 100644 --- a/host/src/transport/mod.rs +++ b/host/src/transport/mod.rs @@ -735,7 +735,7 @@ async fn execute_actions( "The virtual display showed no picture — restarting pipeline to mirror the \ primary display instead" ); - second_screen::send(shared, second_screen::Command::Release); + second_screen::send(shared, second_screen::Command::Fallback(reason)); true } }; From 636d188b11aab11f2ddc3df3a060a81014ce6765 Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Mon, 26 Oct 2026 21:33:24 -0400 Subject: [PATCH 35/39] Keep a card someone else named when Match the iPad's resolution rewrites the settings file --- host/src/capture/mod.rs | 73 ++++++++++--------------- host/src/vdd_settings.rs | 113 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 135 insertions(+), 51 deletions(-) diff --git a/host/src/capture/mod.rs b/host/src/capture/mod.rs index 18d6f2db..18e9e548 100644 --- a/host/src/capture/mod.rs +++ b/host/src/capture/mod.rs @@ -386,59 +386,42 @@ fn prepare_vdd_settings(shared: &SharedControl, outputs: &[OutputInfo]) -> VddRe } let gpu = wanted.ok(); let record = crate::settings::app_data_dir().map(|dir| dir.join(VDD_PIN_RECORD)); - let matching = crate::settings::SettingsFile::load().vdd_match_resolution; let path = std::path::Path::new(VDD_SETTINGS_PATH); + // Change the card only where this host may replace the name. + let owned = std::env::current_exe() + .ok() + .and_then(|exe| exe.parent().map(crate::vdd_settings::driver_owned)) + .unwrap_or(false); let mut mode = None; - if matching { - let client = shared.session.lock().client_info(); - let modes = client.map_or_else(crate::vdd_settings::default_modes, |info| { - crate::vdd_settings::modes_for_client( - info.screen_px.0, - info.screen_px.1, - info.refresh_hz, - ) - }); - match crate::vdd_settings::write_to(path, &modes, gpu) { - Ok(()) => { - info!( - ?modes, - gpu = gpu.unwrap_or("chosen by the driver"), - "Wrote virtual display mode list for the connected client" - ); - mode = modes.first().copied(); - if let Some(Err(error)) = - record.map(|record| crate::vdd_settings::remember_pin(&record, gpu)) - { - warn!(%error, "Could not record the graphics card named for the virtual display"); + let applied = match &record { + None => Err(std::io::Error::other("no app data folder")), + Some(record) if crate::settings::SettingsFile::load().vdd_match_resolution => { + let client = shared.session.lock().client_info(); + let modes = client.map_or_else(crate::vdd_settings::default_modes, |info| { + crate::vdd_settings::modes_for_client( + info.screen_px.0, + info.screen_px.1, + info.refresh_hz, + ) + }); + let written = crate::vdd_settings::write_with_pin(path, record, owned, &modes, gpu); + match &written { + Ok(_) => { + info!( + ?modes, + "Wrote virtual display mode list for the connected client" + ); + mode = modes.first().copied(); } - } - Err(error) => { - warn!( + Err(error) => warn!( %error, "Could not write vdd_settings.xml — driver default modes apply" - ); - return VddRequest { - pin: Err("the settings file could not be written"), - mode, - before, - }; + ), } + written } - return VddRequest { - pin: wanted.map(str::to_string), - mode, - before, - }; - } - // The modes in the file are the user's: change only the card, and only - // a name this host may replace. - let owned = std::env::current_exe() - .ok() - .and_then(|exe| exe.parent().map(crate::vdd_settings::driver_owned)) - .unwrap_or(false); - let applied = match &record { + // The modes in the file are the user's: change only the card. Some(record) => crate::vdd_settings::apply_pin(path, record, owned, gpu), - None => Err(std::io::Error::other("no app data folder")), }; match &applied { Ok(crate::vdd_settings::PinOutcome::Named) => info!( diff --git a/host/src/vdd_settings.rs b/host/src/vdd_settings.rs index cb0da01f..0d37c009 100644 --- a/host/src/vdd_settings.rs +++ b/host/src/vdd_settings.rs @@ -512,12 +512,8 @@ pub fn apply_pin( let invalid = |reason| io::Error::new(io::ErrorKind::InvalidData, reason); let current = std::fs::read_to_string(path)?; let named = named_gpu(¤t).map_err(invalid)?; - let ours = std::fs::read_to_string(record).ok(); - let ours = ours - .as_deref() - .map(str::trim) - .filter(|name| !name.is_empty()); - let (name, outcome) = match pin_change(named.as_deref(), wanted, owned, ours) { + let ours = recorded_pin(record); + let (name, outcome) = match pin_change(named.as_deref(), wanted, owned, ours.as_deref()) { PinChange::Leave => return Ok(PinOutcome::Unchanged), PinChange::Refuse(reason) => return Ok(PinOutcome::Kept(reason)), PinChange::Write(card) => (card, PinOutcome::Named), @@ -533,6 +529,44 @@ pub fn apply_pin( Ok(outcome) } +/// The name this host recorded writing last, if any. +fn recorded_pin(record: &Path) -> Option { + let ours = std::fs::read_to_string(record).ok()?; + let ours = ours.trim(); + (!ours.is_empty()).then(|| ours.to_string()) +} + +/// Write the whole settings file at `path` with `modes`, naming `wanted` +/// under the rules of `pin_change`: a card someone else named stays named +/// in the new file. A file that is missing or does not read as expected +/// names no card. `record` holds the name this host wrote last. +pub fn write_with_pin( + path: &Path, + record: &Path, + owned: bool, + modes: &[VddMode], + wanted: Option<&str>, +) -> io::Result { + let named = std::fs::read_to_string(path) + .ok() + .and_then(|current| named_gpu(¤t).ok().flatten()); + let ours = recorded_pin(record); + let (name, outcome) = match pin_change(named.as_deref(), wanted, owned, ours.as_deref()) { + PinChange::Leave => (named, PinOutcome::Unchanged), + PinChange::Refuse(reason) => (named, PinOutcome::Kept(reason)), + PinChange::Write(card) => (Some(card), PinOutcome::Named), + PinChange::Reset => (None, PinOutcome::Reset), + }; + // Recorded first, so a name this host wrote is always one it knows. + match outcome { + PinOutcome::Named => remember_pin(record, name.as_deref())?, + PinOutcome::Reset => remember_pin(record, None)?, + _ => {} + } + write_to(path, modes, name.as_deref())?; + Ok(outcome) +} + /// What the snapshot reports as asked of the driver after `apply_pin`: the /// card the file names for this host, or why it names none. pub fn pin_result( @@ -933,6 +967,73 @@ mod tests { std::fs::remove_dir_all(&dir).unwrap(); } + /// Match the iPad's resolution rewrites the whole file, under the same + /// rule: on a driver Setup did not install, a card the user or another + /// app named stays named. + #[test] + fn a_whole_file_rewrite_keeps_a_card_someone_else_named() { + let dir = std::env::temp_dir().join(format!("vdd-pin-rewrite-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let (path, record) = ( + dir.join("vdd_settings.xml"), + dir.join("app").join("vdd-pin.txt"), + ); + let named = || named_gpu(&std::fs::read_to_string(&path).unwrap()).unwrap(); + let modes = default_modes(); + const INTEL: &str = "Intel(R) HD Graphics 630"; + const OTHER: &str = "NVIDIA GeForce GTX 1050"; + + // No file yet: this host names the card and records it. + assert_eq!( + write_with_pin(&path, &record, false, &modes, Some(INTEL)).unwrap(), + PinOutcome::Named + ); + assert_eq!(named().as_deref(), Some(INTEL)); + assert_eq!(std::fs::read_to_string(&record).unwrap(), INTEL); + assert!(std::fs::read_to_string(&path) + .unwrap() + .contains("1920")); + // No card to name later: its own name goes, and so does the record. + assert_eq!( + write_with_pin(&path, &record, false, &modes, None).unwrap(), + PinOutcome::Reset + ); + assert_eq!(named(), None); + assert!(!record.exists()); + + // Someone else's card stays, with or without a card to name. + std::fs::write(&path, VENDOR.replace("default", OTHER)).unwrap(); + let written = write_with_pin(&path, &record, false, &modes, Some(INTEL)); + assert_eq!( + written.as_ref().unwrap(), + &PinOutcome::Kept(NAMED_ELSEWHERE) + ); + assert_eq!(named().as_deref(), Some(OTHER)); + assert_eq!(pin_result(Ok(INTEL), &written), Err(NAMED_ELSEWHERE)); + assert_eq!( + write_with_pin(&path, &record, false, &modes, None).unwrap(), + PinOutcome::Unchanged + ); + assert_eq!(named().as_deref(), Some(OTHER)); + assert!(!record.exists()); + // On a driver Setup installed, the main screen's card wins. + assert_eq!( + write_with_pin(&path, &record, true, &modes, Some(INTEL)).unwrap(), + PinOutcome::Named + ); + assert_eq!(named().as_deref(), Some(INTEL)); + + // A file that does not read as expected is replaced, naming the card. + std::fs::write(&path, "").unwrap(); + assert_eq!( + write_with_pin(&path, &record, false, &modes, Some(INTEL)).unwrap(), + PinOutcome::Named + ); + assert_eq!(named().as_deref(), Some(INTEL)); + std::fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn only_setups_marker_makes_the_driver_this_hosts() { let dir = std::env::temp_dir().join(format!("vdd-owned-{}", std::process::id())); From fc5acae22c74662cff06363389159dd3f7ee1b6e Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Mon, 26 Oct 2026 23:37:24 -0400 Subject: [PATCH 36/39] Turn the virtual display off when the mode changes while its mode is set --- host/src/capture/second_screen.rs | 83 ++++++++++++++++++++++++++++--- 1 file changed, 75 insertions(+), 8 deletions(-) diff --git a/host/src/capture/second_screen.rs b/host/src/capture/second_screen.rs index f264011a..b79e2828 100644 --- a/host/src/capture/second_screen.rs +++ b/host/src/capture/second_screen.rs @@ -277,6 +277,20 @@ pub fn note_attached(shared: &SharedControl, device_name: String) { state.proving = None; } +/// [`note_attached`], unless the mode changed or quitting began. The mode +/// is held while the display is recorded, so a mode change either comes +/// first and is seen here, or comes after, and its capture generation finds +/// the display to turn off. +#[cfg(any(windows, test))] +fn note_attached_if_selected(shared: &SharedControl, device_name: String) -> bool { + let mode = shared.capture_target.lock(); + if *mode != CaptureTarget::VirtualExtended || quitting(shared) { + return false; + } + note_attached(shared, device_name); + true +} + /// Capture `generation` streams the second screen from `now`. The first /// generation after the switch starts the [`PROVE_TIMEOUT`] deadline; a /// later one inherits it, and any capture failure, until an image arrives. @@ -671,14 +685,23 @@ pub(crate) fn attach(shared: &SharedControl, driver: &impl Driver, timeout: Dura disable(shared, driver); } AttachResult::Attached(chosen) => { + // Setting the mode can take seconds, and the user may choose + // another mode meanwhile. let chosen = driver.settle(chosen); - info!( - device = %chosen.device_name, - width = chosen.width, - height = chosen.height, - "Virtual display attached — capturing it" - ); - note_attached(shared, chosen.device_name); + let (device, width, height) = (chosen.device_name.clone(), chosen.width, chosen.height); + if note_attached_if_selected(shared, chosen.device_name) { + info!( + %device, + width, height, "Virtual display attached — capturing it" + ); + } else if quitting(shared) { + info!("Quitting while the virtual display started — the quit turns it off"); + } else { + // Nothing records it as ours, so no capture generation turns + // it off: this does, after the gap, as for a cancelled start. + info!("Display mode changed while the virtual display's mode was set — turning it off"); + disable(shared, driver); + } } AttachResult::TimedOut => { warn!( @@ -926,11 +949,13 @@ mod tests { /// A driver on a paused clock. Enable takes `enable_takes`, and the /// display attaches `attach_after` later, or never. `on_sleep` sees the /// clock each time the host waits, as the transport's ticks would. - /// With `enable_fails`, the Enable task reports that instead. + /// With `enable_fails`, the Enable task reports that instead. Setting + /// the mode after the attach takes `settle_takes`. struct FakeDriver<'a> { start: Instant, elapsed: Cell, enable_takes: Duration, + settle_takes: Duration, attach_after: Option, on_since: Cell>, toggles: RefCell>, @@ -949,6 +974,7 @@ mod tests { start: Instant::now(), elapsed: Cell::new(Duration::ZERO), enable_takes, + settle_takes: Duration::ZERO, attach_after, on_since: Cell::new(None), toggles: RefCell::new(Vec::new()), @@ -996,6 +1022,7 @@ mod tests { } fn settle(&self, chosen: OutputInfo) -> OutputInfo { + self.sleep(self.settle_takes); self.calls.borrow_mut().push(Call::Settle); chosen } @@ -1205,6 +1232,46 @@ mod tests { assert_eq!(next_step(&shared, driver.now()), Step::Nothing); } + /// Review round 3, defect 1: Mirror chosen while the attached display's + /// mode was being set. Mirror's capture generation found no display to + /// turn off, and the start then recorded it as the active second screen. + #[test] + fn a_mode_change_while_the_mode_is_set_turns_the_display_back_off() { + let shared = second_screen_selected(); + connect_viewer(&shared); + assert_eq!(next_step(&shared, Instant::now()), Step::Attach); + let mirror = Once::at(secs(4.0)); + let on_sleep = |elapsed: Duration| { + mirror.run(elapsed, || { + *shared.capture_target.lock() = CaptureTarget::PrimaryAuto; + let generation = crate::supervisor::CURRENT_GENERATION.load(Ordering::SeqCst); + assert_eq!( + reconcile_virtual_display(&CaptureTarget::PrimaryAuto, &shared, generation), + Some(CaptureTarget::PrimaryAuto) + ); + }) + }; + // Enable done at 3 s, attached at 3.5 s, the mode set by 6.5 s. + let driver = FakeDriver { + settle_takes: secs(3.0), + ..FakeDriver::new(secs(3.0), Some(secs(0.5)), &on_sleep) + }; + attach(&shared, &driver, ATTACH_TIMEOUT); + run(&shared, &driver, ATTACH_TIMEOUT, false); + assert!(mirror.done.get()); + assert!(driver.calls().contains(&Call::Settle)); + let toggles = driver.toggles.borrow().clone(); + assert_eq!( + toggles.iter().map(|(_, on)| *on).collect::>(), + [true, false], + "{toggles:?}" + ); + assert!(toggles[1].0 - toggles[0].0 >= MIN_TOGGLE_GAP, "{toggles:?}"); + assert_eq!(*shared.vdd_output.lock(), None); + assert_ne!(*shared.vdd_status.lock(), VddStatus::Active); + assert_eq!(next_step(&shared, driver.now()), Step::Nothing); + } + /// Review round 1, defect 3: Mirror then Second screen again during the /// start, before any capture generation reconciled Mirror. The start must /// not stay claimed with nobody running it. From 504b9e7004e026283df9507f89ed03d6251e698e Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Mon, 26 Oct 2026 23:37:24 -0400 Subject: [PATCH 37/39] Keep settings files this host cannot read, check the driver is still Setup's, and keep the pin record on a failed write --- host/src/capture/mod.rs | 2 +- host/src/diagnostics.rs | 23 ++++- host/src/vdd_settings.rs | 209 ++++++++++++++++++++++++++++++++++----- 3 files changed, 204 insertions(+), 30 deletions(-) diff --git a/host/src/capture/mod.rs b/host/src/capture/mod.rs index 18e9e548..6def197c 100644 --- a/host/src/capture/mod.rs +++ b/host/src/capture/mod.rs @@ -415,7 +415,7 @@ fn prepare_vdd_settings(shared: &SharedControl, outputs: &[OutputInfo]) -> VddRe } Err(error) => warn!( %error, - "Could not write vdd_settings.xml — driver default modes apply" + "Could not write vdd_settings.xml — the modes the driver has apply" ), } written diff --git a/host/src/diagnostics.rs b/host/src/diagnostics.rs index cd36747c..35c4e13c 100644 --- a/host/src/diagnostics.rs +++ b/host/src/diagnostics.rs @@ -178,6 +178,19 @@ fn windows_name(product: &str, build: &str) -> String { #[cfg(windows)] pub(crate) fn registry_string(key: &str, name: &str) -> String { + registry_sz(key, name).unwrap_or_else(|code| format!("unavailable({code})")) +} + +/// A string value under HKLM, `None` when it is missing or empty. +#[cfg(windows)] +pub(crate) fn registry_value(key: &str, name: &str) -> Option { + registry_sz(key, name) + .ok() + .filter(|value| !value.is_empty()) +} + +#[cfg(windows)] +fn registry_sz(key: &str, name: &str) -> Result { use windows::{ core::PCWSTR, Win32::System::Registry::{RegGetValueW, HKEY_LOCAL_MACHINE, RRF_RT_REG_SZ}, @@ -198,11 +211,13 @@ pub(crate) fn registry_string(key: &str, name: &str) -> String { ) }; if result.is_err() { - return format!("unavailable({})", result.0); + return Err(result.0.to_string()); } - String::from_utf16_lossy(&value[..value.iter().position(|c| *c == 0).unwrap_or(value.len())]) - .trim() - .to_string() + Ok(String::from_utf16_lossy( + &value[..value.iter().position(|c| *c == 0).unwrap_or(value.len())], + ) + .trim() + .to_string()) } #[cfg(windows)] diff --git a/host/src/vdd_settings.rs b/host/src/vdd_settings.rs index 0d37c009..aa7f97d1 100644 --- a/host/src/vdd_settings.rs +++ b/host/src/vdd_settings.rs @@ -474,12 +474,49 @@ pub enum PinOutcome { } /// Whether Setup installed the virtual display driver beside the app in -/// `app_dir`; it records that in this marker (installer/EternalMonitor.iss). +/// `app_dir`, and it is still that install. Setup records the driver's +/// uninstaller and version in this marker (installer/EternalMonitor.iss); +/// another app that replaced or upgraded the driver since changed its +/// registration, and the marker no longer counts, as in +/// installer/scripts/vdd-driver-remove.ps1. pub fn driver_owned(app_dir: &Path) -> bool { - app_dir + let marker = app_dir .join("driver") - .join("installed-by-eternalmonitor.txt") - .is_file() + .join("installed-by-eternalmonitor.txt"); + let Ok(marker) = std::fs::read_to_string(marker) else { + return false; + }; + driver_registration() + .is_some_and(|(location, version)| marker_matches(&marker, &location, &version)) +} + +/// Whether the marker records the driver registered at `location` with +/// `version`: its uninstaller's path and the version on two lines, compared +/// ignoring case as PowerShell's `-eq` does in the scripts. +fn marker_matches(marker: &str, location: &str, version: &str) -> bool { + let location = location.strip_suffix('\\').unwrap_or(location); + let expected = format!("{location}\\unins000.exe\r\n{version}"); + marker.to_lowercase() == expected.to_lowercase() +} + +/// Where the driver's own setup registered it, and its version, as the +/// scripts read it: the first uninstall entry with both. +#[cfg(windows)] +fn driver_registration() -> Option<(String, String)> { + [ + r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\VirtualDisplayDriver_is1", + r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\VirtualDisplayDriver_is1", + ] + .into_iter() + .find_map(|key| { + let value = |name| crate::diagnostics::registry_value(key, name); + Some((value("InstallLocation")?, value("DisplayVersion")?)) + }) +} + +#[cfg(not(windows))] +fn driver_registration() -> Option<(String, String)> { + None } /// Remember the card this host last wrote into the settings file, or that @@ -520,15 +557,29 @@ pub fn apply_pin( PinChange::Reset => ("default".to_string(), PinOutcome::Reset), }; let updated = gpu_edit(¤t, &name).map_err(invalid)?; - // Recorded first, so a name this host wrote is always one it knows. - remember_pin( - record, - (outcome == PinOutcome::Named).then_some(name.as_str()), - )?; - replace(path, &updated)?; + let new_record = (outcome == PinOutcome::Named).then_some(name.as_str()); + record_around(record, new_record, ours.as_deref(), || { + replace(path, &updated) + })?; Ok(outcome) } +/// Record `new` as this host's name, then `write` the settings file. The +/// record comes first, so a name this host wrote is always one it knows; +/// when the write fails, the record goes back to `old`, the name the file +/// still holds, so a later attempt still knows that name as its own. +fn record_around( + record: &Path, + new: Option<&str>, + old: Option<&str>, + write: impl FnOnce() -> io::Result<()>, +) -> io::Result<()> { + remember_pin(record, new)?; + write().inspect_err(|_| { + let _ = remember_pin(record, old); + }) +} + /// The name this host recorded writing last, if any. fn recorded_pin(record: &Path) -> Option { let ours = std::fs::read_to_string(record).ok()?; @@ -538,8 +589,11 @@ fn recorded_pin(record: &Path) -> Option { /// Write the whole settings file at `path` with `modes`, naming `wanted` /// under the rules of `pin_change`: a card someone else named stays named -/// in the new file. A file that is missing or does not read as expected -/// names no card. `record` holds the name this host wrote last. +/// in the new file. A missing file names no card. A file this host cannot +/// read may name anyone's card, so it is replaced only where any name may +/// be, on a driver Setup installed and with a card to name; otherwise it is +/// left as it is and the error says why. `record` holds the name this host +/// wrote last. pub fn write_with_pin( path: &Path, record: &Path, @@ -547,9 +601,15 @@ pub fn write_with_pin( modes: &[VddMode], wanted: Option<&str>, ) -> io::Result { - let named = std::fs::read_to_string(path) - .ok() - .and_then(|current| named_gpu(¤t).ok().flatten()); + let named = match std::fs::read_to_string(path) { + Ok(current) => match named_gpu(¤t) { + Ok(named) => named, + Err(_) if owned && wanted.is_some() => None, + Err(reason) => return Err(io::Error::new(io::ErrorKind::InvalidData, reason)), + }, + Err(error) if error.kind() == io::ErrorKind::NotFound => None, + Err(error) => return Err(error), + }; let ours = recorded_pin(record); let (name, outcome) = match pin_change(named.as_deref(), wanted, owned, ours.as_deref()) { PinChange::Leave => (named, PinOutcome::Unchanged), @@ -557,13 +617,12 @@ pub fn write_with_pin( PinChange::Write(card) => (Some(card), PinOutcome::Named), PinChange::Reset => (None, PinOutcome::Reset), }; - // Recorded first, so a name this host wrote is always one it knows. + let write = || write_to(path, modes, name.as_deref()); match outcome { - PinOutcome::Named => remember_pin(record, name.as_deref())?, - PinOutcome::Reset => remember_pin(record, None)?, - _ => {} + PinOutcome::Named => record_around(record, name.as_deref(), ours.as_deref(), write)?, + PinOutcome::Reset => record_around(record, None, ours.as_deref(), write)?, + _ => write()?, } - write_to(path, modes, name.as_deref())?; Ok(outcome) } @@ -1024,29 +1083,129 @@ mod tests { ); assert_eq!(named().as_deref(), Some(INTEL)); - // A file that does not read as expected is replaced, naming the card. + // A file that does not read as expected is replaced, naming the + // card, only on a driver Setup installed. std::fs::write(&path, "").unwrap(); assert_eq!( - write_with_pin(&path, &record, false, &modes, Some(INTEL)).unwrap(), + write_with_pin(&path, &record, true, &modes, Some(INTEL)).unwrap(), PinOutcome::Named ); assert_eq!(named().as_deref(), Some(INTEL)); std::fs::remove_dir_all(&dir).unwrap(); } + /// Review round 3, defect 2: a card named in a way this host does not + /// read (here CDATA, which the driver reads) counted as no card, and + /// Match the iPad's resolution replaced it on a driver another app + /// installed. #[test] - fn only_setups_marker_makes_the_driver_this_hosts() { + fn a_whole_file_rewrite_keeps_a_file_it_cannot_read() { + let dir = std::env::temp_dir().join(format!("vdd-pin-unread-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let (path, record) = ( + dir.join("vdd_settings.xml"), + dir.join("app").join("vdd-pin.txt"), + ); + let modes = default_modes(); + const INTEL: &str = "Intel(R) HD Graphics 630"; + let cdata = VENDOR.replace(">default<", "><"); + for document in [cdata.as_str(), ""] { + for wanted in [Some(INTEL), None] { + std::fs::write(&path, document).unwrap(); + let written = write_with_pin(&path, &record, false, &modes, wanted); + assert!(written.is_err(), "{document:?} {wanted:?}"); + assert_eq!(std::fs::read_to_string(&path).unwrap(), document); + assert_eq!( + pin_result(Ok(INTEL), &written), + Err("the settings file was left as it is") + ); + assert!(!record.exists()); + } + // Nor is it replaced without a card to name on a driver Setup + // installed: the name in it may not be this host's. + let written = write_with_pin(&path, &record, true, &modes, None); + assert!(written.is_err(), "{document:?}"); + assert_eq!(std::fs::read_to_string(&path).unwrap(), document); + } + std::fs::remove_dir_all(&dir).unwrap(); + } + + /// Review round 3, defect 4: the record named the new card before the + /// file was replaced. When the replacement failed, the file still held + /// this host's earlier name, which the record no longer did, so every + /// later attempt refused to replace it. + #[test] + fn a_failed_write_keeps_this_hosts_earlier_name_its_own() { + let dir = std::env::temp_dir().join(format!("vdd-pin-failed-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let (path, record) = ( + dir.join("vdd_settings.xml"), + dir.join("app").join("vdd-pin.txt"), + ); + let named = || named_gpu(&std::fs::read_to_string(&path).unwrap()).unwrap(); + // A directory where the new file is written first makes it fail. + let blocker = path.with_extension("xml.tmp"); + let modes = default_modes(); + const INTEL: &str = "Intel(R) HD Graphics 630"; + const AMD: &str = "AMD Radeon(TM) Graphics"; + type Pin<'a> = dyn Fn(Option<&str>) -> io::Result + 'a; + let edit: &Pin = &|wanted| apply_pin(&path, &record, false, wanted); + let rewrite: &Pin = &|wanted| write_with_pin(&path, &record, false, &modes, wanted); + for pin in [edit, rewrite] { + std::fs::write(&path, VENDOR).unwrap(); + assert_eq!(pin(Some(INTEL)).unwrap(), PinOutcome::Named); + for wanted in [Some(AMD), None] { + std::fs::create_dir(&blocker).unwrap(); + assert!(pin(wanted).is_err(), "{wanted:?}"); + std::fs::remove_dir(&blocker).unwrap(); + assert_eq!(named().as_deref(), Some(INTEL)); + assert_eq!(std::fs::read_to_string(&record).unwrap(), INTEL); + } + // The next attempt replaces it. + assert_eq!(pin(Some(AMD)).unwrap(), PinOutcome::Named); + assert_eq!(named().as_deref(), Some(AMD)); + assert_eq!(std::fs::read_to_string(&record).unwrap(), AMD); + assert_eq!(pin(None).unwrap(), PinOutcome::Reset); + assert_eq!(named(), None); + assert!(!record.exists()); + } + std::fs::remove_dir_all(&dir).unwrap(); + } + + /// Review round 3, defect 3: the marker counted whatever it said, after + /// another app replaced or upgraded the driver. + #[test] + fn only_setups_marker_for_the_registered_driver_makes_it_this_hosts() { let dir = std::env::temp_dir().join(format!("vdd-owned-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(dir.join("driver")).unwrap(); assert!(!driver_owned(&dir)); + // A marker for no driver registered here. std::fs::write( dir.join("driver").join("installed-by-eternalmonitor.txt"), - "x", + "C:\\Elsewhere\\unins000.exe\r\n0.0.0", ) .unwrap(); - assert!(driver_owned(&dir)); + assert!(!driver_owned(&dir)); std::fs::remove_dir_all(&dir).unwrap(); + + // What Setup writes, for the install the scripts find. + const LOCATION: &str = "C:\\VirtualDisplayDriver"; + let setup = "C:\\VirtualDisplayDriver\\unins000.exe\r\n25.5.2"; + assert!(marker_matches(setup, LOCATION, "25.5.2")); + assert!(marker_matches( + setup, + "C:\\VirtualDisplayDriver\\", + "25.5.2" + )); + assert!(marker_matches(&setup.to_uppercase(), LOCATION, "25.5.2")); + // Upgraded, or installed again elsewhere, by another app. + assert!(!marker_matches(setup, LOCATION, "25.7.23")); + assert!(!marker_matches(setup, "D:\\Tools\\VDD", "25.5.2")); + assert!(!marker_matches("x", LOCATION, "25.5.2")); + assert!(!marker_matches(&format!("{setup}\r\n"), LOCATION, "25.5.2")); } #[test] From 62d8526c0d21857dfabd76b5fc0210df83f7c09d Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Mon, 26 Oct 2026 23:37:24 -0400 Subject: [PATCH 38/39] Say a second screen showed no picture instead of calling it late --- host/src/gui/page_stream.rs | 18 ++++++++++++-- host/src/gui/tests.rs | 48 ++++++++++++++++++++++++++++--------- 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/host/src/gui/page_stream.rs b/host/src/gui/page_stream.rs index 87452f49..42b2b59d 100644 --- a/host/src/gui/page_stream.rs +++ b/host/src/gui/page_stream.rs @@ -124,7 +124,9 @@ impl AnalyzerApp { } // The user asked for the extended (virtual) display but it could not be brought up, // so capture silently fell back to the primary monitor. Say so. - if let Some(notice) = second_screen_notice(vdd_status, vdd_health, vdd_problem) { + let vdd_failure = crate::capture::second_screen::remembered_failure(&self.control.shared); + if let Some(notice) = second_screen_notice(vdd_status, vdd_health, vdd_problem, vdd_failure) + { let mut copy_logs = false; let mut retry = false; widgets::banner( @@ -654,11 +656,13 @@ pub(super) struct SecondScreenNotice { } /// The notice for a second screen that could not start, by what the display -/// driver tasks and its device report. `None` unless the second screen failed. +/// driver tasks and its device report, and why the start failed (the +/// `second_screen_fallback` reason). `None` unless the second screen failed. pub(super) fn second_screen_notice( status: VddStatus, health: VddTaskHealth, problem: Option, + failure: Option<&str>, ) -> Option { use VddStatus::{Failed, TaskFailed}; let notice = |body: &str, retry, installer| { @@ -712,6 +716,16 @@ pub(super) fn second_screen_notice( true, true, ), + // It attached, so it was not late: it showed no picture. + (Failed, VddTaskHealth::Ok, None) + if matches!(failure, Some("no_frames" | "duplication_failed")) => + { + notice( + "Windows added the second screen, but EternalMonitor couldn't get a picture from it, so the iPad shows your main screen instead. Click Try again. If it keeps happening, restart the PC.", + true, + false, + ) + } (Failed, VddTaskHealth::Ok, None) => notice( "Windows didn't add the second screen in time. Click Try again. If it keeps happening, run EternalMonitor-Setup.exe again to repair the display driver.", true, diff --git a/host/src/gui/tests.rs b/host/src/gui/tests.rs index 6f247190..e7b756d7 100644 --- a/host/src/gui/tests.rs +++ b/host/src/gui/tests.rs @@ -362,28 +362,53 @@ fn the_second_screen_notice_says_which_driver_problem_applies() { VddTaskHealth::NotResponding, VddTaskHealth::TasksMissing, ] { - assert_eq!(second_screen_notice(status, health, None), None); + assert_eq!(second_screen_notice(status, health, None, None), None); } } for status in [VddStatus::Failed, VddStatus::TaskFailed] { // A hung driver needs a Windows restart; trying again cannot help. - let hung = second_screen_notice(status, VddTaskHealth::NotResponding, None).unwrap(); + let hung = second_screen_notice(status, VddTaskHealth::NotResponding, None, None).unwrap(); assert!(hung.body.contains("Restart the PC"), "{}", hung.body); assert!(!hung.retry && !hung.installer); // Missing driver tasks need the installer. - let missing = second_screen_notice(status, VddTaskHealth::TasksMissing, None).unwrap(); + let missing = + second_screen_notice(status, VddTaskHealth::TasksMissing, None, None).unwrap(); assert!(missing.body.contains("isn't installed"), "{}", missing.body); assert!(missing.retry && missing.installer); } // Without a known driver problem the earlier wording stays. - let task = second_screen_notice(VddStatus::TaskFailed, VddTaskHealth::Ok, None).unwrap(); + let task = second_screen_notice(VddStatus::TaskFailed, VddTaskHealth::Ok, None, None).unwrap(); assert!(task.body.starts_with("Part of the EternalMonitor install")); assert!(task.installer); - let slow = second_screen_notice(VddStatus::Failed, VddTaskHealth::Ok, None).unwrap(); - assert!(slow - .body - .starts_with("Windows didn't add the second screen in time")); - assert!(slow.retry && !slow.installer); + for failure in [None, Some("attach_timeout")] { + let slow = + second_screen_notice(VddStatus::Failed, VddTaskHealth::Ok, None, failure).unwrap(); + assert!(slow + .body + .starts_with("Windows didn't add the second screen in time")); + assert!(slow.retry && !slow.installer); + } +} + +/// Review round 3, defect 5: a second screen that attached but showed no +/// picture was said not to have been added in time. +#[test] +fn a_second_screen_without_a_picture_is_not_called_late() { + use crate::control::VddStatus; + use crate::vdd::VddTaskHealth; + for failure in ["no_frames", "duplication_failed"] { + let blank = second_screen_notice(VddStatus::Failed, VddTaskHealth::Ok, None, Some(failure)) + .unwrap(); + assert!( + blank + .body + .starts_with("Windows added the second screen, but EternalMonitor couldn't get a picture from it"), + "{}", + blank.body + ); + assert!(!blank.body.contains("in time"), "{}", blank.body); + assert!(blank.retry && !blank.installer); + } } #[test] @@ -392,7 +417,7 @@ fn the_second_screen_notice_says_what_to_do_about_the_driver_device() { use crate::vdd::VddTaskHealth; use crate::vdd_device::VddProblem; let notice = |status, problem| { - second_screen_notice(status, VddTaskHealth::Ok, Some(problem)) + second_screen_notice(status, VddTaskHealth::Ok, Some(problem), None) .unwrap_or_else(|| panic!("no notice for {status:?} {problem:?}")) }; for status in [VddStatus::Failed, VddStatus::TaskFailed] { @@ -446,13 +471,14 @@ fn the_second_screen_notice_says_what_to_do_about_the_driver_device() { VddStatus::TaskFailed, VddTaskHealth::NotResponding, Some(VddProblem::NoDisplay), + None, ) .unwrap(); assert!(hung.body.contains("Restart the PC"), "{}", hung.body); // Nothing failed: no notice, whatever was last reported. for status in [VddStatus::Inactive, VddStatus::Active, VddStatus::Attaching] { assert_eq!( - second_screen_notice(status, VddTaskHealth::Ok, Some(VddProblem::NoDisplay)), + second_screen_notice(status, VddTaskHealth::Ok, Some(VddProblem::NoDisplay), None), None ); } From cce3466cc74f5b5140fa159270a29feba2334d05 Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Tue, 27 Oct 2026 03:25:35 -0400 Subject: [PATCH 39/39] Keep the pin record true across a crash or power cut: list the old and new card before the settings file is replaced, and replace the record as the settings file is --- host/src/vdd_settings.rs | 219 +++++++++++++++++++++++++++++---------- 1 file changed, 167 insertions(+), 52 deletions(-) diff --git a/host/src/vdd_settings.rs b/host/src/vdd_settings.rs index aa7f97d1..3eb68fae 100644 --- a/host/src/vdd_settings.rs +++ b/host/src/vdd_settings.rs @@ -444,7 +444,7 @@ pub const NAMED_ELSEWHERE: &str = "the settings file names a card this host did /// Whose name the file may hold: this host replaces a name only when it /// installed the driver (`owned`), when the file leaves the card to the -/// driver, or when the name is the one it wrote last (`ours`). Other apps +/// driver, or when the name is one it recorded writing (`ours`). Other apps /// share this driver and its file. With no card to name (`wanted` is /// `None`), this host's own earlier name is taken back, so the file never /// keeps a pin the log says was not made. @@ -452,9 +452,9 @@ pub fn pin_change( named: Option<&str>, wanted: Option<&str>, owned: bool, - ours: Option<&str>, + ours: &[&str], ) -> PinChange { - let is_ours = |name: &str| ours.is_some_and(|ours| same_card(ours, name)); + let is_ours = |name: &str| ours.iter().any(|ours| same_card(ours, name)); match (wanted, named) { (Some(card), Some(name)) if same_card(card, name) => PinChange::Leave, (Some(_), Some(name)) if !owned && !is_ours(name) => PinChange::Refuse(NAMED_ELSEWHERE), @@ -519,27 +519,26 @@ fn driver_registration() -> Option<(String, String)> { None } -/// Remember the card this host last wrote into the settings file, or that -/// it wrote none. -pub fn remember_pin(record: &Path, gpu: Option<&str>) -> io::Result<()> { - match gpu { - Some(gpu) => { - if let Some(parent) = record.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::write(record, gpu) - } - None => match std::fs::remove_file(record) { +/// Remember the cards this host may have written into the settings file +/// last, one per line, or that it wrote none. The record is replaced as the +/// settings file is, so a crash or power cut leaves the old or the new one. +pub fn remember_pins(record: &Path, names: &[&str]) -> io::Result<()> { + if names.is_empty() { + return match std::fs::remove_file(record) { Err(error) if error.kind() != io::ErrorKind::NotFound => Err(error), _ => Ok(()), - }, + }; + } + if let Some(parent) = record.parent() { + std::fs::create_dir_all(parent)?; } + replace(record, &names.join("\n")) } /// Make the settings file at `path` name `wanted`, changing only the card, -/// under the rules of `pin_change`. `record` holds the name this host -/// wrote last. A missing file stays missing, and one that does not parse -/// as expected is left alone; the error says why. +/// under the rules of `pin_change`. `record` holds the names this host +/// recorded writing. A missing file stays missing, and one that does not +/// parse as expected is left alone; the error says why. pub fn apply_pin( path: &Path, record: &Path, @@ -549,8 +548,9 @@ pub fn apply_pin( let invalid = |reason| io::Error::new(io::ErrorKind::InvalidData, reason); let current = std::fs::read_to_string(path)?; let named = named_gpu(¤t).map_err(invalid)?; - let ours = recorded_pin(record); - let (name, outcome) = match pin_change(named.as_deref(), wanted, owned, ours.as_deref()) { + let ours = recorded_pins(record); + let ours: Vec<&str> = ours.iter().map(String::as_str).collect(); + let (name, outcome) = match pin_change(named.as_deref(), wanted, owned, &ours) { PinChange::Leave => return Ok(PinOutcome::Unchanged), PinChange::Refuse(reason) => return Ok(PinOutcome::Kept(reason)), PinChange::Write(card) => (card, PinOutcome::Named), @@ -558,33 +558,49 @@ pub fn apply_pin( }; let updated = gpu_edit(¤t, &name).map_err(invalid)?; let new_record = (outcome == PinOutcome::Named).then_some(name.as_str()); - record_around(record, new_record, ours.as_deref(), || { - replace(path, &updated) - })?; + record_around(record, new_record, &ours, || replace(path, &updated))?; Ok(outcome) } -/// Record `new` as this host's name, then `write` the settings file. The -/// record comes first, so a name this host wrote is always one it knows; -/// when the write fails, the record goes back to `old`, the name the file -/// still holds, so a later attempt still knows that name as its own. +/// `write` the settings file, `new` taking the place of `ours` as the names +/// this host wrote, with the record true wherever a crash or power cut +/// stops it: until the file is replaced it may hold either, so the record +/// holds both; once it is, only `new`. With `new` as `None` the record +/// keeps `ours` until the file no longer names them. When the write fails, +/// the record goes back to `ours`. fn record_around( record: &Path, new: Option<&str>, - old: Option<&str>, + ours: &[&str], write: impl FnOnce() -> io::Result<()>, ) -> io::Result<()> { - remember_pin(record, new)?; + let mut both = ours.to_vec(); + both.extend(new.filter(|new| !ours.iter().any(|name| same_card(name, new)))); + let grew = both.len() > ours.len(); + if grew { + remember_pins(record, &both)?; + } write().inspect_err(|_| { - let _ = remember_pin(record, old); - }) + if grew { + let _ = remember_pins(record, ours); + } + })?; + // A record left holding both is still true. + let _ = remember_pins(record, new.as_slice()); + Ok(()) } -/// The name this host recorded writing last, if any. -fn recorded_pin(record: &Path) -> Option { - let ours = std::fs::read_to_string(record).ok()?; - let ours = ours.trim(); - (!ours.is_empty()).then(|| ours.to_string()) +/// The names this host recorded writing: one, or more after a write that +/// a crash or power cut stopped. +fn recorded_pins(record: &Path) -> Vec { + let Ok(ours) = std::fs::read_to_string(record) else { + return Vec::new(); + }; + ours.lines() + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(String::from) + .collect() } /// Write the whole settings file at `path` with `modes`, naming `wanted` @@ -592,8 +608,8 @@ fn recorded_pin(record: &Path) -> Option { /// in the new file. A missing file names no card. A file this host cannot /// read may name anyone's card, so it is replaced only where any name may /// be, on a driver Setup installed and with a card to name; otherwise it is -/// left as it is and the error says why. `record` holds the name this host -/// wrote last. +/// left as it is and the error says why. `record` holds the names this +/// host recorded writing. pub fn write_with_pin( path: &Path, record: &Path, @@ -610,8 +626,9 @@ pub fn write_with_pin( Err(error) if error.kind() == io::ErrorKind::NotFound => None, Err(error) => return Err(error), }; - let ours = recorded_pin(record); - let (name, outcome) = match pin_change(named.as_deref(), wanted, owned, ours.as_deref()) { + let ours = recorded_pins(record); + let ours: Vec<&str> = ours.iter().map(String::as_str).collect(); + let (name, outcome) = match pin_change(named.as_deref(), wanted, owned, &ours) { PinChange::Leave => (named, PinOutcome::Unchanged), PinChange::Refuse(reason) => (named, PinOutcome::Kept(reason)), PinChange::Write(card) => (Some(card), PinOutcome::Named), @@ -619,8 +636,8 @@ pub fn write_with_pin( }; let write = || write_to(path, modes, name.as_deref()); match outcome { - PinOutcome::Named => record_around(record, name.as_deref(), ours.as_deref(), write)?, - PinOutcome::Reset => record_around(record, None, ours.as_deref(), write)?, + PinOutcome::Named => record_around(record, name.as_deref(), &ours, write)?, + PinOutcome::Reset => record_around(record, None, &ours, write)?, _ => write()?, } Ok(outcome) @@ -654,7 +671,9 @@ pub fn write_to(path: &Path, modes: &[VddMode], gpu: Option<&str>) -> io::Result /// interrupted write leaves the old file whole. fn replace(path: &Path, contents: &str) -> io::Result<()> { use std::io::Write; - let tmp = path.with_extension("xml.tmp"); + let mut tmp = path.as_os_str().to_owned(); + tmp.push(".tmp"); + let tmp = std::path::PathBuf::from(tmp); let mut file = std::fs::File::create(&tmp)?; file.write_all(contents.as_bytes())?; file.sync_all()?; @@ -900,39 +919,39 @@ mod tests { let write = || Write(INTEL.to_string()); // The driver chooses: this host may name a card, owned or not. for owned in [false, true] { - assert_eq!(pin_change(None, Some(INTEL), owned, None), write()); + assert_eq!(pin_change(None, Some(INTEL), owned, &[]), write()); } // A card another app or the user named stays on a driver Setup did // not install, and is replaced on one it did. assert_eq!( - pin_change(Some(OTHER), Some(INTEL), false, None), + pin_change(Some(OTHER), Some(INTEL), false, &[]), Refuse(NAMED_ELSEWHERE) ); assert_eq!( - pin_change(Some(OTHER), Some(INTEL), false, Some(INTEL)), + pin_change(Some(OTHER), Some(INTEL), false, &[INTEL]), Refuse(NAMED_ELSEWHERE) ); - assert_eq!(pin_change(Some(OTHER), Some(INTEL), true, None), write()); + assert_eq!(pin_change(Some(OTHER), Some(INTEL), true, &[]), write()); // This host's own earlier name, in any case, is replaced. assert_eq!( pin_change( Some("nvidia geforce gtx 1050"), Some(INTEL), false, - Some(OTHER) + &[OTHER] ), write() ); // Already named, ignoring case. assert_eq!( - pin_change(Some("intel(r) hd graphics 630"), Some(INTEL), false, None), + pin_change(Some("intel(r) hd graphics 630"), Some(INTEL), false, &[]), Leave ); // No card to name: this host's earlier name is taken back, anyone // else's stays. - assert_eq!(pin_change(Some(INTEL), None, false, Some(INTEL)), Reset); - assert_eq!(pin_change(Some(INTEL), None, true, None), Leave); - assert_eq!(pin_change(None, None, false, Some(INTEL)), Leave); + assert_eq!(pin_change(Some(INTEL), None, false, &[INTEL]), Reset); + assert_eq!(pin_change(Some(INTEL), None, true, &[]), Leave); + assert_eq!(pin_change(None, None, false, &[INTEL]), Leave); } #[test] @@ -1174,6 +1193,102 @@ mod tests { std::fs::remove_dir_all(&dir).unwrap(); } + /// Run a pin that a crash or power cut stops just before the settings + /// file is replaced: what stays on disk is the record as it stood then, + /// and the old file. + fn crash_before_replace(record: &Path, new: Option<&str>) { + let ours = recorded_pins(record); + let ours: Vec<&str> = ours.iter().map(String::as_str).collect(); + let mut on_disk = None; + record_around(record, new, &ours, || { + on_disk = Some(std::fs::read_to_string(record).ok()); + Ok(()) + }) + .unwrap(); + let _ = std::fs::remove_file(record); + if let Some(names) = on_disk.unwrap() { + std::fs::write(record, names).unwrap(); + } + } + + /// Review P3-1: the record named the new card before the settings file + /// held it, so a crash or power cut between the two left this host's own + /// name looking like another app's, and later attempts kept it. + #[test] + fn a_crash_before_a_new_name_is_written_keeps_the_old_one_this_hosts() { + let dir = std::env::temp_dir().join(format!("vdd-pin-crash-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let (path, record) = ( + dir.join("vdd_settings.xml"), + dir.join("app").join("vdd-pin.txt"), + ); + let named = || named_gpu(&std::fs::read_to_string(&path).unwrap()).unwrap(); + let modes = default_modes(); + const INTEL: &str = "Intel(R) HD Graphics 630"; + const AMD: &str = "AMD Radeon(TM) Graphics"; + type Pin<'a> = dyn Fn(Option<&str>) -> io::Result + 'a; + let edit: &Pin = &|wanted| apply_pin(&path, &record, false, wanted); + let rewrite: &Pin = &|wanted| write_with_pin(&path, &record, false, &modes, wanted); + for pin in [edit, rewrite] { + std::fs::write(&path, VENDOR).unwrap(); + assert_eq!(pin(Some(INTEL)).unwrap(), PinOutcome::Named); + crash_before_replace(&record, Some(AMD)); + assert_eq!(named().as_deref(), Some(INTEL)); + // The next run still knows the name the file holds as its own. + assert_eq!(pin(Some(AMD)).unwrap(), PinOutcome::Named); + assert_eq!(named().as_deref(), Some(AMD)); + // Once the file is replaced, the record holds only the new name. + assert_eq!(std::fs::read_to_string(&record).unwrap(), AMD); + crash_before_replace(&record, Some(INTEL)); + assert_eq!(pin(None).unwrap(), PinOutcome::Reset); + assert_eq!(named(), None); + assert!(!record.exists()); + } + // Neither file is left half written beside the real one. + assert!(!record.with_extension("txt.tmp").exists()); + assert!(!path.with_extension("xml.tmp").exists()); + std::fs::remove_dir_all(&dir).unwrap(); + } + + /// Review P3-1: taking the name back deleted the record before the + /// settings file was replaced, so a crash or power cut between the two + /// left the file naming a card this host no longer knew it wrote. + #[test] + fn a_crash_before_the_name_is_taken_back_keeps_it_this_hosts() { + let dir = std::env::temp_dir().join(format!("vdd-pin-reset-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let (path, record) = ( + dir.join("vdd_settings.xml"), + dir.join("app").join("vdd-pin.txt"), + ); + let named = || named_gpu(&std::fs::read_to_string(&path).unwrap()).unwrap(); + let modes = default_modes(); + const INTEL: &str = "Intel(R) HD Graphics 630"; + const AMD: &str = "AMD Radeon(TM) Graphics"; + type Pin<'a> = dyn Fn(Option<&str>) -> io::Result + 'a; + let edit: &Pin = &|wanted| apply_pin(&path, &record, false, wanted); + let rewrite: &Pin = &|wanted| write_with_pin(&path, &record, false, &modes, wanted); + for pin in [edit, rewrite] { + std::fs::write(&path, VENDOR).unwrap(); + assert_eq!(pin(Some(INTEL)).unwrap(), PinOutcome::Named); + crash_before_replace(&record, None); + assert_eq!(named().as_deref(), Some(INTEL)); + // Still this host's: taken back on the next run. + assert_eq!(pin(None).unwrap(), PinOutcome::Reset); + assert_eq!(named(), None); + assert!(!record.exists()); + // And replaced by another card. + assert_eq!(pin(Some(INTEL)).unwrap(), PinOutcome::Named); + crash_before_replace(&record, None); + assert_eq!(pin(Some(AMD)).unwrap(), PinOutcome::Named); + assert_eq!(named().as_deref(), Some(AMD)); + assert_eq!(pin(None).unwrap(), PinOutcome::Reset); + } + std::fs::remove_dir_all(&dir).unwrap(); + } + /// Review round 3, defect 3: the marker counted whatever it said, after /// another app replaced or upgraded the driver. #[test]