Skip to content

Repository files navigation

MSIX Auto Update Test

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.

Layout

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)

Two-entry-point MSIX

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.

CLI commands

mautd watch [--once] [--simulate-plugins N] [--feed URL]
mautd status
mautd settings get [key]
mautd settings set <key> <value>
mautd --help
  • watch runs the UpdateServiceHost loop, printing structured log lines on every state transition. --once does a single check and exits. --simulate-plugins N spawns 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.
  • status prints the running package version, current settings, the update marker (if a restart just happened), and the latest version advertised by the feed.
  • settings get/set reads 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 atomic File.Replace so the file stays consistent under concurrent edits.
  • settings set validates 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 code 2. Valid changes return exit 0.

Settings keys

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.

In-app update flow walkthrough

  1. Start. App.OnLaunched constructs the shared UpdateServiceHost with JsonFileUpdateSettingsStore, HttpAppInstallerFeedClient, WinUiIdleProbe, WinUiRestartHandler, PackageCurrentVersion, and SystemClock. The host's tick loop starts with a 2 second period.
  2. Tick. Every tick UpdateStateMachine either polls the feed (if the cadence interval has elapsed) or re-evaluates the current state. If the parsed remote Version is higher than Package.Current.Id.Version, it transitions to Pending and raises UpdateDetected.
  3. Gate. While in Pending, the apply gate depends on Policy:
    • Immediate opens the gate at once.
    • WhenIdle accumulates idle seconds (from WinUiIdleProbe, which tracks the last pointer move / key press) and opens once IdleThresholdMinutes is met.
    • Scheduled waits until local time crosses ScheduledTimeOfDay.
    • AskUser and Manual wait for ApproveUpdate() (the Home page's "Update now" button).
    • Quiet hours defer non-user policies.
  4. Drain. Draining waits up to DrainTimeoutSeconds for the host to be idle, then Apply writes the update marker (from-version, to-version, timestamp) to LocalFolder and calls AppInstance.Restart("--updated").
  5. Restart. Because the .appinstaller is published with UpdateBlocksActivation="true", Windows applies any pending update synchronously before the relaunch completes. The new process detects the --updated argument on startup, reads the marker, displays an InfoBar "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.

Package identity

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.

Dev loop

.\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 tests

BuildAndRun.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.

Release flow

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 $pwd

What it does:

  1. Rewrites Package.appxmanifest Identity Version to X.Y.Z.0 via tools/ManifestTool. (X must be >= 1; the 2018 AppInstaller schema rejects a major version of 0.)
  2. 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.
  3. makeappx pack per architecture into artifacts/bundle-input/MsixAutoUpdateTest_<plat>.msix, then makeappx bundle into artifacts/MsixAutoUpdateTest.msixbundle.
  4. signtool sign /fd SHA256 /a /f <pfx> /p <pwd> MsixAutoUpdateTest.msixbundle.
  5. Generates artifacts/MsixAutoUpdateTest.appinstaller from templates/. The template now sets UpdateBlocksActivation="true" so the WinUI's AppInstance.Restart applies the update synchronously before relaunch.
  6. git tag vX.Y.Z, pushes the tag, then gh release create uploading the .msixbundle, the .appinstaller, and the public .cer.
  7. Restores Package.appxmanifest on 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).

Bootstrap URL

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>.

One-time cert trust (per machine, admin)

Import-Certificate -FilePath .\MsixAutoUpdateTest_TemporaryKey.cer `
    -CertStoreLocation Cert:\LocalMachine\TrustedPeople

Single-arch dev build

scripts/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 $pwd

Tests

dotnet test

tests/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.
  • UpdateSettingsKeys validation: 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.

Packaging notes

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.

About

Tiny WinUI 3 MSIX app for testing GitHub Releases .appinstaller auto-update

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages