Skip to content

Commit 1cf71fb

Browse files
Fix DPI scaling for embedded PuTTY and PowerShell windows on multi-monitor setups (#3352)
* Initial plan * Fix DPI scaling for embedded PuTTY and PowerShell windows Co-authored-by: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> * Send WM_DPICHANGED_AFTERPARENT to embedded window on DPI change Co-authored-by: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> * Fix cross-process DPI scaling: send WM_DPICHANGED with explicit DPI to embedded window Co-authored-by: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> * DPI fix: detach/reattach embedded window so Windows delivers WM_DPICHANGED natively Co-authored-by: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> * DPI fix: SetThreadDpiHostingBehavior + visible detach/reattach for PuTTY + Console API for PowerShell Co-authored-by: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> * Fix PowerShell initial DPI + PuTTY WS_POPUP/window-size for reliable DpiChanged delivery Co-authored-by: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> * Fix: PuTTY DPI issues * Chore: Refactoring / cleanup * Fix: Initial size * Blog: Add technical article on high DPI for embedded processes (conhost + GUI) Co-authored-by: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> * Cleanup: remove unused constants, DPI_HOSTING_BEHAVIOR enum, and SetThreadDpiHostingBehavior from NativeMethods Co-authored-by: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> * Add DPI/font scaling and initial size bug fix entries to next-release changelog Co-authored-by: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> * Update blog post: add C#/WPF mention, XAML snippet, inline comments, remove "What Does Not Work" table, rewrite summary Co-authored-by: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com> * Docs: Add descriptions to blog entries --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
1 parent fcb00a4 commit 1cf71fb

File tree

14 files changed

+524
-22
lines changed

14 files changed

+524
-22
lines changed

Source/NETworkManager.Utilities/NativeMethods.cs

Lines changed: 150 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ public class NativeMethods
1616
public const long WS_POPUP = 0x80000000L;
1717
public const long WS_CAPTION = 0x00C00000L;
1818

19+
/// <summary>The value returned by CreateFile on failure.</summary>
20+
public static readonly IntPtr INVALID_HANDLE_VALUE = new(-1);
21+
1922
#endregion
2023

2124
#region Enum
@@ -44,11 +47,37 @@ public enum WM : uint
4447

4548
#endregion
4649

47-
#region Pinvoke/Win32 Methods
50+
#region Structs
4851

49-
[DllImport("user32.dll")]
50-
public static extern IntPtr GetForegroundWindow();
52+
[StructLayout(LayoutKind.Sequential)]
53+
public struct RECT
54+
{
55+
public int left, top, right, bottom;
56+
}
57+
58+
[StructLayout(LayoutKind.Sequential)]
59+
public struct COORD
60+
{
61+
public short X;
62+
public short Y;
63+
}
64+
65+
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
66+
public struct CONSOLE_FONT_INFOEX
67+
{
68+
public uint cbSize;
69+
public uint nFont;
70+
public COORD dwFontSize;
71+
public uint FontFamily;
72+
public uint FontWeight;
73+
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
74+
public string FaceName;
75+
}
5176

77+
#endregion
78+
79+
#region Pinvoke/Win32 Methods
80+
5281
[DllImport("user32.dll", SetLastError = true)]
5382
public static extern long SetParent(IntPtr hWndChild, IntPtr hWndParent);
5483

@@ -76,14 +105,129 @@ public static extern IntPtr SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, in
76105
[DllImport("user32.dll", CharSet = CharSet.Auto)]
77106
public static extern IntPtr SendMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
78107

79-
[DllImport("user32.dll", SetLastError = true)]
80-
public static extern bool MoveWindow(IntPtr hWnd, int x, int y, int cx, int cy, bool repaint);
81-
82108
[DllImport("user32.dll")]
83109
public static extern bool ShowWindow(IntPtr hWnd, WindowShowStyle nCmdShow);
84110

85111
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
86112
public static extern bool SetForegroundWindow(IntPtr hWnd);
87113

114+
/// <summary>
115+
/// Returns the DPI (dots per inch) value for the monitor that contains the specified window.
116+
/// Returns 0 if the window handle is invalid. Available on Windows 10 version 1607+.
117+
/// </summary>
118+
[DllImport("user32.dll")]
119+
public static extern uint GetDpiForWindow(IntPtr hWnd);
120+
121+
[DllImport("user32.dll", SetLastError = true)]
122+
public static extern bool GetWindowRect(IntPtr hWnd, ref RECT lpRect);
123+
124+
[DllImport("kernel32.dll", SetLastError = true)]
125+
public static extern bool AttachConsole(uint dwProcessId);
126+
127+
[DllImport("kernel32.dll", SetLastError = true)]
128+
public static extern bool FreeConsole();
129+
130+
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
131+
private static extern IntPtr CreateFile(string lpFileName, uint dwDesiredAccess, uint dwShareMode,
132+
IntPtr lpSecurityAttributes, uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile);
133+
134+
[DllImport("kernel32.dll", SetLastError = true)]
135+
private static extern bool CloseHandle(IntPtr hObject);
136+
137+
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
138+
private static extern bool GetCurrentConsoleFontEx(IntPtr hConsoleOutput, bool bMaximumWindow,
139+
ref CONSOLE_FONT_INFOEX lpConsoleCurrentFontEx);
140+
141+
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
142+
private static extern bool SetCurrentConsoleFontEx(IntPtr hConsoleOutput, bool bMaximumWindow,
143+
ref CONSOLE_FONT_INFOEX lpConsoleCurrentFontEx);
144+
145+
#endregion
146+
147+
#region Helpers
148+
149+
/// <summary>
150+
/// Attaches to <paramref name="processId"/>'s console and rescales its current font
151+
/// by <paramref name="scaleFactor"/> using <c>SetCurrentConsoleFontEx</c>.
152+
/// This is a cross-process-safe approach that bypasses WM_DPICHANGED message passing
153+
/// entirely. Works for any conhost-based console (PowerShell, cmd, etc.).
154+
/// </summary>
155+
public static void TryRescaleConsoleFont(uint processId, double scaleFactor)
156+
{
157+
if (Math.Abs(scaleFactor - 1.0) < 0.01)
158+
return;
159+
160+
if (!AttachConsole(processId))
161+
return;
162+
163+
const uint GENERIC_READ_WRITE = 0xC0000000u;
164+
const uint FILE_SHARE_READ_WRITE = 3u;
165+
const uint OPEN_EXISTING = 3u;
166+
167+
var hOut = CreateFile("CONOUT$", GENERIC_READ_WRITE, FILE_SHARE_READ_WRITE,
168+
IntPtr.Zero, OPEN_EXISTING, 0u, IntPtr.Zero);
169+
170+
try
171+
{
172+
if (hOut == INVALID_HANDLE_VALUE)
173+
return;
174+
175+
try
176+
{
177+
var fi = new CONSOLE_FONT_INFOEX { cbSize = (uint)Marshal.SizeOf<CONSOLE_FONT_INFOEX>() };
178+
if (GetCurrentConsoleFontEx(hOut, false, ref fi))
179+
{
180+
fi.dwFontSize.Y = (short)Math.Max(1, (int)Math.Round(fi.dwFontSize.Y * scaleFactor));
181+
fi.cbSize = (uint)Marshal.SizeOf<CONSOLE_FONT_INFOEX>();
182+
SetCurrentConsoleFontEx(hOut, false, ref fi);
183+
}
184+
}
185+
finally
186+
{
187+
CloseHandle(hOut);
188+
}
189+
}
190+
finally
191+
{
192+
FreeConsole();
193+
}
194+
}
195+
196+
/// <summary>
197+
/// Sends a <c>WM_DPICHANGED</c> message to a GUI window (e.g. PuTTY) so it can
198+
/// rescale its fonts and layout internally. This is necessary because
199+
/// <c>WM_DPICHANGED</c> is not reliably forwarded to cross-process child windows
200+
/// embedded via <c>SetParent</c>. Requires PuTTY 0.75+ to take effect.
201+
/// </summary>
202+
public static void TrySendDpiChangedMessage(IntPtr hWnd, double oldDpi, double newDpi)
203+
{
204+
if (hWnd == IntPtr.Zero)
205+
return;
206+
207+
if (Math.Abs(newDpi - oldDpi) < 0.01)
208+
return;
209+
210+
const uint WM_DPICHANGED = 0x02E0;
211+
212+
var newDpiInt = (int)Math.Round(newDpi);
213+
var wParam = (IntPtr)((newDpiInt << 16) | newDpiInt); // HIWORD = Y DPI, LOWORD = X DPI
214+
215+
// Build the suggested new rect from the current window position.
216+
var rect = new RECT();
217+
GetWindowRect(hWnd, ref rect);
218+
219+
// lParam must point to a RECT with the suggested new size/position.
220+
var lParam = Marshal.AllocHGlobal(Marshal.SizeOf<RECT>());
221+
try
222+
{
223+
Marshal.StructureToPtr(rect, lParam, false);
224+
SendMessage(hWnd, WM_DPICHANGED, wParam, lParam);
225+
}
226+
finally
227+
{
228+
Marshal.FreeHGlobal(lParam);
229+
}
230+
}
231+
88232
#endregion
89233
}

Source/NETworkManager/Controls/PowerShellControl.xaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
</local:UserControlBase.Resources>
2020
<Grid SizeChanged="WindowGrid_SizeChanged">
2121
<!-- Background color will prevent flickering when app inside the panel is closed -->
22-
<WindowsFormsHost Background="{DynamicResource MahApps.Brushes.Window.Background}" Margin="10">
22+
<WindowsFormsHost Background="{DynamicResource MahApps.Brushes.Window.Background}" Margin="10"
23+
DpiChanged="WindowsFormsHost_DpiChanged">
2324
<WindowsFormsHost.Style>
2425
<Style TargetType="{x:Type WindowsFormsHost}">
2526
<Style.Triggers>

Source/NETworkManager/Controls/PowerShellControl.xaml.cs

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,19 @@ private void WindowGrid_SizeChanged(object sender, SizeChangedEventArgs e)
2121
ResizeEmbeddedWindow();
2222
}
2323

24+
private void WindowsFormsHost_DpiChanged(object sender, DpiChangedEventArgs e)
25+
{
26+
ResizeEmbeddedWindow();
27+
28+
if (!IsConnected)
29+
return;
30+
31+
// Rescale the console font of the embedded conhost process so it remains the same physical size when the DPI changes.
32+
NativeMethods.TryRescaleConsoleFont(
33+
(uint)_process.Id,
34+
e.NewDpi.PixelsPerInchX / e.OldDpi.PixelsPerInchX);
35+
}
36+
2437
#endregion
2538

2639
#region Variables
@@ -89,8 +102,10 @@ private void UserControl_Loaded(object sender, RoutedEventArgs e)
89102

90103
// Fix 1: The control is not visible by default, thus height and width is not set. If the values are not set, the size does not scale properly
91104
// Fix 2: Somehow the initial size need to be 20px smaller than the actual size after using Dragablz (https://github.com/BornToBeRoot/NETworkManager/pull/2678)
92-
WindowHost.Height = (int)ActualHeight - 20;
93-
WindowHost.Width = (int)ActualWidth - 20;
105+
// Fix 3: The size needs to be scaled by the DPI, otherwise the embedded window is too small on high DPI screens (https://github.com/BornToBeRoot/NETworkManager/pull/3352)
106+
var dpi = System.Windows.Media.VisualTreeHelper.GetDpi(this);
107+
WindowHost.Height = (int)((ActualHeight - 20) * dpi.DpiScaleY);
108+
WindowHost.Width = (int)((ActualWidth - 20) * dpi.DpiScaleX);
94109

95110
Connect().ConfigureAwait(false);
96111

@@ -165,6 +180,9 @@ private async Task Connect()
165180

166181
if (_appWin != IntPtr.Zero)
167182
{
183+
// Capture DPI before embedding to correct font scaling afterwards
184+
var initialWindowDpi = NativeMethods.GetDpiForWindow(_appWin);
185+
168186
NativeMethods.SetParent(_appWin, WindowHost.Handle);
169187

170188
// Show window before set style and resize
@@ -177,10 +195,16 @@ private async Task Connect()
177195

178196
IsConnected = true;
179197

180-
// Resize embedded application & refresh
181-
// Requires a short delay because it's not applied immediately
198+
// Resize after short delay — not applied immediately
182199
await Task.Delay(250);
200+
183201
ResizeEmbeddedWindow();
202+
203+
// Correct font if conhost started at a different DPI than our panel
204+
var currentPanelDpi = NativeMethods.GetDpiForWindow(WindowHost.Handle);
205+
206+
if (initialWindowDpi != currentPanelDpi)
207+
NativeMethods.TryRescaleConsoleFont((uint)_process.Id, (double)currentPanelDpi / initialWindowDpi);
184208
}
185209
}
186210
else

Source/NETworkManager/Controls/PuTTYControl.xaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
</local:UserControlBase.Resources>
2020
<Grid SizeChanged="WindowGrid_SizeChanged">
2121
<!-- Background color will prevent flickering when app inside the panel is closed -->
22-
<WindowsFormsHost Background="{DynamicResource MahApps.Brushes.Window.Background}" Margin="10">
22+
<WindowsFormsHost Background="{DynamicResource MahApps.Brushes.Window.Background}" Margin="10"
23+
DpiChanged="WindowsFormsHost_DpiChanged">
2324
<WindowsFormsHost.Style>
2425
<Style TargetType="{x:Type WindowsFormsHost}">
2526
<Style.Triggers>

Source/NETworkManager/Controls/PuTTYControl.xaml.cs

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,19 @@ private void WindowGrid_SizeChanged(object sender, SizeChangedEventArgs e)
2222
ResizeEmbeddedWindow();
2323
}
2424

25+
private void WindowsFormsHost_DpiChanged(object sender, DpiChangedEventArgs e)
26+
{
27+
ResizeEmbeddedWindow();
28+
29+
if (!IsConnected)
30+
return;
31+
32+
// Send WM_DPICHANGED to the embedded window so it can rescale fonts and UI elements.
33+
NativeMethods.TrySendDpiChangedMessage(
34+
_appWin,
35+
e.OldDpi.PixelsPerInchX,
36+
e.NewDpi.PixelsPerInchX);
37+
}
2538
#endregion
2639

2740
#region Variables
@@ -90,8 +103,10 @@ private void UserControl_Loaded(object sender, RoutedEventArgs e)
90103

91104
// Fix 1: The control is not visible by default, thus height and width is not set. If the values are not set, the size does not scale properly
92105
// Fix 2: Somehow the initial size need to be 20px smaller than the actual size after using Dragablz (https://github.com/BornToBeRoot/NETworkManager/pull/2678)
93-
WindowHost.Height = (int)ActualHeight - 20;
94-
WindowHost.Width = (int)ActualWidth - 20;
106+
// Fix 3: The size needs to be scaled by the DPI, otherwise the embedded window is too small on high DPI screens (https://github.com/BornToBeRoot/NETworkManager/pull/3352)
107+
var dpi = System.Windows.Media.VisualTreeHelper.GetDpi(this);
108+
WindowHost.Height = (int)((ActualHeight - 20) * dpi.DpiScaleY);
109+
WindowHost.Width = (int)((ActualWidth - 20) * dpi.DpiScaleX);
95110

96111
Connect().ConfigureAwait(false);
97112

@@ -178,25 +193,31 @@ private async Task Connect()
178193

179194
if (!_process.HasExited)
180195
{
196+
// Capture DPI before embedding to correct font scaling afterwards
197+
var initialWindowDpi = NativeMethods.GetDpiForWindow(_appWin);
198+
181199
NativeMethods.SetParent(_appWin, WindowHost.Handle);
182200

183201
// Show window before set style and resize
184202
NativeMethods.ShowWindow(_appWin, NativeMethods.WindowShowStyle.Maximize);
185203

186-
// Remove border etc.
204+
// Remove border etc.
187205
long style = (int)NativeMethods.GetWindowLong(_appWin, NativeMethods.GWL_STYLE);
188-
style &= ~(NativeMethods.WS_CAPTION | NativeMethods.WS_POPUP |
189-
NativeMethods
190-
.WS_THICKFRAME); // NativeMethods.WS_POPUP --> Overflow? (https://github.com/BornToBeRoot/NETworkManager/issues/167)
206+
style &= ~(NativeMethods.WS_CAPTION | NativeMethods.WS_POPUP | NativeMethods.WS_THICKFRAME);
191207
NativeMethods.SetWindowLongPtr(_appWin, NativeMethods.GWL_STYLE, new IntPtr(style));
192208

193209
IsConnected = true;
194210

195-
// Resize embedded application & refresh
196-
// Requires a short delay because it's not applied immediately
211+
// Resize after short delay — not applied immediately
197212
await Task.Delay(250);
198213

199214
ResizeEmbeddedWindow();
215+
216+
// Correct DPI if PuTTY started at a different DPI than our panel
217+
var currentPanelDpi = NativeMethods.GetDpiForWindow(WindowHost.Handle);
218+
219+
if (initialWindowDpi != currentPanelDpi)
220+
NativeMethods.TrySendDpiChangedMessage(_appWin, initialWindowDpi, currentPanelDpi);
200221
}
201222
}
202223
}

Source/NETworkManager/Controls/TigerVNCControl.xaml.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,10 @@ private void UserControl_Loaded(object sender, RoutedEventArgs e)
8888
return;
8989

9090
// Fix 1: The control is not visible by default, thus height and width is not set. If the values are not set, the size does not scale properly
91-
WindowHost.Height = (int)ActualHeight;
92-
WindowHost.Width = (int)ActualWidth;
91+
// Fix 2: The size needs to be scaled by the DPI, otherwise the embedded window is too small on high DPI screens (https://github.com/BornToBeRoot/NETworkManager/pull/3352)
92+
var dpi = System.Windows.Media.VisualTreeHelper.GetDpi(this);
93+
WindowHost.Height = (int)(ActualHeight * dpi.DpiScaleY);
94+
WindowHost.Width = (int)(ActualWidth * dpi.DpiScaleX);
9395

9496
Connect().ConfigureAwait(false);
9597

Website/blog/2024-01-01-welcome/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
---
22
slug: welcome
33
title: Welcome
4+
description: "Welcome to the new NETworkManager website, blod and documentation. It's build with Docusaurus, a static website generator, and hosted on GitHub Pages."
45
authors: [borntoberoot]
56
tags: [welcome, docusaurus]
67
---

Website/blog/2024-05-24-introducing-code-signing-to-binaries/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
---
22
slug: introducing-code-signing-for-binaries
33
title: Introducing Code Signing for Binaries
4+
description: "Starting with NETworkManager version 2024.5.24.0, the binaries and the installer are now signed with a code signing certificate."
45
authors: [borntoberoot]
56
tags: [code-signing, binaries, installer]
67
---

Website/blog/2025-09-06-introducing-hosts-file-editor/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
---
22
slug: introducing-hosts-file-editor
33
title: Introducing Hosts File Editor
4+
description: "NETworkManager 2025.8.10.0 introduced a new feature, the `Hosts File Editor`. You can now easily manage and edit your system's hosts file in a user-friendly interface."
45
authors: [borntoberoot]
56
tags: [hosts file, dns, new feature]
67
---

Website/blog/2025-10-19-streamlined-profile-management-with-tags-and-filters/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
---
22
slug: streamlined-profile-management-with-tags-and-filters
33
title: Streamlined Profile Management with Tags & Filters
4+
description: "NETworkManager 2025.10.18.0 brings a streamlined approach to profile management with the introduction of Tags and Filtering. You can now add or remove tags directly within each profile, making it effortless to organize and quickly locate your hosts and networks."
45
authors: [borntoberoot]
56
tags: [profile management, tags, new feature]
67
---

0 commit comments

Comments
 (0)