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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user