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

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