feat: add device enumeration for PnP displays (#82)

- Legacy EnumDisplayDevices is bound to the calling thread, it's not working on RDP session.
This commit is contained in:
Nguyen Duy
2026-05-14 15:58:13 +07:00
parent b5e84749a7
commit 61e129cf6d
3 changed files with 103 additions and 2 deletions
+62
View File
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;
@@ -202,6 +203,58 @@ namespace ParsecVDisplay
return Native.CM_Locate_DevNodeA(out devInst, deviceId, 0) == 0;
}
public struct ClassDevice
{
public string InstanceId;
public uint DevInst;
}
/// <summary>
/// Enumerate present devices of a given setup class. Returns the device
/// instance ID (e.g. "DISPLAY\PSCCDD0\5&amp;abc&amp;UID256") and the
/// CM devnode handle for each. This goes through the PnP device tree
/// rather than GDI, so it is session-independent — useful when the app
/// runs inside an RDP session and EnumDisplayDevices is blind to the
/// console session's displays.
/// </summary>
public static List<ClassDevice> EnumerateClass(string classGuid)
{
var list = new List<ClassDevice>();
var guid = Guid.Parse(classGuid);
var devInfo = Native.SetupDiGetClassDevsA(ref guid, null, null, Native.DIGCF_PRESENT);
if (!devInfo.IsValidHandle())
return list;
try
{
var devInfoData = new Native.SP_DEVINFO_DATA();
devInfoData.cbSize = sizeof(Native.SP_DEVINFO_DATA);
const int bufSize = Native.MAX_DEVICE_ID_LEN;
byte* buf = stackalloc byte[bufSize];
for (uint i = 0; Native.SetupDiEnumDeviceInfo(devInfo, i, &devInfoData); i++)
{
int required = 0;
if (Native.SetupDiGetDeviceInstanceIdA(devInfo, &devInfoData, buf, bufSize, &required))
{
list.Add(new ClassDevice
{
InstanceId = Marshal.PtrToStringAnsi((IntPtr)buf),
DevInst = devInfoData.DevInst,
});
}
}
}
finally
{
Native.SetupDiDestroyDeviceInfoList(devInfo);
}
return list;
}
public static bool IsValidHandle(this IntPtr handle)
{
return handle != IntPtr.Zero
@@ -346,6 +399,15 @@ namespace ParsecVDisplay
int PropertyBufferSize,
int* RequiredSize);
[DllImport("setupapi.dll", SetLastError = true, EntryPoint = "SetupDiGetDeviceInstanceIdA", CharSet = CharSet.Ansi)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool SetupDiGetDeviceInstanceIdA(
IntPtr DeviceInfoSet,
SP_DEVINFO_DATA* DeviceInfoData,
byte* DeviceInstanceId,
int DeviceInstanceIdSize,
int* RequiredSize);
[DllImport("setupapi.dll")]
public static extern uint CM_Get_DevNode_Status(
uint* pulStatus,
+37 -2
View File
@@ -291,14 +291,20 @@ namespace ParsecVDisplay
public void TakeScreenshot(string saveFile)
{
if (string.IsNullOrEmpty(DeviceName))
return;
var devMode = new Native.DEVMODE();
devMode.dmSize = (short)Marshal.SizeOf(typeof(Native.DEVMODE));
Native.EnumDisplaySettings(DeviceName, -1, ref devMode);
if (!Native.EnumDisplaySettings(DeviceName, -1, ref devMode))
return;
int x = devMode.dmPositionX;
int y = devMode.dmPositionY;
int width = devMode.dmPelsWidth;
int height = devMode.dmPelsHeight;
if (width <= 0 || height <= 0)
return;
using (var bmp = new Bitmap(width, height))
using (var gfx = Graphics.FromImage(bmp))
@@ -386,6 +392,35 @@ namespace ParsecVDisplay
}
}
// Supplement with PnP-tree enumeration for Parsec monitors the GDI
// pass didn't see. Under RDP, EnumDisplayDevices is bound to the
// RDP session's window station and is blind to the console session's
// virtual displays — but SetupDi walks the kernel device tree and
// works regardless. These are added with Active=false (no GDI
// handle → can't ChangeDisplaySettings), so the UI shows them as
// [offline] but the count is correct.
const string CLASS_MONITOR_GUID = "{4d36e96e-e325-11ce-bfc1-08002be10318}";
foreach (var dev in Device.EnumerateClass(CLASS_MONITOR_GUID))
{
if (dev.InstanceId.IndexOf("PSCCDD0", StringComparison.OrdinalIgnoreCase) < 0)
continue;
if (displayMap.ContainsKey(dev.InstanceId))
continue;
var phantom = new Display
{
Active = false,
Address = ParseDisplayAddress(dev.InstanceId),
DeviceName = string.Empty,
DisplayName = "PSCCDD0",
};
if (Device.GetParentDeviceInstance(dev.DevInst, out uint parentInst, out phantom.AdapterInstance))
phantom.AdapterArrival = Device.GetDeviceLastArrival(parentInst);
displayMap.Add(dev.InstanceId, phantom);
}
var displays = displayMap.Values.ToList();
// Sort displays by adapter arrival (older adapter → lower number),
@@ -395,7 +430,7 @@ namespace ParsecVDisplay
displays.Sort((a, b) =>
{
if (a.AdapterInstance == b.AdapterInstance)
return a.DeviceName.CompareTo(b.DeviceName);
return a.Address.CompareTo(b.Address);
return a.AdapterArrival.CompareTo(b.AdapterArrival);
});
+4
View File
@@ -48,6 +48,10 @@ namespace ParsecVDisplay
public void MirrorScreen(string displayDevice)
{
// Phantom (PnP-only) displays have no GDI handle; nothing to mirror.
if (string.IsNullOrEmpty(displayDevice))
return;
if (!IsMirroring)
{
IsMirroring = true;