Skip to content

Commit b1cad9a

Browse files
CopilotBornToBeRoot
andcommitted
Blog: Add technical article on high DPI for embedded processes (conhost + GUI)
Co-authored-by: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
1 parent 51ab08a commit b1cad9a

File tree

1 file changed

+282
-0
lines changed
  • Website/blog/2026-03-04-high-dpi-embedded-processes

1 file changed

+282
-0
lines changed
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
---
2+
slug: high-dpi-embedded-processes
3+
title: "High DPI for Embedded Processes in WPF: Making SetParent Work Across Monitor Scales"
4+
authors: [borntoberoot]
5+
tags: [wpf, dpi, windows, c#, win32, putty, powershell]
6+
---
7+
8+
Modern Windows setups with multiple monitors at different scale factors (100 %, 125 %, 150 %, …) expose a hard Win32 limitation the moment you embed a foreign process window into your own application via `SetParent`. Windows simply does not forward `WM_DPICHANGED` across process boundaries.
9+
10+
This article documents the investigation and the two different solutions NETworkManager uses for its embedded **PowerShell** (a console host process) and **PuTTY** (a GUI process) tabs — together with the complete, relevant C# source code.
11+
12+
<!-- truncate -->
13+
14+
## The Embedding Technique
15+
16+
NETworkManager uses `WindowsFormsHost` to host a native Win32 `Panel` (WinForms `Panel`), and then calls `SetParent` to re-parent a foreign process window into that panel:
17+
18+
```csharp
19+
// Make the external process window a child of our WinForms panel
20+
NativeMethods.SetParent(_appWin, WindowHost.Handle);
21+
22+
// Strip decorations so it looks native inside our tab
23+
long style = (int)NativeMethods.GetWindowLong(_appWin, NativeMethods.GWL_STYLE);
24+
style &= ~(NativeMethods.WS_CAPTION | NativeMethods.WS_POPUP | NativeMethods.WS_THICKFRAME);
25+
NativeMethods.SetWindowLongPtr(_appWin, NativeMethods.GWL_STYLE, new IntPtr(style));
26+
```
27+
28+
This works fine visually — the external window appears seamlessly inside the WPF application. **But fonts never rescale when the user drags the window to a monitor with a different DPI.**
29+
30+
## Why DPI Notifications Break
31+
32+
WPF applications declare `PerMonitorV2` DPI awareness in their manifest. When the application's `HwndSource` (the root Win32 window) moves to a different DPI monitor, Windows walks the entire Win32 window tree within the **same process** and sends `WM_DPICHANGED` / `WM_DPICHANGED_AFTERPARENT` to every child. The `WindowsFormsHost``WindowHost` chain is all in-process, so it receives `DpiChanged` events correctly.
33+
34+
The problem is that `_appWin` is owned by a **completely separate process** (PuTTY, conhost). From the Windows DWM compositor's perspective it is now a child window of your panel, but the DPI notification system only walks intra-process window trees. The external child window never receives any DPI message.
35+
36+
### What Does Not Work
37+
38+
Before arriving at the solutions below, several approaches were tried:
39+
40+
| Attempt | Why it failed |
41+
|---------|---------------|
42+
| Send `WM_DPICHANGED_AFTERPARENT` (0x02E3) | Causes the process to call `GetDpiForWindow` on itself — returns the DPI of its **current monitor** (now wrong because it is a child, not a top-level window) |
43+
| Send `WM_DPICHANGED` (0x02E0) with explicit DPI in wParam | Works only for newer PuTTY builds (0.75+) that handle this message; breaks for older builds and doesn't help console processes at all |
44+
| Hide → detach → move → re-embed | **Hiding** the window before detaching prevents the trigger: Windows only sends `WM_DPICHANGED` to **visible** top-level windows that cross a monitor DPI boundary |
45+
46+
## Solution A — Console Host Processes (PowerShell, cmd)
47+
48+
PowerShell runs inside **conhost.exe**, the Windows console host. Unlike a GUI process, conhost exposes its font settings through the Console API (`kernel32.dll`). This is a true cross-process interface: any process can attach to an existing console and modify its font without sending any window messages.
49+
50+
```csharp
51+
// NativeMethods helpers used below
52+
[DllImport("kernel32.dll", SetLastError = true)]
53+
public static extern bool AttachConsole(uint dwProcessId);
54+
55+
[DllImport("kernel32.dll", SetLastError = true)]
56+
public static extern bool FreeConsole();
57+
58+
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
59+
private static extern IntPtr CreateFile(string lpFileName, uint dwDesiredAccess,
60+
uint dwShareMode, IntPtr lpSecurityAttributes, uint dwCreationDisposition,
61+
uint dwFlagsAndAttributes, IntPtr hTemplateFile);
62+
63+
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
64+
private static extern bool GetCurrentConsoleFontEx(IntPtr hConsoleOutput,
65+
bool bMaximumWindow, ref CONSOLE_FONT_INFOEX lpConsoleCurrentFontEx);
66+
67+
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
68+
private static extern bool SetCurrentConsoleFontEx(IntPtr hConsoleOutput,
69+
bool bMaximumWindow, ref CONSOLE_FONT_INFOEX lpConsoleCurrentFontEx);
70+
```
71+
72+
### Rescale helper
73+
74+
```csharp
75+
/// <summary>
76+
/// Attaches to <paramref name="processId"/>'s console and rescales its current font
77+
/// by <paramref name="scaleFactor"/> using SetCurrentConsoleFontEx.
78+
/// Works for any conhost-based console (PowerShell, cmd, etc.).
79+
/// </summary>
80+
public static void TryRescaleConsoleFont(uint processId, double scaleFactor)
81+
{
82+
if (Math.Abs(scaleFactor - 1.0) < 0.01)
83+
return;
84+
85+
if (!AttachConsole(processId))
86+
return;
87+
88+
const uint GENERIC_READ_WRITE = 0xC0000000u;
89+
const uint FILE_SHARE_READ_WRITE = 3u;
90+
const uint OPEN_EXISTING = 3u;
91+
92+
var hOut = CreateFile("CONOUT$", GENERIC_READ_WRITE, FILE_SHARE_READ_WRITE,
93+
IntPtr.Zero, OPEN_EXISTING, 0u, IntPtr.Zero);
94+
try
95+
{
96+
if (hOut == INVALID_HANDLE_VALUE)
97+
return;
98+
try
99+
{
100+
var fi = new CONSOLE_FONT_INFOEX
101+
{
102+
cbSize = (uint)Marshal.SizeOf<CONSOLE_FONT_INFOEX>()
103+
};
104+
if (GetCurrentConsoleFontEx(hOut, false, ref fi))
105+
{
106+
fi.dwFontSize.Y = (short)Math.Max(1,
107+
(int)Math.Round(fi.dwFontSize.Y * scaleFactor));
108+
fi.cbSize = (uint)Marshal.SizeOf<CONSOLE_FONT_INFOEX>();
109+
SetCurrentConsoleFontEx(hOut, false, ref fi);
110+
}
111+
}
112+
finally { CloseHandle(hOut); }
113+
}
114+
finally { FreeConsole(); }
115+
}
116+
```
117+
118+
### Using it in the PowerShell control
119+
120+
`WindowsFormsHost` raises `DpiChanged` when the parent WPF window moves monitors. The handler uses the `NewDpi / OldDpi` ratio to rescale the font relatively:
121+
122+
```csharp
123+
private void WindowsFormsHost_DpiChanged(object sender, DpiChangedEventArgs e)
124+
{
125+
ResizeEmbeddedWindow();
126+
127+
if (!IsConnected)
128+
return;
129+
130+
NativeMethods.TryRescaleConsoleFont(
131+
(uint)_process.Id,
132+
e.NewDpi.PixelsPerInchX / e.OldDpi.PixelsPerInchX);
133+
}
134+
```
135+
136+
### Fixing the initial DPI baseline
137+
138+
There is a subtle bug if the embedded process spawns on a **different monitor** than NETworkManager: conhost's font is scaled for *its* monitor's DPI, not ours. Every subsequent `newDpi/oldDpi` relative rescale will then compound the error.
139+
140+
Fix: read `GetDpiForWindow` for both windows **before** `SetParent`, and correct the baseline immediately after embedding:
141+
142+
```csharp
143+
// Capture DPI before embedding to correct font scaling afterwards
144+
var initialWindowDpi = NativeMethods.GetDpiForWindow(_appWin);
145+
146+
NativeMethods.SetParent(_appWin, WindowHost.Handle);
147+
// ... ShowWindow, strip styles, IsConnected = true ...
148+
149+
await Task.Delay(250);
150+
ResizeEmbeddedWindow();
151+
152+
// Correct font if conhost started at a different DPI than our panel
153+
var currentPanelDpi = NativeMethods.GetDpiForWindow(WindowHost.Handle);
154+
if (initialWindowDpi != currentPanelDpi)
155+
NativeMethods.TryRescaleConsoleFont(
156+
(uint)_process.Id,
157+
(double)currentPanelDpi / initialWindowDpi);
158+
```
159+
160+
`GetDpiForWindow` is available since Windows 10 version 1607:
161+
162+
```csharp
163+
[DllImport("user32.dll")]
164+
public static extern uint GetDpiForWindow(IntPtr hWnd);
165+
```
166+
167+
## Solution B — GUI Processes (PuTTY)
168+
169+
PuTTY is a standard Win32 GUI application, not a console. The Console API does not apply. Instead, the approach is to send `WM_DPICHANGED` (0x02E0) directly to the PuTTY window, which it handles natively (requires PuTTY 0.75+ for reliable behaviour).
170+
171+
`WM_DPICHANGED` carries the new DPI packed into `wParam` (LOWORD = DPI X, HIWORD = DPI Y) and a `RECT*` in `lParam` with the suggested new window rect:
172+
173+
```csharp
174+
/// <summary>
175+
/// Sends WM_DPICHANGED to a GUI window so it can rescale its fonts and layout.
176+
/// WM_DPICHANGED is not reliably forwarded to cross-process child windows
177+
/// embedded via SetParent, so we send it explicitly.
178+
/// </summary>
179+
public static void TrySendDpiChangedMessage(IntPtr hWnd, double oldDpi, double newDpi)
180+
{
181+
if (hWnd == IntPtr.Zero)
182+
return;
183+
184+
if (Math.Abs(newDpi - oldDpi) < 0.01)
185+
return;
186+
187+
const uint WM_DPICHANGED = 0x02E0;
188+
189+
var newDpiInt = (int)Math.Round(newDpi);
190+
// HIWORD = Y DPI, LOWORD = X DPI
191+
var wParam = (IntPtr)((newDpiInt << 16) | newDpiInt);
192+
193+
// lParam must point to a RECT with the suggested new size/position.
194+
var rect = new RECT();
195+
GetWindowRect(hWnd, ref rect);
196+
197+
var lParam = Marshal.AllocHGlobal(Marshal.SizeOf<RECT>());
198+
try
199+
{
200+
Marshal.StructureToPtr(rect, lParam, false);
201+
SendMessage(hWnd, WM_DPICHANGED, wParam, lParam);
202+
}
203+
finally
204+
{
205+
Marshal.FreeHGlobal(lParam);
206+
}
207+
}
208+
```
209+
210+
### Using it in the PuTTY control
211+
212+
```csharp
213+
private void WindowsFormsHost_DpiChanged(object sender, DpiChangedEventArgs e)
214+
{
215+
ResizeEmbeddedWindow();
216+
217+
if (!IsConnected)
218+
return;
219+
220+
NativeMethods.TrySendDpiChangedMessage(
221+
_appWin,
222+
e.OldDpi.PixelsPerInchX,
223+
e.NewDpi.PixelsPerInchX);
224+
}
225+
```
226+
227+
### Fixing the initial DPI baseline for PuTTY
228+
229+
Same issue as PowerShell: PuTTY may start on a different monitor. Because PuTTY is a GUI process, the console API does not apply — but the explicit `WM_DPICHANGED` message works for the initial correction too:
230+
231+
```csharp
232+
// Capture DPI before embedding
233+
var initialWindowDpi = NativeMethods.GetDpiForWindow(_appWin);
234+
235+
NativeMethods.SetParent(_appWin, WindowHost.Handle);
236+
// ... ShowWindow, strip styles, IsConnected = true ...
237+
238+
await Task.Delay(250);
239+
ResizeEmbeddedWindow();
240+
241+
// Correct DPI if PuTTY started at a different DPI than our panel
242+
var currentPanelDpi = NativeMethods.GetDpiForWindow(WindowHost.Handle);
243+
if (initialWindowDpi != currentPanelDpi)
244+
NativeMethods.TrySendDpiChangedMessage(_appWin, initialWindowDpi, currentPanelDpi);
245+
```
246+
247+
## Sizing the WindowsFormsHost at Load
248+
249+
One more pitfall: `WindowsFormsHost` starts with zero size because WPF's logical pixel coordinates do not account for the system DPI. The panel's `ClientSize` must be set in **physical pixels**:
250+
251+
```csharp
252+
private void UserControl_Loaded(object sender, RoutedEventArgs e)
253+
{
254+
if (_initialized) return;
255+
256+
// VisualTreeHelper.GetDpi returns DpiScaleX/Y as a ratio (1.0 = 96 DPI, 1.5 = 144 DPI).
257+
var dpi = System.Windows.Media.VisualTreeHelper.GetDpi(this);
258+
WindowHost.Height = (int)((ActualHeight - 20) * dpi.DpiScaleY);
259+
WindowHost.Width = (int)((ActualWidth - 20) * dpi.DpiScaleX);
260+
261+
Connect().ConfigureAwait(false);
262+
_initialized = true;
263+
}
264+
```
265+
266+
The `-20` offset compensates for a layout quirk introduced by the Dragablz tab control (see [pull request #2678](https://github.com/BornToBeRoot/NETworkManager/pull/2678)).
267+
268+
## Summary
269+
270+
| Process type | DPI change handler | Initial DPI correction |
271+
|---|---|---|
272+
| **Console host** (conhost.exe) | `AttachConsole` + `SetCurrentConsoleFontEx` with `newDpi / oldDpi` scale factor | Same — compare `GetDpiForWindow` before and after embed |
273+
| **GUI process** (PuTTY, any Win32 app) | Send `WM_DPICHANGED` (0x02E0) with explicit new DPI | Same — send `WM_DPICHANGED` from old to new DPI |
274+
| Both | `WindowsFormsHost` initial size set in physical pixels via `VisualTreeHelper.GetDpi` ||
275+
276+
The full implementation is available in the NETworkManager source:
277+
278+
- [`NETworkManager.Utilities/NativeMethods.cs`](https://github.com/BornToBeRoot/NETworkManager/blob/main/Source/NETworkManager.Utilities/NativeMethods.cs) — all P/Invoke declarations and helpers
279+
- [`NETworkManager/Controls/PowerShellControl.xaml.cs`](https://github.com/BornToBeRoot/NETworkManager/blob/main/Source/NETworkManager/Controls/PowerShellControl.xaml.cs) — console host approach
280+
- [`NETworkManager/Controls/PuTTYControl.xaml.cs`](https://github.com/BornToBeRoot/NETworkManager/blob/main/Source/NETworkManager/Controls/PuTTYControl.xaml.cs) — GUI process approach
281+
282+
If you encounter a similar cross-process embedding scenario, open an [issue on GitHub](https://github.com/BornToBeRoot/NETworkManager/issues) — we are happy to discuss edge cases.

0 commit comments

Comments
 (0)