13 KiB
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.GetLatestReleaseAsyncto fetch the Gitea release. - Use
VersionComparerto decide whether the latest is newer. - Expose observable properties:
IsUpdateAvailable,LatestVersion,CurrentVersion,IsChecking,LastCheckStatus(UpToDate | UpdateAvailable | CheckFailed | NeverChecked). - Expose a
CheckNowAsyncmethod for the manual Help menu action.
Lifecycle:
- Registered as a singleton in DI.
- Startup check is fired from
MainViewModelonce the main window is shown. It runs fire-and-forget on a backgroundTask; 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/, setLastCheckStatus = 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.exeso the UI can launch it.
Discovery strategy (first hit wins):
- Walk up from
AppContext.BaseDirectorylooking for a siblinginstall.json. The installer is at{installDir}/uninstaller/ClaudeDo.Installer.exe. - Fall back to reading
HKLM\Software\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo\InstallLocation(written by the existingWriteUninstallRegistryStep).
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 == trueand the user has not dismissed this session. - Text:
Update available: v{CurrentVersion} → v{LatestVersion}. - Actions:
Update now(primary),Dismiss(sets a transientIsBannerDismissedflag 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 toMainViewModel.CheckForUpdatesCommand, which callsUpdateCheckService.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 updatesinline message, auto-hides after ~3 seconds.
- Leaves room for future items (
About,Documentation, etc.).
Update action flow
- User clicks Update now in the banner.
MainViewModelresolves the installer path viaInstallerLocator.- UI spawns
ClaudeDo.Installer.exewith no arguments. The installer's existingInstallModeDetectorreadsinstall.jsonalongside it, hits Gitea, and entersUpdatemode. - UI closes itself immediately after spawning the process.
- The installer performs its standard update flow:
StopServiceStep→DownloadAndExtractStep→StartServiceStep. - 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:
-
Handle
--replace-selfargument 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).
-
Check for a newer installer. If no
--replace-selfarg:- 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).
-
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.
- Cancel →
-
Relaunch sequence:
- Download to
%TEMP%\ClaudeDo.Installer-<version>.exe(show a minimal inline progress UI; no separate window). - Verify against
checksums.txtfrom the release viaChecksumVerifier. On failure, show error with[Continue with current installer]action → proceed to wizard. Process.Startnew exe with args--replace-self "<current-exe-path>".- Exit current process.
- Download to
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.Versionafter trimming a leadingv/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 coveringGetLatestReleaseAsyncandDownloadAsync.VersionComparerTests— boundary cases (equal, newer, older, unparseable, mixedv-prefix).SelfUpdaterTests:- Asset-name regex correctly isolates version from
ClaudeDo.Installer-0.3.0.exeand ignoresClaudeDo-0.3.0-win-x64.zip. - Decision logic given mocked
IReleaseClientresponses. --replace-selfhandler: given a temp dummy file, the handler waits, deletes, copies — verified with a mock filesystem / temp dir.
- Asset-name regex correctly isolates version from
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— stubbedIReleaseClient, assert state transitions for each status.InstallerLocatorTests— fake filesystem, verify walk-up and registry-fallback discovery.
Manual verification (add to docs/open.md):
- Build
v0.2.xinstaller and upload to a test Gitea release. - Tag
v0.3.0with new installer asset. - Install
v0.2.x, run thev0.2.xinstaller again — confirm self-update prompt appears and replaces the binary in place. - With
v0.2.xinstalled 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. - Pull the network during check in both places — confirm silent fallback, no user-visible errors.
Files Summary
New:
src/ClaudeDo.Releases/ClaudeDo.Releases.csprojsrc/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.cssrc/ClaudeDo.Ui/Services/InstallerLocator.cstests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj
Modified:
src/ClaudeDo.Installer/App.xaml.cs— self-update run +--replace-selfhandling before wizard.src/ClaudeDo.Installer/Core/InstallModeDetector.cs— use sharedVersionComparer; drop now-moved types.src/ClaudeDo.Installer/ClaudeDo.Installer.csproj— referenceClaudeDo.Releases.src/ClaudeDo.Ui/ClaudeDo.Ui.csproj— referenceClaudeDo.Releases.src/ClaudeDo.Ui/Views/MainWindow.axaml— banner + Help menu dropdown.src/ClaudeDo.Ui/ViewModels/MainViewModel.cs— banner state,CheckForUpdatesCommand, wiring toUpdateCheckService.src/ClaudeDo.App/Program.cs(or existing DI composition root) — registerUpdateCheckService,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
MenuIteminside aMenu, or a custom flyout) is chosen during implementation based on what fits the current custom titlebar.