Files
ClaudeDo/src/ClaudeDo.Installer/CLAUDE.md
T
Mika Kuns 63d8b5c28d
Changelog / changelog (push) Successful in 2s
Release / release (push) Successful in 40s
fix(installer): unbreak the update path and cache the download
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
2026-08-04 17:09:10 +02:00

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 (no Program.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)

  1. Load locale
  2. Detect mode — InstallModeDetector reads install.json + Gitea API
  3. Open WizardWindow (FreshInstall / Update) or SettingsWindow (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: DownloadAndExtractStepWriteConfigStepInitDatabaseStepRegisterMcpStep (optional) → RegisterAutostartStepCreateShortcutsStepWriteUninstallRegistryStepWriteInstallManifestStepStartWorkerStep

Update: StopWorkerStepDownloadAndExtractStepRegisterAutostartStepRegisterMcpStepStartWorkerStepWriteInstallManifestStepWriteUninstallRegistryStep

Repair (via SettingsViewModel): StopWorkerStepDownloadAndExtractStepRegisterAutostartStepStartWorkerStep

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