diff --git a/installer/webhook-server.iss b/installer/webhook-server.iss index 8f14e2b..562546b 100644 --- a/installer/webhook-server.iss +++ b/installer/webhook-server.iss @@ -70,10 +70,14 @@ Filename: "powershell.exe"; \ Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\scripts\install-service.ps1"" -BinaryPath ""{app}\{#ServiceExeName}"""; \ StatusMsg: "Installing Windows Service..."; \ Flags: runhidden -; 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. +; Post-install GUI launch. The GUI's app.manifest is requireAdministrator, +; so launching with shellexec (ShellExecute) honors the manifest and triggers +; a clean UAC prompt. Using plain CreateProcess via the default Run path +; would skip the manifest and result in an un-elevated GUI that cannot connect +; to the admin pipe. +Filename: "{app}\{#AppExeName}"; \ + Description: "Launch {#AppName}"; \ + Flags: postinstall nowait shellexec skipifsilent [UninstallRun] Filename: "powershell.exe"; \ diff --git a/src/WebhookServer.Core/Ipc/AdminProtocol.cs b/src/WebhookServer.Core/Ipc/AdminProtocol.cs index 6977ccb..449eb58 100644 --- a/src/WebhookServer.Core/Ipc/AdminProtocol.cs +++ b/src/WebhookServer.Core/Ipc/AdminProtocol.cs @@ -34,6 +34,7 @@ public sealed class BackupEntry public string FileName { get; set; } = ""; public DateTimeOffset SavedAt { get; set; } public long SizeBytes { get; set; } + public string? Description { get; set; } } public sealed class RestoreBackupArgs @@ -41,6 +42,11 @@ public sealed class RestoreBackupArgs public string FileName { get; set; } = ""; } +public sealed class CreateCheckpointArgs +{ + public string? Description { get; set; } +} + public sealed class AdminRequest { [JsonPropertyName("op")] public string Op { get; set; } = ""; diff --git a/src/WebhookServer.Core/Storage/ConfigStore.cs b/src/WebhookServer.Core/Storage/ConfigStore.cs index 2f6daa3..e8d5417 100644 --- a/src/WebhookServer.Core/Storage/ConfigStore.cs +++ b/src/WebhookServer.Core/Storage/ConfigStore.cs @@ -48,7 +48,14 @@ public sealed class ConfigStore Directory.CreateDirectory(backupsDir); var stamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss"); var backupPath = System.IO.Path.Combine(backupsDir, $"config-{stamp}.json"); - File.Copy(Path, backupPath, overwrite: false); + if (!File.Exists(backupPath)) + { + File.Copy(Path, backupPath, overwrite: false); + var sidecar = new { description = "Before save", reason = "before-save" }; + File.WriteAllText( + System.IO.Path.ChangeExtension(backupPath, ".meta.json"), + JsonSerializer.Serialize(sidecar, ConfigJson.Compact)); + } PruneBackups(backupsDir, retain: 30); } catch @@ -71,11 +78,18 @@ public sealed class ConfigStore private static void PruneBackups(string backupsDir, int retain) { var stale = new DirectoryInfo(backupsDir).GetFiles("config-*.json") + .Where(f => !f.Name.EndsWith(".meta.json", StringComparison.OrdinalIgnoreCase)) .OrderByDescending(f => f.Name) .Skip(retain); foreach (var f in stale) { - try { f.Delete(); } catch { } + try + { + f.Delete(); + var sidecar = System.IO.Path.ChangeExtension(f.FullName, ".meta.json"); + if (File.Exists(sidecar)) File.Delete(sidecar); + } + catch { } } } diff --git a/src/WebhookServer.Gui/Services/AdminPipeClient.cs b/src/WebhookServer.Gui/Services/AdminPipeClient.cs index 300d3f0..3611aaa 100644 --- a/src/WebhookServer.Gui/Services/AdminPipeClient.cs +++ b/src/WebhookServer.Gui/Services/AdminPipeClient.cs @@ -101,6 +101,6 @@ 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); + public Task CreateCheckpointAsync(string? description, CancellationToken ct = default) => + InvokeAsync(AdminOps.CreateCheckpoint, new CreateCheckpointArgs { Description = description }, ct); } diff --git a/src/WebhookServer.Gui/ViewModels/ConfigCheckpointsViewModel.cs b/src/WebhookServer.Gui/ViewModels/ConfigCheckpointsViewModel.cs index 72e6797..d0fa3fc 100644 --- a/src/WebhookServer.Gui/ViewModels/ConfigCheckpointsViewModel.cs +++ b/src/WebhookServer.Gui/ViewModels/ConfigCheckpointsViewModel.cs @@ -46,9 +46,20 @@ public sealed partial class ConfigCheckpointsViewModel : ObservableObject [RelayCommand] private async Task TakeCheckpointAsync() { + // Prompt for an optional description on the UI thread. + string? description = null; + var prompted = Application.Current.Dispatcher.Invoke(() => + { + var dlg = new Views.TakeCheckpointDialog { Owner = Application.Current.MainWindow }; + if (dlg.ShowDialog() != true) return false; + description = string.IsNullOrWhiteSpace(dlg.Description) ? null : dlg.Description; + return true; + }); + if (!prompted) return; + try { - var entry = await _client.CreateCheckpointAsync().ConfigureAwait(false); + var entry = await _client.CreateCheckpointAsync(description).ConfigureAwait(false); await RefreshAsync().ConfigureAwait(false); if (entry is not null) { diff --git a/src/WebhookServer.Gui/Views/ConfigCheckpointsDialog.xaml b/src/WebhookServer.Gui/Views/ConfigCheckpointsDialog.xaml index a4f70c8..45ce611 100644 --- a/src/WebhookServer.Gui/Views/ConfigCheckpointsDialog.xaml +++ b/src/WebhookServer.Gui/Views/ConfigCheckpointsDialog.xaml @@ -39,11 +39,13 @@ HeadersVisibility="Column" GridLinesVisibility="Horizontal"> - + - diff --git a/src/WebhookServer.Gui/Views/TakeCheckpointDialog.xaml b/src/WebhookServer.Gui/Views/TakeCheckpointDialog.xaml new file mode 100644 index 0000000..0591b88 --- /dev/null +++ b/src/WebhookServer.Gui/Views/TakeCheckpointDialog.xaml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + +