7 Commits

Author SHA1 Message Date
justin 16ce906044 Installer: synchronous service stop + kill stray GUI/Service processes
Release / build-installer (push) Has been cancelled
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) <noreply@anthropic.com>
2026-05-08 10:18:01 -04:00
justin a24d49f463 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) <noreply@anthropic.com>
2026-05-08 10:16:49 -04:00
justin e7e533d8c6 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) <noreply@anthropic.com>
2026-05-08 10:15:26 -04:00
justin 93a9c327e0 Phase 4: backups + import/export config
Release / build-installer (push) Has been cancelled
CI / build (pull_request) Has been cancelled
ConfigStore.SaveAsync now snapshots the previous config to
%ProgramData%\WebhookServer\backups\config-<timestamp>.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) <noreply@anthropic.com>
2026-05-08 09:55:03 -04:00
justin 9e6abeef74 Phase 6+7: Inno Setup installer + GitHub Actions release pipeline
CI / build (pull_request) Has been cancelled
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) <noreply@anthropic.com>
2026-05-08 09:52:37 -04:00
justin 9525ee358e 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) <noreply@anthropic.com>
2026-05-08 09:51:00 -04:00
justin f3bca1e8ff 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) <noreply@anthropic.com>
2026-05-08 09:48:33 -04:00
22 changed files with 816 additions and 17 deletions
+27
View File
@@ -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
+71
View File
@@ -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
+1 -1
View File
@@ -1,7 +1,7 @@
<Project>
<PropertyGroup>
<Version>0.1.0</Version>
<Version>0.1.1</Version>
<Authors>Justin Paul</Authors>
<Company>Justin Paul</Company>
<Product>Webhook Server</Product>
+117
View File
@@ -0,0 +1,117 @@
; 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"
[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;
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

+68
View File
@@ -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
+138
View File
@@ -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)"
@@ -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
@@ -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)
-8
View File
@@ -1,13 +1,5 @@
using System.Configuration;
using System.Data;
using System.Windows;
namespace WebhookServer.Gui;
/// <summary>
/// Interaction logic for App.xaml
/// </summary>
public partial class App : Application
{
}
@@ -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;
+15
View File
@@ -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;
+22 -3
View File
@@ -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}">
<Window.InputBindings>
<KeyBinding Key="N" Modifiers="Control" Command="{Binding AddEndpointCommand}"/>
@@ -26,9 +27,27 @@
<MenuItem Header="_File">
<MenuItem Header="_New endpoint…" Command="{Binding AddEndpointCommand}" InputGestureText="Ctrl+N"/>
<Separator/>
<MenuItem Header="_Import config…" IsEnabled="False" ToolTip="Coming soon"/>
<MenuItem Header="_Export config…" IsEnabled="False" ToolTip="Coming soon"/>
<MenuItem Header="_Backups" IsEnabled="False" ToolTip="Coming soon"/>
<MenuItem Header="_Import config…" Command="{Binding ImportConfigCommand}"/>
<MenuItem Header="_Export config…" Command="{Binding ExportConfigCommand}"/>
<MenuItem Header="Config _Checkpoints"
ItemsSource="{Binding Backups}"
ToolTip="Snapshots taken automatically before each save. Click one to restore."
SubmenuOpened="OnBackupsSubmenuOpened">
<MenuItem.ItemContainerStyle>
<Style TargetType="MenuItem">
<Setter Property="Header">
<Setter.Value>
<MultiBinding StringFormat="{}{0:yyyy-MM-dd HH:mm:ss} ({1:n0} bytes)">
<Binding Path="SavedAt"/>
<Binding Path="SizeBytes"/>
</MultiBinding>
</Setter.Value>
</Setter>
<Setter Property="Command" Value="{Binding DataContext.RestoreBackupCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<Setter Property="CommandParameter" Value="{Binding}"/>
</Style>
</MenuItem.ItemContainerStyle>
</MenuItem>
<Separator/>
<MenuItem Header="E_xit" Command="{Binding ExitCommand}"/>
</MenuItem>
+34 -3
View File
@@ -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);
}
}
@@ -86,4 +86,18 @@ public sealed class AdminPipeClient
var lst = resp.Data.Value.GetProperty("lines").Deserialize<List<LogLine>>(AdminProtocol.JsonOptions);
return lst ?? new List<LogLine>();
}
public async Task<List<BackupEntry>> ListBackupsAsync(CancellationToken ct = default)
{
var resp = await InvokeAsync(AdminOps.ListBackups, null, ct).ConfigureAwait(false);
if (!resp.Ok || resp.Data is null) return new List<BackupEntry>();
var lst = resp.Data.Value.GetProperty("backups").Deserialize<List<BackupEntry>>(AdminProtocol.JsonOptions);
return lst ?? new List<BackupEntry>();
}
public Task<AdminResponse> RestoreBackupAsync(string fileName, CancellationToken ct = default) =>
InvokeAsync(AdminOps.RestoreBackup, new RestoreBackupArgs { FileName = fileName }, ct);
public Task<AdminResponse> ImportConfigAsync(ServerConfig config, CancellationToken ct = default) =>
InvokeAsync(AdminOps.ImportConfig, config, ct);
}
@@ -0,0 +1,86 @@
using System.Drawing;
using System.Runtime.Versioning;
using System.Windows;
using System.Windows.Forms;
namespace WebhookServer.Gui.Services;
/// <summary>
/// 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.
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class TrayIcon : IDisposable
{
private readonly NotifyIcon _icon;
private readonly Func<Window?> _resolveMainWindow;
private readonly Func<Task> _restartServiceAsync;
public TrayIcon(Func<Window?> resolveMainWindow, Func<Task> 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();
}
}
@@ -175,6 +175,92 @@ public sealed partial class MainViewModel : ObservableObject
}
}
[ObservableProperty] private System.Collections.ObjectModel.ObservableCollection<BackupEntry> _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 - 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); }
}
[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<ServerConfig>(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 checkpoint of the current config is saved first, so you can roll back from File → Config Checkpoints.",
"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()
{
+2 -1
View File
@@ -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">
<Grid Margin="20">
<Grid.RowDefinitions>
@@ -14,6 +14,15 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<UseWindowsForms>true</UseWindowsForms>
<ApplicationIcon>..\..\resources\webhook-server.ico</ApplicationIcon>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AssemblyTitle>Webhook Server</AssemblyTitle>
</PropertyGroup>
<ItemGroup>
<Resource Include="..\..\resources\webhook-server.ico" Link="webhook-server.ico" />
<Resource Include="..\..\resources\webhook-server.png" Link="webhook-server.png" />
</ItemGroup>
</Project>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="WebhookServer.Gui"/>
<!-- The GUI talks to the service via a named pipe ACL'd to SYSTEM and the
Administrators group. UAC token splitting denies that group on the
standard user token, so without elevation the pipe connect fails with
"Access is denied". Always run elevated. Start Menu shortcuts and the
installer's post-install launch both honor this. -->
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v2">
<requestedExecutionLevel level="requireAdministrator" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
</application>
</compatibility>
</assembly>
@@ -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<RestoreBackupArgs>(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<ServerConfig>(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<BackupEntry> ListBackups()
{
var dir = Path.Combine(ServicePaths.DataRoot, "backups");
if (!Directory.Exists(dir)) return new List<BackupEntry>();
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<ServerConfig> 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<ServerConfig>(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.