From a808964cf191feb040d1a1c14c26bb9cb92da5ee Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Fri, 8 May 2026 10:03:43 -0400 Subject: [PATCH] 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.