Phase 5: tray icon with minimize-to-tray and context menu

GUI csproj enables UseWindowsForms (NotifyIcon lives in WinForms even
in .NET 8). New Services/TrayIcon.cs wraps NotifyIcon with a context
menu (Open / Restart service / Exit) and the embedded webhook-server
icon. MainWindow creates the TrayIcon, hides itself on minimize and
restores on tray double-click.

Adds GlobalUsings.cs to alias the WPF defaults for types that exist
in both WPF and WinForms (Application, MessageBox, TextBox, Binding,
etc.) so existing code keeps compiling without per-file changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 09:51:00 -04:00
parent f3bca1e8ff
commit 9525ee358e
6 changed files with 132 additions and 12 deletions
-8
View File
@@ -1,13 +1,5 @@
using System.Configuration;
using System.Data;
using System.Windows;
namespace WebhookServer.Gui;
/// <summary>
/// Interaction logic for App.xaml
/// </summary>
public partial class App : Application
{
}
@@ -1,6 +1,7 @@
using System.Globalization;
using System.Windows.Data;
using System.Windows.Media;
using Brush = System.Windows.Media.Brush;
using Brushes = System.Windows.Media.Brushes;
namespace WebhookServer.Gui.Converters;
+15
View File
@@ -0,0 +1,15 @@
// Enabling UseWindowsForms (for the system tray NotifyIcon) brings the WinForms
// namespace into scope, which conflicts with WPF for several common type names.
// Alias the most-used types to their WPF variants project-wide so existing code
// keeps compiling. Files that genuinely need a WinForms type import it explicitly
// (System.Windows.Forms.NotifyIcon etc. in Services/TrayIcon.cs).
global using Application = System.Windows.Application;
global using MessageBox = System.Windows.MessageBox;
global using Clipboard = System.Windows.Clipboard;
global using TextBox = System.Windows.Controls.TextBox;
global using RadioButton = System.Windows.Controls.RadioButton;
global using MessageBoxButton = System.Windows.MessageBoxButton;
global using MessageBoxImage = System.Windows.MessageBoxImage;
global using MessageBoxResult = System.Windows.MessageBoxResult;
global using Binding = System.Windows.Data.Binding;
+28 -3
View File
@@ -8,12 +8,37 @@ namespace WebhookServer.Gui;
public partial class MainWindow : Window
{
private readonly TrayIcon _tray;
private readonly MainViewModel _vm;
public MainWindow()
{
InitializeComponent();
var vm = new MainViewModel(new AdminPipeClient());
DataContext = vm;
Loaded += async (_, _) => await vm.RefreshCommand.ExecuteAsync(null);
_vm = new MainViewModel(new AdminPipeClient());
DataContext = _vm;
_tray = new TrayIcon(
resolveMainWindow: () => Application.Current.MainWindow,
restartServiceAsync: async () => await new AdminPipeClient().RestartListenerAsync());
Loaded += async (_, _) => await _vm.RefreshCommand.ExecuteAsync(null);
StateChanged += OnStateChanged;
Closed += (_, _) => _tray.Dispose();
}
private void OnStateChanged(object? sender, EventArgs e)
{
// Minimize-to-tray: hide the window when the user minimizes; restoring is
// via the tray icon's double-click or context menu.
if (WindowState == WindowState.Minimized)
{
Hide();
ShowInTaskbar = false;
}
else
{
ShowInTaskbar = true;
}
}
private void OnLogTailChanged(object sender, TextChangedEventArgs e)
@@ -0,0 +1,86 @@
using System.Drawing;
using System.Runtime.Versioning;
using System.Windows;
using System.Windows.Forms;
namespace WebhookServer.Gui.Services;
/// <summary>
/// Minimal system tray icon using Windows Forms NotifyIcon. Owns a context menu
/// (Open / Restart service / Exit) and toggles the main window visibility on
/// double-click. Hide-to-tray on minimize is wired in MainWindow.xaml.cs.
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class TrayIcon : IDisposable
{
private readonly NotifyIcon _icon;
private readonly Func<Window?> _resolveMainWindow;
private readonly Func<Task> _restartServiceAsync;
public TrayIcon(Func<Window?> resolveMainWindow, Func<Task> restartServiceAsync)
{
_resolveMainWindow = resolveMainWindow;
_restartServiceAsync = restartServiceAsync;
_icon = new NotifyIcon
{
Icon = LoadEmbeddedIcon(),
Text = "Webhook Server",
Visible = true,
};
_icon.DoubleClick += (_, _) => ShowMainWindow();
_icon.ContextMenuStrip = BuildMenu();
}
private ContextMenuStrip BuildMenu()
{
var menu = new ContextMenuStrip();
menu.Items.Add("&Open Webhook Server", null, (_, _) => ShowMainWindow());
menu.Items.Add(new ToolStripSeparator());
menu.Items.Add("&Restart service", null, async (_, _) => await _restartServiceAsync().ConfigureAwait(false));
menu.Items.Add(new ToolStripSeparator());
menu.Items.Add("E&xit", null, (_, _) => Application.Current.Shutdown());
return menu;
}
private void ShowMainWindow()
{
var w = _resolveMainWindow();
if (w is null) return;
if (w.WindowState == WindowState.Minimized) w.WindowState = WindowState.Normal;
w.Show();
w.Activate();
w.Topmost = true;
w.Topmost = false;
}
private static Icon LoadEmbeddedIcon()
{
// Pulled from the WPF Resource items in the csproj via the application
// pack URI. Falling back to SystemIcons keeps the tray usable if the
// resource is somehow missing.
try
{
var uri = new Uri("pack://application:,,,/webhook-server.ico", UriKind.Absolute);
using var stream = Application.GetResourceStream(uri).Stream;
return new Icon(stream);
}
catch
{
return SystemIcons.Application;
}
}
public void ShowBalloon(string title, string message)
{
_icon.BalloonTipTitle = title;
_icon.BalloonTipText = message;
_icon.ShowBalloonTip(3000);
}
public void Dispose()
{
_icon.Visible = false;
_icon.Dispose();
}
}
@@ -14,6 +14,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<UseWindowsForms>true</UseWindowsForms>
<ApplicationIcon>..\..\resources\webhook-server.ico</ApplicationIcon>
<AssemblyTitle>Webhook Server</AssemblyTitle>
</PropertyGroup>