PeekDesktop Auto-Updater

April 24, 2026 · View on GitHub

Technical documentation for PeekDesktop's in-place auto-update system. Zero external dependencies — built entirely on System.IO.Compression (BCL) and Win32 P/Invoke.

How It Works

User Experience

  1. Background check — 2 seconds after startup, PeekDesktop queries the GitHub Releases API for the latest version
  2. Notification — if a newer version exists, a balloon/toast notification appears: "Version X.Y.Z is available"
  3. User prompt — clicking the balloon (or using tray menu → "Check for Updates") shows a dialog: "Download and install? PeekDesktop will restart automatically."
  4. Automatic update — on "Yes", the app downloads, verifies, swaps, and restarts in ~1 second
  5. Settings — users can disable background checks via tray menu → "Auto-Check for Updates"

The Update Sequence

┌─────────────────────────────────────────────────────────────────┐
│ 1. Query GitHub API: /repos/shanselman/PeekDesktop/releases/latest │
│ 2. Parse assets[] array → pick correct zip (x64 or arm64)      │
│ 3. Download zip to %TEMP%\PeekDesktop-update-{guid}.zip         │
│ 4. Extract PeekDesktop.exe → same directory as PeekDesktop.new.exe │
│ 5. Verify Authenticode signature + signer identity              │
│ 6. Preflight: verify directory is writable                      │
│ 7. Rename PeekDesktop.exe → PeekDesktop.old.exe                 │
│ 8. Rename PeekDesktop.new.exe → PeekDesktop.exe                 │
│ 9. Launch new PeekDesktop.exe --restarting                      │
│ 10. Remove tray icon, release mutex, exit                       │
└─────────────────────────────────────────────────────────────────┘

Architecture Detection

The updater uses RuntimeInformation.ProcessArchitecture to select the correct release asset:

ArchitectureAsset pattern
X64PeekDesktop-vX.Y.Z-win-x64.zip
Arm64PeekDesktop-vX.Y.Z-win-arm64.zip

This means an x64 build running under emulation on ARM64 stays on x64. Users who want native ARM64 should download that build manually once.


Security

Authenticode Signature Verification

Every downloaded update is verified before installation using two layers:

  1. Chain trustWinVerifyTrust with WINTRUST_ACTION_GENERIC_VERIFY_V2 verifies the binary has a valid Authenticode signature from a trusted certificate authority, with full revocation checking (WTD_REVOKE_WHOLECHAIN)

  2. Signer identity — after trust verification, the updater extracts the signer's display name using WTHelperProvDataFromStateDataWTHelperGetProvSignerFromChainCertGetNameStringW, and compares it against the expected publisher substring ("Hanselman")

If either check fails, the update is rejected with a clear error message and no files are modified.

Resource Cleanup

WinVerifyTrust is called with WTD_STATEACTION_VERIFY followed by WTD_STATEACTION_CLOSE in a finally path to prevent leaking the Authenticode certificate store.

URL Validation

Release URLs are validated against https://github.com/shanselman/PeekDesktop/ before being used. The download uses the browser_download_url from the GitHub API response, which redirects to GitHub's CDN. WinHTTP follows HTTPS→HTTPS redirects automatically.

Download Safety

  • Downloads are capped at 50 MB to prevent memory exhaustion
  • WinHTTP read/query errors throw immediately (no silent truncation)
  • Temp files are cleaned up on failure

The Swap Dance

The trickiest part of the auto-updater is replacing the running executable. Windows allows renaming a running exe (the OS holds a handle to the inode, not the filename), so the sequence is:

PeekDesktop.exe      (running)
PeekDesktop.new.exe  (verified new version)

Step 1: File.Move(PeekDesktop.exe → PeekDesktop.old.exe, overwrite: true)
Step 2: File.Move(PeekDesktop.new.exe → PeekDesktop.exe)
Step 3: CreateProcessW("PeekDesktop.exe" --restarting)
Step 4: Remove tray icon, release mutex, Environment.Exit(0)

Why the Exact Path Matters

The autostart registry entry (HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Run\PeekDesktop) stores the exact exe path. If the new exe doesn't land at exactly the same path with the same filename, "Start with Windows" breaks. That's why we do in-place replacement, not "install to a new version folder."

Rollback on Failure

If any step fails after the rename:

  • If PeekDesktop.exe is missing but PeekDesktop.old.exe exists → rename it back
  • Partial downloads (.new.exe, temp zips) are cleaned up
  • The user sees an error dialog but the app keeps running on the old version

Startup Recovery

On every launch, CleanupPreviousUpdate() runs (after acquiring the mutex) to delete:

  • PeekDesktop.old.exe — leftover from successful update
  • PeekDesktop.new.exe — leftover from failed update
  • PeekDesktop-update-*.zip in %TEMP% — leftover temp downloads

Mutex Handoff

PeekDesktop uses a named mutex (Local\PeekDesktop_SingleInstance) to prevent multiple instances. During an update restart:

  1. Old process launches new process with --restarting flag
  2. Old process releases the mutex and exits
  3. New process detects --restarting and retries mutex acquisition for up to 5 seconds (20 × 250ms)
  4. If the old process is slow to exit, AbandonedMutexException is caught and treated as success

The launch happens before the mutex release — if CreateProcessW fails, the mutex is never released, and the old process stays running with an error message.


Files Changed

FileRole
AppUpdater.csCore update logic: check, download, extract, verify, swap, restart, cleanup
NativeMethods.csWinVerifyTrust, WTHelper*, CertGetNameStringW, CreateProcessW P/Invoke
WinHttp.csDownloadToFile() — writes HTTP response to disk (50MB cap)
Program.cs--restarting mutex retry, cleanup on startup, mutex release wiring
Settings.csAutoCheckForUpdates property (default: true)
TrayIcon.cs"Auto-Check for Updates" toggle, balloon → prompt → install flow

Dependencies

Zero new NuGet packages. Everything uses:

CapabilitySourceBinary cost
HTTP downloadswinhttp.dll (OS native, already in use)0 KB
ZIP extractionSystem.IO.Compression (BCL)~200-400 KB
Signature verificationwintrust.dll + crypt32.dll (OS native)0 KB
Process launchkernel32.dll CreateProcessW (OS native)0 KB
JSON parsingSystem.Text.Json.Utf8JsonReader (already in use)0 KB

Testing

Interop Harness Tests

7 auto-updater tests in PeekDesktop.InteropHarness:

TestWhat it covers
Version comparison logicModel shape, tag normalization
Asset matching by architecturex64/arm64 selection, fake arch rejection
Release JSON deserializationassets[] array parsing with Utf8JsonReader
Authenticode rejects unsignedFake PE file correctly rejected by WinVerifyTrust
WinHttp download to fileGet() + DownloadToFile() against real HTTPS endpoints
Zip extraction round-tripCreate zip → extract PeekDesktop.exe → verify content
WinVerifyTrust state cleanup100 calls with no handle leak

Run with: dotnet run --project src\PeekDesktop.InteropHarness\PeekDesktop.InteropHarness.csproj

Manual Swap Dance Test

To test the full download→swap→restart flow locally:

  1. Add #if DEBUG bypasses in AppUpdater.cs:
    • Around the version comparison (force update regardless of version)
    • Around the signature check (skip for unsigned dev builds)
  2. Build debug: dotnet build src\PeekDesktop\PeekDesktop.csproj
  3. Run: dotnet run --project src\PeekDesktop\PeekDesktop.csproj
  4. Right-click tray → "Check for Updates" → Yes
  5. Watch it download v0.8.5, swap, and restart
  6. Verify: About → should show v0.8.5

Full Signed Test

To test with real Authenticode verification:

  1. Tag a new version: git tag v0.9.0 and push
  2. The CI workflow builds, signs (Azure Trusted Signing), and creates a GitHub Release
  3. Run the previous signed release — it should auto-update to v0.9.0
  4. Verify the signer identity check passes in the log

Design Decisions

Why not a separate updater process?

A separate updater exe adds complexity (two binaries to ship, coordinate, keep in sync). Since Windows allows renaming running executables, the app can update itself in-place.

Why not Squirrel, WinSparkle, or other update frameworks?

They pull in significant dependencies that conflict with the project's goal of minimal binary size and zero NuGet runtime deps. The entire auto-updater adds ~200-400 KB (from System.IO.Compression) vs megabytes for a framework.

Why WinHTTP instead of HttpClient?

HttpClient pulls in the entire managed networking stack (~1 MB in AOT). WinHTTP uses the OS HTTP+TLS stack at zero binary cost and is already used for update checks.

Why not download in the background automatically?

The user should consent before we download and replace their executable. A ~2 MB download on metered connections deserves a prompt. The "Auto-Check for Updates" toggle controls only the check, not the download.

Why require Authenticode verification?

Without it, a compromised GitHub release or MITM attack could inject a malicious binary. The signature check ensures only properly signed binaries from the expected publisher are installed.

What about the signer name constant?

NativeMethods.cs contains ExpectedSignerSubstring = "Hanselman". This must match the CN or display name from the Azure Trusted Signing certificate. After signing a release, verify with signtool verify /pa /v PeekDesktop.exe and update the constant if needed.


Transition for Existing Users

Users on v0.8.5 and earlier have the old AppUpdater that only opens the browser. Their upgrade path:

  1. They see "new version available" → opens GitHub Releases page
  2. They download and extract manually (one last time)
  3. From that point forward, all updates are automatic via this auto-updater

This is an inherent one-time transition — there's no way to silently upgrade the updater itself without the user installing the new version first.