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

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