diff --git a/docs/open.md b/docs/open.md index 87807fe..88c1c1c 100644 --- a/docs/open.md +++ b/docs/open.md @@ -65,9 +65,9 @@ Bedingt durch Slice "Planning B/C". Ablauf identisch zur alten open.md: 8. Delete-Versuch auf Parent mit Children → freundlicher Fehlerdialog, kein Delete. **Bekannte Follow-ups (non-blocking):** -- ⬜ `Border.badge.planned` (blau) ist in `IslandStyles.axaml` definiert, wird aber nie angewendet — `TaskRowView` behält die `planning`-Klasse für `Active` UND `Finalized`, daher amber statt blau bei finalisiert. Entweder Class-Swap auf `planned` bei `Finalized`, oder die unused Style+Brush entfernen. -- ⬜ Tote `Instance`-Statics auf `BoolToItalicConverter` und `BoolToDraftOpacityConverter` — `App.axaml` registriert via Resource-Dictionary, die statischen Members können weg. -- ⬜ `Ui.Tests` IWorkerClient-Fakes (`DetailsIslandPlanningTests`, `PlanningDiffViewModelTests`, `ConflictResolutionViewModelTests`) fehlen `OpenInteractiveTerminalAsync` und `QueuePlanningSubtasksAsync` — Constructor-Drift, Fakes auf gemeinsame abstrakte Basis rebasen. +- ✅ `Border.badge.planned` (blau) wird jetzt bei `Finalized` angewendet — `TaskRowView` nutzt `Classes.planning`/`Classes.planned` gebunden an `IsPlanActive`/`IsPlanFinalized`; der Child-„PLANNED"-Badge nutzt direkt `planned`. +- ✅ Tote `Instance`-Statics auf `BoolToItalicConverter` und `BoolToDraftOpacityConverter` entfernt (Registrierung läuft über das Resource-Dictionary in `App.axaml`). +- ✅ `Ui.Tests` IWorkerClient-Fakes auf gemeinsame Basis `StubWorkerClient` rebased — kein Constructor-Drift mehr; die drei Fakes überschreiben nur ihre relevanten Member. ### 1.2 Prime Claude — Manual Verification @@ -129,11 +129,11 @@ Voraussetzung: funktionierendes Gitea-Release unter `git.kuns.dev/releases/Claud ### 2.7 Settings-Dialog ✅ - `SettingsModalView` als `TabControl`, Tabs: General, Prime Claude, etc. Persistiert in `~/.todo-app/ui.config.json` und `worker.config.json`. -### 2.8 (NEU) Planning-Phase Badge-Farbe für `Finalized` ⬜ -Siehe §1.1 — `planning`-Klasse bleibt amber, blauer `planned`-Style nicht angewendet. +### 2.8 (NEU) Planning-Phase Badge-Farbe für `Finalized` ✅ +`Finalized` zeigt jetzt den blauen `planned`-Badge (Class-Binding in `TaskRowView`). -### 2.9 (NEU) Tote Converter-Statics entfernen ⬜ -`BoolToItalicConverter.Instance`, `BoolToDraftOpacityConverter.Instance` — siehe §1.1. +### 2.9 (NEU) Tote Converter-Statics entfernen ✅ +`BoolToItalicConverter.Instance`, `BoolToDraftOpacityConverter.Instance` entfernt. --- @@ -161,8 +161,11 @@ Siehe §1.1 — `planning`-Klasse bleibt amber, blauer `planned`-Style nicht ang ## 4. Service-Deployment -### 4.1 Windows-Service-Hosting ✅ -- `Program.cs` ruft `builder.Host.UseWindowsService(o => o.ServiceName = "ClaudeDoWorker")`. +### 4.1 Worker-Autostart als Per-User-Task ✅ (ersetzt Windows-Service) +- Der Worker läuft **nicht mehr als Windows-Service** (LocalSystem konnte die Claude-CLI-Auth des Users nicht sehen). Stattdessen: per-user **Logon-Scheduled-Task** „ClaudeDoWorker" (`schtasks /Create /XML`), läuft als angemeldeter User, versteckt, mit Restart-on-Failure. +- Worker ist `WinExe` (kein Konsolenfenster) + Serilog-File-Sink (`~/.todo-app/logs/worker-*.log`) + Single-Instance-Mutex. +- Installer migriert beim Update den alten Service automatisch weg (`sc stop`/`delete`) und registriert die Task; Uninstall entfernt Task + Worker-Prozess. App startet/neustartet den Worker als Prozess und sorgt beim Start dafür, dass er läuft. +- Implementiert 2026-05-29, getestet (Build + Unit-Tests grün), **manuelle E2E-Verifikation am Gerät ausstehend** (Update von 1.0.2-alpha → Task, Logoff/Logon-Autostart, Uninstall). ### 4.2 Pfad-Auflösung absolut ✅ - `WorkerConfig.Load` expandiert `~`/`%USERPROFILE%` für alle Pfad-Felder. @@ -220,13 +223,13 @@ Siehe §1.1 — `planning`-Klasse bleibt amber, blauer `planned`-Style nicht ang | Stelle | Issue | Status | |---|---|---| -| `WorkerHub.GetActive` returnt `IReadOnlyList` mit anonymen Typen | Sollte expliziter `ActiveTaskDto` sein, den Worker UND UI teilen | ⬜ | +| `WorkerHub.GetActive` returnt `IReadOnlyList` mit anonymen Typen | Sollte expliziter `ActiveTaskDto` sein, den Worker UND UI teilen | ✅ (gibt bereits `IReadOnlyList` zurück) | | `TaskRunner` führt eine `if (list.WorkingDir != null)`-Verzweigung mitten in der Methode | Strategy-Pattern wenn die Methode wächst, aktuell noch klein genug | ⬜ | | `App.Services` als public static `ServiceProvider` | Service-Locator-Antipattern, toleriert weil nur in `App.OnFrameworkInitializationCompleted` | ⬜ | | Embedded `schema.sql` ohne Versionierung | Durch EF-Core-Migrationen ersetzt | ✅ | -| CRLF-Warnings beim Commit | `.gitattributes` mit `* text=auto eol=lf` (oder explizit pro Sprache) wäre sauberer | ⬜ | -| Tote Converter-Instances (`BoolToItalicConverter.Instance`, `BoolToDraftOpacityConverter.Instance`) | Per Resource-Dictionary registriert, Statics ungenutzt | ⬜ | -| 1 unausgeführter `// TODO` in `DetailsIslandViewModel` (`SendPromptAsync` ohne Hub-Methode) | Entweder Hub-Methode bauen oder TODO entfernen | ⬜ | +| CRLF-Warnings beim Commit | `.gitattributes` mit `* text=auto eol=lf` (oder explizit pro Sprache) wäre sauberer | ✅ (`.gitattributes` angelegt) | +| Tote Converter-Instances (`BoolToItalicConverter.Instance`, `BoolToDraftOpacityConverter.Instance`) | Per Resource-Dictionary registriert, Statics ungenutzt | ✅ (entfernt) | +| 1 unausgeführter `// TODO` in `DetailsIslandViewModel` (`SendPromptAsync` ohne Hub-Methode) | Entweder Hub-Methode bauen oder TODO entfernen | ✅ (im Main-Code nicht mehr vorhanden) | --- diff --git a/docs/superpowers/plans/2026-05-29-worker-per-user-autostart.md b/docs/superpowers/plans/2026-05-29-worker-per-user-autostart.md new file mode 100644 index 0000000..7f1cc0e --- /dev/null +++ b/docs/superpowers/plans/2026-05-29-worker-per-user-autostart.md @@ -0,0 +1,655 @@ +# Worker Per-User Autostart Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the worker's Windows service with a per-user logon Scheduled Task so the worker runs as the logged-in user (Claude auth works), windowless, with file logging and auto-restart. + +**Architecture:** Worker becomes a windowless (`WinExe`) process with Serilog file logging and a single-instance mutex. The installer registers a hidden logon Scheduled Task (via `schtasks /Create /XML`), migrates away the old `ClaudeDoWorker` service, and manages the worker as a process. The app launches/restarts the worker as a process and ensures it's running. + +**Tech Stack:** .NET 8, ASP.NET Core (worker), WPF (installer), Avalonia (app), Serilog, Windows Task Scheduler (`schtasks`), `sc.exe`. + +**Build note:** `.slnx` fails on .NET 8 — always build individual `.csproj` files. + +--- + +## File Structure + +**Worker** +- Modify `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` — WinExe, Serilog packages, drop Hosting.WindowsServices. +- Modify `src/ClaudeDo.Worker/Program.cs` — mutex, Serilog, remove `UseWindowsService`. + +**Installer** +- Create `src/ClaudeDo.Installer/Core/ScheduledTaskXml.cs` — pure XML builder. +- Create `src/ClaudeDo.Installer/Steps/RegisterAutostartStep.cs` — migrate service + register task. +- Rename/rewrite `StopServiceStep.cs` → `StopWorkerStep.cs`, `StartServiceStep.cs` → `StartWorkerStep.cs`. +- Delete `src/ClaudeDo.Installer/Steps/RegisterServiceStep.cs`. +- Modify `Pages/ServicePage/ServicePageViewModel.cs` + `ServicePageView.xaml` — drop account radios. +- Modify `Core/InstallContext.cs` — drop `ServiceAccount`. +- Modify `Pages/InstallPage/InstallPageViewModel.cs` — pipeline wiring. +- Modify `App.xaml.cs` — DI registration. +- Modify `Core/UninstallRunner.cs` — task delete + process kill. +- Modify `Views/SettingsViewModel.cs` — use renamed steps. + +**App** +- Create `src/ClaudeDo.Ui/Services/WorkerLocator.cs` — resolve worker exe path. +- Modify `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs` — process restart + ensure-running. +- Modify `src/ClaudeDo.App/Program.cs` — register `WorkerLocator`, pass to shell VM if needed. + +**Tests** +- Create `tests/ClaudeDo.Installer.Tests/ScheduledTaskXmlTests.cs`. +- Create `tests/ClaudeDo.Ui.Tests/Services/WorkerLocatorTests.cs`. + +--- + +## Task 1: Worker → WinExe + Serilog packages + +**Files:** Modify `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` + +- [ ] **Step 1:** In the main `` add `WinExe`. Remove the `Microsoft.Extensions.Hosting.WindowsServices` PackageReference. Add: + +```xml + + +``` + +- [ ] **Step 2:** Build: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` — Expected: succeeds (packages restore). + +--- + +## Task 2: Worker single-instance mutex + Serilog + drop UseWindowsService + +**Files:** Modify `src/ClaudeDo.Worker/Program.cs` + +- [ ] **Step 1:** At the very top of the file (before `var cfg = WorkerConfig.Load();`), add the single-instance guard: + +```csharp +using System.Threading; + +// Single-instance per user session. Multiple launch paths exist (logon task, +// app ensure-running, Restart button); a second instance exits cleanly instead +// of fighting over the SignalR port. +var mutex = new Mutex(true, @"Local\ClaudeDoWorker", out var createdNew); +if (!createdNew) + return; // another instance already owns the port; exit 0 +``` + +- [ ] **Step 2:** Remove the `builder.Host.UseWindowsService(...)` line (lines ~21-23 incl. the comment). + +- [ ] **Step 3:** After `var builder = WebApplication.CreateBuilder(args);`, add Serilog file logging: + +```csharp +using Serilog; + +var logRoot = ClaudeDo.Data.Paths.Expand(cfg.LogRoot); +Directory.CreateDirectory(logRoot); +builder.Host.UseSerilog((ctx, lc) => lc + .MinimumLevel.Information() + .WriteTo.File( + System.IO.Path.Combine(logRoot, "worker-.log"), + rollingInterval: RollingInterval.Day, + retainedFileCountLimit: 7, + shared: true)); +``` + +(If `cfg.LogRoot` is already absolute/expanded, `Paths.Expand` is a safe no-op. Verify `WorkerConfig` exposes `LogRoot`; if the property differs, use the actual name.) + +- [ ] **Step 4:** At the very end of the file, after the run block, add `GC.KeepAlive(mutex);` to ensure the mutex isn't collected. + +- [ ] **Step 5:** Build: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` — Expected: succeeds. + +- [ ] **Step 6:** Run worker tests: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj` — Expected: all pass (set `CLAUDEDO_SKIP_CLI_PREFLIGHT=1` if needed; existing tests already handle this). + +--- + +## Task 3: Scheduled-task XML builder (pure, TDD) + +**Files:** Create `src/ClaudeDo.Installer/Core/ScheduledTaskXml.cs`, Test `tests/ClaudeDo.Installer.Tests/ScheduledTaskXmlTests.cs` + +- [ ] **Step 1: Write the failing test:** + +```csharp +using ClaudeDo.Installer.Core; +using Xunit; + +namespace ClaudeDo.Installer.Tests; + +public class ScheduledTaskXmlTests +{ + [Fact] + public void Build_EmbedsUserExeAndLogonTrigger() + { + var xml = ScheduledTaskXml.Build( + userId: "MACHINE\\mika", + workerExePath: @"C:\Program Files\ClaudeDo\worker\ClaudeDo.Worker.exe", + restartIntervalMinutes: 1); + + Assert.Contains("", xml); + Assert.Contains("MACHINE\\mika", xml); + Assert.Contains("InteractiveToken", xml); + Assert.Contains("true", xml); + Assert.Contains("LeastPrivilege", xml); + Assert.Contains(@"C:\Program Files\ClaudeDo\worker\ClaudeDo.Worker.exe", xml); + Assert.Contains("PT1M", xml); + } + + [Fact] + public void Build_ClampsRestartIntervalToOneMinuteMinimum() + { + var xml = ScheduledTaskXml.Build("M\\u", @"C:\w.exe", restartIntervalMinutes: 0); + Assert.Contains("PT1M", xml); + } +} +``` + +- [ ] **Step 2: Run it, verify fail:** `dotnet test tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj --filter ScheduledTaskXmlTests` — Expected: FAIL (type missing). + +- [ ] **Step 3: Implement:** + +```csharp +using System.Security; + +namespace ClaudeDo.Installer.Core; + +/// Builds a Task Scheduler definition XML for the per-user worker autostart. +/// Pure function so it can be unit-tested without admin rights. +public static class ScheduledTaskXml +{ + public static string Build(string userId, string workerExePath, int restartIntervalMinutes) + { + var minutes = restartIntervalMinutes < 1 ? 1 : restartIntervalMinutes; + var user = SecurityElement.Escape(userId); + var cmd = SecurityElement.Escape(workerExePath); + return $""" + + + + ClaudeDo background worker (per-user). + + + + true + {user} + + + + + {user} + InteractiveToken + LeastPrivilege + + + + IgnoreNew + false + false + true + true + true + PT0S + + PT{minutes}M + 3 + + + + + {cmd} + + + + """; + } +} +``` + +- [ ] **Step 4: Run, verify pass:** same filter — Expected: PASS. + +--- + +## Task 4: RegisterAutostartStep (migrate service + register task) + +**Files:** Create `src/ClaudeDo.Installer/Steps/RegisterAutostartStep.cs` + +- [ ] **Step 1: Implement** (no unit test — shells out to `sc`/`schtasks`; logic kept thin): + +```csharp +using System.IO; +using System.Security.Principal; +using ClaudeDo.Installer.Core; + +namespace ClaudeDo.Installer.Steps; + +public sealed class RegisterAutostartStep : IInstallStep +{ + public const string TaskName = "ClaudeDoWorker"; + private const string LegacyServiceName = "ClaudeDoWorker"; + + public string Name => "Register Autostart"; + + public async Task ExecuteAsync(InstallContext ctx, IProgress progress, CancellationToken ct) + { + var workerExe = Path.Combine(ctx.InstallDirectory, "worker", "ClaudeDo.Worker.exe"); + if (!File.Exists(workerExe)) + return StepResult.Fail($"Worker executable not found: {workerExe}"); + + // 1) Migrate away the legacy Windows service if present. + progress.Report("Checking for legacy worker service..."); + var (queryExit, _) = await ProcessRunner.RunAsync("sc.exe", $"query {LegacyServiceName}", null, progress, ct); + if (queryExit == 0) + { + progress.Report("Removing legacy worker service..."); + await ProcessRunner.RunAsync("sc.exe", $"stop {LegacyServiceName}", null, progress, ct); + await ProcessRunner.RunAsync("sc.exe", $"delete {LegacyServiceName}", null, progress, ct); + for (var i = 0; i < 30; i++) + { + ct.ThrowIfCancellationRequested(); + var (q, _) = await ProcessRunner.RunAsync("sc.exe", $"query {LegacyServiceName}", null, progress, ct); + if (q != 0) break; + await Task.Delay(1000, ct); + } + } + + // 2) Register (or replace) the per-user logon task. + var userId = WindowsIdentity.GetCurrent().Name; + var minutes = Math.Max(1, ctx.RestartDelayMs / 60000); + var xml = ScheduledTaskXml.Build(userId, workerExe, minutes); + + var xmlPath = Path.Combine(Path.GetTempPath(), $"ClaudeDoWorker-{Guid.NewGuid():N}.xml"); + await File.WriteAllTextAsync(xmlPath, xml, new System.Text.UnicodeEncoding(false, true), ct); + try + { + progress.Report("Registering logon task..."); + var (exit, output) = await ProcessRunner.RunAsync( + "schtasks.exe", $"/Create /TN \"{TaskName}\" /XML \"{xmlPath}\" /F", null, progress, ct); + if (exit != 0) + return StepResult.Fail($"schtasks /Create failed (exit {exit}): {output}"); + } + finally + { + try { File.Delete(xmlPath); } catch { /* best effort */ } + } + + return StepResult.Ok(); + } +} +``` + +- [ ] **Step 2: Build:** `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` — Expected: succeeds (after Task 5/6 it compiles fully; if `RestartDelayMs` exists on `InstallContext` already, this compiles now). + +--- + +## Task 5: StopWorkerStep + StartWorkerStep (replace service steps) + +**Files:** Create `src/ClaudeDo.Installer/Steps/StopWorkerStep.cs`, `src/ClaudeDo.Installer/Steps/StartWorkerStep.cs`. Delete `StopServiceStep.cs`, `StartServiceStep.cs`, `RegisterServiceStep.cs`. + +- [ ] **Step 1: Create `StopWorkerStep.cs`:** + +```csharp +using System.Diagnostics; +using System.IO; +using ClaudeDo.Installer.Core; + +namespace ClaudeDo.Installer.Steps; + +public sealed class StopWorkerStep : IInstallStep +{ + public const string TaskName = "ClaudeDoWorker"; + public const string ProcessName = "ClaudeDo.Worker"; + + public string Name => "Stop Worker"; + + public async Task ExecuteAsync(InstallContext ctx, IProgress progress, CancellationToken ct) + { + progress.Report("Stopping worker task (if running)..."); + await ProcessRunner.RunAsync("schtasks.exe", $"/End /TN \"{TaskName}\"", null, progress, ct); + + progress.Report("Stopping worker process (if running)..."); + var installDir = ctx.InstallDirectory; + foreach (var p in Process.GetProcessesByName(ProcessName)) + { + try + { + var path = p.MainModule?.FileName; + if (path is not null && !IsUnder(path, installDir)) continue; + p.Kill(entireProcessTree: true); + p.WaitForExit(10000); + } + catch { /* process may have exited or be inaccessible */ } + finally { p.Dispose(); } + } + await Task.CompletedTask; + return StepResult.Ok(); + } + + private static bool IsUnder(string filePath, string dir) + { + try + { + if (string.IsNullOrWhiteSpace(dir)) return true; // can't scope — be permissive + var full = Path.GetFullPath(filePath); + var root = Path.GetFullPath(dir).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; + return full.StartsWith(root, StringComparison.OrdinalIgnoreCase); + } + catch { return false; } + } +} +``` + +- [ ] **Step 2: Create `StartWorkerStep.cs`:** + +```csharp +using ClaudeDo.Installer.Core; + +namespace ClaudeDo.Installer.Steps; + +public sealed class StartWorkerStep : IInstallStep +{ + public const string TaskName = "ClaudeDoWorker"; + + public string Name => "Start Worker"; + + public async Task ExecuteAsync(InstallContext ctx, IProgress progress, CancellationToken ct) + { + progress.Report("Starting worker..."); + var (exit, output) = await ProcessRunner.RunAsync("schtasks.exe", $"/Run /TN \"{TaskName}\"", null, progress, ct); + if (exit != 0) + return StepResult.Fail($"schtasks /Run failed (exit {exit}): {output}"); + return StepResult.Ok(); + } +} +``` + +- [ ] **Step 3:** Delete `src/ClaudeDo.Installer/Steps/StopServiceStep.cs`, `StartServiceStep.cs`, `RegisterServiceStep.cs`. + +- [ ] **Step 4:** Grep for remaining references: `StopServiceStep`, `StartServiceStep`, `RegisterServiceStep` across `src/` — fix each (Tasks 6-9 cover them). + +--- + +## Task 6: InstallContext + ServicePage cleanup + +**Files:** Modify `src/ClaudeDo.Installer/Core/InstallContext.cs`, `Pages/ServicePage/ServicePageViewModel.cs`, `Pages/ServicePage/ServicePageView.xaml` + +- [ ] **Step 1:** In `InstallContext.cs` remove the `ServiceAccount` property (keep `AutoStart`, `RestartDelayMs`, `SignalRPort`, `ClaudeBin`, etc.). + +- [ ] **Step 2:** In `ServicePageViewModel.cs` remove `IsLocalSystem`/`IsCurrentUser` `[ObservableProperty]` fields and the `_context.ServiceAccount = ...` line in `ApplyAsync`. Keep port/claudeBin/autostart/restartDelay. + +- [ ] **Step 3:** In `ServicePageView.xaml` remove the radio buttons / account-selection UI bound to those properties. Leave the rest. + +- [ ] **Step 4:** Build: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` — Expected: succeeds after Tasks 7-9. + +--- + +## Task 7: Pipeline wiring + DI + +**Files:** Modify `Pages/InstallPage/InstallPageViewModel.cs`, `App.xaml.cs` + +- [ ] **Step 1:** In `InstallPageViewModel.LoadAsync`, update the **Update** display steps to: + +```csharp +Steps.Add(new StepViewModel("Stop Worker")); +Steps.Add(new StepViewModel("Download and Extract")); +Steps.Add(new StepViewModel("Register Autostart")); +Steps.Add(new StepViewModel("Start Worker")); +Steps.Add(new StepViewModel("Write Install Manifest")); +Steps.Add(new StepViewModel("Register in Add/Remove Programs")); +``` + +And the **Fresh** display steps to: + +```csharp +Steps.Add(new StepViewModel("Download and Extract")); +Steps.Add(new StepViewModel("Write Configuration")); +Steps.Add(new StepViewModel("Initialize Database")); +Steps.Add(new StepViewModel("Register Autostart")); +Steps.Add(new StepViewModel("Create Shortcuts")); +Steps.Add(new StepViewModel("Register in Add/Remove Programs")); +Steps.Add(new StepViewModel("Write Install Manifest")); +Steps.Add(new StepViewModel("Start Worker")); +``` + +- [ ] **Step 2:** In `RunInstallAsync`, set the Update execution list to: + +```csharp +steps = new IInstallStep[] +{ + _serviceProvider.GetRequiredService(), + _serviceProvider.GetRequiredService(), + _serviceProvider.GetRequiredService(), + _serviceProvider.GetRequiredService(), + _serviceProvider.GetRequiredService(), + _serviceProvider.GetRequiredService(), +}; +``` + +- [ ] **Step 3:** In `App.xaml.cs` `BuildServices`, replace the service-step registrations. Fresh-install `IInstallStep` order must be: Download, WriteConfig, InitDatabase, **RegisterAutostart**, CreateShortcuts, WriteUninstallRegistry, WriteInstallManifest, **StartWorker**. Register: + +```csharp +sc.AddSingleton(); +sc.AddSingleton(sp => sp.GetRequiredService()); +sc.AddSingleton(); +sc.AddSingleton(); +sc.AddSingleton(); +sc.AddSingleton(sp => sp.GetRequiredService()); +sc.AddSingleton(); +sc.AddSingleton(); +sc.AddSingleton(sp => sp.GetRequiredService()); +sc.AddSingleton(); +sc.AddSingleton(sp => sp.GetRequiredService()); +sc.AddSingleton(); +sc.AddSingleton(sp => sp.GetRequiredService()); + +// Not part of the default fresh IEnumerable — pulled individually. +sc.AddSingleton(); +``` + +Remove old `StopServiceStep`/`StartServiceStep`/`RegisterServiceStep` registrations. + +- [ ] **Step 4:** Build: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` — Expected: succeeds after Tasks 8-9. + +--- + +## Task 8: SettingsViewModel + UninstallRunner + +**Files:** Modify `Views/SettingsViewModel.cs`, `Core/UninstallRunner.cs` + +- [ ] **Step 1:** In `SettingsViewModel.cs`, change ctor params/fields `StopServiceStep`/`StartServiceStep` → `StopWorkerStep`/`StartWorkerStep` (rename type usages only; the Save/Repair logic stays). Update the `Repair` step array to `{ _stopWorker, _downloadStep, _startWorker }`. + +- [ ] **Step 2:** In `UninstallRunner.cs`: + - Constructor param `StopServiceStep` → `StopWorkerStep` (field too). + - Replace `sc.exe delete ClaudeDoWorker` with task removal + legacy service cleanup: + +```csharp +// 3) Unregister autostart task + remove any legacy service. +progress.Report("Removing autostart task..."); +await ProcessRunner.RunAsync("schtasks.exe", $"/Delete /TN \"{StopWorkerStep.TaskName}\" /F", null, progress, ct); +await ProcessRunner.RunAsync("sc.exe", "delete ClaudeDoWorker", null, progress, ct); // legacy, best-effort +``` + + - The existing `_stopService.ExecuteAsync` call becomes `_stopWorker.ExecuteAsync` (kills the worker process before deleting files). + +- [ ] **Step 3:** Build: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` — Expected: **succeeds, 0 errors**. + +- [ ] **Step 4:** Run installer tests: `dotnet test tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj` — Expected: all pass (incl. new `ScheduledTaskXmlTests`). + +--- + +## Task 9: App WorkerLocator (TDD) + +**Files:** Create `src/ClaudeDo.Ui/Services/WorkerLocator.cs`, Test `tests/ClaudeDo.Ui.Tests/Services/WorkerLocatorTests.cs` + +- [ ] **Step 1: Write failing test:** + +```csharp +using ClaudeDo.Ui.Services; +using Xunit; + +namespace ClaudeDo.Ui.Tests.Services; + +public class WorkerLocatorTests +{ + [Fact] + public void FindByWalkingUp_FindsWorkerExeBesideInstallJson() + { + var root = Path.Combine(Path.GetTempPath(), "claudedo_wl_" + Guid.NewGuid().ToString("N")); + var appDir = Path.Combine(root, "app"); + var workerDir = Path.Combine(root, "worker"); + Directory.CreateDirectory(appDir); + Directory.CreateDirectory(workerDir); + File.WriteAllText(Path.Combine(root, "install.json"), "{}"); + var exe = Path.Combine(workerDir, "ClaudeDo.Worker.exe"); + File.WriteAllText(exe, ""); + + try + { + var found = new WorkerLocator().FindByWalkingUp(appDir); + Assert.Equal(exe, found); + } + finally { Directory.Delete(root, recursive: true); } + } + + [Fact] + public void FindByWalkingUp_ReturnsNullWhenNoManifest() + { + var dir = Path.Combine(Path.GetTempPath(), "claudedo_wl_none_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try { Assert.Null(new WorkerLocator().FindByWalkingUp(dir)); } + finally { Directory.Delete(dir, recursive: true); } + } +} +``` + +- [ ] **Step 2: Run, verify fail:** `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj --filter WorkerLocatorTests` — Expected: FAIL. + +- [ ] **Step 3: Implement** (mirror `InstallerLocator`): + +```csharp +namespace ClaudeDo.Ui.Services; + +public sealed class WorkerLocator +{ + private const string InstallJson = "install.json"; + private const string WorkerExe = "ClaudeDo.Worker.exe"; + private const string WorkerSubdir = "worker"; + + public string? Find() + => FindByWalkingUp(AppContext.BaseDirectory) + ?? (OperatingSystem.IsWindows() ? FindByRegistry() : null); + + public string? FindByWalkingUp(string startDir) + { + var dir = new DirectoryInfo(startDir); + while (dir is not null) + { + if (File.Exists(Path.Combine(dir.FullName, InstallJson))) + { + var candidate = Path.Combine(dir.FullName, WorkerSubdir, WorkerExe); + return File.Exists(candidate) ? candidate : null; + } + dir = dir.Parent; + } + return null; + } + + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + public string? FindByRegistry() + { + if (!OperatingSystem.IsWindows()) return null; + try + { + using var key = Microsoft.Win32.Registry.LocalMachine + .OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo"); + var location = key?.GetValue("InstallLocation") as string; + if (string.IsNullOrEmpty(location)) return null; + var candidate = Path.Combine(location, WorkerSubdir, WorkerExe); + return File.Exists(candidate) ? candidate : null; + } + catch { return null; } + } +} +``` + +- [ ] **Step 4: Run, verify pass.** + +--- + +## Task 10: App restart-worker + ensure-running + +**Files:** Modify `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`, `src/ClaudeDo.App/Program.cs` + +- [ ] **Step 1:** In `App/Program.cs` register the locator: `sc.AddSingleton();` and ensure `IslandsShellViewModel` receives it (constructor injection; the VM is `AddSingleton()` so DI supplies it). + +- [ ] **Step 2:** In `IslandsShellViewModel`, add a `WorkerLocator` constructor dependency and store it. Replace `RestartWorkerService` (the `ServiceController` version) with a process relaunch: + +```csharp +private void RestartWorkerService() +{ + var exe = _workerLocator.Find(); + if (exe is null) throw new InvalidOperationException("Worker executable not found."); + + foreach (var p in System.Diagnostics.Process.GetProcessesByName("ClaudeDo.Worker")) + { + try { p.Kill(entireProcessTree: true); p.WaitForExit(10000); } + catch { /* may have exited */ } + finally { p.Dispose(); } + } + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true }); +} +``` + +Update `RestartWorkerAsync` messages accordingly (drop the "service not installed" `InvalidOperationException` branch wording → generic failure). + +- [ ] **Step 3:** Add ensure-running on startup. After the VM wires up the worker connection, schedule a one-shot check: + +```csharp +private bool _ensureRunningAttempted; + +private async Task EnsureWorkerRunningAsync() +{ + if (_ensureRunningAttempted) return; + _ensureRunningAttempted = true; + await Task.Delay(TimeSpan.FromSeconds(4)); + if (_worker.IsConnected) return; + var exe = _workerLocator.Find(); + if (exe is null) return; + try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true }); } + catch { /* logon task is the primary mechanism; this is a convenience */ } +} +``` + +Call `_ = EnsureWorkerRunningAsync();` from the VM's existing init path (where the connection is started). Use the actual `WorkerClient` field name and its `IsConnected` member. + +- [ ] **Step 4:** Remove `using System.ServiceProcess;` and the `ServiceController` usage. Remove the `System.ServiceProcess.ServiceProcess` package reference from `ClaudeDo.Ui.csproj` if present and now unused. + +- [ ] **Step 5:** Build: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj` — Expected: succeeds. + +- [ ] **Step 6:** Run UI tests: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj` — Expected: all pass (incl. `WorkerLocatorTests`). If `IslandsShellViewModel` construction is exercised in a test, supply a `WorkerLocator` instance. + +--- + +## Task 11: Full build + test sweep + +- [ ] **Step 1:** Build each project: +```bash +dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj +dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj +dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj +``` +Expected: all succeed, 0 errors. + +- [ ] **Step 2:** Run all test projects: +```bash +dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj +dotnet test tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj +dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj +dotnet test tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj +``` +Expected: all pass. + +- [ ] **Step 3:** Grep for leftovers: `ServiceController`, `UseWindowsService`, `RegisterServiceStep`, `StopServiceStep`, `StartServiceStep`, `ServiceAccount` in `src/` — Expected: no matches (except the legacy `sc delete ClaudeDoWorker` migration/cleanup strings). + +--- + +## Notes for the implementer +- Worker config property for the log directory: confirm the exact name on `WorkerConfig` (spec assumes `LogRoot`). Use the real one. +- `ProcessRunner.RunAsync` signature is `(string file, string args, string? workingDir, IProgress progress, CancellationToken ct)` returning `(int ExitCode, string Output)` — match existing call sites. +- Keep the legacy `sc delete ClaudeDoWorker` calls (migration + uninstall) so existing service installs are cleaned up. diff --git a/docs/superpowers/specs/2026-05-29-worker-per-user-autostart-design.md b/docs/superpowers/specs/2026-05-29-worker-per-user-autostart-design.md new file mode 100644 index 0000000..54a3b68 --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-worker-per-user-autostart-design.md @@ -0,0 +1,165 @@ +# Worker per-user autostart (drop Windows service) + +Status: approved 2026-05-29 +Author: brainstorm session (mika kuns + Claude) + +## Problem + +The worker runs as a Windows **service** registered under `LocalSystem`. The worker +shells out to the `claude` CLI, whose authentication is stored per-user +(`%USERPROFILE%\.claude`). Under `LocalSystem` the worker uses the system profile and +cannot see the user's Claude login, so task execution fails. The installer even exposes a +"Current User" service-account radio that the backend rejects (`RegisterServiceStep` +fails the install). Net effect: the only installable configuration cannot authenticate +Claude. + +## Goal + +Run the worker as the logged-in **user** so it inherits the user's Claude auth, starting +automatically at logon and staying alive in the background (independent of the desktop +app, so Prime/scheduled tasks fire when the UI is closed). + +## Decisions (locked) + +1. **Lifetime:** background from logon, always — independent of the UI. +2. **Mechanism:** per-user **logon Scheduled Task** (`schtasks`), run only when the user is + logged on (no stored password), hidden, with restart-on-failure. +3. **No console window:** worker becomes `WinExe`; add a **Serilog rolling file sink** so + worker diagnostics aren't lost. +4. **App ensures running:** "Restart Worker" becomes process-based; on app startup, if + SignalR doesn't connect within a few seconds, the app launches the worker. +5. **Auto-migrate:** the installer detects and removes the old `ClaudeDoWorker` service, + then registers the task. Uninstall removes the task + kills the worker process. + +## Non-goals + +- Cross-account elevation (admin elevates as a *different* account than the interactive + user). Single-user / user-is-admin is assumed; the task targets the interactive user. +- Running the worker when no user is logged on (that's the whole point — it must be a user + session for Claude auth). + +--- + +## Component changes + +### ClaudeDo.Worker + +- **`ClaudeDo.Worker.csproj`**: `WinExe`. Add packages + `Serilog.AspNetCore` and `Serilog.Sinks.File`. +- **`Program.cs`**: + - Remove `builder.Host.UseWindowsService(...)`. + - Configure Serilog file sink: path `/worker-.log`, `rollingInterval: Day`, + `retainedFileCountLimit: 7`, shared write. `LogRoot` comes from `WorkerConfig` + (expand `~`). Wire via `builder.Host.UseSerilog(...)`. + - **Single-instance guard:** at startup create `new Mutex(true, @"Local\ClaudeDoWorker", + out var createdNew)`. If `!createdNew`, log "another worker instance is already + running" and exit 0. Hold the mutex for process lifetime. `Local\` namespace = per + user session, which is what we want. +- CLI preflight (`ClaudeCliPreflight`) behavior unchanged. + +### ClaudeDo.Installer + +- **New `Steps/RegisterAutostartStep.cs`** (`IInstallStep`, "Register Autostart"): + - Build a Task Scheduler **definition XML** (UTF-16) and register via + `schtasks /Create /TN "ClaudeDoWorker" /XML "" /F`. + - XML shape: + - `Principals/Principal`: `UserId` = current interactive user + (`WindowsIdentity.GetCurrent().Name`), `LogonType=InteractiveToken`, + `RunLevel=LeastPrivilege`. + - `Triggers/LogonTrigger` with the same `UserId`. + - `Settings`: `Hidden=true`, `MultipleInstancesPolicy=IgnoreNew`, + `StartWhenAvailable=true`, `ExecutionTimeLimit=PT0S`, + `DisallowStartIfOnBatteries=false`, `StopIfGoingOnBatteries=false`, + `RestartOnFailure` with `Interval` (>= `PT1M`; Task Scheduler's minimum granularity + is one minute) and `Count=3`. + - `Actions/Exec/Command`: quoted path to `/worker/ClaudeDo.Worker.exe`. + - The XML builder is a **pure function** (string in → XML string out) so it is unit + testable without admin. +- **`MigrateServiceStep`** (or folded into `RegisterAutostartStep` as a first phase): + detect the old service via `sc query ClaudeDoWorker`; if present, `sc stop` then + `sc delete` (poll for clearance like the old `RegisterServiceStep` did). No-op when the + service doesn't exist (fresh installs). +- **Rename `StopServiceStep` → `StopWorkerStep`, `StartServiceStep` → `StartWorkerStep`**, + reworked to be process/task based: + - Stop: `schtasks /End /TN ClaudeDoWorker` (ignore errors) + kill any + `ClaudeDo.Worker` process whose `MainModule.FileName` is under the install dir; + wait for exit. This unlocks `worker/` binaries before extract. + - Start: `schtasks /Run /TN ClaudeDoWorker` (preferred — launches as the task principal). + Used by fresh install (so the worker runs immediately rather than waiting for next + logon) and by Settings "restart". +- **`Pages/ServicePage/ServicePageViewModel.cs`**: remove `IsLocalSystem`/`IsCurrentUser` + radios and `ServiceAccount` usage. Keep SignalR port, Claude CLI path, "Start at logon" + toggle (`AutoStart`), restart delay (maps to task `RestartOnFailure/Interval`, clamped + to >= 1 min). Update `ServicePageView.xaml` accordingly. Remove `ServiceAccount` from + `InstallContext`. +- **`RegisterServiceStep.cs`**: deleted (replaced by `RegisterAutostartStep`). +- **Pipelines (`InstallPageViewModel`)**: + - Fresh: DownloadAndExtract → WriteConfig → InitDatabase → **RegisterAutostart** (incl. + migration no-op) → CreateShortcuts → WriteUninstallRegistry → WriteInstallManifest → + **StartWorker**. + - Update: **StopWorker** → DownloadAndExtract → **RegisterAutostart** (migrates old + service) → **StartWorker** → WriteInstallManifest → WriteUninstallRegistry. +- **DI (`App.xaml.cs`)**: register the renamed/new steps (concrete + `IInstallStep` where + needed, following the existing double-registration pattern). +- **`Core/UninstallRunner.cs`**: replace `sc delete ClaudeDoWorker` with + `schtasks /Delete /TN ClaudeDoWorker /F` and kill the worker process; also `sc delete` + the legacy service best-effort (in case an old service still lingers). + +### ClaudeDo.Ui / ClaudeDo.App + +- **New `Services/WorkerLocator.cs`**: resolve `/worker/ClaudeDo.Worker.exe` + by walking up for `install.json` then registry `InstallLocation` (mirrors + `InstallerLocator`). +- **`ViewModels/IslandsShellViewModel.cs`**: + - `RestartWorkerService`: drop `System.ServiceProcess.ServiceController`. Kill worker + process(es) under the install dir, then `Process.Start(workerExe)`. + - **Ensure-running:** on startup, if the `WorkerClient` connection isn't established + within ~4s, launch the worker via `WorkerLocator` + `Process.Start`. Guarded so it + runs at most once per app session. +- Remove the `System.ServiceProcess` package reference / usings if no longer used. + +--- + +## Data flow + +- **Logon:** Task Scheduler starts `ClaudeDo.Worker.exe` in the user session → mutex + acquired → Serilog file logging → SignalR hub on `127.0.0.1:47821` → app connects. +- **App start with worker down:** app waits ~4s for SignalR; if absent, `Process.Start` + worker → mutex acquired → hub up → app connects. +- **Duplicate launch (task + app race):** second instance fails the mutex → logs → exits 0. +- **Restart Worker button:** kill worker proc → relaunch → mutex reacquired. + +## Error handling + +- `schtasks`/`sc` calls go through the existing `ProcessRunner`; non-zero exits surface as + `StepResult.Fail` with the captured output (except best-effort cleanup which is ignored). +- Worker single-instance: losing the mutex is a normal, non-error exit (code 0). +- App ensure-running: `Process.Start` failures are swallowed (the logon task is the primary + mechanism; the app launch is a convenience). + +## Testing + +- **Unit (no admin required):** + - Task-definition XML builder: asserts UserId, LogonType, Hidden, RestartOnFailure + interval clamping, quoted command path. + - `WorkerLocator`: path resolution via temp `install.json`. + - Migration decision: given `sc query` output (exists / not-found), decide stop+delete vs + no-op — keep the decision pure, mock `ProcessRunner` output. + - Restart-delay → task interval clamping (`< 1 min` → `PT1M`). +- **Manual verification (post-build, on this machine):** + 1. Update from installed `1.0.2-alpha`: old service is removed (`sc query ClaudeDoWorker` + → not found), task exists (`schtasks /Query /TN ClaudeDoWorker`), worker process runs + as the user, app connects, no console window. + 2. Worker log file appears at `~/.todo-app/logs/worker-.log`. + 3. Kill worker → click Restart Worker in app → reconnects. + 4. Close app, confirm worker still running (Prime/queue alive); reopen app → connects. + 5. Log off / log on → worker autostarts. + 6. Uninstall → task gone, worker process gone, (data kept unless opted out). + +## Risks + +- **Task restart granularity is minutes**, not the old seconds-level service restart. The + worker's own long-running resilience + the app ensure-running cover short gaps; acceptable. +- **Elevated installer must target the interactive user.** Using + `WindowsIdentity.GetCurrent().Name` is correct when the user elevates themselves (the + assumed single-user case). Documented non-goal otherwise. diff --git a/src/ClaudeDo.App/Program.cs b/src/ClaudeDo.App/Program.cs index 91e2052..e63687b 100644 --- a/src/ClaudeDo.App/Program.cs +++ b/src/ClaudeDo.App/Program.cs @@ -83,6 +83,7 @@ sealed class Program sc.AddSingleton(_ => new HttpClient { Timeout = TimeSpan.FromSeconds(10) }); sc.AddSingleton(sp => new ReleaseClient(sp.GetRequiredService())); sc.AddSingleton(); + sc.AddSingleton(); sc.AddSingleton(sp => { var releases = sp.GetRequiredService(); diff --git a/src/ClaudeDo.Installer/App.xaml.cs b/src/ClaudeDo.Installer/App.xaml.cs index 33e0645..424d658 100644 --- a/src/ClaudeDo.Installer/App.xaml.cs +++ b/src/ClaudeDo.Installer/App.xaml.cs @@ -203,24 +203,26 @@ public partial class App : Application sc.AddSingleton(); // Steps — execution order matters for the FreshInstall pipeline (IEnumerable). - // Double-registered as both IInstallStep and concrete type so Task 15's Update pipeline + // Double-registered as both IInstallStep and concrete type so the Update pipeline // can pull them out individually via GetRequiredService(). sc.AddSingleton(); sc.AddSingleton(sp => sp.GetRequiredService()); sc.AddSingleton(); sc.AddSingleton(); - sc.AddSingleton(); - sc.AddSingleton(sp => sp.GetRequiredService()); + sc.AddSingleton(); + sc.AddSingleton(sp => sp.GetRequiredService()); sc.AddSingleton(); - sc.AddSingleton(); + sc.AddSingleton(); + sc.AddSingleton(sp => sp.GetRequiredService()); sc.AddSingleton(); sc.AddSingleton(sp => sp.GetRequiredService()); + // Start the worker last in the fresh pipeline (binaries + task must exist first). + sc.AddSingleton(); + sc.AddSingleton(sp => sp.GetRequiredService()); // Stop — NOT registered as IInstallStep (not part of default FreshInstall pipeline). // Pulled by Update flow + Repair/Uninstall. - sc.AddSingleton(); - // StartServiceStep is also registered as IInstallStep above (fresh-install pipeline). - sc.AddSingleton(); + sc.AddSingleton(); // Runners sc.AddSingleton(); diff --git a/src/ClaudeDo.Installer/Core/InstallContext.cs b/src/ClaudeDo.Installer/Core/InstallContext.cs index 2966b5b..e11f4fa 100644 --- a/src/ClaudeDo.Installer/Core/InstallContext.cs +++ b/src/ClaudeDo.Installer/Core/InstallContext.cs @@ -23,7 +23,6 @@ public sealed class InstallContext public int SignalRPort { get; set; } = 47_821; public int QueueBackstopIntervalMs { get; set; } = 30_000; public string ClaudeBin { get; set; } = "claude"; - public string ServiceAccount { get; set; } = "CurrentUser"; public bool AutoStart { get; set; } = true; public int RestartDelayMs { get; set; } = 5000; diff --git a/src/ClaudeDo.Installer/Core/ScheduledTaskXml.cs b/src/ClaudeDo.Installer/Core/ScheduledTaskXml.cs new file mode 100644 index 0000000..7d0cc80 --- /dev/null +++ b/src/ClaudeDo.Installer/Core/ScheduledTaskXml.cs @@ -0,0 +1,52 @@ +using System.Security; + +namespace ClaudeDo.Installer.Core; + +public static class ScheduledTaskXml +{ + public static string Build(string userId, string workerExePath, int restartIntervalMinutes) + { + var minutes = restartIntervalMinutes < 1 ? 1 : restartIntervalMinutes; + var user = SecurityElement.Escape(userId); + var cmd = SecurityElement.Escape(workerExePath); + return $""" + + + + ClaudeDo background worker (per-user). + + + + true + {user} + + + + + {user} + InteractiveToken + LeastPrivilege + + + + IgnoreNew + false + false + true + true + true + PT0S + + PT{minutes}M + 3 + + + + + {cmd} + + + + """; + } +} diff --git a/src/ClaudeDo.Installer/Core/UninstallRunner.cs b/src/ClaudeDo.Installer/Core/UninstallRunner.cs index a5f6364..eda0e5f 100644 --- a/src/ClaudeDo.Installer/Core/UninstallRunner.cs +++ b/src/ClaudeDo.Installer/Core/UninstallRunner.cs @@ -9,9 +9,9 @@ namespace ClaudeDo.Installer.Core; public sealed class UninstallRunner { private readonly InstallContext _context; - private readonly StopServiceStep _stopService; + private readonly StopWorkerStep _stopService; - public UninstallRunner(InstallContext context, StopServiceStep stopService) + public UninstallRunner(InstallContext context, StopWorkerStep stopService) { _context = context; _stopService = stopService; @@ -27,16 +27,17 @@ public sealed class UninstallRunner // 2) Stop service. If stop fails we MUST abort — deleting a service whose // process is still running leaves orphan locked binaries under the install dir // which Directory.Delete will silently skip. - progress.Report("Stopping worker service..."); + progress.Report("Stopping worker..."); var stopResult = await _stopService.ExecuteAsync(_context, progress, ct); if (!stopResult.Success) return StepResult.Fail( - $"Cannot uninstall: worker service did not stop cleanly. {stopResult.ErrorMessage} " + + $"Cannot uninstall: worker did not stop cleanly. {stopResult.ErrorMessage} " + "Kill the worker manually and re-run uninstall."); - // 3) Unregister service. - progress.Report("Unregistering service..."); - await ProcessRunner.RunAsync("sc.exe", $"delete {StopServiceStep.ServiceName}", null, progress, ct); + // 3) Unregister the autostart task, and best-effort remove any legacy service. + progress.Report("Removing autostart task..."); + await ProcessRunner.RunAsync("schtasks.exe", $"/Delete /TN \"{StopWorkerStep.TaskName}\" /F", null, progress, ct); + await ProcessRunner.RunAsync("sc.exe", "delete ClaudeDoWorker", null, progress, ct); // 3b) Remove Apps & Features registry entry (best-effort). progress.Report("Removing Add/Remove Programs entry..."); diff --git a/src/ClaudeDo.Installer/Pages/InstallPage/InstallPageViewModel.cs b/src/ClaudeDo.Installer/Pages/InstallPage/InstallPageViewModel.cs index e6618b9..f8fb57a 100644 --- a/src/ClaudeDo.Installer/Pages/InstallPage/InstallPageViewModel.cs +++ b/src/ClaudeDo.Installer/Pages/InstallPage/InstallPageViewModel.cs @@ -42,20 +42,23 @@ public partial class InstallPageViewModel : ObservableObject, IInstallerPage Steps.Clear(); if (_context.Mode == InstallerMode.Update) { - Steps.Add(new StepViewModel("Stop Worker Service")); + Steps.Add(new StepViewModel("Stop Worker")); Steps.Add(new StepViewModel("Download and Extract")); - Steps.Add(new StepViewModel("Start Worker Service")); + Steps.Add(new StepViewModel("Register Autostart")); + Steps.Add(new StepViewModel("Start Worker")); Steps.Add(new StepViewModel("Write Install Manifest")); + Steps.Add(new StepViewModel("Register in Add/Remove Programs")); } else { Steps.Add(new StepViewModel("Download and Extract")); Steps.Add(new StepViewModel("Write Configuration")); Steps.Add(new StepViewModel("Initialize Database")); - Steps.Add(new StepViewModel("Register Windows Service")); + Steps.Add(new StepViewModel("Register Autostart")); Steps.Add(new StepViewModel("Create Shortcuts")); Steps.Add(new StepViewModel("Register in Add/Remove Programs")); Steps.Add(new StepViewModel("Write Install Manifest")); + Steps.Add(new StepViewModel("Start Worker")); } return Task.CompletedTask; } @@ -116,10 +119,15 @@ public partial class InstallPageViewModel : ObservableObject, IInstallerPage { steps = new IInstallStep[] { - _serviceProvider.GetRequiredService(), + _serviceProvider.GetRequiredService(), _serviceProvider.GetRequiredService(), - _serviceProvider.GetRequiredService(), + // Migrates the legacy service away and (re)registers the logon task. + _serviceProvider.GetRequiredService(), + _serviceProvider.GetRequiredService(), _serviceProvider.GetRequiredService(), + // Refresh the bundled uninstaller exe + Add/Remove-Programs version so a + // manual update also renews the installer that bootstraps future updates. + _serviceProvider.GetRequiredService(), }; } else diff --git a/src/ClaudeDo.Installer/Pages/ServicePage/ServicePageView.xaml b/src/ClaudeDo.Installer/Pages/ServicePage/ServicePageView.xaml index 4edd763..c8f6ef4 100644 --- a/src/ClaudeDo.Installer/Pages/ServicePage/ServicePageView.xaml +++ b/src/ClaudeDo.Installer/Pages/ServicePage/ServicePageView.xaml @@ -9,8 +9,8 @@ - - + @@ -33,18 +33,11 @@ -