Every update failed at "Could not replace the existing files": the app relaunches the installer via ShellExecute without a working directory, so it inherited the app's CWD - which the Start Menu shortcut sets to <InstallDir>\app. A process's current directory is locked by Windows, so the installer blocked its own `app` -> `app.bak` rename. Retries and reboots could not help. - installer moves its CWD to %TEMP% at startup, and both relaunch sites in the UI pass an explicit WorkingDirectory - cache the release zip in %TEMP%\ClaudeDo-download-cache and reuse it on a retry while its SHA-256 still matches, so a failed attempt no longer costs another full download; drop it after a successful install, delete a mismatching one, prune zips of other versions - roll back a half-done stash: a leftover app.bak was deleted as a stale stash on the next attempt, and that copy was the only one left - name the blocked path in the error message
6.7 KiB
ClaudeDo.Installer
WPF GUI installer, updater, and configuration tool for ClaudeDo. Not WiX/NSIS — the app is its own installer.
Note: this is the one project where System.Windows is correct (WPF, not Avalonia).
Project Facts
<UseWPF>true</UseWPF>,WinExe,net8.0-windows<EnableWindowsTargeting>true</EnableWindowsTargeting>— allows Linux CI to cross-compile- Single-file framework-dependent publish:
dotnet publish -r win-x64 -p:PublishSingleFile=true(needs .NET 8 Desktop Runtime) - Entry point:
App.xaml/App.xaml.cs(noProgram.cs) - References:
ClaudeDo.Data,ClaudeDo.Releases,ClaudeDo.Localization - Manifests:
app.manifest(requireAdministrator, Release) /app.debug.manifest(asInvoker, Debug) - No CLI args — mode is detected from
install.json+ the Gitea API
Startup Sequence (App.OnStartup)
- Load locale
- Detect mode —
InstallModeDetectorreadsinstall.json+ Gitea API - Open
WizardWindow(FreshInstall / Update) orSettingsWindow(Config)
The installer does not self-update. Each release ships a stable-named
ClaudeDo.Installer.exe asset (permanent URL
…/releases/latest/download/ClaudeDo.Installer.exe); the installer never checks for or
replaces itself on launch. The in-app "Update" button relaunches the on-disk installer to
run the app update — the installer binary itself only changes when the user downloads a
fresh copy. App-update detection is unaffected: WriteInstallManifestStep records
ctx.InstalledVersion (the release tag from DownloadAndExtractStep), which
InstallModeDetector compares against the latest tag.
Modes (Core/InstallerMode.cs)
| Mode | Condition | Window |
|---|---|---|
FreshInstall |
No install.json |
Full wizard (all pages) |
Update |
install.json present + newer release available |
Wizard — Welcome + Install pages only |
Config |
Current version, or Gitea API unreachable | SettingsWindow (settings / repair / uninstall) |
Install Pipelines
Each step implements IInstallStep; InstallerService runs them sequentially, stops on failure.
FreshInstall:
DownloadAndExtractStep → WriteConfigStep → InitDatabaseStep → RegisterMcpStep (optional) → RegisterAutostartStep → CreateShortcutsStep → WriteUninstallRegistryStep → WriteInstallManifestStep → StartWorkerStep
Update:
StopWorkerStep → DownloadAndExtractStep → RegisterAutostartStep → RegisterMcpStep → StartWorkerStep → WriteInstallManifestStep → WriteUninstallRegistryStep
Repair (via SettingsViewModel):
StopWorkerStep → DownloadAndExtractStep → RegisterAutostartStep → StartWorkerStep
Uninstall (UninstallRunner):
Stop worker → remove legacy task/service → delete HKLM uninstall key + shortcuts → delete install dir (cmd.exe trampoline if uninstaller exe is inside it) → optionally delete ~/.todo-app
Folder Layout
Installer/
Steps/ — one class per action (see pipeline lists above)
Core/ — InstallContext, InstallerMode, InstallModeDetector, InstallManifest(+Store),
ConfigModels, InstallerService, UninstallRunner, PageResolver,
AutostartShortcut, ShortcutFactory, ProcessRunner, DarkTitleBar
Interfaces/ — IInstallStep + StepResult/StepStatus/StepProgress, IInstallerPage
Pages/ — WelcomePage, PathsPage, ServicePage, UiSettingsPage, InstallPage
(each: ViewModel + View.xaml)
Views/ — WizardWindow(+WizardViewModel), SettingsWindow(+SettingsViewModel)
Key Step Behaviors
RegisterMcpStep — registers the external MCP endpoint with the Claude CLI:
claude mcp remove --scope user claudedo
claude mcp add --transport http --scope user claudedo http://127.0.0.1:{ExternalMcpPort}/mcp
Non-fatal if claude CLI is missing or too old (prints the manual command). Server name: claudedo.
RegisterAutostartStep — creates a per-user Startup-folder shortcut ClaudeDo Worker.lnk (Environment.SpecialFolder.Startup). Also migrates away from legacy mechanisms:
- Deletes legacy Windows service:
sc.exe stop/delete ClaudeDoWorker - Deletes legacy scheduled task:
schtasks /Delete /TN ClaudeDoWorker
No new service or scheduled task is created. Rationale: the worker must run in the user's interactive session so Claude CLI auth works.
DownloadAndExtractStep — fetches checksums.txt first and only touches the install dir after the zip verifies. The zip is cached in %TEMP%\ClaudeDo-download-cache (ctor takes an override for tests) and reused on a retry when its SHA-256 still matches, so a failed attempt doesn't cost another full download; it is dropped after a successful install, a bad download is deleted immediately, and zips of other versions are pruned. app\/worker\ are stashed to *.bak before extraction and restored if extraction fails.
Gotcha: the installer must never run from inside the install dir
App.OnStartup sets Environment.CurrentDirectory to %TEMP%, and the UI passes an explicit WorkingDirectory when it relaunches us. A process's current directory is locked by Windows: inheriting the app's CWD (<InstallDir>\app, from the Start Menu shortcut's "start in") made the installer block its own app → app.bak rename, so every update failed with "Could not replace the existing files" — unaffected by retries or a reboot. Keep both guards.
InstallContext Defaults
| Property | Default |
|---|---|
InstallDirectory |
C:\Program Files\ClaudeDo |
DbPath |
~/.todo-app/todo.db |
LogRoot |
~/.todo-app/logs |
SandboxRoot |
~/.todo-app/sandbox |
WorktreeRootStrategy |
sibling |
SignalRPort |
47821 |
ExternalMcpPort |
47822 |
QueueBackstopIntervalMs |
30000 |
ClaudeBin |
claude |
AutoStart |
true |
SignalRUrl |
http://127.0.0.1:47821/hub |
Files Written by Install
| Path | Content |
|---|---|
~/.todo-app/worker.config.json |
Worker config |
~/.todo-app/ui.config.json |
UI config |
~/.todo-app/todo.db |
SQLite DB (EF migrations) |
<InstallDir>\install.json |
Install manifest |
<InstallDir>\app\ |
UI binaries |
<InstallDir>\worker\ |
Worker binaries |
<InstallDir>\uninstaller\ClaudeDo.Installer.exe |
Uninstaller copy |
HKLM\Software\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo |
Uninstall registry key |
| Start Menu shortcut | ClaudeDo.lnk |
| Desktop shortcut (optional) | ClaudeDo.lnk |
%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\ClaudeDo Worker.lnk |
Worker autostart |
The Apps & Features uninstall string and "Rerun Installer" both point at <InstallDir>\uninstaller\ClaudeDo.Installer.exe with no /uninstall flag — Config mode is detected from install.json.