Merge branch 'feat/self-update'

Self-update for app and installer. Integrates cleanly with the
worker-log-footer feature that landed on main in parallel — the
shell VM now carries both worker-log state and update-check state,
and MainWindow hosts both the update banner and the footer log line.

Conflict resolved in IslandsShellViewModel.cs: kept nullable property
types from main's test-only parameterless constructor work, and added
the UpdateCheck property exposing the injected service.
This commit is contained in:
mika kuns
2026-04-23 15:24:07 +02:00
33 changed files with 1074 additions and 26 deletions

View File

@@ -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;

View File

@@ -1,4 +1,5 @@
using ClaudeDo.Installer.Core;
using ClaudeDo.Releases;
namespace ClaudeDo.Installer.Tests;

View File

@@ -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
{

View File

@@ -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>

View File

@@ -1,7 +1,7 @@
using System.Net;
using System.Net.Http;
namespace ClaudeDo.Installer.Tests;
namespace ClaudeDo.Releases.Tests;
internal sealed class FakeHttpMessageHandler : HttpMessageHandler
{

View File

@@ -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
{

View File

@@ -0,0 +1,256 @@
using System.Net.Http;
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));
}
}
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);
}
}
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);
}
}
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();
}
}

View File

@@ -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);
}
}

View File

@@ -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));
}
}

View File

@@ -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);
}
}