A WinUI 3 (Windows App SDK 2.2) packaged desktop app plus a packaged CLI that
together demonstrate an MSIX .appinstaller auto-update flow with user-
configurable policy (Immediate, WhenIdle, Scheduled, AskUser, Manual). The
goal is to validate the patterns we plan to lift into OpenClaw: a long-running
client that polls a stable .appinstaller feed, gates the apply step on user
intent or idle, drains in-flight work, then restarts.
MsixAutoUpdateTest/ WinUI 3 app: NavigationView with Home + Settings pages
src/MsixAutoUpdateTest.Core/ Pure-logic update library (no WinUI, no WinAppSDK)
src/MsixAutoUpdateTest.Cli/ Console companion app (mautd.exe alias)
tools/ManifestTool/ Rewrites Package.appxmanifest Version on release
tests/MsixAutoUpdateTest.Tests/ xUnit tests for Core + ManifestTool + CLI parser
templates/ .appinstaller template (substituted on release)
scripts/Build-Msix.ps1 Build + sign a single-arch .msix (dev convenience)
scripts/Publish-Release.ps1 Full release: build ARM64 + x64, bundle, sign, tag, gh release
BuildAndRun.ps1 Dev loop: detects arch, builds, runs via `winapp run`
artifacts/ Output dir (gitignored)
The single MSIX contains two <Application> entries in Package.appxmanifest:
| Application Id | Executable | Purpose |
|---|---|---|
App |
MsixAutoUpdateTest.exe |
The WinUI window. Launched from the Start menu. |
Cli |
MsixAutoUpdateTest.Cli.exe |
The mautd CLI. Hidden from the Start menu (AppListEntry="none"). |
The Cli application also declares a uap5:Extension Category="windows.appExecutionAlias"
with Alias="mautd.exe", so after MSIX install you can run mautd from any terminal.
mautd watch [--once] [--simulate-plugins N] [--feed URL]
mautd status
mautd settings get [key]
mautd settings set <key> <value>
mautd --help
watchruns theUpdateServiceHostloop, printing structured log lines on every state transition.--oncedoes a single check and exits.--simulate-plugins Nspawns N background tasks that finish in 3 to 30 seconds each; the idle probe stays busy until they all complete, so you can watch the WhenIdle policy in action.statusprints the running package version, current settings, the update marker (if a restart just happened), and the latest version advertised by the feed.settings get/setreads or mutates the shared JSON settings file in LocalFolder. The WinUI Settings page is just another consumer of the same file. Writes from both processes are serialized through a named mutex and an atomicFile.Replaceso the file stays consistent under concurrent edits.settings setvalidates every input. Unknown enum values, numeric strings that fall outside the enum range, negative numeric settings, and snooze timestamps that are not strictly in the future are all rejected with a clear message and exit code2. Valid changes return exit0.
These are the keys accepted by mautd settings set. They mirror the fields on
UpdateSettings in MsixAutoUpdateTest.Core.
| Key | Example value | Meaning |
|---|---|---|
policy |
Immediate, WhenIdle, Scheduled, AskUser, Manual |
How to gate the apply step. |
cadence |
Every15Min, Hourly, Every8Hours, Daily, Never |
Feed poll interval. |
channel |
Stable, Preview |
Opaque tag; UI can branch feed URLs on this. |
idleThresholdMinutes |
5 |
Cumulative idle minutes the state machine must observe before WhenIdle / Draining opens the apply gate. The probe itself reports idleness instantaneously (5 second window of no input); the threshold is accumulated by UpdateStateMachine, not by the probe. |
scheduledTimeOfDay |
03:30 |
Local time-of-day the Scheduled policy fires. |
quietHoursStart |
22:00 |
Start of the quiet window. Non-user policies defer. |
quietHoursEnd |
07:00 |
End of the quiet window. |
snoozeUntilUtc |
2026-07-01T00:00:00Z or empty |
Snooze expires at this UTC instant. |
drainTimeoutSeconds |
300 |
Max seconds Draining waits for idle before forcing apply. |
- Start.
App.OnLaunchedconstructs the sharedUpdateServiceHostwithJsonFileUpdateSettingsStore,HttpAppInstallerFeedClient,WinUiIdleProbe,WinUiRestartHandler,PackageCurrentVersion, andSystemClock. The host's tick loop starts with a 2 second period. - Tick. Every tick
UpdateStateMachineeither polls the feed (if the cadence interval has elapsed) or re-evaluates the current state. If the parsed remoteVersionis higher thanPackage.Current.Id.Version, it transitions toPendingand raisesUpdateDetected. - Gate. While in
Pending, the apply gate depends onPolicy:Immediateopens the gate at once.WhenIdleaccumulates idle seconds (fromWinUiIdleProbe, which tracks the last pointer move / key press) and opens onceIdleThresholdMinutesis met.Scheduledwaits until local time crossesScheduledTimeOfDay.AskUserandManualwait forApproveUpdate()(the Home page's "Update now" button).- Quiet hours defer non-user policies.
- Drain.
Drainingwaits up toDrainTimeoutSecondsfor the host to be idle, thenApplywrites the update marker (from-version, to-version, timestamp) to LocalFolder and callsAppInstance.Restart("--updated"). - Restart. Because the
.appinstalleris published withUpdateBlocksActivation="true", Windows applies any pending update synchronously before the relaunch completes. The new process detects the--updatedargument on startup, reads the marker, displays anInfoBar"Updated from X to Y", and deletes the marker.
The Home page reflects the live state (Idle / Watching / Pending / Draining / Applying / Snoozed) and exposes Check now, Update now (enabled only under AskUser/Manual policy + Pending state), and Snooze 1 / 7 days.
| Field | Value |
|---|---|
| Identity Name | Clinick.MsixAutoUpdateTest |
| Publisher | CN=Andrew Clinick, O=Clinick, C=US |
| Display Name | MSIX Auto Update Test |
| Start version | 0.1.0.0 |
| AppInstaller URL | https://github.com/aclinick/msix-autoupdate-test/releases/latest/download/MsixAutoUpdateTest.appinstaller |
The Publisher above must match exactly the subject of the code-signing
certificate (case, spaces, commas all included), otherwise package registration
fails with 0x80073CF3.
.\BuildAndRun.ps1 MsixAutoUpdateTest\MsixAutoUpdateTest.csproj # auto-detects ARM64 / x64, builds, launches via winapp run
.\BuildAndRun.ps1 MsixAutoUpdateTest\MsixAutoUpdateTest.csproj -SkipRun
dotnet test # all 58 Core + ManifestTool + CLI testsBuildAndRun.ps1 never uses AnyCPU. On ARM64 hosts it passes
-p:Platform=ARM64; elsewhere -p:Platform=x64. The WinUI csproj has a
PublishCliAsSingleFile MSBuild target that runs dotnet publish on the CLI
as a self-contained single-file MsixAutoUpdateTest.Cli.exe, then bundles it
as Content so it lives next to the WinUI exe inside the MSIX. This means the
mautd alias works even in dev-mode-registered packages, not just signed
releases.
Releases are cut locally (no GitHub Actions). One script does everything:
$pwd = Read-Host -AsSecureString "PFX password"
.\scripts\Publish-Release.ps1 -Version 1.0.0 `
-PfxPath .\MsixAutoUpdateTest_TemporaryKey.pfx `
-PfxPassword $pwdWhat it does:
- Rewrites
Package.appxmanifestIdentity VersiontoX.Y.Z.0viatools/ManifestTool. (Xmust be>= 1; the 2018 AppInstaller schema rejects a major version of0.) - For ARM64 and x64:
dotnet publish -c Release -p:Platform=<plat> -r win-<plat> --self-contained true. The WinUI csproj target also produces the CLI single-file; the script copies that exe into the publish folder. makeappx packper architecture intoartifacts/bundle-input/MsixAutoUpdateTest_<plat>.msix, thenmakeappx bundleintoartifacts/MsixAutoUpdateTest.msixbundle.signtool sign /fd SHA256 /a /f <pfx> /p <pwd> MsixAutoUpdateTest.msixbundle.- Generates
artifacts/MsixAutoUpdateTest.appinstallerfromtemplates/. The template now setsUpdateBlocksActivation="true"so the WinUI'sAppInstance.Restartapplies the update synchronously before relaunch. git tag vX.Y.Z, pushes the tag, thengh release createuploading the.msixbundle, the.appinstaller, and the public.cer.- Restores
Package.appxmanifeston disk so the working tree is unchanged after the script.
Idempotent: re-running with the same version deletes and recreates the GitHub release (the git tag is reused; pass -SkipTag if it already exists locally).
https://github.com/aclinick/msix-autoupdate-test/releases/latest/download/MsixAutoUpdateTest.appinstaller
This URL never changes. GitHub redirects latest/download/<name> to the newest release's asset, and Windows polls the same URL on the cadence in <UpdateSettings>.
Import-Certificate -FilePath .\MsixAutoUpdateTest_TemporaryKey.cer `
-CertStoreLocation Cert:\LocalMachine\TrustedPeoplescripts/Build-Msix.ps1 produces one signed .msix for one architecture, including both the WinUI and CLI executables. Useful for local smoke tests when you don't need to cut a release.
$pwd = Read-Host -AsSecureString "PFX password"
.\scripts\Build-Msix.ps1 -Version 0.1.1.0 -Platform ARM64 `
-PfxPath .\MsixAutoUpdateTest_TemporaryKey.pfx -PfxPassword $pwddotnet testtests/MsixAutoUpdateTest.Tests (xUnit) covers:
UpdateStateMachine: every state transition for every policy with fake clock / idle / feed / restart handler. Includes restart-failure recovery (marker is rolled back, state returns to Pending) and the unknown-policy guard arm.JsonFileUpdateSettingsStore: missing-file defaults, full round-trip of every property, forward-compat (unknown JSON properties ignored), corrupt-file fallback, and a concurrent-writers stress test (two threads, 100 writes each, no exceptions, final file deserializes).HttpAppInstallerFeedClient.Parse: 2017 and 2018 schemas, malformed XML, wrong root element, missing or unparseable Version attribute.UpdateSettingsKeysvalidation: invalid enums (text and out-of-range numbers), negative numeric settings, snooze-in-the-past all rejected.UpdateMarkerEvaluator: applied / not-applied / running-newer outcomes for the post-restart marker check.- Quiet-hours classification, cadence-to-TimeSpan mapping, settings-key round trip.
CliArgsParser: every subcommand, all flags, invalid input.ManifestVersionRewriter: original four-part-version coverage (pre-existing).
77 tests total, all green.
The CLI is published as a self-contained, trimmed (TrimMode=partial in Release),
compressed single-file inside the same MSIX as the WinUI app. The WinUI app is
also published self-contained, so the .NET runtime is duplicated inside the
package. Framework-dependent CLI publish was investigated first because it would
have let the CLI reuse the WinUI app's runtime; on an ARM64 host with no system
.NET 10 install, the CLI's apphost cannot discover the WinUI app's flat self-
contained layout as a "shared framework" install, so mautd fails to launch
with "You must install or update .NET to run this application". Restructuring
the WinUI output into a shared/Microsoft.NETCore.App/<ver> layout would
sidestep this but breaks Windows App SDK packaging. Trimming the self-contained
CLI is the practical floor and shrinks the signed .msixbundle by roughly 28
MB versus an untrimmed self-contained CLI. See the comment block in
MsixAutoUpdateTest.csproj near PublishCliAsSingleFile for the full chain of
decisions.