Files
ClaudeDo/src/ClaudeDo.Installer/CLAUDE.md
T
mika kuns 445242cd7d fix(installer): skip rewriting the autostart shortcut when already current
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).
2026-08-06 11:08:15 +02:00

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 (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 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: 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
  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 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.

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