using System.Runtime.InteropServices; using System.Threading; using MeetingAssistant.Recording; using Microsoft.Extensions.Options; namespace MeetingAssistant.Hotkeys; public sealed class GlobalHotkeyService : BackgroundService { private const int HotkeyId = 0x4D41; private const int WmHotkey = 0x0312; private const int WmQuit = 0x0012; private readonly MeetingAssistantOptions options; private readonly MeetingRecordingCoordinator coordinator; private readonly ILogger logger; private TaskCompletionSource? messageLoopCompletion; private uint messageThreadId; public GlobalHotkeyService( IOptions options, MeetingRecordingCoordinator coordinator, ILogger logger) { this.options = options.Value; this.coordinator = coordinator; this.logger = logger; } protected override Task ExecuteAsync(CancellationToken stoppingToken) { if (!OperatingSystem.IsWindows()) { logger.LogInformation("Global hotkey registration is only supported on Windows"); return Task.CompletedTask; } messageLoopCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var messageThread = new Thread(() => { try { RunMessageLoop(stoppingToken); messageLoopCompletion.TrySetResult(); } catch (Exception exception) { messageLoopCompletion.TrySetException(exception); } }) { IsBackground = true, Name = "Meeting Assistant Hotkey Loop" }; messageThread.Start(); return messageLoopCompletion.Task; } private void RunMessageLoop(CancellationToken stoppingToken) { var hotkey = HotkeyDefinition.Parse(options.Hotkey.Toggle); messageThreadId = GetCurrentThreadId(); using var stoppingRegistration = stoppingToken.Register(() => PostQuitToMessageThread()); if (!RegisterHotKey(IntPtr.Zero, HotkeyId, (uint)hotkey.Modifiers, hotkey.VirtualKey)) { logger.LogWarning("Could not register global hotkey {Hotkey}", options.Hotkey.Toggle); return; } logger.LogInformation("Registered global hotkey {Hotkey}", options.Hotkey.Toggle); try { while (!stoppingToken.IsCancellationRequested && GetMessage(out var message, IntPtr.Zero, 0, 0) > 0) { if (message.Message == WmHotkey) { _ = Task.Run(ToggleRecordingAsync, CancellationToken.None); } } } finally { UnregisterHotKey(IntPtr.Zero, HotkeyId); messageThreadId = 0; } } public override Task StopAsync(CancellationToken cancellationToken) { PostQuitToMessageThread(); return base.StopAsync(cancellationToken); } private async Task ToggleRecordingAsync() { try { var status = await coordinator.ToggleAsync(CancellationToken.None); logger.LogInformation("Hotkey toggled recording. IsRecording={IsRecording}", status.IsRecording); } catch (Exception exception) { logger.LogError(exception, "Hotkey toggle failed"); } } private void PostQuitToMessageThread() { var threadId = messageThreadId; if (threadId != 0) { PostThreadMessage(threadId, WmQuit, UIntPtr.Zero, IntPtr.Zero); } } [DllImport("user32.dll", SetLastError = true)] private static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk); [DllImport("user32.dll", SetLastError = true)] private static extern bool UnregisterHotKey(IntPtr hWnd, int id); [DllImport("user32.dll")] private static extern int GetMessage(out NativeMessage lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax); [DllImport("kernel32.dll")] private static extern uint GetCurrentThreadId(); [DllImport("user32.dll", SetLastError = true)] private static extern bool PostThreadMessage(uint idThread, int msg, UIntPtr wParam, IntPtr lParam); [StructLayout(LayoutKind.Sequential)] private struct NativeMessage { public IntPtr Hwnd; public int Message; public UIntPtr WParam; public IntPtr LParam; public uint Time; public NativePoint Point; } [StructLayout(LayoutKind.Sequential)] private struct NativePoint { public int X; public int Y; } }