Compare commits

...

11 Commits

Author SHA1 Message Date
Muyuan Li (from Dev Box)
6786fcaa84 Use Logger.LogError exception overload for full diagnostics
Preserves stack trace and inner exceptions in hook error logging.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-08 15:54:33 +08:00
Muyuan Li (from Dev Box)
f8b0b40ebf Add robustness: try/catch in hook, dialog guard, navigation check
- Wrap hook callback in try/catch to prevent exceptions from escaping
  into the Win32 hook chain (which could crash the process)
- Guard against null lParam before marshaling
- Pass keys through when delete confirmation dialog is active
- Only intercept arrow keys when multiple items are available for
  navigation (single item or CLI mode lets arrows reach the preview)
- Re-check _isDeleteInProgress inside close lambdas to handle race
  between enqueue and execution
- Clear _keyboardHookProc on install failure for consistent state

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-08 15:54:33 +08:00
Muyuan Li (from Dev Box)
7a7d1f0d40 Pass hook handle to CallNextHookEx for consistency with repo patterns
Use _keyboardHookHandle instead of IntPtr.Zero in all CallNextHookEx
calls to match other hook implementations in the repo (e.g.,
ColorPickerUI MouseHook).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-08 15:54:33 +08:00
Muyuan Li (from Dev Box)
e44eb8a61f Guard against Win key modifier to preserve Win+Arrow snap behavior
Add VK_LWIN/VK_RWIN check so Win+Arrow (window snapping), Win+Ctrl+W,
and other Win-key combos pass through to the OS instead of being
intercepted by the hook.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-08 15:54:33 +08:00
Muyuan Li (from Dev Box)
3c4b93189b Move foreground check before marshal, use TryEnqueue return value
- Check foreground window before marshaling KBDLLHOOKSTRUCT to avoid
  per-keystroke overhead when Peek is not active
- Only swallow the key if TryEnqueue succeeds, so if the dispatcher is
  shutting down the key passes through instead of being lost

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-08 15:54:33 +08:00
Muyuan Li (from Dev Box)
381c254d13 Replace magic numbers with named VK constants for modifier keys
Define VK_CONTROL, VK_ALT, VK_SHIFT, and KEY_PRESSED_MASK as named
constants for readability and maintainability.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-08 15:54:32 +08:00
Muyuan Li (from Dev Box)
5883f8b665 Add fast-path vkCode filter and fix nullable warning in hook setup
- Early-return for keys we never handle, avoiding GetAsyncKeyState and
  GetForegroundWindow calls on every system keypress
- Remove 'using' on ProcessModule (unnecessary disposal) and add null-
  forgiving operator on _keyboardHookProc to suppress nullable warning

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-08 15:54:32 +08:00
Muyuan Li (from Dev Box)
a956a525c7 Fix duplicate KeyUp handler and cache window handle in hook callback
- Remove/re-add Content_KeyUp to prevent accumulating duplicate handlers
  when Initialize() is called multiple times
- Cache the HWND once during Initialize() and reuse it in the hook
  callback to avoid per-keypress allocations

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-08 15:54:32 +08:00
Muyuan Li (from Dev Box)
6a790f2177 Also check Shift modifier to match XAML KeyboardAccelerator definitions
The XAML accelerators are defined without Shift, so the hook should not
intercept Shift+key combos (e.g., Shift+Arrow for text selection in
Monaco, Shift+Escape, Ctrl+Shift+W).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-08 15:54:32 +08:00
Muyuan Li (from Dev Box)
eb6bf09024 Address PR review: log Win32 errors, validate unhook, use null handle for CallNextHookEx, fix comment
- Log Marshal.GetLastWin32Error() when SetWindowsHookEx fails
- Check UnhookWindowsHookEx return value before clearing handle
- Pass IntPtr.Zero to CallNextHookEx (standard for low-level hooks)
- Reword BrowserControl comment to clarify defense-in-depth role

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-08 15:54:31 +08:00
Muyuan Li (from Dev Box)
08713f3a12 Fix Peek Ctrl+W shortcut not working after clicking preview
When the user clicks inside a file preview in Peek (PDF, text/code files,
markdown, HTML), the WebView2 control or shell preview handler captures
keyboard focus. Since these controls handle input in their own process/
message loop, the Ctrl+W KeyboardAccelerator defined on the parent XAML
Grid never fires, making it impossible to close the Peek window with the
keyboard shortcut.

This fix adds a low-level keyboard hook (WH_KEYBOARD_LL) that intercepts
Ctrl+W, Escape, and arrow navigation keys at the OS level when the Peek
window is in the foreground. This works regardless of which child control
has focus (WebView2, native shell preview handlers, etc.).

Additionally, browser-level accelerator keys are disabled on the WebView2
control (AreBrowserAcceleratorKeysEnabled = false) as defense-in-depth,
preventing Chromium from consuming shortcuts like Ctrl+W internally.

Fixes #48274

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-08 15:54:31 +08:00
3 changed files with 205 additions and 0 deletions

View File

@@ -199,6 +199,12 @@ namespace Peek.FilePreviewer.Controls
PreviewBrowser.CoreWebView2.Settings.IsScriptEnabled = IsDevFilePreview;
PreviewBrowser.CoreWebView2.Settings.IsWebMessageEnabled = false;
// Disable browser-level accelerator keys (Ctrl+W, Ctrl+T, F5, etc.) as a
// defense-in-depth measure. This alone does not guarantee XAML KeyboardAccelerators
// work when WebView2 has focus (a low-level keyboard hook in MainWindow handles
// that), but it prevents WebView2 from consuming shortcuts like Ctrl+W internally.
PreviewBrowser.CoreWebView2.Settings.AreBrowserAcceleratorKeysEnabled = false;
if (IsDevFilePreview)
{
PreviewBrowser.CoreWebView2.SetVirtualHostNameToFolderMapping(Microsoft.PowerToys.FilePreviewCommon.MonacoHelper.VirtualHostName, Microsoft.PowerToys.FilePreviewCommon.MonacoHelper.MonacoDirectory, CoreWebView2HostResourceAccessKind.Allow);

View File

@@ -131,5 +131,36 @@ namespace Peek.UI.Native
/// contain string paths.
/// </summary>
internal const uint SHCNF_PATH = 0x0001;
// Low-level keyboard hook support
internal const int WH_KEYBOARD_LL = 13;
internal delegate IntPtr LowLevelKeyboardProc(int nCode, IntPtr wParam, IntPtr lParam);
[StructLayout(LayoutKind.Sequential)]
internal struct KBDLLHOOKSTRUCT
{
public uint vkCode;
public uint scanCode;
public uint flags;
public uint time;
public IntPtr dwExtraInfo;
}
[DllImport("user32.dll", SetLastError = true)]
internal static extern IntPtr SetWindowsHookEx(int idHook, LowLevelKeyboardProc lpfn, IntPtr hMod, uint dwThreadId);
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static extern bool UnhookWindowsHookEx(IntPtr hhk);
[DllImport("user32.dll")]
internal static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam);
[DllImport("user32.dll")]
internal static extern short GetAsyncKeyState(int vKey);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
internal static extern IntPtr GetModuleHandle(string? lpModuleName);
}
}

View File

@@ -3,6 +3,7 @@
// See the LICENSE file in the project root for more information.
using System;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using ManagedCommon;
@@ -20,6 +21,7 @@ using Peek.FilePreviewer.Previewers;
using Peek.UI.Extensions;
using Peek.UI.Helpers;
using Peek.UI.Models;
using Peek.UI.Native;
using Peek.UI.Telemetry.Events;
using Windows.Foundation;
using WinUIEx;
@@ -42,6 +44,10 @@ namespace Peek.UI
private bool _isDeleteInProgress;
private bool _exitAfterClose;
private IntPtr _keyboardHookHandle;
private NativeMethods.LowLevelKeyboardProc? _keyboardHookProc;
private Windows.Win32.Foundation.HWND _cachedWindowHandle;
public MainWindow()
{
InitializeComponent();
@@ -207,8 +213,12 @@ namespace Peek.UI
}
ViewModel.ScalingFactor = this.GetMonitorScale();
this.Content.KeyUp -= Content_KeyUp;
this.Content.KeyUp += Content_KeyUp;
_cachedWindowHandle = new Windows.Win32.Foundation.HWND(this.GetWindowHandle());
InstallKeyboardHook();
bootTime.Stop();
PowerToysTelemetry.Log.WriteEvent(new OpenedEvent() { FileExtension = ViewModel.CurrentItem?.Extension ?? string.Empty, HotKeyToVisibleTimeMs = bootTime.ElapsedMilliseconds });
@@ -216,6 +226,8 @@ namespace Peek.UI
private void Uninitialize()
{
UninstallKeyboardHook();
this.Restore();
this.Hide();
@@ -309,8 +321,164 @@ namespace Peek.UI
return false;
}
private void InstallKeyboardHook()
{
if (_keyboardHookHandle != IntPtr.Zero)
{
return;
}
_keyboardHookProc = LowLevelKeyboardHookCallback;
using var process = System.Diagnostics.Process.GetCurrentProcess();
var moduleHandle = NativeMethods.GetModuleHandle(process.MainModule?.ModuleName);
_keyboardHookHandle = NativeMethods.SetWindowsHookEx(
NativeMethods.WH_KEYBOARD_LL,
_keyboardHookProc!,
moduleHandle,
0);
if (_keyboardHookHandle == IntPtr.Zero)
{
var error = Marshal.GetLastWin32Error();
_keyboardHookProc = null;
Logger.LogError($"Failed to install keyboard hook for Peek window. Win32 error: {error}");
}
}
private void UninstallKeyboardHook()
{
if (_keyboardHookHandle != IntPtr.Zero)
{
if (NativeMethods.UnhookWindowsHookEx(_keyboardHookHandle))
{
_keyboardHookHandle = IntPtr.Zero;
_keyboardHookProc = null;
}
else
{
var error = Marshal.GetLastWin32Error();
Logger.LogError($"Failed to uninstall keyboard hook for Peek window. Win32 error: {error}");
}
}
}
private IntPtr LowLevelKeyboardHookCallback(int nCode, IntPtr wParam, IntPtr lParam)
{
const int WM_KEYDOWN = 0x0100;
const uint VK_W = 0x57;
const uint VK_ESCAPE = 0x1B;
const uint VK_LEFT = 0x25;
const uint VK_UP = 0x26;
const uint VK_RIGHT = 0x27;
const uint VK_DOWN = 0x28;
const int VK_CONTROL = 0x11;
const int VK_ALT = 0x12;
const int VK_SHIFT = 0x10;
const int VK_LWIN = 0x5B;
const int VK_RWIN = 0x5C;
const int KEY_PRESSED_MASK = 0x8000;
try
{
if (nCode >= 0 && wParam == (IntPtr)WM_KEYDOWN && lParam != IntPtr.Zero)
{
// Only handle when our window is in the foreground (cheap check before marshaling)
var foreground = Windows.Win32.PInvoke_PeekUI.GetForegroundWindow();
if (foreground != _cachedWindowHandle)
{
return NativeMethods.CallNextHookEx(_keyboardHookHandle, nCode, wParam, lParam);
}
var hookStruct = Marshal.PtrToStructure<NativeMethods.KBDLLHOOKSTRUCT>(lParam);
// Fast-path: skip keys we never handle
if (hookStruct.vkCode != VK_W && hookStruct.vkCode != VK_ESCAPE &&
hookStruct.vkCode != VK_LEFT && hookStruct.vkCode != VK_RIGHT &&
hookStruct.vkCode != VK_UP && hookStruct.vkCode != VK_DOWN)
{
return NativeMethods.CallNextHookEx(_keyboardHookHandle, nCode, wParam, lParam);
}
bool ctrlPressed = (NativeMethods.GetAsyncKeyState(VK_CONTROL) & KEY_PRESSED_MASK) != 0;
bool altPressed = (NativeMethods.GetAsyncKeyState(VK_ALT) & KEY_PRESSED_MASK) != 0;
bool shiftPressed = (NativeMethods.GetAsyncKeyState(VK_SHIFT) & KEY_PRESSED_MASK) != 0;
bool winPressed = (NativeMethods.GetAsyncKeyState(VK_LWIN) & KEY_PRESSED_MASK) != 0 ||
(NativeMethods.GetAsyncKeyState(VK_RWIN) & KEY_PRESSED_MASK) != 0;
bool handled = false;
// Pass keys through while delete confirmation dialog is showing
if (_isDeleteInProgress)
{
return NativeMethods.CallNextHookEx(_keyboardHookHandle, nCode, wParam, lParam);
}
if (ctrlPressed && !altPressed && !shiftPressed && !winPressed && hookStruct.vkCode == VK_W)
{
handled = DispatcherQueue.TryEnqueue(() =>
{
if (!_isDeleteInProgress)
{
Uninitialize();
}
});
}
else if (!ctrlPressed && !altPressed && !shiftPressed && !winPressed && hookStruct.vkCode == VK_ESCAPE)
{
handled = DispatcherQueue.TryEnqueue(() =>
{
if (!_isDeleteInProgress)
{
Uninitialize();
}
});
}
else if (!ctrlPressed && !altPressed && !shiftPressed && !winPressed && hookStruct.vkCode == VK_LEFT)
{
if (ViewModel.DisplayItemCount > 1)
{
handled = DispatcherQueue.TryEnqueue(() => ViewModel.AttemptPreviousNavigation());
}
}
else if (!ctrlPressed && !altPressed && !shiftPressed && !winPressed && hookStruct.vkCode == VK_RIGHT)
{
if (ViewModel.DisplayItemCount > 1)
{
handled = DispatcherQueue.TryEnqueue(() => ViewModel.AttemptNextNavigation());
}
}
else if (!ctrlPressed && !altPressed && !shiftPressed && !winPressed && hookStruct.vkCode == VK_UP)
{
if (ViewModel.DisplayItemCount > 1)
{
handled = DispatcherQueue.TryEnqueue(() => ViewModel.AttemptPreviousNavigation());
}
}
else if (!ctrlPressed && !altPressed && !shiftPressed && !winPressed && hookStruct.vkCode == VK_DOWN)
{
if (ViewModel.DisplayItemCount > 1)
{
handled = DispatcherQueue.TryEnqueue(() => ViewModel.AttemptNextNavigation());
}
}
if (handled)
{
return (IntPtr)1;
}
}
}
catch (Exception ex)
{
Logger.LogError("Unhandled exception in Peek keyboard hook.", ex);
}
return NativeMethods.CallNextHookEx(_keyboardHookHandle, nCode, wParam, lParam);
}
public void Dispose()
{
UninstallKeyboardHook();
themeListener?.Dispose();
}