1832 lines
64 KiB
Markdown
1832 lines
64 KiB
Markdown
# Self-Update 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:** Add a "newer version available" check to both the Avalonia app (banner + Help menu manual trigger) and the WPF installer (self-replacement before running the wizard).
|
|
|
|
**Architecture:** A new `ClaudeDo.Releases` library consolidates Gitea API access, checksum verification, version comparison, and installer self-update logic. The installer runs `SelfUpdater` before showing its wizard. The app runs `UpdateCheckService` on startup; a banner in `MainWindow` and a `Help → Check for updates` menu item expose status; clicking **Update now** launches the installed `ClaudeDo.Installer.exe` and closes the app.
|
|
|
|
**Tech Stack:** .NET 8, Avalonia 12 (UI), WPF (Installer), CommunityToolkit.Mvvm, xUnit, `Microsoft.Extensions.DependencyInjection`.
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-04-23-self-update-design.md`
|
|
|
|
**Build note:** The root solution `ClaudeDo.slnx` does not build on .NET 8 (known issue — needs .NET 9). Always build and test against individual `.csproj` files, not the solution.
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
**New:**
|
|
|
|
- `src/ClaudeDo.Releases/ClaudeDo.Releases.csproj`
|
|
- `src/ClaudeDo.Releases/IReleaseClient.cs` — moved from `src/ClaudeDo.Installer/Core/IReleaseClient.cs`
|
|
- `src/ClaudeDo.Releases/ReleaseClient.cs` — moved from `src/ClaudeDo.Installer/Core/ReleaseClient.cs`
|
|
- `src/ClaudeDo.Releases/ChecksumVerifier.cs` — moved from `src/ClaudeDo.Installer/Core/ChecksumVerifier.cs`
|
|
- `src/ClaudeDo.Releases/VersionComparer.cs`
|
|
- `src/ClaudeDo.Releases/SelfUpdater.cs`
|
|
- `src/ClaudeDo.Releases/SelfUpdateResult.cs`
|
|
- `tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj`
|
|
- `tests/ClaudeDo.Releases.Tests/VersionComparerTests.cs`
|
|
- `tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs`
|
|
- `tests/ClaudeDo.Releases.Tests/ReleaseClientTests.cs` — port existing from `tests/ClaudeDo.Installer.Tests`
|
|
- `tests/ClaudeDo.Releases.Tests/ChecksumVerifierTests.cs` — port existing from `tests/ClaudeDo.Installer.Tests`
|
|
- `src/ClaudeDo.Ui/Services/UpdateCheckService.cs`
|
|
- `src/ClaudeDo.Ui/Services/InstallerLocator.cs`
|
|
- `tests/ClaudeDo.Ui.Tests/Services/UpdateCheckServiceTests.cs`
|
|
- `tests/ClaudeDo.Ui.Tests/Services/InstallerLocatorTests.cs`
|
|
|
|
**Modified:**
|
|
|
|
- `ClaudeDo.slnx` — add `src/ClaudeDo.Releases/ClaudeDo.Releases.csproj` and `tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj`.
|
|
- `src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` — add `ProjectReference` to `ClaudeDo.Releases`.
|
|
- `src/ClaudeDo.Installer/Core/InstallModeDetector.cs` — use `VersionComparer` from `ClaudeDo.Releases`; update namespace imports after the type moves.
|
|
- `src/ClaudeDo.Installer/App.xaml.cs` — run `SelfUpdater` before the wizard; handle `--replace-self` argument.
|
|
- `src/ClaudeDo.Ui/ClaudeDo.Ui.csproj` — add `ProjectReference` to `ClaudeDo.Releases`.
|
|
- `src/ClaudeDo.Ui/Views/MainWindow.axaml` — banner above content; Help menu in the titlebar.
|
|
- `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs` — wire `UpdateCheckService`, expose `IsBannerVisible`, `LatestVersion`, `InlineStatusText`, `CheckForUpdatesCommand`, `UpdateNowCommand`, `DismissBannerCommand`.
|
|
- `src/ClaudeDo.App/Program.cs` — register `IReleaseClient`, `HttpClient` (via `IHttpClientFactory` or a single-instance singleton), `UpdateCheckService`, `InstallerLocator`.
|
|
- `docs/open.md` — manual verification checklist entries.
|
|
|
|
---
|
|
|
|
## Task 1: Create `ClaudeDo.Releases` project skeleton
|
|
|
|
**Files:**
|
|
|
|
- Create: `src/ClaudeDo.Releases/ClaudeDo.Releases.csproj`
|
|
- Modify: `ClaudeDo.slnx`
|
|
- Modify: `src/ClaudeDo.Installer/ClaudeDo.Installer.csproj`
|
|
|
|
- [ ] **Step 1: Create the csproj**
|
|
|
|
Write `src/ClaudeDo.Releases/ClaudeDo.Releases.csproj`:
|
|
|
|
```xml
|
|
<Project Sdk="Microsoft.NET.Sdk">
|
|
<PropertyGroup>
|
|
<TargetFramework>net8.0</TargetFramework>
|
|
<Nullable>enable</Nullable>
|
|
<ImplicitUsings>enable</ImplicitUsings>
|
|
<LangVersion>latest</LangVersion>
|
|
</PropertyGroup>
|
|
</Project>
|
|
```
|
|
|
|
- [ ] **Step 2: Add to solution**
|
|
|
|
Edit `ClaudeDo.slnx` — add the new `<Project>` line under the `/src/` folder:
|
|
|
|
```xml
|
|
<Project Path="src/ClaudeDo.Releases/ClaudeDo.Releases.csproj" />
|
|
```
|
|
|
|
- [ ] **Step 3: Add reference from Installer**
|
|
|
|
Edit `src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` — add inside the existing `<ItemGroup>` that holds `<ProjectReference>` (or create one):
|
|
|
|
```xml
|
|
<ProjectReference Include="..\ClaudeDo.Releases\ClaudeDo.Releases.csproj" />
|
|
```
|
|
|
|
- [ ] **Step 4: Build to confirm skeleton compiles**
|
|
|
|
Run: `dotnet build src/ClaudeDo.Releases/ClaudeDo.Releases.csproj`
|
|
Expected: `Build succeeded.`
|
|
|
|
Run: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj`
|
|
Expected: `Build succeeded.` (still consumes types from its own `Core/` folder — nothing moved yet)
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Releases/ClaudeDo.Releases.csproj src/ClaudeDo.Installer/ClaudeDo.Installer.csproj ClaudeDo.slnx
|
|
git commit -m "feat(releases): add empty ClaudeDo.Releases library"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Move release-API + checksum types into `ClaudeDo.Releases`
|
|
|
|
**Files:**
|
|
|
|
- Create: `src/ClaudeDo.Releases/IReleaseClient.cs`
|
|
- Create: `src/ClaudeDo.Releases/ReleaseClient.cs`
|
|
- Create: `src/ClaudeDo.Releases/ChecksumVerifier.cs`
|
|
- Delete: `src/ClaudeDo.Installer/Core/IReleaseClient.cs`
|
|
- Delete: `src/ClaudeDo.Installer/Core/ReleaseClient.cs`
|
|
- Delete: `src/ClaudeDo.Installer/Core/ChecksumVerifier.cs`
|
|
|
|
- [ ] **Step 1: Move `IReleaseClient.cs` to Releases**
|
|
|
|
Create `src/ClaudeDo.Releases/IReleaseClient.cs` with the exact content of `src/ClaudeDo.Installer/Core/IReleaseClient.cs` but change the namespace from `ClaudeDo.Installer.Core` to `ClaudeDo.Releases`:
|
|
|
|
```csharp
|
|
namespace ClaudeDo.Releases;
|
|
|
|
public sealed record ReleaseAsset(string Name, string BrowserDownloadUrl, long Size);
|
|
|
|
public sealed record GiteaRelease(
|
|
string TagName,
|
|
string Name,
|
|
IReadOnlyList<ReleaseAsset> Assets);
|
|
|
|
public interface IReleaseClient
|
|
{
|
|
Task<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct);
|
|
|
|
Task DownloadAsync(string url, string destPath, IProgress<long> progress, CancellationToken ct);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Move `ReleaseClient.cs` to Releases**
|
|
|
|
Create `src/ClaudeDo.Releases/ReleaseClient.cs` with the content of `src/ClaudeDo.Installer/Core/ReleaseClient.cs`, changing the namespace to `ClaudeDo.Releases`. The rest of the file is unchanged (same HTTP logic, same `DefaultApiBase`).
|
|
|
|
- [ ] **Step 3: Move `ChecksumVerifier.cs` to Releases**
|
|
|
|
Create `src/ClaudeDo.Releases/ChecksumVerifier.cs` with the content of `src/ClaudeDo.Installer/Core/ChecksumVerifier.cs`, changing the namespace to `ClaudeDo.Releases`. No other changes.
|
|
|
|
- [ ] **Step 4: Delete the originals**
|
|
|
|
```bash
|
|
rm src/ClaudeDo.Installer/Core/IReleaseClient.cs \
|
|
src/ClaudeDo.Installer/Core/ReleaseClient.cs \
|
|
src/ClaudeDo.Installer/Core/ChecksumVerifier.cs
|
|
```
|
|
|
|
- [ ] **Step 5: Add `using ClaudeDo.Releases;` to every installer file that referenced these types**
|
|
|
|
Run `grep -l "ClaudeDo.Installer.Core" src/ClaudeDo.Installer --include="*.cs" -r` to find files that use types in `ClaudeDo.Installer.Core`. Inspect each and, wherever the file used `IReleaseClient`, `ReleaseClient`, `GiteaRelease`, `ReleaseAsset`, `ChecksumVerifier`, add `using ClaudeDo.Releases;`.
|
|
|
|
Known call sites (from the current code) — confirm and update each:
|
|
|
|
- `src/ClaudeDo.Installer/Core/InstallModeDetector.cs` — uses `IReleaseClient`, `GiteaRelease`
|
|
- `src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs` — uses `IReleaseClient`, `ChecksumVerifier`
|
|
- `src/ClaudeDo.Installer/App.xaml.cs` — registers `ReleaseClient` in DI
|
|
|
|
In `InstallModeDetector.cs`, the `DetectedState` record currently returns `GiteaRelease?`. That type is now in `ClaudeDo.Releases`. Keep the record signature identical, just add `using ClaudeDo.Releases;`.
|
|
|
|
- [ ] **Step 6: Build Installer to verify moves**
|
|
|
|
Run: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj`
|
|
Expected: `Build succeeded.`
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Releases/ src/ClaudeDo.Installer/
|
|
git commit -m "refactor(releases): move release-API + checksum types to ClaudeDo.Releases"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Create `ClaudeDo.Releases.Tests` and port existing tests
|
|
|
|
**Files:**
|
|
|
|
- Create: `tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj`
|
|
- Create: `tests/ClaudeDo.Releases.Tests/ReleaseClientTests.cs` (port)
|
|
- Create: `tests/ClaudeDo.Releases.Tests/ChecksumVerifierTests.cs` (port)
|
|
- Modify: `ClaudeDo.slnx`
|
|
- Modify (delete ported tests): `tests/ClaudeDo.Installer.Tests/...`
|
|
|
|
- [ ] **Step 1: Create the test csproj**
|
|
|
|
Write `tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj`:
|
|
|
|
```xml
|
|
<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>
|
|
```
|
|
|
|
- [ ] **Step 2: Add to solution**
|
|
|
|
Edit `ClaudeDo.slnx` — add under `/tests/` folder:
|
|
|
|
```xml
|
|
<Project Path="tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj" />
|
|
```
|
|
|
|
- [ ] **Step 3: Identify ported tests**
|
|
|
|
Run: `grep -l -E "ReleaseClient|ChecksumVerifier" tests/ClaudeDo.Installer.Tests/ -r --include="*.cs"`
|
|
Expected: a list of files (e.g. `ReleaseClientTests.cs`, `ChecksumVerifierTests.cs`). If no match, there are none to port and this task still proceeds — just skip the file copies.
|
|
|
|
- [ ] **Step 4: Copy each matched test file to `tests/ClaudeDo.Releases.Tests/`**
|
|
|
|
For each file from Step 3:
|
|
1. Copy to `tests/ClaudeDo.Releases.Tests/<filename>`.
|
|
2. Change namespace from `ClaudeDo.Installer.Tests...` to `ClaudeDo.Releases.Tests`.
|
|
3. Update `using ClaudeDo.Installer.Core;` → `using ClaudeDo.Releases;`.
|
|
4. Delete the original file in `tests/ClaudeDo.Installer.Tests/`.
|
|
|
|
- [ ] **Step 5: Build + run the new test project**
|
|
|
|
Run: `dotnet test tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj`
|
|
Expected: all ported tests pass (or `No tests were found` if none were ported — in which case the project still builds and we'll add tests in later tasks).
|
|
|
|
- [ ] **Step 6: Build + run the Installer tests (confirm no broken refs)**
|
|
|
|
Run: `dotnet test tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj`
|
|
Expected: `Build succeeded.` and all remaining tests pass.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add tests/ClaudeDo.Releases.Tests/ tests/ClaudeDo.Installer.Tests/ ClaudeDo.slnx
|
|
git commit -m "test(releases): port ReleaseClient + ChecksumVerifier tests to new project"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Add `VersionComparer` (TDD)
|
|
|
|
**Files:**
|
|
|
|
- Create: `src/ClaudeDo.Releases/VersionComparer.cs`
|
|
- Create: `tests/ClaudeDo.Releases.Tests/VersionComparerTests.cs`
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Write `tests/ClaudeDo.Releases.Tests/VersionComparerTests.cs`:
|
|
|
|
```csharp
|
|
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);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run to verify failure**
|
|
|
|
Run: `dotnet test tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj --filter VersionComparerTests`
|
|
Expected: FAIL — `VersionComparer` does not exist.
|
|
|
|
- [ ] **Step 3: Implement**
|
|
|
|
Write `src/ClaudeDo.Releases/VersionComparer.cs`:
|
|
|
|
```csharp
|
|
namespace ClaudeDo.Releases;
|
|
|
|
public readonly record struct VersionCompareResult(bool IsNewer, bool Unparseable);
|
|
|
|
public static class VersionComparer
|
|
{
|
|
/// <summary>
|
|
/// Returns IsNewer=true only when both versions parse as System.Version AND latest > current.
|
|
/// Pre-release tags like "0.2.0-beta" fail to parse and are treated as not newer with
|
|
/// Unparseable=true so callers can surface a hint.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests to verify pass**
|
|
|
|
Run: `dotnet test tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj --filter VersionComparerTests`
|
|
Expected: all pass.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Releases/VersionComparer.cs tests/ClaudeDo.Releases.Tests/VersionComparerTests.cs
|
|
git commit -m "feat(releases): add VersionComparer"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Refactor `InstallModeDetector` to use `VersionComparer`
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/ClaudeDo.Installer/Core/InstallModeDetector.cs`
|
|
|
|
- [ ] **Step 1: Replace the private `IsNewer` method with a call to `VersionComparer.Compare`**
|
|
|
|
Edit `src/ClaudeDo.Installer/Core/InstallModeDetector.cs`. The existing `DetectAsync` currently calls a private `IsNewer(latestVersion, manifest.Version, out var unparseable)`. Replace the method and its call site:
|
|
|
|
Inside `DetectAsync`, change:
|
|
|
|
```csharp
|
|
var newer = IsNewer(latestVersion, manifest.Version, out var unparseable);
|
|
```
|
|
|
|
to:
|
|
|
|
```csharp
|
|
var cmp = VersionComparer.Compare(latestVersion, manifest.Version);
|
|
var newer = cmp.IsNewer;
|
|
var unparseable = cmp.Unparseable;
|
|
```
|
|
|
|
Delete the entire private `IsNewer` method at the bottom of the file.
|
|
|
|
Ensure `using ClaudeDo.Releases;` is present at the top.
|
|
|
|
- [ ] **Step 2: Build + run existing tests**
|
|
|
|
Run: `dotnet test tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj`
|
|
Expected: all existing `InstallModeDetector` tests still pass — `VersionComparer` preserves identical semantics.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Installer/Core/InstallModeDetector.cs
|
|
git commit -m "refactor(installer): use shared VersionComparer in InstallModeDetector"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Add `SelfUpdater` — installer-asset matching (TDD)
|
|
|
|
**Files:**
|
|
|
|
- Create: `src/ClaudeDo.Releases/SelfUpdater.cs`
|
|
- Create: `src/ClaudeDo.Releases/SelfUpdateResult.cs`
|
|
- Create: `tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs`
|
|
|
|
- [ ] **Step 1: Write failing tests for asset matching**
|
|
|
|
Write `tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs`:
|
|
|
|
```csharp
|
|
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));
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Write the minimum stub to make the compiler happy**
|
|
|
|
Write `src/ClaudeDo.Releases/SelfUpdateResult.cs`:
|
|
|
|
```csharp
|
|
namespace ClaudeDo.Releases;
|
|
|
|
public sealed record InstallerAssetMatch(ReleaseAsset Asset, string Version);
|
|
```
|
|
|
|
Write `src/ClaudeDo.Releases/SelfUpdater.cs`:
|
|
|
|
```csharp
|
|
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;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Run tests to verify pass**
|
|
|
|
Run: `dotnet test tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj --filter SelfUpdaterAssetMatchingTests`
|
|
Expected: all 3 tests pass.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Releases/SelfUpdater.cs src/ClaudeDo.Releases/SelfUpdateResult.cs tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs
|
|
git commit -m "feat(releases): add SelfUpdater installer-asset matching"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Add `SelfUpdater.DecideUpdateAsync` (TDD)
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/ClaudeDo.Releases/SelfUpdater.cs`
|
|
- Modify: `src/ClaudeDo.Releases/SelfUpdateResult.cs`
|
|
- Modify: `tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs`
|
|
|
|
- [ ] **Step 1: Add failing decision-logic tests**
|
|
|
|
Append to `tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs`:
|
|
|
|
```csharp
|
|
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);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Add return types to `SelfUpdateResult.cs`**
|
|
|
|
Extend `src/ClaudeDo.Releases/SelfUpdateResult.cs`:
|
|
|
|
```csharp
|
|
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);
|
|
```
|
|
|
|
- [ ] **Step 3: Implement `DecideUpdateAsync`**
|
|
|
|
Add to `src/ClaudeDo.Releases/SelfUpdater.cs`:
|
|
|
|
```csharp
|
|
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);
|
|
}
|
|
```
|
|
|
|
Add `using System.Net.Http;` at the top.
|
|
|
|
- [ ] **Step 4: Run tests**
|
|
|
|
Run: `dotnet test tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj --filter SelfUpdater`
|
|
Expected: all SelfUpdater tests pass.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Releases/SelfUpdater.cs src/ClaudeDo.Releases/SelfUpdateResult.cs tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs
|
|
git commit -m "feat(releases): add SelfUpdater.DecideUpdateAsync"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Add `SelfUpdater.HandleReplaceSelfAsync` (TDD)
|
|
|
|
**Purpose:** When the installer is launched with `--replace-self "<old-path>"`, it waits for the old process to release its file lock, deletes the old exe, copies itself to the old path, relaunches from the old path, and exits.
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/ClaudeDo.Releases/SelfUpdater.cs`
|
|
- Modify: `tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs`
|
|
|
|
- [ ] **Step 1: Write failing tests**
|
|
|
|
Append to `tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs`:
|
|
|
|
```csharp
|
|
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);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Implement**
|
|
|
|
Add to `src/ClaudeDo.Releases/SelfUpdater.cs`:
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// Called when launched with `--replace-self "<old-path>"`. Waits for the old process
|
|
/// to release its file lock, deletes the old file, copies the current exe to the old path,
|
|
/// then calls <paramref name="launchProcess"/> with that path. Returns true on success.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Run tests**
|
|
|
|
Run: `dotnet test tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj --filter SelfUpdaterReplaceSelfTests`
|
|
Expected: both tests pass.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Releases/SelfUpdater.cs tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs
|
|
git commit -m "feat(releases): add SelfUpdater.HandleReplaceSelfAsync"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Add `SelfUpdater.DownloadAndVerifyAsync` (TDD)
|
|
|
|
**Purpose:** Download the new installer to `%TEMP%` and verify it against `checksums.txt`.
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/ClaudeDo.Releases/SelfUpdater.cs`
|
|
- Modify: `tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs`
|
|
|
|
- [ ] **Step 1: Write failing tests**
|
|
|
|
Append to `tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs`:
|
|
|
|
```csharp
|
|
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();
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Implement**
|
|
|
|
Add to `src/ClaudeDo.Releases/SelfUpdater.cs`:
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// Downloads the installer asset + checksums file to <paramref name="tempDir"/>, verifies the
|
|
/// installer's sha256 against the entry in checksums.txt keyed by asset name, and returns the
|
|
/// path to the verified installer on success. Returns null on download or verification failure.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
```
|
|
|
|
Add `using System.Net.Http;` and `using System.IO;` at the top if not present.
|
|
|
|
- [ ] **Step 3: Run tests**
|
|
|
|
Run: `dotnet test tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj --filter SelfUpdaterDownloadTests`
|
|
Expected: both pass.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Releases/SelfUpdater.cs tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs
|
|
git commit -m "feat(releases): add SelfUpdater.DownloadAndVerifyAsync"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Wire `SelfUpdater` into Installer `App.xaml.cs`
|
|
|
|
**Purpose:** Before the wizard window shows, (1) handle `--replace-self` if present, otherwise (2) check for a newer installer and prompt the user.
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/ClaudeDo.Installer/App.xaml.cs`
|
|
- Create: `src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml`
|
|
- Create: `src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml.cs`
|
|
|
|
- [ ] **Step 1: Create the prompt window XAML**
|
|
|
|
Write `src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml`:
|
|
|
|
```xml
|
|
<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="180"
|
|
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>
|
|
```
|
|
|
|
- [ ] **Step 2: Create the prompt window code-behind**
|
|
|
|
Write `src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml.cs`:
|
|
|
|
```csharp
|
|
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;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Modify `App.xaml.cs` OnStartup to run self-update first**
|
|
|
|
Edit `src/ClaudeDo.Installer/App.xaml.cs`. Locate `OnStartup`; immediately after `base.OnStartup(e);` (but before service construction and main window show), insert the self-update logic. Replace the relevant top of `OnStartup` with:
|
|
|
|
```csharp
|
|
protected override async void OnStartup(StartupEventArgs e)
|
|
{
|
|
base.OnStartup(e);
|
|
|
|
// --- Self-update pre-flight ---
|
|
var currentExePath = System.Reflection.Assembly.GetEntryAssembly()!.Location;
|
|
if (currentExePath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
currentExePath = System.IO.Path.ChangeExtension(currentExePath, ".exe");
|
|
}
|
|
|
|
// Argument 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 ClaudeDo.Releases.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;
|
|
}
|
|
// If replacement failed, fall through to normal wizard from the temp location.
|
|
}
|
|
else
|
|
{
|
|
// Normal launch: check for a newer installer.
|
|
using var http = new System.Net.Http.HttpClient();
|
|
var releases = new ClaudeDo.Releases.ReleaseClient(http);
|
|
var currentVersion = InstallContext.GetInstallerVersion();
|
|
|
|
var decision = await ClaudeDo.Releases.SelfUpdater.DecideUpdateAsync(releases, currentVersion, CancellationToken.None);
|
|
if (decision.Kind == ClaudeDo.Releases.SelfUpdateDecisionKind.UpdateAvailable)
|
|
{
|
|
var prompt = new Views.SelfUpdatePromptWindow(currentVersion, decision.LatestVersion!);
|
|
var ok = prompt.ShowDialog() == true;
|
|
if (!ok)
|
|
{
|
|
Shutdown(0);
|
|
return;
|
|
}
|
|
if (prompt.Choice == Views.SelfUpdateChoice.Update)
|
|
{
|
|
prompt.ShowProgress("Downloading...");
|
|
var tempDir = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "ClaudeDo.Installer.Update");
|
|
var verifiedPath = await ClaudeDo.Releases.SelfUpdater.DownloadAndVerifyAsync(
|
|
releases,
|
|
decision.InstallerAsset!,
|
|
decision.ChecksumsAsset!,
|
|
tempDir,
|
|
new Progress<long>(bytes => { /* could update prompt.ProgressText */ }),
|
|
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
|
|
{
|
|
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(verifiedPath)
|
|
{
|
|
ArgumentList = { "--replace-self", currentExePath },
|
|
UseShellExecute = true,
|
|
});
|
|
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 falls through to normal wizard.
|
|
}
|
|
// Failure / no-update also falls through to normal wizard.
|
|
}
|
|
|
|
// --- Existing wizard start-up unchanged below this line ---
|
|
// (Preserve the rest of the original OnStartup body as-is.)
|
|
```
|
|
|
|
> **Note:** If `InstallContext.GetInstallerVersion()` does not yet return a `System.Version`-parseable string, see Step 4.
|
|
|
|
- [ ] **Step 4: Ensure `InstallContext.GetInstallerVersion()` returns a parseable version**
|
|
|
|
Open `src/ClaudeDo.Installer/Core/InstallContext.cs` and inspect the existing `GetInstallerVersion()`. If it returns something like `"0.0.0-dev"` or an informational string, add a variant that returns the raw assembly `Version.ToString()` (e.g. `"0.0.0.0"`). If it already returns a numeric `Major.Minor.Build` string, no change is needed.
|
|
|
|
If a change is required, add (or replace) with:
|
|
|
|
```csharp
|
|
public static string GetInstallerVersion()
|
|
{
|
|
var v = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version ?? new Version(0, 0, 0, 0);
|
|
return v.ToString();
|
|
}
|
|
```
|
|
|
|
Only change this method if needed — preserve existing behavior if it already returns a parseable version.
|
|
|
|
- [ ] **Step 5: Build + smoke-test**
|
|
|
|
Run: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj`
|
|
Expected: `Build succeeded.`
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Installer/App.xaml.cs src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml.cs src/ClaudeDo.Installer/Core/InstallContext.cs
|
|
git commit -m "feat(installer): self-update pre-flight before wizard"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: Add `UpdateCheckService` in `ClaudeDo.Ui.Services`
|
|
|
|
**Files:**
|
|
|
|
- Create: `src/ClaudeDo.Ui/Services/UpdateCheckService.cs`
|
|
- Create: `tests/ClaudeDo.Ui.Tests/Services/UpdateCheckServiceTests.cs`
|
|
- Modify: `src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
|
|
|
- [ ] **Step 1: Add project reference from Ui**
|
|
|
|
Edit `src/ClaudeDo.Ui/ClaudeDo.Ui.csproj` — inside the existing `<ItemGroup>` holding `<ProjectReference>` items (or create one), add:
|
|
|
|
```xml
|
|
<ProjectReference Include="..\ClaudeDo.Releases\ClaudeDo.Releases.csproj" />
|
|
```
|
|
|
|
- [ ] **Step 2: Write failing tests**
|
|
|
|
Write `tests/ClaudeDo.Ui.Tests/Services/UpdateCheckServiceTests.cs`:
|
|
|
|
```csharp
|
|
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);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Implement `UpdateCheckService`**
|
|
|
|
Write `src/ClaudeDo.Ui/Services/UpdateCheckService.cs`:
|
|
|
|
```csharp
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests**
|
|
|
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj --filter UpdateCheckServiceTests`
|
|
Expected: all pass.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Ui/Services/UpdateCheckService.cs src/ClaudeDo.Ui/ClaudeDo.Ui.csproj tests/ClaudeDo.Ui.Tests/
|
|
git commit -m "feat(ui): add UpdateCheckService"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 12: Add `InstallerLocator` in `ClaudeDo.Ui.Services`
|
|
|
|
**Files:**
|
|
|
|
- Create: `src/ClaudeDo.Ui/Services/InstallerLocator.cs`
|
|
- Create: `tests/ClaudeDo.Ui.Tests/Services/InstallerLocatorTests.cs`
|
|
|
|
- [ ] **Step 1: Write failing tests**
|
|
|
|
Write `tests/ClaudeDo.Ui.Tests/Services/InstallerLocatorTests.cs`:
|
|
|
|
```csharp
|
|
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));
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Implement**
|
|
|
|
Write `src/ClaudeDo.Ui/Services/InstallerLocator.cs`:
|
|
|
|
```csharp
|
|
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";
|
|
|
|
/// <summary>
|
|
/// Convenience entry point for production callers: starts from AppContext.BaseDirectory
|
|
/// and falls back to registry if the walk fails.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
If the Ui csproj does not already pull in `Microsoft.Win32.Registry`, add:
|
|
|
|
```xml
|
|
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
|
|
```
|
|
|
|
(Windows-only runtime reference; Avalonia can ship cross-platform so gate usage with `OperatingSystem.IsWindows()` as shown.)
|
|
|
|
- [ ] **Step 3: Run tests**
|
|
|
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj --filter InstallerLocatorTests`
|
|
Expected: all pass.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Ui/Services/InstallerLocator.cs src/ClaudeDo.Ui/ClaudeDo.Ui.csproj tests/ClaudeDo.Ui.Tests/Services/InstallerLocatorTests.cs
|
|
git commit -m "feat(ui): add InstallerLocator"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 13: Wire new services into `ClaudeDo.App/Program.cs` DI
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/ClaudeDo.App/Program.cs`
|
|
|
|
- [ ] **Step 1: Add new service registrations**
|
|
|
|
Edit `src/ClaudeDo.App/Program.cs`. Inside `BuildServices()` (or whatever method builds the `ServiceCollection`), register:
|
|
|
|
```csharp
|
|
sc.AddSingleton<System.Net.Http.HttpClient>();
|
|
sc.AddSingleton<ClaudeDo.Releases.IReleaseClient>(sp =>
|
|
new ClaudeDo.Releases.ReleaseClient(sp.GetRequiredService<System.Net.Http.HttpClient>()));
|
|
|
|
sc.AddSingleton<ClaudeDo.Ui.Services.InstallerLocator>();
|
|
sc.AddSingleton(sp =>
|
|
{
|
|
var releases = sp.GetRequiredService<ClaudeDo.Releases.IReleaseClient>();
|
|
var version = System.Reflection.Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "0.0.0.0";
|
|
return new ClaudeDo.Ui.Services.UpdateCheckService(releases, version);
|
|
});
|
|
```
|
|
|
|
Place the registrations alongside existing singleton registrations. Keep the order logical: `HttpClient` → `IReleaseClient` → services that depend on them.
|
|
|
|
- [ ] **Step 2: Inject `UpdateCheckService` and `InstallerLocator` into `IslandsShellViewModel`**
|
|
|
|
Locate `IslandsShellViewModel`'s constructor. Add parameters for both services and forward them to fields. Also update the `IslandsShellViewModel` DI registration in `Program.cs` so that these are resolved through the container (normally Microsoft DI does this automatically as long as the ViewModel is registered with `AddSingleton<IslandsShellViewModel>()` or similar — verify by inspecting the existing registration).
|
|
|
|
- [ ] **Step 3: Build**
|
|
|
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
|
|
Expected: `Build succeeded.`
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.App/Program.cs src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs
|
|
git commit -m "feat(app): register UpdateCheckService and InstallerLocator in DI"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 14: Extend `IslandsShellViewModel` with update-check state + commands
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`
|
|
|
|
- [ ] **Step 1: Add fields, properties, and commands**
|
|
|
|
At the top of `IslandsShellViewModel`, add (or extend) fields:
|
|
|
|
```csharp
|
|
private readonly UpdateCheckService _updateCheck;
|
|
private readonly InstallerLocator _installerLocator;
|
|
|
|
[ObservableProperty] private bool _isUpdateBannerVisible;
|
|
[ObservableProperty] private string? _updateBannerLatestVersion;
|
|
[ObservableProperty] private string? _inlineUpdateStatus; // short message shown briefly
|
|
private bool _bannerDismissedThisSession;
|
|
```
|
|
|
|
Add `using ClaudeDo.Ui.Services;` and `using CommunityToolkit.Mvvm.ComponentModel;` if not already present.
|
|
|
|
- [ ] **Step 2: Subscribe to update service**
|
|
|
|
In the constructor, after assigning fields:
|
|
|
|
```csharp
|
|
_updateCheck.PropertyChanged += (_, e) =>
|
|
{
|
|
if (e.PropertyName == nameof(UpdateCheckService.LastCheckStatus))
|
|
{
|
|
RefreshBannerFromStatus();
|
|
}
|
|
};
|
|
// Fire-and-forget startup check — never block UI on this.
|
|
_ = Task.Run(async () =>
|
|
{
|
|
try { await _updateCheck.CheckNowAsync(CancellationToken.None); } catch { }
|
|
});
|
|
```
|
|
|
|
And the helper:
|
|
|
|
```csharp
|
|
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;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Add commands**
|
|
|
|
Add three commands:
|
|
|
|
```csharp
|
|
[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); // close the app so the installer can replace its files
|
|
}
|
|
catch
|
|
{
|
|
// Intentionally silent — if this fails there's not much we can do from UI.
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Build**
|
|
|
|
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
|
|
Expected: `Build succeeded.`
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs
|
|
git commit -m "feat(ui): wire update-check state and commands into shell VM"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 15: Add banner + Help menu to `MainWindow.axaml`
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml`
|
|
|
|
- [ ] **Step 1: Adjust the Grid RowDefinitions to leave room for a banner**
|
|
|
|
In `src/ClaudeDo.Ui/Views/MainWindow.axaml`, change:
|
|
|
|
```xml
|
|
<Grid RowDefinitions="36,*,22">
|
|
```
|
|
|
|
to:
|
|
|
|
```xml
|
|
<Grid RowDefinitions="36,Auto,*,22">
|
|
```
|
|
|
|
Then shift every child element in Row 1 (content) and Row 2 (status bar) down by one: `Grid.Row="1"` → `Grid.Row="2"`, `Grid.Row="2"` → `Grid.Row="3"`. Be systematic — search the file for `Grid.Row="1"` and `Grid.Row="2"` and update every occurrence.
|
|
|
|
- [ ] **Step 2: Insert the banner at `Grid.Row="1"`**
|
|
|
|
Add (at the appropriate place, sibling to the titlebar and content rows):
|
|
|
|
```xml
|
|
<Border Grid.Row="1"
|
|
Background="{DynamicResource AccentSoftBrush}"
|
|
BorderBrush="{DynamicResource LineBrush}"
|
|
BorderThickness="0,0,0,1"
|
|
Padding="12,6"
|
|
IsVisible="{Binding IsUpdateBannerVisible}">
|
|
<Grid ColumnDefinitions="*,Auto,Auto">
|
|
<TextBlock Grid.Column="0"
|
|
VerticalAlignment="Center"
|
|
Foreground="{DynamicResource TextDimBrush}">
|
|
<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"
|
|
Content="Update now"
|
|
Command="{Binding UpdateNowCommand}"/>
|
|
<Button Grid.Column="2"
|
|
Content="Dismiss"
|
|
Command="{Binding DismissBannerCommand}"/>
|
|
</Grid>
|
|
</Border>
|
|
<TextBlock Grid.Row="1" HorizontalAlignment="Right" VerticalAlignment="Center"
|
|
Margin="0,0,12,0"
|
|
Foreground="{DynamicResource TextFaintBrush}"
|
|
Text="{Binding InlineUpdateStatus}"
|
|
IsVisible="{Binding InlineUpdateStatus, Converter={StaticResource StringNotEmptyConverter}}"/>
|
|
```
|
|
|
|
If `StringNotEmptyConverter` does not exist, use a simple `TextBlock` bound to `InlineUpdateStatus` and rely on empty text being invisible-ish (set `IsVisible="{Binding InlineUpdateStatus, Converter={x:Static ObjectConverters.IsNotNull}}"` — Avalonia provides `ObjectConverters.IsNotNull` in `Avalonia.Data.Converters`).
|
|
|
|
The VM field for the banner's "current version" text exposes `UpdateCheck.CurrentVersion` — add an exposed property on `IslandsShellViewModel`: `public UpdateCheckService UpdateCheck => _updateCheck;` so the XAML can bind to nested properties.
|
|
|
|
- [ ] **Step 3: Add Help menu to the titlebar**
|
|
|
|
Locate the titlebar row (`Grid.Row="0"`). The current titlebar has `ColumnDefinitions="Auto,*,Auto"` — add a menu in the middle (column 1) or as a new element in column 0 after the existing app-title content. A safe insertion: in the leftmost column next to the app icon/title, add:
|
|
|
|
```xml
|
|
<Menu Grid.Column="0" Margin="12,0,0,0" VerticalAlignment="Center"
|
|
Background="Transparent">
|
|
<MenuItem Header="Help">
|
|
<MenuItem Header="Check for updates" Command="{Binding CheckForUpdatesCommand}"/>
|
|
</MenuItem>
|
|
</Menu>
|
|
```
|
|
|
|
Placement is flexible — pick a spot that matches the existing titlebar layout (you may need to read the current titlebar `StackPanel`/`Grid` around line 30-60 of the file). Prefer adding the menu to whichever column already holds titlebar controls.
|
|
|
|
- [ ] **Step 4: Add a property on `IslandsShellViewModel` exposing the service**
|
|
|
|
In `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`, add:
|
|
|
|
```csharp
|
|
public UpdateCheckService UpdateCheck => _updateCheck;
|
|
```
|
|
|
|
- [ ] **Step 5: Build + run the app manually**
|
|
|
|
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj src/ClaudeDo.App/ClaudeDo.App.csproj`
|
|
Expected: `Build succeeded.`
|
|
|
|
Run: `dotnet run --project src/ClaudeDo.App/ClaudeDo.App.csproj`
|
|
Expected: the app launches; Help menu is visible in the titlebar; no banner shown unless a newer release actually exists in Gitea.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Ui/Views/MainWindow.axaml src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs
|
|
git commit -m "feat(ui): add update banner and Help menu to MainWindow"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 16: Add manual verification checklist to `docs/open.md`
|
|
|
|
**Files:**
|
|
|
|
- Modify: `docs/open.md`
|
|
|
|
- [ ] **Step 1: Append a new section**
|
|
|
|
Add to the end of `docs/open.md`:
|
|
|
|
```markdown
|
|
## Self-Update — Manual Verification
|
|
|
|
Preconditions: a working Gitea release at `git.kuns.dev/releases/ClaudeDo` with two asset names the code expects — `ClaudeDo-<version>-win-x64.zip`, `ClaudeDo.Installer-<version>.exe` — and `checksums.txt` listing both.
|
|
|
|
1. Install current 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 banner appears with "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".
|
|
6. Run the `v0.2.x` installer manually — confirm it prompts to self-update to v0.3.0. Click **Update** — confirm the running exe is replaced and the wizard opens on the new version.
|
|
7. Repeat step 6 with **Continue anyway** — confirm wizard opens without self-update.
|
|
8. Kill network during startup in both the app and the installer — confirm silent fallback (no errors, no banner, wizard opens normally).
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add docs/open.md
|
|
git commit -m "docs(open): add self-update manual verification checklist"
|
|
```
|
|
|
|
---
|
|
|
|
## Self-Review
|
|
|
|
Before handing off: walk through `docs/superpowers/specs/2026-04-23-self-update-design.md` one more time and confirm each section maps to a task above.
|
|
|
|
- Shared module (Spec §Architecture) → Tasks 1, 2, 3
|
|
- `VersionComparer` (Spec §Version Comparison) → Tasks 4, 5
|
|
- `SelfUpdater` asset matching (Spec §B, step 3) → Task 6
|
|
- `SelfUpdater` decision (Spec §B, step 2) → Task 7
|
|
- `--replace-self` handler (Spec §B, step 1 + relaunch) → Task 8
|
|
- Download + verify (Spec §B, step 4) → Task 9
|
|
- Installer entry integration + prompt (Spec §B, step 3) → Task 10
|
|
- `UpdateCheckService` (Spec §A) → Task 11
|
|
- `InstallerLocator` (Spec §A) → Task 12
|
|
- DI wiring (Spec §Architecture Overview) → Task 13
|
|
- Shell VM state + commands (Spec §A UI changes + Update action flow) → Task 14
|
|
- Banner + Help menu (Spec §A UI changes) → Task 15
|
|
- Manual verification (Spec §Testing) → Task 16
|
|
|
|
No gaps.
|