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:
@@ -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>
|
||||
|
||||
47
src/ClaudeDo.Ui/Services/InstallerLocator.cs
Normal file
47
src/ClaudeDo.Ui/Services/InstallerLocator.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
73
src/ClaudeDo.Ui/Services/UpdateCheckService.cs
Normal file
73
src/ClaudeDo.Ui/Services/UpdateCheckService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user