Skip to content

Commit 8310b30

Browse files
CopilotBornToBeRoot
andcommitted
DPI fix: SetThreadDpiHostingBehavior + visible detach/reattach for PuTTY + Console API for PowerShell
Co-authored-by: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
1 parent 06a543a commit 8310b30

File tree

3 files changed

+212
-58
lines changed

3 files changed

+212
-58
lines changed

Source/NETworkManager.Utilities/NativeMethods.cs

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,17 @@ public class NativeMethods
1313
public const int WS_THICKFRAME = 0x00040000;
1414
public const int SWP_NOZORDER = 0x0004;
1515
public const int SWP_NOACTIVATE = 0x0010;
16+
public const int SWP_SHOWWINDOW = 0x0040;
1617
public const long WS_POPUP = 0x80000000L;
1718
public const long WS_CAPTION = 0x00C00000L;
19+
public const long WS_CHILD = 0x40000000L;
20+
public const uint MONITOR_DEFAULTTONEAREST = 0x00000002;
21+
22+
/// <summary>Places the window at the bottom of the Z order (behind all others).</summary>
23+
public static readonly IntPtr HWND_BOTTOM = new IntPtr(1);
24+
25+
/// <summary>The value returned by CreateFile on failure.</summary>
26+
public static readonly IntPtr INVALID_HANDLE_VALUE = new IntPtr(-1);
1827

1928
#endregion
2029

@@ -42,6 +51,57 @@ public enum WM : uint
4251
SYSCOMMAND = 0x0112
4352
}
4453

54+
/// <summary>
55+
/// Controls how a thread hosts child windows with different DPI awareness contexts.
56+
/// Set to <see cref="DPI_HOSTING_BEHAVIOR_MIXED"/> before calling SetParent with a
57+
/// cross-process child window to enable DPI notification forwarding.
58+
/// Available on Windows 10 1803 (build 17134) and later.
59+
/// </summary>
60+
public enum DPI_HOSTING_BEHAVIOR
61+
{
62+
DPI_HOSTING_BEHAVIOR_INVALID = -1,
63+
DPI_HOSTING_BEHAVIOR_DEFAULT = 0,
64+
DPI_HOSTING_BEHAVIOR_MIXED = 1
65+
}
66+
67+
#endregion
68+
69+
#region Structs
70+
71+
[StructLayout(LayoutKind.Sequential)]
72+
public struct RECT
73+
{
74+
public int left, top, right, bottom;
75+
}
76+
77+
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
78+
public struct MONITORINFO
79+
{
80+
public int cbSize;
81+
public RECT rcMonitor;
82+
public RECT rcWork;
83+
public uint dwFlags;
84+
}
85+
86+
[StructLayout(LayoutKind.Sequential)]
87+
public struct COORD
88+
{
89+
public short X;
90+
public short Y;
91+
}
92+
93+
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
94+
public struct CONSOLE_FONT_INFOEX
95+
{
96+
public uint cbSize;
97+
public uint nFont;
98+
public COORD dwFontSize;
99+
public uint FontFamily;
100+
public uint FontWeight;
101+
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
102+
public string FaceName;
103+
}
104+
45105
#endregion
46106

47107
#region Pinvoke/Win32 Methods
@@ -88,5 +148,105 @@ public static extern IntPtr SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, in
88148
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
89149
public static extern bool SetForegroundWindow(IntPtr hWnd);
90150

151+
/// <summary>
152+
/// Sets the DPI hosting behavior for windows created or reparented on the calling thread.
153+
/// Call with <see cref="DPI_HOSTING_BEHAVIOR.DPI_HOSTING_BEHAVIOR_MIXED"/> before
154+
/// SetParent to opt into mixed-DPI hosting and enable DPI notification routing for
155+
/// child windows. Returns the previous behavior so it can be restored.
156+
/// Windows 10 1803+ only.
157+
/// </summary>
158+
[DllImport("user32.dll")]
159+
public static extern DPI_HOSTING_BEHAVIOR SetThreadDpiHostingBehavior(DPI_HOSTING_BEHAVIOR value);
160+
161+
[DllImport("user32.dll")]
162+
public static extern IntPtr MonitorFromWindow(IntPtr hWnd, uint dwFlags);
163+
164+
[DllImport("user32.dll", CharSet = CharSet.Auto)]
165+
public static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFO lpmi);
166+
167+
[DllImport("kernel32.dll", SetLastError = true)]
168+
public static extern bool AttachConsole(uint dwProcessId);
169+
170+
[DllImport("kernel32.dll", SetLastError = true)]
171+
public static extern bool FreeConsole();
172+
173+
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
174+
private static extern IntPtr CreateFile(string lpFileName, uint dwDesiredAccess, uint dwShareMode,
175+
IntPtr lpSecurityAttributes, uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile);
176+
177+
[DllImport("kernel32.dll", SetLastError = true)]
178+
private static extern bool CloseHandle(IntPtr hObject);
179+
180+
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
181+
private static extern bool GetCurrentConsoleFontEx(IntPtr hConsoleOutput, bool bMaximumWindow,
182+
ref CONSOLE_FONT_INFOEX lpConsoleCurrentFontEx);
183+
184+
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
185+
private static extern bool SetCurrentConsoleFontEx(IntPtr hConsoleOutput, bool bMaximumWindow,
186+
ref CONSOLE_FONT_INFOEX lpConsoleCurrentFontEx);
187+
188+
#endregion
189+
190+
#region Helpers
191+
192+
/// <summary>
193+
/// Returns the physical-pixel bounding rectangle of the monitor that contains
194+
/// the specified window (nearest monitor if the window is off-screen).
195+
/// </summary>
196+
public static RECT GetMonitorBoundsForWindow(IntPtr hWnd)
197+
{
198+
var hMonitor = MonitorFromWindow(hWnd, MONITOR_DEFAULTTONEAREST);
199+
var mi = new MONITORINFO { cbSize = Marshal.SizeOf<MONITORINFO>() };
200+
GetMonitorInfo(hMonitor, ref mi);
201+
return mi.rcMonitor;
202+
}
203+
204+
/// <summary>
205+
/// Attaches to <paramref name="processId"/>'s console and rescales its current font
206+
/// by <paramref name="scaleFactor"/> using <c>SetCurrentConsoleFontEx</c>.
207+
/// This is a cross-process-safe approach that bypasses WM_DPICHANGED message passing
208+
/// entirely. Works for any conhost-based console (PowerShell, cmd, etc.).
209+
/// </summary>
210+
public static void TryRescaleConsoleFont(uint processId, double scaleFactor)
211+
{
212+
if (Math.Abs(scaleFactor - 1.0) < 0.01)
213+
return;
214+
215+
if (!AttachConsole(processId))
216+
return;
217+
218+
const uint GENERIC_READ_WRITE = 0xC0000000u;
219+
const uint FILE_SHARE_READ_WRITE = 3u;
220+
const uint OPEN_EXISTING = 3u;
221+
222+
var hOut = CreateFile("CONOUT$", GENERIC_READ_WRITE, FILE_SHARE_READ_WRITE,
223+
IntPtr.Zero, OPEN_EXISTING, 0u, IntPtr.Zero);
224+
225+
try
226+
{
227+
if (hOut == INVALID_HANDLE_VALUE)
228+
return;
229+
230+
try
231+
{
232+
var fi = new CONSOLE_FONT_INFOEX { cbSize = (uint)Marshal.SizeOf<CONSOLE_FONT_INFOEX>() };
233+
if (GetCurrentConsoleFontEx(hOut, false, ref fi))
234+
{
235+
fi.dwFontSize.Y = (short)Math.Max(1, (int)Math.Round(fi.dwFontSize.Y * scaleFactor));
236+
fi.cbSize = (uint)Marshal.SizeOf<CONSOLE_FONT_INFOEX>();
237+
SetCurrentConsoleFontEx(hOut, false, ref fi);
238+
}
239+
}
240+
finally
241+
{
242+
CloseHandle(hOut);
243+
}
244+
}
245+
finally
246+
{
247+
FreeConsole();
248+
}
249+
}
250+
91251
#endregion
92252
}

Source/NETworkManager/Controls/PowerShellControl.xaml.cs

Lines changed: 18 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -16,55 +16,26 @@ public partial class PowerShellControl : UserControlBase, IDragablzTabItem, IEmb
1616
{
1717
#region Events
1818

19-
private bool _isDpiChanging;
20-
2119
private void WindowGrid_SizeChanged(object sender, SizeChangedEventArgs e)
2220
{
2321
ResizeEmbeddedWindow();
2422
}
2523

26-
private async void WindowsFormsHost_DpiChanged(object sender, DpiChangedEventArgs e)
24+
private void WindowsFormsHost_DpiChanged(object sender, DpiChangedEventArgs e)
2725
{
28-
if (!IsConnected || _appWin == IntPtr.Zero || _isDpiChanging)
29-
return;
30-
31-
_isDpiChanging = true;
26+
ResizeEmbeddedWindow();
3227

33-
try
34-
{
35-
// Windows does not forward DPI change notifications across process boundaries
36-
// after SetParent. Workaround: temporarily detach the embedded window back to
37-
// a top-level window on the new monitor. Windows then delivers WM_DPICHANGED
38-
// natively to the process's own message loop so it can rescale its fonts,
39-
// exactly as it would when running standalone. Then re-embed and resize.
40-
var screen = System.Windows.Forms.Screen.FromHandle(WindowHost.Handle);
41-
42-
NativeMethods.ShowWindow(_appWin, NativeMethods.WindowShowStyle.Hide);
43-
NativeMethods.SetParent(_appWin, IntPtr.Zero);
44-
45-
// Place the window on the new monitor so Windows sends it WM_DPICHANGED.
46-
// The size (800x600) is a reasonable placeholder; the final dimensions are
47-
// set by ResizeEmbeddedWindow() after re-embedding.
48-
NativeMethods.SetWindowPos(_appWin, IntPtr.Zero,
49-
screen.Bounds.X + screen.Bounds.Width / 2,
50-
screen.Bounds.Y + screen.Bounds.Height / 2,
51-
800, 600, NativeMethods.SWP_NOZORDER | NativeMethods.SWP_NOACTIVATE);
52-
53-
// 250 ms gives the process's message loop time to dequeue and handle
54-
// WM_DPICHANGED, which Windows delivers asynchronously for cross-thread windows.
55-
await Task.Delay(250);
56-
57-
if (!IsConnected || _appWin == IntPtr.Zero)
58-
return;
28+
if (!IsConnected || _process == null || _process.HasExited)
29+
return;
5930

60-
NativeMethods.SetParent(_appWin, WindowHost.Handle);
61-
ResizeEmbeddedWindow();
62-
NativeMethods.ShowWindow(_appWin, NativeMethods.WindowShowStyle.ShowNoActivate);
63-
}
64-
finally
65-
{
66-
_isDpiChanging = false;
67-
}
31+
// PowerShell runs inside conhost.exe, a console host process. The Windows
32+
// Console API (kernel32.dll) provides cross-process access to the console's
33+
// font settings, so we can rescale fonts directly without any window message
34+
// passing — completely bypassing the WM_DPICHANGED cross-process delivery
35+
// problem that affects the PuTTY (non-console) embedding approach.
36+
NativeMethods.TryRescaleConsoleFont(
37+
(uint)_process.Id,
38+
e.NewDpi.PixelsPerInchX / e.OldDpi.PixelsPerInchX);
6839
}
6940

7041
#endregion
@@ -211,7 +182,13 @@ private async Task Connect()
211182

212183
if (_appWin != IntPtr.Zero)
213184
{
185+
// Enable mixed-DPI hosting on this thread before SetParent so that
186+
// Windows routes DPI notifications to the cross-process child window.
187+
// SetThreadDpiHostingBehavior is available on Windows 10 1803+.
188+
var prevDpiHosting = NativeMethods.SetThreadDpiHostingBehavior(
189+
NativeMethods.DPI_HOSTING_BEHAVIOR.DPI_HOSTING_BEHAVIOR_MIXED);
214190
NativeMethods.SetParent(_appWin, WindowHost.Handle);
191+
NativeMethods.SetThreadDpiHostingBehavior(prevDpiHosting);
215192

216193
// Show window before set style and resize
217194
NativeMethods.ShowWindow(_appWin, NativeMethods.WindowShowStyle.Maximize);

Source/NETworkManager/Controls/PuTTYControl.xaml.cs

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -33,31 +33,42 @@ private async void WindowsFormsHost_DpiChanged(object sender, DpiChangedEventArg
3333

3434
try
3535
{
36-
// Windows does not forward DPI change notifications across process boundaries
37-
// after SetParent. Workaround: temporarily detach the embedded window back to
38-
// a top-level window on the new monitor. Windows then delivers WM_DPICHANGED
39-
// natively to the process's own message loop so it can rescale its fonts,
40-
// exactly as it would when running standalone. Then re-embed and resize.
41-
var screen = System.Windows.Forms.Screen.FromHandle(WindowHost.Handle);
36+
// Use MonitorFromWindow for reliable physical-pixel coordinates — WinForms's
37+
// Screen.FromHandle can return DPI-virtualized (wrong) coordinates on
38+
// PerMonitorV2 setups.
39+
var bounds = NativeMethods.GetMonitorBoundsForWindow(WindowHost.Handle);
40+
int cx = bounds.left + (bounds.right - bounds.left) / 2;
41+
int cy = bounds.top + (bounds.bottom - bounds.top) / 2;
42+
43+
// Windows sends WM_DPICHANGED only to *visible* top-level windows that move
44+
// across a monitor DPI boundary. The previous approach hid the window before
45+
// detaching, which prevented the trigger. Fix: detach first (window stays
46+
// visible), then reposition on the new monitor.
47+
//
48+
// Clear WS_CHILD in case SetParent set it; a WS_CHILD window is not treated
49+
// as top-level and will not receive WM_DPICHANGED.
50+
long style = NativeMethods.GetWindowLong(_appWin, NativeMethods.GWL_STYLE);
51+
if ((style & NativeMethods.WS_CHILD) != 0)
52+
{
53+
style &= ~NativeMethods.WS_CHILD;
54+
NativeMethods.SetWindowLongPtr(_appWin, NativeMethods.GWL_STYLE, new IntPtr(style));
55+
}
4256

43-
NativeMethods.ShowWindow(_appWin, NativeMethods.WindowShowStyle.Hide);
4457
NativeMethods.SetParent(_appWin, IntPtr.Zero);
4558

46-
// Place the window on the new monitor so Windows sends it WM_DPICHANGED.
47-
// The size (800x600) is a reasonable placeholder; the final dimensions are
48-
// set by ResizeEmbeddedWindow() after re-embedding.
49-
NativeMethods.SetWindowPos(_appWin, IntPtr.Zero,
50-
screen.Bounds.X + screen.Bounds.Width / 2,
51-
screen.Bounds.Y + screen.Bounds.Height / 2,
52-
800, 600, NativeMethods.SWP_NOZORDER | NativeMethods.SWP_NOACTIVATE);
59+
// Place as a 1×1 window at the centre of the new monitor, behind all other
60+
// windows (HWND_BOTTOM). SWP_SHOWWINDOW ensures the window is visible so
61+
// Windows detects the monitor change and delivers WM_DPICHANGED natively.
62+
NativeMethods.SetWindowPos(_appWin, NativeMethods.HWND_BOTTOM, cx, cy, 1, 1,
63+
NativeMethods.SWP_NOACTIVATE | NativeMethods.SWP_SHOWWINDOW);
5364

54-
// 250 ms gives the process's message loop time to dequeue and handle
55-
// WM_DPICHANGED, which Windows delivers asynchronously for cross-thread windows.
56-
await Task.Delay(250);
65+
// Give the process time to dequeue and handle WM_DPICHANGED.
66+
await Task.Delay(300);
5767

5868
if (!IsConnected || _appWin == IntPtr.Zero)
5969
return;
6070

71+
NativeMethods.ShowWindow(_appWin, NativeMethods.WindowShowStyle.Hide);
6172
NativeMethods.SetParent(_appWin, WindowHost.Handle);
6273
ResizeEmbeddedWindow();
6374
NativeMethods.ShowWindow(_appWin, NativeMethods.WindowShowStyle.ShowNoActivate);
@@ -224,7 +235,13 @@ private async Task Connect()
224235

225236
if (!_process.HasExited)
226237
{
238+
// Enable mixed-DPI hosting on this thread before SetParent so that
239+
// Windows routes DPI notifications to the cross-process child window.
240+
// SetThreadDpiHostingBehavior is available on Windows 10 1803+.
241+
var prevDpiHosting = NativeMethods.SetThreadDpiHostingBehavior(
242+
NativeMethods.DPI_HOSTING_BEHAVIOR.DPI_HOSTING_BEHAVIOR_MIXED);
227243
NativeMethods.SetParent(_appWin, WindowHost.Handle);
244+
NativeMethods.SetThreadDpiHostingBehavior(prevDpiHosting);
228245

229246
// Show window before set style and resize
230247
NativeMethods.ShowWindow(_appWin, NativeMethods.WindowShowStyle.Maximize);

0 commit comments

Comments
 (0)