Restore installer GUI launch (via shellexec) + checkpoint descriptions
Two follow-ups to the previous Config Checkpoints commit:
1. Bring back the post-install "Launch Webhook Server" checkbox in the
installer. The previous attempt failed because Inno Setup's
postinstall flag launches via CreateProcess after Setup exits,
bypassing the GUI's requireAdministrator manifest. Adding the
shellexec flag switches to ShellExecute, which DOES honor the
manifest and triggers a clean UAC prompt - so the post-install
GUI launch works as expected.
2. Each checkpoint now carries a description, stored in a sidecar
.meta.json file next to the snapshot. Defaults:
- Auto-on-save: "Before save"
- Midnight scheduler: "Nightly auto-checkpoint"
- Manual: opens a small dialog so the user can type a meaningful
description (defaults to "Manual checkpoint" if blank)
The dialog and pruning both clean up sidecars alongside snapshots.
The Config Checkpoints grid grows a Description column between
When and Size.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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"; \
|
||||
|
||||
@@ -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; } = "";
|
||||
|
||||
@@ -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 { }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -101,6 +101,6 @@ public sealed class AdminPipeClient
|
||||
public Task<AdminResponse> ImportConfigAsync(ServerConfig config, CancellationToken ct = default) =>
|
||||
InvokeAsync(AdminOps.ImportConfig, config, ct);
|
||||
|
||||
public Task<BackupEntry?> CreateCheckpointAsync(CancellationToken ct = default) =>
|
||||
InvokeAsync<BackupEntry>(AdminOps.CreateCheckpoint, null, ct);
|
||||
public Task<BackupEntry?> CreateCheckpointAsync(string? description, CancellationToken ct = default) =>
|
||||
InvokeAsync<BackupEntry>(AdminOps.CreateCheckpoint, new CreateCheckpointArgs { Description = description }, ct);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -39,11 +39,13 @@
|
||||
HeadersVisibility="Column"
|
||||
GridLinesVisibility="Horizontal">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="When (local)" Width="200"
|
||||
<DataGridTextColumn Header="When (local)" Width="170"
|
||||
Binding="{Binding SavedAt, StringFormat='{}{0:yyyy-MM-dd HH:mm:ss}', ConverterCulture=en-US}"/>
|
||||
<DataGridTextColumn Header="Description" Width="*"
|
||||
Binding="{Binding Description}"/>
|
||||
<DataGridTextColumn Header="Size" Width="100"
|
||||
Binding="{Binding SizeBytes, StringFormat='{}{0:n0} bytes'}"/>
|
||||
<DataGridTextColumn Header="File name" Width="*"
|
||||
<DataGridTextColumn Header="File name" Width="200"
|
||||
Binding="{Binding FileName}" FontFamily="Consolas"/>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<Window x:Class="WebhookServer.Gui.Views.TakeCheckpointDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Take checkpoint"
|
||||
Height="180" Width="440"
|
||||
ResizeMode="NoResize"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Icon="/webhook-server.ico"
|
||||
ShowInTaskbar="False">
|
||||
<Grid Margin="16">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0" TextWrapping="Wrap"
|
||||
Text="Description for this checkpoint (optional):"/>
|
||||
<TextBox x:Name="DescriptionBox" Grid.Row="1" Margin="0,8,0,0" MaxLength="120">
|
||||
<TextBox.InputBindings>
|
||||
<KeyBinding Key="Enter" Command="{Binding OkCommand, ElementName=Self, FallbackValue={x:Null}}"/>
|
||||
</TextBox.InputBindings>
|
||||
</TextBox>
|
||||
<TextBlock Grid.Row="2" Foreground="Gray" FontStyle="Italic" FontSize="11" Margin="0,4,0,0"
|
||||
Text="Examples: 'Before adding new endpoint', 'Pre-AD-policy-change'. Leave blank to use 'Manual checkpoint'."/>
|
||||
|
||||
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
|
||||
<Button Content="OK" Width="80" IsDefault="True" Click="OnOk" Margin="0,0,8,0"/>
|
||||
<Button Content="Cancel" Width="80" IsCancel="True" Click="OnCancel"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace WebhookServer.Gui.Views;
|
||||
|
||||
public partial class TakeCheckpointDialog : Window
|
||||
{
|
||||
public string Description { get; private set; } = "";
|
||||
|
||||
public TakeCheckpointDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
Loaded += (_, _) => DescriptionBox.Focus();
|
||||
}
|
||||
|
||||
private void OnOk(object sender, RoutedEventArgs e)
|
||||
{
|
||||
Description = DescriptionBox.Text?.Trim() ?? "";
|
||||
DialogResult = true;
|
||||
Close();
|
||||
}
|
||||
|
||||
private void OnCancel(object sender, RoutedEventArgs e)
|
||||
{
|
||||
DialogResult = false;
|
||||
Close();
|
||||
}
|
||||
}
|
||||
@@ -227,8 +227,11 @@ internal sealed class AdminPipeServer : BackgroundService
|
||||
|
||||
case AdminOps.CreateCheckpoint:
|
||||
{
|
||||
var entry = CreateCheckpoint("manual");
|
||||
_logger.LogInformation("Manual checkpoint created: {File}", entry.FileName);
|
||||
var args = DeserializeData<CreateCheckpointArgs>(request);
|
||||
var description = args?.Description;
|
||||
if (string.IsNullOrWhiteSpace(description)) description = "Manual checkpoint";
|
||||
var entry = CreateCheckpoint("manual", description);
|
||||
_logger.LogInformation("Manual checkpoint created: {File} ({Desc})", entry.FileName, description);
|
||||
return AdminResponse.Success(entry);
|
||||
}
|
||||
|
||||
@@ -238,10 +241,13 @@ internal sealed class AdminPipeServer : BackgroundService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot the current config.json into the backups folder. Used both by the
|
||||
/// "Take checkpoint now" GUI action and by the midnight scheduler.
|
||||
/// Snapshot the current config.json into the backups folder. Used by the
|
||||
/// "Take checkpoint now" GUI action, the midnight scheduler, and the
|
||||
/// auto-on-save hook in ConfigStore. Description is stored in a sidecar
|
||||
/// .meta.json file next to the snapshot so it survives restarts and can
|
||||
/// be rendered in the GUI.
|
||||
/// </summary>
|
||||
public static BackupEntry CreateCheckpoint(string reason)
|
||||
public static BackupEntry CreateCheckpoint(string reason, string description)
|
||||
{
|
||||
var configPath = ServicePaths.ConfigPath;
|
||||
if (!File.Exists(configPath))
|
||||
@@ -252,17 +258,23 @@ internal sealed class AdminPipeServer : BackgroundService
|
||||
|
||||
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);
|
||||
|
||||
// Write the sidecar metadata.
|
||||
var sidecarPath = Path.ChangeExtension(dest, ".meta.json");
|
||||
var sidecar = new { description, reason };
|
||||
File.WriteAllText(sidecarPath, JsonSerializer.Serialize(sidecar, ConfigJson.Compact));
|
||||
|
||||
var info = new FileInfo(dest);
|
||||
return new BackupEntry
|
||||
{
|
||||
FileName = info.Name,
|
||||
SavedAt = info.LastWriteTimeUtc,
|
||||
SizeBytes = info.Length,
|
||||
Description = description,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -271,6 +283,7 @@ internal sealed class AdminPipeServer : BackgroundService
|
||||
var dir = Path.Combine(ServicePaths.DataRoot, "backups");
|
||||
if (!Directory.Exists(dir)) return new List<BackupEntry>();
|
||||
return new DirectoryInfo(dir).GetFiles("config-*.json")
|
||||
.Where(f => !f.Name.EndsWith(".meta.json", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(f => f.Name)
|
||||
.Take(50)
|
||||
.Select(f => new BackupEntry
|
||||
@@ -278,10 +291,23 @@ internal sealed class AdminPipeServer : BackgroundService
|
||||
FileName = f.Name,
|
||||
SavedAt = f.LastWriteTimeUtc,
|
||||
SizeBytes = f.Length,
|
||||
Description = ReadSidecarDescription(f.FullName),
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string? ReadSidecarDescription(string snapshotPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var sidecarPath = Path.ChangeExtension(snapshotPath, ".meta.json");
|
||||
if (!File.Exists(sidecarPath)) return null;
|
||||
using var doc = JsonDocument.Parse(File.ReadAllText(sidecarPath));
|
||||
return doc.RootElement.TryGetProperty("description", out var d) ? d.GetString() : null;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private async Task<ServerConfig> RestoreBackupAsync(string fileName, CancellationToken ct)
|
||||
{
|
||||
// Refuse anything that tries to escape the backups directory.
|
||||
|
||||
@@ -34,7 +34,7 @@ internal sealed class CheckpointScheduler : BackgroundService
|
||||
|
||||
try
|
||||
{
|
||||
var entry = AdminPipeServer.CreateCheckpoint("daily");
|
||||
var entry = AdminPipeServer.CreateCheckpoint("daily", "Nightly auto-checkpoint");
|
||||
_logger.LogInformation("Daily checkpoint created: {File}", entry.FileName);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
|
||||
Reference in New Issue
Block a user