fix: clipboard restoration on windows
This commit is contained in:
parent
c90cfa7021
commit
45c7a8d166
6 changed files with 662 additions and 391 deletions
|
|
@ -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.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
217
packages/native-helpers/windows-helper/src/StaThreadRunner.cs
Normal file
217
packages/native-helpers/windows-helper/src/StaThreadRunner.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue