Files
meeting-assistant/MeetingAssistant/Hotkeys/GlobalHotkeyService.cs
T
2026-05-27 12:55:17 +02:00

173 lines
5.6 KiB
C#

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<GlobalHotkeyService> logger;
private readonly Dictionary<int, string> hotkeyProfiles = [];
private TaskCompletionSource? messageLoopCompletion;
private uint messageThreadId;
public GlobalHotkeyService(
ILaunchProfileOptionsProvider launchProfiles,
MeetingRecordingCoordinator coordinator,
ILogger<GlobalHotkeyService> 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;
}
}