Files
meeting-assistant/MeetingAssistant/Taskbar/UnoTaskbarIconService.Windows.cs
T
codex 693f52afee
PR and Push Build/Test / build-and-test (push) Successful in 7m0s
Add tray rules and identities editor
2026-05-28 01:28:16 +02:00

293 lines
9.7 KiB
C#

#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