Search code examples
c#winapi.net-8.0avaloniauiavalonia

Global keyboard shortcut with windows key on Windows with .NET 8


I am writing an app switcher inspired by Contexts kind of for fun, and got stuck on reacting to a global keyboard shortcut. I am using .NET 8 + Avalonia.

What I got so far:

using System;
using System.Diagnostics;
using System.Reactive;
using System.Reactive.Linq;
using System.Reactive.Subjects;
using System.Runtime.InteropServices;

namespace WindowSwitcher.Services.Keyboard;

public class KeyboardInterceptor2 : IKeyboardInterceptor
{
    private const int WhKeyboardLl = 13;
    private const int WmKeydown = 0x0100;
    private const int WmSyskeydown = 0x0104;
    private const int WmKeyup = 0x0101;
    private const int WmSyskeyup = 0x0105;

    private readonly Subject<Unit> _signalSubject = new();
    private readonly IntPtr _hookId;
    private bool _consumeNextWinKeyUp;

    public IObservable<Unit> Signal => _signalSubject.AsObservable();

    public KeyboardInterceptor2()
    {
        _hookId = SetHook(HookCallback);
    }

    private IntPtr SetHook(LowLevelKeyboardProc proc)
    {
        using var curProcess = Process.GetCurrentProcess();
        using var curModule = curProcess.MainModule!;

        return SetWindowsHookEx(WhKeyboardLl, proc, GetModuleHandle(curModule.ModuleName), 0);
    }

    private IntPtr HookCallback(int nCode, IntPtr wParam, IntPtr lParam)
    {
        if (nCode >= 0)
        {
            int vkCode = Marshal.ReadInt32(lParam);

            if (wParam == WmKeydown || wParam == WmSyskeydown)
            {
                if (vkCode == (int)VirtualKeyStates.VkS)
                {
                    if ((GetAsyncKeyState(VirtualKeyStates.VkLwin) & 0x8000) != 0 ||
                        (GetAsyncKeyState(VirtualKeyStates.VkRwin) & 0x8000) != 0)
                    {
                        _signalSubject.OnNext(Unit.Default);
                        _consumeNextWinKeyUp = true;
                        return 1; // Consume the S key press when Windows key is pressed
                    }
                }
            }
            else if (wParam == WmKeyup || wParam == WmSyskeyup)
            {
                if ((vkCode == (int)VirtualKeyStates.VkLwin || vkCode == (int)VirtualKeyStates.VkRwin) && _consumeNextWinKeyUp)
                {
                    _consumeNextWinKeyUp = false;
                    return 1; // Consume the Windows key up event
                }
            }
        }

        return CallNextHookEx(_hookId, nCode, wParam, lParam);
    }

    public void Dispose()
    {
        UnhookWindowsHookEx(_hookId);
        _signalSubject.Dispose();
    }

    private delegate IntPtr LowLevelKeyboardProc(int nCode, IntPtr wParam, IntPtr lParam);

    [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
    private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelKeyboardProc lpfn, IntPtr hMod, uint dwThreadId);

    [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static extern bool UnhookWindowsHookEx(IntPtr hhk);

    [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
    private static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam);

    [DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
    private static extern IntPtr GetModuleHandle(string lpModuleName);

    [DllImport("user32.dll")]
    private static extern short GetAsyncKeyState(VirtualKeyStates nVirtKey);

    private enum VirtualKeyStates
    {
        VkLwin = 0x5B,
        VkRwin = 0x5C,
        VkS = 0x53
    }
}

Basically, all I want is to press Windows + S to activate my program, and it works, just not every time, and most of the time menu start would pop up. To me this looks fine, but maybe there is some weird synchronization problem?

I have an idea to try with just two observables that will be updated separately (one per each key), and if they are both set, I will trigger the signal.

I tried also GlobalHotKey, but with .NET 8 the Key class is not available - I even set TargetFramework to net8.0-windows, but it didn't do anything.

What is the best approach to this? Is there something built into Avalonia? InputManager got marked as internal, so that's out of the picture.


Solution

  • With the help of Claude I was able to come up with the following:

    using System;
    using System.ComponentModel;
    using System.Diagnostics.CodeAnalysis;
    using System.Reactive;
    using System.Reactive.Linq;
    using System.Reactive.Subjects;
    using System.Runtime.InteropServices;
    
    namespace WindowSwitcher.Services.Keyboard
    {
        public class HotKeyInterceptor : IKeyboardInterceptor
        {
            private const int WmHotkey = 0x0312;
            const int ModControl = 0x0002;
            const int ModWin = 0x0008;
            const int VkTab = 0x09;
    
            private readonly Subject<Unit> _signalSubject = new Subject<Unit>();
            private readonly int _hotkeyId = 213769420;
            private readonly MessageWindow _messageWindow;
    
            public IObservable<Unit> Signal => _signalSubject.AsObservable();
    
            [RequiresAssemblyFiles()]
            public HotKeyInterceptor()
            {
                _messageWindow = new MessageWindow();
                _messageWindow.HotKeyPressed += OnHotKeyPressed;
    
                UnregisterHotKey(_messageWindow.Handle, _hotkeyId);
                
                if (!RegisterHotKey(_messageWindow.Handle, _hotkeyId, ModWin | ModControl, VkTab))
                {
                    int error = Marshal.GetLastWin32Error();
                    string errorMessage = GetErrorMessage(error);
                    throw new Win32Exception(error, $"Could not register the hot key. {errorMessage}");
                }
            }
    
            private void OnHotKeyPressed(object? sender, EventArgs e)
            {
                _signalSubject.OnNext(Unit.Default);
            }
    
            public void Dispose()
            {
                UnregisterHotKey(_messageWindow.Handle, _hotkeyId);
                _messageWindow.Dispose();
                _signalSubject.Dispose();
            }
            
            
            private string GetErrorMessage(int errorCode)
            {
                switch (errorCode)
                {
                    case 1409:
                        return "The hotkey is already registered by another application.";
                    case 1400:
                        return "The window handle is not valid.";
                    case 87:
                        return "An invalid parameter was passed to the function.";
                    default:
                        return $"Unknown error occurred. Error code: {errorCode}";
                }
            }
    
            [DllImport("user32.dll", SetLastError = true)]
            private static extern bool RegisterHotKey(IntPtr hWnd, int id, int fsModifiers, int vk);
    
            [DllImport("user32.dll")]
            private static extern bool UnregisterHotKey(IntPtr hWnd, int id);
    
            private class MessageWindow : IDisposable
            {
                private const int WsExToolwindow = 0x80;
                private const int WsPopup = unchecked((int)0x80000000);
    
                public event EventHandler? HotKeyPressed;
    
                private readonly IntPtr _hwnd;
    
                public IntPtr Handle => _hwnd;
    
                [RequiresAssemblyFiles("Calls System.Runtime.InteropServices.Marshal.GetHINSTANCE(Module)")]
                public MessageWindow()
                {
                    var wndClass = new WindowClass
                    {
                        lpfnWndProc = Marshal.GetFunctionPointerForDelegate(WndProc),
                        hInstance = Marshal.GetHINSTANCE(typeof(MessageWindow).Module),
                        lpszClassName = "MessageWindowClass"
                    };
    
                    var classAtom = RegisterClass(ref wndClass);
                    if (classAtom == 0)
                        throw new InvalidOperationException("Failed to register window class");
    
                    _hwnd = CreateWindowEx(
                        WsExToolwindow,
                        classAtom,
                        "MessageWindow",
                        WsPopup,
                        0, 0, 0, 0,
                        IntPtr.Zero,
                        IntPtr.Zero,
                        wndClass.hInstance,
                        IntPtr.Zero);
    
                    if (_hwnd == IntPtr.Zero)
                        throw new InvalidOperationException("Failed to create message window");
                }
    
                private IntPtr WndProc(IntPtr hwnd, uint msg, IntPtr wParam, IntPtr lParam)
                {
                    if (msg == WmHotkey)
                    {
                        HotKeyPressed?.Invoke(this, EventArgs.Empty);
                        return IntPtr.Zero;
                    }
                    return DefWindowProc(hwnd, msg, wParam, lParam);
                }
    
                public void Dispose()
                {
                    if (_hwnd != IntPtr.Zero)
                    {
                        DestroyWindow(_hwnd);
                    }
                }
    
                [DllImport("user32.dll")]
                private static extern ushort RegisterClass(ref WindowClass lpWndClass);
    
                [DllImport("user32.dll")]
                private static extern IntPtr CreateWindowEx(
                    int dwExStyle,
                    ushort classAtom,
                    string lpWindowName,
                    int dwStyle,
                    int x, int y,
                    int nWidth, int nHeight,
                    IntPtr hWndParent,
                    IntPtr hMenu,
                    IntPtr hInstance,
                    IntPtr lpParam);
    
                [DllImport("user32.dll")]
                private static extern IntPtr DefWindowProc(IntPtr hWnd, uint uMsg, IntPtr wParam, IntPtr lParam);
    
                [DllImport("user32.dll")]
                [return: MarshalAs(UnmanagedType.Bool)]
                private static extern bool DestroyWindow(IntPtr hwnd);
    
                [StructLayout(LayoutKind.Sequential)]
                private struct WindowClass
                {
                    public int style;
                    public IntPtr lpfnWndProc;
                    public int cbClsExtra;
                    public int cbWndExtra;
                    public IntPtr hInstance;
                    public IntPtr hIcon;
                    public IntPtr hCursor;
                    public IntPtr hbrBackground;
                    [MarshalAs(UnmanagedType.LPStr)]
                    public string lpszMenuName;
                    [MarshalAs(UnmanagedType.LPStr)]
                    public string lpszClassName;
                }
            }
        }
    }
    

    Works every time I press the shortcut, so I guess it is solved. I also had to settle on ctrl+win+tab as a shortcut, since Win+S just opens the start menu.