diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index bc5ba04289..7e460dba2f 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -637,6 +637,7 @@ hmodule hmonitor homies homljgmgpmcbpjbnjpfijnhipfkiclkd +HOOKPROC HORZRES HORZSIZE Hostbackdropbrush diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/LocalKeyboardListener.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/LocalKeyboardListener.cs new file mode 100644 index 0000000000..c3dd7c28bf --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/LocalKeyboardListener.cs @@ -0,0 +1,157 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using ManagedCommon; + +using Windows.System; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.UI.WindowsAndMessaging; + +namespace Microsoft.CmdPal.UI.Helpers; + +/// +/// A class that listens for local keyboard events using a Windows hook. +/// +internal sealed class LocalKeyboardListener : IDisposable +{ + /// + /// Event that is raised when a key is pressed down. + /// + public event EventHandler? KeyPressed; + + private bool _disposed; + private UnhookWindowsHookExSafeHandle? _handle; + private HOOKPROC? _hookProc; // Keep reference to prevent GC collection + + /// + /// Registers a global keyboard hook to listen for key down events. + /// + /// + /// Throws if the hook could not be registered, which may happen if the system is unable to set the hook. + /// + public void RegisterKeyboardHook() + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (_handle is not null && !_handle.IsInvalid) + { + // Hook is already set + return; + } + + _hookProc = KeyEventHook; + if (!SetWindowKeyHook(_hookProc)) + { + throw new InvalidOperationException("Failed to register keyboard hook."); + } + } + + /// + /// Attempts to register a global keyboard hook to listen for key down events. + /// + /// + /// if the keyboard hook was successfully registered; otherwise, . + /// + public bool Start() + { + if (_disposed) + { + return false; + } + + try + { + RegisterKeyboardHook(); + return true; + } + catch (Exception ex) + { + Logger.LogError("Failed to register hook", ex); + return false; + } + } + + private void UnregisterKeyboardHook() + { + if (_handle is not null && !_handle.IsInvalid) + { + // The SafeHandle should automatically call UnhookWindowsHookEx when disposed + _handle.Dispose(); + _handle = null; + } + + _hookProc = null; + } + + private bool SetWindowKeyHook(HOOKPROC hookProc) + { + if (_handle is not null && !_handle.IsInvalid) + { + // Hook is already set + return false; + } + + _handle = PInvoke.SetWindowsHookEx( + WINDOWS_HOOK_ID.WH_KEYBOARD, + hookProc, + PInvoke.GetModuleHandle(null), + PInvoke.GetCurrentThreadId()); + + // Check if the hook was successfully set + return _handle is not null && !_handle.IsInvalid; + } + + private static bool IsKeyDownHook(LPARAM lParam) + { + // The 30th bit tells what the previous key state is with 0 being the "UP" state + // For more info see https://learn.microsoft.com/windows/win32/winmsg/keyboardproc#lparam-in + return ((lParam.Value >> 30) & 1) == 0; + } + + private LRESULT KeyEventHook(int nCode, WPARAM wParam, LPARAM lParam) + { + try + { + if (nCode >= 0 && IsKeyDownHook(lParam)) + { + InvokeKeyDown((VirtualKey)wParam.Value); + } + } + catch (Exception ex) + { + Logger.LogError("Failed when invoking key down keyboard hook event", ex); + } + + // Call next hook in chain - pass null as first parameter for current hook + return PInvoke.CallNextHookEx(null, nCode, wParam, lParam); + } + + private void InvokeKeyDown(VirtualKey virtualKey) + { + if (!_disposed) + { + KeyPressed?.Invoke(this, new LocalKeyboardListenerKeyPressedEventArgs(virtualKey)); + } + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + UnregisterKeyboardHook(); + } + + _disposed = true; + } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/LocalKeyboardListenerKeyPressedEventArgs.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/LocalKeyboardListenerKeyPressedEventArgs.cs new file mode 100644 index 0000000000..ce26cd037f --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/LocalKeyboardListenerKeyPressedEventArgs.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Windows.System; + +namespace Microsoft.CmdPal.UI.Helpers; + +public class LocalKeyboardListenerKeyPressedEventArgs(VirtualKey key) : EventArgs +{ + public VirtualKey Key { get; } = key; +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs index 012ec3ccf6..5311f4b8b4 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs @@ -27,6 +27,7 @@ using Microsoft.Windows.AppLifecycle; using Windows.ApplicationModel.Activation; using Windows.Foundation; using Windows.Graphics; +using Windows.System; using Windows.UI; using Windows.UI.WindowManagement; using Windows.Win32; @@ -44,7 +45,8 @@ public sealed partial class MainWindow : WindowEx, IRecipient, IRecipient, IRecipient, - IRecipient + IRecipient, + IDisposable { [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1310:Field names should not contain underscore", Justification = "Stylistically, window messages are WM_")] [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1306:Field names should begin with lower-case letter", Justification = "Stylistically, window messages are WM_")] @@ -54,6 +56,7 @@ public sealed partial class MainWindow : WindowEx, private readonly WNDPROC? _originalWndProc; private readonly List _hotkeys = []; private readonly KeyboardListener _keyboardListener; + private readonly LocalKeyboardListener _localKeyboardListener; private bool _ignoreHotKeyWhenFullScreen = true; private DesktopAcrylicController? _acrylicController; @@ -116,6 +119,18 @@ public sealed partial class MainWindow : WindowEx, { Summon(string.Empty); }); + + _localKeyboardListener = new LocalKeyboardListener(); + _localKeyboardListener.KeyPressed += LocalKeyboardListener_OnKeyPressed; + _localKeyboardListener.Start(); + } + + private static void LocalKeyboardListener_OnKeyPressed(object? sender, LocalKeyboardListenerKeyPressedEventArgs e) + { + if (e.Key == VirtualKey.GoBack) + { + WeakReferenceMessenger.Default.Send(new GoBackMessage()); + } } private void SettingsChangedHandler(SettingsModel sender, object? args) => HotReloadSettings(); @@ -376,6 +391,7 @@ public sealed partial class MainWindow : WindowEx, // WinUI bug is causing a crash on shutdown when FailFastOnErrors is set to true (#51773592). // Workaround by turning it off before shutdown. App.Current.DebugSettings.FailFastOnErrors = false; + _localKeyboardListener.Dispose(); DisposeAcrylic(); _keyboardListener.Stop(); @@ -682,4 +698,10 @@ public sealed partial class MainWindow : WindowEx, return PInvoke.CallWindowProc(_originalWndProc, hwnd, uMsg, wParam, lParam); } + + public void Dispose() + { + _localKeyboardListener.Dispose(); + DisposeAcrylic(); + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/NativeMethods.txt b/src/modules/cmdpal/Microsoft.CmdPal.UI/NativeMethods.txt index a432d9a808..aa31c5a3f2 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/NativeMethods.txt +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/NativeMethods.txt @@ -47,4 +47,10 @@ DWM_CLOAKED_APP CoWaitForMultipleObjects INFINITE -CWMO_FLAGS \ No newline at end of file +CWMO_FLAGS + +GetCurrentThreadId +SetWindowsHookEx +UnhookWindowsHookEx +CallNextHookEx +GetModuleHandle \ No newline at end of file