Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 16ce906044 | |||
| a24d49f463 | |||
| e7e533d8c6 | |||
| 93a9c327e0 | |||
| 9e6abeef74 | |||
| 9525ee358e | |||
| f3bca1e8ff |
@@ -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
|
||||||
@@ -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,7 +1,7 @@
|
|||||||
<Project>
|
<Project>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>0.1.0</Version>
|
<Version>0.1.1</Version>
|
||||||
<Authors>Justin Paul</Authors>
|
<Authors>Justin Paul</Authors>
|
||||||
<Company>Justin Paul</Company>
|
<Company>Justin Paul</Company>
|
||||||
<Product>Webhook Server</Product>
|
<Product>Webhook Server</Product>
|
||||||
|
|||||||
@@ -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 |
@@ -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
|
||||||
@@ -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 BindHttps = "bind-https";
|
||||||
public const string RestartListener = "restart-listener";
|
public const string RestartListener = "restart-listener";
|
||||||
public const string Ping = "ping";
|
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
|
public sealed class AdminRequest
|
||||||
|
|||||||
@@ -38,6 +38,25 @@ public sealed class ConfigStore
|
|||||||
var dir = System.IO.Path.GetDirectoryName(Path);
|
var dir = System.IO.Path.GetDirectoryName(Path);
|
||||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
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";
|
var tmp = Path + ".tmp";
|
||||||
await using (var fs = File.Create(tmp))
|
await using (var fs = File.Create(tmp))
|
||||||
{
|
{
|
||||||
@@ -49,6 +68,17 @@ public sealed class ConfigStore
|
|||||||
File.Move(tmp, Path, overwrite: true);
|
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)
|
public static void ClearPlaintexts(ServerConfig config)
|
||||||
{
|
{
|
||||||
foreach (var ep in config.Endpoints)
|
foreach (var ep in config.Endpoints)
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
using System.Configuration;
|
|
||||||
using System.Data;
|
|
||||||
using System.Windows;
|
|
||||||
|
|
||||||
namespace WebhookServer.Gui;
|
namespace WebhookServer.Gui;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Interaction logic for App.xaml
|
|
||||||
/// </summary>
|
|
||||||
public partial class App : Application
|
public partial class App : Application
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Windows.Data;
|
using System.Windows.Data;
|
||||||
using System.Windows.Media;
|
using Brush = System.Windows.Media.Brush;
|
||||||
|
using Brushes = System.Windows.Media.Brushes;
|
||||||
|
|
||||||
namespace WebhookServer.Gui.Converters;
|
namespace WebhookServer.Gui.Converters;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
xmlns:models="clr-namespace:WebhookServer.Core.Models;assembly=WebhookServer.Core"
|
xmlns:models="clr-namespace:WebhookServer.Core.Models;assembly=WebhookServer.Core"
|
||||||
mc:Ignorable="d"
|
mc:Ignorable="d"
|
||||||
Title="Webhook Server" Height="600" Width="1000"
|
Title="Webhook Server" Height="600" Width="1000"
|
||||||
|
Icon="/webhook-server.ico"
|
||||||
d:DataContext="{d:DesignInstance Type=vm:MainViewModel}">
|
d:DataContext="{d:DesignInstance Type=vm:MainViewModel}">
|
||||||
<Window.InputBindings>
|
<Window.InputBindings>
|
||||||
<KeyBinding Key="N" Modifiers="Control" Command="{Binding AddEndpointCommand}"/>
|
<KeyBinding Key="N" Modifiers="Control" Command="{Binding AddEndpointCommand}"/>
|
||||||
@@ -26,9 +27,27 @@
|
|||||||
<MenuItem Header="_File">
|
<MenuItem Header="_File">
|
||||||
<MenuItem Header="_New endpoint…" Command="{Binding AddEndpointCommand}" InputGestureText="Ctrl+N"/>
|
<MenuItem Header="_New endpoint…" Command="{Binding AddEndpointCommand}" InputGestureText="Ctrl+N"/>
|
||||||
<Separator/>
|
<Separator/>
|
||||||
<MenuItem Header="_Import config…" IsEnabled="False" ToolTip="Coming soon"/>
|
<MenuItem Header="_Import config…" Command="{Binding ImportConfigCommand}"/>
|
||||||
<MenuItem Header="_Export config…" IsEnabled="False" ToolTip="Coming soon"/>
|
<MenuItem Header="_Export config…" Command="{Binding ExportConfigCommand}"/>
|
||||||
<MenuItem Header="_Backups" IsEnabled="False" ToolTip="Coming soon"/>
|
<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/>
|
<Separator/>
|
||||||
<MenuItem Header="E_xit" Command="{Binding ExitCommand}"/>
|
<MenuItem Header="E_xit" Command="{Binding ExitCommand}"/>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|||||||
@@ -8,12 +8,37 @@ namespace WebhookServer.Gui;
|
|||||||
|
|
||||||
public partial class MainWindow : Window
|
public partial class MainWindow : Window
|
||||||
{
|
{
|
||||||
|
private readonly TrayIcon _tray;
|
||||||
|
private readonly MainViewModel _vm;
|
||||||
|
|
||||||
public MainWindow()
|
public MainWindow()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
var vm = new MainViewModel(new AdminPipeClient());
|
_vm = new MainViewModel(new AdminPipeClient());
|
||||||
DataContext = vm;
|
DataContext = _vm;
|
||||||
Loaded += async (_, _) => await vm.RefreshCommand.ExecuteAsync(null);
|
|
||||||
|
_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)
|
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))
|
if (DataContext is MainViewModel vm && vm.EditEndpointCommand.CanExecute(null))
|
||||||
vm.EditEndpointCommand.Execute(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);
|
var lst = resp.Data.Value.GetProperty("lines").Deserialize<List<LogLine>>(AdminProtocol.JsonOptions);
|
||||||
return lst ?? new List<LogLine>();
|
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]
|
[RelayCommand]
|
||||||
private async Task RestartServiceAsync()
|
private async Task RestartServiceAsync()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
Title="About Webhook Server"
|
Title="About Webhook Server"
|
||||||
Height="320" Width="420"
|
Height="360" Width="440"
|
||||||
ResizeMode="NoResize"
|
ResizeMode="NoResize"
|
||||||
WindowStartupLocation="CenterOwner"
|
WindowStartupLocation="CenterOwner"
|
||||||
|
Icon="/webhook-server.ico"
|
||||||
ShowInTaskbar="False">
|
ShowInTaskbar="False">
|
||||||
<Grid Margin="20">
|
<Grid Margin="20">
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
|
|||||||
@@ -14,6 +14,15 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<UseWPF>true</UseWPF>
|
<UseWPF>true</UseWPF>
|
||||||
|
<UseWindowsForms>true</UseWindowsForms>
|
||||||
|
<ApplicationIcon>..\..\resources\webhook-server.ico</ApplicationIcon>
|
||||||
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
<AssemblyTitle>Webhook Server</AssemblyTitle>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Resource Include="..\..\resources\webhook-server.ico" Link="webhook-server.ico" />
|
||||||
|
<Resource Include="..\..\resources\webhook-server.png" Link="webhook-server.png" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -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 });
|
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:
|
default:
|
||||||
return AdminResponse.Failure($"unknown op '{request.Op}'");
|
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()
|
private ServerConfig CloneSnapshotForEdit()
|
||||||
{
|
{
|
||||||
// Round-trip via JSON to avoid sharing references with the live snapshot.
|
// Round-trip via JSON to avoid sharing references with the live snapshot.
|
||||||
|
|||||||
Reference in New Issue
Block a user