Add tray rules and identities editor
PR and Push Build/Test / build-and-test (push) Successful in 7m0s

This commit is contained in:
2026-05-28 01:28:16 +02:00
parent 71f1e6a0b8
commit 693f52afee
31 changed files with 3136 additions and 327 deletions
+14 -3
View File
@@ -5,6 +5,7 @@ namespace MeetingAssistant.Taskbar;
public enum MeetingTaskbarAction
{
EditRules,
StartRecording,
StopRecording,
AbortRecording,
@@ -27,7 +28,10 @@ public static class MeetingTaskbarMenuBuilder
RecordingStatus status,
IReadOnlyList<LaunchProfile> launchProfiles)
{
var items = new List<MeetingTaskbarMenuItem>();
var items = new List<MeetingTaskbarMenuItem>
{
new("Edit rules and identities", MeetingTaskbarAction.EditRules)
};
if (status.IsRecording)
{
@@ -41,7 +45,7 @@ public static class MeetingTaskbarMenuBuilder
foreach (var profile in launchProfiles.Where(profile => !IsActiveProfile(profile, status)))
{
items.Add(new MeetingTaskbarMenuItem(
$"Switch to {profile.Name}",
AppendHotkey($"Switch to {profile.Name}", profile.Options.Hotkey.Toggle),
MeetingTaskbarAction.SwitchProfile,
profile.Name));
}
@@ -51,7 +55,7 @@ public static class MeetingTaskbarMenuBuilder
foreach (var profile in launchProfiles)
{
items.Add(new MeetingTaskbarMenuItem(
$"Start meeting recording ({profile.Name})",
AppendHotkey($"Start meeting recording ({profile.Name})", profile.Options.Hotkey.Toggle),
MeetingTaskbarAction.StartRecording,
profile.Name));
}
@@ -84,4 +88,11 @@ public static class MeetingTaskbarMenuBuilder
status.LaunchProfile,
StringComparison.OrdinalIgnoreCase);
}
private static string AppendHotkey(string text, string hotkey)
{
return string.IsNullOrWhiteSpace(hotkey)
? text
: $"{text}\t{hotkey.Trim()}";
}
}
@@ -0,0 +1,292 @@
#if WINDOWS
using System.Drawing;
using System.Runtime.InteropServices;
using H.NotifyIcon.Core;
using MeetingAssistant.LaunchProfiles;
using MeetingAssistant.Recording;
using MeetingAssistant.Workflow;
namespace MeetingAssistant.Taskbar;
public sealed class UnoTaskbarIconService : IHostedService, IDisposable
{
private static readonly Guid TrayIconId = Guid.Parse("91F3D8B7-E61F-4E73-98F5-29A665991C67");
private readonly MeetingRecordingCoordinator coordinator;
private readonly ILaunchProfileOptionsProvider launchProfiles;
private readonly IWorkflowRulesEditorWindowService rulesEditorWindow;
private readonly ILogger<UnoTaskbarIconService> logger;
private readonly object sync = new();
private CancellationTokenSource? refreshCancellation;
private Task? refreshTask;
private TrayIconWithContextMenu? trayIcon;
private Icon? currentIcon;
private RecordingProcessState? currentState;
private string? currentProfilesSignature;
private string? currentMenuSignature;
public UnoTaskbarIconService(
MeetingRecordingCoordinator coordinator,
ILaunchProfileOptionsProvider launchProfiles,
IWorkflowRulesEditorWindowService rulesEditorWindow,
ILogger<UnoTaskbarIconService> logger)
{
this.coordinator = coordinator;
this.launchProfiles = launchProfiles;
this.rulesEditorWindow = rulesEditorWindow;
this.logger = logger;
}
public Task StartAsync(CancellationToken cancellationToken)
{
if (!OperatingSystem.IsWindows())
{
logger.LogInformation("Taskbar icon is only supported on Windows");
return Task.CompletedTask;
}
var menu = BuildMenu();
currentMenuSignature = BuildMenuSignature(menu);
currentIcon = CreateIcon(menu.State);
currentState = menu.State;
trayIcon = new TrayIconWithContextMenu(TrayIconId)
{
Icon = currentIcon.Handle,
ToolTip = menu.Tooltip,
ContextMenu = BuildContextMenu(menu)
};
trayIcon.Created += (_, _) => logger.LogInformation(
"Taskbar icon native create event received: isCreated={IsCreated}",
trayIcon.IsCreated);
trayIcon.Removed += (_, _) => logger.LogInformation("Taskbar icon native remove event received");
trayIcon.Create();
trayIcon.Show();
logger.LogInformation(
"Taskbar icon created through H.NotifyIcon: isCreated={IsCreated}, state={State}, tooltip={Tooltip}",
trayIcon.IsCreated,
menu.State,
menu.Tooltip);
refreshCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
refreshTask = Task.Run(() => RefreshLoopAsync(refreshCancellation.Token), CancellationToken.None);
return Task.CompletedTask;
}
public async Task StopAsync(CancellationToken cancellationToken)
{
refreshCancellation?.Cancel();
if (refreshTask is not null)
{
try
{
await refreshTask.WaitAsync(cancellationToken);
}
catch (OperationCanceledException)
{
}
}
DisposeTrayIcon();
}
public void Dispose()
{
refreshCancellation?.Cancel();
refreshCancellation?.Dispose();
DisposeTrayIcon();
}
private async Task RefreshLoopAsync(CancellationToken cancellationToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
while (await timer.WaitForNextTickAsync(cancellationToken))
{
try
{
RefreshVisualState();
}
catch (Exception exception)
{
logger.LogError(exception, "Taskbar icon refresh failed");
}
}
}
private void RefreshVisualState()
{
lock (sync)
{
if (trayIcon is null)
{
return;
}
var menu = BuildMenu();
var menuSignature = BuildMenuSignature(menu);
if (currentState != menu.State)
{
var previousIcon = currentIcon;
var nextIcon = CreateIcon(menu.State);
if (!trayIcon.UpdateIcon(nextIcon.Handle))
{
logger.LogWarning("Taskbar icon update returned false for state {State}", menu.State);
}
currentIcon = nextIcon;
currentState = menu.State;
previousIcon?.Dispose();
logger.LogInformation("Taskbar icon state changed to {State}", menu.State);
}
trayIcon.UpdateToolTip(menu.Tooltip);
if (!string.Equals(currentMenuSignature, menuSignature, StringComparison.Ordinal))
{
trayIcon.ContextMenu = BuildContextMenu(menu);
currentMenuSignature = menuSignature;
logger.LogInformation("Taskbar menu changed: {MenuItems}", menuSignature);
}
}
}
private void DisposeTrayIcon()
{
lock (sync)
{
trayIcon?.Dispose();
trayIcon = null;
currentIcon?.Dispose();
currentIcon = null;
currentState = null;
currentMenuSignature = null;
}
}
private MeetingTaskbarMenu BuildMenu()
{
var profiles = launchProfiles.GetProfiles();
var profilesSignature = string.Join(
", ",
profiles.Select(profile => $"{profile.Name}:{profile.Options.Hotkey.Toggle}"));
if (!string.Equals(currentProfilesSignature, profilesSignature, StringComparison.Ordinal))
{
currentProfilesSignature = profilesSignature;
logger.LogInformation("Taskbar menu launch profiles: {LaunchProfiles}", profilesSignature);
}
return MeetingTaskbarMenuBuilder.Build(
coordinator.CurrentStatus,
profiles);
}
private PopupMenu BuildContextMenu(MeetingTaskbarMenu menu)
{
var popupMenu = new PopupMenu();
for (var index = 0; index < menu.Items.Count; index++)
{
if (index == 1)
{
popupMenu.Items.Add(new PopupMenuSeparator());
}
var menuItem = menu.Items[index];
popupMenu.Items.Add(new PopupMenuItem(
menuItem.Text,
(_, _) => _ = ExecuteMenuItemAsync(menuItem)));
}
return popupMenu;
}
private async Task ExecuteMenuItemAsync(MeetingTaskbarMenuItem item)
{
try
{
logger.LogInformation(
"Taskbar menu action {Action} selected for launch profile {LaunchProfile}",
item.Action,
item.ProfileName);
switch (item.Action)
{
case MeetingTaskbarAction.EditRules:
rulesEditorWindow.Show();
break;
case MeetingTaskbarAction.StartRecording:
await coordinator.StartAsync(item.ProfileName, CancellationToken.None);
break;
case MeetingTaskbarAction.StopRecording:
await coordinator.StopAsync(CancellationToken.None);
break;
case MeetingTaskbarAction.AbortRecording:
await coordinator.AbortAsync(CancellationToken.None);
break;
case MeetingTaskbarAction.SwitchProfile:
await coordinator.ToggleAsync(item.ProfileName, CancellationToken.None);
break;
}
RefreshVisualState();
logger.LogInformation(
"Taskbar menu action {Action} completed for launch profile {LaunchProfile}",
item.Action,
item.ProfileName);
}
catch (Exception exception)
{
logger.LogError(
exception,
"Taskbar menu action {Action} failed for launch profile {LaunchProfile}",
item.Action,
item.ProfileName);
}
}
private static string BuildMenuSignature(MeetingTaskbarMenu menu)
{
return string.Join(
"|",
menu.Items.Select(item => $"{item.Action}:{item.ProfileName}:{item.Text}"));
}
private static Icon CreateIcon(RecordingProcessState state)
{
var (color, text) = state switch
{
RecordingProcessState.Recording => (Color.FromArgb(220, 38, 38), "R"),
RecordingProcessState.Summarizing => (Color.FromArgb(37, 99, 235), "S"),
_ => (Color.FromArgb(75, 85, 99), "I")
};
using var bitmap = new Bitmap(16, 16);
using (var graphics = Graphics.FromImage(bitmap))
{
graphics.Clear(Color.Transparent);
graphics.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
using var brush = new SolidBrush(color);
graphics.FillEllipse(brush, 1, 1, 14, 14);
using var font = new Font(FontFamily.GenericSansSerif, 8, FontStyle.Bold, GraphicsUnit.Pixel);
using var textBrush = new SolidBrush(Color.White);
var size = graphics.MeasureString(text, font);
graphics.DrawString(
text,
font,
textBrush,
(16 - size.Width) / 2,
(16 - size.Height) / 2);
}
var handle = bitmap.GetHicon();
try
{
return (Icon)Icon.FromHandle(handle).Clone();
}
finally
{
DestroyIcon(handle);
}
}
[DllImport("user32.dll", SetLastError = true)]
private static extern bool DestroyIcon(IntPtr hIcon);
}
#endif
@@ -1,243 +0,0 @@
using System.Drawing;
using System.Runtime.InteropServices;
using System.Windows.Forms;
using MeetingAssistant.LaunchProfiles;
using MeetingAssistant.Recording;
namespace MeetingAssistant.Taskbar;
public sealed class WindowsTaskbarIconService : IHostedService, IDisposable
{
private readonly MeetingRecordingCoordinator coordinator;
private readonly ILaunchProfileOptionsProvider launchProfiles;
private readonly ILogger<WindowsTaskbarIconService> logger;
private readonly TaskCompletionSource started = new(TaskCreationOptions.RunContinuationsAsynchronously);
private Thread? uiThread;
private SynchronizationContext? uiContext;
private NotifyIcon? notifyIcon;
private ContextMenuStrip? contextMenu;
private System.Windows.Forms.Timer? refreshTimer;
private Icon? currentIcon;
private RecordingProcessState? currentState;
public WindowsTaskbarIconService(
MeetingRecordingCoordinator coordinator,
ILaunchProfileOptionsProvider launchProfiles,
ILogger<WindowsTaskbarIconService> logger)
{
this.coordinator = coordinator;
this.launchProfiles = launchProfiles;
this.logger = logger;
}
public Task StartAsync(CancellationToken cancellationToken)
{
if (!OperatingSystem.IsWindows())
{
logger.LogInformation("Taskbar icon is only supported on Windows");
return Task.CompletedTask;
}
uiThread = new Thread(RunMessageLoop)
{
IsBackground = true,
Name = "Meeting Assistant Taskbar Icon"
};
uiThread.SetApartmentState(ApartmentState.STA);
uiThread.Start();
return started.Task.WaitAsync(cancellationToken);
}
public async Task StopAsync(CancellationToken cancellationToken)
{
var context = uiContext;
if (context is not null)
{
context.Post(_ => Application.ExitThread(), null);
}
if (uiThread is not null)
{
while (uiThread.IsAlive && !cancellationToken.IsCancellationRequested)
{
await Task.Delay(50, cancellationToken);
}
}
}
public void Dispose()
{
currentIcon?.Dispose();
contextMenu?.Dispose();
notifyIcon?.Dispose();
refreshTimer?.Dispose();
}
private void RunMessageLoop()
{
try
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
uiContext = SynchronizationContext.Current ?? new WindowsFormsSynchronizationContext();
SynchronizationContext.SetSynchronizationContext(uiContext);
contextMenu = new ContextMenuStrip();
contextMenu.Opening += (_, _) => RebuildMenu();
notifyIcon = new NotifyIcon
{
ContextMenuStrip = contextMenu,
Visible = true
};
refreshTimer = new System.Windows.Forms.Timer
{
Interval = 1000
};
refreshTimer.Tick += (_, _) => RefreshVisualState();
refreshTimer.Start();
RefreshVisualState();
started.TrySetResult();
Application.Run();
}
catch (Exception exception)
{
started.TrySetException(exception);
logger.LogError(exception, "Taskbar icon service failed");
}
finally
{
refreshTimer?.Stop();
if (notifyIcon is not null)
{
notifyIcon.Visible = false;
}
Dispose();
}
}
private void RefreshVisualState()
{
if (notifyIcon is null)
{
return;
}
var menu = BuildMenu();
if (currentState != menu.State)
{
var nextIcon = CreateIcon(menu.State);
notifyIcon.Icon = nextIcon;
currentIcon?.Dispose();
currentIcon = nextIcon;
currentState = menu.State;
}
notifyIcon.Text = TruncateTooltip(menu.Tooltip);
}
private void RebuildMenu()
{
if (contextMenu is null)
{
return;
}
var menu = BuildMenu();
contextMenu.Items.Clear();
foreach (var menuItem in menu.Items)
{
var item = new ToolStripMenuItem(menuItem.Text)
{
Tag = menuItem
};
item.Click += (_, _) => _ = ExecuteMenuItemAsync(menuItem);
contextMenu.Items.Add(item);
}
}
private MeetingTaskbarMenu BuildMenu()
{
return MeetingTaskbarMenuBuilder.Build(
coordinator.CurrentStatus,
launchProfiles.GetProfiles());
}
private async Task ExecuteMenuItemAsync(MeetingTaskbarMenuItem item)
{
try
{
switch (item.Action)
{
case MeetingTaskbarAction.StartRecording:
await coordinator.StartAsync(item.ProfileName, CancellationToken.None);
break;
case MeetingTaskbarAction.StopRecording:
await coordinator.StopAsync(CancellationToken.None);
break;
case MeetingTaskbarAction.AbortRecording:
await coordinator.AbortAsync(CancellationToken.None);
break;
case MeetingTaskbarAction.SwitchProfile:
await coordinator.ToggleAsync(item.ProfileName, CancellationToken.None);
break;
}
}
catch (Exception exception)
{
logger.LogError(
exception,
"Taskbar menu action {Action} failed for launch profile {LaunchProfile}",
item.Action,
item.ProfileName);
}
}
private static string TruncateTooltip(string tooltip)
{
return tooltip.Length <= 63 ? tooltip : tooltip[..63];
}
private static Icon CreateIcon(RecordingProcessState state)
{
var (color, text) = state switch
{
RecordingProcessState.Recording => (Color.FromArgb(220, 38, 38), "R"),
RecordingProcessState.Summarizing => (Color.FromArgb(37, 99, 235), "S"),
_ => (Color.FromArgb(75, 85, 99), "I")
};
using var bitmap = new Bitmap(16, 16);
using (var graphics = Graphics.FromImage(bitmap))
{
graphics.Clear(Color.Transparent);
graphics.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
using var brush = new SolidBrush(color);
graphics.FillEllipse(brush, 1, 1, 14, 14);
using var font = new Font(FontFamily.GenericSansSerif, 8, FontStyle.Bold, GraphicsUnit.Pixel);
using var textBrush = new SolidBrush(Color.White);
var size = graphics.MeasureString(text, font);
graphics.DrawString(
text,
font,
textBrush,
(16 - size.Width) / 2,
(16 - size.Height) / 2);
}
var handle = bitmap.GetHicon();
try
{
return (Icon)Icon.FromHandle(handle).Clone();
}
finally
{
DestroyIcon(handle);
}
}
[DllImport("user32.dll", SetLastError = true)]
private static extern bool DestroyIcon(IntPtr hIcon);
}