RegisterAutostartStep rewrote the Startup .lnk on every install/update/repair even when it already pointed at the right worker exe. AutostartShortcut.Install now reads the existing shortcut's target via ShortcutFactory.TryGetTarget and skips the rewrite when it matches, reporting the skip in progress output. Legacy service/scheduled-task cleanup stays unconditional (migration safety net).
11 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 binary only changes when the user downloads a fresh copy. The in-app "Update" button relaunches
the on-disk installer to run the app update. 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: Welcome → SystemCheck → Paths → Service → UiSettings → Install |
Update |
install.json present + newer release available |
Wizard — Welcome + Install pages only (SystemCheck not shown) |
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
Checks/ — environment preflight checks, see "Environment Checks" below
Pages/ — WelcomePage, SystemCheckPage, 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); AutostartShortcut.Install skips the rewrite (and reports it) when the shortcut already points at the current worker exe, so update/repair runs don't touch it needlessly. Also migrates away from legacy mechanisms, unconditionally on every run (no cached "already migrated" flag — see the comment in the step):
- 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. Cache is dropped after a successful install, a bad download is deleted
immediately, other versions are pruned. app\/worker\ are stashed to *.bak before extraction
and restored if it 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.
Environment Checks
Checks/ holds one IEnvironmentCheck per concern, run in parallel by EnvironmentCheckService.RunAllAsync:
| Check | Severity | What it verifies |
|---|---|---|
GitCheck |
Error | git resolvable (via ExecutableResolver) and runs |
WriteAccessCheck |
Error | install dir + %APPDATA% (or first existing parent) are writable |
ClaudeCliCheck |
Error | claude resolvable on PATH, including npm .cmd/.bat/.ps1 shims |
ClaudeVersionCheck |
Error | resolved claude --version ≥ ClaudeVersionCheck.MinimumVersion (currently 2.1.220) |
ClaudeAuthCheck |
Error | claude auth status --json reports loggedIn: true (never sends a prompt) |
GitIdentityCheck |
Warning | git config user.name/user.email are set |
PortCheck |
Warning | SignalRPort/ExternalMcpPort are free, or already owned by a running ClaudeDo.Worker |
PermissionModeAutoCheck |
Warning | CLI's --help still lists auto as a --permission-mode choice |
Each check returns a CheckResult with CheckStatus (Ok / Failed / Unknown). A check that
throws is caught by EnvironmentCheckService and turned into Unknown, never a crash.
Gating rule: EnvironmentCheckReport.HasBlockingError is true only when a check with
Severity == Error has Status == Failed. Warnings never block, and Unknown never blocks
regardless of severity (an indeterminate result — e.g. the CLI not found, so version/auth/auto-mode
can't be checked — must not strand the user; the underlying Error-severity check for the CLI
itself, ClaudeCliCheck, is what blocks in that case).
The check-row rendering and the check-run logic (busy state, summary text, Recheck command) live
in one place — Checks/CheckListViewModel.cs + Checks/CheckListView.xaml — composed by every
page that hosts a check list, not duplicated per page.
SystemCheckPage (Pages/SystemCheckPage/) hosts the check list in the FreshInstall wizard
only, registered via PageResolver at Order = 1 (directly after WelcomePage); WizardViewModel
filters it back out in Update mode along with Paths/Service/UiSettings. Checks run automatically
on page entry (LoadAsync, guarded against double-entry). "Next" is disabled via
IInstallerPage.BlocksNavigation (IsRunning || HasBlockingError) — WizardViewModel.CanGoNext
subscribes to PropertyChanged on the current page so a live recheck can flip it back. A "Recheck"
button re-runs EnvironmentCheckService.RunAllAsync (disabled while already running).
"Claude Help Me" button (Core/ClaudeHelpLauncher.cs) — a second button below the shared
check-list footer, enabled only when claude-cli is Ok and claude-auth is not Failed (Unknown
stays enabled — an indeterminate login state shouldn't block the one feature that could help
diagnose it). BuildReportAsync renders all check results (Id/Severity/Status/Message table,
plus the full Detail of any Failed check) and system info (OS, dotnet --list-runtimes,
resolved git/claude messages, planned install dir/ports) into
%TEMP%\claudedo-setup-diagnose.md — English and hardcoded (an AI assistant reads it, not the
user) and deliberately excludes credentials/tokens/env-var dumps. LaunchTerminal then opens
wt.exe -d %TEMP% cmd.exe /k <claude> <prompt> (or cmd.exe /k <claude> <prompt> if wt.exe
isn't resolvable) via the injectable IProcessLauncher, pointing the initial prompt at that
report file. Errors from either step surface as ClaudeHelpError on the page, never an
exception. It reads the last report via CheckListViewModel.LastReport, so it re-evaluates on
every check run.
DiagnosePage (Pages/DiagnosePage/) hosts the same CheckListView in SettingsWindow (Config
mode only, ShowInSettings = true / ShowInWizard = false, Order = 5 — after UiSettings).
Nothing here blocks navigation and checks do not auto-run on load — only on a "Recheck" click.
Unlike the wizard, its InstallContext is built from the installed configuration
(InstallerWorkerConfig.Load() for ClaudeBin/SignalRPort, the shared InstallContext for
InstallDirectory/ExternalMcpPort), refreshed on every LoadAsync() — not the wizard-default
InstallContext the DI container hands out, which is only populated once a page's ApplyAsync
(i.e. Save) runs. DiagnosePage intentionally has no "Claude Help Me" button — that one is
wizard-only.