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;
+}