Files
ClaudeDo/docs/superpowers/specs/2026-04-23-self-update-design.md

13 KiB

Self-Update for App and Installer — Design

Date: 2026-04-23 Status: Approved

Goals

Give ClaudeDo two update paths:

  • A — App-side update check: the Avalonia UI checks Gitea for a newer release on startup (and via a manual menu action) and surfaces a dismissible banner. Clicking Update now launches the locally installed installer in Update mode and closes the UI.
  • B — Installer self-update: the WPF installer checks for a newer installer binary on launch and offers to replace itself before continuing. After replacement, it proceeds with its normal wizard.

Non-goals:

  • No silent/background auto-apply of updates. The user always initiates the final update action.
  • No periodic in-app polling (startup + manual only).
  • No changes to the release pipeline — release assets (ClaudeDo-<version>-win-x64.zip, ClaudeDo.Installer-<version>.exe, checksums.txt) stay as they are.

Architecture Overview

A new shared library, ClaudeDo.Releases, hosts the release-API client, version comparison, checksum verification, and installer self-update logic. Both ClaudeDo.Installer and ClaudeDo.Ui reference it. This removes the existing duplication between the installer's release plumbing and what the app needs, and keeps a single asset-matching / version-parsing code path.

ClaudeDo.Releases  (new, netstandard2.0 or net8.0)
├── ReleaseClient.cs        (moved from Installer/Core)
├── IReleaseClient.cs       (moved)
├── ChecksumVerifier.cs     (moved)
├── VersionComparer.cs      (new)
└── SelfUpdater.cs          (new — installer self-update mechanism)

ClaudeDo.Installer  (WPF, consumes ClaudeDo.Releases)
├── App.xaml.cs             (modified — SelfUpdater + --replace-self arg)
└── Core/InstallModeDetector.cs  (modified — now uses VersionComparer)

ClaudeDo.Ui  (Avalonia, consumes ClaudeDo.Releases)
├── Services/UpdateCheckService.cs  (new)
├── Services/InstallerLocator.cs    (new)
├── ViewModels/MainViewModel.cs     (modified — banner + Help menu state)
└── Views/MainWindow.axaml          (modified — banner + Help dropdown)

Part A — App-Side Update Check

ClaudeDo.Ui/Services/UpdateCheckService.cs

Responsibilities:

  • Read the current app version from Assembly.GetExecutingAssembly().GetName().Version.
  • Call IReleaseClient.GetLatestReleaseAsync to fetch the Gitea release.
  • Use VersionComparer to decide whether the latest is newer.
  • Expose observable properties: IsUpdateAvailable, LatestVersion, CurrentVersion, IsChecking, LastCheckStatus (UpToDate | UpdateAvailable | CheckFailed | NeverChecked).
  • Expose a CheckNowAsync method for the manual Help menu action.

Lifecycle:

  • Registered as a singleton in DI.
  • Startup check is fired from MainViewModel once the main window is shown. It runs fire-and-forget on a background Task; UI never blocks on it.
  • Manual check is awaited by its command and briefly shows a status message (see UI).

Error handling:

  • Network / API errors → log to ~/.todo-app/logs/, set LastCheckStatus = CheckFailed, do not surface a banner. Manual check shows a small inline status only.

ClaudeDo.Ui/Services/InstallerLocator.cs

Responsibilities:

  • Resolve the path to the installed ClaudeDo.Installer.exe so the UI can launch it.

Discovery strategy (first hit wins):

  1. Walk up from AppContext.BaseDirectory looking for a sibling install.json. The installer is at {installDir}/uninstaller/ClaudeDo.Installer.exe.
  2. Fall back to reading HKLM\Software\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo\InstallLocation (written by the existing WriteUninstallRegistryStep).

If neither yields a valid path, the banner's Update now button is disabled with a tooltip explaining the installer could not be located.

UI changes

Banner at the top of MainView (above content, below custom chrome):

  • Visible when UpdateCheckService.IsUpdateAvailable == true and the user has not dismissed this session.
  • Text: Update available: v{CurrentVersion} → v{LatestVersion}.
  • Actions: Update now (primary), Dismiss (sets a transient IsBannerDismissed flag that resets on app restart — intentionally not persisted so the banner returns next launch if still relevant).
  • Styled to match existing chrome/accent conventions (compact, dismissible, non-modal).

Help dropdown in the custom titlebar:

  • New menu: Help.
  • First item: Check for updates → binds to MainViewModel.CheckForUpdatesCommand, which calls UpdateCheckService.CheckNowAsync.
  • When a check completes:
    • UpdateAvailable → banner appears (no separate dialog).
    • UpToDate → a brief inline status in the banner area: You're up to date (v{CurrentVersion}), auto-hides after ~3 seconds.
    • CheckFailedCould not check for updates inline message, auto-hides after ~3 seconds.
  • Leaves room for future items (About, Documentation, etc.).

Update action flow

  1. User clicks Update now in the banner.
  2. MainViewModel resolves the installer path via InstallerLocator.
  3. UI spawns ClaudeDo.Installer.exe with no arguments. The installer's existing InstallModeDetector reads install.json alongside it, hits Gitea, and enters Update mode.
  4. UI closes itself immediately after spawning the process.
  5. The installer performs its standard update flow: StopServiceStepDownloadAndExtractStepStartServiceStep.
  6. When the user clicks Finish the installer exits. The user re-launches the app via their existing shortcut.

No IPC between UI and installer is needed — the installer is already self-sufficient once launched against an existing install directory.

Part B — Installer Self-Update

ClaudeDo.Releases/SelfUpdater.cs

Runs from ClaudeDo.Installer/App.xaml.cs before any window is shown.

Flow:

  1. Handle --replace-self argument first. If the installer was launched with --replace-self "<old-path>":

    • Wait up to 5 seconds for the old process to exit (poll for file lock release).
    • Delete <old-path>.
    • Copy own exe to <old-path>.
    • Start a new process at <old-path> with no args, then exit the current (temp) process. This ensures the user's shortcut or Apps & Features entry now points at the updated binary.
    • If any step fails, fall through to the normal wizard (the user still has a working installer, just in a temp location).
  2. Check for a newer installer. If no --replace-self arg:

    • Parse own assembly version.
    • Fetch latest release.
    • Find the installer asset matching ClaudeDo.Installer-<version>.exe (regex: ^ClaudeDo\.Installer-(?<version>[\d\.]+)\.exe$).
    • Compare via VersionComparer.
    • If not newer, or if check fails, proceed to the normal wizard (existing Config-mode fallback behavior).
  3. Prompt if newer. Show a small modal dialog (plain WPF Window, reusing the installer's titlebar/accent styles):

    "A newer installer is available: v{latest}. Update before continuing?" [Update] [Continue anyway] [Cancel]

    • CancelApplication.Current.Shutdown(0).
    • Continue anyway → proceed to normal wizard.
    • Update → run relaunch sequence.
  4. Relaunch sequence:

    • Download to %TEMP%\ClaudeDo.Installer-<version>.exe (show a minimal inline progress UI; no separate window).
    • Verify against checksums.txt from the release via ChecksumVerifier. On failure, show error with [Continue with current installer] action → proceed to wizard.
    • Process.Start new exe with args --replace-self "<current-exe-path>".
    • Exit current process.

Why --replace-self rather than a shell script

A child process holding a handle to the new exe is reliable cross-Windows-version. Relying on a .bat or cmd /c helper leaves a file that we would need to clean up, and behaves badly when the installer was launched from a mounted share or non-ASCII path. The --replace-self approach keeps everything in managed code and uses a single exe throughout.

Edge case: running from uninstaller/ copy

When the installer runs from {installDir}/uninstaller/ClaudeDo.Installer.exe (via the app's Update now or from Apps & Features), the self-update flow is identical. It is desirable for the uninstaller copy to be kept current — stale uninstaller binaries would otherwise drift behind and could have bugs the new app release expects fixed.

Version Comparison (VersionComparer)

Centralizes the logic currently in InstallModeDetector.IsNewer:

  • Parses both versions as System.Version after trimming a leading v / V.
  • Returns (bool isNewer, bool unparseable).
  • Unparseable (e.g. 0.2.0-beta) → treated as not newer; callers can surface a hint if desired.

Both InstallModeDetector (existing behavior) and SelfUpdater / UpdateCheckService (new callers) share this logic.

Error Handling Summary

Scenario App behavior Installer behavior
Gitea unreachable Silent; log to file; no banner Silent; skip self-update; proceed to wizard
JSON parse error Same as unreachable Same as unreachable
Version unparseable No banner; log a hint No prompt; proceed
Installer exe not found on disk Update now button disabled with tooltip N/A
Download fails N/A (app delegates to installer) Error dialog with [Continue with current installer]
Checksum mismatch N/A Error dialog with [Continue with current installer]
Relaunch fails N/A Error dialog; user keeps temp exe and current exe both

Testing

New test project: tests/ClaudeDo.Releases.Tests

  • ReleaseClientTests — move existing installer tests covering GetLatestReleaseAsync and DownloadAsync.
  • VersionComparerTests — boundary cases (equal, newer, older, unparseable, mixed v-prefix).
  • SelfUpdaterTests:
    • Asset-name regex correctly isolates version from ClaudeDo.Installer-0.3.0.exe and ignores ClaudeDo-0.3.0-win-x64.zip.
    • Decision logic given mocked IReleaseClient responses.
    • --replace-self handler: given a temp dummy file, the handler waits, deletes, copies — verified with a mock filesystem / temp dir.

Existing project: tests/ClaudeDo.Installer.Tests

  • SelfUpdateIntegrationTest: build the installer, invoke it with --replace-self <dummy> pointing at a temp file, assert the dummy is replaced by a copy of the test installer, and the process exits cleanly. Run only on Windows CI.

App tests (tests/ClaudeDo.Ui.Tests — add if absent):

  • UpdateCheckServiceTests — stubbed IReleaseClient, assert state transitions for each status.
  • InstallerLocatorTests — fake filesystem, verify walk-up and registry-fallback discovery.

Manual verification (add to docs/open.md):

  1. Build v0.2.x installer and upload to a test Gitea release.
  2. Tag v0.3.0 with new installer asset.
  3. Install v0.2.x, run the v0.2.x installer again — confirm self-update prompt appears and replaces the binary in place.
  4. With v0.2.x installed and a v0.3.0 release published, launch the app — confirm banner appears, Update now launches installer, update completes, app relaunches at v0.3.0.
  5. Pull the network during check in both places — confirm silent fallback, no user-visible errors.

Files Summary

New:

  • src/ClaudeDo.Releases/ClaudeDo.Releases.csproj
  • src/ClaudeDo.Releases/ReleaseClient.cs (moved)
  • src/ClaudeDo.Releases/IReleaseClient.cs (moved)
  • src/ClaudeDo.Releases/ChecksumVerifier.cs (moved)
  • src/ClaudeDo.Releases/VersionComparer.cs (new)
  • src/ClaudeDo.Releases/SelfUpdater.cs (new)
  • src/ClaudeDo.Ui/Services/UpdateCheckService.cs
  • src/ClaudeDo.Ui/Services/InstallerLocator.cs
  • tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj

Modified:

  • src/ClaudeDo.Installer/App.xaml.cs — self-update run + --replace-self handling before wizard.
  • src/ClaudeDo.Installer/Core/InstallModeDetector.cs — use shared VersionComparer; drop now-moved types.
  • src/ClaudeDo.Installer/ClaudeDo.Installer.csproj — reference ClaudeDo.Releases.
  • src/ClaudeDo.Ui/ClaudeDo.Ui.csproj — reference ClaudeDo.Releases.
  • src/ClaudeDo.Ui/Views/MainWindow.axaml — banner + Help menu dropdown.
  • src/ClaudeDo.Ui/ViewModels/MainViewModel.cs — banner state, CheckForUpdatesCommand, wiring to UpdateCheckService.
  • src/ClaudeDo.App/Program.cs (or existing DI composition root) — register UpdateCheckService, InstallerLocator, IReleaseClient, HttpClient.
  • ClaudeDo.slnx — add new projects.
  • docs/open.md — add manual verification checklist.

Open Decisions Deferred to Implementation

  • Exact Avalonia styling/layout of the banner is left to implementation to match the existing chrome polish pass from commit 3c420ac.
  • The Help dropdown control type (Avalonia MenuItem inside a Menu, or a custom flyout) is chosen during implementation based on what fits the current custom titlebar.