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 0000000..dd4e491
Binary files /dev/null and b/resources/webhook-server.ico differ
diff --git a/resources/webhook-server.png b/resources/webhook-server.png
new file mode 100644
index 0000000..be9ef7d
Binary files /dev/null and b/resources/webhook-server.png differ
diff --git a/scripts/build-installer.ps1 b/scripts/build-installer.ps1
new file mode 100644
index 0000000..af8e060
--- /dev/null
+++ b/scripts/build-installer.ps1
@@ -0,0 +1,68 @@
+<#
+.SYNOPSIS
+ End-to-end installer build: publish service + GUI, then run Inno Setup
+ to produce dist/WebhookServer-Setup-{version}.exe.
+
+.DESCRIPTION
+ Reads the version from Directory.Build.props. Requires Inno Setup 6 (ISCC.exe)
+ on PATH or in the standard install location. CI runs this same script after
+ setup-dotnet + winget install Inno Setup.
+#>
+[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.