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

3
.gitignore vendored
View File

@@ -1,3 +1,6 @@
# Local dev worktrees (created by using-git-worktrees skill)
.worktrees/
# .NET build output
bin/
obj/

View File

@@ -5,10 +5,12 @@
<Project Path="src/ClaudeDo.Ui/ClaudeDo.Ui.csproj" />
<Project Path="src/ClaudeDo.Worker/ClaudeDo.Worker.csproj" />
<Project Path="src/ClaudeDo.Installer/ClaudeDo.Installer.csproj" />
<Project Path="src/ClaudeDo.Releases/ClaudeDo.Releases.csproj" />
</Folder>
<Folder Name="/tests/">
<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>

View File

@@ -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 13 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).

View File

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

View File

@@ -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;
@@ -21,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>();

View File

@@ -45,6 +45,7 @@
<ItemGroup>
<ProjectReference Include="..\ClaudeDo.Data\ClaudeDo.Data.csproj" />
<ProjectReference Include="..\ClaudeDo.Releases\ClaudeDo.Releases.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,3 +1,5 @@
using ClaudeDo.Releases;
namespace ClaudeDo.Installer.Core;
public sealed record DetectedState(
@@ -31,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);
@@ -41,16 +45,4 @@ public sealed class InstallModeDetector
};
}
/// <summary>
/// Returns true only when both versions parse as System.Version (major.minor[.build[.revision]])
/// AND latest &gt; 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;
}
}

View File

@@ -1,6 +1,7 @@
using System.IO;
using System.IO.Compression;
using ClaudeDo.Installer.Core;
using ClaudeDo.Releases;
namespace ClaudeDo.Installer.Steps;

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
using System.IO;
using System.Security.Cryptography;
namespace ClaudeDo.Installer.Core;
namespace ClaudeDo.Releases;
public static class ChecksumVerifier
{

View File

@@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
</PropertyGroup>
</Project>

View File

@@ -1,4 +1,4 @@
namespace ClaudeDo.Installer.Core;
namespace ClaudeDo.Releases;
public sealed record ReleaseAsset(string Name, string BrowserDownloadUrl, long Size);

View File

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

View File

@@ -0,0 +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);

View File

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

View File

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

View File

@@ -2,6 +2,7 @@
<ItemGroup>
<ProjectReference Include="..\ClaudeDo.Data\ClaudeDo.Data.csproj" />
<ProjectReference Include="..\ClaudeDo.Releases\ClaudeDo.Releases.csproj" />
</ItemGroup>
<ItemGroup>
@@ -9,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>
<ItemGroup>

View File

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

View File

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

View File

@@ -1,4 +1,7 @@
using Avalonia.Threading;
using System;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data.Models;
@@ -13,6 +16,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 == true ? "Online"
@@ -21,6 +25,14 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
public bool IsOffline => Worker?.IsConnected != true && Worker?.IsReconnecting != true;
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;
@@ -79,9 +91,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();
@@ -109,5 +125,74 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
Dispatcher.UIThread.Post(ClearWorkerLog);
};
_ = 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.
}
}
}

View File

@@ -21,7 +21,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}"
@@ -58,6 +58,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 -->
@@ -81,8 +92,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}" />
@@ -92,7 +142,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>
@@ -106,7 +156,7 @@
</Grid>
<!-- Footer: connection status -->
<Border Grid.Row="2"
<Border Grid.Row="3"
Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,1,0,0">

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