Skip to content
185 changes: 179 additions & 6 deletions Source/NETworkManager.Utilities/NativeMethods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,17 @@ public class NativeMethods
public const int WS_THICKFRAME = 0x00040000;
public const int SWP_NOZORDER = 0x0004;
public const int SWP_NOACTIVATE = 0x0010;
public const int SWP_SHOWWINDOW = 0x0040;
public const long WS_POPUP = 0x80000000L;
public const long WS_CAPTION = 0x00C00000L;
public const long WS_CHILD = 0x40000000L;
public const uint MONITOR_DEFAULTTONEAREST = 0x00000002;

/// <summary>Places the window at the bottom of the Z order (behind all others).</summary>
public static readonly IntPtr HWND_BOTTOM = new(1);

/// <summary>The value returned by CreateFile on failure.</summary>
public static readonly IntPtr INVALID_HANDLE_VALUE = new(-1);

#endregion

Expand Down Expand Up @@ -42,13 +51,52 @@ public enum WM : uint
SYSCOMMAND = 0x0112
}

/// <summary>
/// Controls how a thread hosts child windows with different DPI awareness contexts.
/// Set to <see cref="DPI_HOSTING_BEHAVIOR_MIXED"/> before calling SetParent with a
/// cross-process child window to enable DPI notification forwarding.
/// Available on Windows 10 1803 (build 17134) and later.
/// </summary>
public enum DPI_HOSTING_BEHAVIOR
{
DPI_HOSTING_BEHAVIOR_INVALID = -1,
DPI_HOSTING_BEHAVIOR_DEFAULT = 0,
DPI_HOSTING_BEHAVIOR_MIXED = 1
}

#endregion

#region Pinvoke/Win32 Methods
#region Structs

[DllImport("user32.dll")]
public static extern IntPtr GetForegroundWindow();
[StructLayout(LayoutKind.Sequential)]
public struct RECT
{
public int left, top, right, bottom;
}

[StructLayout(LayoutKind.Sequential)]
public struct COORD
{
public short X;
public short Y;
}

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct CONSOLE_FONT_INFOEX
{
public uint cbSize;
public uint nFont;
public COORD dwFontSize;
public uint FontFamily;
public uint FontWeight;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
public string FaceName;
}

#endregion

#region Pinvoke/Win32 Methods

[DllImport("user32.dll", SetLastError = true)]
public static extern long SetParent(IntPtr hWndChild, IntPtr hWndParent);

Expand Down Expand Up @@ -76,14 +124,139 @@ public static extern IntPtr SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, in
[DllImport("user32.dll", CharSet = CharSet.Auto)]
public static extern IntPtr SendMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);

[DllImport("user32.dll", SetLastError = true)]
public static extern bool MoveWindow(IntPtr hWnd, int x, int y, int cx, int cy, bool repaint);

[DllImport("user32.dll")]
public static extern bool ShowWindow(IntPtr hWnd, WindowShowStyle nCmdShow);

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

/// <summary>
/// Sets the DPI hosting behavior for windows created or reparented on the calling thread.
/// Call with <see cref="DPI_HOSTING_BEHAVIOR.DPI_HOSTING_BEHAVIOR_MIXED"/> before
/// SetParent to opt into mixed-DPI hosting and enable DPI notification routing for
/// child windows. Returns the previous behavior so it can be restored.
/// Windows 10 1803+ only.
/// </summary>
[DllImport("user32.dll")]
public static extern DPI_HOSTING_BEHAVIOR SetThreadDpiHostingBehavior(DPI_HOSTING_BEHAVIOR value);

/// <summary>
/// Returns the DPI (dots per inch) value for the monitor that contains the specified window.
/// Returns 0 if the window handle is invalid. Available on Windows 10 version 1607+.
/// </summary>
[DllImport("user32.dll")]
public static extern uint GetDpiForWindow(IntPtr hWnd);

[DllImport("user32.dll", SetLastError = true)]
public static extern bool GetWindowRect(IntPtr hWnd, ref RECT lpRect);

[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool AttachConsole(uint dwProcessId);

[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool FreeConsole();

[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern IntPtr CreateFile(string lpFileName, uint dwDesiredAccess, uint dwShareMode,
IntPtr lpSecurityAttributes, uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile);

[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool CloseHandle(IntPtr hObject);

[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool GetCurrentConsoleFontEx(IntPtr hConsoleOutput, bool bMaximumWindow,
ref CONSOLE_FONT_INFOEX lpConsoleCurrentFontEx);

[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool SetCurrentConsoleFontEx(IntPtr hConsoleOutput, bool bMaximumWindow,
ref CONSOLE_FONT_INFOEX lpConsoleCurrentFontEx);

#endregion

#region Helpers

/// <summary>
/// Attaches to <paramref name="processId"/>'s console and rescales its current font
/// by <paramref name="scaleFactor"/> using <c>SetCurrentConsoleFontEx</c>.
/// This is a cross-process-safe approach that bypasses WM_DPICHANGED message passing
/// entirely. Works for any conhost-based console (PowerShell, cmd, etc.).
/// </summary>
public static void TryRescaleConsoleFont(uint processId, double scaleFactor)
{
if (Math.Abs(scaleFactor - 1.0) < 0.01)
return;

if (!AttachConsole(processId))
return;

const uint GENERIC_READ_WRITE = 0xC0000000u;
const uint FILE_SHARE_READ_WRITE = 3u;
const uint OPEN_EXISTING = 3u;

var hOut = CreateFile("CONOUT$", GENERIC_READ_WRITE, FILE_SHARE_READ_WRITE,
IntPtr.Zero, OPEN_EXISTING, 0u, IntPtr.Zero);

try
{
if (hOut == INVALID_HANDLE_VALUE)
return;

try
{
var fi = new CONSOLE_FONT_INFOEX { cbSize = (uint)Marshal.SizeOf<CONSOLE_FONT_INFOEX>() };
if (GetCurrentConsoleFontEx(hOut, false, ref fi))
{
fi.dwFontSize.Y = (short)Math.Max(1, (int)Math.Round(fi.dwFontSize.Y * scaleFactor));
fi.cbSize = (uint)Marshal.SizeOf<CONSOLE_FONT_INFOEX>();
SetCurrentConsoleFontEx(hOut, false, ref fi);
}
}
finally
{
CloseHandle(hOut);
}
}
finally
{
FreeConsole();
}
}

/// <summary>
/// Sends a <c>WM_DPICHANGED</c> message to a GUI window (e.g. PuTTY) so it can
/// rescale its fonts and layout internally. This is necessary because
/// <c>WM_DPICHANGED</c> is not reliably forwarded to cross-process child windows
/// embedded via <c>SetParent</c>. Requires PuTTY 0.75+ to take effect.
/// </summary>
public static void TrySendDpiChangedMessage(IntPtr hWnd, double oldDpi, double newDpi)
{
if (hWnd == IntPtr.Zero)
return;

if (Math.Abs(newDpi - oldDpi) < 0.01)
return;

const uint WM_DPICHANGED = 0x02E0;

var newDpiInt = (int)Math.Round(newDpi);
var wParam = (IntPtr)((newDpiInt << 16) | newDpiInt); // HIWORD = Y DPI, LOWORD = X DPI

// Build the suggested new rect from the current window position.
var rect = new RECT();
GetWindowRect(hWnd, ref rect);

// lParam must point to a RECT with the suggested new size/position.
var lParam = Marshal.AllocHGlobal(Marshal.SizeOf<RECT>());
try
{
Marshal.StructureToPtr(rect, lParam, false);
SendMessage(hWnd, WM_DPICHANGED, wParam, lParam);
}
finally
{
Marshal.FreeHGlobal(lParam);
}
}

#endregion
}
3 changes: 2 additions & 1 deletion Source/NETworkManager/Controls/PowerShellControl.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
</local:UserControlBase.Resources>
<Grid SizeChanged="WindowGrid_SizeChanged">
<!-- Background color will prevent flickering when app inside the panel is closed -->
<WindowsFormsHost Background="{DynamicResource MahApps.Brushes.Window.Background}" Margin="10">
<WindowsFormsHost Background="{DynamicResource MahApps.Brushes.Window.Background}" Margin="10"
DpiChanged="WindowsFormsHost_DpiChanged">
<WindowsFormsHost.Style>
<Style TargetType="{x:Type WindowsFormsHost}">
<Style.Triggers>
Expand Down
37 changes: 37 additions & 0 deletions Source/NETworkManager/Controls/PowerShellControl.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,23 @@ private void WindowGrid_SizeChanged(object sender, SizeChangedEventArgs e)
ResizeEmbeddedWindow();
}

private void WindowsFormsHost_DpiChanged(object sender, DpiChangedEventArgs e)
{
ResizeEmbeddedWindow();

if (!IsConnected || _process == null || _process.HasExited)
return;

// PowerShell runs inside conhost.exe, a console host process. The Windows
// Console API (kernel32.dll) provides cross-process access to the console's
// font settings, so we can rescale fonts directly without any window message
// passing — completely bypassing the WM_DPICHANGED cross-process delivery
// problem that affects the PuTTY (non-console) embedding approach.
NativeMethods.TryRescaleConsoleFont(
(uint)_process.Id,
e.NewDpi.PixelsPerInchX / e.OldDpi.PixelsPerInchX);
}

#endregion

#region Variables
Expand Down Expand Up @@ -165,7 +182,18 @@ private async Task Connect()

if (_appWin != IntPtr.Zero)
{
// Capture the DPI of conhost's window before embedding. The process
// might have started on a different monitor than ours, so its font
// may be scaled for a different DPI. We correct this after embedding.
var initialWindowDpi = NativeMethods.GetDpiForWindow(_appWin);

// Enable mixed-DPI hosting on this thread before SetParent so that
// Windows routes DPI notifications to the cross-process child window.
// SetThreadDpiHostingBehavior is available on Windows 10 1803+.
var prevDpiHosting = NativeMethods.SetThreadDpiHostingBehavior(
NativeMethods.DPI_HOSTING_BEHAVIOR.DPI_HOSTING_BEHAVIOR_MIXED);
NativeMethods.SetParent(_appWin, WindowHost.Handle);
NativeMethods.SetThreadDpiHostingBehavior(prevDpiHosting);

// Show window before set style and resize
NativeMethods.ShowWindow(_appWin, NativeMethods.WindowShowStyle.Maximize);
Expand All @@ -181,6 +209,15 @@ private async Task Connect()
// Requires a short delay because it's not applied immediately
await Task.Delay(250);
ResizeEmbeddedWindow();

// If conhost started at a different DPI than our monitor (e.g. it
// spawned on a secondary monitor with a different scale factor),
// correct the font now so WindowsFormsHost_DpiChanged always starts
// from the right baseline for relative scaling.
var currentPanelDpi = NativeMethods.GetDpiForWindow(WindowHost.Handle);
if (initialWindowDpi != currentPanelDpi)
NativeMethods.TryRescaleConsoleFont((uint)_process.Id,
(double)currentPanelDpi / initialWindowDpi);
}
}
else
Expand Down
3 changes: 2 additions & 1 deletion Source/NETworkManager/Controls/PuTTYControl.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
</local:UserControlBase.Resources>
<Grid SizeChanged="WindowGrid_SizeChanged">
<!-- Background color will prevent flickering when app inside the panel is closed -->
<WindowsFormsHost Background="{DynamicResource MahApps.Brushes.Window.Background}" Margin="10">
<WindowsFormsHost Background="{DynamicResource MahApps.Brushes.Window.Background}" Margin="10"
DpiChanged="WindowsFormsHost_DpiChanged">
<WindowsFormsHost.Style>
<Style TargetType="{x:Type WindowsFormsHost}">
<Style.Triggers>
Expand Down
41 changes: 37 additions & 4 deletions Source/NETworkManager/Controls/PuTTYControl.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,22 @@ private void WindowGrid_SizeChanged(object sender, SizeChangedEventArgs e)
ResizeEmbeddedWindow();
}

private async void WindowsFormsHost_DpiChanged(object sender, DpiChangedEventArgs e)
{
ResizeEmbeddedWindow();

if (!IsConnected || _process == null || _process.HasExited)
return;

// PuTTY is a GUI application (not console-based), so the Console Font API
// (AttachConsole/SetCurrentConsoleFontEx) does not apply. Instead, send
// WM_DPICHANGED directly to the PuTTY window so it can rescale its fonts
// and layout internally — bypassing the cross-process delivery limitation.
NativeMethods.TrySendDpiChangedMessage(
_appWin,
e.OldDpi.PixelsPerInchX,
e.NewDpi.PixelsPerInchX);
}
#endregion

#region Variables
Expand Down Expand Up @@ -178,16 +194,25 @@ private async Task Connect()

if (!_process.HasExited)
{
// Capture PuTTY's window DPI before embedding. The process
// might have started on a different monitor than ours, so its font
// may be scaled for a different DPI. We correct this after embedding.
var initialWindowDpi = NativeMethods.GetDpiForWindow(_appWin);

// Enable mixed-DPI hosting on this thread before SetParent so that
// Windows routes DPI notifications to the cross-process child window.
// SetThreadDpiHostingBehavior is available on Windows 10 1803+.
var prevDpiHosting = NativeMethods.SetThreadDpiHostingBehavior(
NativeMethods.DPI_HOSTING_BEHAVIOR.DPI_HOSTING_BEHAVIOR_MIXED);
NativeMethods.SetParent(_appWin, WindowHost.Handle);
NativeMethods.SetThreadDpiHostingBehavior(prevDpiHosting);

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

// Remove border etc.
// Remove border etc.
long style = (int)NativeMethods.GetWindowLong(_appWin, NativeMethods.GWL_STYLE);
style &= ~(NativeMethods.WS_CAPTION | NativeMethods.WS_POPUP |
NativeMethods
.WS_THICKFRAME); // NativeMethods.WS_POPUP --> Overflow? (https://github.com/BornToBeRoot/NETworkManager/issues/167)
style &= ~(NativeMethods.WS_CAPTION | NativeMethods.WS_POPUP | NativeMethods.WS_THICKFRAME);
NativeMethods.SetWindowLongPtr(_appWin, NativeMethods.GWL_STYLE, new IntPtr(style));

IsConnected = true;
Expand All @@ -197,6 +222,14 @@ private async Task Connect()
await Task.Delay(250);

ResizeEmbeddedWindow();

// If PuTTY started at a different DPI than our panel (e.g. it
// spawned on a secondary monitor with a different scale factor),
// send WM_DPICHANGED so PuTTY rescales its fonts to match.
var currentPanelDpi = NativeMethods.GetDpiForWindow(WindowHost.Handle);
if (initialWindowDpi != currentPanelDpi)
NativeMethods.TrySendDpiChangedMessage(_appWin,
initialWindowDpi, currentPanelDpi);
}
}
}
Expand Down