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 <noreply@anthropic.com>
This commit is contained in:
147
clients/windows/VoiceCat.App/Native/RawInput.cs
Normal file
147
clients/windows/VoiceCat.App/Native/RawInput.cs
Normal file
@@ -0,0 +1,147 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace VoiceCat.App.Native;
|
||||
|
||||
/// <summary>
|
||||
/// 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 (<c>RegisterRawInputDevices</c> + <c>WM_INPUT</c> with
|
||||
/// <c>RIDEV_INPUTSINK</c>) rather than a low-level keyboard hook (<c>SetWindowsHookEx</c> /
|
||||
/// <c>WH_KEYBOARD_LL</c>). 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
|
||||
/// <c>WM_INPUT</c> 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; <see cref="TryParseKey"/> filters to the single PTT
|
||||
/// virtual-key code one level up (MainForm).
|
||||
/// </summary>
|
||||
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 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Register a background keyboard sink targeting <paramref name="hwnd"/>. With
|
||||
/// RIDEV_INPUTSINK the window receives WM_INPUT even when it is not in the foreground.</summary>
|
||||
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<RAWINPUTDEVICE>());
|
||||
}
|
||||
|
||||
/// <summary>Tear down the keyboard sink. For RIDEV_REMOVE the target handle must be NULL.</summary>
|
||||
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<RAWINPUTDEVICE>());
|
||||
}
|
||||
|
||||
/// <summary>Decode a WM_INPUT message's <paramref name="hRawInput"/> (the message's LParam).
|
||||
/// Returns false for anything that isn't a keyboard event. On success, <paramref name="vkey"/>
|
||||
/// is the virtual-key code and <paramref name="keyUp"/> distinguishes a release from a press
|
||||
/// (auto-repeat arrives as repeated presses).</summary>
|
||||
public static bool TryParseKey(IntPtr hRawInput, out ushort vkey, out bool keyUp)
|
||||
{
|
||||
vkey = 0;
|
||||
keyUp = false;
|
||||
|
||||
uint headerSize = (uint)Marshal.SizeOf<RAWINPUTHEADER>();
|
||||
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<RAWINPUTHEADER>(buf);
|
||||
if (header.dwType != RIM_TYPEKEYBOARD)
|
||||
return false;
|
||||
|
||||
// The keyboard payload immediately follows the (8-byte-aligned) header.
|
||||
var kb = Marshal.PtrToStructure<RAWKEYBOARD>(buf + (int)headerSize);
|
||||
vkey = kb.VKey;
|
||||
keyUp = (kb.Flags & RI_KEY_BREAK) != 0;
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeHGlobal(buf);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public static bool IsKeyDown(int vKey) => (GetAsyncKeyState(vKey) & 0x8000) != 0;
|
||||
}
|
||||
Reference in New Issue
Block a user