From 2aa14642a017fc62a226c3a7dca53b4f29bb2901 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Fri, 8 May 2026 11:26:35 -0400 Subject: [PATCH] GUI: hide-to-tray on X button; tray persists until explicit Exit The minimize-to-tray behavior already worked, but clicking the X button killed the GUI process and took the tray with it. That made "tray when the GUI window is closed" a UX dead end - the only way to get the tray was to leave the window minimized. Now: - X button / Alt+F4 -> hide window, tray stays alive - Tray double-click -> reopens window - File -> Exit (or tray's Exit menu) -> truly quits the process Wired by adding a RealExitRequested event on MainViewModel that the window subscribes to (so File -> Exit sets the ExitForReal flag before calling Shutdown), and a parallel onExit callback on TrayIcon for the tray menu's Exit item. The Closing handler checks ExitForReal: if false (X / Alt+F4) it cancels the close and hides; if true, it disposes the tray and lets the close proceed. Auto-start at login is still TBD - if you want the tray to be there without manually launching the GUI after a reboot, that's a separate Task Scheduler entry. Skipping for now. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/WebhookServer.Gui/MainWindow.xaml.cs | 34 +++++++++++++++++-- src/WebhookServer.Gui/Services/TrayIcon.cs | 6 ++-- .../ViewModels/MainViewModel.cs | 6 +++- 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/WebhookServer.Gui/MainWindow.xaml.cs b/src/WebhookServer.Gui/MainWindow.xaml.cs index 9f387ff..244e345 100644 --- a/src/WebhookServer.Gui/MainWindow.xaml.cs +++ b/src/WebhookServer.Gui/MainWindow.xaml.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using System.Windows; using System.Windows.Controls; using System.Windows.Input; @@ -11,19 +12,48 @@ public partial class MainWindow : Window private readonly TrayIcon _tray; private readonly MainViewModel _vm; + /// + /// Set to true when the user has explicitly asked to quit (File -> Exit or + /// Tray -> Exit). The OnClosing handler reads this to decide whether to + /// actually let the window close or hide it to the tray. + /// + public bool ExitForReal { get; set; } + public MainWindow() { InitializeComponent(); _vm = new MainViewModel(new AdminPipeClient()); DataContext = _vm; + _vm.RealExitRequested += OnRealExitRequested; _tray = new TrayIcon( resolveMainWindow: () => Application.Current.MainWindow, - restartServiceAsync: async () => await new AdminPipeClient().RestartListenerAsync()); + restartServiceAsync: async () => await new AdminPipeClient().RestartListenerAsync(), + onExit: OnRealExitRequested); Loaded += async (_, _) => await _vm.RefreshCommand.ExecuteAsync(null); StateChanged += OnStateChanged; - Closed += (_, _) => _tray.Dispose(); + Closing += OnClosing; + } + + private void OnClosing(object? sender, CancelEventArgs e) + { + if (ExitForReal) + { + _tray.Dispose(); + return; + } + // Treat the X button / Alt+F4 like a minimize: hide to tray, keep the + // process alive so the tray icon persists. + e.Cancel = true; + Hide(); + ShowInTaskbar = false; + } + + private void OnRealExitRequested() + { + ExitForReal = true; + Application.Current.Shutdown(); } private void OnStateChanged(object? sender, EventArgs e) diff --git a/src/WebhookServer.Gui/Services/TrayIcon.cs b/src/WebhookServer.Gui/Services/TrayIcon.cs index 7cedfa5..4eb507c 100644 --- a/src/WebhookServer.Gui/Services/TrayIcon.cs +++ b/src/WebhookServer.Gui/Services/TrayIcon.cs @@ -16,11 +16,13 @@ public sealed class TrayIcon : IDisposable private readonly NotifyIcon _icon; private readonly Func _resolveMainWindow; private readonly Func _restartServiceAsync; + private readonly Action _onExit; - public TrayIcon(Func resolveMainWindow, Func restartServiceAsync) + public TrayIcon(Func resolveMainWindow, Func restartServiceAsync, Action onExit) { _resolveMainWindow = resolveMainWindow; _restartServiceAsync = restartServiceAsync; + _onExit = onExit; _icon = new NotifyIcon { @@ -39,7 +41,7 @@ public sealed class TrayIcon : IDisposable 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()); + menu.Items.Add("E&xit", null, (_, _) => _onExit()); return menu; } diff --git a/src/WebhookServer.Gui/ViewModels/MainViewModel.cs b/src/WebhookServer.Gui/ViewModels/MainViewModel.cs index 5e944b6..e2bed34 100644 --- a/src/WebhookServer.Gui/ViewModels/MainViewModel.cs +++ b/src/WebhookServer.Gui/ViewModels/MainViewModel.cs @@ -286,10 +286,14 @@ public sealed partial class MainViewModel : ObservableObject } } + /// Raised when the user picks File -> Exit. MainWindow flips its + /// ExitForReal flag and shuts down, bypassing the X-hides-to-tray logic. + public event Action? RealExitRequested; + [RelayCommand] private void Exit() { - Application.Current.Shutdown(); + RealExitRequested?.Invoke(); } [RelayCommand]