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 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 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); }