From abfdfa9fddd288a9b2357a7b450c66af6432cc62 Mon Sep 17 00:00:00 2001 From: bobo198504 <32607316+bobo198504@users.noreply.github.com> Date: Sun, 6 Sep 2026 23:18:53 +0800 Subject: [PATCH 1/2] Fix OverflowException in WindowChromeWorker._HandleNCHitTest when dragging across different-DPI monitors Dragging the preview window onto a monitor with a different DPI (e.g. a non-primary 4K display) crashed with an arithmetic overflow inside WPF's WindowChromeWorker hit-test. The crash was triggered on WM_NCHITTEST, whose DPI math can't be made safe from the outside, so: - Short-circuit WM_NCHITTEST to HTCLIENT in ViewerWindow, and restore window dragging by hand (WM_NCLBUTTONDOWN + HT CAPTION) on the title area. - Apply the WM_DPICHANGED suggested rect so the HWND and WPF geometry stay in sync as the window moves between monitors. - Opt into Per-Monitor V2 DPI awareness (SetProcessDpiAwarenessContext) with a V1 fallback. - Clamp MoveWindow coordinates/size and sanitize non-finite window size so a degenerate rect can never reach Win32/WPF. Fixes QL-Win/QuickLook#1996 --- QuickLook.Common/Helpers/WindowHelper.cs | 34 ++++++++++- QuickLook/App.xaml.cs | 16 +++-- QuickLook/NativeMethods/SHCore.cs | 12 ++++ QuickLook/ViewerWindow.Actions.cs | 17 +++++- QuickLook/ViewerWindow.xaml.cs | 78 ++++++++++++++++++++++++ 5 files changed, 149 insertions(+), 8 deletions(-) diff --git a/QuickLook.Common/Helpers/WindowHelper.cs b/QuickLook.Common/Helpers/WindowHelper.cs index d23f207f3..32efa3675 100644 --- a/QuickLook.Common/Helpers/WindowHelper.cs +++ b/QuickLook.Common/Helpers/WindowHelper.cs @@ -88,7 +88,35 @@ public static void MoveWindow(this Window window, out var pxWidth, out var pxHeight); // Use absolute location and relative size. WPF will scale the size to the target display - User32.MoveWindow(handle, (int)Math.Round(pxLeft), (int)Math.Round(pxTop), pxWidth, pxHeight, true); + // + // Guard against arithmetic overflow in WindowChromeWorker.HandleNCHitTest (net462). + // When the window is on a per-monitor DPI display the values here are physical pixels + // that may be NaN or outside the int32 range (e.g. a window straddling a negative- + // coordinate monitor). Feeding such a rect to User32/WPF lets the (int) casts inside + // Win32.MoveWindow and WindowChrome hit-testing throw OverflowException, so clamp them. + var x = ToInt32Clamped(pxLeft); + var y = ToInt32Clamped(pxTop); + // Keep the physical window rect strictly larger than the invisible resize border and + // caption. If it ever shrinks to zero/smaller, WindowChromeWorker._HandleNCHitTest + // (net462) computes a degenerate rect that throws OverflowException on WM_NCHITTEST. + var w = Math.Max(ToInt32Clamped(pxWidth), 24); + var h = Math.Max(ToInt32Clamped(pxHeight), 56); + + User32.MoveWindow(handle, x, y, w, h, true); + } + + private static int ToInt32Clamped(double value) + { + // Math.Round on NaN returns NaN; (int)NaN in a checked context throws OverflowException. + if (double.IsNaN(value)) + return 0; + + if (value <= int.MinValue) + return int.MinValue; + if (value >= int.MaxValue) + return int.MaxValue; + + return (int)Math.Round(value); } public static Rect GetWindowRectInPixel(this Window window) @@ -116,8 +144,8 @@ private static void TransformToPixels(this Visual visual, matrix = src.CompositionTarget.TransformToDevice; } - pixelX = (int)Math.Round(matrix.M11 * unitX); - pixelY = (int)Math.Round(matrix.M22 * unitY); + pixelX = ToInt32Clamped(matrix.M11 * unitX); + pixelY = ToInt32Clamped(matrix.M22 * unitY); } public static bool IsForegroundWindowBelongToSelf() diff --git a/QuickLook/App.xaml.cs b/QuickLook/App.xaml.cs index 9184585ff..df6bba8de 100644 --- a/QuickLook/App.xaml.cs +++ b/QuickLook/App.xaml.cs @@ -60,13 +60,21 @@ static App() RenderOptions.ProcessRenderMode = RenderMode.SoftwareOnly; } - // Explicitly set to PerMonitor to avoid being overridden by the system - if (SHCore.SetProcessDpiAwareness(SHCore.PROCESS_DPI_AWARENESS.PROCESS_PER_MONITOR_DPI_AWARE) is uint result) + // Per-Monitor V2 so a window dragged across monitors with different DPI gets WM_DPICHANGED + // and is rescaled automatically. This keeps WPF's per-window DPI in sync and prevents + // WindowChromeWorker._HandleNCHitTest from overflowing on a non-primary 4K display. + // Fall back to V1 (SetProcessDpiAwareness) on systems that don't support the context API. + if (Environment.OSVersion.Version >= new Version(10, 0, 15063) && + SHCore.SetProcessDpiAwarenessContext(SHCore.DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)) + { + Debug.WriteLine("DPI Awareness context: Per-Monitor V2 applied"); + } + else if (SHCore.SetProcessDpiAwareness(SHCore.PROCESS_DPI_AWARENESS.PROCESS_PER_MONITOR_DPI_AWARE) is uint result) { Debug.WriteLine( result == 0 ? - "DPI Awareness applied successfully" : - $"DPI Awareness manual setup failed. Error Code: {result}" + "DPI Awareness (V1) applied successfully" : + $"DPI Awareness (V1) manual setup failed. Error Code: {result}" ); } diff --git a/QuickLook/NativeMethods/SHCore.cs b/QuickLook/NativeMethods/SHCore.cs index ec5bf304b..28a016995 100644 --- a/QuickLook/NativeMethods/SHCore.cs +++ b/QuickLook/NativeMethods/SHCore.cs @@ -15,6 +15,7 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +using System; using System.Runtime.InteropServices; namespace QuickLook.NativeMethods; @@ -30,4 +31,15 @@ public enum PROCESS_DPI_AWARENESS [DllImport("shcore.dll")] public static extern uint SetProcessDpiAwareness(PROCESS_DPI_AWARENESS awareness); + + /// + /// DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2. Unlike the V1 context (set via + /// SetProcessDpiAwareness), V2 makes Windows raise WM_DPICHANGED and rescale a window as it is + /// dragged across monitors with different DPI. This keeps WPF's per-window DPI in sync, which + /// prevents WindowChromeWorker._HandleNCHitTest from overflowing on a non-primary 4K display. + /// + public static readonly nint DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = new IntPtr(-4); + + [DllImport("user32.dll")] + public static extern bool SetProcessDpiAwarenessContext(nint value); } diff --git a/QuickLook/ViewerWindow.Actions.cs b/QuickLook/ViewerWindow.Actions.cs index c5fb50506..8d8f81b81 100644 --- a/QuickLook/ViewerWindow.Actions.cs +++ b/QuickLook/ViewerWindow.Actions.cs @@ -161,13 +161,28 @@ private void PositionWindow(Size size) if (WindowState == WindowState.Maximized) return; - size = new Size(Math.Max(MinWidth, size.Width), Math.Max(MinHeight, size.Height)); + // Math.Max(MinWidth, NaN) keeps NaN, which then flows into the WPF window geometry and + // causes an OverflowException inside WindowChromeWorker.HandleNCHitTest (net462). + // Sanitize any NaN / non-finite / non-positive size before it reaches the window. + size = new Size( + FinitePositive(Math.Max(MinWidth, size.Width), MinWidth), + FinitePositive(Math.Max(MinHeight, size.Height), MinHeight)); var newRect = IsLoaded ? ResizeAndCentreExistingWindow(size) : ResizeAndCentreNewWindow(size); + // MoveWindow clamps any non-finite / out-of-range coordinate and guarantees a sane + // physical window size, so the window can sit on a negative-coordinate monitor (e.g. a + // secondary display left of the primary) without being dragged to the primary screen. this.MoveWindow(newRect.Left, newRect.Top, newRect.Width, newRect.Height); } + private static double FinitePositive(double value, double fallback) + { + if (double.IsNaN(value) || double.IsInfinity(value) || value <= 0) + return fallback; + return value; + } + private Rect ResizeAndCentreExistingWindow(Size size) { // Align window just like in macOS ... diff --git a/QuickLook/ViewerWindow.xaml.cs b/QuickLook/ViewerWindow.xaml.cs index 7eb190af7..b79dcba9d 100644 --- a/QuickLook/ViewerWindow.xaml.cs +++ b/QuickLook/ViewerWindow.xaml.cs @@ -22,8 +22,10 @@ using System; using System.Diagnostics; using System.IO; +using System.Runtime.InteropServices; using System.Windows; using System.Windows.Input; +using System.Windows.Interop; using System.Windows.Media; using System.Windows.Media.Animation; using System.Windows.Shell; @@ -45,6 +47,7 @@ public partial class ViewerWindow : Window private string _path = string.Empty; private FileSystemWatcher _autoReloadWatcher; private readonly bool _autoReload; + private HwndSource _windowHwndSource; internal ViewerWindow() { @@ -67,6 +70,11 @@ internal ViewerWindow() windowFrameContainer.PreviewMouseMove += ShowWindowCaptionContainer; + // Window dragging is done by hand here (WM_NCLBUTTONDOWN + HT CAPTION) instead of relying on + // WindowChrome's hit test, because we answer WM_NCHITTEST with HTCLIENT to prevent a net462 + // WindowChrome overflow when dragging across different-DPI monitors. + titleArea.MouseLeftButtonDown += TitleArea_MouseLeftButtonDown; + Topmost = SettingHelper.Get("Topmost", false); buttonTop.Tag = Topmost ? "Top" : "Auto"; @@ -188,6 +196,48 @@ protected override void OnSourceInitialized(EventArgs e) WindowHelper.RemoveWindowControls(this); ApplyWindowBackgroundEffects(); + + // Handle WM_DPICHANGED so dragging the window onto a monitor with a different DPI + // (e.g. a 4K secondary display on a per-monitor-DPI system) keeps the HWND geometry and + // WPF's view of it in sync. Without this, WindowChromeWorker._HandleNCHitTest (net462) + // reads a stale window rect during the drag and throws OverflowException. + var handle = new WindowInteropHelper(this).Handle; + if (handle != IntPtr.Zero && HwndSource.FromHwnd(handle) is HwndSource hwndSource) + { + _windowHwndSource = hwndSource; + hwndSource.AddHook(WndProc); + } + } + + private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) + { + const int WM_NCHITTEST = 0x0084; + const int WM_DPICHANGED = 0x02E0; + + // Short-circuit WM_NCHITTEST with HTCLIENT. Verified to stop net462 + // WindowChromeWorker._HandleNCHitTest from overflowing during cross-DPI dragging (the + // overflow is in WPF's own DPI math, so no condition on the window rect can reliably + // detect it). Dragging still works because the content panels use WM_NCLBUTTONDOWN(HT + // CAPTION) directly rather than relying on a hit test result. + if (msg == WM_NCHITTEST) + { + handled = true; + return new IntPtr(1); // HTCLIENT + } + + if (msg == WM_DPICHANGED && lParam != IntPtr.Zero) + { + var suggested = Marshal.PtrToStructure(lParam); + var width = Math.Max(suggested.Right - suggested.Left, 1); + var height = Math.Max(suggested.Bottom - suggested.Top, 1); + + QuickLook.Common.NativeMethods.User32.MoveWindow(hwnd, suggested.Left, suggested.Top, width, height, true); + + handled = true; + return IntPtr.Zero; + } + + return IntPtr.Zero; } protected override void OnContentRendered(EventArgs e) @@ -464,6 +514,34 @@ private void ShowWindowCaptionContainer(object sender, MouseEventArgs e) show.Begin(); } + private void TitleArea_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (e.LeftButton != MouseButtonState.Pressed) + return; + + // Do not allow dragging when window is borderless (e.g. fullscreen) + if (WindowStyle == WindowStyle.None) + return; + + // Start the native move loop directly. Window.DragMove() depends on a hit-test result, but + // we answer WM_NCHITTEST with HTCLIENT (to avoid a WindowChrome overflow), so drag by hand. + var hwnd = new WindowInteropHelper(this).Handle; + if (hwnd == IntPtr.Zero) + return; + + ReleaseCapture(); + SendMessage(hwnd, WM_NCLBUTTONDOWN, new IntPtr(HTCAPTION), IntPtr.Zero); + } + + private const int WM_NCLBUTTONDOWN = 0x00A1; + private const int HTCAPTION = 0x0002; + + [DllImport("user32.dll")] + private static extern bool ReleaseCapture(); + + [DllImport("user32.dll", CharSet = CharSet.Auto)] + private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam); + private void AutoHideCaptionContainer(object sender, EventArgs e) { if (!ContextObject.TitlebarAutoHide) From 1b9d2b5463c89acb6cebcc29046a645fe791d5a6 Mon Sep 17 00:00:00 2001 From: bobo198504 <32607316+bobo198504@users.noreply.github.com> Date: Sun, 6 Sep 2026 23:23:58 +0800 Subject: [PATCH 2/2] Preserve border/corner resizing by reporting resize hit-test zones in WM_NCHITTEST Sourcery correctly flagged that returning HTCLIENT for every WM_NCHITTEST disabled the resize borders while ResizeMode=CanResize. Report HTLEFT/HTRIGHT/ HTTOP/HTBOTTOM and the corner zones at the window edges, and HTCLIENT elsewhere, still bypassing the WindowChrome hit-test that overflows during cross-DPI drags. --- QuickLook/ViewerWindow.xaml.cs | 37 ++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/QuickLook/ViewerWindow.xaml.cs b/QuickLook/ViewerWindow.xaml.cs index b79dcba9d..4b20a6698 100644 --- a/QuickLook/ViewerWindow.xaml.cs +++ b/QuickLook/ViewerWindow.xaml.cs @@ -214,15 +214,40 @@ private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref b const int WM_NCHITTEST = 0x0084; const int WM_DPICHANGED = 0x02E0; - // Short-circuit WM_NCHITTEST with HTCLIENT. Verified to stop net462 - // WindowChromeWorker._HandleNCHitTest from overflowing during cross-DPI dragging (the - // overflow is in WPF's own DPI math, so no condition on the window rect can reliably - // detect it). Dragging still works because the content panels use WM_NCLBUTTONDOWN(HT - // CAPTION) directly rather than relying on a hit test result. + // Implement the hit test ourselves instead of deferring to WindowChromeWorker. + // WindowChromeWorker._HandleNCHitTest (net462) overflows during cross-DPI dragging (its + // per-window DPI math goes out of sync with the HWND rect and can't be corrected from the + // outside). Reporting the resize border zones here keeps edge/corner resizing working while + // everything else is treated as client area. Title-bar dragging is handled by + // TitleArea_MouseLeftButtonDown, and caption buttons are WPF content, so neither needs a + // non-client hit-test result. if (msg == WM_NCHITTEST) { + // Mouse position (screen, physical pixels) is packed into lParam as signed 16-bit pairs. + int v = lParam.ToInt32(); + int mx = (short)(v & 0xFFFF); + int my = (short)((v >> 16) & 0xFFFF); + + QuickLook.Common.NativeMethods.User32.GetWindowRect(hwnd, out var r); + + // Resize border zone (physical pixels). The WindowChrome resize border is 6 logical + // pixels; a fixed 8 physical-pixel zone covers it across common DPI scale factors. + const int border = 8; + bool left = mx < r.Left + border; + bool right = mx >= r.Right - border; + bool top = my < r.Top + border; + bool bottom = my >= r.Bottom - border; + handled = true; - return new IntPtr(1); // HTCLIENT + if (top && left) return new IntPtr(13); // HTTOPLEFT + if (top && right) return new IntPtr(14); // HTTOPRIGHT + if (bottom && left) return new IntPtr(16); // HTBOTTOMLEFT + if (bottom && right) return new IntPtr(17); // HTBOTTOMRIGHT + if (left) return new IntPtr(10); // HTLEFT + if (right) return new IntPtr(11); // HTRIGHT + if (top) return new IntPtr(12); // HTTOP + if (bottom) return new IntPtr(15); // HTBOTTOM + return new IntPtr(1); // HTCLIENT } if (msg == WM_DPICHANGED && lParam != IntPtr.Zero)