From 86012e02b9540decea023d68788ad8fe08acdac2 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 13:59:45 +0200 Subject: [PATCH 01/18] feat(releases): add empty ClaudeDo.Releases library --- ClaudeDo.slnx | 1 + src/ClaudeDo.Installer/ClaudeDo.Installer.csproj | 1 + src/ClaudeDo.Releases/ClaudeDo.Releases.csproj | 8 ++++++++ 3 files changed, 10 insertions(+) create mode 100644 src/ClaudeDo.Releases/ClaudeDo.Releases.csproj diff --git a/ClaudeDo.slnx b/ClaudeDo.slnx index 178690d..232244a 100644 --- a/ClaudeDo.slnx +++ b/ClaudeDo.slnx @@ -5,6 +5,7 @@ + diff --git a/src/ClaudeDo.Installer/ClaudeDo.Installer.csproj b/src/ClaudeDo.Installer/ClaudeDo.Installer.csproj index 139ba01..eb58cf3 100644 --- a/src/ClaudeDo.Installer/ClaudeDo.Installer.csproj +++ b/src/ClaudeDo.Installer/ClaudeDo.Installer.csproj @@ -45,6 +45,7 @@ + diff --git a/src/ClaudeDo.Releases/ClaudeDo.Releases.csproj b/src/ClaudeDo.Releases/ClaudeDo.Releases.csproj new file mode 100644 index 0000000..ad1ad4c --- /dev/null +++ b/src/ClaudeDo.Releases/ClaudeDo.Releases.csproj @@ -0,0 +1,8 @@ + + + net8.0 + enable + enable + latest + + From 41e0bea162d402e9c132ca717998db9d089df004 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 14:03:09 +0200 Subject: [PATCH 02/18] docs(superpowers): add worker-log footer implementation plan --- .../plans/2026-04-23-worker-log-footer.md | 718 ++++++++++++++++++ 1 file changed, 718 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-23-worker-log-footer.md diff --git a/docs/superpowers/plans/2026-04-23-worker-log-footer.md b/docs/superpowers/plans/2026-04-23-worker-log-footer.md new file mode 100644 index 0000000..361f094 --- /dev/null +++ b/docs/superpowers/plans/2026-04-23-worker-log-footer.md @@ -0,0 +1,718 @@ +# Worker Log Footer Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Surface important Worker lifecycle events in the UI footer as a single rotating, color-coded line that auto-hides after 30s of silence. + +**Architecture:** Add `WorkerLogLevel` enum in shared `ClaudeDo.Data` project. `HubBroadcaster` gets a `WorkerLog(message, level, timestampUtc)` SignalR event. Seven emit sites in `TaskRunner`, `TaskMergeService`, `TaskResetService` (callers of `WorktreeManager`, not WorktreeManager itself — they have the task title in scope). UI side: `WorkerClient` surfaces a `WorkerLogReceived` event; footer state lives on `IslandsShellViewModel` (existing root VM for `MainWindow`, also owns connection state); `System.Timers.Timer` clears the line after 30s; a `WorkerLogLevelToBrushConverter` maps level → brush in XAML. + +**Tech Stack:** .NET 8, ASP.NET Core SignalR, Avalonia 12, CommunityToolkit.Mvvm, xUnit. + +**Spec:** `docs/superpowers/specs/2026-04-23-worker-log-footer-design.md` + +**Deviation from spec:** Spec names `WorktreeManager.CreateAsync` / `DiscardAsync` as emit sites. In practice, `WorktreeManager` has only the task ID in scope; its callers (`TaskRunner`, `TaskResetService`) have the title. Emitting from callers avoids adding constructor dependencies to `WorktreeManager` and produces identical user-visible behavior. + +**Build note:** Per project convention, `dotnet build ClaudeDo.slnx` fails on .NET 8 — always build individual csprojs. + +--- + +### Task 1: Add `WorkerLogLevel` enum (shared contract) + +**Files:** +- Create: `src/ClaudeDo.Data/Models/WorkerLogLevel.cs` + +- [ ] **Step 1: Write the enum** + +```csharp +namespace ClaudeDo.Data.Models; + +public enum WorkerLogLevel +{ + Info, + Success, + Warn, + Error, +} +``` + +- [ ] **Step 2: Build Data project** + +Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj` +Expected: Build succeeded, 0 errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/ClaudeDo.Data/Models/WorkerLogLevel.cs +git commit -m "feat(data): add WorkerLogLevel enum" +``` + +--- + +### Task 2: Add `WorkerLog` broadcaster method + SignalR JSON enum-as-string + +**Files:** +- Modify: `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs` (append method) +- Modify: `src/ClaudeDo.Worker/Program.cs` (line ~23 — the `AddSignalR()` call) + +- [ ] **Step 1: Add enum-as-string serialization** + +Replace: +```csharp +builder.Services.AddSignalR(); +``` +with: +```csharp +builder.Services.AddSignalR().AddJsonProtocol(options => +{ + options.PayloadSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter()); +}); +``` + +- [ ] **Step 2: Add `WorkerLog` method to `HubBroadcaster`** + +Add the following method inside `HubBroadcaster` class (after the existing `RunCreated` method): + +```csharp +public Task WorkerLog(string message, WorkerLogLevel level, DateTime timestampUtc) => + _hub.Clients.All.SendAsync("WorkerLog", message, level, timestampUtc); +``` + +Add to the using block at top of file (if not already present): +```csharp +using ClaudeDo.Data.Models; +``` + +- [ ] **Step 3: Build** + +Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` +Expected: Build succeeded, 0 errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/ClaudeDo.Worker/Hub/HubBroadcaster.cs src/ClaudeDo.Worker/Program.cs +git commit -m "feat(worker): add WorkerLog SignalR event" +``` + +--- + +### Task 3: Emit `WorkerLog` from `TaskRunner` + +**Files:** +- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs` + +Four emit sites in this file: +1. **Created worktree** — right after `_wtManager.CreateAsync` succeeds (around line 69). +2. **Started Claude** — just before invoking Claude process. +3. **Committed changes** — after auto-commit (before the `WorktreeUpdated` broadcast around line 318). +4. **Finished** — at both success (line 330) and failure paths, mirroring the existing `TaskFinished` call. + +- [ ] **Step 1: Add using for `WorkerLogLevel`** + +Ensure `TaskRunner.cs` has at the top: +```csharp +using ClaudeDo.Data.Models; +``` + +- [ ] **Step 2: Emit "Created worktree"** + +After the line `wtCtx = await _wtManager.CreateAsync(task, list, ct);` (around line 69), add: +```csharp +await _broadcaster.WorkerLog($"Created worktree for \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow); +``` +(Place inside the same `if` branch that called `CreateAsync`, after the assignment.) + +- [ ] **Step 3: Emit "Started Claude"** + +Locate the point just before `ClaudeProcess` is invoked (search for where `ClaudeProcess` or `RunProcessAsync` is called). Just before the invocation, add: +```csharp +await _broadcaster.WorkerLog($"Started Claude for \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow); +``` + +- [ ] **Step 4: Emit "Committed changes"** + +Locate the auto-commit code path (around line 318, just before `await _broadcaster.WorktreeUpdated(task.Id);`). Add immediately before that call: +```csharp +await _broadcaster.WorkerLog($"Committed changes in \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow); +``` + +- [ ] **Step 5: Emit "Finished (done)"** + +Find the success finish path (around line 330, where `TaskFinished` is broadcast with status `"done"`). Add immediately before that call: +```csharp +await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (done)", WorkerLogLevel.Success, DateTime.UtcNow); +``` + +- [ ] **Step 6: Emit "Finished (failed)"** + +Find the failure path (search for `TaskFinished` with status `"failed"`). Add immediately before: +```csharp +await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (failed)", WorkerLogLevel.Error, DateTime.UtcNow); +``` + +- [ ] **Step 7: Build** + +Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` +Expected: Build succeeded, 0 errors. + +- [ ] **Step 8: Run existing Worker tests (no regressions)** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj` +Expected: All tests pass. + +- [ ] **Step 9: Commit** + +```bash +git add src/ClaudeDo.Worker/Runner/TaskRunner.cs +git commit -m "feat(worker): emit WorkerLog events from TaskRunner" +``` + +--- + +### Task 4: Emit `WorkerLog` from `TaskMergeService` and `TaskResetService` + +**Files:** +- Modify: `src/ClaudeDo.Worker/Services/TaskMergeService.cs` +- Modify: `src/ClaudeDo.Worker/Services/TaskResetService.cs` + +Both services already have `HubBroadcaster` injected (`_broadcaster`). Both already load the task entity (needed for title). + +- [ ] **Step 1: Add using in both files** + +Add to the top of each file (if not already present): +```csharp +using ClaudeDo.Data.Models; +``` + +- [ ] **Step 2: Emit "Merged" in `TaskMergeService.MergeAsync`** + +Locate the existing log line around line 137: +```csharp +_logger.LogInformation( + "Merged task {TaskId} branch {Branch} into {Target} (remove worktree: {Remove})", + ...); +``` + +Immediately after it (before `return new MergeResult(...)` on line 140), add: +```csharp +await _broadcaster.WorkerLog($"Merged \"{task.Title}\" into {targetBranch}", WorkerLogLevel.Success, DateTime.UtcNow); +``` +Use whatever variable names `task` and `targetBranch` are in scope — adjust to match the actual local names at that site. + +- [ ] **Step 3: Emit "Discarded" in `TaskResetService.ResetAsync`** + +Locate the call `await _wtManager.DiscardAsync(wt, list.WorkingDir, ct);` (line 53). Immediately after it, add: +```csharp +await _broadcaster.WorkerLog($"Discarded worktree for \"{task.Title}\"", WorkerLogLevel.Warn, DateTime.UtcNow); +``` + +- [ ] **Step 4: Emit "Reset" in `TaskResetService.ResetAsync`** + +Locate the existing line `_logger.LogInformation("Reset task {TaskId} to Manual ...` (line 66). Immediately after it, add: +```csharp +await _broadcaster.WorkerLog($"Reset \"{task.Title}\"", WorkerLogLevel.Warn, DateTime.UtcNow); +``` + +- [ ] **Step 5: Build** + +Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` +Expected: Build succeeded, 0 errors. + +- [ ] **Step 6: Run existing Worker tests** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj` +Expected: All tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add src/ClaudeDo.Worker/Services/TaskMergeService.cs src/ClaudeDo.Worker/Services/TaskResetService.cs +git commit -m "feat(worker): emit WorkerLog for merge, discard, reset" +``` + +--- + +### Task 5: Add `WorkerLogEntry` record + `WorkerLogReceived` event on `WorkerClient` + +**Files:** +- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs` + +- [ ] **Step 1: Add using for `WorkerLogLevel`** + +Add at the top of `WorkerClient.cs`: +```csharp +using ClaudeDo.Data.Models; +``` + +- [ ] **Step 2: Declare the `WorkerLogEntry` record** + +Add at the top of the file (above or below the `WorkerClient` class, same namespace): +```csharp +public sealed record WorkerLogEntry(string Message, WorkerLogLevel Level, DateTime TimestampUtc); +``` + +- [ ] **Step 3: Add the event field** + +Alongside the other `public event Action<...>?` declarations (around lines 42-48), add: +```csharp +public event Action? WorkerLogReceivedEvent; +``` + +- [ ] **Step 4: Register the SignalR handler** + +Alongside the other `_hub.On<...>` registrations (around lines 80-117), add: +```csharp +_hub.On("WorkerLog", (message, level, timestampUtc) => +{ + WorkerLogReceivedEvent?.Invoke(new WorkerLogEntry(message, level, timestampUtc)); +}); +``` + +- [ ] **Step 5: Build** + +Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj` +Expected: Build succeeded, 0 errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/ClaudeDo.Ui/Services/WorkerClient.cs +git commit -m "feat(ui): subscribe to WorkerLog SignalR event" +``` + +--- + +### Task 6: Create `ClaudeDo.Ui.Tests` project and add `WorkerLogLevelToBrushConverter` + +**Files:** +- Create: `src/ClaudeDo.Ui/Converters/WorkerLogLevelToBrushConverter.cs` +- Modify: `src/ClaudeDo.Ui/App.axaml` (register converter as resource) +- Create: `tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj` +- Create: `tests/ClaudeDo.Ui.Tests/WorkerLogLevelToBrushConverterTests.cs` + +- [ ] **Step 1: Write the converter** + +Create `src/ClaudeDo.Ui/Converters/WorkerLogLevelToBrushConverter.cs`: + +```csharp +using System; +using System.Globalization; +using Avalonia; +using Avalonia.Data.Converters; +using Avalonia.Media; +using ClaudeDo.Data.Models; + +namespace ClaudeDo.Ui.Converters; + +public sealed class WorkerLogLevelToBrushConverter : IValueConverter +{ + private static readonly IBrush SuccessBrush = new SolidColorBrush(Color.Parse("#4CAF50")); + private static readonly IBrush WarnBrush = new SolidColorBrush(Color.Parse("#FFA726")); + private static readonly IBrush ErrorBrush = new SolidColorBrush(Color.Parse("#EF5350")); + private static readonly IBrush InfoFallback = new SolidColorBrush(Color.Parse("#888888")); + + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is not WorkerLogLevel level) + return AvaloniaProperty.UnsetValue; + + return level switch + { + WorkerLogLevel.Success => SuccessBrush, + WorkerLogLevel.Warn => WarnBrush, + WorkerLogLevel.Error => ErrorBrush, + WorkerLogLevel.Info => ResolveInfoBrush(), + _ => AvaloniaProperty.UnsetValue, + }; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => + throw new NotSupportedException(); + + private static IBrush ResolveInfoBrush() + { + if (Application.Current is { } app && + app.Resources.TryGetResource("TextDimBrush", app.ActualThemeVariant, out var res) && + res is IBrush brush) + { + return brush; + } + return InfoFallback; + } +} +``` + +- [ ] **Step 2: Register converter in `App.axaml`** + +Open `src/ClaudeDo.Ui/App.axaml`. Inside the `` section (add one if missing), add alongside any existing converter entries: + +```xml + +``` + +Ensure the `xmlns:converters="using:ClaudeDo.Ui.Converters"` namespace is declared at the root `` element. If other converters (e.g. `StatusColorConverter`) are already resources in `App.axaml` follow the same pattern; if they're declared per-view, declare this converter at the top of `MainWindow.axaml` in Task 8 instead. + +- [ ] **Step 3: Create UI test project** + +Create `tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj`: + +```xml + + + net8.0 + enable + false + + + + + + + + + + + + +``` + +If the existing `tests/ClaudeDo.Worker.Tests/*.csproj` uses different `Microsoft.NET.Test.Sdk` / xUnit versions, match those versions exactly to avoid analyzer mismatches. + +- [ ] **Step 4: Write the failing test** + +Create `tests/ClaudeDo.Ui.Tests/WorkerLogLevelToBrushConverterTests.cs`: + +```csharp +using System.Globalization; +using Avalonia; +using Avalonia.Media; +using ClaudeDo.Data.Models; +using ClaudeDo.Ui.Converters; +using Xunit; + +namespace ClaudeDo.Ui.Tests; + +public class WorkerLogLevelToBrushConverterTests +{ + [Theory] + [InlineData(WorkerLogLevel.Success, "#FF4CAF50")] + [InlineData(WorkerLogLevel.Warn, "#FFFFA726")] + [InlineData(WorkerLogLevel.Error, "#FFEF5350")] + public void Convert_maps_level_to_expected_brush_color(WorkerLogLevel level, string expectedArgb) + { + var converter = new WorkerLogLevelToBrushConverter(); + + var result = converter.Convert(level, typeof(IBrush), null, CultureInfo.InvariantCulture); + + var solid = Assert.IsType(result); + Assert.Equal(expectedArgb.ToLowerInvariant(), $"#{solid.Color.ToUInt32():X8}".ToLowerInvariant()); + } + + [Fact] + public void Convert_info_returns_a_brush_fallback_when_no_app() + { + var converter = new WorkerLogLevelToBrushConverter(); + + var result = converter.Convert(WorkerLogLevel.Info, typeof(IBrush), null, CultureInfo.InvariantCulture); + + Assert.IsAssignableFrom(result); + } + + [Fact] + public void Convert_unknown_value_returns_unset() + { + var converter = new WorkerLogLevelToBrushConverter(); + + var result = converter.Convert("not a level", typeof(IBrush), null, CultureInfo.InvariantCulture); + + Assert.Equal(AvaloniaProperty.UnsetValue, result); + } +} +``` + +- [ ] **Step 5: Run the tests** + +Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj` +Expected: All 5 tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/ClaudeDo.Ui/Converters/WorkerLogLevelToBrushConverter.cs src/ClaudeDo.Ui/App.axaml tests/ClaudeDo.Ui.Tests/ +git commit -m "feat(ui): add WorkerLogLevelToBrushConverter with tests" +``` + +--- + +### Task 7: Add footer state + 30s auto-clear timer to `IslandsShellViewModel` + +**Files:** +- Modify: `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs` +- Create: `tests/ClaudeDo.Ui.Tests/IslandsShellViewModelWorkerLogTests.cs` + +Timer uses `System.Timers.Timer` (not `DispatcherTimer`) so unit tests don't need an Avalonia dispatcher. The elapsed callback marshals to the UI thread via `Dispatcher.UIThread.Post` when the dispatcher is available; in tests the VM logic under test sets properties directly so no marshalling is needed. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/ClaudeDo.Ui.Tests/IslandsShellViewModelWorkerLogTests.cs`: + +```csharp +using System; +using ClaudeDo.Data.Models; +using ClaudeDo.Ui.Services; +using ClaudeDo.Ui.ViewModels; +using Xunit; + +namespace ClaudeDo.Ui.Tests; + +public class IslandsShellViewModelWorkerLogTests +{ + private static IslandsShellViewModel NewVm() => + // The real constructor requires island VMs + WorkerClient. These tests + // only exercise the WorkerLog handling, so we use a test-only constructor + // that bypasses the sub-VMs. Add `internal IslandsShellViewModel()` for tests. + IslandsShellViewModel.CreateForTests(); + + [Fact] + public void Receiving_event_sets_text_level_and_visible() + { + var vm = NewVm(); + var at = new DateTime(2026, 4, 23, 14, 32, 0, DateTimeKind.Utc); + + vm.OnWorkerLogReceived(new WorkerLogEntry("Created worktree for \"X\"", WorkerLogLevel.Info, at)); + + Assert.True(vm.IsWorkerLogVisible); + Assert.Equal(WorkerLogLevel.Info, vm.WorkerLogLevel); + Assert.Contains("Created worktree for \"X\"", vm.WorkerLogText); + } + + [Fact] + public void Second_event_replaces_first() + { + var vm = NewVm(); + vm.OnWorkerLogReceived(new WorkerLogEntry("first", WorkerLogLevel.Info, DateTime.UtcNow)); + vm.OnWorkerLogReceived(new WorkerLogEntry("second", WorkerLogLevel.Success, DateTime.UtcNow)); + + Assert.Contains("second", vm.WorkerLogText); + Assert.Equal(WorkerLogLevel.Success, vm.WorkerLogLevel); + } + + [Fact] + public void ClearWorkerLog_hides_line() + { + var vm = NewVm(); + vm.OnWorkerLogReceived(new WorkerLogEntry("msg", WorkerLogLevel.Info, DateTime.UtcNow)); + + vm.ClearWorkerLog(); + + Assert.False(vm.IsWorkerLogVisible); + Assert.Null(vm.WorkerLogText); + } + + [Fact] + public void Text_is_formatted_as_HHmm_dot_message_local_time() + { + var vm = NewVm(); + var utc = new DateTime(2026, 4, 23, 12, 0, 0, DateTimeKind.Utc); + var expectedLocalHhmm = utc.ToLocalTime().ToString("HH:mm"); + + vm.OnWorkerLogReceived(new WorkerLogEntry("hello", WorkerLogLevel.Info, utc)); + + Assert.StartsWith(expectedLocalHhmm + " · ", vm.WorkerLogText); + } +} +``` + +- [ ] **Step 2: Run tests to confirm they fail to compile** + +Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj` +Expected: Build errors — `CreateForTests`, `OnWorkerLogReceived`, `ClearWorkerLog`, `IsWorkerLogVisible`, `WorkerLogText`, `WorkerLogLevel` do not yet exist. + +- [ ] **Step 3: Implement on `IslandsShellViewModel`** + +Open `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`. Add `using`s if missing: + +```csharp +using System.Timers; +using Avalonia.Threading; +using ClaudeDo.Data.Models; +using ClaudeDo.Ui.Services; +``` + +Inside the class, add: + +```csharp +[ObservableProperty] private string? workerLogText; +[ObservableProperty] private WorkerLogLevel workerLogLevel; +[ObservableProperty] private bool isWorkerLogVisible; + +private readonly Timer _workerLogTimer = new(TimeSpan.FromSeconds(30).TotalMilliseconds) +{ + AutoReset = false, +}; + +internal static IslandsShellViewModel CreateForTests() => + (IslandsShellViewModel)System.Runtime.Serialization.FormatterServices + .GetUninitializedObject(typeof(IslandsShellViewModel)); +``` + +(If `FormatterServices` is unavailable under `net8.0`, instead add a parameterless `internal IslandsShellViewModel() {}` constructor guarded for tests only.) + +In the existing real constructor, wire up subscription (after the line `Worker.PropertyChanged += ...` block, around line 63-70): + +```csharp +Worker.WorkerLogReceivedEvent += OnWorkerLogReceived; +_workerLogTimer.Elapsed += (_, _) => +{ + if (Dispatcher.UIThread.CheckAccess()) ClearWorkerLog(); + else Dispatcher.UIThread.Post(ClearWorkerLog); +}; +``` + +Add the methods the tests call: + +```csharp +public void OnWorkerLogReceived(WorkerLogEntry entry) +{ + var hhmm = entry.TimestampUtc.ToLocalTime().ToString("HH:mm"); + WorkerLogText = $"{hhmm} · {entry.Message}"; + WorkerLogLevel = entry.Level; + IsWorkerLogVisible = true; + + _workerLogTimer.Stop(); + _workerLogTimer.Start(); +} + +public void ClearWorkerLog() +{ + IsWorkerLogVisible = false; + WorkerLogText = null; +} +``` + +- [ ] **Step 4: Run tests** + +Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj` +Expected: All tests pass (5 converter + 4 VM = 9 tests). + +- [ ] **Step 5: Build the UI project as a sanity check** + +Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj` +Expected: Build succeeded. + +- [ ] **Step 6: Commit** + +```bash +git add src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs tests/ClaudeDo.Ui.Tests/IslandsShellViewModelWorkerLogTests.cs +git commit -m "feat(ui): add worker log state and 30s timer to shell VM" +``` + +--- + +### Task 8: Update `MainWindow.axaml` footer — dock log line right + +**Files:** +- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml` (lines 104-135 — the footer `Border`) + +- [ ] **Step 1: Add the converter resource to the window (if not already in App.axaml)** + +If Task 6 declared the converter in `App.axaml`, skip this step. Otherwise, add a `` block near the top of `MainWindow.axaml`: +```xml + + + +``` +Ensure `xmlns:converters="using:ClaudeDo.Ui.Converters"` is declared on the root ``. + +- [ ] **Step 2: Replace the footer body** + +Replace the existing footer `` inner contents (the `` at lines 109-134) with: + +```xml + + + + + + + + + + + + + + + +``` + +- [ ] **Step 3: Build the UI** + +Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj` +Expected: Build succeeded, 0 errors. No XAML compilation errors. + +- [ ] **Step 4: Build the full app (entry point)** + +Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj` +Expected: Build succeeded. + +- [ ] **Step 5: Manual smoke test** + +Start the Worker and the App (two separate processes per CLAUDE.md). + +Exercise each event and confirm the footer line appears with the expected color and copy: + +1. Start a task → expect `HH:MM · Created worktree for ""` (dim/info). +2. Observe while Claude runs → expect `HH:MM · Started Claude for "<title>"` (dim/info). +3. Task commits → expect `HH:MM · Committed changes in "<title>"` (dim/info). +4. Task finishes successfully → expect `HH:MM · Finished "<title>" (done)` (green). +5. Trigger a failing task → expect `HH:MM · Finished "<title>" (failed)` (red). +6. Reset a failed task → expect `HH:MM · Discarded worktree for "<title>"` (amber) followed by `HH:MM · Reset "<title>"` (amber). +7. Merge a completed task → expect `HH:MM · Merged "<title>" into <branch>` (green). +8. Wait 30s with no new events → footer log line disappears (connection pill remains). +9. Trigger a burst of 3 events in quick succession → only the most recent is shown; timer resets on each. +10. Long task title (≥60 chars) → line is ellipsized, connection pill on the left remains fully visible. + +- [ ] **Step 6: Commit** + +```bash +git add src/ClaudeDo.Ui/Views/MainWindow.axaml +git commit -m "feat(ui): show worker log line in footer" +``` + +--- + +## Self-Review + +- **Spec coverage:** + - Enum `WorkerLogLevel` in `ClaudeDo.Data` — Task 1 ✓ + - SignalR enum-as-string — Task 2 ✓ + - `HubBroadcaster.WorkerLog` — Task 2 ✓ + - 7 emit sites with correct level mapping — Tasks 3, 4 ✓ + - `WorkerClient.WorkerLogReceived` event + `WorkerLogEntry` record — Task 5 ✓ + - `WorkerLogLevelToBrushConverter` with unit tests — Task 6 ✓ + - Footer VM state + 30s timer + tests — Task 7 ✓ + - Footer XAML (DockPanel, connection left, log right, level-based color, ellipsis) — Task 8 ✓ + - Out-of-scope items (history drawer, filtering, persistence) — correctly omitted ✓ + +- **Placeholder scan:** No "TBD" / "handle edge cases" / "similar to Task N". All code is inline. + +- **Type consistency:** `WorkerLogEntry(Message, Level, TimestampUtc)` — same signature used in Task 5 (declaration), Task 7 (consumer tests + VM). `WorkerLog(message, level, timestampUtc)` — same signature in Task 2 (broadcaster) and Tasks 3-4 (callers). `OnWorkerLogReceived` / `ClearWorkerLog` / `IsWorkerLogVisible` / `WorkerLogText` / `WorkerLogLevel` — consistent between Task 7 test and Task 7 implementation. From 46e01aefed788eb946e34c00e4cfb8fe9ef1c21c Mon Sep 17 00:00:00 2001 From: mika kuns <mika.kuns@fb-tuning.de> Date: Thu, 23 Apr 2026 14:09:40 +0200 Subject: [PATCH 03/18] refactor(releases): move release-API + checksum types to ClaudeDo.Releases --- src/ClaudeDo.Installer/App.xaml.cs | 1 + src/ClaudeDo.Installer/Core/InstallModeDetector.cs | 2 ++ src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs | 1 + src/ClaudeDo.Installer/Views/SettingsViewModel.cs | 1 + .../Core => ClaudeDo.Releases}/ChecksumVerifier.cs | 2 +- .../Core => ClaudeDo.Releases}/IReleaseClient.cs | 2 +- .../Core => ClaudeDo.Releases}/ReleaseClient.cs | 2 +- 7 files changed, 8 insertions(+), 3 deletions(-) rename src/{ClaudeDo.Installer/Core => ClaudeDo.Releases}/ChecksumVerifier.cs (97%) rename src/{ClaudeDo.Installer/Core => ClaudeDo.Releases}/IReleaseClient.cs (92%) rename src/{ClaudeDo.Installer/Core => ClaudeDo.Releases}/ReleaseClient.cs (98%) diff --git a/src/ClaudeDo.Installer/App.xaml.cs b/src/ClaudeDo.Installer/App.xaml.cs index fd282fb..bb06f73 100644 --- a/src/ClaudeDo.Installer/App.xaml.cs +++ b/src/ClaudeDo.Installer/App.xaml.cs @@ -2,6 +2,7 @@ using System.Net.Http; using System.Reflection; using System.Windows; using ClaudeDo.Installer.Core; +using ClaudeDo.Releases; using ClaudeDo.Installer.Pages.InstallPage; using ClaudeDo.Installer.Pages.PathsPage; using ClaudeDo.Installer.Pages.ServicePage; diff --git a/src/ClaudeDo.Installer/Core/InstallModeDetector.cs b/src/ClaudeDo.Installer/Core/InstallModeDetector.cs index 574d300..1ddec3d 100644 --- a/src/ClaudeDo.Installer/Core/InstallModeDetector.cs +++ b/src/ClaudeDo.Installer/Core/InstallModeDetector.cs @@ -1,3 +1,5 @@ +using ClaudeDo.Releases; + namespace ClaudeDo.Installer.Core; public sealed record DetectedState( diff --git a/src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs b/src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs index aa97f78..515c54d 100644 --- a/src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs +++ b/src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs @@ -1,6 +1,7 @@ using System.IO; using System.IO.Compression; using ClaudeDo.Installer.Core; +using ClaudeDo.Releases; namespace ClaudeDo.Installer.Steps; diff --git a/src/ClaudeDo.Installer/Views/SettingsViewModel.cs b/src/ClaudeDo.Installer/Views/SettingsViewModel.cs index 60afb58..f79119d 100644 --- a/src/ClaudeDo.Installer/Views/SettingsViewModel.cs +++ b/src/ClaudeDo.Installer/Views/SettingsViewModel.cs @@ -1,5 +1,6 @@ using System.Windows; using ClaudeDo.Installer.Core; +using ClaudeDo.Releases; using ClaudeDo.Installer.Steps; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; diff --git a/src/ClaudeDo.Installer/Core/ChecksumVerifier.cs b/src/ClaudeDo.Releases/ChecksumVerifier.cs similarity index 97% rename from src/ClaudeDo.Installer/Core/ChecksumVerifier.cs rename to src/ClaudeDo.Releases/ChecksumVerifier.cs index b2835a1..d94955c 100644 --- a/src/ClaudeDo.Installer/Core/ChecksumVerifier.cs +++ b/src/ClaudeDo.Releases/ChecksumVerifier.cs @@ -1,7 +1,7 @@ using System.IO; using System.Security.Cryptography; -namespace ClaudeDo.Installer.Core; +namespace ClaudeDo.Releases; public static class ChecksumVerifier { diff --git a/src/ClaudeDo.Installer/Core/IReleaseClient.cs b/src/ClaudeDo.Releases/IReleaseClient.cs similarity index 92% rename from src/ClaudeDo.Installer/Core/IReleaseClient.cs rename to src/ClaudeDo.Releases/IReleaseClient.cs index 8c82449..96dd10b 100644 --- a/src/ClaudeDo.Installer/Core/IReleaseClient.cs +++ b/src/ClaudeDo.Releases/IReleaseClient.cs @@ -1,4 +1,4 @@ -namespace ClaudeDo.Installer.Core; +namespace ClaudeDo.Releases; public sealed record ReleaseAsset(string Name, string BrowserDownloadUrl, long Size); diff --git a/src/ClaudeDo.Installer/Core/ReleaseClient.cs b/src/ClaudeDo.Releases/ReleaseClient.cs similarity index 98% rename from src/ClaudeDo.Installer/Core/ReleaseClient.cs rename to src/ClaudeDo.Releases/ReleaseClient.cs index 78550dd..160845d 100644 --- a/src/ClaudeDo.Installer/Core/ReleaseClient.cs +++ b/src/ClaudeDo.Releases/ReleaseClient.cs @@ -2,7 +2,7 @@ using System.IO; using System.Net.Http; using System.Text.Json; -namespace ClaudeDo.Installer.Core; +namespace ClaudeDo.Releases; public sealed class ReleaseClient : IReleaseClient { From 27054e67159b96f2537865a95c0cefbb9297ce79 Mon Sep 17 00:00:00 2001 From: mika kuns <mika.kuns@fb-tuning.de> Date: Thu, 23 Apr 2026 14:15:53 +0200 Subject: [PATCH 04/18] chore: ignore .worktrees/ for local dev worktrees --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 13cbffe..9fadc0a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Local dev worktrees (created by using-git-worktrees skill) +.worktrees/ + # .NET build output bin/ obj/ From 5346737e2b9223a6b0e23d06a045b3110fab14f0 Mon Sep 17 00:00:00 2001 From: mika kuns <mika.kuns@fb-tuning.de> Date: Thu, 23 Apr 2026 14:18:17 +0200 Subject: [PATCH 05/18] test(releases): port ReleaseClient + ChecksumVerifier tests to new project Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- ClaudeDo.slnx | 1 + .../DownloadAndExtractStepTests.cs | 1 + .../InstallModeDetectorTests.cs | 1 + .../ChecksumVerifierTests.cs | 4 ++-- .../ClaudeDo.Releases.Tests.csproj | 20 +++++++++++++++++++ .../FakeHttpMessageHandler.cs | 2 +- .../ReleaseClientTests.cs | 4 ++-- 7 files changed, 28 insertions(+), 5 deletions(-) rename tests/{ClaudeDo.Installer.Tests => ClaudeDo.Releases.Tests}/ChecksumVerifierTests.cs (97%) create mode 100644 tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj rename tests/{ClaudeDo.Installer.Tests => ClaudeDo.Releases.Tests}/FakeHttpMessageHandler.cs (96%) rename tests/{ClaudeDo.Installer.Tests => ClaudeDo.Releases.Tests}/ReleaseClientTests.cs (98%) diff --git a/ClaudeDo.slnx b/ClaudeDo.slnx index 232244a..eb6dbf9 100644 --- a/ClaudeDo.slnx +++ b/ClaudeDo.slnx @@ -11,5 +11,6 @@ <Project Path="tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj" /> <Project Path="tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj" /> <Project Path="tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj" /> + <Project Path="tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj" /> </Folder> </Solution> diff --git a/tests/ClaudeDo.Installer.Tests/DownloadAndExtractStepTests.cs b/tests/ClaudeDo.Installer.Tests/DownloadAndExtractStepTests.cs index 888cfa0..cbba0df 100644 --- a/tests/ClaudeDo.Installer.Tests/DownloadAndExtractStepTests.cs +++ b/tests/ClaudeDo.Installer.Tests/DownloadAndExtractStepTests.cs @@ -2,6 +2,7 @@ using System.IO; using System.IO.Compression; using ClaudeDo.Installer.Core; using ClaudeDo.Installer.Steps; +using ClaudeDo.Releases; namespace ClaudeDo.Installer.Tests; diff --git a/tests/ClaudeDo.Installer.Tests/InstallModeDetectorTests.cs b/tests/ClaudeDo.Installer.Tests/InstallModeDetectorTests.cs index 223a0a7..6fce8e5 100644 --- a/tests/ClaudeDo.Installer.Tests/InstallModeDetectorTests.cs +++ b/tests/ClaudeDo.Installer.Tests/InstallModeDetectorTests.cs @@ -1,4 +1,5 @@ using ClaudeDo.Installer.Core; +using ClaudeDo.Releases; namespace ClaudeDo.Installer.Tests; diff --git a/tests/ClaudeDo.Installer.Tests/ChecksumVerifierTests.cs b/tests/ClaudeDo.Releases.Tests/ChecksumVerifierTests.cs similarity index 97% rename from tests/ClaudeDo.Installer.Tests/ChecksumVerifierTests.cs rename to tests/ClaudeDo.Releases.Tests/ChecksumVerifierTests.cs index d72a0d5..4023d9d 100644 --- a/tests/ClaudeDo.Installer.Tests/ChecksumVerifierTests.cs +++ b/tests/ClaudeDo.Releases.Tests/ChecksumVerifierTests.cs @@ -1,7 +1,7 @@ using System.IO; -using ClaudeDo.Installer.Core; +using ClaudeDo.Releases; -namespace ClaudeDo.Installer.Tests; +namespace ClaudeDo.Releases.Tests; public sealed class ChecksumVerifierTests : IDisposable { diff --git a/tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj b/tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj new file mode 100644 index 0000000..fb0bb98 --- /dev/null +++ b/tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj @@ -0,0 +1,20 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + </PropertyGroup> + <ItemGroup> + <Using Include="Xunit" /> + </ItemGroup> + <ItemGroup> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" /> + <PackageReference Include="xunit" Version="2.9.3" /> + <PackageReference Include="xunit.runner.visualstudio" Version="3.0.1" /> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="../../src/ClaudeDo.Releases/ClaudeDo.Releases.csproj" /> + </ItemGroup> +</Project> diff --git a/tests/ClaudeDo.Installer.Tests/FakeHttpMessageHandler.cs b/tests/ClaudeDo.Releases.Tests/FakeHttpMessageHandler.cs similarity index 96% rename from tests/ClaudeDo.Installer.Tests/FakeHttpMessageHandler.cs rename to tests/ClaudeDo.Releases.Tests/FakeHttpMessageHandler.cs index 960a8fb..dbd5260 100644 --- a/tests/ClaudeDo.Installer.Tests/FakeHttpMessageHandler.cs +++ b/tests/ClaudeDo.Releases.Tests/FakeHttpMessageHandler.cs @@ -1,7 +1,7 @@ using System.Net; using System.Net.Http; -namespace ClaudeDo.Installer.Tests; +namespace ClaudeDo.Releases.Tests; internal sealed class FakeHttpMessageHandler : HttpMessageHandler { diff --git a/tests/ClaudeDo.Installer.Tests/ReleaseClientTests.cs b/tests/ClaudeDo.Releases.Tests/ReleaseClientTests.cs similarity index 98% rename from tests/ClaudeDo.Installer.Tests/ReleaseClientTests.cs rename to tests/ClaudeDo.Releases.Tests/ReleaseClientTests.cs index 954e513..1faf924 100644 --- a/tests/ClaudeDo.Installer.Tests/ReleaseClientTests.cs +++ b/tests/ClaudeDo.Releases.Tests/ReleaseClientTests.cs @@ -1,8 +1,8 @@ using System.Net; using System.Net.Http; -using ClaudeDo.Installer.Core; +using ClaudeDo.Releases; -namespace ClaudeDo.Installer.Tests; +namespace ClaudeDo.Releases.Tests; public sealed class ReleaseClientTests { From 7c0f8d8408f077f1e3fc2f9d9fe372996dda3191 Mon Sep 17 00:00:00 2001 From: mika kuns <mika.kuns@fb-tuning.de> Date: Thu, 23 Apr 2026 14:21:25 +0200 Subject: [PATCH 06/18] feat(releases): add VersionComparer --- src/ClaudeDo.Releases/VersionComparer.cs | 18 +++++++++++ .../VersionComparerTests.cs | 30 +++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 src/ClaudeDo.Releases/VersionComparer.cs create mode 100644 tests/ClaudeDo.Releases.Tests/VersionComparerTests.cs diff --git a/src/ClaudeDo.Releases/VersionComparer.cs b/src/ClaudeDo.Releases/VersionComparer.cs new file mode 100644 index 0000000..e38d1fc --- /dev/null +++ b/src/ClaudeDo.Releases/VersionComparer.cs @@ -0,0 +1,18 @@ +namespace ClaudeDo.Releases; + +public readonly record struct VersionCompareResult(bool IsNewer, bool Unparseable); + +public static class VersionComparer +{ + public static VersionCompareResult Compare(string latest, string current) + { + var latestTrimmed = (latest ?? "").TrimStart('v', 'V'); + var currentTrimmed = (current ?? "").TrimStart('v', 'V'); + + var unparseable = !Version.TryParse(latestTrimmed, out var lv) + | !Version.TryParse(currentTrimmed, out var cv); + + if (unparseable) return new VersionCompareResult(false, true); + return new VersionCompareResult(lv > cv, false); + } +} diff --git a/tests/ClaudeDo.Releases.Tests/VersionComparerTests.cs b/tests/ClaudeDo.Releases.Tests/VersionComparerTests.cs new file mode 100644 index 0000000..5aba89f --- /dev/null +++ b/tests/ClaudeDo.Releases.Tests/VersionComparerTests.cs @@ -0,0 +1,30 @@ +namespace ClaudeDo.Releases.Tests; + +public class VersionComparerTests +{ + [Theory] + [InlineData("0.2.0", "0.1.0", true, false)] + [InlineData("0.2.0", "0.2.0", false, false)] + [InlineData("0.1.0", "0.2.0", false, false)] + [InlineData("v0.2.0", "0.1.0", true, false)] + [InlineData("0.2.0", "v0.1.0", true, false)] + [InlineData("1.0.0.0", "0.99.99.99", true, false)] + public void Compare_ParseableVersions(string latest, string current, bool expectedNewer, bool expectedUnparseable) + { + var result = VersionComparer.Compare(latest, current); + Assert.Equal(expectedNewer, result.IsNewer); + Assert.Equal(expectedUnparseable, result.Unparseable); + } + + [Theory] + [InlineData("0.2.0-beta", "0.1.0")] + [InlineData("0.2.0", "0.1.0-alpha")] + [InlineData("garbage", "0.1.0")] + [InlineData("", "0.1.0")] + public void Compare_UnparseableReturnsNotNewer(string latest, string current) + { + var result = VersionComparer.Compare(latest, current); + Assert.False(result.IsNewer); + Assert.True(result.Unparseable); + } +} From 5b4cdd366ee213805b0b7b17378b04e39391cf50 Mon Sep 17 00:00:00 2001 From: mika kuns <mika.kuns@fb-tuning.de> Date: Thu, 23 Apr 2026 14:36:45 +0200 Subject: [PATCH 07/18] refactor(installer): use shared VersionComparer in InstallModeDetector --- .../Core/InstallModeDetector.cs | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/src/ClaudeDo.Installer/Core/InstallModeDetector.cs b/src/ClaudeDo.Installer/Core/InstallModeDetector.cs index 1ddec3d..81fa819 100644 --- a/src/ClaudeDo.Installer/Core/InstallModeDetector.cs +++ b/src/ClaudeDo.Installer/Core/InstallModeDetector.cs @@ -33,7 +33,9 @@ public sealed class InstallModeDetector return new DetectedState(InstallerMode.Config, manifest, null, null); var latestVersion = release.TagName.TrimStart('v', 'V'); - var newer = IsNewer(latestVersion, manifest.Version, out var unparseable); + var cmp = VersionComparer.Compare(latestVersion, manifest.Version); + var newer = cmp.IsNewer; + var unparseable = cmp.Unparseable; if (newer) return new DetectedState(InstallerMode.Update, manifest, release, latestVersion); @@ -43,16 +45,4 @@ public sealed class InstallModeDetector }; } - /// <summary> - /// Returns true only when both versions parse as System.Version (major.minor[.build[.revision]]) - /// AND latest > current. Semver pre-release tags like "0.2.0-beta" fail to parse and are - /// treated as "not newer" — the user drops into Config mode with no update offered, but - /// <paramref name="unparseable"/> is set so the UI can surface a hint. - /// </summary> - private static bool IsNewer(string latest, string current, out bool unparseable) - { - unparseable = !Version.TryParse(latest, out var lv) | !Version.TryParse(current, out var cv); - if (unparseable) return false; - return lv > cv; - } } From ba0b38b4f17f483e1a3e9d4df0329cc7f5719e31 Mon Sep 17 00:00:00 2001 From: mika kuns <mika.kuns@fb-tuning.de> Date: Thu, 23 Apr 2026 14:38:20 +0200 Subject: [PATCH 08/18] feat(releases): add SelfUpdater installer-asset matching --- src/ClaudeDo.Releases/SelfUpdateResult.cs | 3 ++ src/ClaudeDo.Releases/SelfUpdater.cs | 22 ++++++++++ .../SelfUpdaterTests.cs | 44 +++++++++++++++++++ 3 files changed, 69 insertions(+) create mode 100644 src/ClaudeDo.Releases/SelfUpdateResult.cs create mode 100644 src/ClaudeDo.Releases/SelfUpdater.cs create mode 100644 tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs diff --git a/src/ClaudeDo.Releases/SelfUpdateResult.cs b/src/ClaudeDo.Releases/SelfUpdateResult.cs new file mode 100644 index 0000000..82acdca --- /dev/null +++ b/src/ClaudeDo.Releases/SelfUpdateResult.cs @@ -0,0 +1,3 @@ +namespace ClaudeDo.Releases; + +public sealed record InstallerAssetMatch(ReleaseAsset Asset, string Version); diff --git a/src/ClaudeDo.Releases/SelfUpdater.cs b/src/ClaudeDo.Releases/SelfUpdater.cs new file mode 100644 index 0000000..b5df89d --- /dev/null +++ b/src/ClaudeDo.Releases/SelfUpdater.cs @@ -0,0 +1,22 @@ +using System.Text.RegularExpressions; + +namespace ClaudeDo.Releases; + +public static partial class SelfUpdater +{ + [GeneratedRegex(@"^ClaudeDo\.Installer-(?<version>[\d\.]+)\.exe$", RegexOptions.IgnoreCase)] + private static partial Regex InstallerAssetRegex(); + + public static InstallerAssetMatch? FindInstallerAsset(IEnumerable<ReleaseAsset> assets) + { + foreach (var asset in assets) + { + var m = InstallerAssetRegex().Match(asset.Name); + if (m.Success) + { + return new InstallerAssetMatch(asset, m.Groups["version"].Value); + } + } + return null; + } +} diff --git a/tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs b/tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs new file mode 100644 index 0000000..9abaf70 --- /dev/null +++ b/tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs @@ -0,0 +1,44 @@ +namespace ClaudeDo.Releases.Tests; + +public class SelfUpdaterAssetMatchingTests +{ + [Fact] + public void FindInstallerAsset_PicksInstallerExeByPattern() + { + var assets = new[] + { + new ReleaseAsset("ClaudeDo-0.3.0-win-x64.zip", "https://x/app.zip", 10), + new ReleaseAsset("ClaudeDo.Installer-0.3.0.exe", "https://x/inst.exe", 20), + new ReleaseAsset("checksums.txt", "https://x/checks", 1), + }; + + var result = SelfUpdater.FindInstallerAsset(assets); + + Assert.NotNull(result); + Assert.Equal("ClaudeDo.Installer-0.3.0.exe", result!.Asset.Name); + Assert.Equal("0.3.0", result.Version); + } + + [Fact] + public void FindInstallerAsset_ReturnsNullWhenAbsent() + { + var assets = new[] + { + new ReleaseAsset("ClaudeDo-0.3.0-win-x64.zip", "https://x/app.zip", 10), + }; + + Assert.Null(SelfUpdater.FindInstallerAsset(assets)); + } + + [Fact] + public void FindInstallerAsset_IgnoresAppZipThatContainsInstaller() + { + var assets = new[] + { + new ReleaseAsset("ClaudeDo.Installer.Portable-0.3.0.zip", "https://x/1", 1), + new ReleaseAsset("not-the-installer.exe", "https://x/2", 1), + }; + + Assert.Null(SelfUpdater.FindInstallerAsset(assets)); + } +} From e017d66023031d32b3066957d5a5c3628a9fd7ba Mon Sep 17 00:00:00 2001 From: mika kuns <mika.kuns@fb-tuning.de> Date: Thu, 23 Apr 2026 14:40:45 +0200 Subject: [PATCH 09/18] feat(releases): add SelfUpdater.DecideUpdateAsync --- src/ClaudeDo.Releases/SelfUpdateResult.cs | 12 +++ src/ClaudeDo.Releases/SelfUpdater.cs | 37 +++++++++ .../SelfUpdaterTests.cs | 82 +++++++++++++++++++ 3 files changed, 131 insertions(+) diff --git a/src/ClaudeDo.Releases/SelfUpdateResult.cs b/src/ClaudeDo.Releases/SelfUpdateResult.cs index 82acdca..6d2f96e 100644 --- a/src/ClaudeDo.Releases/SelfUpdateResult.cs +++ b/src/ClaudeDo.Releases/SelfUpdateResult.cs @@ -1,3 +1,15 @@ namespace ClaudeDo.Releases; public sealed record InstallerAssetMatch(ReleaseAsset Asset, string Version); + +public enum SelfUpdateDecisionKind +{ + NoUpdate, + UpdateAvailable, +} + +public sealed record SelfUpdateDecision( + SelfUpdateDecisionKind Kind, + string? LatestVersion = null, + ReleaseAsset? InstallerAsset = null, + ReleaseAsset? ChecksumsAsset = null); diff --git a/src/ClaudeDo.Releases/SelfUpdater.cs b/src/ClaudeDo.Releases/SelfUpdater.cs index b5df89d..71d18f2 100644 --- a/src/ClaudeDo.Releases/SelfUpdater.cs +++ b/src/ClaudeDo.Releases/SelfUpdater.cs @@ -1,3 +1,4 @@ +using System.Net.Http; using System.Text.RegularExpressions; namespace ClaudeDo.Releases; @@ -19,4 +20,40 @@ public static partial class SelfUpdater } return null; } + + public static async Task<SelfUpdateDecision> DecideUpdateAsync( + IReleaseClient releases, + string currentVersion, + CancellationToken ct) + { + GiteaRelease? release; + try + { + release = await releases.GetLatestReleaseAsync(ct); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + return new SelfUpdateDecision(SelfUpdateDecisionKind.NoUpdate); + } + + if (release is null) + return new SelfUpdateDecision(SelfUpdateDecisionKind.NoUpdate); + + var match = FindInstallerAsset(release.Assets); + if (match is null) + return new SelfUpdateDecision(SelfUpdateDecisionKind.NoUpdate); + + var cmp = VersionComparer.Compare(match.Version, currentVersion); + if (!cmp.IsNewer) + return new SelfUpdateDecision(SelfUpdateDecisionKind.NoUpdate); + + var checksums = release.Assets.FirstOrDefault( + a => string.Equals(a.Name, "checksums.txt", StringComparison.OrdinalIgnoreCase)); + + return new SelfUpdateDecision( + SelfUpdateDecisionKind.UpdateAvailable, + LatestVersion: match.Version, + InstallerAsset: match.Asset, + ChecksumsAsset: checksums); + } } diff --git a/tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs b/tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs index 9abaf70..efb46de 100644 --- a/tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs +++ b/tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs @@ -1,3 +1,5 @@ +using System.Net.Http; + namespace ClaudeDo.Releases.Tests; public class SelfUpdaterAssetMatchingTests @@ -42,3 +44,83 @@ public class SelfUpdaterAssetMatchingTests Assert.Null(SelfUpdater.FindInstallerAsset(assets)); } } + +public class SelfUpdaterDecisionTests +{ + private sealed class FakeReleaseClient : IReleaseClient + { + public GiteaRelease? Release { get; set; } + public bool Throw { get; set; } + + public Task<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct) + { + if (Throw) throw new HttpRequestException("boom"); + return Task.FromResult(Release); + } + + public Task DownloadAsync(string url, string destPath, IProgress<long> progress, CancellationToken ct) + => throw new NotSupportedException("not used in decision tests"); + } + + [Fact] + public async Task Decide_NoRelease_NoUpdate() + { + var client = new FakeReleaseClient { Release = null }; + var d = await SelfUpdater.DecideUpdateAsync(client, currentVersion: "0.1.0", CancellationToken.None); + Assert.Equal(SelfUpdateDecisionKind.NoUpdate, d.Kind); + } + + [Fact] + public async Task Decide_NetworkError_NoUpdate() + { + var client = new FakeReleaseClient { Throw = true }; + var d = await SelfUpdater.DecideUpdateAsync(client, "0.1.0", CancellationToken.None); + Assert.Equal(SelfUpdateDecisionKind.NoUpdate, d.Kind); + } + + [Fact] + public async Task Decide_OlderLatest_NoUpdate() + { + var client = new FakeReleaseClient + { + Release = new GiteaRelease("v0.1.0", "rel", new[] + { + new ReleaseAsset("ClaudeDo.Installer-0.1.0.exe", "u", 1), + }), + }; + var d = await SelfUpdater.DecideUpdateAsync(client, "0.2.0", CancellationToken.None); + Assert.Equal(SelfUpdateDecisionKind.NoUpdate, d.Kind); + } + + [Fact] + public async Task Decide_NewerLatestWithAsset_UpdateAvailable() + { + var client = new FakeReleaseClient + { + Release = new GiteaRelease("v0.3.0", "rel", new[] + { + new ReleaseAsset("ClaudeDo.Installer-0.3.0.exe", "https://x", 20), + new ReleaseAsset("checksums.txt", "https://checks", 1), + }), + }; + var d = await SelfUpdater.DecideUpdateAsync(client, "0.2.0", CancellationToken.None); + Assert.Equal(SelfUpdateDecisionKind.UpdateAvailable, d.Kind); + Assert.Equal("0.3.0", d.LatestVersion); + Assert.NotNull(d.InstallerAsset); + Assert.NotNull(d.ChecksumsAsset); + } + + [Fact] + public async Task Decide_NewerLatestButNoInstallerAsset_NoUpdate() + { + var client = new FakeReleaseClient + { + Release = new GiteaRelease("v0.3.0", "rel", new[] + { + new ReleaseAsset("ClaudeDo-0.3.0-win-x64.zip", "u", 20), + }), + }; + var d = await SelfUpdater.DecideUpdateAsync(client, "0.2.0", CancellationToken.None); + Assert.Equal(SelfUpdateDecisionKind.NoUpdate, d.Kind); + } +} From 0c3dcb0052efe719bd236d35df96e751dd362236 Mon Sep 17 00:00:00 2001 From: mika kuns <mika.kuns@fb-tuning.de> Date: Thu, 23 Apr 2026 14:42:41 +0200 Subject: [PATCH 10/18] feat(releases): add SelfUpdater.HandleReplaceSelfAsync --- src/ClaudeDo.Releases/SelfUpdater.cs | 36 +++++++++++++ .../SelfUpdaterTests.cs | 53 +++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/src/ClaudeDo.Releases/SelfUpdater.cs b/src/ClaudeDo.Releases/SelfUpdater.cs index 71d18f2..99ada5b 100644 --- a/src/ClaudeDo.Releases/SelfUpdater.cs +++ b/src/ClaudeDo.Releases/SelfUpdater.cs @@ -56,4 +56,40 @@ public static partial class SelfUpdater InstallerAsset: match.Asset, ChecksumsAsset: checksums); } + + public static async Task<bool> HandleReplaceSelfAsync( + string oldPath, + string currentExePath, + Func<string, bool> launchProcess, + int maxWaitMs = 5000) + { + var deadline = DateTime.UtcNow.AddMilliseconds(maxWaitMs); + while (DateTime.UtcNow < deadline) + { + try + { + if (File.Exists(oldPath)) + { + File.Delete(oldPath); + } + break; + } + catch (IOException) + { + await Task.Delay(100); + } + catch (UnauthorizedAccessException) + { + await Task.Delay(100); + } + } + + if (File.Exists(oldPath)) + { + return false; + } + + File.Copy(currentExePath, oldPath, overwrite: false); + return launchProcess(oldPath); + } } diff --git a/tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs b/tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs index efb46de..62c33f8 100644 --- a/tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs +++ b/tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs @@ -124,3 +124,56 @@ public class SelfUpdaterDecisionTests Assert.Equal(SelfUpdateDecisionKind.NoUpdate, d.Kind); } } + +public class SelfUpdaterReplaceSelfTests : IDisposable +{ + private readonly string _tempDir; + + public SelfUpdaterReplaceSelfTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDo.Releases.Tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() { try { Directory.Delete(_tempDir, true); } catch { } } + + [Fact] + public async Task Replace_DeletesOldAndCopiesCurrent() + { + var oldPath = Path.Combine(_tempDir, "old.exe"); + var currentPath = Path.Combine(_tempDir, "current.exe"); + await File.WriteAllTextAsync(oldPath, "OLD"); + await File.WriteAllTextAsync(currentPath, "NEW"); + + var relaunchedWith = ""; + var result = await SelfUpdater.HandleReplaceSelfAsync( + oldPath: oldPath, + currentExePath: currentPath, + launchProcess: path => { relaunchedWith = path; return true; }, + maxWaitMs: 500); + + Assert.True(result); + Assert.Equal(oldPath, relaunchedWith); + Assert.Equal("NEW", await File.ReadAllTextAsync(oldPath)); + } + + [Fact] + public async Task Replace_TimesOutWhenFileStaysLocked_ReturnsFalse() + { + var oldPath = Path.Combine(_tempDir, "locked.exe"); + var currentPath = Path.Combine(_tempDir, "current.exe"); + await File.WriteAllTextAsync(oldPath, "OLD"); + await File.WriteAllTextAsync(currentPath, "NEW"); + + // Hold an exclusive lock across the wait window. + using var lockStream = new FileStream(oldPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + + var result = await SelfUpdater.HandleReplaceSelfAsync( + oldPath: oldPath, + currentExePath: currentPath, + launchProcess: _ => true, + maxWaitMs: 200); + + Assert.False(result); + } +} From 98c188a5da8eb8af26ba0cc6870609a8fc24c91c Mon Sep 17 00:00:00 2001 From: mika kuns <mika.kuns@fb-tuning.de> Date: Thu, 23 Apr 2026 14:45:13 +0200 Subject: [PATCH 11/18] feat(releases): add SelfUpdater.DownloadAndVerifyAsync --- src/ClaudeDo.Releases/SelfUpdater.cs | 31 ++++++++ .../SelfUpdaterTests.cs | 77 +++++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/src/ClaudeDo.Releases/SelfUpdater.cs b/src/ClaudeDo.Releases/SelfUpdater.cs index 99ada5b..e0004d1 100644 --- a/src/ClaudeDo.Releases/SelfUpdater.cs +++ b/src/ClaudeDo.Releases/SelfUpdater.cs @@ -1,3 +1,4 @@ +using System.IO; using System.Net.Http; using System.Text.RegularExpressions; @@ -92,4 +93,34 @@ public static partial class SelfUpdater File.Copy(currentExePath, oldPath, overwrite: false); return launchProcess(oldPath); } + + public static async Task<string?> DownloadAndVerifyAsync( + IReleaseClient releases, + ReleaseAsset installerAsset, + ReleaseAsset checksumsAsset, + string tempDir, + IProgress<long> progress, + CancellationToken ct) + { + Directory.CreateDirectory(tempDir); + var installerPath = Path.Combine(tempDir, installerAsset.Name); + var checksumsPath = Path.Combine(tempDir, "checksums.txt"); + + try + { + await releases.DownloadAsync(installerAsset.BrowserDownloadUrl, installerPath, progress, ct); + await releases.DownloadAsync(checksumsAsset.BrowserDownloadUrl, checksumsPath, new Progress<long>(_ => { }), ct); + } + catch (Exception ex) when (ex is HttpRequestException or IOException or TaskCanceledException) + { + return null; + } + + var checksumsText = await File.ReadAllTextAsync(checksumsPath, ct); + var map = ChecksumVerifier.ParseChecksumsFile(checksumsText); + if (!map.TryGetValue(installerAsset.Name, out var expected)) + return null; + + return ChecksumVerifier.Verify(installerPath, expected) ? installerPath : null; + } } diff --git a/tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs b/tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs index 62c33f8..01d1c82 100644 --- a/tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs +++ b/tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs @@ -177,3 +177,80 @@ public class SelfUpdaterReplaceSelfTests : IDisposable Assert.False(result); } } + +public class SelfUpdaterDownloadTests : IDisposable +{ + private readonly string _tempDir; + + public SelfUpdaterDownloadTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDo.Releases.Tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() { try { Directory.Delete(_tempDir, true); } catch { } } + + private sealed class StubReleaseClient : IReleaseClient + { + public string FileContent { get; set; } = ""; + public string ChecksumsBody { get; set; } = ""; + + public Task<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct) => Task.FromResult<GiteaRelease?>(null); + + public async Task DownloadAsync(string url, string destPath, IProgress<long> progress, CancellationToken ct) + { + if (url.EndsWith("checksums.txt", StringComparison.OrdinalIgnoreCase)) + { + await File.WriteAllTextAsync(destPath, ChecksumsBody, ct); + } + else + { + await File.WriteAllTextAsync(destPath, FileContent, ct); + } + progress.Report(FileContent.Length); + } + } + + [Fact] + public async Task Download_MatchingChecksum_ReturnsPath() + { + var content = "FAKE-INSTALLER-BINARY"; + var hash = Sha256Hex(content); + var client = new StubReleaseClient + { + FileContent = content, + ChecksumsBody = $"{hash} ClaudeDo.Installer-0.3.0.exe\n", + }; + var installer = new ReleaseAsset("ClaudeDo.Installer-0.3.0.exe", "https://x/inst", content.Length); + var checksums = new ReleaseAsset("checksums.txt", "https://x/checksums.txt", 100); + + var path = await SelfUpdater.DownloadAndVerifyAsync( + client, installer, checksums, _tempDir, new Progress<long>(_ => { }), CancellationToken.None); + + Assert.NotNull(path); + Assert.Equal(content, await File.ReadAllTextAsync(path!)); + } + + [Fact] + public async Task Download_ChecksumMismatch_ReturnsNull() + { + var client = new StubReleaseClient + { + FileContent = "real", + ChecksumsBody = "deadbeef" + new string('0', 56) + " ClaudeDo.Installer-0.3.0.exe\n", + }; + var installer = new ReleaseAsset("ClaudeDo.Installer-0.3.0.exe", "https://x/inst", 4); + var checksums = new ReleaseAsset("checksums.txt", "https://x/checksums.txt", 100); + + var path = await SelfUpdater.DownloadAndVerifyAsync( + client, installer, checksums, _tempDir, new Progress<long>(_ => { }), CancellationToken.None); + + Assert.Null(path); + } + + private static string Sha256Hex(string s) + { + using var sha = System.Security.Cryptography.SHA256.Create(); + return Convert.ToHexString(sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(s))).ToLowerInvariant(); + } +} From caf900b02dff3d441dc61f96467046add910f36f Mon Sep 17 00:00:00 2001 From: mika kuns <mika.kuns@fb-tuning.de> Date: Thu, 23 Apr 2026 14:49:48 +0200 Subject: [PATCH 12/18] feat(installer): self-update pre-flight before wizard --- src/ClaudeDo.Installer/App.xaml.cs | 98 +++++++++++++++++++ .../Views/SelfUpdatePromptWindow.xaml | 25 +++++ .../Views/SelfUpdatePromptWindow.xaml.cs | 42 ++++++++ 3 files changed, 165 insertions(+) create mode 100644 src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml create mode 100644 src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml.cs diff --git a/src/ClaudeDo.Installer/App.xaml.cs b/src/ClaudeDo.Installer/App.xaml.cs index bb06f73..33e0645 100644 --- a/src/ClaudeDo.Installer/App.xaml.cs +++ b/src/ClaudeDo.Installer/App.xaml.cs @@ -22,6 +22,104 @@ public partial class App : Application { base.OnStartup(e); + // --- Self-update pre-flight --- + // Resolve current exe path. Assembly.Location may point to a .dll for apphost-based + // .NET apps; swap to the .exe companion when that happens. + var currentExePath = Assembly.GetEntryAssembly()!.Location; + if (currentExePath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) + { + currentExePath = System.IO.Path.ChangeExtension(currentExePath, ".exe"); + } + + // Arg form: --replace-self "<old-path>" + var replaceSelfIndex = Array.FindIndex(e.Args, a => a.Equals("--replace-self", StringComparison.OrdinalIgnoreCase)); + if (replaceSelfIndex >= 0 && replaceSelfIndex + 1 < e.Args.Length) + { + var oldPath = e.Args[replaceSelfIndex + 1]; + var relaunched = await SelfUpdater.HandleReplaceSelfAsync( + oldPath: oldPath, + currentExePath: currentExePath, + launchProcess: path => + { + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true }); + return true; + } + catch { return false; } + }); + if (relaunched) + { + Shutdown(0); + return; + } + // Replacement failed — fall through to normal wizard from the temp location. + } + else + { + // Normal launch: check for a newer installer. + using var selfUpdateHttp = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; + var selfUpdateReleases = new ReleaseClient(selfUpdateHttp); + var currentVersion = GetInstallerVersion(); + + var decision = await SelfUpdater.DecideUpdateAsync(selfUpdateReleases, currentVersion, CancellationToken.None); + if (decision.Kind == SelfUpdateDecisionKind.UpdateAvailable) + { + var prompt = new SelfUpdatePromptWindow(currentVersion, decision.LatestVersion!); + DarkTitleBar.Apply(prompt); + var ok = prompt.ShowDialog() == true; + if (!ok) + { + Shutdown(0); + return; + } + if (prompt.Choice == SelfUpdateChoice.Update) + { + prompt.ShowProgress("Downloading..."); + var tempDir = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "ClaudeDo.Installer.Update"); + var verifiedPath = await SelfUpdater.DownloadAndVerifyAsync( + selfUpdateReleases, + decision.InstallerAsset!, + decision.ChecksumsAsset!, + tempDir, + new Progress<long>(_ => { }), + CancellationToken.None); + + if (verifiedPath is null) + { + MessageBox.Show(prompt, + "Update download or verification failed. Continuing with current installer.", + "ClaudeDo Installer", MessageBoxButton.OK, MessageBoxImage.Warning); + } + else + { + try + { + var psi = new System.Diagnostics.ProcessStartInfo(verifiedPath) + { + UseShellExecute = true, + }; + psi.ArgumentList.Add("--replace-self"); + psi.ArgumentList.Add(currentExePath); + System.Diagnostics.Process.Start(psi); + Shutdown(0); + return; + } + catch (Exception ex) + { + MessageBox.Show(prompt, + "Failed to launch updated installer: " + ex.Message + "\nContinuing with current installer.", + "ClaudeDo Installer", MessageBoxButton.OK, MessageBoxImage.Warning); + } + } + } + // SelfUpdateChoice.Continue — fall through to normal wizard. + } + // No-update or check failed — fall through to normal wizard. + } + + // --- Existing wizard start-up unchanged below this line --- + _services = BuildServices(); var context = _services.GetRequiredService<InstallContext>(); diff --git a/src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml b/src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml new file mode 100644 index 0000000..e9f9aa4 --- /dev/null +++ b/src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml @@ -0,0 +1,25 @@ +<Window x:Class="ClaudeDo.Installer.Views.SelfUpdatePromptWindow" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + Title="ClaudeDo Installer Update" + Width="460" Height="200" + WindowStartupLocation="CenterScreen" + ResizeMode="NoResize" + Background="#1a1a1a" Foreground="#f0f0f0"> + <Grid Margin="20"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="*"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + <TextBlock Grid.Row="0" FontSize="16" FontWeight="SemiBold" Text="A newer installer is available"/> + <TextBlock Grid.Row="1" Margin="0,8,0,0" TextWrapping="Wrap" x:Name="DetailText"/> + <TextBlock Grid.Row="2" Margin="0,12,0,0" TextWrapping="Wrap" Foreground="#a0a0a0" x:Name="ProgressText" Visibility="Collapsed"/> + <StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right"> + <Button x:Name="UpdateBtn" Content="Update" MinWidth="90" Margin="4,0" Padding="10,4" Click="UpdateBtn_Click" IsDefault="True"/> + <Button x:Name="ContinueBtn" Content="Continue anyway" MinWidth="140" Margin="4,0" Padding="10,4" Click="ContinueBtn_Click"/> + <Button x:Name="CancelBtn" Content="Cancel" MinWidth="90" Margin="4,0" Padding="10,4" Click="CancelBtn_Click" IsCancel="True"/> + </StackPanel> + </Grid> +</Window> diff --git a/src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml.cs b/src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml.cs new file mode 100644 index 0000000..9d73059 --- /dev/null +++ b/src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml.cs @@ -0,0 +1,42 @@ +using System.Windows; + +namespace ClaudeDo.Installer.Views; + +public enum SelfUpdateChoice { Update, Continue, Cancel } + +public partial class SelfUpdatePromptWindow : Window +{ + public SelfUpdateChoice Choice { get; private set; } = SelfUpdateChoice.Cancel; + + public SelfUpdatePromptWindow(string currentVersion, string latestVersion) + { + InitializeComponent(); + DetailText.Text = $"Installer v{latestVersion} is available (you are running v{currentVersion}). Update before continuing?"; + } + + public void ShowProgress(string text) + { + ProgressText.Text = text; + ProgressText.Visibility = Visibility.Visible; + UpdateBtn.IsEnabled = false; + ContinueBtn.IsEnabled = false; + } + + private void UpdateBtn_Click(object sender, RoutedEventArgs e) + { + Choice = SelfUpdateChoice.Update; + DialogResult = true; + } + + private void ContinueBtn_Click(object sender, RoutedEventArgs e) + { + Choice = SelfUpdateChoice.Continue; + DialogResult = true; + } + + private void CancelBtn_Click(object sender, RoutedEventArgs e) + { + Choice = SelfUpdateChoice.Cancel; + DialogResult = false; + } +} From c06d1d6afb995adb63615f6dbfbb19404c537728 Mon Sep 17 00:00:00 2001 From: mika kuns <mika.kuns@fb-tuning.de> Date: Thu, 23 Apr 2026 14:53:20 +0200 Subject: [PATCH 13/18] feat(ui): add UpdateCheckService --- src/ClaudeDo.Ui/ClaudeDo.Ui.csproj | 1 + .../Services/UpdateCheckService.cs | 73 +++++++++++++++++++ .../Services/UpdateCheckServiceTests.cs | 62 ++++++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 src/ClaudeDo.Ui/Services/UpdateCheckService.cs create mode 100644 tests/ClaudeDo.Ui.Tests/Services/UpdateCheckServiceTests.cs diff --git a/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj b/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj index dc83e5e..c296826 100644 --- a/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj +++ b/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj @@ -2,6 +2,7 @@ <ItemGroup> <ProjectReference Include="..\ClaudeDo.Data\ClaudeDo.Data.csproj" /> + <ProjectReference Include="..\ClaudeDo.Releases\ClaudeDo.Releases.csproj" /> </ItemGroup> <ItemGroup> diff --git a/src/ClaudeDo.Ui/Services/UpdateCheckService.cs b/src/ClaudeDo.Ui/Services/UpdateCheckService.cs new file mode 100644 index 0000000..bd72759 --- /dev/null +++ b/src/ClaudeDo.Ui/Services/UpdateCheckService.cs @@ -0,0 +1,73 @@ +using ClaudeDo.Releases; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace ClaudeDo.Ui.Services; + +public enum UpdateCheckStatus +{ + NeverChecked, + CheckFailed, + UpToDate, + UpdateAvailable, +} + +public sealed partial class UpdateCheckService : ObservableObject +{ + private readonly IReleaseClient _releases; + + [ObservableProperty] private bool _isUpdateAvailable; + [ObservableProperty] private string? _latestVersion; + [ObservableProperty] private string _currentVersion; + [ObservableProperty] private bool _isChecking; + [ObservableProperty] private UpdateCheckStatus _lastCheckStatus = UpdateCheckStatus.NeverChecked; + + public UpdateCheckService(IReleaseClient releases, string currentVersion) + { + _releases = releases; + _currentVersion = currentVersion; + } + + public async Task CheckNowAsync(CancellationToken ct) + { + IsChecking = true; + try + { + GiteaRelease? rel; + try + { + rel = await _releases.GetLatestReleaseAsync(ct); + } + catch + { + LastCheckStatus = UpdateCheckStatus.CheckFailed; + IsUpdateAvailable = false; + return; + } + + if (rel is null) + { + LastCheckStatus = UpdateCheckStatus.CheckFailed; + IsUpdateAvailable = false; + return; + } + + var latest = (rel.TagName ?? "").TrimStart('v', 'V'); + var cmp = VersionComparer.Compare(latest, CurrentVersion); + if (cmp.IsNewer) + { + LatestVersion = latest; + IsUpdateAvailable = true; + LastCheckStatus = UpdateCheckStatus.UpdateAvailable; + } + else + { + IsUpdateAvailable = false; + LastCheckStatus = UpdateCheckStatus.UpToDate; + } + } + finally + { + IsChecking = false; + } + } +} diff --git a/tests/ClaudeDo.Ui.Tests/Services/UpdateCheckServiceTests.cs b/tests/ClaudeDo.Ui.Tests/Services/UpdateCheckServiceTests.cs new file mode 100644 index 0000000..77b5956 --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/Services/UpdateCheckServiceTests.cs @@ -0,0 +1,62 @@ +using System.Net.Http; +using ClaudeDo.Releases; +using ClaudeDo.Ui.Services; + +namespace ClaudeDo.Ui.Tests.Services; + +public class UpdateCheckServiceTests +{ + private sealed class FakeReleaseClient : IReleaseClient + { + public GiteaRelease? Release { get; set; } + public bool Throw { get; set; } + + public Task<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct) + { + if (Throw) throw new HttpRequestException(); + return Task.FromResult(Release); + } + + public Task DownloadAsync(string url, string destPath, IProgress<long> progress, CancellationToken ct) + => throw new NotSupportedException(); + } + + [Fact] + public async Task Check_NewerRelease_SetsUpdateAvailable() + { + var svc = new UpdateCheckService(new FakeReleaseClient + { + Release = new GiteaRelease("v0.3.0", "r", new[] { new ReleaseAsset("ClaudeDo-0.3.0-win-x64.zip", "u", 1) }), + }, + currentVersion: "0.1.0"); + + await svc.CheckNowAsync(CancellationToken.None); + + Assert.Equal(UpdateCheckStatus.UpdateAvailable, svc.LastCheckStatus); + Assert.True(svc.IsUpdateAvailable); + Assert.Equal("0.3.0", svc.LatestVersion); + } + + [Fact] + public async Task Check_SameRelease_SetsUpToDate() + { + var svc = new UpdateCheckService(new FakeReleaseClient + { + Release = new GiteaRelease("v0.1.0", "r", new[] { new ReleaseAsset("ClaudeDo-0.1.0-win-x64.zip", "u", 1) }), + }, + currentVersion: "0.1.0"); + + await svc.CheckNowAsync(CancellationToken.None); + + Assert.Equal(UpdateCheckStatus.UpToDate, svc.LastCheckStatus); + Assert.False(svc.IsUpdateAvailable); + } + + [Fact] + public async Task Check_NetworkError_SetsCheckFailedButDoesNotThrow() + { + var svc = new UpdateCheckService(new FakeReleaseClient { Throw = true }, "0.1.0"); + await svc.CheckNowAsync(CancellationToken.None); + Assert.Equal(UpdateCheckStatus.CheckFailed, svc.LastCheckStatus); + } +} From ee097068111753e1f7a5bd936b3d813932d4d52e Mon Sep 17 00:00:00 2001 From: mika kuns <mika.kuns@fb-tuning.de> Date: Thu, 23 Apr 2026 14:56:57 +0200 Subject: [PATCH 14/18] feat(ui): add InstallerLocator --- src/ClaudeDo.Ui/ClaudeDo.Ui.csproj | 1 + src/ClaudeDo.Ui/Services/InstallerLocator.cs | 47 +++++++++++++++ .../Services/InstallerLocatorTests.cs | 58 +++++++++++++++++++ 3 files changed, 106 insertions(+) create mode 100644 src/ClaudeDo.Ui/Services/InstallerLocator.cs create mode 100644 tests/ClaudeDo.Ui.Tests/Services/InstallerLocatorTests.cs diff --git a/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj b/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj index c296826..9fddf53 100644 --- a/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj +++ b/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj @@ -10,6 +10,7 @@ <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" /> <PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" /> + <PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" /> </ItemGroup> <PropertyGroup> diff --git a/src/ClaudeDo.Ui/Services/InstallerLocator.cs b/src/ClaudeDo.Ui/Services/InstallerLocator.cs new file mode 100644 index 0000000..de15542 --- /dev/null +++ b/src/ClaudeDo.Ui/Services/InstallerLocator.cs @@ -0,0 +1,47 @@ +namespace ClaudeDo.Ui.Services; + +public sealed class InstallerLocator +{ + private const string InstallJson = "install.json"; + private const string InstallerExe = "ClaudeDo.Installer.exe"; + private const string UninstallerSubdir = "uninstaller"; + + public string? Find() + => FindByWalkingUp(AppContext.BaseDirectory) ?? FindByRegistry(); + + public string? FindByWalkingUp(string startDir) + { + var dir = new DirectoryInfo(startDir); + while (dir is not null) + { + var manifest = Path.Combine(dir.FullName, InstallJson); + if (File.Exists(manifest)) + { + var candidate = Path.Combine(dir.FullName, UninstallerSubdir, InstallerExe); + 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, UninstallerSubdir, InstallerExe); + return File.Exists(candidate) ? candidate : null; + } + catch + { + return null; + } + } +} diff --git a/tests/ClaudeDo.Ui.Tests/Services/InstallerLocatorTests.cs b/tests/ClaudeDo.Ui.Tests/Services/InstallerLocatorTests.cs new file mode 100644 index 0000000..4ddc948 --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/Services/InstallerLocatorTests.cs @@ -0,0 +1,58 @@ +using ClaudeDo.Ui.Services; + +namespace ClaudeDo.Ui.Tests.Services; + +public class InstallerLocatorTests : IDisposable +{ + private readonly string _root; + + public InstallerLocatorTests() + { + _root = Path.Combine(Path.GetTempPath(), "ClaudeDo.Ui.Tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_root); + } + + public void Dispose() { try { Directory.Delete(_root, true); } catch { } } + + [Fact] + public void Find_WalkUpFromAppDir_ToInstallJsonSibling() + { + var installDir = Path.Combine(_root, "ClaudeDo"); + var appDir = Path.Combine(installDir, "app"); + var uninstallerDir = Path.Combine(installDir, "uninstaller"); + Directory.CreateDirectory(appDir); + Directory.CreateDirectory(uninstallerDir); + + File.WriteAllText(Path.Combine(installDir, "install.json"), "{}"); + var installerPath = Path.Combine(uninstallerDir, "ClaudeDo.Installer.exe"); + File.WriteAllText(installerPath, "x"); + + var locator = new InstallerLocator(); + var found = locator.FindByWalkingUp(appDir); + + Assert.Equal(installerPath, found); + } + + [Fact] + public void Find_ReturnsNullWhenNoInstallJson() + { + var appDir = Path.Combine(_root, "somewhere", "app"); + Directory.CreateDirectory(appDir); + + var locator = new InstallerLocator(); + Assert.Null(locator.FindByWalkingUp(appDir)); + } + + [Fact] + public void Find_ReturnsNullWhenInstallerMissingFromUninstallerDir() + { + var installDir = Path.Combine(_root, "ClaudeDo"); + var appDir = Path.Combine(installDir, "app"); + Directory.CreateDirectory(appDir); + Directory.CreateDirectory(Path.Combine(installDir, "uninstaller")); + File.WriteAllText(Path.Combine(installDir, "install.json"), "{}"); + + var locator = new InstallerLocator(); + Assert.Null(locator.FindByWalkingUp(appDir)); + } +} From 0934b294c27744d2fad6b74fe87fc98eaf12768f Mon Sep 17 00:00:00 2001 From: mika kuns <mika.kuns@fb-tuning.de> Date: Thu, 23 Apr 2026 15:03:28 +0200 Subject: [PATCH 15/18] feat(app): register UpdateCheckService and InstallerLocator in DI --- src/ClaudeDo.App/Program.cs | 14 ++++++++++++++ .../ViewModels/IslandsShellViewModel.cs | 9 ++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/ClaudeDo.App/Program.cs b/src/ClaudeDo.App/Program.cs index 26c136a..2c07966 100644 --- a/src/ClaudeDo.App/Program.cs +++ b/src/ClaudeDo.App/Program.cs @@ -1,6 +1,7 @@ using Avalonia; using ClaudeDo.Data; using ClaudeDo.Data.Git; +using ClaudeDo.Releases; using ClaudeDo.Ui; using ClaudeDo.Ui.Services; using ClaudeDo.Ui.ViewModels; @@ -9,6 +10,8 @@ using ClaudeDo.Ui.ViewModels.Modals; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using System; +using System.Net.Http; +using System.Reflection; using System.Runtime.InteropServices; namespace ClaudeDo.App; @@ -75,6 +78,17 @@ sealed class Program sc.AddSingleton<GitService>(); sc.AddSingleton(sp => new WorkerClient(sp.GetRequiredService<AppSettings>().SignalRUrl)); + // Release check + installer update + sc.AddSingleton<HttpClient>(_ => new HttpClient { Timeout = TimeSpan.FromSeconds(10) }); + sc.AddSingleton<IReleaseClient>(sp => new ReleaseClient(sp.GetRequiredService<HttpClient>())); + sc.AddSingleton<InstallerLocator>(); + sc.AddSingleton(sp => + { + var releases = sp.GetRequiredService<IReleaseClient>(); + var version = Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "0.0.0.0"; + return new UpdateCheckService(releases, version); + }); + // ViewModels sc.AddTransient<WorktreeModalViewModel>(); sc.AddTransient<SettingsModalViewModel>(); diff --git a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs index 14d5f49..3c2c7ee 100644 --- a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs @@ -19,6 +19,9 @@ public sealed partial class IslandsShellViewModel : ViewModelBase public bool IsOffline => !Worker.IsConnected && !Worker.IsReconnecting; + private readonly UpdateCheckService _updateCheck; + private readonly InstallerLocator _installerLocator; + [ObservableProperty] private double _windowWidth = 1280; @@ -47,9 +50,13 @@ public sealed partial class IslandsShellViewModel : ViewModelBase ListsIslandViewModel lists, TasksIslandViewModel tasks, DetailsIslandViewModel details, - WorkerClient worker) + WorkerClient worker, + UpdateCheckService updateCheck, + InstallerLocator installerLocator) { Lists = lists; Tasks = tasks; Details = details; Worker = worker; + _updateCheck = updateCheck; + _installerLocator = installerLocator; Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList); Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask); Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync(); From bbe7d73de2fac4930a35fac80be5a94b8e4a06a5 Mon Sep 17 00:00:00 2001 From: mika kuns <mika.kuns@fb-tuning.de> Date: Thu, 23 Apr 2026 15:05:56 +0200 Subject: [PATCH 16/18] feat(ui): wire update-check state and commands into shell VM --- .../ViewModels/IslandsShellViewModel.cs | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs index 3c2c7ee..c364fd6 100644 --- a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs @@ -1,3 +1,6 @@ +using System; +using System.Threading; +using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using ClaudeDo.Ui.Services; @@ -11,6 +14,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase public TasksIslandViewModel Tasks { get; } public DetailsIslandViewModel Details { get; } public WorkerClient Worker { get; } + public UpdateCheckService UpdateCheck => _updateCheck; public string ConnectionText => Worker.IsConnected ? "Online" @@ -22,6 +26,11 @@ public sealed partial class IslandsShellViewModel : ViewModelBase private readonly UpdateCheckService _updateCheck; private readonly InstallerLocator _installerLocator; + [ObservableProperty] private bool _isUpdateBannerVisible; + [ObservableProperty] private string? _updateBannerLatestVersion; + [ObservableProperty] private string? _inlineUpdateStatus; + private bool _bannerDismissedThisSession; + [ObservableProperty] private double _windowWidth = 1280; @@ -76,5 +85,74 @@ public sealed partial class IslandsShellViewModel : ViewModelBase } }; _ = Lists.LoadAsync(); + _updateCheck.PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(UpdateCheckService.LastCheckStatus)) + { + RefreshBannerFromStatus(); + } + }; + // Fire-and-forget startup check — never block UI. + _ = Task.Run(async () => + { + try { await _updateCheck.CheckNowAsync(CancellationToken.None); } catch { } + }); + } + + private void RefreshBannerFromStatus() + { + switch (_updateCheck.LastCheckStatus) + { + case UpdateCheckStatus.UpdateAvailable: + if (_bannerDismissedThisSession) { IsUpdateBannerVisible = false; break; } + UpdateBannerLatestVersion = _updateCheck.LatestVersion; + IsUpdateBannerVisible = true; + InlineUpdateStatus = null; + break; + case UpdateCheckStatus.UpToDate: + IsUpdateBannerVisible = false; + ShowInlineStatus($"You're up to date (v{_updateCheck.CurrentVersion})"); + break; + case UpdateCheckStatus.CheckFailed: + ShowInlineStatus("Could not check for updates"); + break; + } + } + + private async void ShowInlineStatus(string text) + { + InlineUpdateStatus = text; + await Task.Delay(3000); + if (InlineUpdateStatus == text) InlineUpdateStatus = null; + } + + [RelayCommand] + private async Task CheckForUpdatesAsync() + { + await _updateCheck.CheckNowAsync(CancellationToken.None); + } + + [RelayCommand] + private void DismissBanner() + { + _bannerDismissedThisSession = true; + IsUpdateBannerVisible = false; + } + + [RelayCommand] + private void UpdateNow() + { + var path = _installerLocator.Find(); + if (path is null) return; + + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true }); + Environment.Exit(0); + } + catch + { + // Intentionally silent — if this fails there's nothing useful to show. + } } } From 00c62178e15b78ea0359a30075ddc8b6c932f4fb Mon Sep 17 00:00:00 2001 From: mika kuns <mika.kuns@fb-tuning.de> Date: Thu, 23 Apr 2026 15:10:43 +0200 Subject: [PATCH 17/18] feat(ui): add update banner and Help menu to MainWindow --- src/ClaudeDo.Ui/Views/MainWindow.axaml | 58 ++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 4 deletions(-) diff --git a/src/ClaudeDo.Ui/Views/MainWindow.axaml b/src/ClaudeDo.Ui/Views/MainWindow.axaml index 05128f8..e179a8a 100644 --- a/src/ClaudeDo.Ui/Views/MainWindow.axaml +++ b/src/ClaudeDo.Ui/Views/MainWindow.axaml @@ -17,7 +17,7 @@ <KeyBinding Gesture="Shift+OemQuestion" Command="{Binding FocusSearchCommand}"/> <KeyBinding Gesture="Ctrl+N" Command="{Binding FocusAddTaskCommand}"/> </Window.KeyBindings> - <Grid RowDefinitions="36,*,22"> + <Grid RowDefinitions="36,Auto,*,22"> <!-- Custom title bar --> <Border Grid.Row="0" Background="{DynamicResource DeepBrush}" @@ -54,6 +54,17 @@ Foreground="{DynamicResource TextDimBrush}" LetterSpacing="1.4" VerticalAlignment="Center"/> + <!-- Help menu --> + <Menu Margin="12,0,0,0" + Background="Transparent" + VerticalAlignment="Center"> + <MenuItem Header="Help" + FontSize="11" + Foreground="{DynamicResource TextDimBrush}"> + <MenuItem Header="Check for updates" + Command="{Binding CheckForUpdatesCommand}"/> + </MenuItem> + </Menu> </StackPanel> <!-- Middle: draggable strip --> @@ -77,8 +88,47 @@ </Grid> </Border> + <!-- Update banner --> + <Border Grid.Row="1" + Background="{DynamicResource DeepBrush}" + BorderBrush="{DynamicResource LineBrush}" + BorderThickness="0,0,0,1" + Padding="14,6" + IsVisible="{Binding IsUpdateBannerVisible}"> + <Grid ColumnDefinitions="*,Auto,Auto"> + <TextBlock Grid.Column="0" + VerticalAlignment="Center" + Foreground="{DynamicResource TextDimBrush}" + FontSize="12"> + <Run Text="Update available: v"/> + <Run Text="{Binding UpdateCheck.CurrentVersion}"/> + <Run Text=" → v"/> + <Run Text="{Binding UpdateBannerLatestVersion}"/> + </TextBlock> + <Button Grid.Column="1" + Margin="0,0,8,0" + Padding="10,3" + Content="Update now" + Command="{Binding UpdateNowCommand}"/> + <Button Grid.Column="2" + Padding="10,3" + Content="Dismiss" + Command="{Binding DismissBannerCommand}"/> + </Grid> + </Border> + + <!-- Inline update status (appears at right of banner row when no banner) --> + <TextBlock Grid.Row="1" + HorizontalAlignment="Right" + VerticalAlignment="Center" + Margin="0,0,14,0" + FontSize="11" + Foreground="{DynamicResource TextFaintBrush}" + Text="{Binding InlineUpdateStatus}" + IsVisible="{Binding InlineUpdateStatus, Converter={x:Static ObjectConverters.IsNotNull}}"/> + <!-- Background gradient layer --> - <Border Grid.Row="1"> + <Border Grid.Row="2"> <Border.Background> <RadialGradientBrush Center="50%,50%" GradientOrigin="50%,50%" RadiusX="70%" RadiusY="70%"> <GradientStop Offset="0" Color="{StaticResource DeepColor}" /> @@ -88,7 +138,7 @@ </Border> <!-- Three islands --> - <Grid Grid.Row="1" Margin="7" ColumnDefinitions="260,*,320"> + <Grid Grid.Row="2" Margin="7" ColumnDefinitions="260,*,320"> <Border Grid.Column="0" Classes="island" Margin="7"> <islands:ListsIslandView DataContext="{Binding Lists}"/> </Border> @@ -102,7 +152,7 @@ </Grid> <!-- Footer: connection status --> - <Border Grid.Row="2" + <Border Grid.Row="3" Background="{DynamicResource DeepBrush}" BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,1,0,0"> From a41e5b5b2d1d7f7420dd93b763c8c7a54d7a692d Mon Sep 17 00:00:00 2001 From: mika kuns <mika.kuns@fb-tuning.de> Date: Thu, 23 Apr 2026 15:11:39 +0200 Subject: [PATCH 18/18] docs(open): add self-update manual verification checklist --- docs/open.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/open.md b/docs/open.md index 06cb836..a12020d 100644 --- a/docs/open.md +++ b/docs/open.md @@ -191,3 +191,19 @@ Im aktuellen Stand kompiliert die UI, aber mehrere Stellen sind als `// TODO` ma 9. **Tag-Negation (3.4)** — wenn der Bedarf konkret wird. Punkte 1–3 sind ein realistischer Block für eine Session. + +--- + +## Self-Update — Manual Verification + +Preconditions: a working Gitea release at `git.kuns.dev/releases/ClaudeDo` with three assets — `ClaudeDo-<version>-win-x64.zip`, `ClaudeDo.Installer-<version>.exe`, and `checksums.txt` listing both. + +1. Install a baseline version (e.g. `0.2.x`) normally. +2. Publish a new release tagged `v0.3.0` with fresh installer + app zip + checksums. +3. Launch the app — confirm the banner appears: `Update available: v0.2.x → v0.3.0`. +4. Click **Update now** — app closes, installer opens in Update mode, runs, restarts the worker. +5. Re-launch the app — banner is gone; `Help → Check for updates` briefly shows "You're up to date (v0.3.0)". +6. Run the `v0.2.x` installer manually — confirm it prompts to self-update to v0.3.0. Click **Update** → running exe is replaced and the wizard opens on the new version. +7. Repeat step 6 with **Continue anyway** → wizard opens without self-update. +8. Repeat step 6 with **Cancel** → installer exits without any action. +9. Kill network during startup in both app and installer → confirm silent fallback (no errors, no banner, wizard opens normally).