Capture a chosen keyboard's keys without window focus (Raw Input)
The WinForms focus path only sees keys while vRIO is foreground and can't tell one keyboard from another, so vRIO went deaf the moment the sim took focus. The new "Capture keyboard" picker in the Input panel taps one physical keyboard through the Win32 Raw Input API (RIDEV_INPUTSINK) and routes its keys to the panel even in the background — the input-side twin of the lamp mirror writing that keyboard's LEDs under the same condition. Raw Input observes without intercepting, so keys still reach the focused app; the picker defaults to "All keyboards (focus only)", preserving the prior focus-bound behavior, and only registers Raw Input while a specific device is selected. While capturing, the focus path stands down but still swallows bound keys so they can't click panel buttons. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,17 @@ controls:
|
||||
F1–F6 / F7–F12 = the Secondary / Screen columns, numpad = internal
|
||||
keypad (hex keys on the operators), arrows + Space = hat + main, with
|
||||
ABXY / dpad / shoulders on the pad's named buttons.
|
||||
- **Capture one keyboard in the background** (*Capture keyboard* picker in the
|
||||
Input panel): by default keyboard input follows the focused window, so vRIO
|
||||
goes deaf the moment the sim takes focus. Selecting a specific keyboard
|
||||
instead taps it through the Windows **Raw Input** API (`RIDEV_INPUTSINK`), so
|
||||
its keys drive the panel *while the game has focus* — the input-side twin of
|
||||
the lamp mirror writing that keyboard's LEDs under the same condition. Raw
|
||||
Input observes without intercepting: the keystroke still reaches the focused
|
||||
app, so this is meant for a keyboard *dedicated* to the panel, not the one you
|
||||
also type on. Leaving the picker on *All keyboards* keeps the plain
|
||||
focus-only behavior. (No package identity needed — unlike the lamp side, this
|
||||
is a plain Win32 tap.)
|
||||
|
||||
## Wire behavior
|
||||
|
||||
|
||||
+104
-11
@@ -20,6 +20,8 @@ internal sealed class MainForm : Form
|
||||
private readonly InputRouter _router;
|
||||
private readonly XInputGamepad _gamepad = new();
|
||||
private readonly KeyboardLampMirror _lampMirror;
|
||||
private readonly RawKeyboardSource _rawKeyboard = new();
|
||||
private string? _activeInputId; // interface path of the keyboard being captured; null = WinForms focus path
|
||||
private readonly string _bindingsPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "vRIO", "bindings.txt");
|
||||
|
||||
@@ -72,6 +74,18 @@ internal sealed class MainForm : Form
|
||||
DropDownStyle = ComboBoxStyle.DropDownList,
|
||||
Enabled = false, // populated while the mirror is on
|
||||
};
|
||||
private readonly Label _kbInputLabel = new()
|
||||
{
|
||||
Text = "Capture keyboard (no window focus needed):",
|
||||
Location = new Point(10, 168),
|
||||
AutoSize = true,
|
||||
};
|
||||
private readonly ComboBox _kbInputTarget = new()
|
||||
{
|
||||
Location = new Point(10, 186),
|
||||
Width = 286,
|
||||
DropDownStyle = ComboBoxStyle.DropDownList,
|
||||
};
|
||||
|
||||
/// <summary>A keyboard choice in the lamp-mirror picker (null id = all).</summary>
|
||||
private sealed record KbChoice(string? Id, string Name)
|
||||
@@ -81,14 +95,14 @@ internal sealed class MainForm : Form
|
||||
|
||||
private readonly Label _counters = new()
|
||||
{
|
||||
Location = new Point(12, 414),
|
||||
Location = new Point(12, 458),
|
||||
AutoSize = true,
|
||||
Font = new Font("Consolas", 8f),
|
||||
};
|
||||
|
||||
private readonly Label _help = new()
|
||||
{
|
||||
Location = new Point(12, 470),
|
||||
Location = new Point(12, 514),
|
||||
MaximumSize = new Size(306, 0),
|
||||
AutoSize = true,
|
||||
ForeColor = Color.Gray,
|
||||
@@ -99,7 +113,7 @@ internal sealed class MainForm : Form
|
||||
|
||||
private readonly TextBox _logBox = new()
|
||||
{
|
||||
Location = new Point(12, 554),
|
||||
Location = new Point(12, 598),
|
||||
Multiline = true,
|
||||
ReadOnly = true,
|
||||
ScrollBars = ScrollBars.Both, // long wire lines don't wrap — scroll to read
|
||||
@@ -209,6 +223,18 @@ internal sealed class MainForm : Form
|
||||
_lampMirror.KeyboardsChanged += list => RunOnUi(() => RebuildKeyboardPicker(list));
|
||||
_kbLightsTarget.Items.Add(new KbChoice(null, "All keyboards"));
|
||||
_kbLightsTarget.SelectedIndex = 0;
|
||||
|
||||
// Raw-input capture: a chosen keyboard drives the panel even while the
|
||||
// sim has focus. Down-edges only fire when keyboard input is enabled;
|
||||
// up-edges always land so a release is never stranded by a mid-hold flip.
|
||||
_rawKeyboard.KeyDown += name => RunOnUi(() => { if (_kbInput.Checked) _router.KeyDown(name); });
|
||||
_rawKeyboard.KeyUp += name => RunOnUi(() => _router.KeyUp(name));
|
||||
_rawKeyboard.Logged += line => RunOnUi(() => PrependLog(line));
|
||||
_rawKeyboard.KeyboardsChanged += list => RunOnUi(() => RebuildInputKeyboardPicker(list));
|
||||
_kbInputTarget.SelectedIndexChanged += (_, _) => ApplyInputSource();
|
||||
_kbInputTarget.DropDown += (_, _) => _rawKeyboard.RefreshKeyboards();
|
||||
_kbInputTarget.Items.Add(new KbChoice(null, "All keyboards (focus only)"));
|
||||
_kbInputTarget.SelectedIndex = 0;
|
||||
_reloadBindings.Click += (_, _) => LoadBindings();
|
||||
_editBindings.Click += (_, _) => OpenBindingsFile();
|
||||
|
||||
@@ -223,6 +249,7 @@ internal sealed class MainForm : Form
|
||||
_uiTimer.Dispose();
|
||||
_inputTimer.Dispose();
|
||||
_lampMirror.Dispose();
|
||||
_rawKeyboard.Dispose();
|
||||
_service.Dispose();
|
||||
};
|
||||
|
||||
@@ -261,13 +288,13 @@ internal sealed class MainForm : Form
|
||||
device.Controls.AddRange(new Control[] { _spring, _centerAxes, _lampsOff, _testEnter, _testExit });
|
||||
panel.Controls.Add(device);
|
||||
|
||||
var input = new GroupBox { Text = "Input", Location = new Point(12, 224), Size = new Size(306, 182) };
|
||||
input.Controls.AddRange(new Control[] { _kbInput, _padInput, _invertY, _padStatus, _reloadBindings, _editBindings, _kbLights, _kbLightsTarget });
|
||||
var input = new GroupBox { Text = "Input", Location = new Point(12, 224), Size = new Size(306, 226) };
|
||||
input.Controls.AddRange(new Control[] { _kbInput, _padInput, _invertY, _padStatus, _reloadBindings, _editBindings, _kbLights, _kbLightsTarget, _kbInputLabel, _kbInputTarget });
|
||||
panel.Controls.Add(input);
|
||||
|
||||
panel.Controls.Add(_counters);
|
||||
panel.Controls.Add(_help);
|
||||
panel.Controls.Add(new Label { Text = "Wire log:", Location = new Point(12, 536), AutoSize = true });
|
||||
panel.Controls.Add(new Label { Text = "Wire log:", Location = new Point(12, 580), AutoSize = true });
|
||||
|
||||
_logBox.Size = new Size(306, ClientSize.Height - _logBox.Top - 44);
|
||||
panel.Controls.Add(_logBox);
|
||||
@@ -338,6 +365,30 @@ internal sealed class MainForm : Form
|
||||
_kbInput.Checked &&
|
||||
!_portBox.ContainsFocus && !_verMajor.ContainsFocus && !_verMinor.ContainsFocus && !_logBox.ContainsFocus;
|
||||
|
||||
protected override void OnHandleCreated(EventArgs e)
|
||||
{
|
||||
base.OnHandleCreated(e);
|
||||
_rawKeyboard.Attach(Handle);
|
||||
_rawKeyboard.RefreshKeyboards(); // seed the capture picker
|
||||
}
|
||||
|
||||
protected override void WndProc(ref Message m)
|
||||
{
|
||||
// Raw Input arrives outside the normal key-message path; feed it to the
|
||||
// capture source, then let DefWindowProc run its WM_INPUT cleanup.
|
||||
const int WM_INPUT = 0x00FF, WM_INPUT_DEVICE_CHANGE = 0x00FE;
|
||||
switch (m.Msg)
|
||||
{
|
||||
case WM_INPUT:
|
||||
_rawKeyboard.ProcessInput(m.LParam);
|
||||
break;
|
||||
case WM_INPUT_DEVICE_CHANGE:
|
||||
_rawKeyboard.HandleDeviceChange();
|
||||
break;
|
||||
}
|
||||
base.WndProc(ref m);
|
||||
}
|
||||
|
||||
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
|
||||
{
|
||||
// Intercept WM_KEYDOWN before dialog-key processing, or arrows/space
|
||||
@@ -350,7 +401,10 @@ internal sealed class MainForm : Form
|
||||
string name = (keyData & Keys.KeyCode).ToString();
|
||||
if (_router.HasKeyBinding(name))
|
||||
{
|
||||
_router.KeyDown(name);
|
||||
// While a specific keyboard is captured, that source owns
|
||||
// routing; still swallow the key so it can't click a button.
|
||||
if (!_rawKeyboard.IsCapturing)
|
||||
_router.KeyDown(name);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -359,18 +413,57 @@ internal sealed class MainForm : Form
|
||||
|
||||
protected override void OnKeyUp(KeyEventArgs e)
|
||||
{
|
||||
// Unconditional: the router ignores keys it never saw go down, and a
|
||||
// release must land even if the checkbox flipped mid-hold.
|
||||
_router.KeyUp(e.KeyCode.ToString());
|
||||
// Unconditional in focus mode: the router ignores keys it never saw go
|
||||
// down, and a release must land even if the checkbox flipped mid-hold.
|
||||
// In capture mode the raw source owns edges, so don't double-release.
|
||||
if (!_rawKeyboard.IsCapturing)
|
||||
_router.KeyUp(e.KeyCode.ToString());
|
||||
base.OnKeyUp(e);
|
||||
}
|
||||
|
||||
protected override void OnDeactivate(EventArgs e)
|
||||
{
|
||||
_router.ReleaseAllKeys(); // key-up events are lost once unfocused
|
||||
// Focus mode loses key-up events once unfocused, so release held keys.
|
||||
// Capture mode keeps receiving them in the background — leave holds be.
|
||||
if (!_rawKeyboard.IsCapturing)
|
||||
_router.ReleaseAllKeys();
|
||||
base.OnDeactivate(e);
|
||||
}
|
||||
|
||||
/// <summary>Apply the capture-picker selection: swap the keyboard input source.</summary>
|
||||
private void ApplyInputSource()
|
||||
{
|
||||
string? id = (_kbInputTarget.SelectedItem as KbChoice)?.Id;
|
||||
if (id == _activeInputId)
|
||||
return;
|
||||
_activeInputId = id;
|
||||
_router.ReleaseAllKeys(); // don't strand holds across a source swap
|
||||
_rawKeyboard.SetTarget(id);
|
||||
PrependLog(id is null
|
||||
? "Keyboard input: all keyboards, only while vRIO has focus"
|
||||
: $"Keyboard input: capturing \"{(_kbInputTarget.SelectedItem as KbChoice)?.Name}\" in the background");
|
||||
}
|
||||
|
||||
/// <summary>Refresh the capture picker, keeping the pick if the device survived.</summary>
|
||||
private void RebuildInputKeyboardPicker(IReadOnlyList<(string Id, string Name)> keyboards)
|
||||
{
|
||||
string? selected = (_kbInputTarget.SelectedItem as KbChoice)?.Id;
|
||||
_kbInputTarget.BeginUpdate();
|
||||
_kbInputTarget.Items.Clear();
|
||||
_kbInputTarget.Items.Add(new KbChoice(null, "All keyboards (focus only)"));
|
||||
int select = 0;
|
||||
foreach ((string id, string name) in keyboards)
|
||||
{
|
||||
int idx = _kbInputTarget.Items.Add(new KbChoice(id, name));
|
||||
if (id == selected)
|
||||
select = idx;
|
||||
}
|
||||
// A vanished capture device falls back to "All keyboards"; the
|
||||
// selection-changed handler (ApplyInputSource) unregisters accordingly.
|
||||
_kbInputTarget.SelectedIndex = select;
|
||||
_kbInputTarget.EndUpdate();
|
||||
}
|
||||
|
||||
private void InputTick()
|
||||
{
|
||||
double now = _clock.Elapsed.TotalSeconds;
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Windows.Forms;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace VRio.App;
|
||||
|
||||
/// <summary>
|
||||
/// A background keyboard input source built on the Win32 <b>Raw Input</b> API.
|
||||
/// Where the WinForms focus path (<c>ProcessCmdKey</c>/<c>OnKeyUp</c>) 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 <c>RIDEV_INPUTSINK</c> flag — keeps delivering <c>WM_INPUT</c> 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.
|
||||
///
|
||||
/// <para>This is the input-side twin of <see cref="KeyboardLampMirror"/>: that
|
||||
/// class <em>writes</em> a chosen keyboard's LEDs while the game has focus, this
|
||||
/// one <em>reads</em> 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.</para>
|
||||
///
|
||||
/// <para>Registration is scoped to when a device is actually selected
|
||||
/// (<see cref="SetTarget"/>): picking "all keyboards" unregisters and hands
|
||||
/// input back to the focus path, so vRIO isn't pumping <c>WM_INPUT</c> for every
|
||||
/// keystroke system-wide when the feature is idle. Not thread-safe;
|
||||
/// <see cref="ProcessInput"/> and the events fire on the UI thread that owns the
|
||||
/// window handle, which is exactly where <see cref="Input.InputRouter"/> wants
|
||||
/// to be called.</para>
|
||||
/// </summary>
|
||||
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<RAWINPUTHEADER>();
|
||||
|
||||
// --- State ---------------------------------------------------------------
|
||||
|
||||
// hDevice → interface path, so the hot per-keystroke path skips the
|
||||
// two-call GetRawInputDeviceInfo dance once a device is known.
|
||||
private readonly Dictionary<IntPtr, string> _pathByHandle = new();
|
||||
|
||||
private IntPtr _hwnd;
|
||||
private string? _target; // interface path of the keyboard to capture; null = disabled
|
||||
private bool _registered;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>A key on the captured keyboard went down (.NET <see cref="Keys"/> name).</summary>
|
||||
public event Action<string>? KeyDown;
|
||||
|
||||
/// <summary>A key on the captured keyboard came up (.NET <see cref="Keys"/> name).</summary>
|
||||
public event Action<string>? KeyUp;
|
||||
|
||||
/// <summary>The set of attached keyboards changed (interface path, display name) — for a picker.</summary>
|
||||
public event Action<IReadOnlyList<(string Id, string Name)>>? KeyboardsChanged;
|
||||
|
||||
/// <summary>Status/diagnostic lines.</summary>
|
||||
public event Action<string>? Logged;
|
||||
|
||||
/// <summary>True while a specific keyboard is being captured (focus path should stand down).</summary>
|
||||
public bool IsCapturing => _target is not null;
|
||||
|
||||
/// <summary>Bind to the window whose message loop delivers <c>WM_INPUT</c>.</summary>
|
||||
public void Attach(IntPtr hwnd) => _hwnd = hwnd;
|
||||
|
||||
/// <summary>
|
||||
/// Capture one keyboard by interface path, or none (<paramref name="deviceId"/>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void SetTarget(string? deviceId)
|
||||
{
|
||||
if (_target == deviceId)
|
||||
return;
|
||||
_target = deviceId;
|
||||
if (deviceId is null)
|
||||
Unregister();
|
||||
else
|
||||
Register();
|
||||
}
|
||||
|
||||
/// <summary>Re-enumerate keyboards and raise <see cref="KeyboardsChanged"/>.</summary>
|
||||
public void RefreshKeyboards()
|
||||
{
|
||||
_pathByHandle.Clear(); // handles are only stable per connection
|
||||
KeyboardsChanged?.Invoke(EnumerateKeyboards());
|
||||
}
|
||||
|
||||
/// <summary>Handle a <c>WM_INPUT_DEVICE_CHANGE</c>: a keyboard arrived or left.</summary>
|
||||
public void HandleDeviceChange() => RefreshKeyboards();
|
||||
|
||||
/// <summary>
|
||||
/// Handle a <c>WM_INPUT</c> message (pass its <c>LParam</c>). Silently ignores
|
||||
/// input from any keyboard other than the captured one.
|
||||
/// </summary>
|
||||
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<RAWINPUTHEADER>(buffer);
|
||||
if (header.dwType != RIM_TYPEKEYBOARD)
|
||||
return;
|
||||
string? path = PathFor(header.hDevice);
|
||||
if (!string.Equals(path, target, StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
|
||||
var kb = Marshal.PtrToStructure<RAWKEYBOARD>(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<RAWINPUTDEVICE>()))
|
||||
_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<RAWINPUTDEVICE>());
|
||||
_registered = false;
|
||||
}
|
||||
|
||||
// --- Enumeration / naming ------------------------------------------------
|
||||
|
||||
private List<(string Id, string Name)> EnumerateKeyboards()
|
||||
{
|
||||
var result = new List<(string, string)>();
|
||||
uint listSize = (uint)Marshal.SizeOf<RAWINPUTDEVICELIST>();
|
||||
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<string, int>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user