210 lines
5.8 KiB
C#
210 lines
5.8 KiB
C#
using System.Reflection;
|
|
using System.Runtime.InteropServices;
|
|
using CursorLang.Core.Interop;
|
|
using CursorLang.Tests.Shared;
|
|
|
|
namespace CursorLang.Core.Tests.Interop;
|
|
|
|
/// <summary>
|
|
/// Making sense of the events of the system keyboard hook.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
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<int> 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<int> 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<int> 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<int> 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);
|
|
}
|
|
}
|
|
}
|