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:
2026-06-24 13:14:01 +02:00
parent 65736df464
commit 2baefddbe4
5 changed files with 310 additions and 18 deletions

View File

@@ -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);
}
/// <summary>Register or tear down the background keyboard sink to match the current
/// <see cref="VoiceSettings.SystemWidePtt"/> setting. Safe to call repeatedly.</summary>
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);
}
}
/// <summary>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.</summary>
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)