Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
6e4dc95
Fail the display task when the virtual display cannot appear
whoisaldo Oct 22, 2026
69cd3df
Say why the second screen did not appear
whoisaldo Oct 22, 2026
789b011
Draw the virtual display on the main screen's graphics card
whoisaldo Oct 22, 2026
c74ae46
Name Windows 11 from its build number
whoisaldo Oct 22, 2026
6531b16
Repair a preserved virtual display driver from Setup
whoisaldo Oct 22, 2026
d908931
Describe the second screen and Windows 11 fixes
whoisaldo Oct 22, 2026
17977a0
Check the virtual display device state on a real PC
whoisaldo Oct 22, 2026
b08df3f
Leave the display layout to the user when the desktop skips the secon…
whoisaldo Oct 22, 2026
aee11b2
Pin the virtual display's graphics card whatever the resolution setting
whoisaldo Oct 22, 2026
af0f8a6
Repair only a driver Setup installed, and keep working devices
whoisaldo Oct 22, 2026
15b5afd
Describe the layout, graphics card and repair changes
whoisaldo Oct 22, 2026
e5971df
Show which adapter renders each display in the native device check
whoisaldo Oct 22, 2026
c3e54e7
Edit the driver's settings file by parsing it, and leave odd files alone
whoisaldo Oct 22, 2026
c8be1bc
Log how Windows sees the displays when the second screen fails
whoisaldo Oct 22, 2026
1183d23
Count a device removal only once the device is gone
whoisaldo Oct 22, 2026
cb8731a
Mention the failure snapshot in the release notes
whoisaldo Oct 22, 2026
306e4af
Log the failure snapshot for every failed Enable
whoisaldo Oct 22, 2026
f376c21
Leave the virtual display's own adapter out of the duplicate-name check
whoisaldo Oct 22, 2026
dec087b
Refuse settings files that are not well-formed XML 1.0
whoisaldo Oct 22, 2026
ab5ee5a
Say only the display snapshot leaves out names
whoisaldo Oct 22, 2026
b493c5e
Bring the second screen to the iPad's mode when Windows restores another
whoisaldo Oct 22, 2026
c90edf3
Turn the virtual display off beside startup, not before the first frame
whoisaldo Oct 22, 2026
7c1da58
Say in Settings when the resolution match applies
whoisaldo Oct 22, 2026
ddec775
Use the sharpest offered size when the driver lacks the iPad's mode
whoisaldo Oct 22, 2026
800e97c
Describe the second screen size, startup and Settings changes
whoisaldo Oct 22, 2026
8e52661
Edit only the settings files this host and the driver write
whoisaldo Oct 23, 2026
14f31bf
Merge origin/main into fix/hybrid-second-screen
whoisaldo Oct 26, 2026
2cba89b
Pin the main screen's card when Windows lists it twice
whoisaldo Oct 26, 2026
cb426fd
Log the failure snapshot when the iPad ends an attach that never came
whoisaldo Oct 26, 2026
1c661ae
Check that a settings file 0.3.2 wrote gets the card name
whoisaldo Oct 26, 2026
4b44097
Check a started virtual display in two PnP calls and wait at most 4 s…
whoisaldo Oct 26, 2026
f70b9b3
Let the host's attach wait decide when the virtual display's monitor …
whoisaldo Oct 27, 2026
392e4c4
Change the driver's card name only when this host may, and log the ca…
whoisaldo Oct 27, 2026
edccfa5
Join the Enable output before matching it in the toggle test
whoisaldo Oct 27, 2026
06e3d75
Merge origin/fix/second-screen-fallback into fix/hybrid-second-screen
whoisaldo Oct 27, 2026
732c19b
Name the card, settle the mode and log failure snapshots in the secon…
whoisaldo Oct 27, 2026
636d188
Keep a card someone else named when Match the iPad's resolution rewri…
whoisaldo Oct 27, 2026
fc5acae
Turn the virtual display off when the mode changes while its mode is set
whoisaldo Oct 27, 2026
504b9e7
Keep settings files this host cannot read, check the driver is still …
whoisaldo Oct 27, 2026
62d8526
Say a second screen showed no picture instead of calling it late
whoisaldo Oct 27, 2026
554242d
Merge remote-tracking branch 'origin/fix/second-screen-fallback' into…
whoisaldo Oct 27, 2026
5f70c2b
Merge remote-tracking branch 'origin/fix/second-screen-fallback' into…
whoisaldo Oct 27, 2026
cce3466
Keep the pin record true across a crash or power cut: list the old an…
whoisaldo Oct 27, 2026
7def676
Merge remote-tracking branch 'origin/fix/second-screen-fallback' into…
whoisaldo Oct 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

47 changes: 47 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
7 changes: 6 additions & 1 deletion docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,12 @@ triggers via `schtasks`, avoiding a per-toggle UAC prompt.
once per 10 s on its own.
- Before enabling, the host writes `vdd_settings.xml` so the virtual
display offers the iPad's native landscape resolution and refresh. There
is an opt-out in Settings.
is an opt-out in Settings. The worker also names the main screen's
graphics card there just before each Enable, the driver's only way to pick
a card, and replaces only a name it may: on a driver Setup installed, an
empty or `default` one, or its own last one. A start that fails, and a
second screen that shows no picture, log a display snapshot while the
display is still on, with the card the driver rendered on.
- A first-party signed display driver remains a long-term goal.

## Input relay
Expand Down
2 changes: 2 additions & 0 deletions host/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -59,6 +60,7 @@ version = "0.58"
features = [
"Win32_Foundation",
"Win32_Devices_DeviceAndDriverInstallation",
"Win32_Devices_Display",
"Win32_Security",
"Win32_Media_Audio",
"Win32_System_Com",
Expand Down
251 changes: 250 additions & 1 deletion host/src/capture/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ use tracing::{info, warn};
use crate::control::SharedControl;
#[cfg(any(windows, test))]
use crate::control::{CaptureTarget, VddStatus};
#[cfg(windows)]
use crate::vdd_device::DeviceState;

#[cfg(windows)]
pub mod dxgi;
Expand Down Expand Up @@ -339,6 +341,239 @@ fn capture_is_current(shared: &SharedControl, generation: u64) -> bool {
&& 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<String, &'static str>,
mode: Option<crate::vdd_settings::VddMode>,
/// The adapters' LUIDs before the Enable, to find the driver's own.
before: Vec<u64>,
}

/// 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<crate::vdd_settings::VddMode>,
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<Vec<(Option<u32>, 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")
Expand Down Expand Up @@ -383,7 +618,7 @@ pub(crate) fn reconcile_virtual_display(
prove_timeout_ms = second_screen::PROVE_TIMEOUT.as_millis() as u64,
"The virtual display showed no picture — mirroring the primary display instead"
);
second_screen::send(shared, second_screen::Command::Release);
second_screen::send(shared, second_screen::Command::Fallback(reason));
return Some(CaptureTarget::PrimaryAuto);
}
let connected = shared.client_connected();
Expand Down Expand Up @@ -889,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,
Expand Down
Loading
Loading