A small system wrapped around ClickOnce: a server that hosts each app's zipped ClickOnce publish
output, a library apps use to check for updates, and a wrapper installer that downloads the zip
and runs setup.exe.
ClickOnce stays the actual install and update mechanism. Nothing here replaces it — and uninstall remains ClickOnce's job, in Add/Remove Programs.
src/ClickWrap.Server/ Blazor Server admin page + two API endpoints, files on disk
src/ClickWrap.UpdateClient/ one assembly: version check + hand-off to the installer, no UI
src/ClickWrap.Installer/ WPF exe, one self-contained build per app
From an app's point of view the whole thing is two calls:
var update = await client.CheckForUpdateAsync("race-timer"); // version worked out for you
if (update is not null && userSaidYes)
InstalledApp.UpdateAndExit("race-timer"); // updates and closes the app| Developer guide | Setup, and how to ship an app or a new version end to end. Start here. |
| Architecture | How the three pieces fit, and where state lives. |
| Server | Storage layout, API, /admin, configuration. |
| Update-check library | API, return values, version comparison. |
| Installer | install.yaml, existing-install policy, self-update, per-app builds. |
| ClickOnce behaviour | The verified facts the whole design rests on. |
dotnet build ClickWrap.slnx
dotnet run --project src/ClickWrap.ServerUpload a zipped publish folder at http://localhost:8080/admin, add
src/ClickWrap.Installer/apps/{appId}.yaml, then:
pwsh ./build/publish-installers.ps1 -App race-timerout/race-timer/RaceTimerSetup.exe is the single file you distribute. Running it again is how
updates are applied, so there is no separate updater to ship.
installFolderis permanent. ClickOnce refuses to update an app from a folder other than the one it was installed from. Change it after an app ships and updates break for everyone who has it.- Do not start signing manifests for an app that has already shipped. It changes the ClickOnce identity, and every existing user gets a duplicate side-by-side install instead of an update.
- Set
CLICKWRAP_PUBLIC_BASE_URLon the server. Behind a Cloudflare Tunnel the inbound host is not the public one, so without it installers are handed download URLs they cannot reach. - Scope Cloudflare Access to the whole host, with a bypass for
/api/*. Protecting/adminalone does not work: Blazor routes to it client-side over the SignalR circuit, so Access never sees a request to challenge. See server.md.
Icons courtesy of Axialis Software.
The icon is used unmodified, as its licence requires: it ships with the 16x16 and 24x24 frames it was generated with, and must not be re-rendered at other sizes for distribution. That is why the installer's icon looks slightly soft at Explorer's 32px and 48px views — it is deliberate, not an oversight.