Files

653 lines
24 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Globalization;
using System.Linq;
using System.Runtime.InteropServices;
namespace ParsecDisplay
{
internal class Display
{
public enum Orientation
{
Landscape = 0, // Angle0
Portrait, // Angle90
Landscape_Flipped, // Angle180
Portrait_Flipped // Angle270
}
public class Mode
{
public int Width;
public int Height;
public int Hz;
public Mode()
{
}
public Mode(int width, int height, int hz)
{
Width = width;
Height = height;
Hz = hz;
}
public Mode(ulong bits)
{
Width = unchecked((ushort)bits);
Height = unchecked((ushort)(bits >> 16));
Hz = unchecked((ushort)(bits >> 32));
}
public ulong Bits => (uint)(Width & 0xFFFF)
| ((ulong)(Height & 0xFFFF) << 16)
| ((ulong)(Hz & 0xFFFF) << 32);
public string Resolution => $"{Width} × {Height}";
public string RefreshRate => $"{Hz} Hz";
public override string ToString() => $"{Resolution} @ {RefreshRate}";
}
/// <summary>
/// Serializable snapshot of a display's mode + orientation. Used for
/// restoring displays across suspend/resume and across app sessions.
/// </summary>
public class State
{
public int Width;
public int Height;
public int Hz;
public Orientation Orientation;
public string Pack() => $"{Width}x{Height}@{Hz}/{(int)Orientation}";
public static bool TryUnpack(string s, out State state)
{
state = null;
if (string.IsNullOrWhiteSpace(s))
return false;
var m = System.Text.RegularExpressions.Regex.Match(s.Trim(),
@"^(\d+)x(\d+)@(\d+)/([0-3])$");
if (!m.Success)
return false;
state = new State
{
Width = int.Parse(m.Groups[1].Value),
Height = int.Parse(m.Groups[2].Value),
Hz = int.Parse(m.Groups[3].Value),
Orientation = (Orientation)int.Parse(m.Groups[4].Value),
};
return true;
}
}
public State Snapshot()
{
return new State
{
Width = CurrentMode?.Width ?? 0,
Height = CurrentMode?.Height ?? 0,
Hz = CurrentMode?.Hz ?? 0,
Orientation = CurrentOrientation,
};
}
public static string PackStates(List<State> states)
{
return string.Join(",", states.ConvertAll(s => s.Pack()));
}
public static List<State> UnpackStates(string packed)
{
var list = new List<State>();
if (string.IsNullOrWhiteSpace(packed))
return list;
foreach (var tok in packed.Split(','))
{
if (State.TryUnpack(tok, out var s))
list.Add(s);
}
return list;
}
public class ModeSet
{
public int Width;
public int Height;
public List<int> RefreshRates;
}
public bool Active;
public bool Primary;
public int Identifier;
public int CloneOf;
public int Address;
public string AdapterInstance;
public DateTime AdapterArrival;
public string DeviceName;
public string DisplayName;
public Mode CurrentMode;
public Orientation CurrentOrientation;
public List<Mode> ModeList;
public List<ModeSet> SupportedResolutions;
public int DisplayIndex => Address - 0x100;
Display()
{
ModeList = new List<Mode>();
CurrentOrientation = Orientation.Landscape;
SupportedResolutions = new List<ModeSet>();
}
public override string ToString()
{
var str = $"[{Identifier}] {DeviceName} ({DisplayName}#{Address})";
if (CloneOf > 0 && CloneOf < Identifier) str += $" (clone of [{CloneOf}])";
return str;
}
void FetchAllModes()
{
var devMode = new Native.DEVMODE();
devMode.dmSize = (short)Marshal.SizeOf(typeof(Native.DEVMODE));
var set = new Dictionary<ulong, HashSet<int>>();
for (int num = -1; Native.EnumDisplaySettings(DeviceName, num, ref devMode); num++)
{
var mode = new Mode
{
Width = devMode.dmPelsWidth,
Height = devMode.dmPelsHeight,
Hz = devMode.dmDisplayFrequency,
};
if (num == -1)
{
CurrentMode = mode;
CurrentOrientation = devMode.dmDisplayOrientation;
}
else
{
ModeList.Add(mode);
mode.Hz = 0;
if (set.TryGetValue(mode.Bits, out var rrs))
{
rrs.Add(devMode.dmDisplayFrequency);
}
else
{
set[mode.Bits] = new HashSet<int> { devMode.dmDisplayFrequency };
}
}
}
foreach (var kv in set)
{
var mode = new Mode(kv.Key);
var rrs = kv.Value.ToList();
rrs.Sort((a, b) => a - b);
SupportedResolutions.Add(new ModeSet
{
Width = mode.Width,
Height = mode.Height,
RefreshRates = rrs,
});
}
SupportedResolutions.Sort((a, b)
=> b.Width == a.Width ? (b.Height - a.Height) : (b.Width - a.Width));
}
public bool ChangeMode(int? width, int? height, int? hz, Orientation? orientation)
{
return ChangeMode(width, height, hz, orientation, defer: false);
}
/// <summary>
/// Apply a mode change. When <paramref name="defer"/> is true the change is
/// staged with CDS_NORESET — the caller must invoke <see cref="CommitChanges"/>
/// to apply all staged changes atomically. Returns true on DISP_CHANGE_SUCCESSFUL.
/// </summary>
public bool ChangeMode(int? width, int? height, int? hz, Orientation? orientation, bool defer)
{
var mode = new Native.DEVMODE();
mode.dmSize = (short)Marshal.SizeOf(typeof(Native.DEVMODE));
if (!Native.EnumDisplaySettings(DeviceName, -1, ref mode))
return false;
// dmFields must explicitly enumerate the fields we are changing
mode.dmFields = 0;
if (width.HasValue)
{
mode.dmPelsWidth = width.Value;
mode.dmFields |= /*DM_PELSWIDTH*/ 0x80000;
}
if (height.HasValue)
{
mode.dmPelsHeight = height.Value;
mode.dmFields |= /*DM_PELSHEIGHT*/ 0x100000;
}
if (hz.HasValue)
{
mode.dmDisplayFrequency = hz.Value;
mode.dmFields |= /*DM_DISPLAYFREQUENCY*/ 0x400000;
}
if (orientation.HasValue)
{
var newDO = orientation.Value;
mode.dmDisplayOrientation = newDO;
mode.dmFields |= /*DM_DISPLAYORIENTATION*/ 0x80;
if (((int)newDO + (int)CurrentOrientation) % 2 != 0)
{
int t = mode.dmPelsWidth;
mode.dmPelsWidth = mode.dmPelsHeight;
mode.dmPelsHeight = t;
mode.dmFields |= 0x80000 | 0x100000;
}
}
uint flags = /*CDS_UPDATEREGISTRY*/ 0x1;
if (defer)
flags |= /*CDS_NORESET*/ 0x10000000;
int rc = Native.ChangeDisplaySettingsEx(DeviceName, ref mode, IntPtr.Zero, flags, IntPtr.Zero);
if (rc != 0 /* DISP_CHANGE_SUCCESSFUL */)
return false;
// Refresh local cache so subsequent ChangeMode calls see the new state
if (width.HasValue) CurrentMode.Width = width.Value;
if (height.HasValue) CurrentMode.Height = height.Value;
if (hz.HasValue) CurrentMode.Hz = hz.Value;
if (orientation.HasValue) CurrentOrientation = orientation.Value;
return true;
}
/// <summary>
/// Apply all pending CDS_NORESET changes atomically.
/// Returns true if the commit was accepted (DISP_CHANGE_SUCCESSFUL).
/// </summary>
public static bool CommitChanges()
{
return Native.ChangeDisplaySettingsEx(null, IntPtr.Zero, IntPtr.Zero, 0, IntPtr.Zero) == 0;
}
/// <summary>
/// Make this display the primary monitor. The desktop origin must land
/// on the new primary, so we shift every active display by this one's
/// current position before flagging it CDS_SET_PRIMARY, then commit
/// all changes atomically. Matches the vdd.c reference implementation.
/// </summary>
public bool SetPrimary()
{
if (string.IsNullOrEmpty(DeviceName))
return false;
// Capture our current position — every other display will be shifted
// by this offset so we end up at (0,0).
var target = new Native.DEVMODE();
target.dmSize = (short)Marshal.SizeOf(typeof(Native.DEVMODE));
if (!Native.EnumDisplaySettings(DeviceName, -1, ref target))
return false;
int offX = target.dmPositionX;
int offY = target.dmPositionY;
var dd = new Native.DISPLAY_DEVICE();
dd.cb = Marshal.SizeOf(typeof(Native.DISPLAY_DEVICE));
bool targetOk = false;
for (int i = 0; Native.EnumDisplayDevices(null, i, ref dd, 0); i++)
{
if ((dd.StateFlags & Native.DISPLAY_DEVICE_ACTIVE) == 0)
continue;
var dm = new Native.DEVMODE();
dm.dmSize = (short)Marshal.SizeOf(typeof(Native.DEVMODE));
if (!Native.EnumDisplaySettings(dd.DeviceName, -1, ref dm))
continue;
dm.dmPositionX -= offX;
dm.dmPositionY -= offY;
dm.dmFields = /*DM_POSITION*/ 0x20;
uint flags = /*CDS_UPDATEREGISTRY*/ 0x1 | /*CDS_NORESET*/ 0x10000000;
bool isTarget = string.Equals(dd.DeviceName, DeviceName, StringComparison.Ordinal);
if (isTarget)
flags |= /*CDS_SET_PRIMARY*/ 0x10;
int rc = Native.ChangeDisplaySettingsEx(dd.DeviceName, ref dm, IntPtr.Zero, flags, IntPtr.Zero);
if (isTarget && rc == 0)
targetOk = true;
}
// Commit pending positional changes + the primary flag together.
CommitChanges();
return targetOk;
}
public void TakeScreenshot(string saveFile)
{
if (string.IsNullOrEmpty(DeviceName))
return;
var devMode = new Native.DEVMODE();
devMode.dmSize = (short)Marshal.SizeOf(typeof(Native.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))
{
var hdcSrc = Native.GetDC(IntPtr.Zero);
var hdcDest = gfx.GetHdc();
Native.BitBlt(hdcDest, 0, 0, width, height, hdcSrc, x, y, 0x00CC0020);
gfx.ReleaseHdc(hdcDest);
bmp.Save(saveFile, System.Drawing.Imaging.ImageFormat.Png);
Native.ReleaseDC(IntPtr.Zero, hdcSrc);
}
}
public static string DumpModes(List<Mode> modes)
{
return string.Join(",", modes.Select(m => m.Bits.ToString("x")));
}
public static List<Mode> ParseModes(string modes)
{
var list = new List<Mode>();
var tokens = modes.Trim().Split(',');
foreach (var mode in tokens)
if (ulong.TryParse(mode, NumberStyles.HexNumber, null, out var bits))
list.Add(new Mode(bits));
return list;
}
public static List<Display> GetAllDisplays()
{
var displayMap = new Dictionary<string, Display>(StringComparer.OrdinalIgnoreCase);
var cloneGroups = new List<Tuple<Display, Display>>();
var dd = new Native.DISPLAY_DEVICE();
dd.cb = Marshal.SizeOf(typeof(Native.DISPLAY_DEVICE));
for (int i = 0; Native.EnumDisplayDevices(null, i, ref dd, 0); i++)
{
Display prevActiveDisplay = null;
var dd2 = new Native.DISPLAY_DEVICE();
dd2.cb = Marshal.SizeOf(typeof(Native.DISPLAY_DEVICE));
for (int j = 0; Native.EnumDisplayDevices(dd.DeviceName, j, ref dd2, Native.EDD_GET_DEVICE_INTERFACE_NAME); j++)
{
if ((dd2.StateFlags & Native.DISPLAY_DEVICE_ATTACHED) == 0)
continue;
// Derive device instance ID directly from monitor.DeviceID
// (interface path) — avoids reading HKLM\...\monitor\Enum.
if (!TryParseInstanceId(dd2.DeviceID, out var instanceId))
continue;
if (displayMap.ContainsKey(instanceId))
continue;
var display = new Display
{
Active = (dd2.StateFlags & Native.DISPLAY_DEVICE_ACTIVE) != 0,
// Primary is a per-adapter flag on the outer dd, not on the monitor
Primary = (dd.StateFlags & Native.DISPLAY_DEVICE_PRIMARY_DEVICE) != 0,
Address = ParseDisplayAddress(dd2.DeviceID),
DeviceName = dd.DeviceName,
DisplayName = ParseDisplayCode(dd2.DeviceID),
};
if (display.Active)
{
if (prevActiveDisplay == null)
prevActiveDisplay = display;
else
cloneGroups.Add(new Tuple<Display, Display>(prevActiveDisplay, display));
display.FetchAllModes();
}
if (Device.GetDeviceInstance(instanceId, out uint devInst))
{
Device.GetParentDeviceInstance(devInst, out uint parentInst, out display.AdapterInstance);
display.AdapterArrival = Device.GetDeviceLastArrival(parentInst);
}
displayMap.Add(instanceId, display);
}
}
// 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),
// then by monitor Address (UID) within the same adapter — DeviceName
// would sort lexicographically (DISPLAY10 < DISPLAY2), which breaks
// numbering once the GDI ordinal crosses 9.
displays.Sort((a, b) =>
{
if (a.AdapterInstance == b.AdapterInstance)
return a.Address.CompareTo(b.Address);
return a.AdapterArrival.CompareTo(b.AdapterArrival);
});
// Fill display identifier
for (int i = 0; i < displays.Count; i++)
displays[i].Identifier = i + 1;
// Fill clone of identifier
foreach (var group in cloneGroups)
{
group.Item1.CloneOf = group.Item2.Identifier;
group.Item2.CloneOf = group.Item1.Identifier;
}
cloneGroups.Clear();
return displays;
}
/// <summary>
/// Convert a device interface path returned by EnumDisplayDevices with
/// EDD_GET_DEVICE_INTERFACE_NAME to a device instance ID accepted by
/// CM_Locate_DevNodeA. Example transform:
/// <code>
/// \\?\DISPLAY#PSCCDD0#5&amp;abc&amp;UID256#{e6f07b5f-...}
/// → DISPLAY\PSCCDD0\5&amp;abc&amp;UID256
/// </code>
/// </summary>
static bool TryParseInstanceId(string interfacePath, out string instanceId)
{
instanceId = null;
if (string.IsNullOrEmpty(interfacePath))
return false;
int start = interfacePath.StartsWith(@"\\?\") ? 4 : 0;
int end = interfacePath.IndexOf("#{", start, StringComparison.Ordinal);
if (end < 0) end = interfacePath.Length;
if (end <= start) return false;
instanceId = interfacePath.Substring(start, end - start).Replace('#', '\\');
return true;
}
/// <summary>
/// Parse the UID number out of a device interface path / device ID.
/// Walks contiguous digits after "UID" rather than relying on the
/// substring being numeric-to-end (handles trailing "#{guid}").
/// </summary>
static int ParseDisplayAddress(string path)
{
if (string.IsNullOrEmpty(path))
return 0;
int i = path.IndexOf("UID", StringComparison.OrdinalIgnoreCase);
if (i < 0) return 0;
i += 3;
int end = i;
while (end < path.Length && path[end] >= '0' && path[end] <= '9')
end++;
int address;
int.TryParse(path.Substring(i, end - i), out address);
return address;
}
static string ParseDisplayCode(string id)
{
var tokens = id.Split('#');
return tokens.Length >= 2 ? tokens[1] : tokens[0];
}
static class Native
{
public const uint EDD_GET_DEVICE_INTERFACE_NAME = 0x1;
public const uint DISPLAY_DEVICE_ACTIVE = 0x1;
public const uint DISPLAY_DEVICE_ATTACHED = 0x2;
public const uint DISPLAY_DEVICE_PRIMARY_DEVICE = 0x4;
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool EnumDisplayDevices(string lpDevice, int iDevNum, ref DISPLAY_DEVICE lpDisplayDevice, uint dwFlags);
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
public 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;
}
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool EnumDisplaySettings(string deviceName, int modeNum, ref DEVMODE devMode);
[DllImport("user32.dll")]
public static extern int ChangeDisplaySettingsEx(string lpszDeviceName, ref DEVMODE lpDevMode,
IntPtr hwnd, uint dwflags, IntPtr lParam);
[DllImport("user32.dll", EntryPoint = "ChangeDisplaySettingsExA", CharSet = CharSet.Ansi)]
public static extern int ChangeDisplaySettingsEx(string lpszDeviceName, IntPtr lpDevMode,
IntPtr hwnd, uint dwflags, IntPtr lParam);
[StructLayout(LayoutKind.Sequential)]
public struct DEVMODE
{
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
public string dmDeviceName;
public short dmSpecVersion;
public short dmDriverVersion;
public short dmSize;
public short dmDriverExtra;
public int dmFields;
public int dmPositionX;
public int dmPositionY;
public Orientation dmDisplayOrientation;
public int 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 short dmLogPixels;
public int dmBitsPerPel;
public int dmPelsWidth;
public int dmPelsHeight;
public int dmDisplayFlags;
public int dmDisplayFrequency;
public int dmICMMethod;
public int dmICMIntent;
public int dmMediaType;
public int dmDitherType;
public int dmReserved1;
public int dmReserved2;
public int dmPanningWidth;
public int dmPanningHeight;
}
[DllImport("gdi32.dll")]
public static extern IntPtr CreateDC(IntPtr pwszDriver, string pwszDevice, IntPtr pszPort, IntPtr devmode);
[DllImport("gdi32.dll")]
public static extern int BitBlt(IntPtr hdcDest, int nXDest, int nYDest, int nWidth, int nHeight, IntPtr hdcSrc, int nXSrc, int nYSrc, int rasterOperation);
[DllImport("gdi32.dll")]
public static extern int DeleteDC(IntPtr hdc);
[DllImport("user32.dll")]
public static extern IntPtr GetDC(IntPtr hwnd);
[DllImport("user32.dll")]
public static extern int ReleaseDC(IntPtr hwnd, IntPtr hdc);
}
}
}