amical/packages/native-helpers/windows-helper/src/ShortcutMonitor.cs
2025-12-21 11:39:08 +05:30

495 lines
No EOL
18 KiB
C#

using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using WindowsHelper.Models;
namespace WindowsHelper
{
public class ShortcutMonitor
{
#region Windows API
private const int WH_KEYBOARD_LL = 13;
private const int WM_KEYDOWN = 0x0100;
private const int WM_KEYUP = 0x0101;
private const int WM_SYSKEYDOWN = 0x0104;
private const int WM_SYSKEYUP = 0x0105;
// For MsgWaitForMultipleObjects
private const uint QS_ALLINPUT = 0x04FF;
private const uint WAIT_OBJECT_0 = 0;
private const uint WAIT_TIMEOUT = 258;
private const uint INFINITE = 0xFFFFFFFF;
private const int PM_REMOVE = 0x0001;
private delegate IntPtr LowLevelKeyboardProc(int nCode, IntPtr wParam, IntPtr lParam);
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelKeyboardProc lpfn, IntPtr hMod, uint dwThreadId);
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern bool UnhookWindowsHookEx(IntPtr hhk);
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam);
[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern IntPtr GetModuleHandle(string lpModuleName);
[DllImport("user32.dll")]
private static extern short GetAsyncKeyState(int vKey);
[DllImport("user32.dll")]
private static extern uint MsgWaitForMultipleObjects(uint nCount, IntPtr[] pHandles, bool bWaitAll, uint dwMilliseconds, uint dwWakeMask);
[DllImport("user32.dll")]
private static extern bool PeekMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax, int wRemoveMsg);
[StructLayout(LayoutKind.Sequential)]
private struct KBDLLHOOKSTRUCT
{
public uint vkCode;
public uint scanCode;
public uint flags;
public uint time;
public IntPtr dwExtraInfo;
}
// Virtual key codes for modifier keys
private const int VK_SHIFT = 0x10;
private const int VK_CONTROL = 0x11;
private const int VK_MENU = 0x12; // Alt key
private const int VK_LWIN = 0x5B; // Left Windows key
private const int VK_RWIN = 0x5C; // Right Windows key
private const int VK_FUNCTION = 0xFF; // Fn key (not standard, varies by keyboard)
// Left/right specific virtual key codes (Windows low-level hooks send these)
private const int VK_LSHIFT = 0xA0;
private const int VK_RSHIFT = 0xA1;
private const int VK_LCONTROL = 0xA2;
private const int VK_RCONTROL = 0xA3;
private const int VK_LMENU = 0xA4;
private const int VK_RMENU = 0xA5;
#endregion
private IntPtr hookId = IntPtr.Zero;
private LowLevelKeyboardProc? hookProc;
private Thread? messageLoopThread;
private bool isRunning = false;
// STA thread work queue for dispatching work from other threads
private readonly ConcurrentQueue<Action> staWorkQueue = new();
private readonly AutoResetEvent staWorkEvent = new(false);
// Track modifier key states internally to avoid GetAsyncKeyState issues
// Track left and right separately to handle cases where both are pressed
private bool leftShiftPressed = false;
private bool rightShiftPressed = false;
private bool leftCtrlPressed = false;
private bool rightCtrlPressed = false;
private bool leftAltPressed = false;
private bool rightAltPressed = false;
private bool leftWinPressed = false;
private bool rightWinPressed = false;
// Computed properties that combine left/right states
private bool shiftPressed => leftShiftPressed || rightShiftPressed;
private bool ctrlPressed => leftCtrlPressed || rightCtrlPressed;
private bool altPressed => leftAltPressed || rightAltPressed;
private bool winPressed => leftWinPressed || rightWinPressed;
public event EventHandler<HelperEvent>? KeyEventOccurred;
/// <summary>
/// Invokes an async action on the STA thread and waits for completion.
/// Use this for audio/COM operations that require STA thread.
/// </summary>
public Task<T> InvokeOnStaAsync<T>(Func<Task<T>> asyncAction)
{
var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
staWorkQueue.Enqueue(async () =>
{
try
{
var result = await asyncAction();
tcs.SetResult(result);
}
catch (Exception ex)
{
tcs.SetException(ex);
}
});
staWorkEvent.Set();
return tcs.Task;
}
/// <summary>
/// Invokes a synchronous action on the STA thread and waits for completion.
/// Use this for audio/COM operations that require STA thread.
/// </summary>
public Task<T> InvokeOnSta<T>(Func<T> action)
{
var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
staWorkQueue.Enqueue(() =>
{
try
{
var result = action();
tcs.SetResult(result);
}
catch (Exception ex)
{
tcs.SetException(ex);
}
});
staWorkEvent.Set();
return tcs.Task;
}
/// <summary>
/// Posts an action to the STA thread without waiting for completion.
/// </summary>
public void PostToSta(Action action)
{
staWorkQueue.Enqueue(action);
staWorkEvent.Set();
}
public void Start()
{
if (isRunning) return;
isRunning = true;
messageLoopThread = new Thread(MessageLoop)
{
Name = "ShortcutHook",
IsBackground = false
};
messageLoopThread.SetApartmentState(ApartmentState.STA);
messageLoopThread.Start();
}
public void Stop()
{
isRunning = false;
if (hookId != IntPtr.Zero)
{
UnhookWindowsHookEx(hookId);
hookId = IntPtr.Zero;
}
}
private void MessageLoop()
{
try
{
// Keep a reference to the delegate to prevent GC
hookProc = HookCallback;
using (Process curProcess = Process.GetCurrentProcess())
using (ProcessModule? curModule = curProcess.MainModule)
{
if (curModule != null)
{
hookId = SetWindowsHookEx(WH_KEYBOARD_LL, hookProc,
GetModuleHandle(curModule.ModuleName), 0);
}
}
if (hookId == IntPtr.Zero)
{
LogToStderr("Failed to install shortcut hook");
return;
}
LogToStderr("Shortcut hook installed successfully");
LogToStderr("STA thread ready for work dispatch");
// Run Windows message loop with support for STA work queue
var waitHandles = new IntPtr[] { staWorkEvent.SafeWaitHandle.DangerousGetHandle() };
while (isRunning)
{
// Wait for either a Windows message or a work item
var waitResult = MsgWaitForMultipleObjects(1, waitHandles, false, INFINITE, QS_ALLINPUT);
if (waitResult == WAIT_OBJECT_0)
{
// Work item signaled - process all queued work
ProcessStaWorkQueue();
}
else if (waitResult == WAIT_OBJECT_0 + 1)
{
// Windows message available - process all pending messages
MSG msg;
while (PeekMessage(out msg, IntPtr.Zero, 0, 0, PM_REMOVE))
{
if (msg.message == 0x0012) // WM_QUIT
{
isRunning = false;
break;
}
TranslateMessage(ref msg);
DispatchMessage(ref msg);
}
}
}
}
catch (Exception ex)
{
LogToStderr($"Error in shortcut message loop: {ex.Message}");
}
finally
{
if (hookId != IntPtr.Zero)
{
UnhookWindowsHookEx(hookId);
hookId = IntPtr.Zero;
}
}
}
private void ProcessStaWorkQueue()
{
while (staWorkQueue.TryDequeue(out var action))
{
try
{
action();
}
catch (Exception ex)
{
LogToStderr($"Error processing STA work item: {ex.Message}");
}
}
}
private IntPtr HookCallback(int nCode, IntPtr wParam, IntPtr lParam)
{
if (nCode >= 0)
{
try
{
int msg = wParam.ToInt32();
bool isKeyDown = (msg == WM_KEYDOWN || msg == WM_SYSKEYDOWN);
bool isKeyUp = (msg == WM_KEYUP || msg == WM_SYSKEYUP);
if (isKeyDown || isKeyUp)
{
var kbStruct = Marshal.PtrToStructure<KBDLLHOOKSTRUCT>(lParam);
// Update our internal modifier state tracking based on the actual key being pressed/released
UpdateModifierState(kbStruct.vkCode, isKeyDown);
// Create event using our tracked modifier states
var keyEvent = new HelperEvent
{
Type = isKeyDown ? HelperEventType.KeyDown : HelperEventType.KeyUp,
Timestamp = DateTime.UtcNow,
Payload = new HelperEventPayload
{
KeyCode = (int)kbStruct.vkCode,
AltKey = altPressed,
CtrlKey = ctrlPressed,
ShiftKey = shiftPressed,
MetaKey = winPressed,
FnKeyPressed = false // Windows doesn't have standard Fn key detection
}
};
// Check for modifier key changes
if (IsModifierKey(kbStruct.vkCode))
{
// Send flagsChanged event for modifier keys with current tracked state
// State is already updated by UpdateModifierState above
var flagsEvent = new HelperEvent
{
Type = HelperEventType.FlagsChanged,
Timestamp = DateTime.UtcNow,
Payload = new HelperEventPayload
{
KeyCode = (int)kbStruct.vkCode,
AltKey = altPressed,
CtrlKey = ctrlPressed,
ShiftKey = shiftPressed,
MetaKey = winPressed,
FnKeyPressed = false
}
};
KeyEventOccurred?.Invoke(this, flagsEvent);
}
else
{
// Send regular key event
KeyEventOccurred?.Invoke(this, keyEvent);
// Check if this key event should be consumed (prevent default behavior)
// Only for regular key events, not modifiers
var modifierState = new ModifierState
{
Win = winPressed,
Ctrl = ctrlPressed,
Alt = altPressed,
Shift = shiftPressed
};
if (ShortcutManager.Instance.ShouldConsumeKey((int)kbStruct.vkCode, modifierState))
{
// Consume - prevent default behavior (e.g., cursor movement for arrow keys)
return (IntPtr)1;
}
}
}
}
catch (Exception ex)
{
LogToStderr($"Error in hook callback: {ex.Message}");
}
}
return CallNextHookEx(hookId, nCode, wParam, lParam);
}
private void UpdateModifierState(uint vkCode, bool isPressed)
{
switch (vkCode)
{
// Handle generic codes (fallback - Windows low-level hooks typically send left/right specific codes)
// For generic codes, we update both sides to be safe, but this should rarely happen
case VK_SHIFT:
// If we get a generic code, assume left (most common case)
// But also check if right is actually pressed to handle edge cases
if (isPressed)
{
// If right shift is already pressed, don't override it
if (!rightShiftPressed)
{
leftShiftPressed = true;
}
}
else
{
// On release, clear left unless right is still pressed
if (!rightShiftPressed)
{
leftShiftPressed = false;
}
}
break;
case VK_CONTROL:
if (isPressed)
{
if (!rightCtrlPressed)
{
leftCtrlPressed = true;
}
}
else
{
if (!rightCtrlPressed)
{
leftCtrlPressed = false;
}
}
break;
case VK_MENU: // Alt key
if (isPressed)
{
if (!rightAltPressed)
{
leftAltPressed = true;
}
}
else
{
if (!rightAltPressed)
{
leftAltPressed = false;
}
}
break;
// Handle left/right specific codes (what Windows low-level hooks actually send)
case VK_LSHIFT:
leftShiftPressed = isPressed;
break;
case VK_RSHIFT:
rightShiftPressed = isPressed;
break;
case VK_LCONTROL:
leftCtrlPressed = isPressed;
break;
case VK_RCONTROL:
rightCtrlPressed = isPressed;
break;
case VK_LMENU:
leftAltPressed = isPressed;
break;
case VK_RMENU:
rightAltPressed = isPressed;
break;
case VK_LWIN:
leftWinPressed = isPressed;
break;
case VK_RWIN:
rightWinPressed = isPressed;
break;
}
}
private bool IsKeyPressed(int vKey)
{
return (GetAsyncKeyState(vKey) & 0x8000) != 0;
}
private bool IsModifierKey(uint vkCode)
{
return vkCode == VK_SHIFT || vkCode == VK_LSHIFT || vkCode == VK_RSHIFT ||
vkCode == VK_CONTROL || vkCode == VK_LCONTROL || vkCode == VK_RCONTROL ||
vkCode == VK_MENU || vkCode == VK_LMENU || vkCode == VK_RMENU ||
vkCode == VK_LWIN || vkCode == VK_RWIN;
}
private void LogToStderr(string message)
{
var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff");
Console.Error.WriteLine($"[{timestamp}] [ShortcutMonitor] {message}");
Console.Error.Flush();
}
#region Windows Message Loop
[StructLayout(LayoutKind.Sequential)]
private struct MSG
{
public IntPtr hwnd;
public uint message;
public IntPtr wParam;
public IntPtr lParam;
public uint time;
public POINT pt;
}
[StructLayout(LayoutKind.Sequential)]
private struct POINT
{
public int x;
public int y;
}
[DllImport("user32.dll")]
private static extern int GetMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax);
[DllImport("user32.dll")]
private static extern bool TranslateMessage(ref MSG lpMsg);
[DllImport("user32.dll")]
private static extern IntPtr DispatchMessage(ref MSG lpMsg);
#endregion
}
}