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