using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
using System.Windows.Forms;
using Microsoft.Win32;
namespace VRio.App;
///
/// A background keyboard input source built on the Win32 Raw Input API.
/// Where the WinForms focus path (ProcessCmdKey/OnKeyUp) only sees
/// keys while vRIO is the foreground window and can't tell one keyboard from
/// another, Raw Input carries a per-device handle with every keystroke and —
/// with the RIDEV_INPUTSINK flag — keeps delivering WM_INPUT even
/// when vRIO is in the background. That lets the user nominate one physical
/// keyboard as a dedicated cockpit panel: its keys drive the RIO controls while
/// the sim (or RIOJoy) holds focus, and other keyboards are ignored.
///
/// This is the input-side twin of : that
/// class writes a chosen keyboard's LEDs while the game has focus, this
/// one reads a chosen keyboard's keys under the same condition. Raw
/// Input observes without intercepting — the keystroke still reaches whatever
/// app has focus — so the selected keyboard should be one dedicated to the
/// panel, not the one you also type on.
///
/// Registration is scoped to when a device is actually selected
/// (): picking "all keyboards" unregisters and hands
/// input back to the focus path, so vRIO isn't pumping WM_INPUT for every
/// keystroke system-wide when the feature is idle. Not thread-safe;
/// and the events fire on the UI thread that owns the
/// window handle, which is exactly where wants
/// to be called.
///
public sealed class RawKeyboardSource : IDisposable
{
// --- Win32 Raw Input interop ---------------------------------------------
[StructLayout(LayoutKind.Sequential)]
private struct RAWINPUTDEVICE
{
public ushort usUsagePage;
public ushort usUsage;
public uint dwFlags;
public IntPtr hwndTarget;
}
[StructLayout(LayoutKind.Sequential)]
private struct RAWINPUTDEVICELIST
{
public IntPtr hDevice;
public uint dwType;
}
[StructLayout(LayoutKind.Sequential)]
private struct RAWINPUTHEADER
{
public uint dwType;
public uint dwSize;
public IntPtr hDevice;
public IntPtr wParam;
}
[StructLayout(LayoutKind.Sequential)]
private struct RAWKEYBOARD
{
public ushort MakeCode;
public ushort Flags;
public ushort Reserved;
public ushort VKey;
public uint Message;
public uint ExtraInformation;
}
[DllImport("user32.dll", SetLastError = true)]
private static extern bool RegisterRawInputDevices(
[In] RAWINPUTDEVICE[] pRawInputDevices, uint uiNumDevices, uint cbSize);
[DllImport("user32.dll", SetLastError = true)]
private static extern uint GetRawInputData(
IntPtr hRawInput, uint uiCommand, IntPtr pData, ref uint pcbSize, uint cbSizeHeader);
[DllImport("user32.dll", SetLastError = true)]
private static extern uint GetRawInputDeviceList(
[In, Out] RAWINPUTDEVICELIST[]? pRawInputDeviceList, ref uint puiNumDevices, uint cbSize);
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern uint GetRawInputDeviceInfoW(
IntPtr hDevice, uint uiCommand, IntPtr pData, ref uint pcbSize);
private const uint Err = 0xFFFFFFFF; // sentinel: all three APIs return (UINT)-1 on failure
private const uint RIDEV_REMOVE = 0x00000001;
private const uint RIDEV_INPUTSINK = 0x00000100;
private const uint RIDEV_DEVNOTIFY = 0x00002000; // WM_INPUT_DEVICE_CHANGE on hot-plug
private const uint RID_INPUT = 0x10000003;
private const uint RIDI_DEVICENAME = 0x20000007;
private const uint RIM_TYPEKEYBOARD = 1;
private const ushort RI_KEY_BREAK = 0x01; // set = key up (make/break)
private const ushort HidUsagePageGeneric = 0x01;
private const ushort HidUsageKeyboard = 0x06;
private static readonly int HeaderSize = Marshal.SizeOf();
// --- State ---------------------------------------------------------------
// hDevice → interface path, so the hot per-keystroke path skips the
// two-call GetRawInputDeviceInfo dance once a device is known.
private readonly Dictionary _pathByHandle = new();
private IntPtr _hwnd;
private string? _target; // interface path of the keyboard to capture; null = disabled
private bool _registered;
private bool _disposed;
/// A key on the captured keyboard went down (.NET name).
public event Action? KeyDown;
/// A key on the captured keyboard came up (.NET name).
public event Action? KeyUp;
/// The set of attached keyboards changed (interface path, display name) — for a picker.
public event Action>? KeyboardsChanged;
/// Status/diagnostic lines.
public event Action? Logged;
/// True while a specific keyboard is being captured (focus path should stand down).
public bool IsCapturing => _target is not null;
/// Bind to the window whose message loop delivers WM_INPUT.
public void Attach(IntPtr hwnd) => _hwnd = hwnd;
///
/// Capture one keyboard by interface path, or none (
/// null) to release input back to the focus path. Registering/unregistering
/// Raw Input is scoped here so vRIO only taps the global key stream while the
/// feature is actually in use.
///
public void SetTarget(string? deviceId)
{
if (_target == deviceId)
return;
_target = deviceId;
if (deviceId is null)
Unregister();
else
Register();
}
/// Re-enumerate keyboards and raise .
public void RefreshKeyboards()
{
_pathByHandle.Clear(); // handles are only stable per connection
KeyboardsChanged?.Invoke(EnumerateKeyboards());
}
/// Handle a WM_INPUT_DEVICE_CHANGE: a keyboard arrived or left.
public void HandleDeviceChange() => RefreshKeyboards();
///
/// Handle a WM_INPUT message (pass its LParam). Silently ignores
/// input from any keyboard other than the captured one.
///
public void ProcessInput(IntPtr hRawInput)
{
string? target = _target;
if (target is null)
return;
uint size = 0;
if (GetRawInputData(hRawInput, RID_INPUT, IntPtr.Zero, ref size, (uint)HeaderSize) == Err || size == 0)
return;
IntPtr buffer = Marshal.AllocHGlobal((int)size);
try
{
if (GetRawInputData(hRawInput, RID_INPUT, buffer, ref size, (uint)HeaderSize) != size)
return;
var header = Marshal.PtrToStructure(buffer);
if (header.dwType != RIM_TYPEKEYBOARD)
return;
string? path = PathFor(header.hDevice);
if (!string.Equals(path, target, StringComparison.OrdinalIgnoreCase))
return;
var kb = Marshal.PtrToStructure(IntPtr.Add(buffer, HeaderSize));
ushort vkey = kb.VKey;
// 0 = no VK, 0xFF = fake key emitted as part of an escaped sequence.
if (vkey is 0 or 0xFF)
return;
string key = ((Keys)vkey).ToString();
if ((kb.Flags & RI_KEY_BREAK) != 0)
KeyUp?.Invoke(key);
else
KeyDown?.Invoke(key); // repeats while held are dropped by the router
}
finally
{
Marshal.FreeHGlobal(buffer);
}
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_target = null;
Unregister();
KeyDown = null;
KeyUp = null;
KeyboardsChanged = null;
Logged = null;
}
// --- Registration --------------------------------------------------------
private void Register()
{
if (_registered || _hwnd == IntPtr.Zero)
return;
var rid = new[]
{
new RAWINPUTDEVICE
{
usUsagePage = HidUsagePageGeneric,
usUsage = HidUsageKeyboard,
dwFlags = RIDEV_INPUTSINK | RIDEV_DEVNOTIFY, // background delivery + hot-plug notices
hwndTarget = _hwnd,
},
};
if (RegisterRawInputDevices(rid, 1, (uint)Marshal.SizeOf()))
_registered = true;
else
Logged?.Invoke($"Raw keyboard capture unavailable (error {Marshal.GetLastWin32Error()})");
}
private void Unregister()
{
if (!_registered)
return;
// RIDEV_REMOVE must pass a null target window, or the call fails.
var rid = new[]
{
new RAWINPUTDEVICE
{
usUsagePage = HidUsagePageGeneric,
usUsage = HidUsageKeyboard,
dwFlags = RIDEV_REMOVE,
hwndTarget = IntPtr.Zero,
},
};
RegisterRawInputDevices(rid, 1, (uint)Marshal.SizeOf());
_registered = false;
}
// --- Enumeration / naming ------------------------------------------------
private List<(string Id, string Name)> EnumerateKeyboards()
{
var result = new List<(string, string)>();
uint listSize = (uint)Marshal.SizeOf();
uint count = 0;
if (GetRawInputDeviceList(null, ref count, listSize) == Err || count == 0)
return result;
var devices = new RAWINPUTDEVICELIST[count];
uint got = GetRawInputDeviceList(devices, ref count, listSize);
if (got == Err)
return result;
var labelCounts = new Dictionary();
for (int i = 0; i < got; i++)
{
if (devices[i].dwType != RIM_TYPEKEYBOARD)
continue;
string? path = PathFor(devices[i].hDevice);
if (string.IsNullOrEmpty(path))
continue;
string label = FriendlyName(path!);
// Physical keyboards can surface as more than one collection; a bare
// count suffix keeps otherwise-identical labels distinguishable.
if (labelCounts.TryGetValue(label, out int n))
{
labelCounts[label] = n + 1;
label = $"{label} ({n + 1})";
}
else
{
labelCounts[label] = 1;
}
result.Add((path!, label));
}
return result;
}
private string? PathFor(IntPtr hDevice)
{
if (_pathByHandle.TryGetValue(hDevice, out string? cached))
return cached;
string? path = DeviceName(hDevice);
if (path is not null)
_pathByHandle[hDevice] = path;
return path;
}
private static string? DeviceName(IntPtr hDevice)
{
uint chars = 0;
if (GetRawInputDeviceInfoW(hDevice, RIDI_DEVICENAME, IntPtr.Zero, ref chars) == Err || chars == 0)
return null;
IntPtr buffer = Marshal.AllocHGlobal((int)chars * sizeof(char));
try
{
if (GetRawInputDeviceInfoW(hDevice, RIDI_DEVICENAME, buffer, ref chars) == Err)
return null;
return Marshal.PtrToStringUni(buffer);
}
finally
{
Marshal.FreeHGlobal(buffer);
}
}
///
/// Turn a raw device interface path into something a human recognizes: the
/// device's registry description if we can resolve it, else its VID:PID.
///
private static string FriendlyName(string interfacePath)
{
string? desc = RegistryName(interfacePath);
if (!string.IsNullOrWhiteSpace(desc))
return desc!;
Match m = Regex.Match(interfacePath, @"VID_([0-9A-Fa-f]{4}).*?PID_([0-9A-Fa-f]{4})");
return m.Success
? $"Keyboard {m.Groups[1].Value.ToUpperInvariant()}:{m.Groups[2].Value.ToUpperInvariant()}"
: "Keyboard";
}
private static string? RegistryName(string interfacePath)
{
try
{
// \\?\HID#VID_x&PID_y#instance#{class-guid} → Enum key HID\VID_x&PID_y\instance
string s = interfacePath;
if (s.StartsWith(@"\\?\", StringComparison.Ordinal) || s.StartsWith(@"\\.\", StringComparison.Ordinal))
s = s.Substring(4);
int guid = s.LastIndexOf("#{", StringComparison.Ordinal);
if (guid >= 0)
s = s.Substring(0, guid);
string instance = s.Replace('#', '\\');
using RegistryKey? key = Registry.LocalMachine.OpenSubKey(
@"SYSTEM\CurrentControlSet\Enum\" + instance);
if (key is null)
return null;
string? name = key.GetValue("FriendlyName") as string;
if (string.IsNullOrWhiteSpace(name))
name = key.GetValue("DeviceDesc") as string;
if (string.IsNullOrWhiteSpace(name))
return null;
// DeviceDesc is "@driver.inf,%token%;Actual Name" — keep the tail.
int semi = name!.LastIndexOf(';');
return semi >= 0 ? name.Substring(semi + 1) : name;
}
catch (Exception ex) when (ex is System.Security.SecurityException or UnauthorizedAccessException or IOException)
{
return null;
}
}
}