diff --git a/installer/webhook-server.iss b/installer/webhook-server.iss index e112497..89ab350 100644 --- a/installer/webhook-server.iss +++ b/installer/webhook-server.iss @@ -69,9 +69,10 @@ Filename: "powershell.exe"; \ Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\scripts\install-service.ps1"" -BinaryPath ""{app}\{#ServiceExeName}"""; \ StatusMsg: "Installing Windows Service..."; \ Flags: runhidden -Filename: "{app}\{#AppExeName}"; \ - Description: "Launch {#AppName}"; \ - Flags: postinstall nowait skipifsilent +; No post-install GUI launch: the GUI is requireAdministrator and the launch +; from the installer wizard ends up un-elevated for the post-install user, so +; it would just fail to connect to the admin pipe. The Start Menu shortcut +; handles the elevation correctly via the embedded manifest. [UninstallRun] Filename: "powershell.exe"; \ diff --git a/src/WebhookServer.Core/Ipc/AdminProtocol.cs b/src/WebhookServer.Core/Ipc/AdminProtocol.cs index 5ca1c4e..6977ccb 100644 --- a/src/WebhookServer.Core/Ipc/AdminProtocol.cs +++ b/src/WebhookServer.Core/Ipc/AdminProtocol.cs @@ -26,6 +26,7 @@ public static class AdminOps public const string ListBackups = "list-backups"; public const string RestoreBackup = "restore-backup"; public const string ImportConfig = "import-config"; + public const string CreateCheckpoint = "create-checkpoint"; } public sealed class BackupEntry diff --git a/src/WebhookServer.Gui/MainWindow.xaml b/src/WebhookServer.Gui/MainWindow.xaml index c42fc0e..466c38a 100644 --- a/src/WebhookServer.Gui/MainWindow.xaml +++ b/src/WebhookServer.Gui/MainWindow.xaml @@ -29,25 +29,7 @@ - - - - - + diff --git a/src/WebhookServer.Gui/MainWindow.xaml.cs b/src/WebhookServer.Gui/MainWindow.xaml.cs index de2a239..9f387ff 100644 --- a/src/WebhookServer.Gui/MainWindow.xaml.cs +++ b/src/WebhookServer.Gui/MainWindow.xaml.cs @@ -53,9 +53,4 @@ public partial class MainWindow : Window vm.EditEndpointCommand.Execute(null); } - private async void OnBackupsSubmenuOpened(object sender, RoutedEventArgs e) - { - if (DataContext is MainViewModel vm) - await vm.RefreshBackupsCommand.ExecuteAsync(null); - } } diff --git a/src/WebhookServer.Gui/Services/AdminPipeClient.cs b/src/WebhookServer.Gui/Services/AdminPipeClient.cs index 4ce804e..300d3f0 100644 --- a/src/WebhookServer.Gui/Services/AdminPipeClient.cs +++ b/src/WebhookServer.Gui/Services/AdminPipeClient.cs @@ -100,4 +100,7 @@ public sealed class AdminPipeClient public Task ImportConfigAsync(ServerConfig config, CancellationToken ct = default) => InvokeAsync(AdminOps.ImportConfig, config, ct); + + public Task CreateCheckpointAsync(CancellationToken ct = default) => + InvokeAsync(AdminOps.CreateCheckpoint, null, ct); } diff --git a/src/WebhookServer.Gui/ViewModels/ConfigCheckpointsViewModel.cs b/src/WebhookServer.Gui/ViewModels/ConfigCheckpointsViewModel.cs new file mode 100644 index 0000000..72e6797 --- /dev/null +++ b/src/WebhookServer.Gui/ViewModels/ConfigCheckpointsViewModel.cs @@ -0,0 +1,94 @@ +using System.Collections.ObjectModel; +using System.Runtime.Versioning; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using WebhookServer.Core.Ipc; +using WebhookServer.Gui.Services; + +namespace WebhookServer.Gui.ViewModels; + +[SupportedOSPlatform("windows")] +public sealed partial class ConfigCheckpointsViewModel : ObservableObject +{ + private readonly AdminPipeClient _client; + + public ObservableCollection Checkpoints { get; } = new(); + + [ObservableProperty] private BackupEntry? _selected; + [ObservableProperty] private string _statusMessage = ""; + + public ConfigCheckpointsViewModel(AdminPipeClient client) + { + _client = client; + } + + [RelayCommand] + public async Task RefreshAsync() + { + try + { + var list = await _client.ListBackupsAsync().ConfigureAwait(false); + Application.Current.Dispatcher.Invoke(() => + { + Checkpoints.Clear(); + foreach (var b in list) Checkpoints.Add(b); + StatusMessage = list.Count == 0 + ? "No checkpoints yet. Save the config or click Take Checkpoint Now." + : $"{list.Count} checkpoint{(list.Count == 1 ? "" : "s")}."; + }); + } + catch (Exception ex) + { + Application.Current.Dispatcher.Invoke(() => StatusMessage = $"Could not load: {ex.Message}"); + } + } + + [RelayCommand] + private async Task TakeCheckpointAsync() + { + try + { + var entry = await _client.CreateCheckpointAsync().ConfigureAwait(false); + await RefreshAsync().ConfigureAwait(false); + if (entry is not null) + { + Application.Current.Dispatcher.Invoke(() => + { + Selected = Checkpoints.FirstOrDefault(c => c.FileName == entry.FileName); + StatusMessage = $"Created {entry.FileName}"; + }); + } + } + catch (Exception ex) + { + Application.Current.Dispatcher.Invoke(() => + MessageBox.Show(ex.Message, "Take checkpoint failed", MessageBoxButton.OK, MessageBoxImage.Error)); + } + } + + [RelayCommand] + private async Task RollbackAsync() + { + if (Selected is null) return; + + var ok = MessageBox.Show( + $"Roll the configuration back to the checkpoint from {Selected.SavedAt.ToLocalTime():yyyy-MM-dd HH:mm:ss}?\n\nThe current configuration is automatically saved as a new checkpoint first, so you can roll forward again.", + "Confirm rollback", + MessageBoxButton.OKCancel, + MessageBoxImage.Warning); + if (ok != MessageBoxResult.OK) return; + + try + { + await _client.RestoreBackupAsync(Selected.FileName).ConfigureAwait(false); + await RefreshAsync().ConfigureAwait(false); + Application.Current.Dispatcher.Invoke(() => + StatusMessage = $"Rolled back to {Selected!.FileName}."); + } + catch (Exception ex) + { + Application.Current.Dispatcher.Invoke(() => + MessageBox.Show(ex.Message, "Rollback failed", MessageBoxButton.OK, MessageBoxImage.Error)); + } + } +} diff --git a/src/WebhookServer.Gui/ViewModels/MainViewModel.cs b/src/WebhookServer.Gui/ViewModels/MainViewModel.cs index 96190b0..5e944b6 100644 --- a/src/WebhookServer.Gui/ViewModels/MainViewModel.cs +++ b/src/WebhookServer.Gui/ViewModels/MainViewModel.cs @@ -175,39 +175,18 @@ public sealed partial class MainViewModel : ObservableObject } } - [ObservableProperty] private System.Collections.ObjectModel.ObservableCollection _backups = new(); - [RelayCommand] - private async Task RefreshBackupsAsync() + private void ShowConfigCheckpoints() { - try + var dlg = new Views.ConfigCheckpointsDialog { - var list = await _client.ListBackupsAsync().ConfigureAwait(false); - Application.Current.Dispatcher.Invoke(() => - { - Backups.Clear(); - foreach (var b in list) Backups.Add(b); - }); - } - catch { /* ignore - checkpoint listing isn't critical */ } - } - - [RelayCommand] - private async Task RestoreBackupAsync(BackupEntry? entry) - { - if (entry is null) return; - var ok = MessageBox.Show( - $"Restore the configuration from the checkpoint taken at {entry.SavedAt:yyyy-MM-dd HH:mm}?\n\nThe current configuration is automatically saved as a new checkpoint first, so you can roll forward again.", - "Restore checkpoint", - MessageBoxButton.OKCancel, - MessageBoxImage.Question); - if (ok != MessageBoxResult.OK) return; - try - { - await _client.RestoreBackupAsync(entry.FileName).ConfigureAwait(false); - await RefreshAsync().ConfigureAwait(false); - } - catch (Exception ex) { ShowError("Restore failed", ex); } + 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] diff --git a/src/WebhookServer.Gui/Views/ConfigCheckpointsDialog.xaml b/src/WebhookServer.Gui/Views/ConfigCheckpointsDialog.xaml new file mode 100644 index 0000000..a4f70c8 --- /dev/null +++ b/src/WebhookServer.Gui/Views/ConfigCheckpointsDialog.xaml @@ -0,0 +1,51 @@ + + + + A checkpoint is a snapshot of config.json taken before each save and once a day at midnight. + Pick one and click Roll Back to restore it. The current configuration is automatically saved + as a new checkpoint before any rollback, so you can always roll forward again. + + + +