fix: clipboard restoration on windows

This commit is contained in:
haritabh-z01 2026-01-20 18:45:44 +05:30
parent c90cfa7021
commit 45c7a8d166
6 changed files with 662 additions and 391 deletions

View file

@ -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.");
}

View file

@ -1,5 +1,4 @@
using System;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@ -8,31 +7,62 @@ 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<string>? 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.");
@ -234,21 +264,31 @@ namespace WindowsHelper
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

View file

@ -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)

View file

@ -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
{
/// <summary>
/// Handles clipboard operations using a shared STA thread.
/// Provides save/set/restore functionality for clipboard content.
/// </summary>
public class ClipboardService
{
[DllImport("user32.dll")]
private static extern int GetClipboardSequenceNumber();
private readonly StaThreadRunner staRunner;
internal enum StoredDataType { Direct, Image, Stream }
/// <summary>
/// Holds cloned clipboard data to avoid COM object threading issues.
/// Tracks original type so we can restore Image as Image, Stream as Stream.
/// </summary>
internal class ClipboardContent
{
public Dictionary<string, (object Data, StoredDataType Type)> Formats { get; } = new Dictionary<string, (object, StoredDataType)>();
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;
}
/// <summary>
/// Gets the current clipboard sequence number.
/// </summary>
public int GetSequenceNumber()
{
return GetClipboardSequenceNumber();
}
/// <summary>
/// Saves the current clipboard content to memory.
/// Must be called from STA thread or via StaThreadRunner.
/// </summary>
internal ClipboardContent Save()
{
ThrowIfNotRunning();
return staRunner.InvokeOnSta(() => DoSave()).Result;
}
/// <summary>
/// Sets the clipboard to the specified text.
/// </summary>
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.");
}
}
/// <summary>
/// Restores previously saved clipboard content synchronously.
/// Returns error message if restore failed, null on success.
/// </summary>
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;
}
/// <summary>
/// Returns null on success, error message on failure.
/// </summary>
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();
}
}
}

View file

@ -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
{
/// <summary>
/// Monitors global keyboard shortcuts using low-level hooks.
/// Uses StaThreadRunner for STA thread execution.
/// </summary>
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,7 +48,6 @@ 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;
@ -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<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
@ -102,172 +81,75 @@ namespace WindowsHelper
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)
public ShortcutMonitor(StaThreadRunner staRunner)
{
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;
this.staRunner = staRunner;
}
/// <summary>
/// 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.
/// </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;
// 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();
}
/// <summary>
/// Removes the keyboard hook. Must be called before StaThreadRunner.Stop().
/// </summary>
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;
@ -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
}
}

View file

@ -0,0 +1,217 @@
using System;
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
namespace WindowsHelper
{
/// <summary>
/// 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.
/// </summary>
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<Action> workQueue = new();
private readonly AutoResetEvent workEvent = new(false);
private volatile bool isRunning;
private int staThreadId;
/// <summary>
/// Returns true if the STA thread is running and accepting work.
/// </summary>
public bool IsRunning => isRunning;
/// <summary>
/// Returns true if the current thread is the STA thread.
/// </summary>
public bool IsOnStaThread => Thread.CurrentThread.ManagedThreadId == staThreadId;
/// <summary>
/// Starts the STA thread and message loop.
/// </summary>
public void Start()
{
if (isRunning) return;
isRunning = true;
thread = new Thread(MessageLoop)
{
Name = "StaThreadRunner",
IsBackground = false
};
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
}
/// <summary>
/// Stops the STA thread and cleans up resources.
/// </summary>
public void Stop()
{
isRunning = false;
workEvent.Set(); // Wake up the message loop to exit
}
/// <summary>
/// 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.
/// </summary>
public Task<T> InvokeOnSta<T>(Func<T> action)
{
// If already on STA thread, execute directly to avoid deadlock
if (IsOnStaThread)
{
try
{
return Task.FromResult(action());
}
catch (Exception ex)
{
return Task.FromException<T>(ex);
}
}
var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
workQueue.Enqueue(() =>
{
try
{
var result = action();
tcs.SetResult(result);
}
catch (Exception ex)
{
tcs.SetException(ex);
}
});
workEvent.Set();
return tcs.Task;
}
/// <summary>
/// Posts an action to the STA thread without waiting for completion (fire and forget).
/// </summary>
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();
}
}
}