495 lines
No EOL
18 KiB
C#
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
|
|
}
|
|
} |