From 2aa14642a017fc62a226c3a7dca53b4f29bb2901 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Fri, 8 May 2026 11:26:35 -0400 Subject: [PATCH 1/4] 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 @@ private ContextMenuStrip BuildMenu() 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 @@ private void OpenDocumentation() } } + /// 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] From 3cd8c94a947fcb5c26bf46cfa637c7e02149f2c1 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Fri, 8 May 2026 11:28:53 -0400 Subject: [PATCH 2/4] Add File -> Minimize to tray toggle (default on) Adds a checkable MenuItem so the user can opt out of the hide-to-tray behavior. Persisted per-user to %APPDATA%\WebhookServer\gui.json so the choice survives restarts. When ticked (default): X / Alt+F4 / minimize hide to tray, GUI process keeps running, tray icon persists. When unticked: X actually closes the app, minimize is a regular Windows minimize. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/WebhookServer.Gui/MainWindow.xaml | 5 ++ src/WebhookServer.Gui/MainWindow.xaml.cs | 9 ++-- src/WebhookServer.Gui/Services/GuiSettings.cs | 50 +++++++++++++++++++ .../ViewModels/MainViewModel.cs | 11 ++++ 4 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 src/WebhookServer.Gui/Services/GuiSettings.cs diff --git a/src/WebhookServer.Gui/MainWindow.xaml b/src/WebhookServer.Gui/MainWindow.xaml index 466c38a..350d563 100644 --- a/src/WebhookServer.Gui/MainWindow.xaml +++ b/src/WebhookServer.Gui/MainWindow.xaml @@ -31,6 +31,11 @@ + + diff --git a/src/WebhookServer.Gui/MainWindow.xaml.cs b/src/WebhookServer.Gui/MainWindow.xaml.cs index 244e345..e140b88 100644 --- a/src/WebhookServer.Gui/MainWindow.xaml.cs +++ b/src/WebhookServer.Gui/MainWindow.xaml.cs @@ -38,7 +38,7 @@ public MainWindow() private void OnClosing(object? sender, CancelEventArgs e) { - if (ExitForReal) + if (ExitForReal || !_vm.MinimizeToTrayEnabled) { _tray.Dispose(); return; @@ -58,9 +58,10 @@ private void OnRealExitRequested() 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) + // Minimize-to-tray: hide the window when the user minimizes IF they've + // opted in via File -> Minimize to tray. Otherwise behave like a normal + // Windows minimize. + if (WindowState == WindowState.Minimized && _vm.MinimizeToTrayEnabled) { Hide(); ShowInTaskbar = false; diff --git a/src/WebhookServer.Gui/Services/GuiSettings.cs b/src/WebhookServer.Gui/Services/GuiSettings.cs new file mode 100644 index 0000000..fd511b6 --- /dev/null +++ b/src/WebhookServer.Gui/Services/GuiSettings.cs @@ -0,0 +1,50 @@ +using System.IO; +using System.Text.Json; + +namespace WebhookServer.Gui.Services; + +/// +/// Per-user GUI preferences that don't belong in the service-side ServerConfig. +/// Persisted to %APPDATA%\WebhookServer\gui.json. Best-effort: failures to read +/// or write fall back silently to defaults. +/// +public sealed class GuiSettings +{ + /// + /// When true, the X / Alt+F4 / minimize buttons hide the window to the tray + /// and keep the GUI process alive. When false, X exits the app and minimize + /// behaves like a normal Windows minimize. + /// + public bool MinimizeToTrayEnabled { get; set; } = true; + + private static string FilePath => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "WebhookServer", + "gui.json"); + + public static GuiSettings Load() + { + try + { + if (File.Exists(FilePath)) + { + var json = File.ReadAllText(FilePath); + if (!string.IsNullOrWhiteSpace(json)) + return JsonSerializer.Deserialize(json) ?? new GuiSettings(); + } + } + catch { /* fall through to defaults */ } + return new GuiSettings(); + } + + public void Save() + { + try + { + var dir = Path.GetDirectoryName(FilePath); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + File.WriteAllText(FilePath, JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true })); + } + catch { /* best effort */ } + } +} diff --git a/src/WebhookServer.Gui/ViewModels/MainViewModel.cs b/src/WebhookServer.Gui/ViewModels/MainViewModel.cs index e2bed34..60d46ba 100644 --- a/src/WebhookServer.Gui/ViewModels/MainViewModel.cs +++ b/src/WebhookServer.Gui/ViewModels/MainViewModel.cs @@ -29,17 +29,28 @@ public sealed partial class MainViewModel : ObservableObject [ObservableProperty] private ServerConfig _serverConfig = new(); [ObservableProperty] private string _httpBaseUrl = "http://localhost:8080"; [ObservableProperty] private string? _httpsBaseUrl; + [ObservableProperty] private bool _minimizeToTrayEnabled; private readonly DispatcherTimer _logTimer; + private readonly GuiSettings _settings; public MainViewModel(AdminPipeClient client) { _client = client; + _settings = GuiSettings.Load(); + _minimizeToTrayEnabled = _settings.MinimizeToTrayEnabled; + _logTimer = new DispatcherTimer(DispatcherPriority.Background) { Interval = TimeSpan.FromSeconds(3) }; _logTimer.Tick += async (_, _) => await RefreshLogTailAsync(); _logTimer.Start(); } + partial void OnMinimizeToTrayEnabledChanged(bool value) + { + _settings.MinimizeToTrayEnabled = value; + _settings.Save(); + } + [RelayCommand] private async Task RefreshAsync() { From 32e07489ffedd47504567f20e6bb398b69a87717 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Fri, 8 May 2026 11:30:11 -0400 Subject: [PATCH 3/4] Fix endpoint-row context menu: bindings via PlacementTarget.Tag The ContextMenu lived in its own popup visual tree, so the menu items' RelativeSource={RelativeSource AncestorType=Window} couldn't find the Window and the bindings silently failed - none of Edit / Copy URL / Toggle / Delete actually fired their commands. Standard WPF workaround: park MainViewModel on each DataGridRow's Tag (still in the Window's visual tree, so the row Setter binding resolves) and reach it from the menu items via PlacementTarget.Tag. The toggle command parameter likewise comes from PlacementTarget.DataContext (the EndpointConfig the row represents). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/WebhookServer.Gui/MainWindow.xaml | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/WebhookServer.Gui/MainWindow.xaml b/src/WebhookServer.Gui/MainWindow.xaml index 350d563..7789798 100644 --- a/src/WebhookServer.Gui/MainWindow.xaml +++ b/src/WebhookServer.Gui/MainWindow.xaml @@ -68,17 +68,26 @@