diff --git a/packages/native-helpers/windows-helper/src/Program.cs b/packages/native-helpers/windows-helper/src/Program.cs index fe76f99..189b06b 100644 --- a/packages/native-helpers/windows-helper/src/Program.cs +++ b/packages/native-helpers/windows-helper/src/Program.cs @@ -1,14 +1,17 @@ using System; -using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using WindowsHelper.Models; +using WindowsHelper.Services; namespace WindowsHelper { class Program { + static StaThreadRunner? keyboardStaRunner; // Dedicated for keyboard hooks (must stay responsive) + static StaThreadRunner? operationsStaRunner; // For clipboard, audio, and other STA operations static ShortcutMonitor? shortcutMonitor; + static ClipboardService? clipboardService; static RpcHandler? rpcHandler; static readonly CancellationTokenSource cancellationTokenSource = new(); @@ -23,25 +26,46 @@ namespace WindowsHelper try { - // Initialize components - shortcutMonitor = new ShortcutMonitor(); - // Pass shortcutMonitor to RpcHandler for STA thread dispatch (audio operations) - rpcHandler = new RpcHandler(shortcutMonitor); + // Initialize components in dependency order + // Two STA threads: one dedicated for keyboard hooks (must stay responsive), + // one shared for clipboard/audio operations (can tolerate some latency) + + // 1. Keyboard STA thread - dedicated for hooks, must pump messages quickly + keyboardStaRunner = new StaThreadRunner(); + + // 2. Operations STA thread - for clipboard and audio operations + operationsStaRunner = new StaThreadRunner(); + + // 3. ClipboardService - uses operations STA thread + clipboardService = new ClipboardService(operationsStaRunner); + + // 4. ShortcutMonitor - uses dedicated keyboard STA thread + shortcutMonitor = new ShortcutMonitor(keyboardStaRunner); + + // 5. RpcHandler - uses operations STA thread for audio dispatch + rpcHandler = new RpcHandler(operationsStaRunner, clipboardService); // Set up event handlers shortcutMonitor.KeyEventOccurred += OnKeyEvent; - // Start RPC processing in background task + // Start STA threads BEFORE RPC processing to avoid race condition + LogToStderr("Starting keyboard STA thread..."); + keyboardStaRunner.Start(); + + LogToStderr("Starting operations STA thread..."); + operationsStaRunner.Start(); + + // Install keyboard hooks on dedicated STA thread + LogToStderr("Installing keyboard hooks..."); + shortcutMonitor.Start(); + + // Start RPC processing AFTER STA threads are running var rpcTask = Task.Run(() => { LogToStderr("Starting RPC processing in background thread..."); rpcHandler.ProcessRpcRequests(cancellationTokenSource.Token); }, cancellationTokenSource.Token); - // Start shortcut monitoring (this will run the Windows message loop with STA support) - LogToStderr("Starting shortcut monitoring in main thread..."); - shortcutMonitor.Start(); - // Wait for cancellation await Task.Delay(Timeout.Infinite, cancellationTokenSource.Token); } @@ -58,6 +82,8 @@ namespace WindowsHelper { // Cleanup shortcutMonitor?.Stop(); + keyboardStaRunner?.Stop(); + operationsStaRunner?.Stop(); cancellationTokenSource.Cancel(); LogToStderr("WindowsHelper stopped."); } @@ -95,4 +121,4 @@ namespace WindowsHelper }; } } -} \ No newline at end of file +} diff --git a/packages/native-helpers/windows-helper/src/RpcHandler.cs b/packages/native-helpers/windows-helper/src/RpcHandler.cs index 282c9f1..73f9073 100644 --- a/packages/native-helpers/windows-helper/src/RpcHandler.cs +++ b/packages/native-helpers/windows-helper/src/RpcHandler.cs @@ -1,5 +1,4 @@ using System; -using System.IO; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -8,35 +7,66 @@ using WindowsHelper.Services; namespace WindowsHelper { - public class RpcHandler + public class RpcHandler : IDisposable { private readonly JsonSerializerOptions jsonOptions; private readonly AccessibilityService accessibilityService; private readonly AudioService audioService; - private readonly ShortcutMonitor? shortcutMonitor; + private readonly StaThreadRunner? staRunner; + private readonly StaThreadRunner? ownedStaRunner; // Track fallback runner we created private Action? audioCompletionHandler; + private bool disposed; - public RpcHandler(ShortcutMonitor? shortcutMonitor = null) + public RpcHandler(StaThreadRunner? staRunner = null, ClipboardService? clipboardService = null) { - this.shortcutMonitor = shortcutMonitor; + this.staRunner = staRunner; // Use the generated converter settings from the models jsonOptions = WindowsHelper.Models.Converter.Settings; - accessibilityService = new AccessibilityService(); + // Create AccessibilityService with ClipboardService if provided + if (clipboardService != null) + { + accessibilityService = new AccessibilityService(clipboardService); + } + else if (staRunner != null) + { + // Create ClipboardService from StaThreadRunner if not provided + var clipboard = new ClipboardService(staRunner); + accessibilityService = new AccessibilityService(clipboard); + } + else + { + // Fallback: create a minimal StaThreadRunner for clipboard operations + // Track it so we can stop it on disposal + ownedStaRunner = new StaThreadRunner(); + ownedStaRunner.Start(); + var clipboard = new ClipboardService(ownedStaRunner); + accessibilityService = new AccessibilityService(clipboard); + } + audioService = new AudioService(); audioService.SoundPlaybackCompleted += OnSoundPlaybackCompleted; - if (shortcutMonitor != null) + if (staRunner != null) { - LogToStderr("RpcHandler: STA thread dispatch enabled via ShortcutMonitor"); + LogToStderr("RpcHandler: STA thread dispatch enabled via StaThreadRunner"); } } + public void Dispose() + { + if (disposed) return; + disposed = true; + + // Stop the fallback runner if we created it + ownedStaRunner?.Stop(); + } + public void ProcessRpcRequests(CancellationToken cancellationToken) { LogToStderr("RpcHandler: Starting RPC request processing loop."); - + try { string? line; @@ -67,14 +97,14 @@ namespace WindowsHelper { LogToStderr($"Fatal error in RPC processing: {ex.Message}"); } - + LogToStderr("RpcHandler: RPC request processing loop finished."); } private async void HandleRpcRequest(RpcRequest request) { RpcResponse response; - + try { switch (request.Method) @@ -82,19 +112,19 @@ namespace WindowsHelper case Method.GetAccessibilityTreeDetails: response = await HandleGetAccessibilityTreeDetails(request); break; - + case Method.GetAccessibilityContext: response = await HandleGetAccessibilityContext(request); break; - + case Method.PasteText: response = HandlePasteText(request); break; - + case Method.MuteSystemAudio: response = await HandleMuteSystemAudio(request); return; // Response sent after audio playback - + case Method.RestoreSystemAudio: response = HandleRestoreSystemAudio(request); break; @@ -130,14 +160,14 @@ namespace WindowsHelper } }; } - + SendRpcResponse(response); } private async Task HandleGetAccessibilityTreeDetails(RpcRequest request) { LogToStderr($"Handling getAccessibilityTreeDetails for ID: {request.Id}"); - + GetAccessibilityTreeDetailsParams? parameters = null; if (request.Params != null) { @@ -164,7 +194,7 @@ namespace WindowsHelper // Get accessibility tree on UI thread var tree = await Task.Run(() => accessibilityService.FetchAccessibilityTree(parameters?.RootId)); - + return new RpcResponse { Id = request.Id.ToString(), @@ -175,7 +205,7 @@ namespace WindowsHelper private async Task HandleGetAccessibilityContext(RpcRequest request) { LogToStderr($"Handling getAccessibilityContext for ID: {request.Id}"); - + GetAccessibilityContextParams? parameters = null; if (request.Params != null) { @@ -202,7 +232,7 @@ namespace WindowsHelper var editableOnly = parameters?.EditableOnly ?? false; var context = await Task.Run(() => accessibilityService.GetAccessibilityContext(editableOnly)); - + return new RpcResponse { Id = request.Id.ToString(), @@ -213,7 +243,7 @@ namespace WindowsHelper private RpcResponse HandlePasteText(RpcRequest request) { LogToStderr($"Handling pasteText for ID: {request.Id}"); - + if (request.Params == null) { return new RpcResponse @@ -231,24 +261,34 @@ namespace WindowsHelper { var json = JsonSerializer.Serialize(request.Params, jsonOptions); var parameters = JsonSerializer.Deserialize(json, jsonOptions); - + if (parameters != null) { - var success = accessibilityService.PasteText(parameters.Transcript); + var success = accessibilityService.PasteText(parameters.Transcript, out var errorMessage); return new RpcResponse { Id = request.Id.ToString(), Result = new PasteTextResult { Success = success, - Message = success ? "Pasted successfully" : "Paste failed" + Message = success ? "Pasted successfully" : (errorMessage ?? "Paste failed") } }; } } catch (Exception ex) { - LogToStderr($"Error processing pasteText: {ex.Message}"); + LogToStderr($"Error processing pasteText: {ex}"); + return new RpcResponse + { + Id = request.Id.ToString(), + Error = new Error + { + Code = -32603, + Message = $"Error during paste operation: {ex.Message}", + Data = ex.ToString() + } + }; } return new RpcResponse @@ -286,20 +326,8 @@ namespace WindowsHelper audioCompletionHandler = null; }; - // Play sound on STA thread if available (faster due to COM/audio API preferences) - if (shortcutMonitor != null) - { - LogToStderr("Dispatching audio playback to STA thread"); - await shortcutMonitor.InvokeOnStaAsync(async () => - { - await audioService.PlaySound("rec-start", requestId); - return true; - }); - } - else - { - await audioService.PlaySound("rec-start", requestId); - } + // Play sound on thread pool - NAudio handles its own threading internally + await audioService.PlaySound("rec-start", requestId); // Return dummy response (real response sent after audio completion) return new RpcResponse { Id = request.Id.ToString() }; @@ -312,18 +340,8 @@ namespace WindowsHelper var success = audioService.RestoreSystemAudio(); if (success) { - // Play sound asynchronously on STA thread if available (don't wait) - if (shortcutMonitor != null) - { - shortcutMonitor.PostToSta(async () => - { - await audioService.PlaySound("rec-stop", request.Id.ToString()); - }); - } - else - { - _ = audioService.PlaySound("rec-stop", request.Id.ToString()); - } + // Play sound asynchronously - NAudio handles its own threading, don't wait + _ = audioService.PlaySound("rec-stop", request.Id.ToString()); } return new RpcResponse @@ -412,4 +430,4 @@ namespace WindowsHelper Console.Error.Flush(); } } -} \ No newline at end of file +} diff --git a/packages/native-helpers/windows-helper/src/Services/AccessibilityService.cs b/packages/native-helpers/windows-helper/src/Services/AccessibilityService.cs index ac1f40a..a8ee3dc 100644 --- a/packages/native-helpers/windows-helper/src/Services/AccessibilityService.cs +++ b/packages/native-helpers/windows-helper/src/Services/AccessibilityService.cs @@ -1,10 +1,6 @@ using System; -using System.Collections.Generic; using System.Runtime.InteropServices; -using System.Text; using System.Threading; -using System.Threading.Tasks; -using System.Windows.Forms; using WindowsHelper.Models; namespace WindowsHelper.Services @@ -12,105 +8,50 @@ namespace WindowsHelper.Services public class AccessibilityService { #region Windows API - [DllImport("user32.dll")] - private static extern bool SetForegroundWindow(IntPtr hWnd); - - [DllImport("user32.dll")] - private static extern IntPtr GetForegroundWindow(); - - [DllImport("user32.dll", CharSet = CharSet.Auto)] - private static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount); - - [DllImport("user32.dll")] - private static extern int GetWindowTextLength(IntPtr hWnd); - - [DllImport("user32.dll")] - private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId); - - [DllImport("user32.dll")] - private static extern IntPtr GetFocus(); - [DllImport("user32.dll")] private static extern bool keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo); - [DllImport("user32.dll")] - private static extern void Sleep(int dwMilliseconds); - - [DllImport("user32.dll")] - private static extern int GetClipboardSequenceNumber(); - private const byte VK_CONTROL = 0x11; private const byte VK_V = 0x56; private const uint KEYEVENTF_KEYUP = 0x0002; #endregion + private readonly ClipboardService clipboardService; private readonly UIAutomationService uiAutomationService; - public AccessibilityService() + public AccessibilityService(ClipboardService clipboardService) { - uiAutomationService = new UIAutomationService(); + this.clipboardService = clipboardService; + this.uiAutomationService = new UIAutomationService(); } public AccessibilityElementNode? FetchAccessibilityTree(string? rootId) { - // Delegate to UI Automation service for real implementation return uiAutomationService.FetchAccessibilityTree(rootId); } public Context? GetAccessibilityContext(bool editableOnly) { - // Delegate to the new modular AccessibilityContextService return AccessibilityContextService.GetAccessibilityContext(editableOnly); } - public bool PasteText(string text) + public bool PasteText(string text, out string? errorMessage) { + errorMessage = null; + try { LogToStderr($"PasteText called with text length: {text.Length}"); - // Save original clipboard content and sequence number (like Swift's changeCount) - IDataObject? originalClipboard = null; - int originalSequenceNumber = GetClipboardSequenceNumber(); - - Thread saveThread = new Thread(() => - { - try - { - if (Clipboard.ContainsText() || Clipboard.ContainsImage() || Clipboard.ContainsFileDropList()) - { - originalClipboard = Clipboard.GetDataObject(); - } - } - catch (Exception ex) - { - LogToStderr($"Error saving original clipboard: {ex.Message}"); - } - }); - saveThread.SetApartmentState(ApartmentState.STA); - saveThread.Start(); - saveThread.Join(); - - LogToStderr($"Original clipboard saved. Sequence number: {originalSequenceNumber}"); + // Save original clipboard content + var savedContent = clipboardService.Save(); + var originalSeq = clipboardService.GetSequenceNumber(); + LogToStderr($"Original clipboard saved. Sequence number: {originalSeq}"); // Set new clipboard content - Thread setThread = new Thread(() => - { - try - { - Clipboard.SetText(text); - } - catch (Exception ex) - { - LogToStderr($"Error setting clipboard: {ex.Message}"); - } - }); - setThread.SetApartmentState(ApartmentState.STA); - setThread.Start(); - setThread.Join(); - - int newSequenceNumber = GetClipboardSequenceNumber(); - LogToStderr($"Clipboard set. New sequence number: {newSequenceNumber}"); + clipboardService.SetText(text); + var newSeq = clipboardService.GetSequenceNumber(); + LogToStderr($"Clipboard set. New sequence number: {newSeq}"); // Small delay to ensure clipboard is set Thread.Sleep(50); @@ -123,64 +64,44 @@ namespace WindowsHelper.Services LogToStderr("Paste command sent successfully"); - // Restore original clipboard after delay (like Swift's 200ms) - if (originalClipboard != null) + // Wait for paste to complete before restoring + Thread.Sleep(200); + + // Restore original clipboard synchronously and report errors + var restoreError = clipboardService.RestoreSync(savedContent, newSeq); + if (restoreError != null) { - Task.Run(async () => - { - await Task.Delay(200); - RestoreClipboard(originalClipboard, newSequenceNumber); - }); + // Paste succeeded but restore failed - report as partial success + errorMessage = $"Paste succeeded but clipboard restore failed: {restoreError}"; + LogToStderr(errorMessage); + // Still return true since the paste itself worked } return true; } catch (Exception ex) { - LogToStderr($"Error in PasteText: {ex.Message}"); + var detail = BuildExceptionDetail("Error in PasteText", ex); + LogException("Error in PasteText", ex); + errorMessage = detail; return false; } } - private void RestoreClipboard(IDataObject originalClipboard, int expectedSequenceNumber) + private string BuildExceptionDetail(string context, Exception ex) { - Thread restoreThread = new Thread(() => - { - try - { - int currentSequenceNumber = GetClipboardSequenceNumber(); - - // Only restore if our temporary content is still on the clipboard - // (sequence number incremented by exactly 1 from when we set it) - if (currentSequenceNumber == expectedSequenceNumber) - { - Clipboard.SetDataObject(originalClipboard, true); - LogToStderr("Original clipboard content restored."); - } - else - { - // Another app modified the clipboard - don't interfere - LogToStderr($"Clipboard changed by another process (expected: {expectedSequenceNumber}, current: {currentSequenceNumber}); not restoring to avoid conflict."); - } - } - catch (Exception ex) - { - LogToStderr($"Error restoring clipboard: {ex.Message}"); - } - }); - restoreThread.SetApartmentState(ApartmentState.STA); - restoreThread.Start(); - restoreThread.Join(); + return $"{context}: {ex.GetType().Name} (0x{ex.HResult:X8}): {ex.Message}"; } - private string GetWindowTitle(IntPtr hwnd) + private void LogException(string context, Exception ex) { - int length = GetWindowTextLength(hwnd); - if (length == 0) return string.Empty; - - StringBuilder sb = new StringBuilder(length + 1); - GetWindowText(hwnd, sb, sb.Capacity); - return sb.ToString(); + var detail = BuildExceptionDetail(context, ex); + var stack = ex.StackTrace; + if (!string.IsNullOrWhiteSpace(stack)) + { + detail = $"{detail} | StackTrace: {stack.Replace(Environment.NewLine, " | ")}"; + } + LogToStderr(detail); } private void LogToStderr(string message) @@ -190,4 +111,4 @@ namespace WindowsHelper.Services Console.Error.Flush(); } } -} \ No newline at end of file +} diff --git a/packages/native-helpers/windows-helper/src/Services/ClipboardService.cs b/packages/native-helpers/windows-helper/src/Services/ClipboardService.cs new file mode 100644 index 0000000..08f660f --- /dev/null +++ b/packages/native-helpers/windows-helper/src/Services/ClipboardService.cs @@ -0,0 +1,250 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using System.Windows.Forms; + +namespace WindowsHelper.Services +{ + /// + /// Handles clipboard operations using a shared STA thread. + /// Provides save/set/restore functionality for clipboard content. + /// + public class ClipboardService + { + [DllImport("user32.dll")] + private static extern int GetClipboardSequenceNumber(); + + private readonly StaThreadRunner staRunner; + + internal enum StoredDataType { Direct, Image, Stream } + + /// + /// Holds cloned clipboard data to avoid COM object threading issues. + /// Tracks original type so we can restore Image as Image, Stream as Stream. + /// + internal class ClipboardContent + { + public Dictionary Formats { get; } = new Dictionary(); + public bool OriginalWasEmpty { get; set; } + public bool SaveFailed { get; set; } // Track save failure separately - don't clear clipboard on restore + } + + public ClipboardService(StaThreadRunner staRunner) + { + this.staRunner = staRunner; + } + + /// + /// Gets the current clipboard sequence number. + /// + public int GetSequenceNumber() + { + return GetClipboardSequenceNumber(); + } + + /// + /// Saves the current clipboard content to memory. + /// Must be called from STA thread or via StaThreadRunner. + /// + internal ClipboardContent Save() + { + ThrowIfNotRunning(); + return staRunner.InvokeOnSta(() => DoSave()).Result; + } + + /// + /// Sets the clipboard to the specified text. + /// + public void SetText(string text) + { + ThrowIfNotRunning(); + staRunner.InvokeOnSta(() => + { + Clipboard.SetText(text); + return true; + }).Wait(); + } + + private void ThrowIfNotRunning() + { + if (!staRunner.IsRunning) + { + throw new InvalidOperationException("StaThreadRunner is not running. Call Start() before using ClipboardService."); + } + } + + /// + /// Restores previously saved clipboard content synchronously. + /// Returns error message if restore failed, null on success. + /// + internal string? RestoreSync(ClipboardContent content, int expectedSeq) + { + ThrowIfNotRunning(); + return staRunner.InvokeOnSta(() => DoRestore(content, expectedSeq)).Result; + } + + private ClipboardContent DoSave() + { + var content = new ClipboardContent(); + + try + { + var dataObject = Clipboard.GetDataObject(); + + if (dataObject == null) + { + // Can't read clipboard - might be busy/locked, NOT necessarily empty + // Mark as failed so we don't wipe it on restore + LogToStderr("Clipboard.GetDataObject() returned null - clipboard may be busy"); + content.SaveFailed = true; + return content; + } + + var formats = dataObject.GetFormats(); + content.OriginalWasEmpty = formats.Length == 0; + + foreach (var format in formats) + { + try + { + var data = dataObject.GetData(format); + if (data == null) continue; + + if (data is System.Drawing.Image img) + { + // COM object - must convert to bytes, track as Image + using (var ms = new MemoryStream()) + { + img.Save(ms, System.Drawing.Imaging.ImageFormat.Png); + content.Formats[format] = (ms.ToArray(), StoredDataType.Image); + } + } + else if (data is MemoryStream ms) + { + // Stream - convert to bytes, track as Stream + content.Formats[format] = (ms.ToArray(), StoredDataType.Stream); + } + else if (data is string || data is byte[] || data is string[] || + data is System.Collections.Specialized.StringCollection) + { + // Already thread-safe - keep as-is + content.Formats[format] = (data, StoredDataType.Direct); + } + // Skip other COM objects we can't handle + } + catch (Exception ex) + { + LogToStderr($"Could not save clipboard format '{format}': {ex.Message}"); + } + } + } + catch (Exception ex) + { + LogToStderr($"Error saving clipboard: {ex.Message}"); + content.SaveFailed = true; // Don't set OriginalWasEmpty - that would clear clipboard on restore + } + + return content; + } + + /// + /// Returns null on success, error message on failure. + /// + private string? DoRestore(ClipboardContent content, int expectedSeq) + { + try + { + // If save failed, don't touch the clipboard - we don't know what was there + if (content.SaveFailed) + { + var msg = "Save failed earlier; skipping restore to avoid data loss."; + LogToStderr(msg); + return msg; + } + + int currentSeq = GetClipboardSequenceNumber(); + + // Only restore if our temporary content is still on the clipboard + if (currentSeq != expectedSeq) + { + // Not an error - clipboard was changed by user/another app + LogToStderr($"Clipboard changed by another process (expected: {expectedSeq}, current: {currentSeq}); not restoring."); + return null; + } + + if (content.OriginalWasEmpty) + { + Clipboard.Clear(); + LogToStderr("Clipboard cleared (original was empty)."); + return null; + } + + if (content.Formats.Count == 0) + { + var msg = "No clipboard formats could be captured; cannot restore original content."; + LogToStderr(msg); + return msg; + } + + var dataObject = new DataObject(); + + foreach (var kvp in content.Formats) + { + try + { + var (data, dataType) = kvp.Value; + + switch (dataType) + { + case StoredDataType.Image: + // Restore as Image from PNG bytes + using (var ms = new MemoryStream((byte[])data)) + { + using (var img = System.Drawing.Image.FromStream(ms)) + { + // Clone because Image.FromStream requires stream to stay open + dataObject.SetData(kvp.Key, new System.Drawing.Bitmap(img)); + } + } + break; + + case StoredDataType.Stream: + // Restore as MemoryStream + dataObject.SetData(kvp.Key, new MemoryStream((byte[])data)); + break; + + default: + // Direct types - pass through + dataObject.SetData(kvp.Key, data); + break; + } + } + catch (Exception ex) + { + LogToStderr($"Could not restore clipboard format '{kvp.Key}': {ex.Message}"); + // Continue trying other formats + } + } + + Clipboard.SetDataObject(dataObject, true); + LogToStderr($"Original clipboard content restored ({content.Formats.Count} formats)."); + return null; + } + catch (Exception ex) + { + var msg = $"Error restoring clipboard: {ex.Message}"; + LogToStderr(msg); + return msg; + } + } + + private void LogToStderr(string message) + { + var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff"); + Console.Error.WriteLine($"[{timestamp}] [ClipboardService] {message}"); + Console.Error.Flush(); + } + } +} diff --git a/packages/native-helpers/windows-helper/src/ShortcutMonitor.cs b/packages/native-helpers/windows-helper/src/ShortcutMonitor.cs index 9939efd..1bdca85 100644 --- a/packages/native-helpers/windows-helper/src/ShortcutMonitor.cs +++ b/packages/native-helpers/windows-helper/src/ShortcutMonitor.cs @@ -1,13 +1,14 @@ 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 { + /// + /// Monitors global keyboard shortcuts using low-level hooks. + /// Uses StaThreadRunner for STA thread execution. + /// public class ShortcutMonitor { #region Windows API @@ -17,13 +18,6 @@ namespace WindowsHelper 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)] @@ -38,15 +32,6 @@ namespace WindowsHelper [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 { @@ -63,8 +48,7 @@ namespace WindowsHelper 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; @@ -74,14 +58,9 @@ namespace WindowsHelper private const int VK_RMENU = 0xA5; #endregion + private readonly StaThreadRunner staRunner; 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 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 @@ -93,7 +72,7 @@ namespace WindowsHelper 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; @@ -102,172 +81,75 @@ namespace WindowsHelper public event EventHandler? KeyEventOccurred; - /// - /// Invokes an async action on the STA thread and waits for completion. - /// Use this for audio/COM operations that require STA thread. - /// - public Task InvokeOnStaAsync(Func> asyncAction) + public ShortcutMonitor(StaThreadRunner staRunner) { - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - staWorkQueue.Enqueue(async () => - { - try - { - var result = await asyncAction(); - tcs.SetResult(result); - } - catch (Exception ex) - { - tcs.SetException(ex); - } - }); - staWorkEvent.Set(); - - return tcs.Task; + this.staRunner = staRunner; } /// - /// Invokes a synchronous action on the STA thread and waits for completion. - /// Use this for audio/COM operations that require STA thread. + /// Installs the keyboard hook on the STA thread. /// - public Task InvokeOnSta(Func action) - { - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - staWorkQueue.Enqueue(() => - { - try - { - var result = action(); - tcs.SetResult(result); - } - catch (Exception ex) - { - tcs.SetException(ex); - } - }); - staWorkEvent.Set(); - - return tcs.Task; - } - - /// - /// Posts an action to the STA thread without waiting for completion. - /// - public void PostToSta(Action action) - { - staWorkQueue.Enqueue(action); - staWorkEvent.Set(); - } - public void Start() { - if (isRunning) return; + // Guard against multiple hook installations + if (hookId != IntPtr.Zero) return; - isRunning = true; - messageLoopThread = new Thread(MessageLoop) + staRunner.InvokeOnSta(() => { - Name = "ShortcutHook", - IsBackground = false - }; - messageLoopThread.SetApartmentState(ApartmentState.STA); - messageLoopThread.Start(); + InstallHook(); + return true; + }).Wait(); } + /// + /// Removes the keyboard hook. Must be called before StaThreadRunner.Stop(). + /// public void Stop() { - isRunning = false; - if (hookId != IntPtr.Zero) - { - UnhookWindowsHookEx(hookId); - hookId = IntPtr.Zero; - } - } + if (hookId == IntPtr.Zero) return; - 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 + // Unhook must be called from the same thread that installed the hook + var task = staRunner.InvokeOnSta(() => { if (hookId != IntPtr.Zero) { UnhookWindowsHookEx(hookId); hookId = IntPtr.Zero; + LogToStderr("Shortcut hook removed"); } + return true; + }); + + // Wait with timeout to prevent hang if STA thread is already stopped + if (!task.Wait(TimeSpan.FromSeconds(5))) + { + LogToStderr("Warning: Timeout waiting to unhook - STA thread may be unresponsive"); } } - private void ProcessStaWorkQueue() + private void InstallHook() { - while (staWorkQueue.TryDequeue(out var action)) + // Keep a reference to the delegate to prevent GC + hookProc = HookCallback; + + using (Process curProcess = Process.GetCurrentProcess()) + using (ProcessModule? curModule = curProcess.MainModule) { - try + if (curModule != null) { - action(); - } - catch (Exception ex) - { - LogToStderr($"Error processing STA work item: {ex.Message}"); + hookId = SetWindowsHookEx(WH_KEYBOARD_LL, hookProc, + GetModuleHandle(curModule.ModuleName), 0); } } + + if (hookId == IntPtr.Zero) + { + LogToStderr("Failed to install shortcut hook"); + } + else + { + LogToStderr("Shortcut hook installed successfully"); + } } private IntPtr HookCallback(int nCode, IntPtr wParam, IntPtr lParam) @@ -307,7 +189,6 @@ namespace WindowsHelper 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, @@ -330,8 +211,6 @@ namespace WindowsHelper KeyEventOccurred?.Invoke(this, keyEvent); // Track regular key state for multi-key shortcuts - // We need to track which non-modifier keys are held down so that - // shortcuts like Shift+A+B can work properly var keyName = VirtualKeyMap.GetKeyName((int)kbStruct.vkCode); if (keyName != null) { @@ -346,7 +225,6 @@ namespace WindowsHelper } // Check if this key event should be consumed (prevent default behavior) - // Only for regular key events, not modifiers var modifierState = new ModifierState { Win = winPressed, @@ -377,13 +255,9 @@ namespace WindowsHelper 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; @@ -391,7 +265,6 @@ namespace WindowsHelper } else { - // On release, clear left unless right is still pressed if (!rightShiftPressed) { leftShiftPressed = false; @@ -430,7 +303,7 @@ namespace WindowsHelper } } break; - + // Handle left/right specific codes (what Windows low-level hooks actually send) case VK_LSHIFT: leftShiftPressed = isPressed; @@ -459,11 +332,6 @@ namespace WindowsHelper } } - 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 || @@ -478,34 +346,5 @@ namespace WindowsHelper 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 } -} \ No newline at end of file +} diff --git a/packages/native-helpers/windows-helper/src/StaThreadRunner.cs b/packages/native-helpers/windows-helper/src/StaThreadRunner.cs new file mode 100644 index 0000000..85e49ee --- /dev/null +++ b/packages/native-helpers/windows-helper/src/StaThreadRunner.cs @@ -0,0 +1,217 @@ +using System; +using System.Collections.Concurrent; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; + +namespace WindowsHelper +{ + /// + /// Owns and manages a single STA thread for Windows operations that require + /// single-threaded apartment (clipboard, COM, UI automation, etc). + /// Provides thread-safe dispatch methods for executing work on the STA thread. + /// + public class StaThreadRunner + { + #region Windows API + private const uint QS_ALLINPUT = 0x04FF; + private const uint WAIT_OBJECT_0 = 0; + private const uint INFINITE = 0xFFFFFFFF; + private const int PM_REMOVE = 0x0001; + + [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); + + [DllImport("user32.dll")] + private static extern bool TranslateMessage(ref MSG lpMsg); + + [DllImport("user32.dll")] + private static extern IntPtr DispatchMessage(ref MSG lpMsg); + + [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; + } + #endregion + + private Thread? thread; + private readonly ConcurrentQueue workQueue = new(); + private readonly AutoResetEvent workEvent = new(false); + private volatile bool isRunning; + private int staThreadId; + + /// + /// Returns true if the STA thread is running and accepting work. + /// + public bool IsRunning => isRunning; + + /// + /// Returns true if the current thread is the STA thread. + /// + public bool IsOnStaThread => Thread.CurrentThread.ManagedThreadId == staThreadId; + + /// + /// Starts the STA thread and message loop. + /// + public void Start() + { + if (isRunning) return; + + isRunning = true; + thread = new Thread(MessageLoop) + { + Name = "StaThreadRunner", + IsBackground = false + }; + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + } + + /// + /// Stops the STA thread and cleans up resources. + /// + public void Stop() + { + isRunning = false; + workEvent.Set(); // Wake up the message loop to exit + } + + /// + /// Invokes a synchronous action on the STA thread and waits for completion. + /// If already on the STA thread, executes directly to avoid deadlock. + /// For async work, pass a lambda that blocks: () => asyncMethod().GetAwaiter().GetResult() + /// This ensures the entire operation stays on the STA thread. + /// + public Task InvokeOnSta(Func action) + { + // If already on STA thread, execute directly to avoid deadlock + if (IsOnStaThread) + { + try + { + return Task.FromResult(action()); + } + catch (Exception ex) + { + return Task.FromException(ex); + } + } + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + workQueue.Enqueue(() => + { + try + { + var result = action(); + tcs.SetResult(result); + } + catch (Exception ex) + { + tcs.SetException(ex); + } + }); + workEvent.Set(); + + return tcs.Task; + } + + /// + /// Posts an action to the STA thread without waiting for completion (fire and forget). + /// + public void PostToSta(Action action) + { + workQueue.Enqueue(action); + workEvent.Set(); + } + + private void MessageLoop() + { + try + { + staThreadId = Thread.CurrentThread.ManagedThreadId; + LogToStderr($"STA thread started (thread ID: {staThreadId})"); + + // Keep SafeWaitHandle alive during the entire loop to prevent GC issues with DangerousGetHandle + var safeHandle = workEvent.SafeWaitHandle; + var waitHandles = new IntPtr[] { safeHandle.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 + ProcessWorkQueue(); + } + 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); + } + } + } + + // Prevent safeHandle from being GC'd during loop + GC.KeepAlive(safeHandle); + } + catch (Exception ex) + { + LogToStderr($"Error in STA message loop: {ex.Message}"); + } + finally + { + LogToStderr("STA thread stopped"); + } + } + + private void ProcessWorkQueue() + { + while (workQueue.TryDequeue(out var action)) + { + try + { + action(); + } + catch (Exception ex) + { + LogToStderr($"Error processing STA work item: {ex.Message}"); + } + } + } + + private void LogToStderr(string message) + { + var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff"); + Console.Error.WriteLine($"[{timestamp}] [StaThreadRunner] {message}"); + Console.Error.Flush(); + } + } +}