Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
bdec2fd
Mirror at once while the second screen starts, and remember a failed …
whoisaldo Oct 26, 2026
001b0bd
Check that a USB iPad gets the mirror at once with the second screen …
whoisaldo Oct 26, 2026
0db545c
Describe the mirror-first second screen start and its grace period
whoisaldo Oct 26, 2026
b1dae00
Keep a second screen start claimed when a new capture generation find…
whoisaldo Oct 26, 2026
b85ac45
Drop the capture module's unused warn import on Windows
whoisaldo Oct 26, 2026
48aabff
Prove the second screen with a real image, and never let capture wait…
whoisaldo Oct 27, 2026
7b5d9c5
Decode whole frames over USB across the second screen switch and its …
whoisaldo Oct 27, 2026
48143ff
Describe the second screen's image check
whoisaldo Oct 27, 2026
44de2f2
Keep unit tests away from the installed display driver tasks
whoisaldo Oct 27, 2026
c504b62
Change the virtual display on one worker thread, and keep a failed se…
whoisaldo Oct 27, 2026
d350b9b
Describe the one worker thread and the proof deadline across restarts
whoisaldo Oct 27, 2026
487acb8
Describe the never-attaching display test without a name
whoisaldo Oct 27, 2026
32c6d5a
Answer a keyframe request with an IDR on NVENC and QSV, and stop NVEN…
whoisaldo Oct 27, 2026
bd59911
Resend a static display's picture as soon as a keyframe is requested,…
whoisaldo Oct 27, 2026
41a423d
Allow each encoder its configured output delay in the mid-GOP keyfram…
whoisaldo Oct 27, 2026
33d14a7
Show the second screen on hybrid laptops and say why it didn't appear…
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
3 changes: 2 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
3 changes: 2 additions & 1 deletion docs/beta-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
23 changes: 20 additions & 3 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
163 changes: 89 additions & 74 deletions host/src/capture/dxgi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()? };
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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::<ID3D11Texture2D>() {
Ok(desktop_texture) => {
unsafe {
device_context.CopyResource(&staging_texture, &desktop_texture);
}

let frame_data: Option<Arc<Vec<u8>>> = if let Some(ref resource) = desktop_resource
{
match resource.cast::<ID3D11Texture2D>() {
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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading