# 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.
  - `CheckFailed` → `Could 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: `StopServiceStep` → `DownloadAndExtractStep` → `StartServiceStep`.
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]`

   - **Cancel** → `Application.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.
