Implement meeting assistant v1

This commit is contained in:
2026-05-20 02:06:16 +02:00
parent 90df1edc03
commit 0297bcc0f6
120 changed files with 11883 additions and 180 deletions
@@ -0,0 +1,150 @@
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<GlobalHotkeyService> logger;
private TaskCompletionSource? messageLoopCompletion;
private uint messageThreadId;
public GlobalHotkeyService(
IOptions<MeetingAssistantOptions> options,
MeetingRecordingCoordinator coordinator,
ILogger<GlobalHotkeyService> 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;
}
}
@@ -0,0 +1,72 @@
namespace MeetingAssistant.Hotkeys;
[Flags]
public enum HotkeyModifiers : uint
{
None = 0,
Alt = 0x0001,
Control = 0x0002,
Shift = 0x0004,
Windows = 0x0008
}
public sealed record HotkeyDefinition(HotkeyModifiers Modifiers, uint VirtualKey)
{
public static HotkeyDefinition Parse(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException("Hotkey cannot be empty.", nameof(value));
}
var modifiers = HotkeyModifiers.None;
uint? virtualKey = null;
foreach (var rawPart in value.Split('+', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var part = rawPart.ToUpperInvariant();
modifiers |= part switch
{
"CTRL" or "CONTROL" => HotkeyModifiers.Control,
"ALT" => HotkeyModifiers.Alt,
"SHIFT" => HotkeyModifiers.Shift,
"WIN" or "WINDOWS" => HotkeyModifiers.Windows,
_ => HotkeyModifiers.None
};
if (modifiers.HasFlagForToken(part))
{
continue;
}
virtualKey = ParseVirtualKey(part);
}
return virtualKey is null
? throw new FormatException($"Hotkey '{value}' does not contain a key.")
: new HotkeyDefinition(modifiers, virtualKey.Value);
}
private static uint ParseVirtualKey(string part)
{
if (part.Length == 1 && char.IsLetterOrDigit(part[0]))
{
return part[0];
}
if (part.Length is 2 or 3 && part[0] == 'F' && int.TryParse(part[1..], out var functionKey) && functionKey is >= 1 and <= 24)
{
return (uint)(0x70 + functionKey - 1);
}
throw new FormatException($"Unsupported hotkey key '{part}'.");
}
}
file static class HotkeyModifierExtensions
{
public static bool HasFlagForToken(this HotkeyModifiers _, string token)
{
return token is "CTRL" or "CONTROL" or "ALT" or "SHIFT" or "WIN" or "WINDOWS";
}
}