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/ARCHITECTURE.md b/ARCHITECTURE.md index d99a0466..0e53e421 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -69,7 +69,8 @@ Rust and Swift share golden vectors and stable packet-type registry tests. One session owns the active link. Another device receives Busy; a reconnect from the same device supersedes its prior session. Duplicate accepted nonces replay the same ACK. All established-session traffic is session-id gated. Reports/input -maintain liveness; expiry removes the client and its managed virtual display. +maintain liveness; expiry removes the client, and 10 s later its managed +virtual display unless the iPad has come back. UDP carries video, audio and control on the same socket. The iPad's established Network.framework backend remains the default. A selectable BSD backend uses a diff --git a/Cargo.lock b/Cargo.lock index a6beaf61..cd4e20bd 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/RELEASE_NOTES.md b/RELEASE_NOTES.md index cba092d3..ded64510 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,50 @@ +## Next Windows release + +Not released yet. + +- 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. 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 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. + Before, 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. 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. 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 + 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 + driver another app installed is left alone with a message saying so. Before, + 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 earlier versions reported as the system. + ## EternalMonitor v0.3.2 Released October 22, 2026. Install the Windows and iPad 0.3.2 updates together; diff --git a/docs/beta-testing.md b/docs/beta-testing.md index 1c0cf0bf..52682a96 100644 --- a/docs/beta-testing.md +++ b/docs/beta-testing.md @@ -175,7 +175,8 @@ at the top of the host's Stream page, next to **Use the iPad as**, click **Secon Settings → How the iPad shows your PC). A running stream restarts by itself. Dragging a window past the edge of the main screen should land it on the iPad. The virtual monitor is created **on demand and only while the iPad is connected** — there is intentionally no second display when idle, so don't expect to see it in -Windows Display settings before connecting. If the extended display can't start, the host shows an +Windows Display settings before connecting. The iPad shows the mirrored main screen first and +switches to the second screen once Windows has added it. If the extended display can't start, the host shows an amber "The second screen couldn't start" banner and mirrors the primary screen — re-run the installer so its display task is registered. diff --git a/docs/decisions.md b/docs/decisions.md index c0c6301c..4b8bd45a 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -119,11 +119,28 @@ registers two SYSTEM scheduled tasks (enable/disable) that the host triggers via `schtasks`, avoiding a per-toggle UAC prompt. - Enabled only while an iPad is connected. Disabled on exit, target change, - startup, panic, and on client loss (the v2 liveness signal finally made - idle teardown possible). + startup, panic, and 10 s after client loss (the v2 liveness signal finally + made idle teardown possible; the grace period lets a quick reconnect keep it). +- The iPad mirrors the primary display while the virtual display starts, and + the stream restarts onto it once it attaches. That capture must deliver a + real image within 5 s; a display that fails to duplicate or only times out + goes back to the mirror and counts as a failed start. The 5 s run from the + switch, across any pipeline restart meanwhile. Capture never waits for the + driver: one worker thread makes every change, in order, and quitting stops + it before its final Disable. The iPad gives up 10 s after + the handshake without a frame, which a slow Enable task plus the attach wait + could exceed. A start that fails is remembered until the user clicks Try + again, restarts the stream or changes the mode, so reconnects mirror + instead of toggling the driver again. The host changes the driver at most + 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/Cargo.toml b/host/Cargo.toml index f0dff145..d5d40a8f 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) @@ -59,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/dxgi.rs b/host/src/capture/dxgi.rs index d4f45196..cb6a8949 100644 --- a/host/src/capture/dxgi.rs +++ b/host/src/capture/dxgi.rs @@ -16,10 +16,11 @@ use super::recovery::{ }; use super::{ client_connected, frame_budget_for, frame_has_image, heartbeat, idle_heartbeat, - reconcile_virtual_display, recovery_poll, resolve_target, FrameSlot, FrameUse, IdleGate, - OutputInfo, RawFrame, ACQUIRE_TIMEOUT_MS, FPS_WINDOW, IDLE_KEEPALIVE, + keepalive_allowed, keepalive_due, read_back_image, reconcile_virtual_display, recovery_poll, + resolve_target, FrameSlot, FrameUse, IdleGate, OutputInfo, RawFrame, ACQUIRE_TIMEOUT_MS, + FPS_WINDOW, }; -use crate::control::SharedControl; +use crate::control::{CaptureTarget, SharedControl}; use crate::stats::PIPELINE_STATS; use windows::Win32::Foundation::RECT; use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_UNKNOWN; @@ -121,6 +122,8 @@ pub fn run_capture_loop( let Some(target) = reconcile_virtual_display(&requested_target, &shared, generation) else { return Ok(()); }; + let second_screen = matches!(&target, CaptureTarget::Output(name) + if shared.vdd_output.lock().as_deref() == Some(name.as_str())); // DXGI factories snapshot adapters at creation. Enable the driver first, // then enumerate and open the chosen output from the same fresh factory. let factory: IDXGIFactory1 = unsafe { CreateDXGIFactory1()? }; @@ -361,6 +364,9 @@ pub fn run_capture_loop( match hr { Ok(()) => { *frame_number += 1; + if !super::second_screen::PROOF_NEEDS_IMAGE { + super::second_screen::note_image(&shared, generation); + } // --- Track cursor position/shape (must read shape before ReleaseFrame) --- if frame_info.LastMouseUpdateTime != 0 { @@ -414,87 +420,86 @@ pub fn run_capture_loop( } continue; } - seen_image = true; + let frame_data = read_back_image(&shared, generation, &mut seen_image, || { + windows::core::Result::Ok(if let Some(ref resource) = desktop_resource { + match resource.cast::() { + Ok(desktop_texture) => { + unsafe { + device_context.CopyResource(&staging_texture, &desktop_texture); + } - let frame_data: Option>> = if let Some(ref resource) = desktop_resource - { - match resource.cast::() { - Ok(desktop_texture) => { - unsafe { - device_context.CopyResource(&staging_texture, &desktop_texture); - } + // Release acquired frame — staging texture has its own copy + unsafe { + dupl.ReleaseFrame()?; + } - // Release acquired frame — staging texture has its own copy - unsafe { - dupl.ReleaseFrame()?; - } + let mut pixel_buf = + match spare.take().and_then(|arc| Arc::try_unwrap(arc).ok()) { + Some(buf) if buf.len() == frame_bytes => buf, + _ => vec![0u8; frame_bytes], + }; - let mut pixel_buf = - match spare.take().and_then(|arc| Arc::try_unwrap(arc).ok()) { - Some(buf) if buf.len() == frame_bytes => buf, - _ => vec![0u8; frame_bytes], - }; - - // Map staging texture for CPU read - let mut mapped = D3D11_MAPPED_SUBRESOURCE::default(); - unsafe { - device_context.Map( - &staging_texture, - 0, - D3D11_MAP_READ, - 0, - Some(&mut mapped), - )?; - } + // Map staging texture for CPU read + let mut mapped = D3D11_MAPPED_SUBRESOURCE::default(); + unsafe { + device_context.Map( + &staging_texture, + 0, + D3D11_MAP_READ, + 0, + Some(&mut mapped), + )?; + } + + // Copy row-by-row (pitch may differ from width*4) + let src_pitch = mapped.RowPitch as usize; + let src_ptr = mapped.pData as *const u8; + for row in 0..tex_height as usize { + let src_offset = row * src_pitch; + let dst_offset = row * row_bytes; + unsafe { + std::ptr::copy_nonoverlapping( + src_ptr.add(src_offset), + pixel_buf.as_mut_ptr().add(dst_offset), + row_bytes, + ); + } + } - // Copy row-by-row (pitch may differ from width*4) - let src_pitch = mapped.RowPitch as usize; - let src_ptr = mapped.pData as *const u8; - for row in 0..tex_height as usize { - let src_offset = row * src_pitch; - let dst_offset = row * row_bytes; unsafe { - std::ptr::copy_nonoverlapping( - src_ptr.add(src_offset), - pixel_buf.as_mut_ptr().add(dst_offset), + device_context.Unmap(&staging_texture, 0); + } + + // Composite the mouse cursor into the BGRA frame. + if cursor_visible && cursor.has_shape { + draw_cursor( + &mut pixel_buf, + tex_width, + tex_height, row_bytes, + &cursor, + cursor_x, + cursor_y, ); } - } - unsafe { - device_context.Unmap(&staging_texture, 0); + Some(Arc::new(pixel_buf)) } - - // Composite the mouse cursor into the BGRA frame. - if cursor_visible && cursor.has_shape { - draw_cursor( - &mut pixel_buf, - tex_width, - tex_height, - row_bytes, - &cursor, - cursor_x, - cursor_y, - ); + Err(e) => { + warn!(error = %e, "Failed to cast IDXGIResource to ID3D11Texture2D"); + unsafe { + dupl.ReleaseFrame()?; + } + None } - - Some(Arc::new(pixel_buf)) } - Err(e) => { - warn!(error = %e, "Failed to cast IDXGIResource to ID3D11Texture2D"); - unsafe { - dupl.ReleaseFrame()?; - } - None + } else { + unsafe { + dupl.ReleaseFrame()?; } - } - } else { - unsafe { - dupl.ReleaseFrame()?; - } - None - }; + None + }) + })?; if let Some(data) = frame_data { if idle.on_frame(client_connected(&shared), now) == FrameUse::Publish { @@ -531,8 +536,16 @@ pub fn run_capture_loop( // the iPad would sit on a black screen until it times out. While a client is // connected, resend the last captured image so the encoder emits the pending IDR // and the stream stays alive. pixel_buf holds the last composited frame (zeroed - // black before the first real frame, which is still a valid keyframe). - if client_connected(&shared) && last_send.elapsed() >= IDLE_KEEPALIVE { + // black before the first real frame, which is still a valid keyframe). A + // pending keyframe request is answered without waiting for the keepalive. + if client_connected(&shared) + && keepalive_due( + last_send.elapsed(), + shared.force_next_idr.load(Ordering::SeqCst), + seen_image, + ) + && keepalive_allowed(seen_image, second_screen) + { resend_last_frame( slot, &shared, @@ -590,7 +603,9 @@ pub fn run_capture_loop( return Ok(()); } let now_ms = crate::clock::host_now_us() / 1000; - if recovery_poll(&shared, now_ms, last_send.elapsed()) { + if recovery_poll(&shared, now_ms, last_send.elapsed()) + && keepalive_allowed(seen_image, second_screen) + { resend_last_frame( slot, &shared, diff --git a/host/src/capture/mod.rs b/host/src/capture/mod.rs index e2f61d69..9e80ca98 100644 --- a/host/src/capture/mod.rs +++ b/host/src/capture/mod.rs @@ -11,16 +11,19 @@ use std::time::{Duration, Instant}; use parking_lot::{Condvar, Mutex}; use tracing::error; -#[cfg(windows)] +#[cfg(any(windows, test))] use tracing::{info, warn}; use crate::control::SharedControl; -#[cfg(windows)] +#[cfg(any(windows, test))] use crate::control::{CaptureTarget, VddStatus}; +#[cfg(windows)] +use crate::vdd_device::DeviceState; #[cfg(windows)] pub mod dxgi; pub mod recovery; +pub mod second_screen; pub mod synthetic; pub(crate) mod timing; @@ -332,12 +335,245 @@ pub(crate) fn recovery_poll(shared: &SharedControl, now_ms: u64, since_send: Dur since_send >= IDLE_KEEPALIVE } -#[cfg(windows)] +#[cfg(any(windows, test))] fn capture_is_current(shared: &SharedControl, generation: u64) -> bool { shared.running.load(std::sync::atomic::Ordering::SeqCst) && crate::supervisor::generation_is_current(generation) } +#[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). +#[cfg(windows)] +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 +/// 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]) -> VddRequest { + let main_screen = outputs + .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(&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 = wanted.ok(); + let record = crate::settings::app_data_dir().map(|dir| dir.join(VDD_PIN_RECORD)); + 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; + 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!( + %error, + "Could not write vdd_settings.xml — the modes the driver has apply" + ), + } + written + } + // The modes in the file are the user's: change only the card. + Some(record) => crate::vdd_settings::apply_pin(path, record, owned, gpu), + }; + 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: crate::vdd_settings::pin_result(wanted, &applied), + mode, + before, + } +} + +/// 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 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 { + 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) == (target.width, target.height) { + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + info!( + 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" + ); + now +} + +/// Log how Windows sees the displays, before the Disable task removes the +/// 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: 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) + ); +} + +/// 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 (`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) +} + +/// 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, +/// 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") @@ -356,168 +592,135 @@ pub(crate) fn client_connected(shared: &SharedControl) -> bool { } /// Reconcile the virtual display device to the requested target and return the concrete -/// target to capture. `VirtualExtended` enables the bundled driver **only once an iPad is -/// connected**, waits for a genuinely-new output to attach, and returns it as an `Output(name)`; -/// any other target (and the no-client / failure / timeout cases) disables the driver so no -/// phantom monitor lingers, falling back to `PrimaryAuto`. -#[cfg(windows)] +/// target to capture. `VirtualExtended` captures the virtual display once it is attached and +/// a viewer is connected (or one left less than [`second_screen::RELEASE_GRACE`] ago), and +/// mirrors `PrimaryAuto` otherwise: while [`second_screen`] turns the display on beside the +/// mirror, after it failed to, and with nobody connected. Any other target turns the display +/// off. Never waits for the driver: the [`second_screen`] worker changes it, and the mirror +/// must not wait behind a slow Enable or attach. +#[cfg(any(windows, test))] pub(crate) fn reconcile_virtual_display( target: &CaptureTarget, shared: &SharedControl, generation: u64, ) -> Option { - let progress = || { - if capture_is_current(shared, generation) { - startup_heartbeat(shared, crate::clock::host_now_us() / 1000); - } - }; - // A previous capture thread may still be leaving its attach poll or a - // driver task (up to 30 s) when the supervisor starts the next generation. - // Only one may trigger driver tasks. Waiting here without heartbeats read - // as a wedged capture loop after 3 s, and each forced restart queued - // another generation behind the same lock. - let _lifecycle = loop { - if let Some(guard) = shared.vdd_lifecycle.try_lock_for(Duration::from_millis(50)) { - break guard; - } - if !capture_is_current(shared, generation) { - return None; - } - progress(); - }; if !capture_is_current(shared, generation) { return None; } + let owned = shared.vdd_output.lock().clone(); match target { CaptureTarget::VirtualExtended => { - // Defer enabling the VDD until an iPad actually connects. The transport restarts the - // pipeline on the first receiver registration, so this runs again with a connected - // client at that point and the display comes up then — never while the PC sits idle. - let waiting_for_client = { - let session = shared.session.lock(); - let waiting = !session.is_active(); - *shared.vdd_status.lock() = if waiting { - VddStatus::WaitingForClient - } else { - VddStatus::Attaching - }; - waiting - }; - if waiting_for_client { - info!( - "Extended display selected but no iPad has connected yet — leaving the \ - virtual display off and mirroring the primary display until a client registers" + // A restart must not erase a second screen that already failed + // to show an image: it goes back to the mirror now. + if let Some(reason) = second_screen::take_unproven(shared, Instant::now()) { + warn!( + second_screen_fallback = reason, + prove_timeout_ms = second_screen::PROVE_TIMEOUT.as_millis() as u64, + "The virtual display showed no picture — mirroring the primary display instead" ); - crate::vdd::set_enabled(false, progress); - *shared.vdd_output.lock() = None; - return capture_is_current(shared, generation) - .then_some(CaptureTarget::PrimaryAuto); + second_screen::send(shared, second_screen::Command::Fallback(reason)); + return Some(CaptureTarget::PrimaryAuto); + } + let connected = shared.client_connected(); + let status = *shared.vdd_status.lock(); + if matches!( + status, + VddStatus::Attaching | VddStatus::Failed | VddStatus::TaskFailed + ) { + return Some(CaptureTarget::PrimaryAuto); } - - let outputs = enumerate_outputs(); // Settings and codec changes restart capture without removing the - // managed monitor. Reuse that output rather than waiting for it to - // appear a second time or selecting a physical secondary monitor. - if let Some(name) = shared.vdd_output.lock().as_ref() { - if outputs + // managed monitor, and the stream moves onto it this way once it + // attaches. Reuse that output rather than turning it on again. + let attached = owned.clone().filter(|name| { + enumerate_outputs() .iter() .any(|output| !output.is_primary && &output.device_name == name) + }); + if let Some(name) = attached { + if status == VddStatus::Active + && (connected || shared.second_screen.lock().keep_without_viewer()) { - *shared.vdd_status.lock() = VddStatus::Active; - return Some(CaptureTarget::Output(name.clone())); + second_screen::start_proving(shared, generation, Instant::now()); + return Some(CaptureTarget::Output(name)); } } - *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 { - 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) { - 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" - ), - } + // The transport's next tick starts the display beside this mirror. + // A viewer that arrived meanwhile may already have claimed it. + { + let mut status = shared.vdd_status.lock(); + if *status != VddStatus::Attaching { + *status = VddStatus::WaitingForClient; } } - - let before: Vec = outputs.into_iter().map(|o| o.device_name).collect(); - if !capture_is_current(shared, generation) { - return None; - } - startup_heartbeat(shared, crate::clock::host_now_us() / 1000); - if !crate::vdd::set_enabled(true, progress) { - if !capture_is_current(shared, generation) { - return None; - } - warn!( - "Virtual display could not be enabled (installer task missing?) — \ - capturing the primary display instead" + if !connected { + info!( + "Extended display selected but no iPad has connected yet — leaving the \ + virtual display off and mirroring the primary display until a client registers" ); - *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, - ); - if !capture_is_current(shared, generation) { - return None; - } - match result { - AttachResult::Cancelled => return None, - AttachResult::Attached(chosen) => { - info!( - device = %chosen.device_name, - width = chosen.width, - height = chosen.height, - "Virtual display attached — capturing it" - ); - *shared.vdd_output.lock() = Some(chosen.device_name.clone()); - *shared.vdd_status.lock() = VddStatus::Active; - return Some(CaptureTarget::Output(chosen.device_name)); + if owned.is_some() { + second_screen::send(shared, second_screen::Command::Release); } - AttachResult::TimedOut => {} } - warn!("Virtual display did not attach in time — capturing the primary display instead"); - // 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); - *shared.vdd_status.lock() = VddStatus::Failed; Some(CaptureTarget::PrimaryAuto) } other => { // Any non-virtual target: ensure the virtual display is off. - crate::vdd::set_enabled(false, progress); - *shared.vdd_output.lock() = None; *shared.vdd_status.lock() = VddStatus::Inactive; + if owned.is_some() { + second_screen::send(shared, second_screen::Command::Release); + } Some(other.clone()) } } } +/// Whether an idle keepalive may resend the last frame. Before the second +/// screen has delivered an image, that frame is the black placeholder: +/// sending it would replace the iPad's mirror with black, so the switch +/// waits for a real picture, or falls back to the mirror. +#[cfg(any(windows, test))] +pub(crate) fn keepalive_allowed(seen_image: bool, second_screen: bool) -> bool { + seen_image || !second_screen +} + +/// How long a keyframe request waits for the encoder to take the resent frame +/// before the picture is resent again. +#[cfg(any(windows, test))] +const KEYFRAME_RESEND: Duration = Duration::from_millis(100); + +/// Whether an idle display's last picture is due to be resent to the viewer. +/// A keyframe request (a viewer that joined or lost its picture) is answered +/// at once when there is a real picture to send: a viewer back within the +/// second screen's grace period otherwise waited for the next keepalive, up +/// to 750 ms, before the encoder could start its session. +#[cfg(any(windows, test))] +pub(crate) fn keepalive_due( + since_send: Duration, + keyframe_requested: bool, + seen_image: bool, +) -> bool { + since_send >= IDLE_KEEPALIVE + || (keyframe_requested && seen_image && since_send >= KEYFRAME_RESEND) +} + +/// Reads back an acquired frame for the encoder. Only an image that was read +/// back counts as the second screen's proof: when the readback fails, the +/// proof stays pending for the capture error that follows. +#[cfg(any(windows, test))] +pub(crate) fn read_back_image( + shared: &SharedControl, + generation: u64, + seen_image: &mut bool, + read: impl FnOnce() -> Result, E>, +) -> Result, E> { + let image = read()?; + if image.is_some() && !std::mem::replace(seen_image, true) { + second_screen::note_image(shared, generation); + } + Ok(image) +} + /// Runs the platform capture loop on the CURRENT thread until stop/close, /// publishing frames into `slot`. The supervisor spawns and monitors this. pub fn run_capture_stage( @@ -536,13 +739,17 @@ pub fn run_capture_stage( let result = recovery::run_until_unblocked( &shared, |frame_number| { - run_platform_loop( + let result = run_platform_loop( &guard.0, shared.clone(), adapter_index, generation, frame_number, - ) + ); + if result.is_err() { + second_screen::note_capture_error(&shared, generation); + } + result }, |delay| wait_while_blocked(&shared, generation, delay), ); @@ -582,6 +789,13 @@ pub fn frame_slot() -> (FrameSlot, FrameSlot) { (producer, consumer) } +/// `ETERNAL_CAPTURE=synthetic`: the test pattern instead of the desktop, and +/// no virtual display driver. +#[cfg(windows)] +pub(crate) fn synthetic_requested() -> bool { + std::env::var("ETERNAL_CAPTURE").is_ok_and(|v| v.trim().eq_ignore_ascii_case("synthetic")) +} + #[cfg(windows)] fn run_platform_loop( slot: &FrameSlot, @@ -590,9 +804,7 @@ fn run_platform_loop( generation: u64, frame_number: &mut u64, ) -> Result<(), Box> { - let synthetic_requested = - std::env::var("ETERNAL_CAPTURE").is_ok_and(|v| v.trim().eq_ignore_ascii_case("synthetic")); - if synthetic_requested { + if synthetic_requested() { synthetic::run_capture_loop(slot, shared, generation, frame_number) } else { dxgi::run_capture_loop(slot, shared, adapter_index, generation, frame_number) @@ -632,7 +844,7 @@ mod tests { assert!(frame_has_image(0, true)); } - fn connect_viewer(shared: &SharedControl) { + pub(super) fn connect_viewer(shared: &SharedControl) { use crate::transport::link::PeerId; use crate::transport::session::ConfigSource; use eternal_wire::v2::control::{ControlMessage, Hello2, StreamConfig, CAP_DECODE_H264}; @@ -740,6 +952,25 @@ mod tests { assert!(recovery_poll(&shared, now + 50, Duration::from_secs(6))); } + /// A viewer back within the second screen's grace period finds a static + /// picture that DXGI reports no change to. Its session's keyframe request + /// is answered at the next acquire timeout, not at the next keepalive. + #[test] + fn a_keyframe_request_on_a_static_display_is_answered_at_once() { + let ms = Duration::from_millis; + let first_resend = |requested: bool, seen_image: bool| { + (0..) + .map(|poll| ms(10 + 16 * poll)) + .find(|&since_send| keepalive_due(since_send, requested, seen_image)) + .unwrap() + }; + assert_eq!(first_resend(false, true), ms(762)); + assert!(first_resend(true, true) <= KEYFRAME_RESEND + ms(16)); + // Before the display's first image only the black placeholder exists; + // the keepalive sends it on the mirror, and never on the second screen. + assert_eq!(first_resend(true, false), ms(762)); + } + #[test] fn an_unwatched_desktop_is_acquired_once_a_second_and_never_encoded() { let start = Instant::now(); @@ -893,6 +1124,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, diff --git a/host/src/capture/second_screen.rs b/host/src/capture/second_screen.rs new file mode 100644 index 00000000..2807cf2b --- /dev/null +++ b/host/src/capture/second_screen.rs @@ -0,0 +1,2005 @@ +//! Starting the second screen beside a live mirror. +//! +//! 0.3.2 held video back while it turned the virtual display on: about 3 s +//! for the Enable task (6 s behind a Disable still running), then up to 10 s +//! for the display to attach. The iPad gives up 10 s after the handshake when +//! no frame has decoded, so on a PC whose display attached slowly or never, +//! the iPad left first and the mirror fallback never reached it. Over USB the +//! host dialed again a second later and turned the display off and on again +//! for every attempt. +//! +//! Now a connecting iPad gets the mirror at once, from the capture generation +//! already running. One worker thread here turns the display on and off, fed +//! by a queue, so capture never waits for the driver, and the gap between +//! changes, a start cancelled by a mode change and quitting are decided in +//! one place. Once the display attaches the transport restarts the pipeline +//! onto it, and the second screen must deliver a real image within +//! [`PROVE_TIMEOUT`] of that switch, across any restarts meanwhile; +//! otherwise the stream goes back to the mirror. A start that failed is +//! remembered: later connections stay on the mirror until the user clicks +//! Try again, restarts the stream, or changes the mode. + +use std::collections::VecDeque; +use std::time::{Duration, Instant}; + +use parking_lot::{Condvar, Mutex}; +#[cfg(any(windows, test))] +use tracing::info; +use tracing::warn; + +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 +/// background or a short Wi-Fi drop is back within seconds; both find the +/// second screen still there. +pub(crate) const RELEASE_GRACE: Duration = Duration::from_secs(10); + +/// The host never turns the virtual display on or off again sooner than this +/// after its previous change. Each change adds or removes a display adapter: +/// the PC's screens blink and Windows plays its device sound. +#[cfg(any(windows, test))] +pub(crate) const MIN_TOGGLE_GAP: Duration = Duration::from_secs(10); + +/// How long the second screen has, from the switch onto it, to capture a +/// real image. Until then the iPad keeps showing the last mirror frame; a +/// display that fails to duplicate, or only ever times out, goes back to the +/// mirror after this. +pub const PROVE_TIMEOUT: Duration = Duration::from_secs(5); + +/// What proves the second screen. True: an image read back for the encoder +/// from a frame Windows presented (`LastPresentTime != 0`). False: any frame +/// the duplication delivers, even one that only moves the pointer. +#[cfg(windows)] +pub(crate) const PROOF_NEEDS_IMAGE: bool = true; + +#[cfg(any(windows, test))] +const WAIT_STEP: Duration = Duration::from_millis(100); + +/// How long a stopped pipeline may take to come back before the display is +/// left off: a restart joins the old generation for up to 5 s, while a quit +/// or Stop leaves it stopped. +#[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); + +/// How long quitting waits for an Enable in flight: the driver task's own +/// limit is 30 s. +const QUIT_ENABLE_WAIT: Duration = Duration::from_secs(35); + +/// What the transport does next for the second screen. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum Step { + Nothing, + /// A viewer arrived: start the display beside the mirror. + Attach, + /// The display attached while the mirror streamed: restart onto it. + Switch, + /// Nobody came back within the grace period: restart to turn it off. + Release, + /// The display showed no picture: restart onto the mirror, turn it off. + Fallback(&'static str), +} + +/// The second screen being streamed, before its first image. It outlives the +/// capture generation that started it: a restart meanwhile keeps the +/// deadline and any capture failure. +#[derive(Debug, Clone, Copy)] +struct Proving { + generation: u64, + since: Instant, + capture_failed: bool, +} + +#[derive(Debug, Default)] +pub(crate) struct State { + /// The display attached; the stream has not moved onto it yet. + switch_pending: bool, + /// When the attached display was last seen without a viewer. + viewer_left_at: Option, + /// The grace period ended; the next capture generation turns it off. + release_requested: bool, + /// When the host last turned the display on or off. + last_toggle: Option, + /// A start is claimed: its Attach is queued or running. + attaching: bool, + proving: Option, + /// Why the second screen last failed, for the log and the backend. + failure: Option<&'static str>, +} + +impl State { + pub(crate) fn step( + &mut self, + selected: bool, + status: &mut VddStatus, + connected: bool, + now: Instant, + ) -> Step { + if !selected { + *self = State { + last_toggle: self.last_toggle, + attaching: self.attaching, + ..State::default() + }; + return Step::Nothing; + } + if let Some(reason) = self.fail_unproven(status, now) { + return Step::Fallback(reason); + } + match *status { + VddStatus::WaitingForClient if connected => { + *status = VddStatus::Attaching; + self.attaching = true; + Step::Attach + } + VddStatus::Active if connected => { + self.viewer_left_at = None; + self.release_requested = false; + if std::mem::take(&mut self.switch_pending) { + Step::Switch + } else { + Step::Nothing + } + } + VddStatus::Active if !self.release_requested => { + let left = *self.viewer_left_at.get_or_insert(now); + if now.duration_since(left) < RELEASE_GRACE { + return Step::Nothing; + } + self.viewer_left_at = None; + self.switch_pending = false; + self.release_requested = true; + Step::Release + } + _ => Step::Nothing, + } + } + + /// End a second screen that failed to prove itself: a capture error, or + /// no image within [`PROVE_TIMEOUT`] of the switch. Returns why. + fn fail_unproven(&mut self, status: &mut VddStatus, now: Instant) -> Option<&'static str> { + let proving = self.proving?; + let reason = if proving.capture_failed { + "duplication_failed" + } else if now.duration_since(proving.since) >= PROVE_TIMEOUT { + "no_frames" + } else { + return None; + }; + *self = State { + last_toggle: self.last_toggle, + attaching: self.attaching, + failure: Some(reason), + ..State::default() + }; + *status = VddStatus::Failed; + Some(reason) + } + + /// Whether a capture generation with no viewer should leave an attached + /// display on: only the grace period's end turns it off. + #[cfg(any(windows, test))] + pub(crate) fn keep_without_viewer(&self) -> bool { + !self.release_requested + } + + #[cfg(any(windows, test))] + pub(crate) fn note_toggle(&mut self, now: Instant) { + self.last_toggle = Some(now); + self.release_requested = false; + } + + #[cfg(any(windows, test))] + fn toggle_delay(&self, now: Instant) -> Duration { + self.last_toggle.map_or(Duration::ZERO, |at| { + (at + MIN_TOGGLE_GAP).saturating_duration_since(now) + }) + } +} + +/// The transport's decision for this tick. Status before state, the order +/// every caller locks them in. +pub(crate) fn next_step(shared: &SharedControl, now: Instant) -> Step { + let selected = *shared.capture_target.lock() == CaptureTarget::VirtualExtended; + let connected = shared.client_connected(); + let mut status = shared.vdd_status.lock(); + shared + .second_screen + .lock() + .step(selected, &mut status, connected, now) +} + +/// A new capture generation ends a second screen that already failed to +/// prove itself, before anything else: a supervisor restart (a capture +/// error that ended the stage, or the watchdog) must not erase the failure. +/// Returns why it failed. +#[cfg(any(windows, test))] +pub(crate) fn take_unproven(shared: &SharedControl, now: Instant) -> Option<&'static str> { + let mut status = shared.vdd_status.lock(); + shared.second_screen.lock().fail_unproven(&mut status, now) +} + +/// Whether the second screen is still proving itself. Nothing is sent from +/// it meanwhile, so the watchdog leaves the verdict to [`next_step`] and +/// [`take_unproven`]. +pub(crate) fn proving(shared: &SharedControl) -> bool { + shared.second_screen.lock().proving.is_some() +} + +/// Why connections mirror instead of extending, after a failed start. The +/// value appears as `second_screen_fallback=` in the log, which the +/// diagnostics upload sends as is. +pub(crate) fn remembered_failure(shared: &SharedControl) -> Option<&'static str> { + let status = *shared.vdd_status.lock(); + let failure = shared.second_screen.lock().failure; + match status { + VddStatus::Failed => Some(failure.unwrap_or("attach_timeout")), + VddStatus::TaskFailed => Some(failure.unwrap_or("enable_failed")), + _ => None, + } +} + +/// The user asked to try again: a failed start, or one whose thread is gone, +/// is tried once more at the next connection. +pub(crate) fn retry(shared: &SharedControl) { + let mut status = shared.vdd_status.lock(); + let mut state = shared.second_screen.lock(); + let stale = *status == VddStatus::Attaching && !state.attaching; + if stale || matches!(*status, VddStatus::Failed | VddStatus::TaskFailed) { + *status = VddStatus::WaitingForClient; + state.failure = None; + } +} + +/// The display attached and is the stream's next source. +pub fn note_attached(shared: &SharedControl, device_name: String) { + *shared.vdd_output.lock() = Some(device_name); + let mut status = shared.vdd_status.lock(); + *status = VddStatus::Active; + let mut state = shared.second_screen.lock(); + state.switch_pending = true; + 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. +pub fn start_proving(shared: &SharedControl, generation: u64, now: Instant) { + let mut state = shared.second_screen.lock(); + match state.proving.as_mut() { + Some(proving) => proving.generation = generation, + None => { + state.proving = Some(Proving { + generation, + since: now, + capture_failed: false, + }) + } + } +} + +/// Capture `generation` captured a real image, ready for the encoder. +pub fn note_image(shared: &SharedControl, generation: u64) { + let mut state = shared.second_screen.lock(); + if let Some(proving) = state.proving.filter(|p| p.generation == generation) { + state.proving = None; + tracing::info!( + after_ms = proving.since.elapsed().as_millis() as u64, + "Second screen captured its first image — streaming it" + ); + } +} + +/// Capture `generation` failed to open or read its display, for example +/// DuplicateOutput returned DXGI_ERROR_UNSUPPORTED. On the second screen, +/// before its first image, that ends the attempt at the next tick, or when +/// the next capture generation starts. +pub fn note_capture_error(shared: &SharedControl, generation: u64) { + let mut state = shared.second_screen.lock(); + if let Some(proving) = state.proving.as_mut() { + if proving.generation == generation { + proving.capture_failed = true; + } + } +} + +/// What the virtual display worker is asked to do. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Command { + /// A viewer arrived: turn the display on beside the mirror. + 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. +#[derive(Default)] +pub(crate) struct Worker { + queue: Mutex, + wake: Condvar, + /// Held across each Enable. Quitting takes it after setting + /// [`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)] +struct Queue { + commands: VecDeque, + quitting: bool, +} + +/// Ask the worker to change the display. Returns at once. +pub(crate) fn send(shared: &SharedControl, command: Command) { + if !driver_available() { + without_driver(shared, command); + return; + } + { + let mut queue = shared.vdd_worker.queue.lock(); + if queue.quitting { + return; + } + queue.commands.push_back(command); + } + shared.vdd_worker.wake.notify_one(); + // Unit tests run the queue themselves, with a fake driver. + #[cfg(windows)] + if cfg!(test) { + return; + } + #[cfg(windows)] + if let Err(error) = spawn_worker(shared) { + warn!(%error, "Could not start the virtual display worker"); + shared.vdd_worker.queue.lock().commands.clear(); + if command == Command::Attach { + warn!( + second_screen_fallback = "enable_failed", + "Could not start the second screen — mirroring the primary display instead" + ); + fail_start(shared, "enable_failed"); + } + } +} + +/// Unit tests queue commands too; they never reach the installed driver +/// tasks. +fn driver_available() -> bool { + #[cfg(windows)] + { + cfg!(test) || !super::synthetic_requested() + } + #[cfg(not(windows))] + { + cfg!(test) + } +} + +fn without_driver(shared: &SharedControl, command: Command) { + match command { + Command::Attach => { + tracing::info!( + second_screen_fallback = "no_driver", + "No virtual display driver with this capture source — mirroring the primary display" + ); + fail_start(shared, "no_driver"); + } + Command::Release | Command::Fallback(_) => *shared.vdd_output.lock() = None, + } +} + +fn fail_start(shared: &SharedControl, reason: &'static str) { + let mut status = shared.vdd_status.lock(); + *status = VddStatus::TaskFailed; + let mut state = shared.second_screen.lock(); + state.attaching = false; + state.failure = Some(reason); +} + +#[cfg(windows)] +fn spawn_worker(shared: &SharedControl) -> std::io::Result<()> { + let mut thread = shared.vdd_worker.thread.lock(); + if thread.is_none() { + let worker = shared.clone(); + *thread = Some( + std::thread::Builder::new() + .name("second-screen".into()) + .spawn(move || { + run( + &worker, + &WindowsDriver { + shared: &worker, + request: Default::default(), + }, + super::vdd_attach_timeout(), + true, + ) + })?, + ); + } + Ok(()) +} + +/// Quitting: once this returns no Enable runs or will start, so the caller's +/// Disable is the last change. Waits for an Enable in flight, then for the +/// worker to exit, both bounded. +pub fn shut_down(shared: &SharedControl) { + let worker = &shared.vdd_worker; + worker.queue.lock().quitting = true; + worker.wake.notify_all(); + if worker.enable.try_lock_for(QUIT_ENABLE_WAIT).is_none() { + warn!("An Enable of the virtual display is still running — turning it off after it"); + } + let Some(thread) = worker.thread.lock().take() else { + return; + }; + let deadline = Instant::now() + QUIT_JOIN; + while !thread.is_finished() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + if thread.is_finished() { + let _ = thread.join(); + } else { + warn!("The virtual display worker did not stop in time"); + } +} + +#[cfg(any(windows, test))] +fn quitting(shared: &SharedControl) -> bool { + shared.vdd_worker.queue.lock().quitting +} + +/// The worker: one command at a time, until the app quits. With `wait` +/// false it returns once the queue is empty, for tests on a paused clock. +#[cfg(any(windows, test))] +pub(crate) fn run( + shared: &SharedControl, + driver: &impl Driver, + attach_timeout: Duration, + wait: bool, +) { + loop { + let command = { + let mut queue = shared.vdd_worker.queue.lock(); + loop { + if queue.quitting { + return; + } + if let Some(command) = queue.commands.pop_front() { + break command; + } + if !wait { + return; + } + shared.vdd_worker.wake.wait(&mut queue); + } + }; + 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); + } + } + } +} + +/// What turning the display on needs from Windows, so tests can supply a +/// clock and a driver that attaches late or never. +#[cfg(any(windows, test))] +pub(crate) trait Driver { + fn outputs(&self) -> Vec; + /// 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))] +struct AttachExit<'a>(&'a SharedControl); + +#[cfg(any(windows, test))] +impl Drop for AttachExit<'_> { + fn drop(&mut self) { + let mut status = self.0.vdd_status.lock(); + if *status == VddStatus::Attaching { + *status = VddStatus::WaitingForClient; + } + self.0.second_screen.lock().attaching = false; + } +} + +/// Turn the display on, wait for it to attach, and record the result. A +/// viewer leaving does not cancel it; a change of mode or quitting does. +/// Capture keeps mirroring throughout. +#[cfg(any(windows, test))] +pub(crate) fn attach(shared: &SharedControl, driver: &impl Driver, timeout: Duration) { + let _exit = AttachExit(shared); + let selected = + || *shared.capture_target.lock() == CaptureTarget::VirtualExtended && !quitting(shared); + if !selected() { + return; + } + // Still on from before (its release saw this start coming): reuse it, + // with no change to wait for. + let outputs = driver.outputs(); + let owned = shared.vdd_output.lock().clone(); + if let Some(name) = owned.filter(|name| { + outputs + .iter() + .any(|output| !output.is_primary && &output.device_name == name) + }) { + info!(device = %name, "Virtual display still attached — capturing it"); + note_attached(shared, name); + return; + } + if !wait_for_toggle_gap(shared, driver, &selected) { + return; + } + if !shared.client_connected() { + info!("The iPad left before the virtual display was turned on — leaving it off"); + return; + } + let stopped_at = driver.now(); + while !shared.running.load(std::sync::atomic::Ordering::SeqCst) { + if !selected() || driver.now().duration_since(stopped_at) >= STOPPED_WAIT { + info!("The stream stopped before the virtual display was turned on — leaving it off"); + return; + } + driver.sleep(WAIT_STEP); + } + let outputs = driver.outputs(); + 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 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, + || selected().then(|| driver.outputs()), + || 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"); + } + AttachResult::Cancelled => { + // The user chose another mode while the display came up. Nothing + // 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) => { + // Setting the mode can take seconds, and the user may choose + // another mode meanwhile. + let chosen = driver.settle(chosen); + 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!( + second_screen_fallback = "attach_timeout", + 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); + let mut status = shared.vdd_status.lock(); + *status = VddStatus::Failed; + shared.second_screen.lock().failure = Some("attach_timeout"); + } + } +} + +/// 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> { + let _enable = shared.vdd_worker.enable.lock(); + if quitting(shared) { + return None; + } + let enabled = driver.enable(); + shared.second_screen.lock().note_toggle(driver.now()); + Some(enabled) +} + +/// Turn the display off after the gap. Quitting skips it: the quit turns it +/// off at once. +#[cfg(any(windows, test))] +fn disable(shared: &SharedControl, driver: &impl Driver) { + if wait_for_toggle_gap(shared, driver, &|| !quitting(shared)) { + driver.set_enabled(false); + shared.second_screen.lock().note_toggle(driver.now()); + } + *shared.vdd_output.lock() = None; +} + +/// Turn off a display this host turned on, unless a start claimed it since. +/// Only the claim counts, not the status: a capture generation may publish +/// the status after asking for this. +#[cfg(any(windows, test))] +pub(crate) fn release(shared: &SharedControl, driver: &impl Driver) { + let wanted = || { + *shared.capture_target.lock() == CaptureTarget::VirtualExtended + && shared.second_screen.lock().attaching + }; + if shared.vdd_output.lock().is_none() || wanted() { + return; + } + // Marks when the worker took the request, apart from when the task ran. + info!("Turning the virtual display off"); + if !wait_for_toggle_gap(shared, driver, &|| !wanted() && !quitting(shared)) { + return; + } + driver.set_enabled(false); + *shared.vdd_output.lock() = None; + shared.second_screen.lock().note_toggle(driver.now()); + info!("Virtual display turned off"); +} + +/// Wait out [`MIN_TOGGLE_GAP`]. False when `go_on` turned false meanwhile. +#[cfg(any(windows, test))] +fn wait_for_toggle_gap( + shared: &SharedControl, + driver: &impl Driver, + go_on: &dyn Fn() -> bool, +) -> bool { + let delay = shared.second_screen.lock().toggle_delay(driver.now()); + if delay.is_zero() { + return go_on(); + } + info!( + wait_ms = delay.as_millis() as u64, + "Waiting before changing the virtual display again, so Windows doesn't add and \ + remove it in quick succession" + ); + let until = driver.now() + delay; + while driver.now() < until { + if !go_on() { + return false; + } + driver.sleep(until.saturating_duration_since(driver.now()).min(WAIT_STEP)); + } + go_on() +} + +#[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)] +impl Driver for WindowsDriver<'_> { + fn outputs(&self) -> Vec { + super::enumerate_outputs() + } + + 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() + } + + fn sleep(&self, duration: Duration) { + std::thread::sleep(duration); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::capture::reconcile_virtual_display; + use crate::capture::tests::connect_viewer; + use crate::transport::link::LinkId; + use std::cell::{Cell, RefCell}; + use std::sync::atomic::Ordering; + + /// The iPad's connect timeout in 0.3.1 and 0.3.2: it disconnects when no + /// frame has decoded this long after the handshake. + const IPAD_TIMEOUT: Duration = Duration::from_secs(10); + /// The 0.3.2 default attach wait after Enable. + const ATTACH_TIMEOUT: Duration = Duration::from_secs(10); + + fn secs(seconds: f64) -> Duration { + Duration::from_secs_f64(seconds) + } + + /// Runs `action` once, the first time the paused clock reaches `at`. + struct Once { + at: Duration, + done: Cell, + } + + impl Once { + fn at(at: Duration) -> Self { + Self { + at, + done: Cell::new(false), + } + } + + fn run(&self, elapsed: Duration, action: impl FnOnce()) { + if elapsed >= self.at && !self.done.replace(true) { + action(); + } + } + } + + fn second_screen_selected() -> SharedControl { + let shared = SharedControl::new(9876, 15_000_000); + shared + .running + .store(true, std::sync::atomic::Ordering::SeqCst); + *shared.capture_target.lock() = CaptureTarget::VirtualExtended; + *shared.vdd_status.lock() = VddStatus::WaitingForClient; + shared + } + + fn disconnect(shared: &SharedControl) { + assert!(shared.session.lock().link_lost(LinkId::Udp).client_lost); + } + + fn output(name: &str, primary: bool) -> OutputInfo { + OutputInfo { + adapter_index: 0, + output_index: 0, + device_name: name.to_string(), + width: 1920, + height: 1080, + left: if primary { 0 } else { -1920 }, + top: 0, + is_primary: primary, + adapter_name: "Test GPU".to_string(), + } + } + + /// 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. 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>, + on_sleep: &'a dyn Fn(Duration), + enable_fails: Option, + calls: RefCell>, + } + + impl<'a> FakeDriver<'a> { + fn new( + enable_takes: Duration, + attach_after: Option, + on_sleep: &'a dyn Fn(Duration), + ) -> Self { + Self { + 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()), + on_sleep, + enable_fails: None, + calls: RefCell::new(Vec::new()), + } + } + + fn calls(&self) -> Vec { + self.calls.borrow().clone() + } + } + + impl Driver for FakeDriver<'_> { + fn outputs(&self) -> Vec { + let mut outputs = vec![output("primary", true)]; + let attached = self + .on_since + .get() + .zip(self.attach_after) + .is_some_and(|(on, after)| self.elapsed.get() >= on + after); + if attached { + outputs.push(output("virtual", false)); + } + outputs + } + + 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.sleep(self.settle_takes); + 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)); + self.on_since.set(enabled.then(|| self.elapsed.get())); + true + } + + fn now(&self) -> Instant { + self.start + self.elapsed.get() + } + + fn sleep(&self, duration: Duration) { + // Report every 50 ms, as often as the transport ticks. + let end = self.elapsed.get() + duration; + while self.elapsed.get() < end { + let step = (end - self.elapsed.get()).min(Duration::from_millis(50)); + self.elapsed.set(self.elapsed.get() + step); + (self.on_sleep)(self.elapsed.get()); + } + } + } + + /// A 0.3.2 log and the 0.3.1 field data: the display never attaches, + /// the iPad sends its goodbye at 10 s, and USB dials again 1 s later. + /// Here no transport step restarts the pipeline while the display + /// starts, so the running generation keeps mirroring to the iPad, with + /// an Enable task far slower than the iPad's whole timeout. The start is + /// not cancelled by the goodbye, ends in one Enable and one Disable, and + /// every later connection mirrors without touching the driver. + #[test] + fn a_display_that_never_attaches_mirrors_at_once_and_never_loops() { + let shared = second_screen_selected(); + connect_viewer(&shared); + let start = Instant::now(); + assert_eq!(next_step(&shared, start), Step::Attach); + assert_eq!(*shared.vdd_status.lock(), VddStatus::Attaching); + + let steps = RefCell::new(Vec::new()); + let (goodbye, redial) = (Once::at(IPAD_TIMEOUT), Once::at(IPAD_TIMEOUT + secs(1.0))); + let on_sleep = |elapsed: Duration| { + goodbye.run(elapsed, || disconnect(&shared)); + redial.run(elapsed, || connect_viewer(&shared)); + steps.borrow_mut().push(next_step(&shared, start + elapsed)); + }; + let driver = FakeDriver::new(secs(8.0), None, &on_sleep); + attach(&shared, &driver, ATTACH_TIMEOUT); + + assert!(goodbye.done.get() && redial.done.get()); + assert!( + steps.borrow().iter().all(|step| *step == Step::Nothing), + "no restart while the display starts" + ); + let toggles = driver.toggles.borrow().clone(); + assert_eq!(toggles.len(), 2, "{toggles:?}"); + assert_eq!(toggles[0], (secs(8.0), true)); + assert!(!toggles[1].1 && toggles[1].0 - toggles[0].0 >= MIN_TOGGLE_GAP); + assert_eq!(*shared.vdd_status.lock(), VddStatus::Failed); + assert_eq!(remembered_failure(&shared), Some("attach_timeout")); + + // Reconnects for the next minute stay on the mirror. + for second in 0..60 { + disconnect(&shared); + connect_viewer(&shared); + let now = driver.now() + Duration::from_secs(second); + assert_eq!(next_step(&shared, now), Step::Nothing); + } + // Try again starts it once more at the next tick. + shared.retry_second_screen(); + assert_eq!(next_step(&shared, driver.now()), Step::Attach); + } + + #[test] + fn the_stream_moves_to_the_second_screen_once_it_attaches() { + let shared = second_screen_selected(); + connect_viewer(&shared); + assert_eq!(next_step(&shared, Instant::now()), Step::Attach); + let steps = RefCell::new(Vec::new()); + let on_sleep = |_| steps.borrow_mut().push(next_step(&shared, Instant::now())); + let driver = FakeDriver::new(secs(3.0), Some(secs(0.5)), &on_sleep); + attach(&shared, &driver, ATTACH_TIMEOUT); + assert!(steps.borrow().iter().all(|step| *step == Step::Nothing)); + assert_eq!(*shared.vdd_status.lock(), VddStatus::Active); + assert_eq!(shared.vdd_output.lock().as_deref(), Some("virtual")); + assert_eq!(driver.toggles.borrow().len(), 1); + let now = driver.now(); + assert_eq!(next_step(&shared, now), Step::Switch); + assert_eq!(next_step(&shared, now), Step::Nothing, "one switch"); + } + + #[test] + fn a_quick_reconnect_keeps_the_second_screen_and_a_long_absence_releases_it() { + let shared = second_screen_selected(); + *shared.vdd_status.lock() = VddStatus::Active; + connect_viewer(&shared); + let start = Instant::now(); + assert_eq!(next_step(&shared, start), Step::Nothing); + + disconnect(&shared); + let mut elapsed = Duration::ZERO; + while elapsed < secs(9.9) { + assert_eq!(next_step(&shared, start + elapsed), Step::Nothing); + assert!(shared.second_screen.lock().keep_without_viewer()); + elapsed += secs(0.25); + } + // The iPad comes back within the grace period, then leaves again. + connect_viewer(&shared); + assert_eq!(next_step(&shared, start + elapsed), Step::Nothing); + disconnect(&shared); + let left = start + elapsed + secs(0.25); + assert_eq!(next_step(&shared, left), Step::Nothing); + assert_eq!( + next_step(&shared, left + RELEASE_GRACE - secs(0.25)), + Step::Nothing + ); + assert_eq!(next_step(&shared, left + RELEASE_GRACE), Step::Release); + assert!(!shared.second_screen.lock().keep_without_viewer()); + assert_eq!( + next_step(&shared, left + RELEASE_GRACE + secs(1.0)), + Step::Nothing, + "one release" + ); + } + + #[test] + fn the_display_is_never_turned_on_within_the_gap_after_it_was_turned_off() { + let shared = second_screen_selected(); + let on_sleep = |_| {}; + let driver = FakeDriver::new(secs(3.0), Some(Duration::ZERO), &on_sleep); + // The grace period ended and the next generation turned it off. + shared.second_screen.lock().note_toggle(driver.now()); + connect_viewer(&shared); + assert_eq!(next_step(&shared, driver.now()), Step::Attach); + attach(&shared, &driver, ATTACH_TIMEOUT); + let toggles = driver.toggles.borrow().clone(); + assert_eq!(toggles, vec![(MIN_TOGGLE_GAP + secs(3.0), true)]); + assert_eq!(*shared.vdd_status.lock(), VddStatus::Active); + } + + #[test] + fn a_viewer_leaving_during_the_gap_leaves_the_display_off() { + let shared = second_screen_selected(); + connect_viewer(&shared); + assert_eq!(next_step(&shared, Instant::now()), Step::Attach); + let goodbye = Once::at(secs(2.0)); + let on_sleep = |elapsed: Duration| goodbye.run(elapsed, || disconnect(&shared)); + let driver = FakeDriver::new(secs(3.0), Some(Duration::ZERO), &on_sleep); + shared.second_screen.lock().note_toggle(driver.now()); + attach(&shared, &driver, ATTACH_TIMEOUT); + assert!(goodbye.done.get()); + assert!(driver.toggles.borrow().is_empty()); + assert_eq!(*shared.vdd_status.lock(), VddStatus::WaitingForClient); + } + + #[test] + fn a_quit_before_the_enable_leaves_the_display_off() { + let shared = second_screen_selected(); + connect_viewer(&shared); + assert_eq!(next_step(&shared, Instant::now()), Step::Attach); + shared.stop(); + let on_sleep = |_| {}; + let driver = FakeDriver::new(secs(3.0), Some(Duration::ZERO), &on_sleep); + attach(&shared, &driver, ATTACH_TIMEOUT); + assert!(driver.toggles.borrow().is_empty()); + assert_eq!(*shared.vdd_status.lock(), VddStatus::WaitingForClient); + } + + /// Review round 2, defect 5: Enable done at 3 s, Mirror chosen at 5 s. + /// The Disable waited for nothing and came 2 s after the Enable; now it + /// waits out the gap, like every change, while the mirror streams. + #[test] + fn a_mode_change_after_the_enable_turns_the_display_back_off() { + let shared = second_screen_selected(); + connect_viewer(&shared); + assert_eq!(next_step(&shared, Instant::now()), Step::Attach); + let switch = Once::at(secs(5.0)); + 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); + let toggles = driver.toggles.borrow().clone(); + assert_eq!( + toggles.iter().map(|(_, on)| *on).collect::>(), + [true, false] + ); + assert_eq!(toggles[0].0, secs(3.0)); + assert!(toggles[1].0 - toggles[0].0 >= MIN_TOGGLE_GAP, "{toggles:?}"); + assert_eq!(*shared.vdd_output.lock(), None); + assert_eq!(*shared.vdd_status.lock(), VddStatus::WaitingForClient); + 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. + #[test] + fn a_mode_change_during_the_start_never_leaves_it_claimed() { + let shared = second_screen_selected(); + connect_viewer(&shared); + let on_sleep = |_| {}; + let driver = FakeDriver::new(secs(3.0), None, &on_sleep); + // A change a moment ago: the start waits out the gap first. + shared.second_screen.lock().note_toggle(driver.now()); + assert_eq!(next_step(&shared, driver.now()), Step::Attach); + let mirror = Once::at(secs(2.0)); + let on_sleep = |elapsed: Duration| { + mirror.run(elapsed, || { + *shared.capture_target.lock() = CaptureTarget::PrimaryAuto + }) + }; + let driver = FakeDriver { + on_sleep: &on_sleep, + ..driver + }; + attach(&shared, &driver, ATTACH_TIMEOUT); + assert!(mirror.done.get()); + assert!(driver.toggles.borrow().is_empty()); + *shared.capture_target.lock() = CaptureTarget::VirtualExtended; + assert_eq!(*shared.vdd_status.lock(), VddStatus::WaitingForClient); + assert_eq!(next_step(&shared, driver.now()), Step::Attach); + } + + #[test] + fn try_again_recovers_a_start_whose_thread_is_gone() { + let shared = second_screen_selected(); + connect_viewer(&shared); + assert_eq!(next_step(&shared, Instant::now()), Step::Attach); + // Its thread is running: Try again leaves it alone. + shared.retry_second_screen(); + assert_eq!(*shared.vdd_status.lock(), VddStatus::Attaching); + // A claim with no thread behind it. + shared.second_screen.lock().attaching = false; + shared.retry_second_screen(); + assert_eq!(*shared.vdd_status.lock(), VddStatus::WaitingForClient); + assert_eq!(next_step(&shared, Instant::now()), Step::Attach); + } + + /// Review round 1, defect 1: the switch happened, then the capture + /// generation streaming the display only ever timed out (or read the + /// black placeholder). It goes back to the mirror, and stays there. + #[test] + fn a_second_screen_that_never_shows_an_image_goes_back_to_the_mirror() { + let shared = second_screen_selected(); + connect_viewer(&shared); + note_attached(&shared, "virtual".into()); + let start = Instant::now(); + assert_eq!(next_step(&shared, start), Step::Switch); + start_proving(&shared, 7, start); + let mut elapsed = Duration::ZERO; + while elapsed < PROVE_TIMEOUT { + assert_eq!(next_step(&shared, start + elapsed), Step::Nothing); + elapsed += secs(0.25); + } + assert_eq!( + next_step(&shared, start + PROVE_TIMEOUT), + Step::Fallback("no_frames") + ); + assert_eq!(*shared.vdd_status.lock(), VddStatus::Failed); + assert_eq!(remembered_failure(&shared), Some("no_frames")); + // Acquisition only timing out never sends the black placeholder. + assert!(!super::super::keepalive_allowed(false, true)); + assert!(super::super::keepalive_allowed(false, false)); + assert!(super::super::keepalive_allowed(true, true)); + // Later connections mirror without touching the driver. + for second in 1..30 { + disconnect(&shared); + connect_viewer(&shared); + let now = start + PROVE_TIMEOUT + Duration::from_secs(second); + assert_eq!(next_step(&shared, now), Step::Nothing); + } + let generation = crate::supervisor::CURRENT_GENERATION.load(Ordering::SeqCst); + assert_eq!( + reconcile_virtual_display(&CaptureTarget::VirtualExtended, &shared, generation), + Some(CaptureTarget::PrimaryAuto) + ); + } + + #[test] + fn a_duplication_error_on_the_second_screen_goes_back_to_the_mirror_at_once() { + let shared = second_screen_selected(); + connect_viewer(&shared); + note_attached(&shared, "virtual".into()); + let start = Instant::now(); + assert_eq!(next_step(&shared, start), Step::Switch); + start_proving(&shared, 7, start); + // An older generation's error is not this display's. + note_capture_error(&shared, 6); + assert_eq!(next_step(&shared, start), Step::Nothing); + // DuplicateOutput returned DXGI_ERROR_UNSUPPORTED. + note_capture_error(&shared, 7); + assert_eq!( + next_step(&shared, start), + Step::Fallback("duplication_failed") + ); + assert_eq!(remembered_failure(&shared), Some("duplication_failed")); + } + + #[test] + fn a_second_screen_that_shows_an_image_stays() { + let shared = second_screen_selected(); + connect_viewer(&shared); + note_attached(&shared, "virtual".into()); + let start = Instant::now(); + assert_eq!(next_step(&shared, start), Step::Switch); + start_proving(&shared, 7, start); + note_image(&shared, 6); + note_image(&shared, 7); + note_capture_error(&shared, 7); + for second in 0..60 { + let now = start + Duration::from_secs(second); + assert_eq!(next_step(&shared, now), Step::Nothing); + } + assert_eq!(*shared.vdd_status.lock(), VddStatus::Active); + } + + #[test] + fn a_fallback_turns_the_display_off_after_the_gap() { + let shared = second_screen_selected(); + let on_sleep = |_| {}; + let driver = FakeDriver::new(secs(3.0), Some(Duration::ZERO), &on_sleep); + note_attached(&shared, "virtual".into()); + shared.second_screen.lock().note_toggle(driver.now()); + *shared.vdd_status.lock() = VddStatus::Failed; + release(&shared, &driver); + assert_eq!(*driver.toggles.borrow(), vec![(MIN_TOGGLE_GAP, false)]); + assert_eq!(*shared.vdd_output.lock(), None); + // A second request finds nothing left to turn off. + release(&shared, &driver); + assert_eq!(driver.toggles.borrow().len(), 1); + } + + /// Review round 1, defect 2: no capture generation may wait for the + /// driver, whatever the status, the mode or the viewer. Before, a + /// generation that read `WaitingForClient` (Try again had just reset it) + /// waited for the lock behind the attach thread's whole Enable. + #[test] + fn mirror_startup_never_waits_for_the_driver_lock() { + let shared = second_screen_selected(); + let generation = crate::supervisor::CURRENT_GENERATION.load(Ordering::SeqCst); + // What a slow Enable on the worker holds. + let _held = shared.vdd_worker.enable.lock(); + for target in [ + CaptureTarget::VirtualExtended, + CaptureTarget::PrimaryAuto, + CaptureTarget::Output("physical".into()), + ] { + for status in [ + VddStatus::Inactive, + VddStatus::WaitingForClient, + VddStatus::Attaching, + VddStatus::Active, + VddStatus::Failed, + VddStatus::TaskFailed, + ] { + for connected in [false, true] { + if connected != shared.client_connected() { + if connected { + connect_viewer(&shared); + } else { + disconnect(&shared); + } + } + *shared.vdd_status.lock() = status; + let (done, finished) = std::sync::mpsc::channel(); + let worker = shared.clone(); + let target = target.clone(); + std::thread::spawn(move || { + let _ = done.send(reconcile_virtual_display(&target, &worker, generation)); + }); + let chosen = finished + .recv_timeout(Duration::from_secs(2)) + .unwrap_or_else(|_| panic!("{status:?} connected={connected} waited")); + assert!(chosen.is_some(), "{status:?}"); + } + } + } + } + + /// The same, with the real attach thread: Try again after a failure, its + /// thread blocked in a slow Enable, and a new capture generation. + #[test] + fn try_again_during_a_slow_enable_still_mirrors_at_once() { + /// Blocks in Enable until released, on a paused clock otherwise. + struct SlowEnable { + entered: std::sync::mpsc::SyncSender<()>, + release: std::sync::mpsc::Receiver<()>, + start: Instant, + elapsed: Cell, + } + impl Driver for SlowEnable { + fn outputs(&self) -> Vec { + vec![output("primary", true)] + } + fn prepare(&self, _: &[OutputInfo]) {} + fn set_enabled(&self, enabled: bool) -> bool { + if enabled { + let _ = self.entered.send(()); + let _ = self.release.recv(); + } + true + } + fn now(&self) -> Instant { + self.start + self.elapsed.get() + } + fn sleep(&self, duration: Duration) { + self.elapsed.set(self.elapsed.get() + duration); + } + } + let shared = second_screen_selected(); + connect_viewer(&shared); + *shared.vdd_status.lock() = VddStatus::Failed; + shared.retry_second_screen(); + assert_eq!(next_step(&shared, Instant::now()), Step::Attach); + let (entered_tx, entered) = std::sync::mpsc::sync_channel(1); + let (release, release_rx) = std::sync::mpsc::channel(); + let worker = shared.clone(); + let attach_thread = std::thread::spawn(move || { + let driver = SlowEnable { + entered: entered_tx, + release: release_rx, + start: Instant::now(), + elapsed: Cell::new(Duration::ZERO), + }; + attach(&worker, &driver, Duration::ZERO); + }); + entered.recv_timeout(Duration::from_secs(5)).unwrap(); + let generation = crate::supervisor::CURRENT_GENERATION.load(Ordering::SeqCst); + let (done, finished) = std::sync::mpsc::channel(); + let capture = shared.clone(); + std::thread::spawn(move || { + let _ = done.send(reconcile_virtual_display( + &CaptureTarget::VirtualExtended, + &capture, + generation, + )); + }); + assert_eq!( + finished.recv_timeout(Duration::from_secs(2)).unwrap(), + Some(CaptureTarget::PrimaryAuto) + ); + release.send(()).unwrap(); + attach_thread.join().unwrap(); + // No output ever appeared: the start failed and is remembered. + assert_eq!(*shared.vdd_status.lock(), VddStatus::Failed); + } + + /// Review round 2, defect 1: the generation streaming the second screen + /// was replaced before the transport's tick ended its proof (by the + /// watchdog, or a capture error that ended the stage). The new generation + /// started the 5 s over and forgot the capture error. + #[test] + fn a_restart_keeps_the_proof_deadline_and_its_capture_error() { + let start = Instant::now(); + let shared = second_screen_selected(); + connect_viewer(&shared); + note_attached(&shared, "virtual".into()); + assert_eq!(next_step(&shared, start), Step::Switch); + start_proving(&shared, 7, start); + start_proving(&shared, 8, start + secs(4.0)); + assert_eq!(next_step(&shared, start + secs(4.9)), Step::Nothing); + assert_eq!( + next_step(&shared, start + PROVE_TIMEOUT), + Step::Fallback("no_frames") + ); + + let shared = second_screen_selected(); + connect_viewer(&shared); + note_attached(&shared, "virtual".into()); + assert_eq!(next_step(&shared, start), Step::Switch); + start_proving(&shared, 7, start); + note_capture_error(&shared, 7); + start_proving(&shared, 8, start + secs(1.0)); + assert_eq!( + next_step(&shared, start + secs(1.0)), + Step::Fallback("duplication_failed") + ); + } + + /// Review round 2, defect 1: nothing is sent from the second screen + /// before its first image, so the watchdog's 5 s without a frame came + /// before the proof's own deadline and restarted the pipeline. + #[test] + fn the_watchdog_leaves_a_second_screen_proving_itself_alone() { + let shared = second_screen_selected(); + connect_viewer(&shared); + let machine = crate::supervisor::Machine::new(1); + let now_ms = 100_000; + shared.hb_capture_loop_ms.store(now_ms, Ordering::Relaxed); + shared + .hb_capture_frame_ms + .store(now_ms - 5_500, Ordering::Relaxed); + shared + .hb_encode_frame_ms + .store(now_ms - 5_500, Ordering::Relaxed); + note_attached(&shared, "virtual".into()); + start_proving(&shared, 7, Instant::now()); + assert_eq!( + crate::supervisor::wedge_reason_at(&shared, &machine, now_ms), + None + ); + // After its first image a stalled second screen is a wedge again. + note_image(&shared, 7); + assert!(crate::supervisor::wedge_reason_at(&shared, &machine, now_ms).is_some()); + } + + /// Review round 2, defect 1: when no tick ended the proof before the + /// restart, the next capture generation does, before anything else. + #[test] + fn the_next_capture_generation_ends_a_second_screen_that_failed() { + let generation = crate::supervisor::CURRENT_GENERATION.load(Ordering::SeqCst); + let previous = generation.wrapping_sub(1); + let long_ago = Instant::now().checked_sub(secs(6.0)).unwrap(); + for (failed_at, capture_error, reason) in [ + (Instant::now(), true, "duplication_failed"), + (long_ago, false, "no_frames"), + ] { + let shared = second_screen_selected(); + connect_viewer(&shared); + note_attached(&shared, "virtual".into()); + assert_eq!(next_step(&shared, failed_at), Step::Switch); + start_proving(&shared, previous, failed_at); + if capture_error { + note_capture_error(&shared, previous); + } + assert_eq!( + reconcile_virtual_display(&CaptureTarget::VirtualExtended, &shared, generation), + Some(CaptureTarget::PrimaryAuto) + ); + assert_eq!(*shared.vdd_status.lock(), VddStatus::Failed); + assert_eq!(remembered_failure(&shared), Some(reason)); + assert!(!proving(&shared)); + // The display is turned off, and the tick finds nothing to do. + let on_sleep = |_| {}; + 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); + } + } + + /// Review round 2, defect 2: the proof was noted before the readback, so + /// a frame that never reached the encoder counted, and the capture error + /// that followed found no proof left to fail. + #[test] + fn only_an_image_read_back_proves_the_second_screen() { + use crate::capture::read_back_image; + let start = Instant::now(); + let shared = second_screen_selected(); + connect_viewer(&shared); + note_attached(&shared, "virtual".into()); + assert_eq!(next_step(&shared, start), Step::Switch); + start_proving(&shared, 7, start); + let mut seen_image = false; + // The texture could not be cast: nothing for the encoder. + assert_eq!( + read_back_image(&shared, 7, &mut seen_image, || Ok::, &str>(None)), + Ok(None) + ); + // Map failed. + assert_eq!( + read_back_image(&shared, 7, &mut seen_image, || Err::, _>("map")), + Err("map") + ); + assert!(proving(&shared) && !seen_image); + note_capture_error(&shared, 7); + assert_eq!( + next_step(&shared, start), + Step::Fallback("duplication_failed") + ); + + let shared = second_screen_selected(); + connect_viewer(&shared); + note_attached(&shared, "virtual".into()); + assert_eq!(next_step(&shared, start), Step::Switch); + start_proving(&shared, 7, start); + let mut seen_image = false; + assert_eq!( + read_back_image(&shared, 7, &mut seen_image, || Ok::<_, ()>(Some(1u8))), + Ok(Some(1)) + ); + assert!(!proving(&shared) && seen_image); + } + + /// A driver for a worker thread. Prepare or Enable can be held until + /// the test lets it go. + struct Gated { + hold_prepare: bool, + hold_enable: bool, + entered: std::sync::mpsc::SyncSender<&'static str>, + go: std::sync::mpsc::Receiver<()>, + toggles: std::sync::Arc>>, + start: Instant, + elapsed: Cell, + } + + impl Driver for Gated { + fn outputs(&self) -> Vec { + vec![output("primary", true)] + } + fn prepare(&self, _: &[OutputInfo]) { + if self.hold_prepare { + let _ = self.entered.send("prepare"); + let _ = self.go.recv(); + } + } + fn set_enabled(&self, enabled: bool) -> bool { + if enabled && self.hold_enable { + let _ = self.entered.send("enable"); + let _ = self.go.recv(); + } + self.toggles.lock().push(enabled); + true + } + fn now(&self) -> Instant { + self.start + self.elapsed.get() + } + fn sleep(&self, duration: Duration) { + self.elapsed.set(self.elapsed.get() + duration); + } + } + + type GatedWorker = ( + std::sync::mpsc::Receiver<&'static str>, + std::sync::mpsc::Sender<()>, + std::sync::Arc>>, + ); + + /// The worker thread as the app runs it, with a [`Gated`] driver. + fn spawn_gated_worker( + shared: &SharedControl, + hold_prepare: bool, + hold_enable: bool, + ) -> GatedWorker { + let (entered_tx, entered) = std::sync::mpsc::sync_channel(4); + let (go, go_rx) = std::sync::mpsc::channel(); + let toggles = std::sync::Arc::new(Mutex::new(Vec::new())); + let (worker, record) = (shared.clone(), toggles.clone()); + let handle = std::thread::spawn(move || { + let driver = Gated { + hold_prepare, + hold_enable, + entered: entered_tx, + go: go_rx, + toggles: record, + start: Instant::now(), + elapsed: Cell::new(Duration::ZERO), + }; + run(&worker, &driver, ATTACH_TIMEOUT, true); + }); + *shared.vdd_worker.thread.lock() = Some(handle); + (entered, go, toggles) + } + + /// Review round 2, defect 3: quitting turned the display off while the + /// start prepared the driver's settings, and its Enable came after: a + /// phantom monitor outlived the app. + #[test] + fn quitting_while_a_start_prepares_never_enables() { + let shared = second_screen_selected(); + connect_viewer(&shared); + assert_eq!(next_step(&shared, Instant::now()), Step::Attach); + let (entered, go, toggles) = spawn_gated_worker(&shared, true, false); + send(&shared, Command::Attach); + assert_eq!(entered.recv_timeout(Duration::from_secs(5)), Ok("prepare")); + let quitter = shared.clone(); + let quit = std::thread::spawn(move || shut_down(&quitter)); + while !quitting(&shared) { + std::thread::sleep(Duration::from_millis(1)); + } + go.send(()).unwrap(); + quit.join().unwrap(); + assert!(shared.vdd_worker.thread.lock().is_none(), "joined"); + assert!(toggles.lock().is_empty(), "{:?}", toggles.lock()); + // Nothing asked for after the quit reaches the driver. + send(&shared, Command::Attach); + assert!(shared.vdd_worker.queue.lock().commands.is_empty()); + } + + /// The other half of defect 3: an Enable already running when the app + /// quits finishes before quitting goes on to its Disable. + #[test] + fn quitting_waits_for_an_enable_in_flight() { + let shared = second_screen_selected(); + connect_viewer(&shared); + assert_eq!(next_step(&shared, Instant::now()), Step::Attach); + let (entered, go, toggles) = spawn_gated_worker(&shared, false, true); + send(&shared, Command::Attach); + assert_eq!(entered.recv_timeout(Duration::from_secs(5)), Ok("enable")); + // Without joining it: an Enable can outlast the join's bound. + let worker = shared.vdd_worker.thread.lock().take().unwrap(); + let (done_tx, done) = std::sync::mpsc::channel(); + let quitter = shared.clone(); + let quit = std::thread::spawn(move || { + shut_down(&quitter); + let _ = done_tx.send(()); + }); + assert!( + done.recv_timeout(Duration::from_millis(300)).is_err(), + "quitting went on while an Enable ran" + ); + go.send(()).unwrap(); + done.recv_timeout(Duration::from_secs(5)).unwrap(); + quit.join().unwrap(); + worker.join().unwrap(); + // The worker leaves the Disable to the quit, which runs it next. + assert_eq!(*toggles.lock(), vec![true]); + } + + /// Review round 2, defect 4: the grace period ended and a capture + /// generation asked for the display to be turned off, but the worker + /// acted before the generation published the status. It read `Active`, + /// took the display as wanted, and nothing asked again. Only a start + /// claimed since keeps it now. + #[test] + fn a_release_turns_the_display_off_whatever_status_it_reads() { + let shared = second_screen_selected(); + note_attached(&shared, "virtual".into()); + shared.second_screen.lock().release_requested = true; + let on_sleep = |_| {}; + let driver = FakeDriver::new(secs(3.0), Some(Duration::ZERO), &on_sleep); + release(&shared, &driver); + assert_eq!(*driver.toggles.borrow(), vec![(Duration::ZERO, false)]); + assert_eq!(*shared.vdd_output.lock(), None); + } + + /// The grace period's end, through a capture generation and the worker: + /// one Disable. + #[test] + fn the_grace_period_ends_with_one_disable() { + let shared = second_screen_selected(); + connect_viewer(&shared); + note_attached(&shared, "virtual".into()); + let start = Instant::now(); + assert_eq!(next_step(&shared, start), Step::Switch); + disconnect(&shared); + assert_eq!(next_step(&shared, start), Step::Nothing); + assert_eq!(next_step(&shared, start + RELEASE_GRACE), Step::Release); + let generation = crate::supervisor::CURRENT_GENERATION.load(Ordering::SeqCst); + assert_eq!( + reconcile_virtual_display(&CaptureTarget::VirtualExtended, &shared, generation), + Some(CaptureTarget::PrimaryAuto) + ); + assert_eq!(*shared.vdd_status.lock(), VddStatus::WaitingForClient); + let on_sleep = |_| {}; + 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)]); + assert_eq!(*shared.vdd_output.lock(), None); + run(&shared, &driver, ATTACH_TIMEOUT, false); + assert_eq!(driver.toggles.borrow().len(), 1); + } + + /// The iPad came back after the release was asked for, before the + /// worker ran it: no change, and the start reuses the display. + #[test] + fn a_viewer_back_before_the_release_runs_keeps_the_display() { + let shared = second_screen_selected(); + note_attached(&shared, "virtual".into()); + shared.second_screen.lock().release_requested = true; + let generation = crate::supervisor::CURRENT_GENERATION.load(Ordering::SeqCst); + assert_eq!( + reconcile_virtual_display(&CaptureTarget::VirtualExtended, &shared, generation), + Some(CaptureTarget::PrimaryAuto) + ); + connect_viewer(&shared); + assert_eq!(next_step(&shared, Instant::now()), Step::Attach); + send(&shared, Command::Attach); + let on_sleep = |_| {}; + let driver = FakeDriver::new(secs(3.0), Some(Duration::ZERO), &on_sleep); + driver.on_since.set(Some(Duration::ZERO)); + run(&shared, &driver, ATTACH_TIMEOUT, false); + assert!(driver.toggles.borrow().is_empty()); + 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/control.rs b/host/src/control.rs index f99e3fa3..a84e81fa 100644 --- a/host/src/control.rs +++ b/host/src/control.rs @@ -36,15 +36,20 @@ 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>>, - /// Serialize driver reconciliation across retiring capture generations. - #[cfg(windows)] - pub(crate) vdd_lifecycle: Arc>, + /// The one thread that turns the virtual display on and off, and its + /// queue. + pub(crate) vdd_worker: Arc, /// Output owned by the enabled driver, retained across pipeline restarts. - #[cfg(windows)] pub(crate) vdd_output: Arc>>, + /// Second screen timing across pipeline restarts: the pending switch onto + /// it, the grace period after its viewer left, the last driver change. + pub(crate) second_screen: Arc>, /// Watchdog heartbeats (ms on the process clock, relaxed): the capture /// loop's aliveness, the last produced frame, and the last encoded frame. /// The supervisor detects wedged/starved stages from these. @@ -83,14 +88,15 @@ pub enum VddStatus { /// Virtual display requested but not yet active because no iPad has connected. The driver /// is deliberately left off until a receiver registers, so an idle PC shows no phantom monitor. WaitingForClient, - /// The first registration has claimed startup; reconnects must not restart it. + /// The display is being turned on while the iPad gets the mirror. + /// Reconnects and disconnects leave the start alone. Attaching, /// Virtual display enabled and being captured. Active, - /// Virtual display was requested but could not be enabled/attached — capture fell back to - /// the primary display. + /// Virtual display was requested but did not attach — capture fell back to the primary + /// display. Later connections mirror too, until the user tries again or changes the mode. Failed, - /// The installer-owned task itself failed to execute. + /// The installer-owned task itself failed to execute. Remembered like `Failed`. TaskFailed, } @@ -148,11 +154,11 @@ 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(())), - #[cfg(windows)] + vdd_worker: Arc::new(Default::default()), vdd_output: Arc::new(Mutex::new(None)), + second_screen: Arc::new(Mutex::new(Default::default())), hb_capture_loop_ms: Arc::new(AtomicU64::new(0)), hb_capture_frame_ms: Arc::new(AtomicU64::new(0)), hb_encode_frame_ms: Arc::new(AtomicU64::new(0)), @@ -190,17 +196,10 @@ impl SharedControl { self.session.lock().is_active() } - /// Claim the single restart needed when a viewer arrives after idle capture. - pub(crate) fn begin_virtual_display_attach(&self) -> bool { - if *self.capture_target.lock() != CaptureTarget::VirtualExtended { - return false; - } - let mut status = self.vdd_status.lock(); - if *status != VddStatus::WaitingForClient { - return false; - } - *status = VddStatus::Attaching; - true + /// The user asked to try again: a second screen that failed to start is + /// tried once more at the next connection. + pub(crate) fn retry_second_screen(&self) { + crate::capture::second_screen::retry(self); } } @@ -232,6 +231,7 @@ pub struct GuiControl { impl GuiControl { pub fn request_restart(&self) { crate::vdd::recheck_tasks(); + self.shared.retry_second_screen(); if let Err(error) = self.supervisor_tx.send(SupervisorCommand::Restart) { warn!(error = %error, "Failed to send restart command"); } @@ -245,6 +245,7 @@ impl GuiControl { pub fn request_start(&self) { crate::vdd::recheck_tasks(); + self.shared.retry_second_screen(); if let Err(error) = self.supervisor_tx.send(SupervisorCommand::Start) { warn!(error = %error, "Failed to send start command"); } @@ -261,23 +262,6 @@ impl GuiControl { mod tests { use super::*; - #[test] - fn usb_reconnects_claim_only_one_virtual_display_start() { - let shared = SharedControl::new(9876, 15_000_000); - *shared.capture_target.lock() = CaptureTarget::VirtualExtended; - *shared.vdd_status.lock() = VddStatus::WaitingForClient; - assert!(shared.begin_virtual_display_attach()); - assert_eq!(*shared.vdd_status.lock(), VddStatus::Attaching); - for _ in 0..50 { - assert!(!shared.begin_virtual_display_attach()); - } - *shared.vdd_status.lock() = VddStatus::Active; - assert!(!shared.begin_virtual_display_attach()); - *shared.vdd_status.lock() = VddStatus::WaitingForClient; - *shared.capture_target.lock() = CaptureTarget::PrimaryAuto; - assert!(!shared.begin_virtual_display_attach()); - } - #[test] fn client_fps_caps_the_host_without_overwriting_its_preference() { let shared = SharedControl::new(9876, 15_000_000); diff --git a/host/src/diagnostics.rs b/host/src/diagnostics.rs index 80d5115c..35c4e13c 100644 --- a/host/src/diagnostics.rs +++ b/host/src/diagnostics.rs @@ -128,14 +128,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!( @@ -159,8 +162,35 @@ 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 { +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}, @@ -181,15 +211,17 @@ 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)] -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}, @@ -215,3 +247,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" + ); + } +} diff --git a/host/src/encoder/mod.rs b/host/src/encoder/mod.rs index 95c35970..d7dd000e 100644 --- a/host/src/encoder/mod.rs +++ b/host/src/encoder/mod.rs @@ -732,8 +732,8 @@ impl EncoderState { } /// Capture time and number of the frame in `self.packet`. An encoder - /// with output delay (NVENC holds one frame) returns an earlier frame - /// than the one just submitted. + /// with output delay (a hardware encoder can hold frames) returns an + /// earlier frame than the one just submitted. fn packet_origin(&mut self, just_submitted: (Instant, u64)) -> (Instant, u64) { self.in_flight .take(self.packet.pts()) @@ -906,6 +906,7 @@ fn encoder_options(encoder_name: &str) -> ffmpeg_next::Dictionary<'_> { opts.set("profile", "baseline"); opts.set("zerolatency", "1"); opts.set("rc", "cbr"); + set_nvenc_keyframe_options(&mut opts); } "h264_amf" => { opts.set("usage", "lowlatency"); @@ -936,6 +937,8 @@ fn encoder_options(encoder_name: &str) -> ffmpeg_next::Dictionary<'_> { opts.set("preset", "veryfast"); opts.set("profile", "baseline"); opts.set("look_ahead", "0"); + // Like NVENC, QSV makes a requested keyframe a plain I-frame. + opts.set("forced_idr", "1"); } "libx264" => { opts.set("preset", "ultrafast"); @@ -950,6 +953,7 @@ fn encoder_options(encoder_name: &str) -> ffmpeg_next::Dictionary<'_> { opts.set("tune", "ll"); opts.set("zerolatency", "1"); opts.set("rc", "cbr"); + set_nvenc_keyframe_options(&mut opts); } "hevc_amf" => { opts.set("usage", "lowlatency"); @@ -962,6 +966,7 @@ fn encoder_options(encoder_name: &str) -> ffmpeg_next::Dictionary<'_> { } "hevc_qsv" => { opts.set("preset", "veryfast"); + opts.set("forced_idr", "1"); } "hevc_videotoolbox" => { opts.set("realtime", "1"); @@ -978,6 +983,18 @@ fn encoder_options(encoder_name: &str) -> ffmpeg_next::Dictionary<'_> { opts } +/// A new viewer, or one back within the second screen's grace period, can +/// start only from an IDR. NVENC made a requested keyframe a plain I-frame, +/// which the transport never starts a session from, so the viewer waited +/// for the GOP's next IDR: up to 0.5 s at 60 fps, but up to 22 s on a static +/// second screen resent at 1.3 fps, past the iPad's 10 s. NVENC also held +/// each frame back two frames, another 1.5 s at that rate; without the delay +/// it returns each frame as it is encoded. +fn set_nvenc_keyframe_options(opts: &mut ffmpeg_next::Dictionary<'_>) { + opts.set("forced-idr", "1"); + opts.set("delay", "0"); +} + fn prepare_frame_for_encode( frame: &mut ffmpeg_next::frame::Video, frame_number: u64, @@ -1507,6 +1524,147 @@ mod tests { assert_eq!(labels, (480..490).map(|n| (at(n), n)).collect::>()); } + #[test] + fn nvenc_and_qsv_make_a_requested_keyframe_an_idr() { + for (name, option) in [ + ("h264_nvenc", "forced-idr"), + ("hevc_nvenc", "forced-idr"), + ("h264_qsv", "forced_idr"), + ("hevc_qsv", "forced_idr"), + ] { + assert_eq!(encoder_options(name).get(option), Some("1"), "{name}"); + } + for name in ["h264_nvenc", "hevc_nvenc"] { + assert_eq!(encoder_options(name).get("delay"), Some("0"), "{name}"); + } + } + + /// Encodes frame `n` as the encode loop does, and returns each packet + /// that came out: whether the transport takes it as a keyframe, and the + /// capture time and number of its frame. + fn encode( + state: &mut EncoderState, + n: u64, + captured: Instant, + force_idr: bool, + ) -> Vec<(bool, Instant, u64)> { + let pts = state.next_pts(captured, n); + state.frame.set_pts(Some(pts)); + prepare_frame_for_encode( + &mut state.frame, + n, + &state.opened_encoder, + &mut 0, + force_idr, + ); + state.send_frame(pts, captured, n).unwrap(); + let mut packets = Vec::new(); + while state.encoder.receive_packet(&mut state.packet).is_ok() { + if state.h264.needs_parameter_sets() { + state + .h264 + .refresh_parameter_sets_from_extradata(encoder_extradata(&state.encoder)); + } + let packet_is_key = state.packet.is_key(); + let (_, info) = normalize_h264_payload_with_info( + state.packet.data().unwrap_or_default(), + packet_is_key, + &mut state.h264, + &state.opened_encoder, + ); + let is_keyframe = info.is_keyframe(packet_is_key, &state.opened_encoder); + let (origin, sequence) = state.packet_origin((captured, n)); + packets.push((is_keyframe, origin, sequence)); + } + packets + } + + /// The value of an integer option on the opened encoder. + fn encoder_option(state: &EncoderState, option: &str) -> u64 { + let option = std::ffi::CString::new(option).unwrap(); + let mut value = 0; + let result = unsafe { + ffmpeg_next::ffi::av_opt_get_int( + state.encoder.as_ptr().cast_mut().cast(), + option.as_ptr(), + ffmpeg_next::ffi::AV_OPT_SEARCH_CHILDREN, + &mut value, + ) + }; + assert_eq!(result, 0, "no option {option:?}"); + value.try_into().unwrap() + } + + /// A viewer back within the second screen's grace period finds the + /// encoder in the middle of a GOP, and a static second screen resends + /// its picture every 750 ms. NVENC answered the session's keyframe + /// request with an I-frame the transport cannot start a session from, + /// so the first picture waited for the GOP's next IDR, 30 frames on: + /// 22 s, and the iPad gave up after 10. Runs each encoder that opens + /// here: NVENC on an NVIDIA PC, libx264 everywhere. + #[test] + fn a_viewer_back_mid_gop_starts_from_the_keyframe_it_asked_for() { + use crate::transport::{first_frame_gate, FirstFrame}; + use std::time::Duration; + ffmpeg_next::init().unwrap(); + let start = Instant::now(); + let mut tested = Vec::new(); + for name in ["h264_nvenc", "h264_qsv", "libx264"] { + let Ok(mut state) = + EncoderState::new(name, 1280, 720, 8_000_000, 60, EncoderInput::Yuv420) + else { + continue; + }; + tested.push(name); + // The last viewer watched 40 frames at 60 fps and left. + for n in 0..40 { + encode( + &mut state, + n, + start + Duration::from_micros(n * 16_667), + n == 0, + ); + } + // It comes back 5 s later; its session asks for a keyframe. + let back = start + Duration::from_secs(5); + let mut force_idr = true; + let mut first = None; + 'frames: for n in 40..60 { + let captured = back + Duration::from_millis(10 + (n - 40) * 750); + for (is_keyframe, origin, sequence) in + encode(&mut state, n, captured, std::mem::take(&mut force_idr)) + { + match first_frame_gate(is_keyframe, origin, Some(back)) { + FirstFrame::Send => { + first = Some((n, sequence)); + break 'frames; + } + FirstFrame::Skip => {} + FirstFrame::SkipAndRequestKeyframe => force_idr = true, + } + } + } + // The requested frame itself opens the session, as soon as the + // encoder's configured delay lets it out: at once on NVENC with + // delay=0 and libx264 with zerolatency, and once QSV has its + // async_depth frames queued (FFmpeg's default 4). + let (submitted, sequence) = + first.unwrap_or_else(|| panic!("{name}: no frame could open the session")); + assert_eq!(sequence, 40, "{name}: the session opened on a later frame"); + let held = match name { + "h264_nvenc" => encoder_option(&state, "delay"), + "h264_qsv" => encoder_option(&state, "async_depth") - 1, + _ => 0, + }; + assert!( + submitted - 40 <= held, + "{name}: frame 40 came out after frame {submitted}, more than {held} frames late" + ); + } + eprintln!("encoders tested: {tested:?}"); + assert!(tested.contains(&"libx264")); + } + #[test] fn startup_tries_other_hardware_before_software() { let mut attempts = Vec::new(); diff --git a/host/src/gui/page_settings.rs b/host/src/gui/page_settings.rs index 49c5f115..e475a7a1 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"); @@ -328,3 +324,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/page_stream.rs b/host/src/gui/page_stream.rs index 6f2b085c..42b2b59d 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( @@ -102,13 +103,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) { @@ -117,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) { + 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( @@ -125,7 +134,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 = @@ -599,6 +608,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; @@ -615,6 +625,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)) + } _ => {} } } @@ -622,41 +650,83 @@ 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, 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 { - 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 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, + ), + (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( + // 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, false, diff --git a/host/src/gui/tests.rs b/host/src/gui/tests.rs index 636e5101..e7b756d7 100644 --- a/host/src/gui/tests.rs +++ b/host/src/gui/tests.rs @@ -362,28 +362,139 @@ 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), 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, 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, 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, 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(); - 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] +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), None) + .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); + // The user changes the layout; EternalMonitor never does. + let off_desktop = notice(status, VddProblem::NotExtended); + assert!( + 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( + 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), None), + None + ); + } +} + +#[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")); } #[test] diff --git a/host/src/lib.rs b/host/src/lib.rs index b7242d0f..8d4ccafe 100644 --- a/host/src/lib.rs +++ b/host/src/lib.rs @@ -25,7 +25,9 @@ pub mod supervisor; pub mod transport; 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/main.rs b/host/src/main.rs index c5d884c5..3aa2b462 100644 --- a/host/src/main.rs +++ b/host/src/main.rs @@ -74,8 +74,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 @@ -302,8 +303,10 @@ fn main() -> Result<(), Box> { gui_control.request_shutdown(); // Never leave the virtual display attached after we exit — it should only exist while - // EternalMonitor is actively using it. If it may still be on, the failure reporter - // turns it off after this process exits. + // EternalMonitor is actively using it. No Enable starts once the second screen worker + // is shut down, so this Disable is the last change. If it may still be on, the failure + // reporter turns it off after this process exits. + eternal_host::capture::second_screen::shut_down(&gui_control.shared); eternal_host::startup::display_off_at_quit(vdd::disable); }; // A window error is saved first and shown after the cleanup, as a panic is. diff --git a/host/src/supervisor.rs b/host/src/supervisor.rs index 581044fa..b7ebfed5 100644 --- a/host/src/supervisor.rs +++ b/host/src/supervisor.rs @@ -392,9 +392,15 @@ pub(crate) fn wedge_reason_at( // A client is connected but no frame has been produced for 5s. Sound // because the idle keepalive guarantees a frame at least every ~750ms - // while a client is registered. + // while a client is registered. Not while the second screen proves + // itself: nothing is sent from it until its first image, and a restart + // here came before its own fallback. let client_connected = shared.client_connected(); - if client_connected && frame_ms > 0 && now_ms.saturating_sub(frame_ms) > 5_000 { + if client_connected + && frame_ms > 0 + && now_ms.saturating_sub(frame_ms) > 5_000 + && !crate::capture::second_screen::proving(shared) + { return Some(format!( "no captured frame for {}ms with a client connected", now_ms.saturating_sub(frame_ms) diff --git a/host/src/transport/mod.rs b/host/src/transport/mod.rs index a1d52e22..c1a24a01 100644 --- a/host/src/transport/mod.rs +++ b/host/src/transport/mod.rs @@ -23,6 +23,7 @@ use tokio::net::UdpSocket; use tokio::sync::mpsc; use tracing::{debug, info, warn}; +use crate::capture::second_screen; use crate::control::{CaptureTarget, SharedControl, SupervisorCommand, VddStatus}; use crate::encoder::NALUnit; use crate::stats::PIPELINE_STATS; @@ -496,7 +497,7 @@ pub async fn start_sender( } #[derive(Debug, PartialEq, Eq)] -enum FirstFrame { +pub(crate) enum FirstFrame { Send, Skip, SkipAndRequestKeyframe, @@ -507,7 +508,7 @@ enum FirstFrame { /// NVENC kept the host's startup frame while the PC sat idle, and the next /// viewer received that old, blank keyframe as its first picture. A stale /// keyframe is skipped and a fresh one requested. -fn first_frame_gate( +pub(crate) fn first_frame_gate( is_keyframe: bool, captured: Instant, session_start: Option, @@ -661,16 +662,13 @@ async fn execute_actions( stats.set_target_addr(target.to_string()); stats.reset_connection_stats(); } - // The virtual extended display is enabled lazily by the capture loop's - // reconcile step, which only acts while a client is connected. If the - // user selected it before any client existed, this first registration - // is the moment to bring it up — which needs a pipeline restart so the - // capture loop re-runs reconciliation. - if shared.begin_virtual_display_attach() { - info!("Client connected with extended display selected — restarting pipeline to enable it"); - shared.stop(); - if let Err(error) = supervisor_tx.send(SupervisorCommand::Restart) { - warn!(error = %error, "Failed to request pipeline restart for the virtual display"); + if *shared.capture_target.lock() == CaptureTarget::VirtualExtended { + if let Some(reason) = second_screen::remembered_failure(shared) { + info!( + second_screen_fallback = reason, + "Client connected, but the second screen failed to start earlier — \ + mirroring the primary display until the user clicks Try again" + ); } } } @@ -694,23 +692,59 @@ async fn execute_actions( .lock() .set_target_addr("waiting for client".to_string()); - // Tear the managed virtual display down when its viewer disappears — - // the capture reconcile disables it on restart once no client is - // connected. (This closes the docs/decisions.md "idle-disconnect teardown" - // item, which was blocked on exactly this liveness signal.) - let vdd_in_use = *shared.capture_target.lock() == CaptureTarget::VirtualExtended - && matches!( - *shared.vdd_status.lock(), - VddStatus::Active | VddStatus::Attaching - ); - if vdd_in_use { - info!("Client gone while streaming the virtual display — restarting pipeline to tear it down"); - shared.stop(); - if let Err(error) = supervisor_tx.send(SupervisorCommand::Restart) { - warn!(error = %error, "Failed to request pipeline restart after client loss"); + // The managed virtual display outlives its viewer by a grace period, + // so a quick reconnect finds it, and a start in progress finishes. + // `second_screen::next_step` turns it off once the grace period ends. + if *shared.capture_target.lock() == CaptureTarget::VirtualExtended { + match *shared.vdd_status.lock() { + VddStatus::Active => info!( + grace_s = second_screen::RELEASE_GRACE.as_secs(), + "Client gone while streaming the virtual display — keeping it on in case \ + the iPad reconnects" + ), + VddStatus::Attaching => { + info!("Client gone while the virtual display starts — letting the start finish") + } + _ => {} } } } + + let restart = match second_screen::next_step(shared, Instant::now()) { + second_screen::Step::Nothing => false, + second_screen::Step::Attach => { + info!( + "Client connected with extended display selected — mirroring the primary \ + display while the virtual display starts" + ); + second_screen::send(shared, second_screen::Command::Attach); + false + } + second_screen::Step::Switch => { + info!("Virtual display ready — restarting pipeline to stream it"); + true + } + second_screen::Step::Release => { + info!("No iPad came back to the virtual display — restarting pipeline to turn it off"); + true + } + second_screen::Step::Fallback(reason) => { + warn!( + second_screen_fallback = reason, + prove_timeout_ms = second_screen::PROVE_TIMEOUT.as_millis() as u64, + "The virtual display showed no picture — restarting pipeline to mirror the \ + primary display instead" + ); + second_screen::send(shared, second_screen::Command::Fallback(reason)); + true + } + }; + if restart { + shared.stop(); + if let Err(error) = supervisor_tx.send(SupervisorCommand::Restart) { + warn!(error = %error, "Failed to request pipeline restart for the virtual display"); + } + } } #[cfg(test)] diff --git a/host/src/vdd.rs b/host/src/vdd.rs index 0974aa84..838c30b1 100644 --- a/host/src/vdd.rs +++ b/host/src/vdd.rs @@ -59,18 +59,34 @@ enum TaskOutcome { Done, TimedOut, Missing, - Failed, + /// With the device state the Enable task found, when it said. + 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 } else if stderr.contains("0x80070002") { Self::Missing } else { - Self::Failed + Self::Failed(crate::vdd_device::from_task_stderr(stderr)) } } } @@ -155,7 +171,7 @@ impl TaskGate { self.stuck = Some((now, enabled)); } TaskOutcome::Missing => self.health = VddTaskHealth::TasksMissing, - TaskOutcome::Failed => {} + TaskOutcome::Failed(_) => {} } } } @@ -178,6 +194,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. @@ -186,6 +206,7 @@ impl DriverTasks { Self { serial: parking_lot::Mutex::new(()), gate: parking_lot::Mutex::new(TaskGate::new()), + disabling: std::sync::atomic::AtomicUsize::new(0), } } @@ -193,19 +214,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. @@ -234,6 +287,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. @@ -265,7 +323,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. @@ -309,32 +367,90 @@ 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) } } } +/// 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) + 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, 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<(), EnableReport> { + let mut ran = None; + if request(true, Purpose::Attempt, progress, &mut ran) { + Ok(()) + } else { + Err(ran.map_or_else(|| refused(task_health()), TaskOutcome::report)) + } } -fn request(enabled: bool, purpose: Purpose, progress: impl FnMut()) -> bool { +fn request( + enabled: bool, + 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| { - let mut outcome = TaskOutcome::Failed; + TASKS.set_enabled_on_turn(enabled, purpose, progress, on_turn, |enabled, progress| { + let mut outcome = TaskOutcome::Failed(None); crate::startup::display::change(enabled, progress, |progress| { outcome = run_task(if enabled { TASK_ENABLE } else { TASK_DISABLE }, progress); outcome == TaskOutcome::Done }); + *ran = Some(outcome); outcome }) } #[cfg(not(windows))] { - let _ = (enabled, purpose, progress); + let _ = (enabled, purpose, progress, ran); + on_turn(); false } } @@ -344,7 +460,31 @@ 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` 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 @@ -493,7 +633,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); @@ -554,7 +694,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); } @@ -709,6 +849,113 @@ 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")); + } + + /// 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!( @@ -723,7 +970,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..75c207da --- /dev/null +++ b/host/src/vdd_device.rs @@ -0,0 +1,445 @@ +//! 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) +} + +/// 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 + }) +} + +/// 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 +} + +#[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); + } + + /// 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()); + // 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); + } + } + + #[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"); + } +} diff --git a/host/src/vdd_settings.rs b/host/src/vdd_settings.rs index 15aa32cf..3eb68fae 100644 --- a/host/src/vdd_settings.rs +++ b/host/src/vdd_settings.rs @@ -7,8 +7,14 @@ //! 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::ops::Range; use std::path::Path; /// A display mode for the virtual display's mode list. @@ -19,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 @@ -58,13 +70,47 @@ 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) +} + +/// When the requested mode cannot be set (the driver offers only its own +/// list), the largest offered size that fits inside it, so the screen is +/// as sharp as it can be instead of staying at 800x600. +pub fn best_offered(requested: VddMode, offered: &[(u32, u32)]) -> 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. -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 { + xml.push_str(&gpu_element(gpu)); + } xml.push_str(" \n"); xml.push_str(&format!( " {global_refresh}\n" @@ -84,15 +130,554 @@ pub fn render(modes: &[VddMode]) -> 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. `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: &[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")?; + let mut named = adapters + .iter() + .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)) { + return Err("two graphics cards have the same name"); + } + } + Ok(name) +} + +/// 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), +} + +/// 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, '_' | ':' | '-' | '.')) +} + +/// 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"); + } + if !tag.attributes_raw().iter().all(u8::is_ascii_whitespace) { + return Err("an element has attributes, which this file never has"); + } + Ok(()) +} + +/// 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) { + 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); + 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(_) if in_name => { + 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()) { + (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::GeneralRef(_) if outside => { + return Err("there is text outside the root element") + } + // 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::Decl(_) => {} + 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::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 + .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. 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 = gpu_edit(¤t, gpu) + .map_err(|reason| io::Error::new(io::ErrorKind::InvalidData, reason))?; + if updated == current { + return Ok(false); + } + replace(path, &updated)?; + 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 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. +pub fn pin_change( + named: Option<&str>, + wanted: Option<&str>, + owned: bool, + ours: &[&str], +) -> PinChange { + 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), + (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`, 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 { + let marker = app_dir + .join("driver") + .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 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 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, + 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 = 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), + PinChange::Reset => ("default".to_string(), PinOutcome::Reset), + }; + 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, || replace(path, &updated))?; + Ok(outcome) +} + +/// `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>, + ours: &[&str], + write: impl FnOnce() -> io::Result<()>, +) -> io::Result<()> { + 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(|_| { + if grew { + let _ = remember_pins(record, ours); + } + })?; + // A record left holding both is still true. + let _ = remember_pins(record, new.as_slice()); + Ok(()) +} + +/// 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` +/// under the rules of `pin_change`: a card someone else named stays named +/// 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 names this +/// host recorded writing. +pub fn write_with_pin( + path: &Path, + record: &Path, + owned: bool, + modes: &[VddMode], + wanted: Option<&str>, +) -> io::Result { + 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_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), + PinChange::Reset => (None, PinOutcome::Reset), + }; + let write = || write_to(path, modes, name.as_deref()); + match outcome { + PinOutcome::Named => record_around(record, name.as_deref(), &ours, write)?, + PinOutcome::Reset => record_around(record, None, &ours, write)?, + _ => write()?, + } + 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]) -> 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))?; + 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 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()?; + drop(file); std::fs::rename(&tmp, path) } @@ -131,6 +716,44 @@ 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 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); @@ -153,7 +776,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 +789,759 @@ 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>")); + } + + /// 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 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), + 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 = 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 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])), + 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(); + 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); + } + + /// 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 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, &[]), 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, &[]), + Refuse(NAMED_ELSEWHERE) + ); + assert_eq!( + pin_change(Some(OTHER), Some(INTEL), false, &[INTEL]), + Refuse(NAMED_ELSEWHERE) + ); + 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, + &[OTHER] + ), + write() + ); + // Already named, ignoring case. + assert_eq!( + 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, &[INTEL]), Reset); + assert_eq!(pin_change(Some(INTEL), None, true, &[]), Leave); + assert_eq!(pin_change(None, None, false, &[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(); + } + + /// 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, only on a driver Setup installed. + std::fs::write(&path, "").unwrap(); + assert_eq!( + 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 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(); + } + + /// 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] + 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"), + "C:\\Elsewhere\\unins000.exe\r\n0.0.0", + ) + .unwrap(); + 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] + 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_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 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); + // 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())); + 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())); 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"); diff --git a/host/src/vdd_snapshot.rs b/host/src/vdd_snapshot.rs new file mode 100644 index 00000000..93a66af1 --- /dev/null +++ b/host/src/vdd_snapshot.rs @@ -0,0 +1,596 @@ +//! 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, +} + +impl Target { + fn is_vdd(&self) -> bool { + self.monitor_path.to_ascii_uppercase().contains(VDD_MONITOR) + } +} + +/// 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()) + .flat_map(|t| std::iter::once(t.adapter).chain(t.source.map(|(adapter, _)| adapter))) + .collect(); + adapters + .iter() + .filter(|a| !vdd.contains(&a.luid)) + .cloned() + .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`. + 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 = 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| 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", + 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.is_vdd(); + 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. `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", + ) + .parse() + .ok(), + default_settings_path: (!path.starts_with("unavailable")).then(|| { + path.trim_end_matches('\\') + .eq_ignore_ascii_case(r"C:\VirtualDisplayDriver") + }), + 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}; + +#[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: Ok(DeviceState::NoMonitor), + pin: Ok("Intel(R) Iris(R) Xe Graphics".into()), + render: None, + 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 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 \ + 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")); + // 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")); + // 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] + 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 cards = render_adapters(&adapters, &[monitor.clone(), vdd]); + let names: Vec<&str> = 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"), &cards), + Ok("NVIDIA GeForce RTX 5080") + ); + // 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()); + } + + /// 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(); + 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}"); + } +} diff --git a/host/tests/e2e_synthetic.rs b/host/tests/e2e_synthetic.rs index 16317894..a18cbac7 100644 --- a/host/tests/e2e_synthetic.rs +++ b/host/tests/e2e_synthetic.rs @@ -257,45 +257,57 @@ fn usb_framed_stream_end_to_end() { assert_eq!(PIPELINE_STATS.lock().transport_retransmits, 0); eprintln!("USB_E2E decoded={decoded} after_recovery={after_recovery} heartbeats={heartbeats} dropped={dropped} recovery_ms={recovery_ms}"); - // A virtual-display startup restarts the pipeline once. The tunnel - // must survive that restart: 0.3.1 closed it, the iPad reconnected, - // and with Extended display selected the reconnect could trigger the - // next restart indefinitely. + // With the second screen selected, a connecting iPad gets the mirror + // from the running pipeline at once, while the virtual display starts + // beside it. 0.3.2 restarted the pipeline first and sent no video + // until the display had attached or a 10 s wait ran out; the iPad + // gives up 10 s after the handshake, and over USB it was dialed again + // into the same restart. Here the start fails at once (no driver + // with synthetic capture), and the reconnect after it mirrors too. + use eternal_host::capture::second_screen; use eternal_host::control::{CaptureTarget, VddStatus}; use eternal_host::supervisor::CURRENT_GENERATION; *shared.capture_target.lock() = CaptureTarget::VirtualExtended; *shared.vdd_status.lock() = VddStatus::WaitingForClient; - let original_generation = CURRENT_GENERATION.load(Ordering::SeqCst); + let generation = CURRENT_GENERATION.load(Ordering::SeqCst); let ControlMessage::Hello2(mut next_hello) = hello else { unreachable!() }; - next_hello.client_nonce += 1; - write_datagram(&mut stream, &encode_control(0, 1, &ControlMessage::Hello2(next_hello.clone()))).await.unwrap(); - let mut received_ack = false; - let mut frames = 0; - let mut new_epoch = None; - let deadline = Instant::now() + Duration::from_secs(5); - while frames < 30 || !received_ack { - assert!(Instant::now() < deadline, "media must resume on the same tunnel after the restart"); - let bytes = tokio::time::timeout(Duration::from_secs(2), read_datagram(&mut stream)) - .await - .expect("tunnel stays open and active across the restart") - .expect("tunnel must not close during the restart"); - if let Ok((_, ControlMessage::HelloAck(ack))) = parse_control(&bytes) { - assert_eq!(ack.status, HelloStatus::Ok); - received_ack = true; - } else if let Ok((header, _)) = MediaHeader::decode(&bytes) { - if header.stream_epoch != epoch && header.frag_index == 0 { - new_epoch = Some(header.stream_epoch); - frames += 1; - } - } + let mut session = 0; + for attempt in 1..=2 { + next_hello.client_nonce += 1; + let sent = Instant::now(); + write_datagram(&mut stream, &encode_control(0, 1, &ControlMessage::Hello2(next_hello.clone()))).await.unwrap(); + let (frame_epoch, decoded) = + first_decoded_frame(&mut stream, &mut session, &mut msg_seq, None, sent + Duration::from_secs(5)).await; + assert_eq!(frame_epoch, epoch, "the mirror comes from the running pipeline"); + assert_eq!(CURRENT_GENERATION.load(Ordering::SeqCst), generation, + "attempt {attempt}: connecting must not restart the pipeline"); + assert_eq!(*shared.vdd_status.lock(), VddStatus::TaskFailed); + eprintln!("USB_SECOND_SCREEN_MIRROR attempt={attempt} first_decoded_ms={}", + decoded.duration_since(sent).as_millis()); } - assert!(new_epoch.is_some(), "the restarted pipeline streams with a new epoch"); - assert_eq!(CURRENT_GENERATION.load(Ordering::SeqCst), original_generation + 1, - "USB HELLO during attach must not restart capture again"); - assert_eq!(*shared.vdd_status.lock(), VddStatus::Attaching); + + // The display attached: the pipeline restarts onto it, and the USB + // tunnel carries the new stream epoch without reconnecting. + second_screen::note_attached(&shared, "virtual".into()); + let switched = Instant::now(); + let (second_epoch, decoded) = first_decoded_frame( + &mut stream, &mut session, &mut msg_seq, Some(epoch), switched + Duration::from_secs(5)).await; + assert_eq!(CURRENT_GENERATION.load(Ordering::SeqCst), generation + 1, "one restart onto it"); + assert_eq!(*shared.vdd_status.lock(), VddStatus::Active); + eprintln!("USB_SECOND_SCREEN_SWITCH first_decoded_ms={}", decoded.duration_since(switched).as_millis()); + + // Its capture failed before it showed an image (DuplicateOutput + // returned an error): back to the mirror on the same tunnel. + second_screen::start_proving(&shared, generation + 1, Instant::now()); + second_screen::note_capture_error(&shared, generation + 1); + let failed = Instant::now(); + let (_, decoded) = first_decoded_frame( + &mut stream, &mut session, &mut msg_seq, Some(second_epoch), failed + Duration::from_secs(5)).await; + assert_eq!(CURRENT_GENERATION.load(Ordering::SeqCst), generation + 2, "one restart back"); + assert_eq!(*shared.vdd_status.lock(), VddStatus::Failed); + eprintln!("USB_SECOND_SCREEN_FALLBACK first_decoded_ms={}", decoded.duration_since(failed).as_millis()); *shared.capture_target.lock() = CaptureTarget::PrimaryAuto; *shared.vdd_status.lock() = VddStatus::Inactive; - eprintln!("USB_VDD_RESTART_KEEPS_TUNNEL frames={frames} restarts=1"); drop(stream); let disconnected = Instant::now(); while shared.client_connected() && disconnected.elapsed() < Duration::from_secs(1) { @@ -321,6 +333,70 @@ fn usb_framed_stream_end_to_end() { } } +/// Reads the USB tunnel until a whole frame of the current session decodes, +/// sending receiver reports so the host keeps the session. Frames of +/// `skip_epoch` (the stream before a restart) are ignored. Returns the +/// decoded frame's stream epoch and when it decoded. +async fn first_decoded_frame( + stream: &mut tokio::net::TcpStream, + session: &mut u32, + msg_seq: &mut u32, + skip_epoch: Option, + deadline: Instant, +) -> (u32, Instant) { + use eternal_host::transport::link::{read_datagram, write_datagram}; + let mut reassembler = Reassembler::new(); + let mut decoder = H264TestDecoder::new(); + let mut last_report = Instant::now(); + let mut highest_seq = 0; + let mut current_epoch = 0; + loop { + assert!(Instant::now() < deadline, "no decodable frame in time"); + if *session != 0 && last_report.elapsed() >= Duration::from_millis(300) { + last_report = Instant::now(); + *msg_seq += 1; + let report = ControlMessage::ReceiverReport(ReceiverReport { + stream_epoch: current_epoch, + highest_seq, + decode_fps_x10: 600, + ..ReceiverReport::default() + }); + write_datagram(stream, &encode_control(*session, *msg_seq, &report)) + .await + .unwrap(); + } + let bytes = tokio::time::timeout(Duration::from_secs(2), read_datagram(stream)) + .await + .expect("the tunnel stays active") + .expect("the tunnel stays open"); + if let Ok((_, ControlMessage::HelloAck(ack))) = parse_control(&bytes) { + assert_eq!(ack.status, HelloStatus::Ok); + *session = ack.session_id; + continue; + } + let Ok((header, payload)) = MediaHeader::decode(&bytes) else { + continue; + }; + if header.session_id != *session || Some(header.stream_epoch) == skip_epoch { + continue; + } + current_epoch = header.stream_epoch; + highest_seq = header.frame_seq; + if let AddOutcome::Completed(frame) = reassembler.add_fragment( + header.frame_seq, + header.frag_index, + header.frag_count, + header.stream_epoch, + payload, + Instant::now(), + ) { + if !decoder.decode(&frame).is_empty() { + return (header.stream_epoch, Instant::now()); + } + } + } +} + fn free_udp_port() -> u16 { UdpSocket::bind("127.0.0.1:0") .and_then(|s| s.local_addr()) 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 diff --git a/host/tests/snapshots/feedback-panel-long-minimum.png b/host/tests/snapshots/feedback-panel-long-minimum.png index d11560f1..9d4a77c9 100644 Binary files a/host/tests/snapshots/feedback-panel-long-minimum.png and b/host/tests/snapshots/feedback-panel-long-minimum.png differ diff --git a/host/tests/snapshots/feedback-panel-long.png b/host/tests/snapshots/feedback-panel-long.png index 4315e833..0ede7e72 100644 Binary files a/host/tests/snapshots/feedback-panel-long.png and b/host/tests/snapshots/feedback-panel-long.png differ diff --git a/host/tests/snapshots/settings.png b/host/tests/snapshots/settings.png index 8ad0760a..66be0a6c 100644 Binary files a/host/tests/snapshots/settings.png and b/host/tests/snapshots/settings.png differ 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" + ); } diff --git a/installer/EternalMonitor.iss b/installer/EternalMonitor.iss index a3b62c97..603c630b 100644 --- a/installer/EternalMonitor.iss +++ b/installer/EternalMonitor.iss @@ -82,6 +82,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 @@ -103,7 +104,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 @@ -181,6 +183,30 @@ begin end; end; +procedure RepairVddDriver; +var + ExitCode: Integer; + Message: String; +begin + { 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) 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; 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..3243c5e0 --- /dev/null +++ b/installer/scripts/vdd-driver-repair.ps1 @@ -0,0 +1,106 @@ +# 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' + +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)) +} + +function Get-Working { @(Get-VddDevices | Where-Object { Test-Healthy $_ }) } + +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." + } +} + +# 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.' + exit 0 + } + + # 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 + } + + # 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) { 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) { + 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) { 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 +} diff --git a/installer/scripts/vdd-toggle.ps1 b/installer/scripts/vdd-toggle.ps1 index 52c967bd..52fc1e6a 100644 --- a/installer/scripts/vdd-toggle.ps1 +++ b/installer/scripts/vdd-toggle.ps1 @@ -7,6 +7,12 @@ # 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 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')] @@ -22,21 +28,48 @@ $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. 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) { + $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"} } + $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"} } + $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 } 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 + $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 + } } 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/installer/tests/Test-VddDriverRepair.ps1 b/installer/tests/Test-VddDriverRepair.ps1 new file mode 100644 index 00000000..003638b3 --- /dev/null +++ b/installer/tests/Test-VddDriverRepair.ps1 @@ -0,0 +1,189 @@ +# 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' +$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\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=$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 + # 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}) + } +} +function Add-Vdd([string]$Inf, [int]$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' } + $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 } +# 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] + if ($global:EMRepair.RemoveSticks) { $global:LASTEXITCODE = 259; return } + $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 += "nefconw $verb" + $exit = 0 + switch ($verb) { + '--create-device-node' { + if ($ArgumentList -notmatch '--hardware-id Root\\MttVDD --class-name Display --class-guid 4D36E968') { throw "Bad create: $ArgumentList" } + Add-Vdd $null 28 | Out-Null + } + '--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=$exit} +} +function Invoke-Repair { + $global:LASTEXITCODE = 0 + try { & $script 2>$null | Out-Null } catch { $global:LASTEXITCODE = "threw: $($_.Exception.Message)" } + $LASTEXITCODE +} +$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 ' ')" + } +} +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 | 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 } + + # 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 'no vendor registration' 10 '' + Reset-Fixture; Remove-Item $nefcon + 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 } + # 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 } + # 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, 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') { + $failures += 'Setup does not repair a preserved driver before registering the tasks' + } + $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' + } + 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, removal not done, 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 +} diff --git a/scripts/win/Test-VddToggle.ps1 b/scripts/win/Test-VddToggle.ps1 index 6973c38a..c51d2c85 100644 --- a/scripts/win/Test-VddToggle.ps1 +++ b/scripts/win/Test-VddToggle.ps1 @@ -2,50 +2,145 @@ 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 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) - $global:EMVddTestState.Devices | Where-Object { !$InstanceId -or $_.InstanceId -eq $InstanceId } + param($Class, [switch]$PresentOnly, [string[]]$InstanceId) + $global:EMVddTestState.Calls += 'get' + $global:EMVddTestState.Devices | Where-Object { + ($InstanceId -and $InstanceId -contains $_.InstanceId) -or (!$InstanceId -and $_.InstanceId -notlike 'DISPLAY\*') + } } function Get-PnpDeviceProperty { - param($InstanceId, $KeyName) - [pscustomobject]@{Data=(Get-PnpDevice -InstanceId $InstanceId).HardwareIds} + 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} + } } 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 = Find-Device $InstanceId + $enabled = $global:EMVddTestState.Enabled + $device.Problem = $enabled.Problem + $device.Status = $enabled.Status + $monitor = Find-Device '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 = Find-Device $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 2>$null | Out-Null + if ($LASTEXITCODE -ne $Code) { throw "$Case`: expected exit $Code, got $LASTEXITCODE" } +} try { - & $toggle -Action enable - & $toggle -Action disable - if (($global:EMVddTestState.Calls -join ',') -ne 'enable:ROOT\DISPLAY\0007,disable:ROOT\DISPLAY\0007') { + 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 | 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 + + # 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" + } + + # 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 0 'not started yet' + + Reset-State + $global:EMVddTestState.Enabled = @{Problem=0; Status='OK'; Monitor=$null} + 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) -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 = @() + 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' + + 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 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 { - Remove-Variable EMVddTestState -Scope Global + Remove-Variable EMVddTestState -Scope Global -ErrorAction SilentlyContinue }