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.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/WebhookServer.Gui/Views/ConfigCheckpointsDialog.xaml.cs b/src/WebhookServer.Gui/Views/ConfigCheckpointsDialog.xaml.cs
new file mode 100644
index 0000000..909105e
--- /dev/null
+++ b/src/WebhookServer.Gui/Views/ConfigCheckpointsDialog.xaml.cs
@@ -0,0 +1,19 @@
+using System.Windows;
+using WebhookServer.Gui.ViewModels;
+
+namespace WebhookServer.Gui.Views;
+
+public partial class ConfigCheckpointsDialog : Window
+{
+ public ConfigCheckpointsDialog()
+ {
+ InitializeComponent();
+ Loaded += async (_, _) =>
+ {
+ if (DataContext is ConfigCheckpointsViewModel vm)
+ await vm.RefreshAsync();
+ };
+ }
+
+ private void OnClose(object sender, RoutedEventArgs e) => Close();
+}
diff --git a/src/WebhookServer.Service/AdminPipeServer.cs b/src/WebhookServer.Service/AdminPipeServer.cs
index 811b0a1..053ed8f 100644
--- a/src/WebhookServer.Service/AdminPipeServer.cs
+++ b/src/WebhookServer.Service/AdminPipeServer.cs
@@ -225,11 +225,47 @@ internal sealed class AdminPipeServer : BackgroundService
return AdminResponse.Success(SafeSnapshotForWire(_state.Snapshot()));
}
+ case AdminOps.CreateCheckpoint:
+ {
+ var entry = CreateCheckpoint("manual");
+ _logger.LogInformation("Manual checkpoint created: {File}", entry.FileName);
+ return AdminResponse.Success(entry);
+ }
+
default:
return AdminResponse.Failure($"unknown op '{request.Op}'");
}
}
+ ///
+ /// Snapshot the current config.json into the backups folder. Used both by the
+ /// "Take checkpoint now" GUI action and by the midnight scheduler.
+ ///
+ public static BackupEntry CreateCheckpoint(string reason)
+ {
+ var configPath = ServicePaths.ConfigPath;
+ if (!File.Exists(configPath))
+ throw new FileNotFoundException("no config.json exists yet to snapshot");
+
+ var dir = Path.Combine(ServicePaths.DataRoot, "backups");
+ Directory.CreateDirectory(dir);
+
+ var stamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
+ var dest = Path.Combine(dir, $"config-{stamp}.json");
+ // If we somehow snapshot twice in the same second, append a suffix.
+ if (File.Exists(dest))
+ dest = Path.Combine(dir, $"config-{stamp}-{reason}.json");
+
+ File.Copy(configPath, dest);
+ var info = new FileInfo(dest);
+ return new BackupEntry
+ {
+ FileName = info.Name,
+ SavedAt = info.LastWriteTimeUtc,
+ SizeBytes = info.Length,
+ };
+ }
+
private static List ListBackups()
{
var dir = Path.Combine(ServicePaths.DataRoot, "backups");
diff --git a/src/WebhookServer.Service/CheckpointScheduler.cs b/src/WebhookServer.Service/CheckpointScheduler.cs
new file mode 100644
index 0000000..983f65e
--- /dev/null
+++ b/src/WebhookServer.Service/CheckpointScheduler.cs
@@ -0,0 +1,50 @@
+using System.Runtime.Versioning;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace WebhookServer.Service;
+
+///
+/// Creates a daily config checkpoint at midnight (local time). Combined with
+/// the auto-on-save snapshots in ConfigStore.SaveAsync, this guarantees a
+/// rollback point for every day even if the user makes no changes.
+///
+[SupportedOSPlatform("windows")]
+internal sealed class CheckpointScheduler : BackgroundService
+{
+ private readonly ILogger _logger;
+
+ public CheckpointScheduler(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ _logger.LogInformation("Daily checkpoint scheduler running");
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ var now = DateTime.Now;
+ var nextMidnight = now.Date.AddDays(1);
+ var delay = nextMidnight - now;
+
+ try { await Task.Delay(delay, stoppingToken).ConfigureAwait(false); }
+ catch (OperationCanceledException) { return; }
+
+ try
+ {
+ var entry = AdminPipeServer.CreateCheckpoint("daily");
+ _logger.LogInformation("Daily checkpoint created: {File}", entry.FileName);
+ }
+ catch (FileNotFoundException)
+ {
+ // No config.json yet (fresh install, GUI never opened) - skip silently.
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Daily checkpoint creation failed");
+ }
+ }
+ }
+}
diff --git a/src/WebhookServer.Service/Program.cs b/src/WebhookServer.Service/Program.cs
index 8eefe98..fbe1766 100644
--- a/src/WebhookServer.Service/Program.cs
+++ b/src/WebhookServer.Service/Program.cs
@@ -49,6 +49,7 @@ try
builder.Services.AddSingleton();
builder.Services.AddHostedService();
builder.Services.AddHostedService();
+ builder.Services.AddHostedService();
var app = builder.Build();