How to get set up, and how to do each of the things you will actually do.
| Why | |
|---|---|
| .NET 10 SDK | Everything builds against net10.0 / net10.0-windows. |
| Visual Studio 2022/18 (or Build Tools) with the ClickOnce Publishing component | ClickOnce publish needs .NET Framework MSBuild; dotnet publish cannot do it. See clickonce.md. |
PowerShell 7 (pwsh) |
For build/publish-installers.ps1. Windows PowerShell also works. |
| Docker | Only for deploying the server. |
The solution is ClickWrap.slnx — the .NET 10 XML solution format, not a .sln.
dotnet build ClickWrap.slnxdotnet run --project src/ClickWrap.Serverhttp://localhost:8080 lists apps, /admin uploads a version. Data lands under
src/ClickWrap.Server/bin/Debug/net10.0/data; set CLICKWRAP_DATA to put it elsewhere.
.claude/launch.json starts the same server on 8090 instead, because another local service
commonly holds 8080. Either port is fine — 8080 is only the default the container binds:
dotnet run --project src/ClickWrap.Server -- --urls http://0.0.0.0:8090In the app you want to distribute, Properties/PublishProfiles/ClickOnceProfile.pubxml:
<PropertyGroup>
<PublishProtocol>ClickOnce</PublishProtocol>
<PublishDir>bin\publish\</PublishDir>
<PublishUrl>bin\publish\</PublishUrl>
<Install>true</Install>
<InstallFrom>Disk</InstallFrom>
<UpdateEnabled>false</UpdateEnabled>
<BootstrapperEnabled>true</BootstrapperEnabled>
<SignManifests>false</SignManifests>
<GenerateManifests>true</GenerateManifests>
<ProductName>Race Timer</ProductName>
<ApplicationVersion>1.0.0.0</ApplicationVersion>
</PropertyGroup>UpdateEnabled=false and SignManifests=false are not incidental — see
clickonce.md. ProductName becomes the Add/Remove Programs name.
"C:\Program Files\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\MSBuild.exe" RaceTimer.csproj -restore -t:Publish -p:PublishProfile=ClickOnceProfile -p:ApplicationVersion=1.0.0.0Zip the contents of bin\publish — setup.exe must be at the root of the archive, not
inside a folder. The server rejects the wrong shape at upload time, but it is easier to get
right the first time:
Compress-Archive -Path bin\publish\* -DestinationPath race-timer-1.0.0.0.zip -ForceOpen /admin, choose <new app>, enter the app id (race-timer), the version (1.0.0.0),
optional release notes, pick the zip, publish.
The app id is permanent — installers are built against it.
src/ClickWrap.Installer/apps/race-timer.yaml:
appId: race-timer
displayName: Race Timer
serverUrl: https://updates.example.com
installFolder: '%LOCALAPPDATA%\ClickWrap\race-timer'
onExistingInstall: adopt
preInstall: []installFolder is permanent too. Changing it after the app has shipped breaks updates for
everyone who already has it.
pwsh ./build/publish-installers.ps1 -App race-timerout/race-timer/RaceTimerSetup.exe — one file, ~62 MB. That is what you distribute.
Reference ClickWrap.UpdateClient, then at startup:
var client = new UpdateClient("https://updates.example.com");
try
{
var update = await client.CheckForUpdateAsync("race-timer");
if (update is not null) ShowUpdateBanner(update);
}
catch (HttpRequestException) { /* offline; ignore */ }And behind the banner's button:
if (!InstalledApp.UpdateAndExit("race-timer"))
{
ShowMessage("Could not find the updater. Reinstall from the download link.");
}You do not work out the running version and you do not shut the app down yourself — both are
handled, and both are easy to get wrong. See client.md for why, and for
StartUpdater if the app must save state before closing.
- Bump
ApplicationVersionand publish. - Zip the publish folder contents.
- Upload it at
/adminagainst the same app id.
That is all. Users get it when they next run the installer, or when your app's update banner
sends them through update.exe. You do not rebuild or redistribute the installer for a normal
release — it always fetches the latest.
Rebuild the installer only when its own YAML changes, or when you change installer code.
Apps installed before this system are registered against wherever they were installed from,
usually a Downloads folder. With the default onExistingInstall: adopt, the installer detects
that and keeps updating them in place — no error, no data loss, nothing for the user to do.
They stay in the old folder on that machine. Fresh installs on new machines go to
installFolder. If you genuinely need the folder moved, set onExistingInstall: reinstall, but
read the trade-off in installer.md first — it cannot be
automated and it discards the app's ClickOnce data.
Bind 0.0.0.0 (the default), put it behind a Cloudflare Tunnel, and let Cloudflare Access
protect it. There is no app-level auth by design.
Scope Access to the whole host with a bypass for /api/* — not to /admin. Blazor routes
between pages client-side over the SignalR circuit, so Access never sees a request for /admin
and never challenges. See server.md.
The Dockerfile lives in src/ClickWrap.Server/, with that folder as the build context. Stamp the
version into both the image label and the binary from one argument:
docker build --build-arg APP_VERSION=1.2.3 -t clickwrap:1.2.3 src/ClickWrap.ServerSet at minimum:
CLICKWRAP_DATA=/data
CLICKWRAP_PUBLIC_BASE_URL=https://updates.example.com
Point the compose healthcheck at /health — see server.md.
Persist /data on a volume — it is the only state. CLICKWRAP_PUBLIC_BASE_URL is not optional
in production: without it, download URLs are built from the tunnel's hostname and installers
cannot reach them.
| Symptom | Cause |
|---|---|
| You cannot start application X from this location because it is already installed from a different location | installFolder differs from where the app was installed. Should not happen with adopt; check the app's UrlUpdateInfo in Add/Remove Programs. |
MSB4803: GenerateBootstrapper is not supported |
You used dotnet publish for a ClickOnce build. Use MSBuild.exe. |
MSB3094: "DestinationFiles" refers to 2 item(s), and "SourceFiles" refers to 1 item(s) on ClickOnce publish |
The publish profile lacks <GenerateManifests>true</GenerateManifests>. |
MSB3030: Could not copy the file "sample" |
You passed -p:AppConfig=. Use -p:ClickWrapApp=. |
Ambiguous project name on restore |
You passed -p:AssemblyName=. Use -p:InstallerAssemblyName=. |
| Installer says the server has no versions | App id mismatch between the YAML and /admin. |
| Download URL points at the wrong host | CLICKWRAP_PUBLIC_BASE_URL is not set. |
| A user has two copies of the app running | The app launched update.exe without shutting itself down. |
| Uninstalled app left a 62 MB folder behind | ClickOnce uninstalled it without the uninstaller: the entry was not hooked (opted out, or ClickOnce restored its own). Collected on the next installer run, or run update.exe --uninstall from that folder. See installer.md. |
| Settings > Apps > Uninstall does nothing or errors | update.exe is missing from the install folder. Run the app's installer again, or run the ClickOnce command after --clickonce in the entry's UninstallString. See the README warning. |
| Uninstaller says the app is still installed | The ClickOnce dialog was cancelled, or Restore was picked. Nothing was removed; run it again. |
| "Publisher cannot be verified" on first install | Expected — manifests are unsigned. Do not fix this by signing them. |
The installer touches the real ClickOnce store and Add/Remove Programs, so test it with a throwaway app rather than a real one. A minimal WPF app published at two versions is enough to cover install, update, adopt, self-update and uninstall. Verify against:
- Add/Remove Programs:
DisplayVersionbumped, still exactly one entry HKCU\Software\ClickWrap\{appId}:Versionmatches- the install folder:
update.exepresent, only the newApplication Files\version - the Add/Remove Programs
UninstallString: starts with"…\update.exe" --uninstall --clickonce, still hooked after an update - after
update.exe --uninstall, or Uninstall in Settings > Apps: entry, folder and registration gone once the window closes, and the data only when the checkbox was ticked - after cancelling the ClickOnce dialog: nothing removed
- after uninstalling through Settings > Apps: the folder collected on the next installer run