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 hotkeyProfiles = []; 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.Toggle); 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}", profileHotkey.Toggle, profileHotkey.ProfileName); continue; } hotkeyProfiles[hotkeyId] = profileHotkey.ProfileName; logger.LogInformation( "Registered global hotkey {Hotkey} for launch profile {LaunchProfile}", profileHotkey.Toggle, profileHotkey.ProfileName); } try { while (!stoppingToken.IsCancellationRequested && GetMessage(out var message, IntPtr.Zero, 0, 0) > 0) { if (message.Message == WmHotkey && hotkeyProfiles.TryGetValue((int)message.WParam, out var profileName)) { _ = Task.Run(() => ToggleRecordingAsync(profileName), CancellationToken.None); } } } finally { foreach (var hotkeyId in hotkeyProfiles.Keys) { UnregisterHotKey(IntPtr.Zero, hotkeyId); } hotkeyProfiles.Clear(); messageThreadId = 0; } } public override Task StopAsync(CancellationToken cancellationToken) { PostQuitToMessageThread(); return base.StopAsync(cancellationToken); } 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 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; } }