docs(self-update): add design spec for app + installer self-update
This commit is contained in:
230
docs/superpowers/specs/2026-04-23-self-update-design.md
Normal file
230
docs/superpowers/specs/2026-04-23-self-update-design.md
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
# 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.
|
||||||
Reference in New Issue
Block a user