15 Commits

Author SHA1 Message Date
justin 24b9e0aa80 Auto-install .NET 8 runtimes if missing
A fresh Windows Server install has neither ASP.NET Core 8 nor .NET
Desktop 8, so the Service refuses to start and the GUI fails to
launch — the symptoms link to aka.ms/dotnet-core-applaunch but it's
not obvious from the GUI itself what's wrong.

Setup now:
- Detects each runtime by looking for an 8.x folder under
  %ProgramFiles%\dotnet\shared\Microsoft.{AspNetCore,WindowsDesktop}.App
- Uses Inno Setup's built-in TDownloadWizardPage to fetch any missing
  runtime from the aka.ms redirect and runs `/install /quiet /norestart`
- Treats Microsoft's 1638 / 3010 / 1641 exit codes as success

If the box has no internet, the prereq install fails with a clear
message and a "continue anyway?" prompt. README + installation docs
list the manual download URLs and `dotnet --list-runtimes` check;
troubleshooting has a section pointing at the same fix when the
service won't start after install.
2026-05-08 13:39:45 -04:00
justin 1229c52ecf Sync .NET cwd, bake version, pass /O absolute (#13) 2026-05-08 13:35:49 -04:00
justin 14d1bdc461 Capture ISCC stdout+stderr and pre-flight {#RepoRoot} paths (#12) 2026-05-08 13:19:09 -04:00
justin 7c164ab3b3 Sync: ISCC cwd fix (#11) 2026-05-08 13:10:13 -04:00
justin d89290aedb Sync: installer diagnostics (#9) 2026-05-08 12:56:22 -04:00
justin ddd36a9116 Sync from GitHub main: v0.1.4 (#8) 2026-05-08 12:32:42 -04:00
justin b66dd245c0 Sync from GitHub main: Gitea Actions support (#7) 2026-05-08 12:02:59 -04:00
justin 1ea724cd1f Sync from GitHub main: v0.1.3 (#6)
CI / build (push) Has been cancelled
2026-05-08 11:32:20 -04:00
justin a2bd338839 Wiki sync: stop treating git's stderr as fatal (#5)
Sync Wiki / sync (push) Has been cancelled
CI / build (push) Has been cancelled
2026-05-08 11:21:07 -04:00
justin b17d832842 Sync from GitHub main: v0.1.1 + v0.1.2 + wiki sync (#3)
Sync Wiki / sync (push) Has been cancelled
CI / build (push) Has been cancelled
2026-05-08 11:14:17 -04:00
justin fe42f2f908 Merge pull request 'Document service account choices for AD-aware hooks' (#1) from claude/pensive-easley-4abcbe into main
CI / build (push) Has been cancelled
Reviewed-on: #1
2026-05-08 10:05:11 -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
5 changed files with 284 additions and 4 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ Designed for sysadmins who want to wire up tools like **Zerto pre/post scripts**
## Quickstart
1. **Download** the latest installer: <https://github.com/recklessop/webhook-server/releases/latest>
2. **Run it.** UAC accept → next, next, finish. Adds a Start Menu entry, registers and starts the Windows Service.
2. **Run it.** UAC accept → next, next, finish. Adds a Start Menu entry, registers and starts the Windows Service. The installer also downloads + installs the **.NET 8 runtimes** (ASP.NET Core + Desktop) if they're missing — fresh Windows Server installs need this.
3. **Open Webhook Server** from the Start Menu (auto-elevates).
4. **File → New endpoint**, configure a slug + script, save, hit the URL.
+24
View File
@@ -6,10 +6,34 @@ This page covers a fresh install. If you already have Webhook Server installed,
- Windows 10, Windows 11, or Windows Server 2019 / 2022 / 2025
- Administrator rights to install the service and to run the GUI
- **.NET 8 runtimes** (the installer downloads + installs them automatically if missing — see below)
- (Optional, only if you publish from source) .NET 8 SDK
The installer is **x64 only**. There is no x86 build.
### .NET 8 runtimes
Webhook Server is published as framework-dependent (so the installer stays small) and needs two .NET 8 runtimes on the target machine:
| Runtime | Used by | Auto-installed by setup |
|---|---|---|
| ASP.NET Core 8 Runtime (`Microsoft.AspNetCore.App` 8.x) | the Service / Kestrel | Yes |
| .NET Desktop Runtime 8 (`Microsoft.WindowsDesktop.App` 8.x) | the WPF GUI | Yes |
A clean Windows Server install has neither. The installer detects what's missing and downloads + installs each one silently before copying our files. If the machine has no internet access, install them manually first:
- ASP.NET Core 8 Runtime — <https://aka.ms/dotnet/8.0/aspnetcore-runtime-win-x64.exe>
- .NET Desktop Runtime 8 — <https://aka.ms/dotnet/8.0/windowsdesktop-runtime-win-x64.exe>
Run each with `/install /quiet /norestart` for unattended installs, or just double-click. A reboot is rarely required.
To check what's already installed:
```powershell
dotnet --list-runtimes
# expect to see Microsoft.AspNetCore.App 8.x.y and Microsoft.WindowsDesktop.App 8.x.y
```
## 1. Download
Grab the latest installer from the GitHub Releases page:
+22
View File
@@ -38,6 +38,28 @@ You launched the GUI without elevation. The admin pipe ACL is `SYSTEM` + `Admini
**Fix in v0.1.0**: right-click the Start Menu shortcut → **Run as administrator**, or upgrade.
### Service won't start after install / GUI says "Disconnected" with no obvious error
If `Get-Service WebhookServer` shows it stopped and `Start-Service WebhookServer` fails, or the GUI itself won't even launch, you're probably missing a .NET 8 runtime. The v0.1.4+ installer auto-fetches them, but a clean Windows Server box might still hit this if the install was offline or used an older installer.
Check what's installed:
```powershell
dotnet --list-runtimes
```
You need both:
- `Microsoft.AspNetCore.App 8.x.y` — for the Service
- `Microsoft.WindowsDesktop.App 8.x.y` — for the GUI
If either is missing, install from:
- ASP.NET Core 8 Runtime — <https://aka.ms/dotnet/8.0/aspnetcore-runtime-win-x64.exe>
- .NET Desktop Runtime 8 — <https://aka.ms/dotnet/8.0/windowsdesktop-runtime-win-x64.exe>
Re-run with `/install /quiet /norestart` for unattended installs. Then `Start-Service WebhookServer`.
### "Connection refused" hitting the hook URL
Three possibilities, in order of probability:
+124
View File
@@ -86,6 +86,17 @@ Filename: "powershell.exe"; \
RunOnceId: "RemoveWebhookService"
[Code]
const
// aka.ms redirects to the latest 8.0.x patch. Inno Setup's downloader
// follows redirects via the Windows HTTP stack.
AspNetCore8Url = 'https://aka.ms/dotnet/8.0/aspnetcore-runtime-win-x64.exe';
WinDesktop8Url = 'https://aka.ms/dotnet/8.0/windowsdesktop-runtime-win-x64.exe';
AspNetCore8File = 'aspnetcore-runtime-8.0-win-x64.exe';
WinDesktop8File = 'windowsdesktop-runtime-8.0-win-x64.exe';
var
DownloadPage: TDownloadWizardPage;
function ServiceExists(): Boolean;
var
ResultCode: Integer;
@@ -96,6 +107,119 @@ begin
Result := (ResultCode = 0);
end;
// True if a Microsoft.* shared-framework directory under
// %ProgramFiles%\dotnet\shared contains at least one 8.x.y subfolder.
function HasDotNet8(const RuntimeName: String): Boolean;
var
rec: TFindRec;
base: String;
begin
Result := False;
base := ExpandConstant('{commonpf}\dotnet\shared\') + RuntimeName;
if not DirExists(base) then Exit;
if FindFirst(base + '\8.*', rec) then
try
repeat
if (rec.Name <> '.') and (rec.Name <> '..') and
DirExists(base + '\' + rec.Name) then begin
Result := True;
Exit;
end;
until not FindNext(rec);
finally
FindClose(rec);
end;
end;
function NeedsAspNet8(): Boolean;
begin
Result := not HasDotNet8('Microsoft.AspNetCore.App');
end;
function NeedsWinDesktop8(): Boolean;
begin
Result := not HasDotNet8('Microsoft.WindowsDesktop.App');
end;
procedure InitializeWizard;
begin
DownloadPage := CreateDownloadPage(
'Downloading prerequisites',
'Webhook Server needs the .NET 8 runtimes. Setup is fetching them now.',
nil);
end;
// Runs a downloaded runtime installer silently. Treats Microsoft's
// "success but reboot pending" / "newer already installed" exit codes
// as successes so we don't fail the whole install over a benign result.
function RunRuntimeInstaller(const FileName, DisplayName: String): String;
var
resultCode: Integer;
fullPath: String;
begin
Result := '';
fullPath := ExpandConstant('{tmp}\') + FileName;
if not Exec(fullPath, '/install /quiet /norestart', '', SW_HIDE,
ewWaitUntilTerminated, resultCode) then begin
Result := 'Could not launch the ' + DisplayName + ' installer.';
Exit;
end;
case resultCode of
0, 1638, 3010, 1641: ;
else
Result := DisplayName + ' installer failed (exit code ' +
IntToStr(resultCode) + ').';
end;
end;
function NextButtonClick(CurPageID: Integer): Boolean;
var
errMsg: String;
begin
Result := True;
if CurPageID <> wpReady then Exit;
if not (NeedsAspNet8 or NeedsWinDesktop8) then Exit;
DownloadPage.Clear;
if NeedsAspNet8 then
DownloadPage.Add(AspNetCore8Url, AspNetCore8File, '');
if NeedsWinDesktop8 then
DownloadPage.Add(WinDesktop8Url, WinDesktop8File, '');
DownloadPage.Show;
try
try
DownloadPage.Download;
except
if MsgBox('Failed to download the .NET 8 runtimes:' + #13#10#13#10 +
GetExceptionMessage + #13#10#13#10 +
'Continue installing anyway? Webhook Server will not start ' +
'until the runtimes are installed manually.',
mbError, MB_YESNO) = IDNO then
Result := False;
Exit;
end;
finally
DownloadPage.Hide;
end;
if NeedsAspNet8 then begin
errMsg := RunRuntimeInstaller(AspNetCore8File, 'ASP.NET Core 8 Runtime');
if errMsg <> '' then begin
MsgBox(errMsg, mbError, MB_OK);
Result := False;
Exit;
end;
end;
if NeedsWinDesktop8 then begin
errMsg := RunRuntimeInstaller(WinDesktop8File, '.NET Desktop Runtime 8');
if errMsg <> '' then begin
MsgBox(errMsg, mbError, MB_OK);
Result := False;
Exit;
end;
end;
end;
function PrepareToInstall(var NeedsRestart: Boolean): String;
var
ResultCode: Integer;
+113 -3
View File
@@ -53,15 +53,125 @@ if ($LASTEXITCODE -ne 0) { throw 'service publish failed' }
-c $Configuration -r win-x64 --self-contained false -o $publishGui | Out-Host
if ($LASTEXITCODE -ne 0) { throw 'GUI publish failed' }
# 2. Compile installer.
# 2. Pre-flight: confirm every source path the .iss references exists, and
# surface the longest path so MAX_PATH issues are obvious in the log.
function Show-SourcePath($label, $path, [switch]$Recursive) {
if (-not (Test-Path $path)) { Write-Warning "MISSING $label : $path"; return }
$items = if ($Recursive) {
Get-ChildItem $path -Recurse -File -ErrorAction SilentlyContinue
} else {
Get-ChildItem $path -File -ErrorAction SilentlyContinue
}
$count = ($items | Measure-Object).Count
$longest = ($items | Measure-Object -Maximum -Property { $_.FullName.Length }).Maximum
Write-Host (" {0,-30} files={1,-5} longestPath={2,-5} root={3}" -f $label, $count, $longest, $path)
}
Write-Host ""
Write-Host "--- pre-flight: source paths the .iss will read ---" -ForegroundColor Cyan
Show-SourcePath 'publish\service' $publishSvc -Recursive
Show-SourcePath 'publish\gui' $publishGui -Recursive
Show-SourcePath 'scripts' (Join-Path $repoRoot 'scripts')
Show-SourcePath 'scripts\examples' (Join-Path $repoRoot 'scripts\examples') -Recursive
Show-SourcePath 'docs' (Join-Path $repoRoot 'docs') -Recursive
Show-SourcePath 'resources' (Join-Path $repoRoot 'resources')
Show-SourcePath 'README.md (file)' (Join-Path $repoRoot 'README.md')
$lpe = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' `
-Name LongPathsEnabled -ErrorAction SilentlyContinue).LongPathsEnabled
Write-Host " LongPathsEnabled (HKLM): $lpe"
Write-Host ""
# 3. 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' }
# Run ISCC from the .iss directory with just the bare filename. When invoked
# with a deeply-nested absolute path on the act-runner host (under
# %SystemRoot%\System32\config\systemprofile\...), ISCC sometimes prints a
# generic "The system cannot find the path specified." before it touches any
# source files. cd-ing first sidesteps it.
$issDir = Split-Path $iss -Parent
$issName = Split-Path $iss -Leaf
# Extra pre-flight: confirm the specific files our .iss references that a
# trivial test .iss wouldn't (icon, README, scripts) actually exist relative
# to the .iss directory the way ISCC will resolve them (RepoRoot = ..\).
Write-Host "--- pre-flight: paths the .iss references via {#RepoRoot} ---" -ForegroundColor Cyan
$issRefs = @(
'resources\webhook-server.ico',
'README.md',
'scripts\install-service.ps1',
'scripts\uninstall-service.ps1',
'publish\service',
'publish\gui',
'docs',
'scripts\examples'
)
foreach ($ref in $issRefs) {
$abs = Join-Path $repoRoot $ref
$exists = Test-Path $abs
Write-Host (" {0,-40} exists={1} ({2})" -f $ref, $exists, $abs)
}
Write-Host ""
Write-Host "--- runtime context ---" -ForegroundColor Cyan
Write-Host " whoami: $(whoami)"
Write-Host " USERPROFILE: $env:USERPROFILE"
Write-Host " APPDATA: $env:APPDATA"
Write-Host " LOCALAPPDATA: $env:LOCALAPPDATA"
Write-Host " TEMP: $env:TEMP"
$isccDir = Split-Path $iscc -Parent
Write-Host " ISCC dir: $isccDir"
foreach ($f in @('ISCC.exe','ISCmplr.dll','ISPP.dll','Default.isl','Compil32.exe')) {
$p = Join-Path $isccDir $f
Write-Host (" {0,-15} exists={1}" -f $f, (Test-Path $p))
}
Write-Host ""
Write-Host " PS location (pre): $((Get-Location).Path)"
Write-Host " .NET cwd (pre): $([System.IO.Directory]::GetCurrentDirectory())"
Push-Location $issDir
$savedDotNetCwd = [System.IO.Directory]::GetCurrentDirectory()
[System.IO.Directory]::SetCurrentDirectory($issDir)
try {
Write-Host " PS location (post): $((Get-Location).Path)"
Write-Host " .NET cwd (post): $([System.IO.Directory]::GetCurrentDirectory())"
# Bake the version into a temp .iss and override OutputDir to an absolute
# path so nothing in the build depends on cwd resolution.
$tempIss = Join-Path $issDir "webhook-server.gen.iss"
$issBody = Get-Content $issName -Raw
$pattern = '(?s)#ifndef AppVersion\s+#define AppVersion "[^"]*"\s+#endif'
if ($issBody -notmatch $pattern) { throw "Could not find #ifndef AppVersion block in $issName" }
$issBody = $issBody -replace $pattern, "#define AppVersion `"$version`""
Set-Content -Path $tempIss -Value $issBody -Encoding ascii
Write-Host " using $tempIss"
# Capture stdout+stderr together so any error line ISCC emits is visible
# in the runner log even if the runner's console capture drops one stream.
# /O<absolute> overrides OutputDir so ..\dist isn't resolved relative to
# whatever cwd ISCC actually inherits.
$logPath = Join-Path $env:TEMP "iscc-$version.log"
& $iscc "/O$dist" (Split-Path $tempIss -Leaf) *>&1 | Tee-Object -FilePath $logPath | ForEach-Object { Write-Host $_ }
$exit = $LASTEXITCODE
Write-Host " ISCC exit code: $exit"
Write-Host " ISCC log path: $logPath"
if (Test-Path $logPath) {
Write-Host " --- iscc log file contents ---"
Get-Content $logPath | ForEach-Object { Write-Host " $_" }
Write-Host " --- end iscc log ---"
}
Remove-Item $tempIss -ErrorAction SilentlyContinue
} finally {
[System.IO.Directory]::SetCurrentDirectory($savedDotNetCwd)
Pop-Location
}
if ($exit -ne 0) { throw "Inno Setup compile failed (exit $exit)" }
$out = Get-Item (Join-Path $dist "WebhookServer-Setup-$version.exe")
Write-Host ""