using System.Reflection; using System.Runtime.InteropServices; using CursorLang.Core.Interop; using CursorLang.Tests.Shared; namespace CursorLang.Core.Tests.Interop; /// /// Making sense of the events of the system keyboard hook. /// /// /// The events are fed straight into the handler the way Windows sends them: /// the tests have no right to press keys for real — the interception is shared /// by the whole system, and a real press would land in someone else's window. /// public sealed class LowLevelKeyboardHookTests { private const int HcAction = 0; private const int WmKeyDown = 0x0100; private const int WmKeyUp = 0x0101; private const int WmSysKeyDown = 0x0104; private const int WmSysKeyUp = 0x0105; private const int WmMouseMove = 0x0200; private const uint Injected = 0x10; private const int CapsLock = 0x14; [Theory] [InlineData(WmKeyDown, true)] [InlineData(WmSysKeyDown, true)] [InlineData(WmKeyUp, false)] [InlineData(WmSysKeyUp, false)] public void Presses_and_releases_reach_the_handler(int message, bool expectedKeyDown) { List<(int Key, bool IsDown)> events = []; var hook = new LowLevelKeyboardHook((key, isDown) => { events.Add((key, isDown)); return false; }); using (hook) { Send(hook, HcAction, message, CapsLock, flags: 0); } Assert.Equal([(CapsLock, expectedKeyDown)], events); } [Fact] public void A_swallowed_event_goes_no_further() { using var hook = new LowLevelKeyboardHook(static (_, _) => true); IntPtr result = Send(hook, HcAction, WmKeyDown, CapsLock, flags: 0); // A non-zero answer breaks the chain: neither the application nor the // case handler in Windows will see the event Assert.Equal(new IntPtr(1), result); } // Synthetic input comes from on-screen keyboards and automation tools [Fact] public void Synthetic_input_is_not_intercepted() { List keys = []; using var hook = new LowLevelKeyboardHook((key, _) => { keys.Add(key); return true; }); IntPtr result = Send(hook, HcAction, WmKeyDown, CapsLock, Injected); Assert.Empty(keys); Assert.NotEqual(new IntPtr(1), result); } // Windows asks for events below zero not to be inspected but simply passed on [Fact] public void Events_not_meant_for_inspection_are_passed_on() { List keys = []; using var hook = new LowLevelKeyboardHook((key, _) => { keys.Add(key); return true; }); Send(hook, code: -1, WmKeyDown, CapsLock, flags: 0); Assert.Empty(keys); } [Fact] public void The_other_messages_are_not_shown_to_the_handler() { List keys = []; using var hook = new LowLevelKeyboardHook((key, _) => { keys.Add(key); return true; }); Send(hook, HcAction, WmMouseMove, CapsLock, flags: 0); Assert.Empty(keys); } [Fact] public void The_handler_sees_the_code_of_the_pressed_key() { List keys = []; using var hook = new LowLevelKeyboardHook((key, _) => { keys.Add(key); return false; }); Send(hook, HcAction, WmKeyDown, virtualKey: 0x41, flags: 0); Send(hook, HcAction, WmKeyDown, virtualKey: 0x1B, flags: 0); Assert.Equal([0x41, 0x1B], keys); } [Fact] public void The_interception_is_installed_and_removed() { Pump.Run(() => { using var hook = new LowLevelKeyboardHook(static (_, _) => false); Assert.False(hook.IsInstalled); Assert.True(hook.Install()); Assert.True(hook.IsInstalled); hook.Uninstall(); Assert.False(hook.IsInstalled); }); } [Fact] public void Installing_again_changes_nothing() { Pump.Run(() => { using var hook = new LowLevelKeyboardHook(static (_, _) => false); Assert.True(hook.Install()); Assert.True(hook.Install()); Assert.True(hook.IsInstalled); hook.Uninstall(); }); } [Fact] public void Removing_without_installing_passes_silently() { var hook = new LowLevelKeyboardHook(static (_, _) => false); hook.Uninstall(); hook.Uninstall(); Assert.False(hook.IsInstalled); } [Fact] public void Closing_removes_the_interception() { Pump.Run(() => { var hook = new LowLevelKeyboardHook(static (_, _) => false); hook.Install(); hook.Dispose(); Assert.False(hook.IsInstalled); }); } // The event arrives from Windows as a structure in unmanaged memory private static IntPtr Send( LowLevelKeyboardHook hook, int code, int message, int virtualKey, uint flags) { // vkCode, scanCode, flags and time take four bytes each, then a pointer const int Size = 24; IntPtr data = Marshal.AllocHGlobal(Size); try { Marshal.WriteInt32(data, 0, virtualKey); Marshal.WriteInt32(data, 4, 0); Marshal.WriteInt32(data, 8, (int)flags); Marshal.WriteInt32(data, 12, 0); Marshal.WriteIntPtr(data, 16, IntPtr.Zero); MethodInfo handler = typeof(LowLevelKeyboardHook) .GetMethod("OnHookEvent", BindingFlags.Instance | BindingFlags.NonPublic)!; return (IntPtr)handler.Invoke(hook, [code, new IntPtr(message), data])!; } finally { Marshal.FreeHGlobal(data); } } }