#if WINDOWS using System.Drawing; using System.Windows; 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 IMicrophoneDeviceProvider microphones; private readonly MicrophoneDeviceSelection microphoneSelection; private readonly IWorkflowRulesEditorWindowService rulesEditorWindow; private readonly IHostApplicationLifetime applicationLifetime; 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, IMicrophoneDeviceProvider microphones, MicrophoneDeviceSelection microphoneSelection, IWorkflowRulesEditorWindowService rulesEditorWindow, IHostApplicationLifetime applicationLifetime, ILogger logger) { this.coordinator = coordinator; this.launchProfiles = launchProfiles; this.microphones = microphones; this.microphoneSelection = microphoneSelection; this.rulesEditorWindow = rulesEditorWindow; this.applicationLifetime = applicationLifetime; 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 = TaskbarIconRenderer.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 = TaskbarIconRenderer.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 activeOptions = GetActiveProfileOptions(profiles); var microphoneSnapshot = microphones.GetMicrophoneSnapshot(activeOptions); 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, microphoneSnapshot.Available, microphoneSnapshot.Current?.Id); } private PopupMenu BuildContextMenu(MeetingTaskbarMenu menu) { var popupMenu = new PopupMenu(); for (var index = 0; index < menu.Items.Count; index++) { if (index == 1 || (menu.Items[index].Action == MeetingTaskbarAction.Exit && menu.Items[index - 1].Action != MeetingTaskbarAction.EditRules)) { popupMenu.Items.Add(new PopupMenuSeparator()); } var menuItem = menu.Items[index]; popupMenu.Items.Add(BuildPopupItem(menuItem)); } return popupMenu; } private PopupItem BuildPopupItem(MeetingTaskbarMenuItem menuItem) { if (menuItem.Items is { Count: > 0 }) { var submenu = new PopupSubMenu(menuItem.Text); foreach (var child in menuItem.Items) { submenu.Items.Add(BuildPopupItem(child)); } return submenu; } return new PopupMenuItem( menuItem.Text, (_, _) => _ = ExecuteMenuItemAsync(menuItem)) { Checked = menuItem.IsChecked }; } 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; case MeetingTaskbarAction.SelectMicrophone: if (!string.IsNullOrWhiteSpace(item.MicrophoneDeviceId)) { microphoneSelection.Select(item.MicrophoneDeviceId); } break; case MeetingTaskbarAction.Exit: ExitApplication(); 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( "|", FlattenMenuItems(menu.Items).Select(item => $"{item.Action}:{item.ProfileName}:{item.MicrophoneDeviceId}:{item.IsChecked}:{item.Text}")); } private static IEnumerable FlattenMenuItems( IEnumerable items) { foreach (var item in items) { yield return item; if (item.Items is null) { continue; } foreach (var child in FlattenMenuItems(item.Items)) { yield return child; } } } private MeetingAssistantOptions GetActiveProfileOptions(IReadOnlyList profiles) { var activeProfile = coordinator.CurrentStatus.LaunchProfile; return profiles.FirstOrDefault(profile => string.Equals( profile.Name, activeProfile, StringComparison.OrdinalIgnoreCase))?.Options ?? profiles.FirstOrDefault(profile => string.Equals( profile.Name, "default", StringComparison.OrdinalIgnoreCase))?.Options ?? profiles.FirstOrDefault()?.Options ?? new MeetingAssistantOptions(); } private void ExitApplication() { var status = coordinator.CurrentStatus; if (MeetingTaskbarExitPolicy.RequiresConfirmation(status.State) && !ConfirmExitDuringProcessing(status.State)) { logger.LogInformation("Taskbar exit canceled while meeting state was {State}", status.State); return; } logger.LogInformation("Taskbar exit requested while meeting state was {State}", status.State); applicationLifetime.StopApplication(); } private static bool ConfirmExitDuringProcessing(RecordingProcessState state) { var activity = state == RecordingProcessState.Recording ? "A meeting is still recording and transcribing." : "A stopped meeting is still transcribing, recognizing speakers, or summarizing."; var result = MessageBox.Show( $"{activity}\n\nExit Meeting Assistant anyway?", "Exit Meeting Assistant", MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No); return result == MessageBoxResult.Yes; } } #endif