3 Commits

Author SHA1 Message Date
justin 9bbb3b7449 v0.1.3
Release / build-installer (push) Has been cancelled
2026-05-08 11:31:09 -04:00
justin 32e07489ff 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) <noreply@anthropic.com>
2026-05-08 11:30:11 -04:00
justin 3cd8c94a94 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) <noreply@anthropic.com>
2026-05-08 11:28:53 -04:00
5 changed files with 86 additions and 10 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
<Project>
<PropertyGroup>
<Version>0.1.2</Version>
<Version>0.1.3</Version>
<Authors>Justin Paul</Authors>
<Company>Justin Paul</Company>
<Product>Webhook Server</Product>
+19 -5
View File
@@ -31,6 +31,11 @@
<MenuItem Header="_Export config…" Command="{Binding ExportConfigCommand}"/>
<MenuItem Header="Config _Checkpoints…" Command="{Binding ShowConfigCheckpointsCommand}"/>
<Separator/>
<MenuItem Header="_Minimize to tray"
IsCheckable="True"
IsChecked="{Binding MinimizeToTrayEnabled, Mode=TwoWay}"
ToolTip="When ticked, closing or minimizing the window hides it to the tray and keeps the GUI process alive. Untick to make the X button quit the app."/>
<Separator/>
<MenuItem Header="E_xit" Command="{Binding ExitCommand}"/>
</MenuItem>
<MenuItem Header="_Server">
@@ -63,17 +68,26 @@
<DataGrid.RowStyle>
<Style TargetType="DataGridRow">
<EventSetter Event="MouseDoubleClick" Handler="OnRowDoubleClick"/>
<!-- The ContextMenu lives in its own visual tree (a popup), so
AncestorType=Window doesn't resolve from inside menu items.
Stash MainViewModel on the row's Tag here (still in the
Window's tree), then reach it from the menu via
PlacementTarget.Tag. -->
<Setter Property="Tag" Value="{Binding DataContext, RelativeSource={RelativeSource AncestorType=Window}}"/>
<Setter Property="ContextMenu">
<Setter.Value>
<ContextMenu>
<MenuItem Header="_Edit…" Command="{Binding DataContext.EditEndpointCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<MenuItem Header="_Copy URL" Command="{Binding DataContext.CopyEndpointUrlCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<MenuItem Header="_Edit…"
Command="{Binding PlacementTarget.Tag.EditEndpointCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
<MenuItem Header="_Copy URL"
Command="{Binding PlacementTarget.Tag.CopyEndpointUrlCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
<Separator/>
<MenuItem Header="Toggle _enabled"
Command="{Binding DataContext.ToggleEnabledCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"/>
Command="{Binding PlacementTarget.Tag.ToggleEnabledCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
CommandParameter="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
<Separator/>
<MenuItem Header="_Delete…" Command="{Binding DataContext.DeleteEndpointCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<MenuItem Header="_Delete…"
Command="{Binding PlacementTarget.Tag.DeleteEndpointCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
</ContextMenu>
</Setter.Value>
</Setter>
+5 -4
View File
@@ -38,7 +38,7 @@ public partial class MainWindow : Window
private void OnClosing(object? sender, CancelEventArgs e)
{
if (ExitForReal)
if (ExitForReal || !_vm.MinimizeToTrayEnabled)
{
_tray.Dispose();
return;
@@ -58,9 +58,10 @@ public partial class MainWindow : Window
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;
@@ -0,0 +1,50 @@
using System.IO;
using System.Text.Json;
namespace WebhookServer.Gui.Services;
/// <summary>
/// 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.
/// </summary>
public sealed class GuiSettings
{
/// <summary>
/// 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.
/// </summary>
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<GuiSettings>(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 */ }
}
}
@@ -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()
{