#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 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 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