From a808964cf191feb040d1a1c14c26bb9cb92da5ee Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Fri, 8 May 2026 10:03:43 -0400 Subject: [PATCH 1/4] Phases 1-7: GUI polish, icons, tray, backups, installer, CI (#1) * Phase 3: app icon (multi-resolution ICO + master PNG) scripts/generate-icons.ps1 renders the icon programmatically with System.Drawing - rounded teal square (#0E7C66) with a stylized white hook glyph - at 16/24/32/48/64/128/256 px and assembles a proper multi-resolution Microsoft ICO. The PNG and ICO outputs land in resources/. The script is the source of truth; re-run after editing the design. GUI csproj uses ApplicationIcon for the EXE icon and embeds the .ico + .png as Resources so MainWindow and AboutDialog can use them via WPF's resource URI scheme. Co-Authored-By: Claude Opus 4.7 (1M context) * Phase 5: tray icon with minimize-to-tray and context menu GUI csproj enables UseWindowsForms (NotifyIcon lives in WinForms even in .NET 8). New Services/TrayIcon.cs wraps NotifyIcon with a context menu (Open / Restart service / Exit) and the embedded webhook-server icon. MainWindow creates the TrayIcon, hides itself on minimize and restores on tray double-click. Adds GlobalUsings.cs to alias the WPF defaults for types that exist in both WPF and WinForms (Application, MessageBox, TextBox, Binding, etc.) so existing code keeps compiling without per-file changes. Co-Authored-By: Claude Opus 4.7 (1M context) * Phase 6+7: Inno Setup installer + GitHub Actions release pipeline installer/webhook-server.iss is an Inno Setup 6 script that: - Installs to %ProgramFiles%\WebhookServer - Creates Start Menu folder + GUI shortcut (and optional desktop icon) - Runs install-service.ps1 post-install to register the Windows Service - Runs uninstall-service.ps1 pre-uninstall to remove it - Bundles the webhook-server icon for the installer / uninstaller scripts/build-installer.ps1 is the local build helper: publishes both projects, finds ISCC.exe (PATH or standard install path), compiles the installer with the version pulled from Directory.Build.props, drops the output in dist/. .github/workflows/ci.yml runs build + test on every push/PR to main. .github/workflows/release.yml triggers on v* tags (or manual dispatch), runs tests, installs Inno Setup via choco, builds the installer, and attaches the .exe to a GitHub Release. Pre-1.0 versions are flagged prerelease automatically. Co-Authored-By: Claude Opus 4.7 (1M context) * Phase 4: backups + import/export config ConfigStore.SaveAsync now snapshots the previous config to %ProgramData%\WebhookServer\backups\config-.json before overwriting, retaining the last 30. Failures are silent so a backup-write hiccup never blocks an actual save. Three new admin pipe ops: - list-backups: returns newest 50 entries with timestamps and sizes - restore-backup: takes a fileName, refuses path-traversal chars, loads the named backup over the live config (which itself triggers a fresh backup of the current state via the SaveAsync hook) - import-config: replaces the current config with a GUI-supplied ServerConfig, merging encrypted secrets where the GUI didn't supply new plaintext GUI File menu items are wired: - Import config: file picker -> ImportConfigAsync - Export config: SaveFileDialog writes the current config as JSON - Backups: dynamic submenu auto-refreshed when opened, listing backups with timestamp + size; click to confirm-and-restore Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 27 ++++ .github/workflows/release.yml | 71 +++++++++ installer/webhook-server.iss | 79 ++++++++++ resources/webhook-server.ico | Bin 0 -> 9615 bytes resources/webhook-server.png | Bin 0 -> 4052 bytes scripts/build-installer.ps1 | 68 +++++++++ scripts/generate-icons.ps1 | 138 ++++++++++++++++++ src/WebhookServer.Core/Ipc/AdminProtocol.cs | 15 ++ src/WebhookServer.Core/Storage/ConfigStore.cs | 30 ++++ src/WebhookServer.Gui/App.xaml.cs | 8 - .../Converters/Converters.cs | 3 +- src/WebhookServer.Gui/GlobalUsings.cs | 15 ++ src/WebhookServer.Gui/MainWindow.xaml | 24 ++- src/WebhookServer.Gui/MainWindow.xaml.cs | 37 ++++- .../Services/AdminPipeClient.cs | 14 ++ src/WebhookServer.Gui/Services/TrayIcon.cs | 86 +++++++++++ .../ViewModels/MainViewModel.cs | 86 +++++++++++ src/WebhookServer.Gui/Views/AboutDialog.xaml | 3 +- .../WebhookServer.Gui.csproj | 8 + src/WebhookServer.Service/AdminPipeServer.cs | 55 +++++++ 20 files changed, 751 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 installer/webhook-server.iss create mode 100644 resources/webhook-server.ico create mode 100644 resources/webhook-server.png create mode 100644 scripts/build-installer.ps1 create mode 100644 scripts/generate-icons.ps1 create mode 100644 src/WebhookServer.Gui/GlobalUsings.cs create mode 100644 src/WebhookServer.Gui/Services/TrayIcon.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2646469 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Restore + run: dotnet restore WebhookServer.sln + + - name: Build + run: dotnet build WebhookServer.sln -c Release --no-restore + + - name: Test + run: dotnet test WebhookServer.sln -c Release --no-build --verbosity normal diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b7e6750 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,71 @@ +name: Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + version: + description: 'Version to build (e.g. 0.1.0). Defaults to Directory.Build.props.' + required: false + +jobs: + build-installer: + runs-on: windows-latest + permissions: + contents: write # needed to create releases / upload assets + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Resolve version + id: ver + shell: pwsh + run: | + if ('${{ github.event_name }}' -eq 'push') { + $v = '${{ github.ref_name }}'.TrimStart('v') + } elseif ('${{ inputs.version }}') { + $v = '${{ inputs.version }}' + } else { + [xml]$p = Get-Content Directory.Build.props + $v = $p.Project.PropertyGroup.Version + } + "version=$v" | Out-File $env:GITHUB_OUTPUT -Append + Write-Host "Building version $v" + + - name: Restore + test + run: | + dotnet restore WebhookServer.sln + dotnet test WebhookServer.sln -c Release + + - name: Install Inno Setup + shell: pwsh + run: | + choco install innosetup --no-progress -y + Write-Host "ISCC at: $((Get-Command iscc).Path)" + + - name: Build installer + shell: pwsh + run: ./scripts/build-installer.ps1 -VersionOverride ${{ steps.ver.outputs.version }} + + - name: Upload installer artifact + uses: actions/upload-artifact@v4 + with: + name: WebhookServer-Setup-${{ steps.ver.outputs.version }} + path: dist/WebhookServer-Setup-*.exe + + - name: Create GitHub Release + if: startsWith(github.ref, 'refs/tags/v') + uses: softprops/action-gh-release@v2 + with: + name: Webhook Server ${{ steps.ver.outputs.version }} + tag_name: ${{ github.ref_name }} + draft: false + prerelease: ${{ startsWith(steps.ver.outputs.version, '0.') }} + files: dist/WebhookServer-Setup-*.exe + generate_release_notes: true diff --git a/installer/webhook-server.iss b/installer/webhook-server.iss new file mode 100644 index 0000000..36c7314 --- /dev/null +++ b/installer/webhook-server.iss @@ -0,0 +1,79 @@ +; Inno Setup script for Webhook Server. +; +; Build: iscc /DAppVersion=0.1.0 webhook-server.iss +; Output: ..\dist\WebhookServer-Setup-{AppVersion}.exe +; +; The installer copies published binaries to {pf}\WebhookServer, installs the +; Windows Service via install-service.ps1 post-install, and removes the service +; via uninstall-service.ps1 pre-uninstall. Start Menu gets a single GUI shortcut. + +#ifndef AppVersion + #define AppVersion "0.1.0" +#endif + +#define AppName "Webhook Server" +#define AppPublisher "Justin Paul" +#define AppURL "https://jpaul.me" +#define AppExeName "WebhookServer.Gui.exe" +#define ServiceExeName "WebhookServer.Service.exe" +#define ServiceName "WebhookServer" +#define RepoRoot "..\" + +[Setup] +AppId={{6E3B3C1A-9C20-4F50-B6A8-2B6D6D7E2F11} +AppName={#AppName} +AppVersion={#AppVersion} +AppPublisher={#AppPublisher} +AppPublisherURL={#AppURL} +AppSupportURL=https://github.com/recklessop/webhook-server +AppUpdatesURL=https://github.com/recklessop/webhook-server/releases +DefaultDirName={autopf}\WebhookServer +DefaultGroupName={#AppName} +DisableProgramGroupPage=yes +OutputBaseFilename=WebhookServer-Setup-{#AppVersion} +OutputDir={#RepoRoot}dist +SetupIconFile={#RepoRoot}resources\webhook-server.ico +UninstallDisplayIcon={app}\{#AppExeName} +PrivilegesRequired=admin +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +Compression=lzma2/max +SolidCompression=yes +WizardStyle=modern +VersionInfoVersion={#AppVersion}.0 +VersionInfoCompany={#AppPublisher} +VersionInfoProductName={#AppName} + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "Create a &desktop shortcut"; GroupDescription: "Additional shortcuts:"; Flags: unchecked + +[Files] +Source: "{#RepoRoot}publish\service\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs +Source: "{#RepoRoot}publish\gui\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs +Source: "{#RepoRoot}scripts\install-service.ps1"; DestDir: "{app}\scripts"; Flags: ignoreversion +Source: "{#RepoRoot}scripts\uninstall-service.ps1"; DestDir: "{app}\scripts"; Flags: ignoreversion +Source: "{#RepoRoot}README.md"; DestDir: "{app}"; Flags: ignoreversion +Source: "{#RepoRoot}resources\webhook-server.ico"; DestDir: "{app}"; Flags: ignoreversion + +[Icons] +Name: "{group}\{#AppName}"; Filename: "{app}\{#AppExeName}"; IconFilename: "{app}\webhook-server.ico" +Name: "{group}\Uninstall {#AppName}"; Filename: "{uninstallexe}" +Name: "{commondesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; IconFilename: "{app}\webhook-server.ico"; Tasks: desktopicon + +[Run] +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 + +[UninstallRun] +Filename: "powershell.exe"; \ + Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\scripts\uninstall-service.ps1"""; \ + Flags: runhidden; \ + RunOnceId: "RemoveWebhookService" diff --git a/resources/webhook-server.ico b/resources/webhook-server.ico new file mode 100644 index 0000000000000000000000000000000000000000..dd4e4918a9d0e865cb5e77cef9dd677beba76bea GIT binary patch literal 9615 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 +[CmdletBinding()] +param( + [string]$Configuration = 'Release', + [string]$VersionOverride +) + +$ErrorActionPreference = 'Stop' +$repoRoot = Split-Path -Parent $PSScriptRoot + +function Get-RepoVersion { + $propsPath = Join-Path $repoRoot 'Directory.Build.props' + [xml]$props = Get-Content $propsPath + return $props.Project.PropertyGroup.Version +} + +function Find-InnoCompiler { + $candidates = @( + 'ISCC.exe', # on PATH + 'C:\Program Files (x86)\Inno Setup 6\ISCC.exe', + 'C:\Program Files\Inno Setup 6\ISCC.exe' + ) + foreach ($c in $candidates) { + $cmd = Get-Command $c -ErrorAction SilentlyContinue + if ($cmd) { return $cmd.Path } + if (Test-Path $c) { return $c } + } + throw "Inno Setup compiler not found. Install with: winget install JRSoftware.InnoSetup" +} + +$version = if ($VersionOverride) { $VersionOverride } else { Get-RepoVersion } +Write-Host "Building Webhook Server installer v$version" -ForegroundColor Cyan + +# 1. Publish both projects. +$publishSvc = Join-Path $repoRoot 'publish\service' +$publishGui = Join-Path $repoRoot 'publish\gui' +Remove-Item -Recurse -Force $publishSvc, $publishGui -ErrorAction SilentlyContinue + +& dotnet publish (Join-Path $repoRoot 'src\WebhookServer.Service\WebhookServer.Service.csproj') ` + -c $Configuration -r win-x64 --self-contained false -o $publishSvc | Out-Host +if ($LASTEXITCODE -ne 0) { throw 'service publish failed' } + +& dotnet publish (Join-Path $repoRoot 'src\WebhookServer.Gui\WebhookServer.Gui.csproj') ` + -c $Configuration -r win-x64 --self-contained false -o $publishGui | Out-Host +if ($LASTEXITCODE -ne 0) { throw 'GUI publish failed' } + +# 2. Compile installer. +$iscc = Find-InnoCompiler +$iss = Join-Path $repoRoot 'installer\webhook-server.iss' +$dist = Join-Path $repoRoot 'dist' +New-Item -ItemType Directory -Path $dist -Force | Out-Null + +Write-Host "Compiling installer with $iscc" +& $iscc "/DAppVersion=$version" $iss +if ($LASTEXITCODE -ne 0) { throw 'Inno Setup compile failed' } + +$out = Get-Item (Join-Path $dist "WebhookServer-Setup-$version.exe") +Write-Host "" +Write-Host ("Built: {0} ({1:n0} bytes)" -f $out.FullName, $out.Length) -ForegroundColor Green diff --git a/scripts/generate-icons.ps1 b/scripts/generate-icons.ps1 new file mode 100644 index 0000000..d68f977 --- /dev/null +++ b/scripts/generate-icons.ps1 @@ -0,0 +1,138 @@ +<# +.SYNOPSIS + Generates webhook-server.ico (multi-resolution) and webhook-server.png from + a programmatic design. Re-run after changing Draw-Icon to refresh assets. + +.DESCRIPTION + Renders the icon at 16/24/32/48/64/128/256 px using System.Drawing, then + assembles a Microsoft-format ICO file with each size embedded as PNG. No + external tools required. + + Design: a rounded-square teal background (#0E7C66) with a stylized white + hook shape (a "J"-like curve with an arrow tip). +#> +[CmdletBinding()] +param( + [string]$OutputDir = (Join-Path $PSScriptRoot '..\resources') +) + +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Drawing + +New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null + +function New-IconBitmap([int]$size) { + $bmp = New-Object System.Drawing.Bitmap $size, $size, ([System.Drawing.Imaging.PixelFormat]::Format32bppArgb) + $g = [System.Drawing.Graphics]::FromImage($bmp) + $g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias + $g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic + $g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality + + # Background: rounded square in brand teal. + $bgColor = [System.Drawing.Color]::FromArgb(0xFF, 0x0E, 0x7C, 0x66) + $bgBrush = New-Object System.Drawing.SolidBrush $bgColor + $radius = [int]($size * 0.22) + $rect = New-Object System.Drawing.RectangleF 0, 0, $size, $size + + $path = New-Object System.Drawing.Drawing2D.GraphicsPath + $d = $radius * 2 + $path.AddArc($rect.X, $rect.Y, $d, $d, 180, 90) + $path.AddArc($rect.Right - $d, $rect.Y, $d, $d, 270, 90) + $path.AddArc($rect.Right - $d, $rect.Bottom - $d, $d, $d, 0, 90) + $path.AddArc($rect.X, $rect.Bottom - $d, $d, $d, 90, 90) + $path.CloseFigure() + $g.FillPath($bgBrush, $path) + + # Foreground: white hook shape - a thick curved stroke shaped like a "J" + # tipped with an arrowhead, sized relative to the canvas. + $fgColor = [System.Drawing.Color]::White + $stroke = [Math]::Max(2, [int]($size * 0.12)) + $pen = New-Object System.Drawing.Pen $fgColor, $stroke + $pen.StartCap = [System.Drawing.Drawing2D.LineCap]::Round + $pen.EndCap = [System.Drawing.Drawing2D.LineCap]::Round + $pen.LineJoin = [System.Drawing.Drawing2D.LineJoin]::Round + + # Hook curve: vertical down-stroke on the right, then a half-circle arc + # curling to the left and ending in a small filled dot for the hook tip. + $cx = [single]($size * 0.62) + $top = [single]($size * 0.22) + $bottom = [single]($size * 0.58) + $arcD = [single]($size * 0.34) # arc diameter + $arcLeft = [single]($cx - $arcD) # left edge of arc circle + + # Vertical stroke from (cx, top) to (cx, bottom). + $g.DrawLine($pen, $cx, $top, $cx, $bottom) + + # Half-circle arc beneath: starts at (cx, bottom), curls to (cx - arcD, bottom). + $arcRect = New-Object System.Drawing.RectangleF $arcLeft, ([single]($bottom - $arcD / 2)), $arcD, $arcD + $g.DrawArc($pen, $arcRect, 0, 180) + + # Filled circle at the tip end of the arc. + $tipR = [single]($stroke * 0.6) + $tipX = $arcLeft + $tipY = [single]($bottom) + $brushFg = New-Object System.Drawing.SolidBrush $fgColor + $g.FillEllipse($brushFg, [single]($tipX - $tipR), [single]($tipY - $tipR), [single]($tipR * 2), [single]($tipR * 2)) + + $brushFg.Dispose(); $pen.Dispose(); $bgBrush.Dispose(); $path.Dispose() + $g.Dispose() + return $bmp +} + +# Generate each size as PNG bytes. Hashtable keys are prefixed with "s" because +# PowerShell hashtable lookups by integer key behave inconsistently with PSObject +# wrapping; string keys round-trip cleanly. +$sizes = @(16, 24, 32, 48, 64, 128, 256) +$pngs = @{} +foreach ($s in $sizes) { + $bmp = New-IconBitmap $s + $ms = New-Object System.IO.MemoryStream + $bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png) + $pngs["s$s"] = $ms.ToArray() + $ms.Dispose() + $bmp.Dispose() +} + +# Save the master 256 PNG separately for places that need a transparent PNG. +$pngPath = Join-Path $OutputDir 'webhook-server.png' +[System.IO.File]::WriteAllBytes($pngPath, [byte[]]$pngs['s256']) + +# Assemble multi-resolution ICO. +$icoPath = Join-Path $OutputDir 'webhook-server.ico' +$ms = New-Object System.IO.MemoryStream +$bw = New-Object System.IO.BinaryWriter $ms +try { + $count = $sizes.Count + $bw.Write([UInt16]0) # idReserved + $bw.Write([UInt16]1) # idType: 1 = ICO + $bw.Write([UInt16]$count) # idCount + + # Directory entries (16 bytes each). + $offset = 6 + 16 * $count + foreach ($s in $sizes) { + $bytes = $pngs["s$s"] + $w = if ($s -ge 256) { 0 } else { $s } + $h = if ($s -ge 256) { 0 } else { $s } + $bw.Write([byte]$w) # width + $bw.Write([byte]$h) # height + $bw.Write([byte]0) # colorCount + $bw.Write([byte]0) # reserved + $bw.Write([UInt16]1) # planes + $bw.Write([UInt16]32) # bitCount + $bw.Write([UInt32]$bytes.Length) + $bw.Write([UInt32]$offset) + $offset += $bytes.Length + } + + # Image data. + foreach ($s in $sizes) { $bw.Write($pngs["s$s"]) } + + $bw.Flush() + [System.IO.File]::WriteAllBytes($icoPath, $ms.ToArray()) +} +finally { + $bw.Dispose(); $ms.Dispose() +} + +Write-Host "Wrote $icoPath ($((Get-Item $icoPath).Length) bytes)" +Write-Host "Wrote $pngPath ($((Get-Item $pngPath).Length) bytes)" diff --git a/src/WebhookServer.Core/Ipc/AdminProtocol.cs b/src/WebhookServer.Core/Ipc/AdminProtocol.cs index 2c9b37f..5ca1c4e 100644 --- a/src/WebhookServer.Core/Ipc/AdminProtocol.cs +++ b/src/WebhookServer.Core/Ipc/AdminProtocol.cs @@ -23,6 +23,21 @@ public static class AdminOps public const string BindHttps = "bind-https"; public const string RestartListener = "restart-listener"; public const string Ping = "ping"; + public const string ListBackups = "list-backups"; + public const string RestoreBackup = "restore-backup"; + public const string ImportConfig = "import-config"; +} + +public sealed class BackupEntry +{ + public string FileName { get; set; } = ""; + public DateTimeOffset SavedAt { get; set; } + public long SizeBytes { get; set; } +} + +public sealed class RestoreBackupArgs +{ + public string FileName { get; set; } = ""; } public sealed class AdminRequest diff --git a/src/WebhookServer.Core/Storage/ConfigStore.cs b/src/WebhookServer.Core/Storage/ConfigStore.cs index 7e8b9a4..2f6daa3 100644 --- a/src/WebhookServer.Core/Storage/ConfigStore.cs +++ b/src/WebhookServer.Core/Storage/ConfigStore.cs @@ -38,6 +38,25 @@ public sealed class ConfigStore var dir = System.IO.Path.GetDirectoryName(Path); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + // Snapshot the previous config (if any) into the backups folder before + // overwriting. Cheap insurance against typos in the GUI. + if (File.Exists(Path) && !string.IsNullOrEmpty(dir)) + { + try + { + var backupsDir = System.IO.Path.Combine(dir, "backups"); + 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); + PruneBackups(backupsDir, retain: 30); + } + catch + { + // Backup is best-effort; don't fail the save if it can't write. + } + } + var tmp = Path + ".tmp"; await using (var fs = File.Create(tmp)) { @@ -49,6 +68,17 @@ public sealed class ConfigStore File.Move(tmp, Path, overwrite: true); } + private static void PruneBackups(string backupsDir, int retain) + { + var stale = new DirectoryInfo(backupsDir).GetFiles("config-*.json") + .OrderByDescending(f => f.Name) + .Skip(retain); + foreach (var f in stale) + { + try { f.Delete(); } catch { } + } + } + public static void ClearPlaintexts(ServerConfig config) { foreach (var ep in config.Endpoints) diff --git a/src/WebhookServer.Gui/App.xaml.cs b/src/WebhookServer.Gui/App.xaml.cs index 9e28109..845e1e0 100644 --- a/src/WebhookServer.Gui/App.xaml.cs +++ b/src/WebhookServer.Gui/App.xaml.cs @@ -1,13 +1,5 @@ -using System.Configuration; -using System.Data; -using System.Windows; - namespace WebhookServer.Gui; -/// -/// Interaction logic for App.xaml -/// public partial class App : Application { } - diff --git a/src/WebhookServer.Gui/Converters/Converters.cs b/src/WebhookServer.Gui/Converters/Converters.cs index e09d260..e346830 100644 --- a/src/WebhookServer.Gui/Converters/Converters.cs +++ b/src/WebhookServer.Gui/Converters/Converters.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Windows.Data; -using System.Windows.Media; +using Brush = System.Windows.Media.Brush; +using Brushes = System.Windows.Media.Brushes; namespace WebhookServer.Gui.Converters; diff --git a/src/WebhookServer.Gui/GlobalUsings.cs b/src/WebhookServer.Gui/GlobalUsings.cs new file mode 100644 index 0000000..59cef05 --- /dev/null +++ b/src/WebhookServer.Gui/GlobalUsings.cs @@ -0,0 +1,15 @@ +// Enabling UseWindowsForms (for the system tray NotifyIcon) brings the WinForms +// namespace into scope, which conflicts with WPF for several common type names. +// Alias the most-used types to their WPF variants project-wide so existing code +// keeps compiling. Files that genuinely need a WinForms type import it explicitly +// (System.Windows.Forms.NotifyIcon etc. in Services/TrayIcon.cs). + +global using Application = System.Windows.Application; +global using MessageBox = System.Windows.MessageBox; +global using Clipboard = System.Windows.Clipboard; +global using TextBox = System.Windows.Controls.TextBox; +global using RadioButton = System.Windows.Controls.RadioButton; +global using MessageBoxButton = System.Windows.MessageBoxButton; +global using MessageBoxImage = System.Windows.MessageBoxImage; +global using MessageBoxResult = System.Windows.MessageBoxResult; +global using Binding = System.Windows.Data.Binding; diff --git a/src/WebhookServer.Gui/MainWindow.xaml b/src/WebhookServer.Gui/MainWindow.xaml index 17ea76f..a2ff565 100644 --- a/src/WebhookServer.Gui/MainWindow.xaml +++ b/src/WebhookServer.Gui/MainWindow.xaml @@ -7,6 +7,7 @@ xmlns:models="clr-namespace:WebhookServer.Core.Models;assembly=WebhookServer.Core" mc:Ignorable="d" Title="Webhook Server" Height="600" Width="1000" + Icon="/webhook-server.ico" d:DataContext="{d:DesignInstance Type=vm:MainViewModel}"> @@ -26,9 +27,26 @@ - - - + + + + + + + diff --git a/src/WebhookServer.Gui/MainWindow.xaml.cs b/src/WebhookServer.Gui/MainWindow.xaml.cs index 3df0142..de2a239 100644 --- a/src/WebhookServer.Gui/MainWindow.xaml.cs +++ b/src/WebhookServer.Gui/MainWindow.xaml.cs @@ -8,12 +8,37 @@ namespace WebhookServer.Gui; public partial class MainWindow : Window { + private readonly TrayIcon _tray; + private readonly MainViewModel _vm; + public MainWindow() { InitializeComponent(); - var vm = new MainViewModel(new AdminPipeClient()); - DataContext = vm; - Loaded += async (_, _) => await vm.RefreshCommand.ExecuteAsync(null); + _vm = new MainViewModel(new AdminPipeClient()); + DataContext = _vm; + + _tray = new TrayIcon( + resolveMainWindow: () => Application.Current.MainWindow, + restartServiceAsync: async () => await new AdminPipeClient().RestartListenerAsync()); + + Loaded += async (_, _) => await _vm.RefreshCommand.ExecuteAsync(null); + StateChanged += OnStateChanged; + Closed += (_, _) => _tray.Dispose(); + } + + private void OnStateChanged(object? sender, EventArgs e) + { + // Minimize-to-tray: hide the window when the user minimizes; restoring is + // via the tray icon's double-click or context menu. + if (WindowState == WindowState.Minimized) + { + Hide(); + ShowInTaskbar = false; + } + else + { + ShowInTaskbar = true; + } } private void OnLogTailChanged(object sender, TextChangedEventArgs e) @@ -27,4 +52,10 @@ public partial class MainWindow : Window if (DataContext is MainViewModel vm && vm.EditEndpointCommand.CanExecute(null)) 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 cc56d04..4ce804e 100644 --- a/src/WebhookServer.Gui/Services/AdminPipeClient.cs +++ b/src/WebhookServer.Gui/Services/AdminPipeClient.cs @@ -86,4 +86,18 @@ public sealed class AdminPipeClient var lst = resp.Data.Value.GetProperty("lines").Deserialize>(AdminProtocol.JsonOptions); return lst ?? new List(); } + + public async Task> ListBackupsAsync(CancellationToken ct = default) + { + var resp = await InvokeAsync(AdminOps.ListBackups, null, ct).ConfigureAwait(false); + if (!resp.Ok || resp.Data is null) return new List(); + var lst = resp.Data.Value.GetProperty("backups").Deserialize>(AdminProtocol.JsonOptions); + return lst ?? new List(); + } + + public Task RestoreBackupAsync(string fileName, CancellationToken ct = default) => + InvokeAsync(AdminOps.RestoreBackup, new RestoreBackupArgs { FileName = fileName }, ct); + + public Task ImportConfigAsync(ServerConfig config, CancellationToken ct = default) => + InvokeAsync(AdminOps.ImportConfig, config, ct); } diff --git a/src/WebhookServer.Gui/Services/TrayIcon.cs b/src/WebhookServer.Gui/Services/TrayIcon.cs new file mode 100644 index 0000000..7cedfa5 --- /dev/null +++ b/src/WebhookServer.Gui/Services/TrayIcon.cs @@ -0,0 +1,86 @@ +using System.Drawing; +using System.Runtime.Versioning; +using System.Windows; +using System.Windows.Forms; + +namespace WebhookServer.Gui.Services; + +/// +/// Minimal system tray icon using Windows Forms NotifyIcon. Owns a context menu +/// (Open / Restart service / Exit) and toggles the main window visibility on +/// double-click. Hide-to-tray on minimize is wired in MainWindow.xaml.cs. +/// +[SupportedOSPlatform("windows")] +public sealed class TrayIcon : IDisposable +{ + private readonly NotifyIcon _icon; + private readonly Func _resolveMainWindow; + private readonly Func _restartServiceAsync; + + public TrayIcon(Func resolveMainWindow, Func restartServiceAsync) + { + _resolveMainWindow = resolveMainWindow; + _restartServiceAsync = restartServiceAsync; + + _icon = new NotifyIcon + { + Icon = LoadEmbeddedIcon(), + Text = "Webhook Server", + Visible = true, + }; + _icon.DoubleClick += (_, _) => ShowMainWindow(); + _icon.ContextMenuStrip = BuildMenu(); + } + + private ContextMenuStrip BuildMenu() + { + var menu = new ContextMenuStrip(); + menu.Items.Add("&Open Webhook Server", null, (_, _) => ShowMainWindow()); + menu.Items.Add(new ToolStripSeparator()); + menu.Items.Add("&Restart service", null, async (_, _) => await _restartServiceAsync().ConfigureAwait(false)); + menu.Items.Add(new ToolStripSeparator()); + menu.Items.Add("E&xit", null, (_, _) => Application.Current.Shutdown()); + return menu; + } + + private void ShowMainWindow() + { + var w = _resolveMainWindow(); + if (w is null) return; + if (w.WindowState == WindowState.Minimized) w.WindowState = WindowState.Normal; + w.Show(); + w.Activate(); + w.Topmost = true; + w.Topmost = false; + } + + private static Icon LoadEmbeddedIcon() + { + // Pulled from the WPF Resource items in the csproj via the application + // pack URI. Falling back to SystemIcons keeps the tray usable if the + // resource is somehow missing. + try + { + var uri = new Uri("pack://application:,,,/webhook-server.ico", UriKind.Absolute); + using var stream = Application.GetResourceStream(uri).Stream; + return new Icon(stream); + } + catch + { + return SystemIcons.Application; + } + } + + public void ShowBalloon(string title, string message) + { + _icon.BalloonTipTitle = title; + _icon.BalloonTipText = message; + _icon.ShowBalloonTip(3000); + } + + public void Dispose() + { + _icon.Visible = false; + _icon.Dispose(); + } +} diff --git a/src/WebhookServer.Gui/ViewModels/MainViewModel.cs b/src/WebhookServer.Gui/ViewModels/MainViewModel.cs index 297fd91..69b8d11 100644 --- a/src/WebhookServer.Gui/ViewModels/MainViewModel.cs +++ b/src/WebhookServer.Gui/ViewModels/MainViewModel.cs @@ -175,6 +175,92 @@ public sealed partial class MainViewModel : ObservableObject } } + [ObservableProperty] private System.Collections.ObjectModel.ObservableCollection _backups = new(); + + [RelayCommand] + private async Task RefreshBackupsAsync() + { + try + { + var list = await _client.ListBackupsAsync().ConfigureAwait(false); + Application.Current.Dispatcher.Invoke(() => + { + Backups.Clear(); + foreach (var b in list) Backups.Add(b); + }); + } + catch { /* ignore - backup listing isn't critical */ } + } + + [RelayCommand] + private async Task RestoreBackupAsync(BackupEntry? entry) + { + if (entry is null) return; + var ok = MessageBox.Show( + $"Restore configuration from {entry.FileName} ({entry.SavedAt:yyyy-MM-dd HH:mm})?\n\nA backup of the current config will be saved first.", + "Restore backup", + 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); } + } + + [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(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 backup of the current config will be saved first.", + "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() { diff --git a/src/WebhookServer.Gui/Views/AboutDialog.xaml b/src/WebhookServer.Gui/Views/AboutDialog.xaml index 8612b27..455f611 100644 --- a/src/WebhookServer.Gui/Views/AboutDialog.xaml +++ b/src/WebhookServer.Gui/Views/AboutDialog.xaml @@ -2,9 +2,10 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="About Webhook Server" - Height="320" Width="420" + Height="360" Width="440" ResizeMode="NoResize" WindowStartupLocation="CenterOwner" + Icon="/webhook-server.ico" ShowInTaskbar="False"> diff --git a/src/WebhookServer.Gui/WebhookServer.Gui.csproj b/src/WebhookServer.Gui/WebhookServer.Gui.csproj index 4ffed94..8308673 100644 --- a/src/WebhookServer.Gui/WebhookServer.Gui.csproj +++ b/src/WebhookServer.Gui/WebhookServer.Gui.csproj @@ -14,6 +14,14 @@ enable enable true + true + ..\..\resources\webhook-server.ico + Webhook Server + + + + + diff --git a/src/WebhookServer.Service/AdminPipeServer.cs b/src/WebhookServer.Service/AdminPipeServer.cs index 54fff23..811b0a1 100644 --- a/src/WebhookServer.Service/AdminPipeServer.cs +++ b/src/WebhookServer.Service/AdminPipeServer.cs @@ -202,11 +202,66 @@ internal sealed class AdminPipeServer : BackgroundService return AdminResponse.Success(new { lines }); } + case AdminOps.ListBackups: + { + var entries = ListBackups(); + return AdminResponse.Success(new { backups = entries }); + } + + case AdminOps.RestoreBackup: + { + var args = DeserializeData(request) ?? throw new ArgumentException("missing fileName"); + var restored = await RestoreBackupAsync(args.FileName, ct).ConfigureAwait(false); + _logger.LogInformation("Restored config from backup {File}", args.FileName); + return AdminResponse.Success(SafeSnapshotForWire(restored)); + } + + case AdminOps.ImportConfig: + { + var incoming = DeserializeData(request) ?? throw new ArgumentException("missing config payload"); + MergeWithExistingSecrets(incoming, _state.Snapshot()); + await _state.ReplaceAsync(incoming, ct).ConfigureAwait(false); + _logger.LogInformation("Config imported ({Count} endpoints)", incoming.Endpoints.Count); + return AdminResponse.Success(SafeSnapshotForWire(_state.Snapshot())); + } + default: return AdminResponse.Failure($"unknown op '{request.Op}'"); } } + private static List ListBackups() + { + var dir = Path.Combine(ServicePaths.DataRoot, "backups"); + if (!Directory.Exists(dir)) return new List(); + return new DirectoryInfo(dir).GetFiles("config-*.json") + .OrderByDescending(f => f.Name) + .Take(50) + .Select(f => new BackupEntry + { + FileName = f.Name, + SavedAt = f.LastWriteTimeUtc, + SizeBytes = f.Length, + }) + .ToList(); + } + + private async Task RestoreBackupAsync(string fileName, CancellationToken ct) + { + // Refuse anything that tries to escape the backups directory. + if (fileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) + throw new ArgumentException("invalid file name"); + var backupPath = Path.Combine(ServicePaths.DataRoot, "backups", fileName); + if (!File.Exists(backupPath)) + throw new FileNotFoundException("backup not found", fileName); + + await using var fs = File.OpenRead(backupPath); + var cfg = await JsonSerializer.DeserializeAsync(fs, ConfigJson.Pretty, ct).ConfigureAwait(false) + ?? throw new InvalidOperationException("backup file was empty"); + await _state.ReplaceAsync(cfg, ct).ConfigureAwait(false); + return _state.Snapshot(); + } + private ServerConfig CloneSnapshotForEdit() { // Round-trip via JSON to avoid sharing references with the live snapshot. -- 2.52.0 From 7d94535d5d562b7662c459180f12c061802a5b23 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Fri, 8 May 2026 10:22:53 -0400 Subject: [PATCH 2/4] v0.1.1: GUI auto-elevates, installer handles upgrades cleanly (#2) * v0.1.1: GUI auto-elevates, installer stops service before file copy Two fixes for the v0.1.0 install experience: 1. Embed app.manifest with requestedExecutionLevel=requireAdministrator so the GUI always elevates. The named pipe is ACL'd to SYSTEM and the Administrators group, but UAC token splitting puts Admins in deny-only on the standard token, so launching the GUI from the Start Menu fails to connect with "Access is denied". The manifest forces UAC to elevate, surfaces the shield icon on the shortcut, and matches the reality that the GUI cannot function without admin rights. 2. Add a [Code] PrepareToInstall hook to webhook-server.iss that runs `sc stop WebhookServer` before file copy. Upgrade installs were failing on locked binaries because the running service held the exes open. sc returns non-zero on fresh installs (no service yet) which we ignore. Bumps Version to 0.1.1. Co-Authored-By: Claude Opus 4.7 (1M context) * Rename "Backups" menu item to "Config Checkpoints" User-facing copy only; internal API names (Backups collection, BackupEntry, list-backups op, etc.) stay the same to avoid churn through the wire protocol and existing on-disk files. The new phrasing makes the auto-snapshot-before-save model more discoverable. Co-Authored-By: Claude Opus 4.7 (1M context) * Installer: synchronous service stop + kill stray GUI/Service processes The previous sc.exe stop is fire-and-forget; on slower machines the file-copy step started before the service had actually released its binaries, leaving the upgrade in a broken state. Switch to net.exe stop which blocks until the service reports STOPPED. Also taskkill any running WebhookServer.Gui.exe (the user might have left the tray running) and any orphan WebhookServer.Service.exe (from deploy.ps1 dev runs) so all copies of the binaries are unlocked before [Files] runs. Pre-flight ServiceExists() check via sc query so the installer only calls "net stop" when there is actually a service to stop, rather than relying on net's error code. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- Directory.Build.props | 2 +- installer/webhook-server.iss | 38 +++++++++++++++++++ src/WebhookServer.Gui/MainWindow.xaml | 3 +- .../ViewModels/MainViewModel.cs | 8 ++-- .../WebhookServer.Gui.csproj | 1 + src/WebhookServer.Gui/app.manifest | 24 ++++++++++++ 6 files changed, 70 insertions(+), 6 deletions(-) create mode 100644 src/WebhookServer.Gui/app.manifest diff --git a/Directory.Build.props b/Directory.Build.props index 65b6f43..434a152 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ - 0.1.0 + 0.1.1 Justin Paul Justin Paul Webhook Server diff --git a/installer/webhook-server.iss b/installer/webhook-server.iss index 36c7314..b97f5ef 100644 --- a/installer/webhook-server.iss +++ b/installer/webhook-server.iss @@ -77,3 +77,41 @@ Filename: "powershell.exe"; \ Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\scripts\uninstall-service.ps1"""; \ Flags: runhidden; \ RunOnceId: "RemoveWebhookService" + +[Code] +function ServiceExists(): Boolean; +var + ResultCode: Integer; +begin + // sc.exe query returns 0 when the service exists, 1060 when it does not. + Exec(ExpandConstant('{sys}\sc.exe'), 'query WebhookServer', '', SW_HIDE, + ewWaitUntilTerminated, ResultCode); + Result := (ResultCode = 0); +end; + +function PrepareToInstall(var NeedsRestart: Boolean): String; +var + ResultCode: Integer; +begin + Result := ''; + + // 1. If the service exists, stop it so its binaries are unlocked before file + // copy. net stop is synchronous (blocks until the service is actually + // stopped), unlike sc stop which is fire-and-forget. Non-zero exit - + // already stopped, missing, dependency error - we ignore; the file copy + // will fail loudly if the binaries are still locked. + if ServiceExists() then + begin + WizardForm.PreparingLabel.Caption := 'Stopping the WebhookServer service...'; + Exec(ExpandConstant('{sys}\net.exe'), 'stop WebhookServer', '', SW_HIDE, + ewWaitUntilTerminated, ResultCode); + end; + + // 2. Kill any running GUI / tray instances so their binaries are unlocked too. + // /f forces termination, /im matches by image name, "*" wildcard would be + // risky so we name them explicitly. + Exec(ExpandConstant('{sys}\taskkill.exe'), '/f /im WebhookServer.Gui.exe', + '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + Exec(ExpandConstant('{sys}\taskkill.exe'), '/f /im WebhookServer.Service.exe', + '', SW_HIDE, ewWaitUntilTerminated, ResultCode); +end; diff --git a/src/WebhookServer.Gui/MainWindow.xaml b/src/WebhookServer.Gui/MainWindow.xaml index a2ff565..64f037c 100644 --- a/src/WebhookServer.Gui/MainWindow.xaml +++ b/src/WebhookServer.Gui/MainWindow.xaml @@ -29,8 +29,9 @@ - - - + @@ -57,6 +39,8 @@ + + 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..3611aaa 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(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 new file mode 100644 index 0000000..d0fa3fc --- /dev/null +++ b/src/WebhookServer.Gui/ViewModels/ConfigCheckpointsViewModel.cs @@ -0,0 +1,105 @@ +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() + { + // 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(description).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 6387b20..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] @@ -290,6 +269,23 @@ public sealed partial class MainViewModel : ObservableObject 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); + } + } + [RelayCommand] private void Exit() { diff --git a/src/WebhookServer.Gui/Views/ConfigCheckpointsDialog.xaml b/src/WebhookServer.Gui/Views/ConfigCheckpointsDialog.xaml new file mode 100644 index 0000000..45ce611 --- /dev/null +++ b/src/WebhookServer.Gui/Views/ConfigCheckpointsDialog.xaml @@ -0,0 +1,53 @@ + + + + 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. + + + +