Files

223 lines
7.3 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, LaunchProfileHotkey> registeredHotkeys = [];
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.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;
}
}