From 9f4db848393ab10cb4dc27af19a31fa37558be5d Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 21 Mar 2026 13:56:48 -0400 Subject: [PATCH 1/9] ci: replace usbmmidd_v2 --- .github/workflows/ci.yml | 37 +++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fae87f9..3eafeff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,8 +102,8 @@ jobs: if: runner.os == 'Windows' shell: pwsh run: | - # function to download and extract a zip file - function DownloadAndExtract { + # function to download a file with retries + function DownloadFile { param ( [string]$Uri, [string]$OutFile @@ -129,27 +129,28 @@ jobs: Write-Host "Failed to download the file after $maxRetries attempts." exit 1 } - - # use .NET to get the base name of the file - $baseName = (Get-Item $OutFile).BaseName - - # Extract the zip file - Expand-Archive -Path $OutFile -DestinationPath $baseName } - # virtual display driver - DownloadAndExtract ` - -Uri "https://www.amyuni.com/downloads/usbmmidd_v2.zip" ` - -OutFile "usbmmidd_v2.zip" + # install Parsec VDD driver silently + DownloadFile ` + -Uri "https://builds.parsec.app/vdd/parsec-vdd-0.45.0.0.exe" ` + -OutFile "parsec-vdd-0.45.0.0.exe" + Start-Process -FilePath ".\parsec-vdd-0.45.0.0.exe" -ArgumentList "/S" -Wait + + # download the ParsecVDisplay app (portable) to control the driver + DownloadFile ` + -Uri "https://github.com/nomi-san/parsec-vdd/releases/download/v0.45.1/ParsecVDisplay-v0.45-portable.zip" ` + -OutFile "ParsecVDisplay.zip" + Expand-Archive -Path "ParsecVDisplay.zip" -DestinationPath "ParsecVDisplay" - # install - Set-Location -Path usbmmidd_v2/usbmmidd_v2 - ./deviceinstaller64 install usbmmidd.inf usbmmidd + # start the app in the background so it keeps virtual displays alive + $appPath = (Resolve-Path "ParsecVDisplay\ParsecVDisplay.exe").Path + Start-Process -FilePath $appPath + Start-Sleep -Seconds 3 - # create 2 virtual displays, using 3+ can crash the runner - # see: https://github.com/LizardByte/libdisplaydevice/pull/36 + # add 2 virtual displays for ($i = 1; $i -le 2; $i++) { - ./deviceinstaller64 enableidd 1 + & $appPath add } - name: Setup python From e24994c00d806f9e7af4ce855f2210bf3e4b7c9e Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 21 Mar 2026 14:14:32 -0400 Subject: [PATCH 2/9] Skip HDR test when displays or topology absent Add runtime guards in tests/unit/windows/test_win_display_device_hdr.cpp: skip the GetSetHdrStates test if no displays are available or if setting an extended topology fails. Replace hard ASSERT_TRUE topology check with conditional GTEST_SKIP_ to avoid failing on systems that don't support extended topologies. --- tests/unit/windows/test_win_display_device_hdr.cpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/unit/windows/test_win_display_device_hdr.cpp b/tests/unit/windows/test_win_display_device_hdr.cpp index 985a785..4b27725 100644 --- a/tests/unit/windows/test_win_display_device_hdr.cpp +++ b/tests/unit/windows/test_win_display_device_hdr.cpp @@ -63,11 +63,17 @@ namespace { } // namespace TEST_F_S(GetSetHdrStates) { - const auto available_devices {getAvailableDevices(*m_layer)}; + const auto available_devices {getAvailableDevices(*m_layer, false)}; ASSERT_TRUE(available_devices); + if (available_devices->empty()) { + GTEST_SKIP_("No displays are available in the system."); + } + const auto topology_guard {makeTopologyGuard(m_win_dd)}; - ASSERT_TRUE(m_win_dd.setTopology(makeExtendedTopology(*available_devices))); + if (!m_win_dd.setTopology(makeExtendedTopology(*available_devices))) { + GTEST_SKIP_("Could not set extended topology (displays may not support it)."); + } const auto hdr_states {m_win_dd.getCurrentHdrStates(display_device::win_utils::flattenTopology(m_win_dd.getCurrentTopology()))}; if (!std::ranges::any_of(hdr_states, [](auto entry) -> bool { From 413ce3ba888cf406a97513adef706f5747ed1d25 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 21 Mar 2026 15:13:49 -0400 Subject: [PATCH 3/9] Add Parsec VDD keepalive script and use it in CI Replace the previous ParsecVDisplay portable download/start in the CI workflow with a new PowerShell script (scripts/parsec-vdd.ps1) that P/Invokes Win32 setupapi/kernel32 to add virtual displays and run a keepalive loop so the driver doesn't unplug them after ~1s. Update the workflow (.github/workflows/ci.yml) to launch the script with DisplayCount=2 and wait briefly for registration. Also tighten a unit test (tests/unit/windows/test_win_display_device_hdr.cpp) to ASSERT that setTopology succeeds instead of skipping when it fails. --- .github/workflows/ci.yml | 26 ++- scripts/parsec-vdd.ps1 | 168 ++++++++++++++++++ .../windows/test_win_display_device_hdr.cpp | 4 +- 3 files changed, 180 insertions(+), 18 deletions(-) create mode 100644 scripts/parsec-vdd.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3eafeff..b60ee97 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,21 +137,17 @@ jobs: -OutFile "parsec-vdd-0.45.0.0.exe" Start-Process -FilePath ".\parsec-vdd-0.45.0.0.exe" -ArgumentList "/S" -Wait - # download the ParsecVDisplay app (portable) to control the driver - DownloadFile ` - -Uri "https://github.com/nomi-san/parsec-vdd/releases/download/v0.45.1/ParsecVDisplay-v0.45-portable.zip" ` - -OutFile "ParsecVDisplay.zip" - Expand-Archive -Path "ParsecVDisplay.zip" -DestinationPath "ParsecVDisplay" - - # start the app in the background so it keeps virtual displays alive - $appPath = (Resolve-Path "ParsecVDisplay\ParsecVDisplay.exe").Path - Start-Process -FilePath $appPath - Start-Sleep -Seconds 3 - - # add 2 virtual displays - for ($i = 1; $i -le 2; $i++) { - & $appPath add - } + # add virtual displays and keep them alive via a background process. + # the script mirrors parsec-vdd.h via Win32 P/Invoke and runs a + # keepalive loop, which is required to prevent the driver from + # unplugging the displays after ~1 second. + $scriptPath = Join-Path $env:GITHUB_WORKSPACE "scripts\parsec-vdd.ps1" + $pwsh = Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0\powershell.exe" + Start-Process -FilePath $pwsh ` + -ArgumentList "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", $scriptPath, "-DisplayCount", "2" ` + -WindowStyle Hidden + # allow time for displays to be added and registered by Windows + Start-Sleep -Seconds 5 - name: Setup python id: setup-python diff --git a/scripts/parsec-vdd.ps1 b/scripts/parsec-vdd.ps1 new file mode 100644 index 0000000..161e7c4 --- /dev/null +++ b/scripts/parsec-vdd.ps1 @@ -0,0 +1,168 @@ +param ( + [Parameter(Mandatory = $true)] + [int]$DisplayCount +) + +Add-Type -TypeDefinition @" +using System; +using System.Runtime.InteropServices; +using System.Threading; + +public class ParsecVdd { + const uint GENERIC_READ = 0x80000000; + const uint GENERIC_WRITE = 0x40000000; + const uint FILE_SHARE_READ = 0x00000001; + const uint FILE_SHARE_WRITE = 0x00000002; + const uint OPEN_EXISTING = 3; + const uint FILE_FLAG_NO_BUFFERING = 0x20000000; + const uint FILE_FLAG_OVERLAPPED = 0x40000000; + const uint FILE_FLAG_WRITE_THROUGH = 0x80000000; + const uint DIGCF_PRESENT = 0x00000002; + const uint DIGCF_DEVICEINTERFACE = 0x00000010; + const uint IOCTL_ADD = 0x0022e004; + const uint IOCTL_UPDATE = 0x0022a00c; + static readonly Guid VDD_ADAPTER_GUID = new Guid("00b41627-04c4-429e-a26e-0265cf50c8fa"); + + [StructLayout(LayoutKind.Sequential)] + struct SP_DEVICE_INTERFACE_DATA { + public int cbSize; + public Guid interfaceClassGuid; + public uint flags; + public IntPtr reserved; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)] + struct SP_DEVICE_INTERFACE_DETAIL_DATA_A { + public int cbSize; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] + public string DevicePath; + } + + [StructLayout(LayoutKind.Sequential)] + struct OVERLAPPED { + public IntPtr Internal; + public IntPtr InternalHigh; + public IntPtr Pointer; + public IntPtr hEvent; + } + + [DllImport("setupapi.dll", SetLastError = true)] + static extern IntPtr SetupDiGetClassDevsA( + ref Guid ClassGuid, IntPtr Enumerator, IntPtr hwndParent, uint Flags); + + [DllImport("setupapi.dll", SetLastError = true)] + static extern bool SetupDiEnumDeviceInterfaces( + IntPtr DeviceInfoSet, IntPtr DeviceInfoData, + ref Guid InterfaceClassGuid, uint MemberIndex, + ref SP_DEVICE_INTERFACE_DATA DeviceInterfaceData); + + [DllImport("setupapi.dll", SetLastError = true, CharSet = CharSet.Ansi)] + static extern bool SetupDiGetDeviceInterfaceDetailA( + IntPtr DeviceInfoSet, ref SP_DEVICE_INTERFACE_DATA DeviceInterfaceData, + ref SP_DEVICE_INTERFACE_DETAIL_DATA_A DeviceInterfaceDetailData, + uint DeviceInterfaceDetailDataSize, out uint RequiredSize, IntPtr DeviceInfoData); + + [DllImport("setupapi.dll")] + static extern bool SetupDiDestroyDeviceInfoList(IntPtr DeviceInfoSet); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Ansi)] + static extern IntPtr CreateFileA( + string lpFileName, uint dwDesiredAccess, uint dwShareMode, + IntPtr lpSecurityAttributes, uint dwCreationDisposition, + uint dwFlagsAndAttributes, IntPtr hTemplateFile); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern IntPtr CreateEvent( + IntPtr lpEventAttributes, bool bManualReset, + bool bInitialState, string lpName); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool DeviceIoControl( + IntPtr hDevice, uint dwIoControlCode, + byte[] lpInBuffer, int nInBufferSize, + out int lpOutBuffer, int nOutBufferSize, + IntPtr lpBytesReturned, ref OVERLAPPED lpOverlapped); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool GetOverlappedResultEx( + IntPtr hFile, ref OVERLAPPED lpOverlapped, + out uint lpNumberOfBytesTransferred, + int dwMilliseconds, bool bAlertable); + + [DllImport("kernel32.dll")] + static extern bool CloseHandle(IntPtr hObject); + + public static IntPtr OpenHandle() { + var guid = VDD_ADAPTER_GUID; + var devInfo = SetupDiGetClassDevsA( + ref guid, IntPtr.Zero, IntPtr.Zero, DIGCF_PRESENT | DIGCF_DEVICEINTERFACE); + if (devInfo == new IntPtr(-1)) return new IntPtr(-1); + + var ifaceData = new SP_DEVICE_INTERFACE_DATA(); + ifaceData.cbSize = Marshal.SizeOf(ifaceData); + + for (uint i = 0; SetupDiEnumDeviceInterfaces( + devInfo, IntPtr.Zero, ref guid, i, ref ifaceData); i++) { + var detail = new SP_DEVICE_INTERFACE_DETAIL_DATA_A(); + detail.cbSize = IntPtr.Size == 8 ? 8 : 6; + uint needed; + SetupDiGetDeviceInterfaceDetailA( + devInfo, ref ifaceData, ref detail, + (uint)Marshal.SizeOf(detail), out needed, IntPtr.Zero); + var handle = CreateFileA( + detail.DevicePath, + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + IntPtr.Zero, OPEN_EXISTING, + FILE_FLAG_NO_BUFFERING | FILE_FLAG_OVERLAPPED | FILE_FLAG_WRITE_THROUGH, + IntPtr.Zero); + if (handle != IntPtr.Zero && handle != new IntPtr(-1)) { + SetupDiDestroyDeviceInfoList(devInfo); + return handle; + } + } + + SetupDiDestroyDeviceInfoList(devInfo); + return new IntPtr(-1); + } + + static int IoControl(IntPtr vdd, uint code, byte[] input) { + var inBuf = new byte[32]; + if (input != null) Array.Copy(input, inBuf, Math.Min(input.Length, inBuf.Length)); + var ov = new OVERLAPPED(); + ov.hEvent = CreateEvent(IntPtr.Zero, true, false, null); + int outBuf = 0; + DeviceIoControl(vdd, code, inBuf, inBuf.Length, out outBuf, 4, IntPtr.Zero, ref ov); + uint transferred; + GetOverlappedResultEx(vdd, ref ov, out transferred, 5000, false); + if (ov.hEvent != IntPtr.Zero) CloseHandle(ov.hEvent); + return outBuf; + } + + public static void Update(IntPtr vdd) { IoControl(vdd, IOCTL_UPDATE, null); } + + public static int AddDisplay(IntPtr vdd) { + int idx = IoControl(vdd, IOCTL_ADD, null); + Update(vdd); + return idx; + } + + public static void Keepalive(IntPtr vdd) { + while (true) { Update(vdd); Thread.Sleep(100); } + } +} +"@ + +$vdd = [ParsecVdd]::OpenHandle() +if ($vdd -eq [IntPtr]::new(-1)) { + Write-Error "Failed to open the Parsec VDD device handle." + exit 1 +} + +for ($i = 1; $i -le $DisplayCount; $i++) { + $idx = [ParsecVdd]::AddDisplay($vdd) + Write-Information "Added virtual display at index $idx" +} + +Write-Information "Keeping $DisplayCount virtual display(s) alive..." +[ParsecVdd]::Keepalive($vdd) diff --git a/tests/unit/windows/test_win_display_device_hdr.cpp b/tests/unit/windows/test_win_display_device_hdr.cpp index 4b27725..fa1f2f0 100644 --- a/tests/unit/windows/test_win_display_device_hdr.cpp +++ b/tests/unit/windows/test_win_display_device_hdr.cpp @@ -71,9 +71,7 @@ TEST_F_S(GetSetHdrStates) { } const auto topology_guard {makeTopologyGuard(m_win_dd)}; - if (!m_win_dd.setTopology(makeExtendedTopology(*available_devices))) { - GTEST_SKIP_("Could not set extended topology (displays may not support it)."); - } + ASSERT_TRUE(m_win_dd.setTopology(makeExtendedTopology(*available_devices))); const auto hdr_states {m_win_dd.getCurrentHdrStates(display_device::win_utils::flattenTopology(m_win_dd.getCurrentTopology()))}; if (!std::ranges::any_of(hdr_states, [](auto entry) -> bool { From aed2f3cee7f8546703e22bf3d710dc1c1f0adc5d Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 21 Mar 2026 15:40:56 -0400 Subject: [PATCH 4/9] Assign distinct resolutions to Parsec VDDs Add enumeration and display-mode APIs to parsec-vdd.ps1 and set distinct resolutions for Parsec virtual displays so Windows can distinguish otherwise-identical VDDs for topology operations. Implements DISPLAY_DEVICE/DEVMODE structs, EnumDisplayDevicesA and ChangeDisplaySettingsExA P/Invoke calls, GetParsecDisplayNames and SetResolution helpers, and a short wait + resolution-assignment loop (cycles 1920x1080, 1280x720, 1600x900 @60Hz). Also log results of each change. Update CI workflow sleep from 5s to 10s to allow time for displays to be added, resolutions set, and registered by Windows. --- .github/workflows/ci.yml | 4 +- scripts/parsec-vdd.ps1 | 109 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b60ee97..5474fe4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -146,8 +146,8 @@ jobs: Start-Process -FilePath $pwsh ` -ArgumentList "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", $scriptPath, "-DisplayCount", "2" ` -WindowStyle Hidden - # allow time for displays to be added and registered by Windows - Start-Sleep -Seconds 5 + # allow time for displays to be added, resolutions set, and registered by Windows + Start-Sleep -Seconds 10 - name: Setup python id: setup-python diff --git a/scripts/parsec-vdd.ps1 b/scripts/parsec-vdd.ps1 index 161e7c4..5b89a50 100644 --- a/scripts/parsec-vdd.ps1 +++ b/scripts/parsec-vdd.ps1 @@ -150,6 +150,97 @@ public class ParsecVdd { public static void Keepalive(IntPtr vdd) { while (true) { Update(vdd); Thread.Sleep(100); } } + + const string VDD_DISPLAY_NAME = "ParsecVDA"; + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)] + struct DISPLAY_DEVICE { + public int cb; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] + public string DeviceName; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] + public string DeviceString; + public uint StateFlags; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] + public string DeviceID; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] + public string DeviceKey; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)] + struct DEVMODE { + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] + public string dmDeviceName; + public ushort dmSpecVersion; + public ushort dmDriverVersion; + public ushort dmSize; + public ushort dmDriverExtra; + public uint dmFields; + public int dmPositionX; + public int dmPositionY; + public uint dmDisplayOrientation; + public uint dmDisplayFixedOutput; + public short dmColor; + public short dmDuplex; + public short dmYResolution; + public short dmTTOption; + public short dmCollate; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] + public string dmFormName; + public ushort dmLogPixels; + public uint dmBitsPerPel; + public uint dmPelsWidth; + public uint dmPelsHeight; + public uint dmDisplayFlags; + public uint dmDisplayFrequency; + public uint dmICMMethod; + public uint dmICMIntent; + public uint dmMediaType; + public uint dmDitherType; + public uint dmReserved1; + public uint dmReserved2; + public uint dmPanningWidth; + public uint dmPanningHeight; + } + + [DllImport("user32.dll", CharSet = CharSet.Ansi)] + static extern bool EnumDisplayDevicesA(string lpDevice, uint iDevNum, ref DISPLAY_DEVICE lpDisplayDevice, uint dwFlags); + + [DllImport("user32.dll", CharSet = CharSet.Ansi)] + static extern int ChangeDisplaySettingsExA(string lpszDeviceName, ref DEVMODE lpDevMode, IntPtr hwnd, uint dwflags, IntPtr lParam); + + const uint CDS_UPDATEREGISTRY = 0x00000001; + const uint CDS_NORESET = 0x10000000; + const uint DM_PELSWIDTH = 0x00080000; + const uint DM_PELSHEIGHT = 0x00100000; + const uint DM_DISPLAYFREQUENCY = 0x00400000; + + public static string[] GetParsecDisplayNames() { + var names = new System.Collections.Generic.List(); + var adapter = new DISPLAY_DEVICE(); + adapter.cb = Marshal.SizeOf(adapter); + for (uint i = 0; EnumDisplayDevicesA(null, i, ref adapter, 0); i++) { + var monitor = new DISPLAY_DEVICE(); + monitor.cb = Marshal.SizeOf(monitor); + if (EnumDisplayDevicesA(adapter.DeviceName, 0, ref monitor, 0)) { + if (monitor.DeviceString.IndexOf(VDD_DISPLAY_NAME, StringComparison.OrdinalIgnoreCase) >= 0) { + names.Add(adapter.DeviceName); + } + } + } + return names.ToArray(); + } + + public static bool SetResolution(string deviceName, uint width, uint height, uint hz) { + var dm = new DEVMODE(); + dm.dmSize = (ushort)Marshal.SizeOf(dm); + dm.dmFields = DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFREQUENCY; + dm.dmPelsWidth = width; + dm.dmPelsHeight = height; + dm.dmDisplayFrequency = hz; + int result = ChangeDisplaySettingsExA(deviceName, ref dm, IntPtr.Zero, CDS_UPDATEREGISTRY | CDS_NORESET, IntPtr.Zero); + return result == 0; + } } "@ @@ -164,5 +255,23 @@ for ($i = 1; $i -le $DisplayCount; $i++) { Write-Information "Added virtual display at index $idx" } +# wait for Windows to register the new displays +Start-Sleep -Seconds 2 + +# assign distinct resolutions so Windows can distinguish the otherwise-identical +# virtual displays, which is required for topology operations to work correctly +$resolutions = @( + @(1920, 1080, 60), + @(1280, 720, 60), + @(1600, 900, 60) +) +$parsecDisplays = [ParsecVdd]::GetParsecDisplayNames() +Write-Information "Found $($parsecDisplays.Count) Parsec virtual display(s)" +for ($i = 0; $i -lt $parsecDisplays.Count; $i++) { + $res = $resolutions[$i % $resolutions.Count] + $ok = [ParsecVdd]::SetResolution($parsecDisplays[$i], $res[0], $res[1], $res[2]) + Write-Information "Set $($parsecDisplays[$i]) to $($res[0])x$($res[1])@$($res[2])Hz: $ok" +} + Write-Information "Keeping $DisplayCount virtual display(s) alive..." [ParsecVdd]::Keepalive($vdd) From 83dd56f17008cbccdf5146db28c0b603cdcddf60 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 21 Mar 2026 18:33:09 -0400 Subject: [PATCH 5/9] Use virtual-display-rs driver in CI Replace the Parsec VDD setup with virtual-display-rs in the CI workflow: download and extract the virtual-display-rs release, install the MSI via msiexec, and invoke a new PowerShell helper. Remove the old scripts/parsec-vdd.ps1 helper and add scripts/virtual-display-rs.ps1 which configures distinct virtual monitor modes and sends a DriverNotify payload over the driver's named pipe (no keepalive required). Adjusted the CI PowerShell invocation and shortened the post-install wait time. --- .github/workflows/ci.yml | 42 +++-- scripts/parsec-vdd.ps1 | 277 --------------------------------- scripts/virtual-display-rs.ps1 | 47 ++++++ 3 files changed, 73 insertions(+), 293 deletions(-) delete mode 100644 scripts/parsec-vdd.ps1 create mode 100644 scripts/virtual-display-rs.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5474fe4..8d171d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -131,23 +131,33 @@ jobs: } } - # install Parsec VDD driver silently - DownloadFile ` - -Uri "https://builds.parsec.app/vdd/parsec-vdd-0.45.0.0.exe" ` - -OutFile "parsec-vdd-0.45.0.0.exe" - Start-Process -FilePath ".\parsec-vdd-0.45.0.0.exe" -ArgumentList "/S" -Wait - - # add virtual displays and keep them alive via a background process. - # the script mirrors parsec-vdd.h via Win32 P/Invoke and runs a - # keepalive loop, which is required to prevent the driver from - # unplugging the displays after ~1 second. - $scriptPath = Join-Path $env:GITHUB_WORKSPACE "scripts\parsec-vdd.ps1" + # install virtual-display-rs driver + $vdaRepo = "https://github.com/MolotovCherry/virtual-display-rs" + $vdaZipUrl = "$vdaRepo/releases/download/v0.3.1/virtual-desktop-driver-installer-x64.zip" + DownloadFile -Uri $vdaZipUrl -OutFile "virtual-display-rs.zip" + + Write-Information "Download complete, extracting archive..." -InformationAction Continue + Expand-Archive -Path "virtual-display-rs.zip" -DestinationPath "virtual-display-rs" + $msi = Get-ChildItem -Path "virtual-display-rs" -Filter "*.msi" -Recurse | Select-Object -First 1 + Write-Information "Found installer: $($msi.FullName)" -InformationAction Continue + Write-Information "Running msiexec..." -InformationAction Continue + Start-Process -FilePath "msiexec.exe" ` + -ArgumentList "/i", $msi.FullName, "/qn", "/norestart", "REBOOT=ReallySuppress" ` + -Wait + Write-Information "Driver installed" -InformationAction Continue + + # add virtual displays with distinct resolutions so Windows can tell them apart. + # the driver is a persistent Windows service with a named pipe API - no keepalive needed. + $scriptPath = Join-Path $env:GITHUB_WORKSPACE "scripts\virtual-display-rs.ps1" $pwsh = Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0\powershell.exe" - Start-Process -FilePath $pwsh ` - -ArgumentList "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", $scriptPath, "-DisplayCount", "2" ` - -WindowStyle Hidden - # allow time for displays to be added, resolutions set, and registered by Windows - Start-Sleep -Seconds 10 + Write-Information "Configuring virtual displays..." -InformationAction Continue + & $pwsh -NonInteractive -ExecutionPolicy Bypass -File $scriptPath -DisplayCount 2 + Write-Information "Virtual displays configured" -InformationAction Continue + + # allow time for displays to be registered by Windows + Write-Information "Waiting for displays to register..." -InformationAction Continue + Start-Sleep -Seconds 5 + Write-Information "Prepare tests complete" -InformationAction Continue - name: Setup python id: setup-python diff --git a/scripts/parsec-vdd.ps1 b/scripts/parsec-vdd.ps1 deleted file mode 100644 index 5b89a50..0000000 --- a/scripts/parsec-vdd.ps1 +++ /dev/null @@ -1,277 +0,0 @@ -param ( - [Parameter(Mandatory = $true)] - [int]$DisplayCount -) - -Add-Type -TypeDefinition @" -using System; -using System.Runtime.InteropServices; -using System.Threading; - -public class ParsecVdd { - const uint GENERIC_READ = 0x80000000; - const uint GENERIC_WRITE = 0x40000000; - const uint FILE_SHARE_READ = 0x00000001; - const uint FILE_SHARE_WRITE = 0x00000002; - const uint OPEN_EXISTING = 3; - const uint FILE_FLAG_NO_BUFFERING = 0x20000000; - const uint FILE_FLAG_OVERLAPPED = 0x40000000; - const uint FILE_FLAG_WRITE_THROUGH = 0x80000000; - const uint DIGCF_PRESENT = 0x00000002; - const uint DIGCF_DEVICEINTERFACE = 0x00000010; - const uint IOCTL_ADD = 0x0022e004; - const uint IOCTL_UPDATE = 0x0022a00c; - static readonly Guid VDD_ADAPTER_GUID = new Guid("00b41627-04c4-429e-a26e-0265cf50c8fa"); - - [StructLayout(LayoutKind.Sequential)] - struct SP_DEVICE_INTERFACE_DATA { - public int cbSize; - public Guid interfaceClassGuid; - public uint flags; - public IntPtr reserved; - } - - [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)] - struct SP_DEVICE_INTERFACE_DETAIL_DATA_A { - public int cbSize; - [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] - public string DevicePath; - } - - [StructLayout(LayoutKind.Sequential)] - struct OVERLAPPED { - public IntPtr Internal; - public IntPtr InternalHigh; - public IntPtr Pointer; - public IntPtr hEvent; - } - - [DllImport("setupapi.dll", SetLastError = true)] - static extern IntPtr SetupDiGetClassDevsA( - ref Guid ClassGuid, IntPtr Enumerator, IntPtr hwndParent, uint Flags); - - [DllImport("setupapi.dll", SetLastError = true)] - static extern bool SetupDiEnumDeviceInterfaces( - IntPtr DeviceInfoSet, IntPtr DeviceInfoData, - ref Guid InterfaceClassGuid, uint MemberIndex, - ref SP_DEVICE_INTERFACE_DATA DeviceInterfaceData); - - [DllImport("setupapi.dll", SetLastError = true, CharSet = CharSet.Ansi)] - static extern bool SetupDiGetDeviceInterfaceDetailA( - IntPtr DeviceInfoSet, ref SP_DEVICE_INTERFACE_DATA DeviceInterfaceData, - ref SP_DEVICE_INTERFACE_DETAIL_DATA_A DeviceInterfaceDetailData, - uint DeviceInterfaceDetailDataSize, out uint RequiredSize, IntPtr DeviceInfoData); - - [DllImport("setupapi.dll")] - static extern bool SetupDiDestroyDeviceInfoList(IntPtr DeviceInfoSet); - - [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Ansi)] - static extern IntPtr CreateFileA( - string lpFileName, uint dwDesiredAccess, uint dwShareMode, - IntPtr lpSecurityAttributes, uint dwCreationDisposition, - uint dwFlagsAndAttributes, IntPtr hTemplateFile); - - [DllImport("kernel32.dll", SetLastError = true)] - static extern IntPtr CreateEvent( - IntPtr lpEventAttributes, bool bManualReset, - bool bInitialState, string lpName); - - [DllImport("kernel32.dll", SetLastError = true)] - static extern bool DeviceIoControl( - IntPtr hDevice, uint dwIoControlCode, - byte[] lpInBuffer, int nInBufferSize, - out int lpOutBuffer, int nOutBufferSize, - IntPtr lpBytesReturned, ref OVERLAPPED lpOverlapped); - - [DllImport("kernel32.dll", SetLastError = true)] - static extern bool GetOverlappedResultEx( - IntPtr hFile, ref OVERLAPPED lpOverlapped, - out uint lpNumberOfBytesTransferred, - int dwMilliseconds, bool bAlertable); - - [DllImport("kernel32.dll")] - static extern bool CloseHandle(IntPtr hObject); - - public static IntPtr OpenHandle() { - var guid = VDD_ADAPTER_GUID; - var devInfo = SetupDiGetClassDevsA( - ref guid, IntPtr.Zero, IntPtr.Zero, DIGCF_PRESENT | DIGCF_DEVICEINTERFACE); - if (devInfo == new IntPtr(-1)) return new IntPtr(-1); - - var ifaceData = new SP_DEVICE_INTERFACE_DATA(); - ifaceData.cbSize = Marshal.SizeOf(ifaceData); - - for (uint i = 0; SetupDiEnumDeviceInterfaces( - devInfo, IntPtr.Zero, ref guid, i, ref ifaceData); i++) { - var detail = new SP_DEVICE_INTERFACE_DETAIL_DATA_A(); - detail.cbSize = IntPtr.Size == 8 ? 8 : 6; - uint needed; - SetupDiGetDeviceInterfaceDetailA( - devInfo, ref ifaceData, ref detail, - (uint)Marshal.SizeOf(detail), out needed, IntPtr.Zero); - var handle = CreateFileA( - detail.DevicePath, - GENERIC_READ | GENERIC_WRITE, - FILE_SHARE_READ | FILE_SHARE_WRITE, - IntPtr.Zero, OPEN_EXISTING, - FILE_FLAG_NO_BUFFERING | FILE_FLAG_OVERLAPPED | FILE_FLAG_WRITE_THROUGH, - IntPtr.Zero); - if (handle != IntPtr.Zero && handle != new IntPtr(-1)) { - SetupDiDestroyDeviceInfoList(devInfo); - return handle; - } - } - - SetupDiDestroyDeviceInfoList(devInfo); - return new IntPtr(-1); - } - - static int IoControl(IntPtr vdd, uint code, byte[] input) { - var inBuf = new byte[32]; - if (input != null) Array.Copy(input, inBuf, Math.Min(input.Length, inBuf.Length)); - var ov = new OVERLAPPED(); - ov.hEvent = CreateEvent(IntPtr.Zero, true, false, null); - int outBuf = 0; - DeviceIoControl(vdd, code, inBuf, inBuf.Length, out outBuf, 4, IntPtr.Zero, ref ov); - uint transferred; - GetOverlappedResultEx(vdd, ref ov, out transferred, 5000, false); - if (ov.hEvent != IntPtr.Zero) CloseHandle(ov.hEvent); - return outBuf; - } - - public static void Update(IntPtr vdd) { IoControl(vdd, IOCTL_UPDATE, null); } - - public static int AddDisplay(IntPtr vdd) { - int idx = IoControl(vdd, IOCTL_ADD, null); - Update(vdd); - return idx; - } - - public static void Keepalive(IntPtr vdd) { - while (true) { Update(vdd); Thread.Sleep(100); } - } - - const string VDD_DISPLAY_NAME = "ParsecVDA"; - - [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)] - struct DISPLAY_DEVICE { - public int cb; - [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] - public string DeviceName; - [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] - public string DeviceString; - public uint StateFlags; - [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] - public string DeviceID; - [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] - public string DeviceKey; - } - - [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)] - struct DEVMODE { - [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] - public string dmDeviceName; - public ushort dmSpecVersion; - public ushort dmDriverVersion; - public ushort dmSize; - public ushort dmDriverExtra; - public uint dmFields; - public int dmPositionX; - public int dmPositionY; - public uint dmDisplayOrientation; - public uint dmDisplayFixedOutput; - public short dmColor; - public short dmDuplex; - public short dmYResolution; - public short dmTTOption; - public short dmCollate; - [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] - public string dmFormName; - public ushort dmLogPixels; - public uint dmBitsPerPel; - public uint dmPelsWidth; - public uint dmPelsHeight; - public uint dmDisplayFlags; - public uint dmDisplayFrequency; - public uint dmICMMethod; - public uint dmICMIntent; - public uint dmMediaType; - public uint dmDitherType; - public uint dmReserved1; - public uint dmReserved2; - public uint dmPanningWidth; - public uint dmPanningHeight; - } - - [DllImport("user32.dll", CharSet = CharSet.Ansi)] - static extern bool EnumDisplayDevicesA(string lpDevice, uint iDevNum, ref DISPLAY_DEVICE lpDisplayDevice, uint dwFlags); - - [DllImport("user32.dll", CharSet = CharSet.Ansi)] - static extern int ChangeDisplaySettingsExA(string lpszDeviceName, ref DEVMODE lpDevMode, IntPtr hwnd, uint dwflags, IntPtr lParam); - - const uint CDS_UPDATEREGISTRY = 0x00000001; - const uint CDS_NORESET = 0x10000000; - const uint DM_PELSWIDTH = 0x00080000; - const uint DM_PELSHEIGHT = 0x00100000; - const uint DM_DISPLAYFREQUENCY = 0x00400000; - - public static string[] GetParsecDisplayNames() { - var names = new System.Collections.Generic.List(); - var adapter = new DISPLAY_DEVICE(); - adapter.cb = Marshal.SizeOf(adapter); - for (uint i = 0; EnumDisplayDevicesA(null, i, ref adapter, 0); i++) { - var monitor = new DISPLAY_DEVICE(); - monitor.cb = Marshal.SizeOf(monitor); - if (EnumDisplayDevicesA(adapter.DeviceName, 0, ref monitor, 0)) { - if (monitor.DeviceString.IndexOf(VDD_DISPLAY_NAME, StringComparison.OrdinalIgnoreCase) >= 0) { - names.Add(adapter.DeviceName); - } - } - } - return names.ToArray(); - } - - public static bool SetResolution(string deviceName, uint width, uint height, uint hz) { - var dm = new DEVMODE(); - dm.dmSize = (ushort)Marshal.SizeOf(dm); - dm.dmFields = DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFREQUENCY; - dm.dmPelsWidth = width; - dm.dmPelsHeight = height; - dm.dmDisplayFrequency = hz; - int result = ChangeDisplaySettingsExA(deviceName, ref dm, IntPtr.Zero, CDS_UPDATEREGISTRY | CDS_NORESET, IntPtr.Zero); - return result == 0; - } -} -"@ - -$vdd = [ParsecVdd]::OpenHandle() -if ($vdd -eq [IntPtr]::new(-1)) { - Write-Error "Failed to open the Parsec VDD device handle." - exit 1 -} - -for ($i = 1; $i -le $DisplayCount; $i++) { - $idx = [ParsecVdd]::AddDisplay($vdd) - Write-Information "Added virtual display at index $idx" -} - -# wait for Windows to register the new displays -Start-Sleep -Seconds 2 - -# assign distinct resolutions so Windows can distinguish the otherwise-identical -# virtual displays, which is required for topology operations to work correctly -$resolutions = @( - @(1920, 1080, 60), - @(1280, 720, 60), - @(1600, 900, 60) -) -$parsecDisplays = [ParsecVdd]::GetParsecDisplayNames() -Write-Information "Found $($parsecDisplays.Count) Parsec virtual display(s)" -for ($i = 0; $i -lt $parsecDisplays.Count; $i++) { - $res = $resolutions[$i % $resolutions.Count] - $ok = [ParsecVdd]::SetResolution($parsecDisplays[$i], $res[0], $res[1], $res[2]) - Write-Information "Set $($parsecDisplays[$i]) to $($res[0])x$($res[1])@$($res[2])Hz: $ok" -} - -Write-Information "Keeping $DisplayCount virtual display(s) alive..." -[ParsecVdd]::Keepalive($vdd) diff --git a/scripts/virtual-display-rs.ps1 b/scripts/virtual-display-rs.ps1 new file mode 100644 index 0000000..365679e --- /dev/null +++ b/scripts/virtual-display-rs.ps1 @@ -0,0 +1,47 @@ +param ( + [Parameter(Mandatory = $true)] + [int]$DisplayCount +) + +# each display gets a distinct resolution so Windows can distinguish them +$resolutions = @( + @{ width = 1920; height = 1080; refresh_rates = @(60) }, + @{ width = 1280; height = 720; refresh_rates = @(60) }, + @{ width = 1600; height = 900; refresh_rates = @(60) } +) + +$monitors = @() +for ($i = 0; $i -lt $DisplayCount; $i++) { + $res = $resolutions[$i % $resolutions.Count] + $monitors += @{ + id = [uint32]$i + name = "VDD$i" + enabled = $true + modes = @(@{ + width = [uint32]$res.width + height = [uint32]$res.height + refresh_rates = @([uint32]$res.refresh_rates[0]) + }) + } +} + +# send {"DriverNotify": [...monitors...]} over the named pipe +$json = [System.Text.Json.JsonSerializer]::Serialize( + @{ DriverNotify = $monitors }, + [System.Text.Json.JsonSerializerOptions]@{ WriteIndented = $false } +) + +Write-Information "Payload: $json" -InformationAction Continue + +Write-Information "Connecting to named pipe..." -InformationAction Continue +$pipe = New-Object System.IO.Pipes.NamedPipeClientStream(".", "virtualdisplaydriver", [System.IO.Pipes.PipeDirection]::InOut) +$pipe.Connect(5000) +$pipe.ReadMode = [System.IO.Pipes.PipeTransmissionMode]::Message +Write-Information "Connected" -InformationAction Continue + +$bytes = [System.Text.Encoding]::UTF8.GetBytes($json) +$pipe.Write($bytes, 0, $bytes.Length) +$pipe.Flush() +$pipe.Dispose() + +Write-Information "Sent monitor config for $DisplayCount display(s)" -InformationAction Continue From e6da7bb9d6d054f2649168bd272519eaada50690 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 21 Mar 2026 19:07:20 -0400 Subject: [PATCH 6/9] Install virtual-display-rs via portable bundle Replace MSI-based installation with the portable virtual-display-rs package in CI. The workflow now downloads the portable zip and nefconw.exe, extracts the driver, trusts its certificate, copies the VirtualDisplayDriver.dll into System32\drivers\UMDF, imports the provided registry entries, and creates the device node using nefconw. Removes the previous msiexec installation steps to support a portable CI-friendly installation method. --- .github/workflows/ci.yml | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d171d4..b3f2e0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -131,19 +131,35 @@ jobs: } } - # install virtual-display-rs driver + # install virtual-display-rs driver using the portable zip + nefconw $vdaRepo = "https://github.com/MolotovCherry/virtual-display-rs" - $vdaZipUrl = "$vdaRepo/releases/download/v0.3.1/virtual-desktop-driver-installer-x64.zip" - DownloadFile -Uri $vdaZipUrl -OutFile "virtual-display-rs.zip" + $vdaPortableUrl = "$vdaRepo/releases/download/v0.3.1/virtual-desktop-driver-portable-x64.zip" + $nefconUrl = "$vdaRepo/raw/master/installer/files/nefconw.exe" + DownloadFile -Uri $vdaPortableUrl -OutFile "virtual-display-rs.zip" + DownloadFile -Uri $nefconUrl -OutFile "nefconw.exe" Write-Information "Download complete, extracting archive..." -InformationAction Continue Expand-Archive -Path "virtual-display-rs.zip" -DestinationPath "virtual-display-rs" - $msi = Get-ChildItem -Path "virtual-display-rs" -Filter "*.msi" -Recurse | Select-Object -First 1 - Write-Information "Found installer: $($msi.FullName)" -InformationAction Continue - Write-Information "Running msiexec..." -InformationAction Continue - Start-Process -FilePath "msiexec.exe" ` - -ArgumentList "/i", $msi.FullName, "/qn", "/norestart", "REBOOT=ReallySuppress" ` - -Wait + + Write-Information "Trusting driver certificate..." -InformationAction Continue + $cat = (Resolve-Path "virtual-display-rs\virtualdisplaydriver.cat").Path + certutil -addstore -f "TrustedPublisher" $cat + certutil -addstore -f "Root" $cat + + Write-Information "Copying driver to System32..." -InformationAction Continue + $umdfDir = "$env:SystemRoot\System32\drivers\UMDF" + New-Item -ItemType Directory -Force -Path $umdfDir | Out-Null + Copy-Item "virtual-display-rs\VirtualDisplayDriver.dll" -Destination $umdfDir -Force + + Write-Information "Importing registry entries..." -InformationAction Continue + reg import "virtual-display-rs\install.reg" + + Write-Information "Creating device node..." -InformationAction Continue + $nefcon = (Resolve-Path "nefconw.exe").Path + & $nefcon --create-device-node ` + --hardware-id "Root\VirtualDisplayDriver" ` + --class-name "Display" ` + --class-guid "4d36e968-e325-11ce-bfc1-08002be10318" Write-Information "Driver installed" -InformationAction Continue # add virtual displays with distinct resolutions so Windows can tell them apart. From 9cef44b331a59718a7829fee7e343c87d17b56bc Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 21 Mar 2026 19:15:12 -0400 Subject: [PATCH 7/9] Use ConvertTo-Json and retry named pipe connect Switches JSON serialization to PowerShell's ConvertTo-Json for Windows PowerShell 5.1 compatibility and makes monitor objects ordered for consistent output. Normalizes the modes array to use an ordered hashtable for each mode. Replaces a single blocking pipe connect with a retry loop (30s deadline, 2s backoff, 2s per-attempt connect timeout), adds informative logging, and returns a non-zero exit on timeout. --- scripts/virtual-display-rs.ps1 | 48 ++++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/scripts/virtual-display-rs.ps1 b/scripts/virtual-display-rs.ps1 index 365679e..0fc5b3e 100644 --- a/scripts/virtual-display-rs.ps1 +++ b/scripts/virtual-display-rs.ps1 @@ -13,31 +13,47 @@ $resolutions = @( $monitors = @() for ($i = 0; $i -lt $DisplayCount; $i++) { $res = $resolutions[$i % $resolutions.Count] - $monitors += @{ + $monitors += [ordered]@{ id = [uint32]$i name = "VDD$i" enabled = $true - modes = @(@{ - width = [uint32]$res.width - height = [uint32]$res.height - refresh_rates = @([uint32]$res.refresh_rates[0]) - }) + modes = @( + [ordered]@{ + width = [uint32]$res.width + height = [uint32]$res.height + refresh_rates = @([uint32]$res.refresh_rates[0]) + } + ) } } -# send {"DriverNotify": [...monitors...]} over the named pipe -$json = [System.Text.Json.JsonSerializer]::Serialize( - @{ DriverNotify = $monitors }, - [System.Text.Json.JsonSerializerOptions]@{ WriteIndented = $false } -) +# ConvertTo-Json is available in Windows PowerShell 5.1 unlike System.Text.Json +$json = @{ DriverNotify = $monitors } | ConvertTo-Json -Depth 10 -Compress Write-Information "Payload: $json" -InformationAction Continue -Write-Information "Connecting to named pipe..." -InformationAction Continue -$pipe = New-Object System.IO.Pipes.NamedPipeClientStream(".", "virtualdisplaydriver", [System.IO.Pipes.PipeDirection]::InOut) -$pipe.Connect(5000) -$pipe.ReadMode = [System.IO.Pipes.PipeTransmissionMode]::Message -Write-Information "Connected" -InformationAction Continue +# retry connecting to the named pipe until the UMDF driver is fully loaded +$connected = $false +$deadline = (Get-Date).AddSeconds(30) +while (-not $connected -and (Get-Date) -lt $deadline) { + try { + Write-Information "Connecting to named pipe..." -InformationAction Continue + $pipe = New-Object System.IO.Pipes.NamedPipeClientStream( + ".", "virtualdisplaydriver", [System.IO.Pipes.PipeDirection]::InOut) + $pipe.Connect(2000) + $pipe.ReadMode = [System.IO.Pipes.PipeTransmissionMode]::Message + $connected = $true + Write-Information "Connected" -InformationAction Continue + } catch { + Write-Information "Pipe not ready, retrying... ($($_.Exception.Message))" -InformationAction Continue + Start-Sleep -Seconds 2 + } +} + +if (-not $connected) { + Write-Error "Timed out waiting for the virtual display driver named pipe." + exit 1 +} $bytes = [System.Text.Encoding]::UTF8.GetBytes($json) $pipe.Write($bytes, 0, $bytes.Length) From 2103d8f959428827e3400a4bb5c39074a01c3b41 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 21 Mar 2026 19:29:57 -0400 Subject: [PATCH 8/9] Use installer package for virtual-display driver CI: download both the portable and installer zips for virtual-display-rs, extract them to distinct paths, and install the driver via the installer package. The workflow now uses the installer DriverCertificate.cer (added to Root and TrustedPublisher) and pnputil to add/install the .inf instead of manually copying the DLL and importing registry entries. Also adjusted downloaded filenames and log messages for clarity. --- .github/workflows/ci.yml | 33 ++++++++++++++++++--------------- scripts/virtual-display-rs.ps1 | 3 --- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3f2e0b..8714608 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,25 +134,24 @@ jobs: # install virtual-display-rs driver using the portable zip + nefconw $vdaRepo = "https://github.com/MolotovCherry/virtual-display-rs" $vdaPortableUrl = "$vdaRepo/releases/download/v0.3.1/virtual-desktop-driver-portable-x64.zip" + $vdaInstallerUrl = "$vdaRepo/releases/download/v0.3.1/virtual-desktop-driver-installer-x64.zip" $nefconUrl = "$vdaRepo/raw/master/installer/files/nefconw.exe" - DownloadFile -Uri $vdaPortableUrl -OutFile "virtual-display-rs.zip" + DownloadFile -Uri $vdaPortableUrl -OutFile "virtual-display-rs-portable.zip" + DownloadFile -Uri $vdaInstallerUrl -OutFile "virtual-display-rs-installer.zip" DownloadFile -Uri $nefconUrl -OutFile "nefconw.exe" - Write-Information "Download complete, extracting archive..." -InformationAction Continue - Expand-Archive -Path "virtual-display-rs.zip" -DestinationPath "virtual-display-rs" + Write-Information "Extracting archives..." -InformationAction Continue + Expand-Archive -Path "virtual-display-rs-portable.zip" -DestinationPath "virtual-display-rs" + Expand-Archive -Path "virtual-display-rs-installer.zip" -DestinationPath "virtual-display-rs-installer" Write-Information "Trusting driver certificate..." -InformationAction Continue - $cat = (Resolve-Path "virtual-display-rs\virtualdisplaydriver.cat").Path - certutil -addstore -f "TrustedPublisher" $cat - certutil -addstore -f "Root" $cat + $cer = (Resolve-Path "virtual-display-rs-installer\DriverCertificate.cer").Path + certutil -addstore -f "Root" $cer + certutil -addstore -f "TrustedPublisher" $cer - Write-Information "Copying driver to System32..." -InformationAction Continue - $umdfDir = "$env:SystemRoot\System32\drivers\UMDF" - New-Item -ItemType Directory -Force -Path $umdfDir | Out-Null - Copy-Item "virtual-display-rs\VirtualDisplayDriver.dll" -Destination $umdfDir -Force - - Write-Information "Importing registry entries..." -InformationAction Continue - reg import "virtual-display-rs\install.reg" + Write-Information "Installing driver via pnputil..." -InformationAction Continue + $inf = (Resolve-Path "virtual-display-rs\VirtualDisplayDriver.inf").Path + pnputil /add-driver $inf /install Write-Information "Creating device node..." -InformationAction Continue $nefcon = (Resolve-Path "nefconw.exe").Path @@ -162,8 +161,12 @@ jobs: --class-guid "4d36e968-e325-11ce-bfc1-08002be10318" Write-Information "Driver installed" -InformationAction Continue - # add virtual displays with distinct resolutions so Windows can tell them apart. - # the driver is a persistent Windows service with a named pipe API - no keepalive needed. + # the named pipe server is hosted by virtual-display-driver-control.exe, not the driver itself + $controlExe = (Resolve-Path "virtual-display-rs\virtual-display-driver-control.exe").Path + Write-Information "Starting virtual display control app..." -InformationAction Continue + Start-Process -FilePath $controlExe -WindowStyle Hidden + + # add virtual displays with distinct resolutions so Windows can tell them apart $scriptPath = Join-Path $env:GITHUB_WORKSPACE "scripts\virtual-display-rs.ps1" $pwsh = Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0\powershell.exe" Write-Information "Configuring virtual displays..." -InformationAction Continue diff --git a/scripts/virtual-display-rs.ps1 b/scripts/virtual-display-rs.ps1 index 0fc5b3e..8ac19c9 100644 --- a/scripts/virtual-display-rs.ps1 +++ b/scripts/virtual-display-rs.ps1 @@ -27,9 +27,6 @@ for ($i = 0; $i -lt $DisplayCount; $i++) { } } -# ConvertTo-Json is available in Windows PowerShell 5.1 unlike System.Text.Json -$json = @{ DriverNotify = $monitors } | ConvertTo-Json -Depth 10 -Compress - Write-Information "Payload: $json" -InformationAction Continue # retry connecting to the named pipe until the UMDF driver is fully loaded From 8c3a72e13c1493268e3195acd383e3b2d5fabba3 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 21 Mar 2026 19:42:18 -0400 Subject: [PATCH 9/9] Remove virtual display test setup from CI Remove the Windows-specific "Prepare tests" step from the CI workflow and delete scripts/virtual-display-rs.ps1. This removes the logic that downloaded and installed the virtual-display-rs driver (portable and installer zips), trusted the driver certificate, installed the driver and device node, started the control app, and configured virtual displays via the PowerShell script. Simplifies CI by no longer setting up virtual displays on Windows runners. --- .github/workflows/ci.yml | 81 ---------------------------------- scripts/virtual-display-rs.ps1 | 60 ------------------------- 2 files changed, 141 deletions(-) delete mode 100644 scripts/virtual-display-rs.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8714608..c53c336 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,87 +97,6 @@ jobs: mingw-w64-ucrt-x86_64-boost mingw-w64-ucrt-x86_64-nlohmann-json - - name: Prepare tests - id: prepare-tests - if: runner.os == 'Windows' - shell: pwsh - run: | - # function to download a file with retries - function DownloadFile { - param ( - [string]$Uri, - [string]$OutFile - ) - - $maxRetries = 5 - $retryCount = 0 - $success = $false - - while (-not $success -and $retryCount -lt $maxRetries) { - $retryCount++ - Write-Host "Downloading $Uri to $OutFile, attempt $retryCount of $maxRetries" - try { - Invoke-WebRequest -Uri $Uri -OutFile $OutFile - $success = $true - } catch { - Write-Host "Attempt $retryCount of $maxRetries failed with error: $($_.Exception.Message). Retrying..." - Start-Sleep -Seconds 5 - } - } - - if (-not $success) { - Write-Host "Failed to download the file after $maxRetries attempts." - exit 1 - } - } - - # install virtual-display-rs driver using the portable zip + nefconw - $vdaRepo = "https://github.com/MolotovCherry/virtual-display-rs" - $vdaPortableUrl = "$vdaRepo/releases/download/v0.3.1/virtual-desktop-driver-portable-x64.zip" - $vdaInstallerUrl = "$vdaRepo/releases/download/v0.3.1/virtual-desktop-driver-installer-x64.zip" - $nefconUrl = "$vdaRepo/raw/master/installer/files/nefconw.exe" - DownloadFile -Uri $vdaPortableUrl -OutFile "virtual-display-rs-portable.zip" - DownloadFile -Uri $vdaInstallerUrl -OutFile "virtual-display-rs-installer.zip" - DownloadFile -Uri $nefconUrl -OutFile "nefconw.exe" - - Write-Information "Extracting archives..." -InformationAction Continue - Expand-Archive -Path "virtual-display-rs-portable.zip" -DestinationPath "virtual-display-rs" - Expand-Archive -Path "virtual-display-rs-installer.zip" -DestinationPath "virtual-display-rs-installer" - - Write-Information "Trusting driver certificate..." -InformationAction Continue - $cer = (Resolve-Path "virtual-display-rs-installer\DriverCertificate.cer").Path - certutil -addstore -f "Root" $cer - certutil -addstore -f "TrustedPublisher" $cer - - Write-Information "Installing driver via pnputil..." -InformationAction Continue - $inf = (Resolve-Path "virtual-display-rs\VirtualDisplayDriver.inf").Path - pnputil /add-driver $inf /install - - Write-Information "Creating device node..." -InformationAction Continue - $nefcon = (Resolve-Path "nefconw.exe").Path - & $nefcon --create-device-node ` - --hardware-id "Root\VirtualDisplayDriver" ` - --class-name "Display" ` - --class-guid "4d36e968-e325-11ce-bfc1-08002be10318" - Write-Information "Driver installed" -InformationAction Continue - - # the named pipe server is hosted by virtual-display-driver-control.exe, not the driver itself - $controlExe = (Resolve-Path "virtual-display-rs\virtual-display-driver-control.exe").Path - Write-Information "Starting virtual display control app..." -InformationAction Continue - Start-Process -FilePath $controlExe -WindowStyle Hidden - - # add virtual displays with distinct resolutions so Windows can tell them apart - $scriptPath = Join-Path $env:GITHUB_WORKSPACE "scripts\virtual-display-rs.ps1" - $pwsh = Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0\powershell.exe" - Write-Information "Configuring virtual displays..." -InformationAction Continue - & $pwsh -NonInteractive -ExecutionPolicy Bypass -File $scriptPath -DisplayCount 2 - Write-Information "Virtual displays configured" -InformationAction Continue - - # allow time for displays to be registered by Windows - Write-Information "Waiting for displays to register..." -InformationAction Continue - Start-Sleep -Seconds 5 - Write-Information "Prepare tests complete" -InformationAction Continue - - name: Setup python id: setup-python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 diff --git a/scripts/virtual-display-rs.ps1 b/scripts/virtual-display-rs.ps1 deleted file mode 100644 index 8ac19c9..0000000 --- a/scripts/virtual-display-rs.ps1 +++ /dev/null @@ -1,60 +0,0 @@ -param ( - [Parameter(Mandatory = $true)] - [int]$DisplayCount -) - -# each display gets a distinct resolution so Windows can distinguish them -$resolutions = @( - @{ width = 1920; height = 1080; refresh_rates = @(60) }, - @{ width = 1280; height = 720; refresh_rates = @(60) }, - @{ width = 1600; height = 900; refresh_rates = @(60) } -) - -$monitors = @() -for ($i = 0; $i -lt $DisplayCount; $i++) { - $res = $resolutions[$i % $resolutions.Count] - $monitors += [ordered]@{ - id = [uint32]$i - name = "VDD$i" - enabled = $true - modes = @( - [ordered]@{ - width = [uint32]$res.width - height = [uint32]$res.height - refresh_rates = @([uint32]$res.refresh_rates[0]) - } - ) - } -} - -Write-Information "Payload: $json" -InformationAction Continue - -# retry connecting to the named pipe until the UMDF driver is fully loaded -$connected = $false -$deadline = (Get-Date).AddSeconds(30) -while (-not $connected -and (Get-Date) -lt $deadline) { - try { - Write-Information "Connecting to named pipe..." -InformationAction Continue - $pipe = New-Object System.IO.Pipes.NamedPipeClientStream( - ".", "virtualdisplaydriver", [System.IO.Pipes.PipeDirection]::InOut) - $pipe.Connect(2000) - $pipe.ReadMode = [System.IO.Pipes.PipeTransmissionMode]::Message - $connected = $true - Write-Information "Connected" -InformationAction Continue - } catch { - Write-Information "Pipe not ready, retrying... ($($_.Exception.Message))" -InformationAction Continue - Start-Sleep -Seconds 2 - } -} - -if (-not $connected) { - Write-Error "Timed out waiting for the virtual display driver named pipe." - exit 1 -} - -$bytes = [System.Text.Encoding]::UTF8.GetBytes($json) -$pipe.Write($bytes, 0, $bytes.Length) -$pipe.Flush() -$pipe.Dispose() - -Write-Information "Sent monitor config for $DisplayCount display(s)" -InformationAction Continue