diff --git a/docs/superpowers/specs/2026-04-23-self-update-design.md b/docs/superpowers/specs/2026-04-23-self-update-design.md new file mode 100644 index 0000000..d0db4f9 --- /dev/null +++ b/docs/superpowers/specs/2026-04-23-self-update-design.md @@ -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--win-x64.zip`, `ClaudeDo.Installer-.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 ""`: + - Wait up to 5 seconds for the old process to exit (poll for file lock release). + - Delete ``. + - Copy own exe to ``. + - Start a new process at `` 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-.exe` (regex: `^ClaudeDo\.Installer-(?[\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-.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 ""`. + - 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 ` 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.