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

231 lines
13 KiB
Markdown

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