using System.Runtime.InteropServices; using System.Threading; using MeetingAssistant.LaunchProfiles; using MeetingAssistant.Recording; namespace MeetingAssistant.Hotkeys; public sealed class GlobalHotkeyService : BackgroundService { private const int HotkeyIdBase = 0x4D41; private const int WmHotkey = 0x0312; private const int WmQuit = 0x0012; private readonly ILaunchProfileOptionsProvider launchProfiles; private readonly MeetingRecordingCoordinator coordinator; private readonly ILogger logger; private readonly Dictionary registeredHotkeys = []; private TaskCompletionSource? messageLoopCompletion; private uint messageThreadId; public GlobalHotkeyService( ILaunchProfileOptionsProvider launchProfiles, MeetingRecordingCoordinator coordinator, ILogger logger) { this.launchProfiles = launchProfiles; 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 hotkeys = launchProfiles.GetHotkeys(); messageThreadId = GetCurrentThreadId(); using var stoppingRegistration = stoppingToken.Register(() => PostQuitToMessageThread()); for (var index = 0; index < hotkeys.Count; index++) { var profileHotkey = hotkeys[index]; var hotkey = HotkeyDefinition.Parse(profileHotkey.Hotkey); var hotkeyId = HotkeyIdBase + index; if (!RegisterHotKey(IntPtr.Zero, hotkeyId, (uint)hotkey.Modifiers, hotkey.VirtualKey)) { logger.LogWarning( "Could not register global hotkey {Hotkey} for launch profile {LaunchProfile} action {Action}", profileHotkey.Hotkey, profileHotkey.ProfileName, profileHotkey.Action); continue; } registeredHotkeys[hotkeyId] = profileHotkey; logger.LogInformation( "Registered global hotkey {Hotkey} for launch profile {LaunchProfile} action {Action}", profileHotkey.Hotkey, profileHotkey.ProfileName, profileHotkey.Action); } try { while (!stoppingToken.IsCancellationRequested && GetMessage(out var message, IntPtr.Zero, 0, 0) > 0) { if (message.Message == WmHotkey && registeredHotkeys.TryGetValue((int)message.WParam, out var hotkey)) { _ = Task.Run(() => HandleHotkeyAsync(hotkey), CancellationToken.None); } } } finally { foreach (var hotkeyId in registeredHotkeys.Keys) { UnregisterHotKey(IntPtr.Zero, hotkeyId); } registeredHotkeys.Clear(); messageThreadId = 0; } } public override Task StopAsync(CancellationToken cancellationToken) { PostQuitToMessageThread(); return base.StopAsync(cancellationToken); } private async Task HandleHotkeyAsync(LaunchProfileHotkey hotkey) { switch (hotkey.Action) { case LaunchProfileHotkeyAction.ToggleRecording: await ToggleRecordingAsync(hotkey.ProfileName); break; case LaunchProfileHotkeyAction.AbortRecording: await AbortRecordingAsync(hotkey.ProfileName); break; case LaunchProfileHotkeyAction.CaptureScreenshot: await CaptureScreenshotAsync(hotkey.ProfileName); break; } } private async Task ToggleRecordingAsync(string profileName) { try { var status = await coordinator.ToggleAsync(profileName, CancellationToken.None); logger.LogInformation( "Hotkey toggled recording for launch profile {LaunchProfile}. IsRecording={IsRecording}", profileName, status.IsRecording); } catch (Exception exception) { logger.LogError(exception, "Hotkey toggle failed"); } } private async Task AbortRecordingAsync(string profileName) { try { var status = await coordinator.AbortAsync(CancellationToken.None); logger.LogInformation( "Hotkey aborted recording for launch profile {LaunchProfile}. IsRecording={IsRecording}", profileName, status.IsRecording); } catch (Exception exception) { logger.LogError(exception, "Hotkey abort failed"); } } private async Task CaptureScreenshotAsync(string profileName) { try { var result = await coordinator.CaptureScreenshotAsync(profileName, CancellationToken.None); logger.LogInformation( "Hotkey captured screenshot for launch profile {LaunchProfile}: {ScreenshotPath}", profileName, result.ScreenshotPath); } catch (Exception exception) { logger.LogError(exception, "Hotkey screenshot capture 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; } }