0b7c20a1fa
* 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) <noreply@anthropic.com> * 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> * 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> * v0.1.3 --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
355 lines
12 KiB
C#
355 lines
12 KiB
C#
using System.Collections.ObjectModel;
|
|
using System.Runtime.Versioning;
|
|
using System.Text;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using System.Windows;
|
|
using System.Windows.Threading;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using WebhookServer.Core.Ipc;
|
|
using WebhookServer.Core.Models;
|
|
using WebhookServer.Gui.Services;
|
|
using WebhookServer.Gui.Views;
|
|
|
|
namespace WebhookServer.Gui.ViewModels;
|
|
|
|
[SupportedOSPlatform("windows")]
|
|
public sealed partial class MainViewModel : ObservableObject
|
|
{
|
|
private readonly AdminPipeClient _client;
|
|
|
|
public ObservableCollection<EndpointConfig> Endpoints { get; } = new();
|
|
|
|
[ObservableProperty] private EndpointConfig? _selectedEndpoint;
|
|
[ObservableProperty] private string _connectionStatus = "Disconnected";
|
|
[ObservableProperty] private bool _isConnected;
|
|
[ObservableProperty] private string _logTail = "";
|
|
[ObservableProperty] private bool _autoScrollLogs = true;
|
|
[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()
|
|
{
|
|
try
|
|
{
|
|
var status = await _client.GetStatusAsync().ConfigureAwait(false);
|
|
var config = await _client.GetConfigAsync().ConfigureAwait(false);
|
|
|
|
Application.Current.Dispatcher.Invoke(() =>
|
|
{
|
|
IsConnected = status?.Running == true;
|
|
ConnectionStatus = IsConnected
|
|
? $"Connected — HTTP {status!.HttpPort}{(status.HttpsPort.HasValue ? $" / HTTPS {status.HttpsPort}" : "")}"
|
|
: "Disconnected";
|
|
|
|
if (status is not null)
|
|
{
|
|
var host = string.IsNullOrEmpty(status.DisplayHost) ? "localhost" : status.DisplayHost;
|
|
HttpBaseUrl = $"http://{host}:{status.HttpPort}";
|
|
HttpsBaseUrl = status.HttpsPort.HasValue ? $"https://{host}:{status.HttpsPort.Value}" : null;
|
|
}
|
|
|
|
Endpoints.Clear();
|
|
if (config is not null)
|
|
{
|
|
ServerConfig = config;
|
|
foreach (var ep in config.Endpoints) Endpoints.Add(ep);
|
|
}
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Application.Current.Dispatcher.Invoke(() =>
|
|
{
|
|
IsConnected = false;
|
|
ConnectionStatus = $"Disconnected: {ex.Message}";
|
|
});
|
|
}
|
|
|
|
await RefreshLogTailAsync().ConfigureAwait(false);
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task RefreshLogTailAsync()
|
|
{
|
|
try
|
|
{
|
|
var lines = await _client.TailLogsAsync(100).ConfigureAwait(false);
|
|
var text = new StringBuilder();
|
|
foreach (var line in lines) text.AppendLine(line.Message);
|
|
Application.Current.Dispatcher.Invoke(() => LogTail = text.ToString());
|
|
}
|
|
catch
|
|
{
|
|
// ignore — main connection state already reflects pipe failure
|
|
}
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task AddEndpointAsync()
|
|
{
|
|
var draft = new EndpointConfig { Id = Guid.NewGuid(), Slug = "new-hook" };
|
|
var dlg = new EndpointEditor { Owner = Application.Current.MainWindow };
|
|
var vm = new EndpointEditorViewModel(draft, isNew: true);
|
|
dlg.DataContext = vm;
|
|
if (dlg.ShowDialog() != true) return;
|
|
|
|
try
|
|
{
|
|
await _client.CreateEndpointAsync(vm.Endpoint).ConfigureAwait(false);
|
|
await RefreshAsync().ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ShowError("Create failed", ex);
|
|
}
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task EditEndpointAsync()
|
|
{
|
|
if (SelectedEndpoint is null) return;
|
|
var dlg = new EndpointEditor { Owner = Application.Current.MainWindow };
|
|
var vm = new EndpointEditorViewModel(SelectedEndpoint, isNew: false);
|
|
dlg.DataContext = vm;
|
|
if (dlg.ShowDialog() != true) return;
|
|
|
|
try
|
|
{
|
|
await _client.UpdateEndpointAsync(vm.Endpoint).ConfigureAwait(false);
|
|
await RefreshAsync().ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ShowError("Update failed", ex);
|
|
}
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task DeleteEndpointAsync()
|
|
{
|
|
if (SelectedEndpoint is null) return;
|
|
var ok = MessageBox.Show(
|
|
$"Delete endpoint '{SelectedEndpoint.Slug}'?",
|
|
"Confirm",
|
|
MessageBoxButton.OKCancel,
|
|
MessageBoxImage.Warning);
|
|
if (ok != MessageBoxResult.OK) return;
|
|
|
|
try
|
|
{
|
|
await _client.DeleteEndpointAsync(SelectedEndpoint.Id).ConfigureAwait(false);
|
|
await RefreshAsync().ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ShowError("Delete failed", ex);
|
|
}
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task ToggleEnabledAsync(EndpointConfig? ep)
|
|
{
|
|
if (ep is null) return;
|
|
try
|
|
{
|
|
await _client.SetEndpointEnabledAsync(ep.Id, !ep.Enabled).ConfigureAwait(false);
|
|
await RefreshAsync().ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ShowError("Toggle failed", ex);
|
|
}
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void ShowConfigCheckpoints()
|
|
{
|
|
var dlg = new Views.ConfigCheckpointsDialog
|
|
{
|
|
Owner = Application.Current.MainWindow,
|
|
DataContext = new ConfigCheckpointsViewModel(_client),
|
|
};
|
|
dlg.ShowDialog();
|
|
// After the dialog closes, the live config may have changed via rollback,
|
|
// so refresh the main grid.
|
|
_ = RefreshAsync();
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task ExportConfigAsync()
|
|
{
|
|
try
|
|
{
|
|
var snap = await _client.GetConfigAsync().ConfigureAwait(false);
|
|
if (snap is null) { ShowError("Export failed", new InvalidOperationException("Service did not return a config.")); return; }
|
|
|
|
var dlg = new Microsoft.Win32.SaveFileDialog
|
|
{
|
|
FileName = $"webhook-server-config-{DateTime.Now:yyyyMMdd-HHmmss}.json",
|
|
DefaultExt = ".json",
|
|
Filter = "JSON config (*.json)|*.json",
|
|
};
|
|
if (dlg.ShowDialog() != true) return;
|
|
|
|
var json = System.Text.Json.JsonSerializer.Serialize(snap, WebhookServer.Core.Storage.ConfigJson.Pretty);
|
|
await System.IO.File.WriteAllTextAsync(dlg.FileName, json).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex) { ShowError("Export failed", ex); }
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task ImportConfigAsync()
|
|
{
|
|
var dlg = new Microsoft.Win32.OpenFileDialog
|
|
{
|
|
Filter = "JSON config (*.json)|*.json",
|
|
CheckFileExists = true,
|
|
};
|
|
if (dlg.ShowDialog() != true) return;
|
|
|
|
try
|
|
{
|
|
var json = await System.IO.File.ReadAllTextAsync(dlg.FileName).ConfigureAwait(false);
|
|
var cfg = System.Text.Json.JsonSerializer.Deserialize<ServerConfig>(json, WebhookServer.Core.Storage.ConfigJson.Pretty);
|
|
if (cfg is null) throw new InvalidOperationException("File did not contain a valid config.");
|
|
|
|
var ok = MessageBox.Show(
|
|
$"Replace the current configuration with {dlg.FileName}?\n\nA checkpoint of the current config is saved first, so you can roll back from File → Config Checkpoints.",
|
|
"Import config",
|
|
MessageBoxButton.OKCancel,
|
|
MessageBoxImage.Warning);
|
|
if (ok != MessageBoxResult.OK) return;
|
|
|
|
await _client.ImportConfigAsync(cfg).ConfigureAwait(false);
|
|
await RefreshAsync().ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex) { ShowError("Import failed", ex); }
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task RestartServiceAsync()
|
|
{
|
|
var ok = MessageBox.Show(
|
|
"Restart the WebhookServer service? In-flight requests will be aborted.",
|
|
"Restart service",
|
|
MessageBoxButton.OKCancel,
|
|
MessageBoxImage.Warning);
|
|
if (ok != MessageBoxResult.OK) return;
|
|
|
|
try
|
|
{
|
|
await _client.RestartListenerAsync().ConfigureAwait(false);
|
|
await Task.Delay(2000).ConfigureAwait(false);
|
|
await RefreshAsync().ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ShowError("Restart failed", ex);
|
|
}
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void ShowAbout()
|
|
{
|
|
var dlg = new Views.AboutDialog { Owner = Application.Current.MainWindow };
|
|
dlg.ShowDialog();
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void OpenDocumentation()
|
|
{
|
|
try
|
|
{
|
|
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
|
{
|
|
FileName = "https://github.com/recklessop/webhook-server/tree/main/docs",
|
|
UseShellExecute = true,
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ShowError("Could not open documentation", ex);
|
|
}
|
|
}
|
|
|
|
/// <summary>Raised when the user picks File -> Exit. MainWindow flips its
|
|
/// ExitForReal flag and shuts down, bypassing the X-hides-to-tray logic.</summary>
|
|
public event Action? RealExitRequested;
|
|
|
|
[RelayCommand]
|
|
private void Exit()
|
|
{
|
|
RealExitRequested?.Invoke();
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void CopyEndpointUrl()
|
|
{
|
|
if (SelectedEndpoint is null || string.IsNullOrEmpty(HttpBaseUrl)) return;
|
|
var url = $"{HttpBaseUrl.TrimEnd('/')}/hook/{SelectedEndpoint.Slug}";
|
|
try
|
|
{
|
|
Clipboard.SetText(url);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ShowError("Copy failed", ex);
|
|
}
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task EditServerSettingsAsync()
|
|
{
|
|
var dlg = new ServerSettings { Owner = Application.Current.MainWindow };
|
|
var vm = new ServerSettingsViewModel(ServerConfig);
|
|
dlg.DataContext = vm;
|
|
if (dlg.ShowDialog() != true) return;
|
|
|
|
try
|
|
{
|
|
ServerConfig.HttpPort = vm.HttpPort;
|
|
ServerConfig.TrustedProxies = vm.TrustedProxiesList;
|
|
ServerConfig.HttpsBinding = vm.BuildBinding();
|
|
ServerConfig.BindAddresses = vm.BindAddressesList;
|
|
ServerConfig.DisplayHost = vm.DisplayHostValue;
|
|
await _client.InvokeAsync(AdminOps.UpdateConfig, ServerConfig).ConfigureAwait(false);
|
|
await RefreshAsync().ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ShowError("Save failed", ex);
|
|
}
|
|
}
|
|
|
|
private static void ShowError(string title, Exception ex)
|
|
{
|
|
Application.Current.Dispatcher.Invoke(() =>
|
|
MessageBox.Show(ex.Message, title, MessageBoxButton.OK, MessageBoxImage.Error));
|
|
}
|
|
}
|