diff --git a/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj b/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
index dc83e5e..c296826 100644
--- a/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
+++ b/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
@@ -2,6 +2,7 @@
+
diff --git a/src/ClaudeDo.Ui/Services/UpdateCheckService.cs b/src/ClaudeDo.Ui/Services/UpdateCheckService.cs
new file mode 100644
index 0000000..bd72759
--- /dev/null
+++ b/src/ClaudeDo.Ui/Services/UpdateCheckService.cs
@@ -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;
+ }
+ }
+}
diff --git a/tests/ClaudeDo.Ui.Tests/Services/UpdateCheckServiceTests.cs b/tests/ClaudeDo.Ui.Tests/Services/UpdateCheckServiceTests.cs
new file mode 100644
index 0000000..77b5956
--- /dev/null
+++ b/tests/ClaudeDo.Ui.Tests/Services/UpdateCheckServiceTests.cs
@@ -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 GetLatestReleaseAsync(CancellationToken ct)
+ {
+ if (Throw) throw new HttpRequestException();
+ return Task.FromResult(Release);
+ }
+
+ public Task DownloadAsync(string url, string destPath, IProgress 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);
+ }
+}