Public Access
Add tray rules and identities editor
PR and Push Build/Test / build-and-test (push) Successful in 7m0s
PR and Push Build/Test / build-and-test (push) Successful in 7m0s
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user