From 2baefddbe4b7512bdf534ca9d61a874ae221ea55 Mon Sep 17 00:00:00 2001 From: Talon Date: Wed, 24 Jun 2026 13:14:01 +0200 Subject: [PATCH] feat(windows): system-wide push-to-talk via Raw Input PTT was focus-scoped (WinForms KeyDown/KeyUp) so it died the moment the window lost focus. Add an optional system-wide path using the Raw Input API (RegisterRawInputDevices + WM_INPUT with RIDEV_INPUTSINK) instead of a WH_KEYBOARD_LL low-level hook -- the latter is the keylogger pattern AV heuristics flag, which is worse for our unsigned MinGW binary. Raw Input involves no DLL injection or global hook and passes keys through. - New VoiceCat.App/Native/RawInput.cs: P/Invoke + structs; register the keyboard sink, decode WM_INPUT to vkey/up-down, GetAsyncKeyState helper. - MainForm overrides OnHandleCreated/OnHandleDestroyed/WndProc to manage the sink and route WM_INPUT to PTT; gates the focus-scoped KeyDown/KeyUp off when system-wide is on; makes the Deactivate force-release conditional; adds a GetAsyncKeyState watchdog on the pump timer so a missed key-up (RDP/lock-screen) cannot leave PTT stuck. - VoiceSettings.SystemWidePtt (default ON) + system-wide checkbox in the Audio settings PTT section. Co-Authored-By: Claude Opus 4.8 --- PROGRESS.md | 18 ++- .../VoiceCat.App/Forms/AudioSettingsForm.cs | 55 +++++-- .../windows/VoiceCat.App/Forms/MainForm.cs | 101 +++++++++++- .../VoiceCat.App/Models/VoiceSettings.cs | 7 + .../windows/VoiceCat.App/Native/RawInput.cs | 147 ++++++++++++++++++ 5 files changed, 310 insertions(+), 18 deletions(-) create mode 100644 clients/windows/VoiceCat.App/Native/RawInput.cs diff --git a/PROGRESS.md b/PROGRESS.md index 954e612..dfad11b 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -26,6 +26,21 @@ up instantly. Newest status at the top. send cushion could be reduced or removed. Shared-core change → add a test and re-verify desktop↔desktop stays low-latency (steady sender ⇒ ~0 arrival jitter ⇒ no regression). +- **Done (2026-06-24):** **Windows PTT can now work system-wide (in the background).** Previously + the PTT key was focus-scoped (WinForms `KeyDown`/`KeyUp`, dead the moment the window lost + focus). Added an AV-safe global path using the **Raw Input API** (`RegisterRawInputDevices` + + `WM_INPUT` with `RIDEV_INPUTSINK`) — *not* a `WH_KEYBOARD_LL` low-level hook, which is the + keylogger pattern AV heuristics flag (worse for our unsigned MinGW binary). New + `clients/windows/VoiceCat.App/Native/RawInput.cs` (P/Invoke + structs); `MainForm` overrides + `OnHandleCreated`/`OnHandleDestroyed`/`WndProc` to register the keyboard sink and handle + `WM_INPUT`, gates the focus-scoped `KeyDown`/`KeyUp` handlers off when system-wide is on, makes + the `Deactivate` force-release conditional, and adds a `GetAsyncKeyState` watchdog on the pump + timer so a missed key-up (RDP/lock-screen focus switch) can't leave PTT stuck. New + `VoiceSettings.SystemWidePtt` (default ON) with a "Works in the background (system-wide)" + checkbox in the Audio settings PTT section. Build green (`dotnet build`, 0 warnings). **Next + (manual):** verify background PTT against a live server, and confirm the binary trips no AV + keyboard-hook detection. + - **Done (2026-06-23):** **Fixed: receive-side noise reduction silently skipped on stereo mic streams (regression from stereo-mic capture below).** The per-listener NR toggle (`vc_set_remote_stream(... noise_reduction)`) did nothing on Windows/macOS/iOS — the UI and @@ -896,7 +911,8 @@ text, device pickers, level meter on each platform. **Windows** (`clients/windows/`): `VoiceCat.Interop` (P/Invoke, `[UnmanagedCallersOnly]`), `VoiceCat.App` (ConnectDialog, ServerIdentityDialog, MainForm with full M5 moderation UI, -PerUserTuningDialog, PttKeyCaptureDialog), `VoiceCat.Interop.Tests`. PTT is focus-scoped. +PerUserTuningDialog, PttKeyCaptureDialog), `VoiceCat.Interop.Tests`. PTT can be system-wide +(Raw Input / WM_INPUT) or focus-scoped, toggled in Audio settings (default system-wide). **macOS** (`clients/apple/macOS/VoiceCatMac.xcodeproj`): NSOutlineView channel tree, NSTableView user list, NSTextView chat, voice controls, full VoiceOver accessibility, admin diff --git a/clients/windows/VoiceCat.App/Forms/AudioSettingsForm.cs b/clients/windows/VoiceCat.App/Forms/AudioSettingsForm.cs index 58ac293..bfefb5e 100644 --- a/clients/windows/VoiceCat.App/Forms/AudioSettingsForm.cs +++ b/clients/windows/VoiceCat.App/Forms/AudioSettingsForm.cs @@ -30,6 +30,7 @@ public sealed class AudioSettingsForm : Form private readonly bool _origMicNoiseReduction; private readonly bool _origStereoMic; private readonly Keys _origPttKey; + private readonly bool _origSystemWidePtt; private readonly bool _origAuxEnabled; private readonly string? _origAuxDeviceId; private readonly int _origAuxGain; @@ -41,6 +42,7 @@ public sealed class AudioSettingsForm : Form private readonly RadioButton _radioAlwaysOn; private readonly Label _lblPttKey; private readonly Button _btnChangePtt; + private readonly CheckBox _chkSystemWidePtt; private readonly Label _lblSensitivity; private readonly TrackBar _trkVad; private readonly TrackBar _trkGain; @@ -74,6 +76,7 @@ public sealed class AudioSettingsForm : Form _origMicNoiseReduction = settings.MicNoiseReduction; _origStereoMic = settings.StereoMic; _origPttKey = _pttKey; + _origSystemWidePtt = settings.SystemWidePtt; _origAuxEnabled = settings.AuxEnabled; _origAuxDeviceId = settings.AuxDeviceId; _origAuxGain = settings.AuxGain; @@ -84,7 +87,7 @@ public sealed class AudioSettingsForm : Form MinimizeBox = false; StartPosition = FormStartPosition.CenterParent; AutoScaleMode = AutoScaleMode.Font; - ClientSize = new Size(420, 611); + ClientSize = new Size(420, 639); // ── Device row ──────────────────────────────────────────────────────── var lblDevice = new Label @@ -174,10 +177,25 @@ public sealed class AudioSettingsForm : Form }; _btnChangePtt.Click += BtnChangePtt_Click; + // Indented PTT sub-option: observe the key system-wide (Raw Input) so it works while + // another app is focused. Visible only in PTT mode (RadioMode_CheckedChanged). + _chkSystemWidePtt = new CheckBox + { + Text = "Wor&ks in the background (system-wide)", + Location = new Point(36, 218), + AutoSize = true, + Checked = settings.SystemWidePtt, + AccessibleName = "System-wide push-to-talk", + AccessibleDescription = + "Let push-to-talk work while another application is focused. Uses the Windows Raw " + + "Input API, not a keyboard hook.", + }; + _chkSystemWidePtt.CheckedChanged += ChkSystemWidePtt_CheckedChanged; + _radioAlwaysOn = new RadioButton { Text = "A&lways on", - Location = new Point(20, 224), + Location = new Point(20, 252), AutoSize = true, }; _radioAlwaysOn.CheckedChanged += RadioMode_CheckedChanged; @@ -186,12 +204,12 @@ public sealed class AudioSettingsForm : Form var lblGain = new Label { Text = "Microphone &volume:", - Location = new Point(12, 268), + Location = new Point(12, 296), AutoSize = true, }; _trkGain = new TrackBar { - Location = new Point(12, 288), + Location = new Point(12, 316), Size = new Size(200, 45), Minimum = 0, Maximum = 400, @@ -211,7 +229,7 @@ public sealed class AudioSettingsForm : Form _chkNoiseReduction = new CheckBox { Text = "Noise &reduction (RNNoise)", - Location = new Point(12, 340), + Location = new Point(12, 368), AutoSize = true, Checked = settings.MicNoiseReduction, AccessibleName = "Microphone noise reduction", @@ -228,7 +246,7 @@ public sealed class AudioSettingsForm : Form _chkStereoMic = new CheckBox { Text = "&Stereo microphone", - Location = new Point(12, 364), + Location = new Point(12, 392), AutoSize = true, Checked = settings.StereoMic, AccessibleName = "Stereo microphone", @@ -244,7 +262,7 @@ public sealed class AudioSettingsForm : Form _chkAux = new CheckBox { Text = "&Aux stream (second input device)", - Location = new Point(12, 404), + Location = new Point(12, 432), AutoSize = true, Checked = settings.AuxEnabled, AccessibleName = "Enable aux input stream", @@ -256,12 +274,12 @@ public sealed class AudioSettingsForm : Form _lblAuxDevice = new Label { Text = "Aux d&evice:", - Location = new Point(12, 434), + Location = new Point(12, 462), AutoSize = true, }; _cboAuxDevice = new ComboBox { - Location = new Point(12, 454), + Location = new Point(12, 482), Width = 300, DropDownStyle = ComboBoxStyle.DropDownList, DisplayMember = "Name", @@ -274,7 +292,7 @@ public sealed class AudioSettingsForm : Form _btnAuxRefresh = new Button { Text = "Re&fresh", - Location = new Point(320, 452), + Location = new Point(320, 480), Size = new Size(80, 26), }; _btnAuxRefresh.Click += (_, _) => LoadAuxDevices(); @@ -282,12 +300,12 @@ public sealed class AudioSettingsForm : Form _lblAuxGain = new Label { Text = "Aux vo&lume:", - Location = new Point(12, 490), + Location = new Point(12, 518), AutoSize = true, }; _trkAuxGain = new TrackBar { - Location = new Point(12, 510), + Location = new Point(12, 538), Size = new Size(200, 45), Minimum = 0, Maximum = 400, @@ -306,14 +324,14 @@ public sealed class AudioSettingsForm : Form { Text = "&OK", DialogResult = DialogResult.OK, - Location = new Point(228, 572), + Location = new Point(228, 600), Size = new Size(80, 27), }; var btnCancel = new Button { Text = "&Cancel", DialogResult = DialogResult.Cancel, - Location = new Point(316, 572), + Location = new Point(316, 600), Size = new Size(80, 27), }; @@ -329,7 +347,7 @@ public sealed class AudioSettingsForm : Form Controls.AddRange([ lblDevice, _cboDevice, _btnRefresh, lblMode, _radioVad, _lblSensitivity, _trkVad, - _radioPtt, _lblPttKey, _btnChangePtt, _radioAlwaysOn, + _radioPtt, _lblPttKey, _btnChangePtt, _chkSystemWidePtt, _radioAlwaysOn, lblGain, _trkGain, _chkNoiseReduction, _chkStereoMic, _chkAux, _lblAuxDevice, _cboAuxDevice, _btnAuxRefresh, _lblAuxGain, _trkAuxGain, btnOk, btnCancel, @@ -443,6 +461,7 @@ public sealed class AudioSettingsForm : Form _trkVad.Visible = isVad; _lblPttKey.Visible = isPtt; _btnChangePtt.Visible = isPtt; + _chkSystemWidePtt.Visible = isPtt; UpdatePttKeyLabel(); _settings.InputMode = (int)mode; @@ -486,6 +505,11 @@ public sealed class AudioSettingsForm : Form } } + private void ChkSystemWidePtt_CheckedChanged(object? sender, EventArgs e) => + // No live effect here — MainForm reads SystemWidePtt after the dialog closes and + // registers/unregisters the Raw Input keyboard sink accordingly. + _settings.SystemWidePtt = _chkSystemWidePtt.Checked; + private void BtnChangePtt_Click(object? sender, EventArgs e) { using var dlg = new PttKeyCaptureDialog(_pttKey); @@ -509,6 +533,7 @@ public sealed class AudioSettingsForm : Form _settings.MicNoiseReduction = _origMicNoiseReduction; _settings.StereoMic = _origStereoMic; _settings.PttKey = (int)_origPttKey; + _settings.SystemWidePtt = _origSystemWidePtt; if (_micStreamId != 0) { diff --git a/clients/windows/VoiceCat.App/Forms/MainForm.cs b/clients/windows/VoiceCat.App/Forms/MainForm.cs index 8c69cd9..03bb7c6 100644 --- a/clients/windows/VoiceCat.App/Forms/MainForm.cs +++ b/clients/windows/VoiceCat.App/Forms/MainForm.cs @@ -1,5 +1,6 @@ using VoiceCat.App.Audio; using VoiceCat.App.Models; +using VoiceCat.App.Native; using VoiceCat.App.Notifications; using VoiceCat.Interop; @@ -32,6 +33,7 @@ public partial class MainForm : Form private InputDeviceCapture? _auxCapture; // client-side capture feeding the aux stream private Keys _pttKey = Keys.F8; private bool _pttEngaged; // guards the PTT cue against key-repeat + private bool _rawInputRegistered; // true while the system-wide PTT keyboard sink is active private bool _serverMuted; private bool _serverDeafened; @@ -56,6 +58,7 @@ public partial class MainForm : Form _client.EventReceived += OnEvent; _client.LevelChanged += OnLevelChanged; _pumpTimer.Tick += (_, _) => _client.PumpEvents(); + _pumpTimer.Tick += (_, _) => PttWatchdog(); _ownPermissions = SafeGetPermissions(); BuildMenus(); @@ -88,13 +91,18 @@ public partial class MainForm : Form chkMute.CheckedChanged += (_, _) => ApplySelfMute(); chkDeafen.CheckedChanged += (_, _) => ApplySelfMute(); - // PTT and global hotkeys (focus-scoped — work only while this form has focus) + // PTT and global hotkeys. The mute/deafen/screen-share toggles (MainForm_HotkeyDown) are + // always focus-scoped. PTT is focus-scoped via KeyDown/KeyUp when system-wide PTT is OFF; + // when ON, the WM_INPUT path (WndProc + Raw Input) owns PTT for both focused and unfocused + // cases and the KeyDown/KeyUp handlers early-return. KeyDown += MainForm_KeyDown; KeyDown += MainForm_HotkeyDown; KeyUp += MainForm_KeyUp; Deactivate += (_, _) => { - if (_micStreamId != 0) _client.SetPushToTalk(false); + // With system-wide PTT we WANT transmission to continue while unfocused, so don't + // release on deactivate — the Raw Input key-up (and PttWatchdog) handle release. + if (!_voiceSettings.SystemWidePtt && _micStreamId != 0) _client.SetPushToTalk(false); }; ApplyPersistedVoiceSettings(); @@ -185,6 +193,7 @@ public partial class MainForm : Form }); dlg.ShowDialog(this); _pttKey = (Keys)_voiceSettings.PttKey; + ApplySystemWidePtt(); // the system-wide toggle may have changed }; settingsMenu.DropDownItems.Add(miAudio); var miNotifications = new ToolStripMenuItem("&Notifications..."); @@ -861,6 +870,7 @@ public partial class MainForm : Form private void MainForm_KeyDown(object? sender, KeyEventArgs e) { + if (_voiceSettings.SystemWidePtt) return; // handled by the WM_INPUT / Raw Input path if ((VcInputMode)_voiceSettings.InputMode != VcInputMode.PushToTalk) return; if (e.KeyCode != _pttKey || _micStreamId == 0) return; if (ActiveControl is TextBox or RichTextBox) return; @@ -903,6 +913,7 @@ public partial class MainForm : Form private void MainForm_KeyUp(object? sender, KeyEventArgs e) { + if (_voiceSettings.SystemWidePtt) return; // handled by the WM_INPUT / Raw Input path if ((VcInputMode)_voiceSettings.InputMode != VcInputMode.PushToTalk) return; if (e.KeyCode != _pttKey || _micStreamId == 0) return; _client.SetPushToTalk(false); @@ -910,6 +921,92 @@ public partial class MainForm : Form e.Handled = e.SuppressKeyPress = true; } + // ── System-wide PTT (Raw Input / WM_INPUT) ──────────────────────────────── + // When the user enables "system-wide" PTT we observe the PTT key via the Raw Input API so it + // works while another app is focused. See VoiceCat.App.Native.RawInput for why this is used in + // preference to a low-level keyboard hook (antivirus keylogger heuristics). + + protected override void OnHandleCreated(EventArgs e) + { + base.OnHandleCreated(e); + ApplySystemWidePtt(); + } + + protected override void OnHandleDestroyed(EventArgs e) + { + if (_rawInputRegistered) + { + RawInput.UnregisterKeyboardSink(); + _rawInputRegistered = false; + } + base.OnHandleDestroyed(e); + } + + protected override void WndProc(ref Message m) + { + if (m.Msg == RawInput.WM_INPUT && _voiceSettings.SystemWidePtt) + HandleRawInput(m.LParam); + base.WndProc(ref m); + } + + /// Register or tear down the background keyboard sink to match the current + /// setting. Safe to call repeatedly. + private void ApplySystemWidePtt() + { + if (!IsHandleCreated) return; // OnHandleCreated will (re)apply once the handle exists + bool want = _voiceSettings.SystemWidePtt; + if (want && !_rawInputRegistered) + { + _rawInputRegistered = RawInput.RegisterKeyboardSink(Handle); + } + else if (!want && _rawInputRegistered) + { + RawInput.UnregisterKeyboardSink(); + _rawInputRegistered = false; + // Release any PTT that was held via Raw Input so it can't stick after switching modes. + if (_micStreamId != 0) _client.SetPushToTalk(false); + _pttEngaged = false; + } + } + + private void HandleRawInput(IntPtr lParam) + { + if ((VcInputMode)_voiceSettings.InputMode != VcInputMode.PushToTalk || _micStreamId == 0) + return; + if (!RawInput.TryParseKey(lParam, out ushort vkey, out bool keyUp)) return; + if (vkey != (ushort)_pttKey) return; + + if (keyUp) + { + _client.SetPushToTalk(false); + _pttEngaged = false; + return; + } + + // Key-down. Don't transmit while typing into our OWN text fields — matches the + // focus-scoped guard. When VoiceCat is in the background ContainsFocus is false, so the + // key still transmits (the whole point of system-wide PTT). + if (ContainsFocus && ActiveControl is TextBox or RichTextBox) return; + _client.SetPushToTalk(true); + if (!_pttEngaged) // first key-down only, not auto-repeat + { + _pttEngaged = true; + _feedback.PlaySound(SoundEvent.Ptt); + } + } + + /// Watchdog (driven by the pump timer) that releases system-wide PTT if the key-up + /// was never observed — e.g. across an RDP or lock-screen focus switch — so PTT can't stick. + private void PttWatchdog() + { + if (!_pttEngaged || !_voiceSettings.SystemWidePtt || _micStreamId == 0) return; + if (!RawInput.IsKeyDown((int)_pttKey)) + { + _client.SetPushToTalk(false); + _pttEngaged = false; + } + } + // ── Channel navigation ──────────────────────────────────────────────────── private void TvChannels_DoubleClick(object? sender, EventArgs e) diff --git a/clients/windows/VoiceCat.App/Models/VoiceSettings.cs b/clients/windows/VoiceCat.App/Models/VoiceSettings.cs index e1fe45f..199311a 100644 --- a/clients/windows/VoiceCat.App/Models/VoiceSettings.cs +++ b/clients/windows/VoiceCat.App/Models/VoiceSettings.cs @@ -34,6 +34,13 @@ public sealed class VoiceSettings /// Push-to-talk key, stored as the integer value of System.Windows.Forms.Keys. public int PttKey { get; set; } = (int)Keys.F8; + /// Make push-to-talk work system-wide — i.e. while another app is focused. When on, + /// MainForm registers a background Raw Input keyboard device (WM_INPUT + RIDEV_INPUTSINK) so the + /// PTT key is observed even when VoiceCat is not in the foreground; when off, PTT is focus-scoped + /// (only fires while the VoiceCat window has focus). Raw Input is used instead of a low-level + /// keyboard hook to avoid antivirus keylogger heuristics. + public bool SystemWidePtt { get; set; } = true; + /// Saved device ID from the last session; null means use the system default. public string? InputDeviceId { get; set; } = null; diff --git a/clients/windows/VoiceCat.App/Native/RawInput.cs b/clients/windows/VoiceCat.App/Native/RawInput.cs new file mode 100644 index 0000000..e808b04 --- /dev/null +++ b/clients/windows/VoiceCat.App/Native/RawInput.cs @@ -0,0 +1,147 @@ +using System.Runtime.InteropServices; + +namespace VoiceCat.App.Native; + +/// +/// Thin wrapper over the Win32 Raw Input API (user32) used to observe the push-to-talk key +/// system-wide — i.e. while another application is focused. +/// +/// We deliberately use Raw Input (RegisterRawInputDevices + WM_INPUT with +/// RIDEV_INPUTSINK) rather than a low-level keyboard hook (SetWindowsHookEx / +/// WH_KEYBOARD_LL). A low-level hook is the textbook keylogger pattern and is exactly +/// what antivirus heuristics flag — especially for an unsigned, statically linked MinGW binary +/// like ours. Raw Input involves no DLL injection and no global hook: the OS simply posts +/// WM_INPUT messages to our own window's message queue (it is the same input path games +/// use), so it does not trip those heuristics. It also passes keystrokes through to the focused +/// app rather than swallowing them. +/// +/// Only the keyboard device is registered; filters to the single PTT +/// virtual-key code one level up (MainForm). +/// +internal static partial class RawInput +{ + // ── Constants ────────────────────────────────────────────────────────────────────────── + public const int WM_INPUT = 0x00FF; + + private const uint RID_INPUT = 0x10000003; // GetRawInputData: get the raw data + private const uint RIM_TYPEKEYBOARD = 1; // RAWINPUTHEADER.dwType for a keyboard + private const uint RIDEV_INPUTSINK = 0x00000100; // receive input even when not in foreground + private const uint RIDEV_REMOVE = 0x00000001; // stop receiving input from the device + private const ushort RI_KEY_BREAK = 0x01; // RAWKEYBOARD.Flags bit set on key-up + + private const ushort HID_USAGE_PAGE_GENERIC = 0x01; + private const ushort HID_USAGE_GENERIC_KEYBOARD = 0x06; + + // ── Structs (must match the Win32 layout exactly) ────────────────────────────────────── + [StructLayout(LayoutKind.Sequential)] + private struct RAWINPUTDEVICE + { + public ushort usUsagePage; + public ushort usUsage; + public uint dwFlags; + public IntPtr hwndTarget; + } + + [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; + } + + // ── P/Invoke (source-generated via LibraryImport, matching VoiceCat.Interop) ──────────── + [LibraryImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool RegisterRawInputDevices( + ref RAWINPUTDEVICE pRawInputDevices, uint uiNumDevices, uint cbSize); + + [LibraryImport("user32.dll")] + private static partial uint GetRawInputData( + IntPtr hRawInput, uint uiCommand, IntPtr pData, ref uint pcbSize, uint cbSizeHeader); + + [LibraryImport("user32.dll")] + private static partial short GetAsyncKeyState(int vKey); + + // ── Public helpers ───────────────────────────────────────────────────────────────────── + + /// Register a background keyboard sink targeting . With + /// RIDEV_INPUTSINK the window receives WM_INPUT even when it is not in the foreground. + public static bool RegisterKeyboardSink(IntPtr hwnd) + { + var rid = new RAWINPUTDEVICE + { + usUsagePage = HID_USAGE_PAGE_GENERIC, + usUsage = HID_USAGE_GENERIC_KEYBOARD, + dwFlags = RIDEV_INPUTSINK, + hwndTarget = hwnd, + }; + return RegisterRawInputDevices(ref rid, 1, (uint)Marshal.SizeOf()); + } + + /// Tear down the keyboard sink. For RIDEV_REMOVE the target handle must be NULL. + public static bool UnregisterKeyboardSink() + { + var rid = new RAWINPUTDEVICE + { + usUsagePage = HID_USAGE_PAGE_GENERIC, + usUsage = HID_USAGE_GENERIC_KEYBOARD, + dwFlags = RIDEV_REMOVE, + hwndTarget = IntPtr.Zero, + }; + return RegisterRawInputDevices(ref rid, 1, (uint)Marshal.SizeOf()); + } + + /// Decode a WM_INPUT message's (the message's LParam). + /// Returns false for anything that isn't a keyboard event. On success, + /// is the virtual-key code and distinguishes a release from a press + /// (auto-repeat arrives as repeated presses). + public static bool TryParseKey(IntPtr hRawInput, out ushort vkey, out bool keyUp) + { + vkey = 0; + keyUp = false; + + uint headerSize = (uint)Marshal.SizeOf(); + uint size = 0; + // First call (pData == NULL) returns 0 on success and fills `size` with the buffer length. + if (GetRawInputData(hRawInput, RID_INPUT, IntPtr.Zero, ref size, headerSize) != 0 || size == 0) + return false; + + IntPtr buf = Marshal.AllocHGlobal((int)size); + try + { + if (GetRawInputData(hRawInput, RID_INPUT, buf, ref size, headerSize) != size) + return false; + + var header = Marshal.PtrToStructure(buf); + if (header.dwType != RIM_TYPEKEYBOARD) + return false; + + // The keyboard payload immediately follows the (8-byte-aligned) header. + var kb = Marshal.PtrToStructure(buf + (int)headerSize); + vkey = kb.VKey; + keyUp = (kb.Flags & RI_KEY_BREAK) != 0; + return true; + } + finally + { + Marshal.FreeHGlobal(buf); + } + } + + /// True if the given virtual-key code is currently held down. Used as a watchdog to + /// catch a missed key-up (e.g. across an RDP / lock-screen focus switch) so PTT can't stick. + public static bool IsKeyDown(int vKey) => (GetAsyncKeyState(vKey) & 0x8000) != 0; +}