diff --git a/docs/TESTING.md b/docs/TESTING.md index 7363cfb..fb3ff76 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -126,3 +126,33 @@ Pré-condições: app **fechado**; em `config.json` (na pasta de dados) acrescen - [ ] Menu da sessão (Select + Y): o volume inicial é o do Windows; ◀/▶ muda de 5 em 5 e tira o mudo; A alterna o mudo. Resultado: ______ - [ ] Ao restaurar a mesa, o som volta para a saída de antes do modo console. Resultado: ______ - [ ] Com `"NativeAudio": true` e **sem** o `SoundVolumeView.exe` na pasta `tools`, tudo acima continua funcionando. Resultado: ______ + +## Telas nativas, sem MultiMonitorTool (issue #91) + +Pré-condições: app **fechado**; em `config.json` acrescente `"NativeDisplays": true` e abra o app. A linha `Startup:` do log deve mostrar `displays=native`. Para voltar ao MultiMonitorTool, troque para `false`. Faça primeiro uma rodada com `false` e anote o que aparece, para comparar. + +### Lista e identidade + +- [ ] O mapa de telas mostra os mesmos monitores que com o MultiMonitorTool, com os mesmos nomes, resoluções e posições, inclusive a TV **desligada/desconectada**. Resultado: ______ +- [ ] A tela de jogo e as telas a esconder que já estavam salvas continuam marcadas (o ID estável é o mesmo). Resultado: ______ +- [ ] A lista de resoluções de cada tela continua igual. Resultado: ______ + +### Sessão com "Desconectar" + +- [ ] TV começando **desligada**: "Jogar agora" liga a TV, ela vira a principal e as telas da mesa desligam. O log mostra `Telas: ativar …` e `Telas: desativar …` com `=> 0`. Resultado: ______ +- [ ] Resolução/Hz salvos para a TV são aplicados. Resultado: ______ +- [ ] Ao sair do Big Picture/Playnite, a mesa volta **exatamente** como estava: mesmas telas, principal, posições e resoluções. O log mostra `Telas: layout restaurado => 0`. Resultado: ______ +- [ ] Repetir a sessão 3 vezes seguidas: nenhuma tela troca de nome (\\.\DISPLAYn) nem de posição entre uma sessão e outra. Resultado: ______ +- [ ] Com a TV já **ligada** antes de começar: mesmo resultado. Resultado: ______ + +### Outras estratégias + +- [ ] "Cortinas pretas": as telas da mesa ficam pretas e voltam ao restaurar. Resultado: ______ +- [ ] "Desligar por DDC/CI" (monitor com DDC/CI ligado no menu dele): as telas da mesa apagam e voltam a acender ao restaurar. Resultado: ______ + +### Janelas e recuperação + +- [ ] Big Picture ou Playnite abrindo na tela errada é movido para a TV. Resultado: ______ +- [ ] Fechar o app no meio da sessão e abrir de novo: "Restaurar setup" traz a mesa de volta a partir do backup. Resultado: ______ +- [ ] Um backup antigo, feito com o MultiMonitorTool, também é restaurado com `"NativeDisplays": true`. Resultado: ______ +- [ ] Com `"NativeDisplays": true` e **sem** o `MultiMonitorTool.exe` na pasta `tools`, tudo acima continua funcionando. Resultado: ______ diff --git a/src/ConsoleMode/App.xaml.cs b/src/ConsoleMode/App.xaml.cs index fb29674..2e98729 100644 --- a/src/ConsoleMode/App.xaml.cs +++ b/src/ConsoleMode/App.xaml.cs @@ -77,7 +77,7 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) AppLog.Write("Controles: " + ControllerInput.DescribeDevices().ReplaceLineEndings(" | ")); ProtocolService.EnsureRegistered(); AppLog.Write($"Startup: exe={Environment.ProcessPath}, args={string.Join(' ', Environment.GetCommandLineArgs().Skip(1))}, " + - $"mmt={AppPaths.HasMmt}, svv={AppPaths.HasSvv}, audio={(AudioService.UseNative ? "native" : "svv")}, rtss-cli={AppPaths.HasRtssCli}"); + $"mmt={AppPaths.HasMmt}, displays={(MonitorService.UseNative ? "native" : "mmt")}, svv={AppPaths.HasSvv}, audio={(AudioService.UseNative ? "native" : "svv")}, rtss-cli={AppPaths.HasRtssCli}"); ViewModel = new MainViewModel(); _window = new MainWindow(ViewModel); diff --git a/src/ConsoleMode/Models/Models.cs b/src/ConsoleMode/Models/Models.cs index 3e84d23..42763cf 100644 --- a/src/ConsoleMode/Models/Models.cs +++ b/src/ConsoleMode/Models/Models.cs @@ -21,6 +21,8 @@ public sealed class AppConfig public bool AudioAutoSwitch { get; set; } /// Use Core Audio instead of SoundVolumeView (issue #91); off until verified on hardware. public bool NativeAudio { get; set; } + /// Use Windows' display APIs instead of MultiMonitorTool (issue #91); off until verified on hardware. + public bool NativeDisplays { get; set; } public int FpsLimit { get; set; } public Dictionary MonitorModes { get; set; } = new(StringComparer.OrdinalIgnoreCase); public bool HdrEnable { get; set; } diff --git a/src/ConsoleMode/Native/CcdHelper.cs b/src/ConsoleMode/Native/CcdHelper.cs index 81b3f9c..6263813 100644 --- a/src/ConsoleMode/Native/CcdHelper.cs +++ b/src/ConsoleMode/Native/CcdHelper.cs @@ -261,7 +261,7 @@ public static int SetHdrState(string gdiDeviceName, bool enable) return DisplayConfigSetDeviceInfo(ref state); } - private static string? GetSourceGdiName(LUID adapterId, uint sourceId) + internal static string? GetSourceGdiName(LUID adapterId, uint sourceId) { var req = new SOURCE_DEVICE_NAME { diff --git a/src/ConsoleMode/Native/NativeDisplays.cs b/src/ConsoleMode/Native/NativeDisplays.cs new file mode 100644 index 0000000..d9a70aa --- /dev/null +++ b/src/ConsoleMode/Native/NativeDisplays.cs @@ -0,0 +1,339 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using ConsoleMode.Models; +using ConsoleMode.Services; +using static ConsoleMode.Native.CcdHelper; + +namespace ConsoleMode.Native; + +/// +/// Displays through Windows' own APIs (CCD, ChangeDisplaySettingsEx, DDC/CI via dxva2), replacing +/// MultiMonitorTool (issue #91). Monitors are named by their GDI source (\\.\DISPLAYn) like before. +/// +internal static class NativeDisplays +{ + /// A connected monitor and the video source it uses (or would use when enabled). + public sealed record Monitor(string GdiName, LUID AdapterId, uint SourceId, uint TargetId, bool IsActive, + int PositionX, int PositionY, string FriendlyName, string DevicePath); + + private const uint QdcAllPaths = 0x1; + private const uint QdcOnlyActivePaths = 0x2; + private const uint PathActive = 0x1; + private const uint ModeIdxInvalid = 0xFFFFFFFF; + private const uint ModeInfoTypeSource = 1; + private const uint SdcApply = 0x80, SdcUseSuppliedDisplayConfig = 0x20, SdcSaveToDatabase = 0x200, SdcAllowChanges = 0x400; + private const uint DeviceInfoGetTargetName = 2; + + // Target (adapter + target id) → source it last had while active, so re-enabling keeps the same \\.\DISPLAYn. + private static readonly Dictionary<(long Adapter, uint Target), uint> LastSource = []; + private static readonly object Gate = new(); + + public static List ListMonitors() + { + if (!Query(QdcAllPaths, out var paths, out var modes)) return []; + + var targets = new List<(long Adapter, uint Target, LUID AdapterId)>(); + var candidates = new Dictionary<(long, uint), List>(); + var active = new Dictionary<(long, uint), int>(); + for (var i = 0; i < paths.Length; i++) + { + ref var p = ref paths[i]; + if (!p.targetInfo.targetAvailable) continue; + var key = (Key(p.targetInfo.adapterId), p.targetInfo.id); + if (!candidates.TryGetValue(key, out var list)) + { + candidates[key] = list = []; + targets.Add((key.Item1, key.Item2, p.targetInfo.adapterId)); + } + // Sources are per adapter; this path's source lives on the same adapter as the target. + if (!list.Contains(p.sourceInfo.id)) list.Add(p.sourceInfo.id); + if ((p.flags & PathActive) != 0 && !active.ContainsKey(key)) active[key] = i; + } + + Dictionary<(long, uint), (long Adapter, uint Source)> assigned; + lock (Gate) + { + foreach (var (key, index) in active) LastSource[key] = paths[index].sourceInfo.id; + // A source is identified by its adapter too, so sources of different adapters never collide. + assigned = DisplayIdentity.AssignSources<(long, uint), (long Adapter, uint Source)>(targets.Select(t => + { + var key = (t.Adapter, t.Target); + (long, uint)? activeSource = active.TryGetValue(key, out var idx) ? (t.Adapter, paths[idx].sourceInfo.id) : null; + (long, uint)? preferred = LastSource.TryGetValue(key, out var last) ? (t.Adapter, last) : null; + IReadOnlyList<(long, uint)> list = candidates[key].Select(s => (t.Adapter, s)).ToList(); + return (key, activeSource, preferred, list); + })); + } + + var result = new List(); + foreach (var t in targets) + { + var key = (t.Adapter, t.Target); + if (!assigned.TryGetValue(key, out var source)) continue; + var sourceId = source.Source; + var gdi = GetSourceGdiName(t.AdapterId, sourceId); + if (string.IsNullOrWhiteSpace(gdi)) continue; + + int x = 0, y = 0; + var isActive = active.TryGetValue(key, out var pathIndex); + if (isActive) + { + var modeIdx = paths[pathIndex].sourceInfo.modeInfoIdx; + if (modeIdx < modes.Length && modes[modeIdx].infoType == ModeInfoTypeSource) + { + x = modes[modeIdx].mode.sourceMode.position.x; + y = modes[modeIdx].mode.sourceMode.position.y; + } + } + var (friendly, devicePath) = GetTargetName(t.AdapterId, t.Target); + result.Add(new Monitor(gdi, t.AdapterId, sourceId, t.Target, isActive, x, y, friendly, devicePath)); + } + return result; + } + + /// Attaches inactive monitors (by the name gave them) to the desktop. + public static int Enable(IReadOnlyCollection gdiNames) + { + var wanted = ListMonitors() + .Where(m => !m.IsActive && gdiNames.Contains(m.GdiName, StringComparer.OrdinalIgnoreCase)) + .ToList(); + if (wanted.Count == 0) return 0; + if (!Query(QdcAllPaths, out var paths, out var modes)) return -1; + + var selected = new List(); + foreach (var p in paths) + if ((p.flags & PathActive) != 0) selected.Add(p); + + foreach (var m in wanted) + { + var index = Array.FindIndex(paths, p => + p.targetInfo.targetAvailable && p.targetInfo.id == m.TargetId && p.sourceInfo.id == m.SourceId && + Key(p.targetInfo.adapterId) == Key(m.AdapterId)); + if (index < 0) + { + AppLog.Write($"Telas: caminho para {m.GdiName} não encontrado"); + continue; + } + var path = paths[index]; + path.flags |= PathActive; + path.sourceInfo.modeInfoIdx = ModeIdxInvalid; + path.targetInfo.modeInfoIdx = ModeIdxInvalid; + selected.Add(path); + } + + var code = SetDisplayConfig((uint)selected.Count, [.. selected], (uint)modes.Length, modes, + SdcApply | SdcUseSuppliedDisplayConfig | SdcAllowChanges | SdcSaveToDatabase); + AppLog.Write($"Telas: ativar {string.Join('+', wanted.Select(m => m.GdiName))} => {code}"); + return code; + } + + /// Detaches monitors from the desktop. Refuses to detach every active one. + public static int Disable(IReadOnlyCollection gdiNames) + { + if (!Query(QdcOnlyActivePaths, out var paths, out var modes)) return -1; + var remaining = 0; + var changed = false; + for (var i = 0; i < paths.Length; i++) + { + var name = GetSourceGdiName(paths[i].sourceInfo.adapterId, paths[i].sourceInfo.id); + if (name is not null && gdiNames.Contains(name, StringComparer.OrdinalIgnoreCase)) + { + paths[i].flags = 0; + changed = true; + } + else remaining++; + } + if (!changed) return 0; + if (remaining == 0) + { + AppLog.Write($"Telas: desativar {string.Join('+', gdiNames)} deixaria o PC sem tela; ignorado"); + return -2; + } + var code = SetDisplayConfig((uint)paths.Length, paths, (uint)modes.Length, modes, + SdcApply | SdcUseSuppliedDisplayConfig | SdcSaveToDatabase | SdcAllowChanges); + AppLog.Write($"Telas: desativar {string.Join('+', gdiNames)} => {code}"); + return code; + } + + /// + /// Queues a mode and/or position for a monitor (applied by ); + /// zero or null keeps the current value. + /// + public static int QueueMode(string gdiName, int width, int height, int frequency, int bitsPerPixel, int? x, int? y) + { + var dm = new NativeWindows.DEVMODE { dmSize = (short)Marshal.SizeOf() }; + if (!NativeWindows.EnumDisplaySettings(gdiName, NativeWindows.ENUM_CURRENT_SETTINGS, ref dm)) return -1; + dm.dmFields = 0; + if (width > 0 && height > 0) + { + dm.dmPelsWidth = width; + dm.dmPelsHeight = height; + dm.dmFields |= DmPelsWidth | DmPelsHeight; + } + if (frequency > 0) { dm.dmDisplayFrequency = frequency; dm.dmFields |= DmDisplayFrequency; } + if (bitsPerPixel > 0) { dm.dmBitsPerPel = bitsPerPixel; dm.dmFields |= DmBitsPerPel; } + if (x is not null && y is not null) + { + dm.dmPositionX = x.Value; + dm.dmPositionY = y.Value; + dm.dmFields |= DmPosition; + } + if (dm.dmFields == 0) return 0; + var code = ChangeDisplaySettingsEx(gdiName, ref dm, 0, CdsUpdateRegistry | CdsNoReset, 0); + if (code != 0) AppLog.Write($"Telas: modo {width}x{height}@{frequency} em {gdiName} => {code}"); + return code; + } + + /// Applies every change queued by at once. + public static int ApplyPending() => ChangeDisplaySettingsEx(null, 0, 0, 0, 0); + + /// DDC/CI power (VCP 0xD6): on, or off (DPM) so it can still be woken over DDC. + public static bool SetPower(string gdiName, bool on) + { + var hMonitor = FindHMonitor(gdiName); + if (hMonitor == 0 || !GetNumberOfPhysicalMonitorsFromHMONITOR(hMonitor, out var count) || count == 0) return false; + var physical = new PHYSICAL_MONITOR[count]; + if (!GetPhysicalMonitorsFromHMONITOR(hMonitor, count, physical)) return false; + try + { + var ok = true; + foreach (var p in physical) ok &= SetVCPFeature(p.hPhysicalMonitor, 0xD6, on ? 1u : 4u); + if (!ok) AppLog.Write($"Telas: DDC/CI {(on ? "ligar" : "desligar")} {gdiName} falhou"); + return ok; + } + finally { DestroyPhysicalMonitors(count, physical); } + } + + /// Moves the main windows of a process onto a rectangle. + public static void MoveProcessWindows(string processName, ScreenRect rect) + { + foreach (var p in Process.GetProcessesByName(processName)) + { + using (p) + { + if (p.MainWindowHandle != 0) + NativeWindows.MoveWindowToRect(p.MainWindowHandle, rect.X, rect.Y, rect.Width, rect.Height); + } + } + } + + private static bool Query(uint flags, out PATH_INFO[] paths, out MODE_INFO[] modes) + { + paths = []; + modes = []; + for (var attempt = 0; attempt < 3; attempt++) + { + if (GetDisplayConfigBufferSizes(flags, out var numPaths, out var numModes) != 0) return false; + paths = new PATH_INFO[numPaths]; + modes = new MODE_INFO[numModes]; + var err = QueryDisplayConfig(flags, ref numPaths, paths, ref numModes, modes, 0); + if (err == ErrorInsufficientBuffer) continue; // the setup changed between the two calls + if (err != 0) return false; + Array.Resize(ref paths, (int)numPaths); + Array.Resize(ref modes, (int)numModes); + return true; + } + return false; + } + + private static (string Friendly, string DevicePath) GetTargetName(LUID adapterId, uint targetId) + { + var req = new TARGET_DEVICE_NAME + { + header = new DEVICE_INFO_HEADER + { + type = DeviceInfoGetTargetName, + size = (uint)Marshal.SizeOf(), + adapterId = adapterId, + id = targetId + } + }; + return DisplayConfigGetDeviceInfo(ref req) == 0 + ? (req.monitorFriendlyDeviceName ?? "", req.monitorDevicePath ?? "") + : ("", ""); + } + + private static nint FindHMonitor(string gdiName) + { + nint found = 0; + EnumDisplayMonitors(0, 0, (hMonitor, _, _, _) => + { + var info = new MONITORINFOEX { cbSize = Marshal.SizeOf() }; + if (GetMonitorInfo(hMonitor, ref info) && string.Equals(info.szDevice, gdiName, StringComparison.OrdinalIgnoreCase)) + { + found = hMonitor; + return false; + } + return true; + }, 0); + return found; + } + + private static long Key(LUID id) => ((long)id.HighPart << 32) | id.LowPart; + + private const int ErrorInsufficientBuffer = 122; + private const int DmPosition = 0x20, DmBitsPerPel = 0x40000, DmPelsWidth = 0x80000, DmPelsHeight = 0x100000, DmDisplayFrequency = 0x400000; + private const uint CdsUpdateRegistry = 0x1, CdsNoReset = 0x10000000; + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct TARGET_DEVICE_NAME + { + public DEVICE_INFO_HEADER header; + public uint flags; + public uint outputTechnology; + public ushort edidManufactureId; + public ushort edidProductCodeId; + public uint connectorInstance; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)] public string monitorFriendlyDeviceName; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] public string monitorDevicePath; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct PHYSICAL_MONITOR + { + public nint hPhysicalMonitor; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] public string szPhysicalMonitorDescription; + } + + [StructLayout(LayoutKind.Sequential)] + private struct RECT { public int Left, Top, Right, Bottom; } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct MONITORINFOEX + { + public int cbSize; + public RECT rcMonitor; + public RECT rcWork; + public uint dwFlags; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string szDevice; + } + + private delegate bool MonitorEnumProc(nint hMonitor, nint hdc, nint rect, nint data); + + [DllImport("user32.dll")] + private static extern int DisplayConfigGetDeviceInfo(ref TARGET_DEVICE_NAME request); + + [DllImport("user32.dll", CharSet = CharSet.Ansi)] + private static extern int ChangeDisplaySettingsEx(string? deviceName, ref NativeWindows.DEVMODE devMode, nint hwnd, uint flags, nint lParam); + + [DllImport("user32.dll", CharSet = CharSet.Ansi)] + private static extern int ChangeDisplaySettingsEx(string? deviceName, nint devMode, nint hwnd, uint flags, nint lParam); + + [DllImport("user32.dll")] + private static extern bool EnumDisplayMonitors(nint hdc, nint clip, MonitorEnumProc callback, nint data); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern bool GetMonitorInfo(nint hMonitor, ref MONITORINFOEX info); + + [DllImport("dxva2.dll", SetLastError = true)] + private static extern bool GetNumberOfPhysicalMonitorsFromHMONITOR(nint hMonitor, out uint count); + + [DllImport("dxva2.dll", SetLastError = true)] + private static extern bool GetPhysicalMonitorsFromHMONITOR(nint hMonitor, uint count, [Out] PHYSICAL_MONITOR[] monitors); + + [DllImport("dxva2.dll", SetLastError = true)] + private static extern bool SetVCPFeature(nint hPhysicalMonitor, byte code, uint value); + + [DllImport("dxva2.dll", SetLastError = true)] + private static extern bool DestroyPhysicalMonitors(uint count, PHYSICAL_MONITOR[] monitors); +} diff --git a/src/ConsoleMode/Services/ConsoleEngine.cs b/src/ConsoleMode/Services/ConsoleEngine.cs index 9a11005..cd7d8bc 100644 --- a/src/ConsoleMode/Services/ConsoleEngine.cs +++ b/src/ConsoleMode/Services/ConsoleEngine.cs @@ -385,6 +385,7 @@ private AppConfig ResolveMonitorNames(AppConfig config) AudioDeviceName = config.AudioDeviceName, AudioAutoSwitch = config.AudioAutoSwitch, NativeAudio = config.NativeAudio, + NativeDisplays = config.NativeDisplays, FpsLimit = config.FpsLimit, MonitorModes = modes, HdrEnable = config.HdrEnable, @@ -399,7 +400,7 @@ private AppConfig ResolveMonitorNames(AppConfig config) private void MoveToFocus(string monitorName, nint[] handles, ScreenRect? rect) { if (rect is not null) - Monitors.MoveWindowViaMmt(monitorName, rect, State.FullscreenMode == "playnite" ? "Playnite.FullscreenApp" : "steamwebhelper"); + Monitors.MoveProcessWindows(monitorName, rect, State.FullscreenMode == "playnite" ? "Playnite.FullscreenApp" : "steamwebhelper"); if (rect is null) return; foreach (var handle in handles) diff --git a/src/ConsoleMode/Services/DisplayBackends.cs b/src/ConsoleMode/Services/DisplayBackends.cs new file mode 100644 index 0000000..72fb992 --- /dev/null +++ b/src/ConsoleMode/Services/DisplayBackends.cs @@ -0,0 +1,244 @@ +using ConsoleMode.Models; +using ConsoleMode.Native; +using Microsoft.Win32; + +namespace ConsoleMode.Services; + +/// +/// The low-level display operations needs. MonitorService keeps the +/// orchestration (retries, waits, fallbacks); a backend only runs single commands. +/// +internal interface IDisplayBackend +{ + string Name { get; } + bool IsAvailable { get; } + List ListMonitors(); + void SaveLayout(string path); + void LoadLayout(string path); + /// Layout backup sections keyed by the names monitors have now. + Dictionary> ResolveLayout(Dictionary> specs); + void Enable(IReadOnlyList names); + void Disable(IReadOnlyList names); + void SetPrimary(string name); + void SetMode(string name, int width, int height, int frequency, string? colors, int? x, int? y, bool primary); + void PowerOn(string name); + void PowerOff(string name); + void MoveProcessWindows(string monitorName, string processName, ScreenRect rect); +} + +/// NirSoft MultiMonitorTool, the original backend. +internal sealed class MmtDisplayBackend : IDisplayBackend +{ + public string Name => "mmt"; + public bool IsAvailable => AppPaths.HasMmt; + + private static int Run(params string[] args) + { + if (!AppPaths.HasMmt) throw new InvalidOperationException(LocalizationService.Get("MmtMissing", AppPaths.MmtPath)); + return ProcessRunner.Run(AppPaths.MmtPath, args); + } + + public List ListMonitors() + { + if (!AppPaths.HasMmt) return []; + var csvPath = Path.Combine(Path.GetTempPath(), $"consolemode_monitors_{Guid.NewGuid():N}.csv"); + try + { + Run("/HideInactiveMonitors", "0", "/scomma", csvPath); + if (!File.Exists(csvPath)) return []; + + var monitors = new List(); + foreach (var row in CsvReader.Read(csvPath)) + { + var name = row.Get("Name"); + if (string.IsNullOrWhiteSpace(name)) continue; + var isActive = string.Equals(row.Get("Active"), "Yes", StringComparison.OrdinalIgnoreCase); + var maxResolution = row.Get("Maximum Resolution"); + var resolution = row.Get("Resolution"); + if (string.IsNullOrWhiteSpace(resolution)) + resolution = string.IsNullOrWhiteSpace(maxResolution) ? "N/A" : maxResolution; + + MonitorService.ParseRes(resolution, out var width, out var height); + MonitorService.ParseRes(maxResolution, out var maxW, out var maxH); + if (maxW <= 0 && width > 0) { maxW = width; maxH = height; } + + monitors.Add(new MonitorInfo + { + Name = name, + Resolution = resolution, + MaximumResolution = maxResolution, + MaxWidth = maxW, + MaxHeight = maxH, + Width = width, + Height = height, + Frequency = row.Get("Frequency"), + Colors = row.Get("Colors"), + IsPrimary = string.Equals(row.Get("Primary"), "Yes", StringComparison.OrdinalIgnoreCase), + IsActive = isActive, + IsDisconnected = string.Equals(row.Get("Disconnected"), "Yes", StringComparison.OrdinalIgnoreCase) || !isActive, + MonitorName = row.Get("Monitor Name"), + ShortId = row.Get("Short Monitor ID"), + MonitorId = row.Get("Monitor ID"), + SerialNumber = row.Get("Monitor Serial Number"), + LeftTop = row.Get("Left-Top") + }); + } + return monitors; + } + finally + { + try { File.Delete(csvPath); } catch { /* ignore */ } + } + } + + public void SaveLayout(string path) => Run("/SaveConfig", path); + public void LoadLayout(string path) => Run("/LoadConfig", path); + public Dictionary> ResolveLayout(Dictionary> specs) => specs; + public void Enable(IReadOnlyList names) => Run(["/enable", .. names]); + public void Disable(IReadOnlyList names) => Run(["/disable", .. names]); + public void SetPrimary(string name) => Run("/SetPrimary", name); + public void PowerOn(string name) => Run("/TurnOn", name); + public void PowerOff(string name) => Run("/TurnOff", name); + + public void SetMode(string name, int width, int height, int frequency, string? colors, int? x, int? y, bool primary) + { + var spec = primary ? $"Name={name} Primary=Yes" : $"Name={name}"; + if (width > 0) spec += $" Width={width}"; + if (height > 0) spec += $" Height={height}"; + if (frequency > 0) spec += $" DisplayFrequency={frequency}"; + if (!string.IsNullOrWhiteSpace(colors)) spec += $" BitsPerPixel={colors}"; + if (x is not null) spec += $" PositionX={x}"; + if (y is not null) spec += $" PositionY={y}"; + Run("/SetMonitors", spec); + } + + public void MoveProcessWindows(string monitorName, string processName, ScreenRect rect) => + Run("/MoveWindow", monitorName, + "Process", processName, + "/WindowLeft", rect.X.ToString(), + "/WindowTop", rect.Y.ToString(), + "/WindowWidth", rect.Width.ToString(), + "/WindowHeight", rect.Height.ToString()); +} + +/// Windows' own display APIs (issue #91): CCD, ChangeDisplaySettingsEx and DDC/CI. +internal sealed class NativeDisplayBackend : IDisplayBackend +{ + public string Name => "native"; + public bool IsAvailable => true; + + public List ListMonitors() + { + var result = new List(); + foreach (var m in NativeDisplays.ListMonitors()) + { + DisplayIdentity.TryParseDevicePath(m.DevicePath, out var hardwareId, out var instanceId); + var (driverKey, edid) = ReadRegistry(instanceId); + + var current = m.IsActive ? NativeWindows.GetCurrentDisplayMode(m.GdiName) : null; + int maxW = edid.PreferredWidth, maxH = edid.PreferredHeight; + if (m.IsActive) + { + foreach (var mode in NativeWindows.EnumerateDisplayModes(m.GdiName)) + { + if ((long)mode.Width * mode.Height <= (long)maxW * maxH) continue; + maxW = mode.Width; + maxH = mode.Height; + } + } + var width = current?.Width ?? maxW; + var height = current?.Height ?? maxH; + var maxText = maxW > 0 ? $"{maxW} X {maxH}" : ""; + + result.Add(new MonitorInfo + { + Name = m.GdiName, + Resolution = current is not null ? $"{current.Width} X {current.Height}" : maxText.Length > 0 ? maxText : "N/A", + MaximumResolution = maxText, + MaxWidth = maxW, + MaxHeight = maxH, + Width = width, + Height = height, + Frequency = current?.Frequency > 0 ? current.Frequency.ToString() : "", + Colors = current?.BitsPerPel > 0 ? current.BitsPerPel.ToString() : "", + IsPrimary = m.IsActive && m.PositionX == 0 && m.PositionY == 0, + IsActive = m.IsActive, + IsDisconnected = !m.IsActive, + MonitorName = !string.IsNullOrWhiteSpace(m.FriendlyName) ? m.FriendlyName : edid.Name, + ShortId = hardwareId, + MonitorId = DisplayIdentity.MonitorId(hardwareId, driverKey), + SerialNumber = edid.Serial, + LeftTop = m.IsActive ? $"{m.PositionX}, {m.PositionY}" : "" + }); + } + return result; + } + + private static (string? DriverKey, EdidInfo Edid) ReadRegistry(string instanceId) + { + var empty = new EdidInfo("", "", 0, 0); + if (string.IsNullOrWhiteSpace(instanceId)) return (null, empty); + try + { + using var key = Registry.LocalMachine.OpenSubKey($@"SYSTEM\CurrentControlSet\Enum\{instanceId}"); + if (key is null) return (null, empty); + using var parameters = key.OpenSubKey("Device Parameters"); + return (key.GetValue("Driver") as string, DisplayIdentity.ParseEdid(parameters?.GetValue("EDID") as byte[])); + } + catch (Exception ex) + { + AppLog.Write($"Telas: registro de {instanceId}: {ex.Message}"); + return (null, empty); + } + } + + public void SaveLayout(string path) + { + var entries = ListMonitors().Select(m => + { + var current = m.IsActive ? NativeWindows.GetCurrentDisplayMode(m.Name) : null; + MonitorService.ParseLeftTop(m.LeftTop, out var x, out var y); + return new LayoutEntry(m.Name, m.MonitorId, m.SerialNumber, current?.BitsPerPel ?? 0, + current?.Width ?? 0, current?.Height ?? 0, current?.Frequency ?? 0, x ?? 0, y ?? 0); + }); + File.WriteAllText(path, DisplayIdentity.FormatLayout(entries)); + } + + public void LoadLayout(string path) + { + var specs = ResolveLayout(DisplayIdentity.ParseLayout(File.ReadAllLines(path))); + foreach (var (name, spec) in specs) + { + int.TryParse(spec.GetValueOrDefault("Width"), out var w); + int.TryParse(spec.GetValueOrDefault("Height"), out var h); + if (w <= 0 || h <= 0) continue; + int.TryParse(spec.GetValueOrDefault("DisplayFrequency"), out var f); + int.TryParse(spec.GetValueOrDefault("BitsPerPixel"), out var bits); + int? x = int.TryParse(spec.GetValueOrDefault("PositionX"), out var px) ? px : null; + int? y = int.TryParse(spec.GetValueOrDefault("PositionY"), out var py) ? py : null; + NativeDisplays.QueueMode(name, w, h, f, bits, x, y); + } + var code = NativeDisplays.ApplyPending(); + AppLog.Write($"Telas: layout restaurado => {code}"); + } + + public Dictionary> ResolveLayout(Dictionary> specs) => + DisplayIdentity.RemapLayoutNames(specs, ListMonitors().Select(m => (m.MonitorId, m.Name))); + + public void Enable(IReadOnlyList names) => NativeDisplays.Enable(names); + public void Disable(IReadOnlyList names) => NativeDisplays.Disable(names); + public void SetPrimary(string name) => CcdHelper.SetPrimary(name); + public void PowerOn(string name) => NativeDisplays.SetPower(name, on: true); + public void PowerOff(string name) => NativeDisplays.SetPower(name, on: false); + + public void SetMode(string name, int width, int height, int frequency, string? colors, int? x, int? y, bool primary) + { + int.TryParse(colors, out var bits); + NativeDisplays.QueueMode(name, width, height, frequency, bits, x, y); + NativeDisplays.ApplyPending(); + if (primary) CcdHelper.SetPrimary(name); + } + + public void MoveProcessWindows(string monitorName, string processName, ScreenRect rect) => + NativeDisplays.MoveProcessWindows(processName, rect); +} diff --git a/src/ConsoleMode/Services/DisplayIdentity.cs b/src/ConsoleMode/Services/DisplayIdentity.cs new file mode 100644 index 0000000..369eb3f --- /dev/null +++ b/src/ConsoleMode/Services/DisplayIdentity.cs @@ -0,0 +1,187 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace ConsoleMode.Services; + +/// One monitor in the layout backup (MultiMonitorTool's .cfg format, kept for compatibility). +public sealed record LayoutEntry(string Name, string MonitorId, string Serial, int BitsPerPixel, + int Width, int Height, int Frequency, int PositionX, int PositionY); + +/// What the monitor's EDID says about itself. +public sealed record EdidInfo(string Name, string Serial, int PreferredWidth, int PreferredHeight); + +/// +/// Monitor identity and layout-file helpers for the native display backend (issue #91). They +/// produce the same values MultiMonitorTool did, so saved settings and backups keep working. +/// Pure, so it's tested. +/// +public static class DisplayIdentity +{ + /// + /// \\?\DISPLAY#GSM5B7F#5&2d8e1c7&0&UID4352#{e6f07b5f-…} → + /// hardware ID GSM5B7F, device instance DISPLAY\GSM5B7F\5&2d8e1c7&0&UID4352. + /// + public static bool TryParseDevicePath(string? devicePath, out string hardwareId, out string instanceId) + { + hardwareId = instanceId = ""; + if (string.IsNullOrWhiteSpace(devicePath)) return false; + var path = devicePath.StartsWith(@"\\?\", StringComparison.Ordinal) ? devicePath[4..] : devicePath; + var parts = path.Split('#'); + if (parts.Length < 3 || parts[0].Length == 0 || parts[1].Length == 0 || parts[2].Length == 0) return false; + hardwareId = parts[1]; + instanceId = $@"{parts[0]}\{parts[1]}\{parts[2]}"; + return true; + } + + /// + /// MultiMonitorTool's "Monitor ID" (the device ID Windows gives the monitor), e.g. + /// MONITOR\GSM5B7F\{4d36e96e-e325-11ce-bfc1-08002be10318}\0004; the last part is the + /// monitor's driver key. It's what MonitorInfo.StableId saves in config.json. + /// + public static string MonitorId(string hardwareId, string? driverKey) => + string.IsNullOrWhiteSpace(hardwareId) || string.IsNullOrWhiteSpace(driverKey) ? "" : $@"MONITOR\{hardwareId}\{driverKey}"; + + /// Name (0xFC) and serial (0xFF) descriptors, and the preferred (native) resolution. + public static EdidInfo ParseEdid(byte[]? edid) + { + if (edid is null || edid.Length < 128) return new EdidInfo("", "", 0, 0); + string name = "", serial = ""; + int width = 0, height = 0; + for (var offset = 54; offset <= 108; offset += 18) + { + if (edid[offset] != 0 || edid[offset + 1] != 0) + { + // Detailed timing; the first one is the preferred mode. + if (width == 0) + { + width = edid[offset + 2] | ((edid[offset + 4] & 0xF0) << 4); + height = edid[offset + 5] | ((edid[offset + 7] & 0xF0) << 4); + } + continue; + } + var text = DescriptorText(edid, offset); + switch (edid[offset + 3]) + { + case 0xFC when name.Length == 0: name = text; break; + case 0xFF when serial.Length == 0: serial = text; break; + } + } + return new EdidInfo(name, serial, width, height); + } + + private static string DescriptorText(byte[] edid, int offset) + { + var sb = new StringBuilder(); + for (var i = offset + 5; i < offset + 18; i++) + { + if (edid[i] == 0x0A || edid[i] == 0) break; + sb.Append((char)edid[i]); + } + return sb.ToString().Trim(); + } + + /// + /// Picks a video source for every monitor. Active ones keep theirs; each inactive one gets its + /// preferred source (the one it last used) when free, else the first free candidate, so no two + /// monitors share a name (\\.\DISPLAYn) and a re-enabled monitor keeps its old one. + /// + public static Dictionary AssignSources( + IEnumerable<(TTarget Target, TSource? Active, TSource? Preferred, IReadOnlyList Candidates)> targets) + where TTarget : notnull where TSource : struct + { + var list = targets.ToList(); + var result = new Dictionary(); + var used = new HashSet(); + foreach (var t in list.Where(t => t.Active is not null)) + { + result[t.Target] = t.Active!.Value; + used.Add(t.Active.Value); + } + foreach (var t in list.Where(t => t.Active is null)) + { + TSource? pick = t.Preferred is { } p && t.Candidates.Contains(p) && !used.Contains(p) ? p : null; + pick ??= t.Candidates.Where(c => !used.Contains(c)).Select(c => (TSource?)c).FirstOrDefault(); + if (pick is not { } chosen) continue; + result[t.Target] = chosen; + used.Add(chosen); + } + return result; + } + + public static string FormatLayout(IEnumerable entries) + { + var sb = new StringBuilder(); + var i = 0; + foreach (var e in entries) + { + sb.Append($"[Monitor{i++}]\r\n"); + sb.Append($"Name={e.Name}\r\n"); + sb.Append($"MonitorID={e.MonitorId}\r\n"); + sb.Append($"SerialNumber={e.Serial}\r\n"); + sb.Append($"BitsPerPixel={e.BitsPerPixel}\r\n"); + sb.Append($"Width={e.Width}\r\n"); + sb.Append($"Height={e.Height}\r\n"); + sb.Append("DisplayFlags=0\r\n"); + sb.Append($"DisplayFrequency={e.Frequency}\r\n"); + sb.Append("DisplayOrientation=0\r\n"); + sb.Append($"PositionX={e.PositionX}\r\n"); + sb.Append($"PositionY={e.PositionY}\r\n"); + } + return sb.ToString(); + } + + /// Monitor sections of a layout file, keyed by Name (last one wins). + public static Dictionary> ParseLayout(IEnumerable lines) + { + var specs = new Dictionary>(StringComparer.OrdinalIgnoreCase); + Dictionary? current = null; + string? currentName = null; + foreach (var line in lines) + { + if (Regex.IsMatch(line, @"^\[Monitor\d+\]")) + { + if (currentName is not null && current is not null) specs[currentName] = current; + current = new Dictionary(StringComparer.OrdinalIgnoreCase); + currentName = null; + continue; + } + + var m = Regex.Match(line, @"^Name=(.+)$"); + if (m.Success && current is not null) + { + currentName = m.Groups[1].Value.Trim(); + current["Name"] = currentName; + continue; + } + + var kv = Regex.Match(line, @"^(\w+)=(.+)$"); + if (kv.Success && current is not null) + current[kv.Groups[1].Value] = kv.Groups[2].Value.Trim(); + } + + if (currentName is not null && current is not null) specs[currentName] = current; + return specs; + } + + /// + /// Re-keys layout sections by the name each monitor has now, matched by MonitorID: a + /// monitor that was disabled can come back as another \.\DISPLAYn. Sections whose monitor isn't + /// found keep their saved name. + /// + public static Dictionary> RemapLayoutNames( + Dictionary> specs, IEnumerable<(string MonitorId, string Name)> current) + { + var byId = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (id, name) in current) + if (!string.IsNullOrWhiteSpace(id)) byId.TryAdd(id, name); + + var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var (savedName, spec) in specs) + { + var name = spec.TryGetValue("MonitorID", out var id) && byId.TryGetValue(id, out var now) ? now : savedName; + var copy = new Dictionary(spec, StringComparer.OrdinalIgnoreCase) { ["Name"] = name }; + result[name] = copy; + } + return result; + } +} diff --git a/src/ConsoleMode/Services/MonitorService.cs b/src/ConsoleMode/Services/MonitorService.cs index b945b6a..81aa76f 100644 --- a/src/ConsoleMode/Services/MonitorService.cs +++ b/src/ConsoleMode/Services/MonitorService.cs @@ -7,6 +7,8 @@ namespace ConsoleMode.Services; public sealed class MonitorService { + private static bool? _useNative; + private readonly IDisplayBackend _backend = UseNative ? new NativeDisplayBackend() : new MmtDisplayBackend(); private readonly object _gate = new(); private List? _cache; private readonly Dictionary> _modesCache = new(StringComparer.OrdinalIgnoreCase); @@ -17,10 +19,19 @@ public void ClearCache() lock (_modesCache) _modesCache.Clear(); } - public int InvokeMmt(params string[] args) + /// + /// Windows' own display APIs instead of MultiMonitorTool (issue #91). Off by default while it's + /// verified on real hardware: set "NativeDisplays": true in config.json and restart the app. + /// + public static bool UseNative => _useNative ??= LoadNativeFlag(); + + /// Display control works: the native backend is on, or MultiMonitorTool is present. + public static bool IsAvailable => UseNative || AppPaths.HasMmt; + + private static bool LoadNativeFlag() { - if (!AppPaths.HasMmt) throw new InvalidOperationException(LocalizationService.Get("MmtMissing", AppPaths.MmtPath)); - return ProcessRunner.Run(AppPaths.MmtPath, args); + try { return ConfigService.Load().NativeDisplays; } + catch { return false; } } public IReadOnlyList GetMonitors(bool forceRefresh = false) @@ -40,69 +51,22 @@ public string ResolveName(string idOrName) => private IReadOnlyList ReadMonitors(bool forceRefresh) { if (!forceRefresh && _cache is not null) return _cache; - if (!AppPaths.HasMmt) + if (!_backend.IsAvailable) { _cache = []; return _cache; } - var csvPath = Path.Combine(Path.GetTempPath(), $"consolemode_monitors_{Guid.NewGuid():N}.csv"); - try - { - InvokeMmt("/HideInactiveMonitors", "0", "/scomma", csvPath); - if (!File.Exists(csvPath)) - { - _cache = []; - return _cache; - } - - var rows = CsvReader.Read(csvPath); - var winMap = BuildDisplayNumberMap(rows); - var monitors = new List(); - foreach (var row in rows) - { - var name = row.Get("Name"); - if (string.IsNullOrWhiteSpace(name)) continue; - var isActive = string.Equals(row.Get("Active"), "Yes", StringComparison.OrdinalIgnoreCase); - var maxResolution = row.Get("Maximum Resolution"); - var resolution = row.Get("Resolution"); - if (string.IsNullOrWhiteSpace(resolution)) - resolution = string.IsNullOrWhiteSpace(maxResolution) ? "N/A" : maxResolution; - - ParseRes(resolution, out var width, out var height); - ParseRes(maxResolution, out var maxW, out var maxH); - if (maxW <= 0 && width > 0) { maxW = width; maxH = height; } - - monitors.Add(new MonitorInfo - { - Name = name, - WindowsDisplayNumber = winMap.GetValueOrDefault(name), - Resolution = resolution, - MaximumResolution = maxResolution, - MaxWidth = maxW, - MaxHeight = maxH, - Width = width, - Height = height, - Frequency = row.Get("Frequency"), - Colors = row.Get("Colors"), - IsPrimary = string.Equals(row.Get("Primary"), "Yes", StringComparison.OrdinalIgnoreCase), - IsActive = isActive, - IsDisconnected = string.Equals(row.Get("Disconnected"), "Yes", StringComparison.OrdinalIgnoreCase) || !isActive, - MonitorName = row.Get("Monitor Name"), - ShortId = row.Get("Short Monitor ID"), - MonitorId = row.Get("Monitor ID"), - SerialNumber = row.Get("Monitor Serial Number"), - LeftTop = row.Get("Left-Top") - }); - } + var monitors = _backend.ListMonitors(); + // Windows' "Display n": active monitors first, in the order they were listed. + var n = 1; + var numbers = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var m in monitors.OrderBy(m => m.IsActive ? 0 : 1)) + if (!numbers.ContainsKey(m.Name)) numbers[m.Name] = n++; + foreach (var m in monitors) m.WindowsDisplayNumber = numbers[m.Name]; - _cache = [.. monitors.OrderBy(m => m.WindowsDisplayNumber > 0 ? m.WindowsDisplayNumber : 999).ThenBy(m => m.Name)]; - return _cache; - } - finally - { - try { File.Delete(csvPath); } catch { /* ignore */ } - } + _cache = [.. monitors.OrderBy(m => m.WindowsDisplayNumber > 0 ? m.WindowsDisplayNumber : 999).ThenBy(m => m.Name)]; + return _cache; } public string? GetPrimaryName() @@ -114,7 +78,7 @@ private IReadOnlyList ReadMonitors(bool forceRefresh) public void SaveBackup() { - InvokeMmt("/SaveConfig", AppPaths.BackupMonitorConfig); + _backend.SaveLayout(AppPaths.BackupMonitorConfig); } public void SaveBackupMeta(MonitorBackupMeta meta) @@ -160,13 +124,13 @@ public RestoreResult RestoreBackup(ConsoleRuntimeState state) } var ctx = GetRestoreContext(state); - var backupSpecs = GetBackupMonitorSpecs(AppPaths.BackupMonitorConfig); + var backupSpecs = _backend.ResolveLayout(GetBackupMonitorSpecs(AppPaths.BackupMonitorConfig)); AppLog.Write($"Restore: iniciando (primário={ctx.OriginalPrimary}, foco={ctx.FocusMonitor}, focoInativo={ctx.FocusWasInactive}, esconder={string.Join('+', ctx.HideMonitors)})"); if (ctx.HideStrategy == "turnOff" && ctx.HideMonitors.Count > 0) { foreach (var name in ctx.HideMonitors.Distinct()) - InvokeMmt("/TurnOn", name); + _backend.PowerOn(name); Thread.Sleep(300); } @@ -182,14 +146,12 @@ public RestoreResult RestoreBackup(ConsoleRuntimeState state) if (allDeviceNames.Count > 0) { - var enableArgs = new List { "/enable" }; - enableArgs.AddRange(allDeviceNames); - InvokeMmt(enableArgs.ToArray()); + _backend.Enable(allDeviceNames); if (monitorsToVerify.Count > 0 && !WaitMonitorsActive(monitorsToVerify)) { foreach (var name in monitorsToVerify) - InvokeMmt("/enable", name); + _backend.Enable([name]); if (!WaitMonitorsActive(monitorsToVerify, 3000)) { AppLog.Write($"Restore: /enable não confirmou todos ({string.Join('+', monitorsToVerify)}); usando ExtendAll"); @@ -207,7 +169,7 @@ public RestoreResult RestoreBackup(ConsoleRuntimeState state) result.Issues.Add($"Não foi possível restaurar o primário para {ctx.OriginalPrimary}"); } - InvokeMmt("/LoadConfig", AppPaths.BackupMonitorConfig); + _backend.LoadLayout(AppPaths.BackupMonitorConfig); Thread.Sleep(800); if (!string.IsNullOrWhiteSpace(ctx.OriginalPrimary)) @@ -242,7 +204,7 @@ public RestoreResult RestoreBackup(ConsoleRuntimeState state) public void SetPrimary(string monitorName) { - InvokeMmt("/SetPrimary", monitorName); + _backend.SetPrimary(monitorName); if (WaitPrimary(monitorName, 2000)) return; CcdHelper.SetPrimary(monitorName); WaitPrimary(monitorName, 3000); @@ -253,17 +215,15 @@ public void EnableMonitors(IReadOnlyList names, bool windowsEnable) if (names.Count == 0) return; if (windowsEnable) { - var args = new List { "/enable" }; - args.AddRange(names); - InvokeMmt(args.ToArray()); + _backend.Enable(names); MarkActive(names); return; } foreach (var name in names) { - InvokeMmt("/TurnOn", name); - InvokeMmt("/enable", name); + _backend.PowerOn(name); + _backend.Enable([name]); } MarkActive(names); } @@ -276,7 +236,7 @@ public bool EnsureActive(string name) { if (WaitMonitorsActive([name], 3000)) return true; AppLog.Write($"Ativar {name}: ainda inativo; repetindo /enable"); - InvokeMmt("/enable", name); + _backend.Enable([name]); if (WaitMonitorsActive([name], 3000)) return true; AppLog.Write($"Ativar {name}: /enable não confirmou; usando ExtendAll"); CcdHelper.ExtendAll(); @@ -286,15 +246,13 @@ public bool EnsureActive(string name) public void DisableWindows(IReadOnlyList names) { if (names.Count == 0) return; - var args = new List { "/disable" }; - args.AddRange(names); - InvokeMmt(args.ToArray()); + _backend.Disable(names); } public void DisableDdc(IReadOnlyList names) { foreach (var name in names) - InvokeMmt("/TurnOff", name); + _backend.PowerOff(name); } public void ApplyFocusMode(string focusMonitor, SavedDisplayMode mode) @@ -379,28 +337,11 @@ public bool UpdateFocusRect(string monitorName, ConsoleRuntimeState state, bool return true; } - public void MoveWindowViaMmt(string monitorName, ScreenRect rect, string processName) - { - InvokeMmt( - "/MoveWindow", monitorName, - "Process", processName, - "/WindowLeft", rect.X.ToString(), - "/WindowTop", rect.Y.ToString(), - "/WindowWidth", rect.Width.ToString(), - "/WindowHeight", rect.Height.ToString()); - } + public void MoveProcessWindows(string monitorName, ScreenRect rect, string processName) => + _backend.MoveProcessWindows(monitorName, processName, rect); - private void SetMonitorMode(string name, int width, int height, int frequency, string? colors, int? x, int? y, bool primary) - { - var spec = primary ? $"Name={name} Primary=Yes" : $"Name={name}"; - if (width > 0) spec += $" Width={width}"; - if (height > 0) spec += $" Height={height}"; - if (frequency > 0) spec += $" DisplayFrequency={frequency}"; - if (!string.IsNullOrWhiteSpace(colors)) spec += $" BitsPerPixel={colors}"; - if (x is not null) spec += $" PositionX={x}"; - if (y is not null) spec += $" PositionY={y}"; - InvokeMmt("/SetMonitors", spec); - } + private void SetMonitorMode(string name, int width, int height, int frequency, string? colors, int? x, int? y, bool primary) => + _backend.SetMode(name, width, height, frequency, colors, x, y, primary); private bool IsCurrentMode(string name, int width, int height, int frequency) { @@ -427,7 +368,7 @@ private bool RestorePrimaryWithRetry(string monitorName, Dictionary s SetMonitorMode(monitorName, w, h, frequency, spec.GetValueOrDefault("BitsPerPixel"), null, null, true); return; } - InvokeMmt("/SetPrimary", monitorName); + _backend.SetPrimary(monitorName); } private bool DisableWithRetry(string monitorName, int maxAttempts = 3) { for (var attempt = 1; attempt <= maxAttempts; attempt++) { - if (attempt == 1) InvokeMmt("/disable", monitorName); + if (attempt == 1) _backend.Disable([monitorName]); else { var code = CcdHelper.DetachDisplay(monitorName); @@ -513,53 +454,8 @@ private void MarkActive(IReadOnlyList names) } } - private static Dictionary BuildDisplayNumberMap(List> rows) - { - var map = new Dictionary(StringComparer.OrdinalIgnoreCase); - var ordered = rows - .Where(r => !string.IsNullOrWhiteSpace(r.Get("Name"))) - .OrderBy(r => string.Equals(r.Get("Active"), "Yes", StringComparison.OrdinalIgnoreCase) ? 0 : 1); - var n = 1; - foreach (var row in ordered) - { - var name = row.Get("Name"); - if (map.ContainsKey(name)) continue; - map[name] = n++; - } - return map; - } - - private static Dictionary> GetBackupMonitorSpecs(string path) - { - var specs = new Dictionary>(StringComparer.OrdinalIgnoreCase); - Dictionary? current = null; - string? currentName = null; - foreach (var line in File.ReadAllLines(path)) - { - if (Regex.IsMatch(line, @"^\[Monitor\d+\]")) - { - if (currentName is not null && current is not null) specs[currentName] = current; - current = new Dictionary(StringComparer.OrdinalIgnoreCase); - currentName = null; - continue; - } - - var m = Regex.Match(line, @"^Name=(.+)$"); - if (m.Success && current is not null) - { - currentName = m.Groups[1].Value.Trim(); - current["Name"] = currentName; - continue; - } - - var kv = Regex.Match(line, @"^(\w+)=(.+)$"); - if (kv.Success && current is not null) - current[kv.Groups[1].Value] = kv.Groups[2].Value.Trim(); - } - - if (currentName is not null && current is not null) specs[currentName] = current; - return specs; - } + private static Dictionary> GetBackupMonitorSpecs(string path) => + DisplayIdentity.ParseLayout(File.ReadAllLines(path)); private static bool IsBackupSpecActive(Dictionary spec) { @@ -568,7 +464,7 @@ private static bool IsBackupSpecActive(Dictionary spec) return w > 0 && h > 0; } - private static void ParseRes(string? text, out int w, out int h) + internal static void ParseRes(string? text, out int w, out int h) { w = 0; h = 0; if (string.IsNullOrWhiteSpace(text)) return; @@ -592,7 +488,7 @@ private static void ParseRes(string? text, out int w, out int h) }; } - private static void ParseLeftTop(string? leftTop, out int? x, out int? y) + internal static void ParseLeftTop(string? leftTop, out int? x, out int? y) { x = y = null; if (string.IsNullOrWhiteSpace(leftTop)) return; diff --git a/src/ConsoleMode/ViewModels/MainViewModel.cs b/src/ConsoleMode/ViewModels/MainViewModel.cs index 57bbf0b..7a53b7b 100644 --- a/src/ConsoleMode/ViewModels/MainViewModel.cs +++ b/src/ConsoleMode/ViewModels/MainViewModel.cs @@ -250,7 +250,7 @@ private static LoadResult LoadData(ConsoleEngine engine) monitors.Add(new LoadedMonitor(monitor, engine.Monitors.GetDisplayModes(monitor.Name, monitor))); if (list.Count == 0) { - error = AppPaths.HasMmt + error = MonitorService.IsAvailable ? LocalizationService.Get("NoDisplayTryRefresh") : LocalizationService.Get("MmtMissing", AppPaths.MmtPath); } @@ -891,6 +891,7 @@ private AppConfig BuildConfig() AudioDeviceName = auto ? "" : SelectedAudio?.Text ?? "", AudioAutoSwitch = auto, NativeAudio = _loadedConfig.NativeAudio, + NativeDisplays = _loadedConfig.NativeDisplays, FpsLimit = ReadFpsLimit(), HdrEnable = HdrEnable, VrrEnable = VrrEnable, diff --git a/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj b/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj index 1a07711..64c310a 100644 --- a/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj +++ b/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj @@ -29,6 +29,7 @@ + diff --git a/tests/ConsoleMode.Tests/DisplayIdentityTests.cs b/tests/ConsoleMode.Tests/DisplayIdentityTests.cs new file mode 100644 index 0000000..e99b774 --- /dev/null +++ b/tests/ConsoleMode.Tests/DisplayIdentityTests.cs @@ -0,0 +1,97 @@ +using System.Text; +using ConsoleMode.Services; + +namespace ConsoleMode.Tests; + +public sealed class DisplayIdentityTests +{ + [Fact] + public void Device_path_gives_the_hardware_id_and_the_device_instance() + { + Assert.True(DisplayIdentity.TryParseDevicePath( + @"\\?\DISPLAY#GSM5B7F#5&2d8e1c7&0&UID4352#{e6f07b5f-ee97-4a90-b076-33f57bf4eaa7}", out var hw, out var instance)); + Assert.Equal("GSM5B7F", hw); + Assert.Equal(@"DISPLAY\GSM5B7F\5&2d8e1c7&0&UID4352", instance); + + Assert.False(DisplayIdentity.TryParseDevicePath("", out _, out _)); + Assert.False(DisplayIdentity.TryParseDevicePath(@"\\?\DISPLAY#GSM5B7F", out _, out _)); + } + + [Fact] + public void Monitor_id_has_MultiMonitorTools_format() + { + Assert.Equal(@"MONITOR\GSM5B7F\{4d36e96e-e325-11ce-bfc1-08002be10318}\0004", + DisplayIdentity.MonitorId("GSM5B7F", @"{4d36e96e-e325-11ce-bfc1-08002be10318}\0004")); + Assert.Equal("", DisplayIdentity.MonitorId("GSM5B7F", null)); + } + + [Fact] + public void Edid_gives_name_serial_and_native_resolution() + { + var edid = new byte[128]; + // Detailed timing 3840 x 2160 (the preferred mode). + edid[54] = 0x08; edid[55] = 0xE8; + edid[56] = 0x00; edid[58] = 0xF0; + edid[59] = 0x70; edid[61] = 0x80; + WriteText(edid, 72, 0xFC, "LG TV SSCR2"); + WriteText(edid, 90, 0xFF, "0x01010101"); + + var info = DisplayIdentity.ParseEdid(edid); + + Assert.Equal("LG TV SSCR2", info.Name); + Assert.Equal("0x01010101", info.Serial); + Assert.Equal(3840, info.PreferredWidth); + Assert.Equal(2160, info.PreferredHeight); + Assert.Equal(new EdidInfo("", "", 0, 0), DisplayIdentity.ParseEdid(null)); + } + + [Fact] + public void Active_monitors_keep_their_source_and_inactive_ones_get_a_free_one() + { + IReadOnlyList all = [0, 1, 2]; + var assigned = DisplayIdentity.AssignSources( + [ + ("desk", 0, null, all), + ("tv", null, 0, all), // wants 0 (its last one) but the desk has it + ("side", null, 2, all), // gets its last one + ("projector", null, null, all) + ]); + + Assert.Equal(0, assigned["desk"]); + Assert.Equal(2, assigned["side"]); + Assert.Equal(1, assigned["tv"]); + Assert.False(assigned.ContainsKey("projector")); // no source left + } + + [Fact] + public void Layout_file_round_trips_and_follows_renamed_monitors() + { + var text = DisplayIdentity.FormatLayout( + [ + new LayoutEntry(@"\\.\DISPLAY1", @"MONITOR\DEL4083\{x}\0001", "ABC", 32, 2560, 1440, 144, 0, 0), + new LayoutEntry(@"\\.\DISPLAY3", @"MONITOR\GSM5B7F\{x}\0004", "", 0, 0, 0, 0, 0, 0) + ]); + var specs = DisplayIdentity.ParseLayout(text.Split("\r\n")); + + Assert.Equal("2560", specs[@"\\.\DISPLAY1"]["Width"]); + Assert.Equal("144", specs[@"\\.\DISPLAY1"]["DisplayFrequency"]); + Assert.Equal("0", specs[@"\\.\DISPLAY3"]["Width"]); + + // The TV came back as DISPLAY2: its section follows it by MonitorID. + var remapped = DisplayIdentity.RemapLayoutNames(specs, + [ + (@"MONITOR\DEL4083\{x}\0001", @"\\.\DISPLAY1"), + (@"MONITOR\GSM5B7F\{x}\0004", @"\\.\DISPLAY2") + ]); + Assert.True(remapped.ContainsKey(@"\\.\DISPLAY2")); + Assert.False(remapped.ContainsKey(@"\\.\DISPLAY3")); + Assert.Equal(@"\\.\DISPLAY2", remapped[@"\\.\DISPLAY2"]["Name"]); + } + + private static void WriteText(byte[] edid, int offset, byte tag, string text) + { + edid[offset + 3] = tag; + var bytes = Encoding.ASCII.GetBytes(text + "\n"); + for (var i = 0; i < 13; i++) edid[offset + 5 + i] = i < bytes.Length ? bytes[i] : (byte)0x20; + } +}