diff --git a/.gitignore b/.gitignore
index 13cbffe..9fadc0a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,6 @@
+# Local dev worktrees (created by using-git-worktrees skill)
+.worktrees/
+
# .NET build output
bin/
obj/
diff --git a/ClaudeDo.slnx b/ClaudeDo.slnx
index 178690d..eb6dbf9 100644
--- a/ClaudeDo.slnx
+++ b/ClaudeDo.slnx
@@ -5,10 +5,12 @@
+
+
diff --git a/docs/open.md b/docs/open.md
index 06cb836..a12020d 100644
--- a/docs/open.md
+++ b/docs/open.md
@@ -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 1–3 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--win-x64.zip`, `ClaudeDo.Installer-.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).
diff --git a/src/ClaudeDo.App/Program.cs b/src/ClaudeDo.App/Program.cs
index 26c136a..2c07966 100644
--- a/src/ClaudeDo.App/Program.cs
+++ b/src/ClaudeDo.App/Program.cs
@@ -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();
sc.AddSingleton(sp => new WorkerClient(sp.GetRequiredService().SignalRUrl));
+ // Release check + installer update
+ sc.AddSingleton(_ => new HttpClient { Timeout = TimeSpan.FromSeconds(10) });
+ sc.AddSingleton(sp => new ReleaseClient(sp.GetRequiredService()));
+ sc.AddSingleton();
+ sc.AddSingleton(sp =>
+ {
+ var releases = sp.GetRequiredService();
+ var version = Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "0.0.0.0";
+ return new UpdateCheckService(releases, version);
+ });
+
// ViewModels
sc.AddTransient();
sc.AddTransient();
diff --git a/src/ClaudeDo.Installer/App.xaml.cs b/src/ClaudeDo.Installer/App.xaml.cs
index fd282fb..33e0645 100644
--- a/src/ClaudeDo.Installer/App.xaml.cs
+++ b/src/ClaudeDo.Installer/App.xaml.cs
@@ -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 ""
+ 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(_ => { }),
+ 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();
diff --git a/src/ClaudeDo.Installer/ClaudeDo.Installer.csproj b/src/ClaudeDo.Installer/ClaudeDo.Installer.csproj
index 139ba01..eb58cf3 100644
--- a/src/ClaudeDo.Installer/ClaudeDo.Installer.csproj
+++ b/src/ClaudeDo.Installer/ClaudeDo.Installer.csproj
@@ -45,6 +45,7 @@
+
diff --git a/src/ClaudeDo.Installer/Core/InstallModeDetector.cs b/src/ClaudeDo.Installer/Core/InstallModeDetector.cs
index 574d300..81fa819 100644
--- a/src/ClaudeDo.Installer/Core/InstallModeDetector.cs
+++ b/src/ClaudeDo.Installer/Core/InstallModeDetector.cs
@@ -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
};
}
- ///
- /// Returns true only when both versions parse as System.Version (major.minor[.build[.revision]])
- /// AND latest > 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
- /// is set so the UI can surface a hint.
- ///
- 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;
- }
}
diff --git a/src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs b/src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs
index aa97f78..515c54d 100644
--- a/src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs
+++ b/src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs
@@ -1,6 +1,7 @@
using System.IO;
using System.IO.Compression;
using ClaudeDo.Installer.Core;
+using ClaudeDo.Releases;
namespace ClaudeDo.Installer.Steps;
diff --git a/src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml b/src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml
new file mode 100644
index 0000000..e9f9aa4
--- /dev/null
+++ b/src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml.cs b/src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml.cs
new file mode 100644
index 0000000..9d73059
--- /dev/null
+++ b/src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml.cs
@@ -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;
+ }
+}
diff --git a/src/ClaudeDo.Installer/Views/SettingsViewModel.cs b/src/ClaudeDo.Installer/Views/SettingsViewModel.cs
index 60afb58..f79119d 100644
--- a/src/ClaudeDo.Installer/Views/SettingsViewModel.cs
+++ b/src/ClaudeDo.Installer/Views/SettingsViewModel.cs
@@ -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;
diff --git a/src/ClaudeDo.Installer/Core/ChecksumVerifier.cs b/src/ClaudeDo.Releases/ChecksumVerifier.cs
similarity index 97%
rename from src/ClaudeDo.Installer/Core/ChecksumVerifier.cs
rename to src/ClaudeDo.Releases/ChecksumVerifier.cs
index b2835a1..d94955c 100644
--- a/src/ClaudeDo.Installer/Core/ChecksumVerifier.cs
+++ b/src/ClaudeDo.Releases/ChecksumVerifier.cs
@@ -1,7 +1,7 @@
using System.IO;
using System.Security.Cryptography;
-namespace ClaudeDo.Installer.Core;
+namespace ClaudeDo.Releases;
public static class ChecksumVerifier
{
diff --git a/src/ClaudeDo.Releases/ClaudeDo.Releases.csproj b/src/ClaudeDo.Releases/ClaudeDo.Releases.csproj
new file mode 100644
index 0000000..ad1ad4c
--- /dev/null
+++ b/src/ClaudeDo.Releases/ClaudeDo.Releases.csproj
@@ -0,0 +1,8 @@
+
+
+ net8.0
+ enable
+ enable
+ latest
+
+
diff --git a/src/ClaudeDo.Installer/Core/IReleaseClient.cs b/src/ClaudeDo.Releases/IReleaseClient.cs
similarity index 92%
rename from src/ClaudeDo.Installer/Core/IReleaseClient.cs
rename to src/ClaudeDo.Releases/IReleaseClient.cs
index 8c82449..96dd10b 100644
--- a/src/ClaudeDo.Installer/Core/IReleaseClient.cs
+++ b/src/ClaudeDo.Releases/IReleaseClient.cs
@@ -1,4 +1,4 @@
-namespace ClaudeDo.Installer.Core;
+namespace ClaudeDo.Releases;
public sealed record ReleaseAsset(string Name, string BrowserDownloadUrl, long Size);
diff --git a/src/ClaudeDo.Installer/Core/ReleaseClient.cs b/src/ClaudeDo.Releases/ReleaseClient.cs
similarity index 98%
rename from src/ClaudeDo.Installer/Core/ReleaseClient.cs
rename to src/ClaudeDo.Releases/ReleaseClient.cs
index 78550dd..160845d 100644
--- a/src/ClaudeDo.Installer/Core/ReleaseClient.cs
+++ b/src/ClaudeDo.Releases/ReleaseClient.cs
@@ -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
{
diff --git a/src/ClaudeDo.Releases/SelfUpdateResult.cs b/src/ClaudeDo.Releases/SelfUpdateResult.cs
new file mode 100644
index 0000000..6d2f96e
--- /dev/null
+++ b/src/ClaudeDo.Releases/SelfUpdateResult.cs
@@ -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);
diff --git a/src/ClaudeDo.Releases/SelfUpdater.cs b/src/ClaudeDo.Releases/SelfUpdater.cs
new file mode 100644
index 0000000..e0004d1
--- /dev/null
+++ b/src/ClaudeDo.Releases/SelfUpdater.cs
@@ -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-(?[\d\.]+)\.exe$", RegexOptions.IgnoreCase)]
+ private static partial Regex InstallerAssetRegex();
+
+ public static InstallerAssetMatch? FindInstallerAsset(IEnumerable 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 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 HandleReplaceSelfAsync(
+ string oldPath,
+ string currentExePath,
+ Func 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 DownloadAndVerifyAsync(
+ IReleaseClient releases,
+ ReleaseAsset installerAsset,
+ ReleaseAsset checksumsAsset,
+ string tempDir,
+ IProgress 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(_ => { }), 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;
+ }
+}
diff --git a/src/ClaudeDo.Releases/VersionComparer.cs b/src/ClaudeDo.Releases/VersionComparer.cs
new file mode 100644
index 0000000..e38d1fc
--- /dev/null
+++ b/src/ClaudeDo.Releases/VersionComparer.cs
@@ -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);
+ }
+}
diff --git a/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj b/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
index 7759ff4..2ea6f07 100644
--- a/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
+++ b/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
@@ -2,6 +2,7 @@
+
@@ -9,6 +10,7 @@
+
diff --git a/src/ClaudeDo.Ui/Services/InstallerLocator.cs b/src/ClaudeDo.Ui/Services/InstallerLocator.cs
new file mode 100644
index 0000000..de15542
--- /dev/null
+++ b/src/ClaudeDo.Ui/Services/InstallerLocator.cs
@@ -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;
+ }
+ }
+}
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/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs
index f7beed4..fdb3e82 100644
--- a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs
+++ b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs
@@ -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.
+ }
}
}
diff --git a/src/ClaudeDo.Ui/Views/MainWindow.axaml b/src/ClaudeDo.Ui/Views/MainWindow.axaml
index c3ef606..ff3e56f 100644
--- a/src/ClaudeDo.Ui/Views/MainWindow.axaml
+++ b/src/ClaudeDo.Ui/Views/MainWindow.axaml
@@ -21,7 +21,7 @@
-
+
+
+
@@ -81,8 +92,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
@@ -92,7 +142,7 @@
-
+
@@ -106,7 +156,7 @@
-
diff --git a/tests/ClaudeDo.Installer.Tests/DownloadAndExtractStepTests.cs b/tests/ClaudeDo.Installer.Tests/DownloadAndExtractStepTests.cs
index 888cfa0..cbba0df 100644
--- a/tests/ClaudeDo.Installer.Tests/DownloadAndExtractStepTests.cs
+++ b/tests/ClaudeDo.Installer.Tests/DownloadAndExtractStepTests.cs
@@ -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;
diff --git a/tests/ClaudeDo.Installer.Tests/InstallModeDetectorTests.cs b/tests/ClaudeDo.Installer.Tests/InstallModeDetectorTests.cs
index 223a0a7..6fce8e5 100644
--- a/tests/ClaudeDo.Installer.Tests/InstallModeDetectorTests.cs
+++ b/tests/ClaudeDo.Installer.Tests/InstallModeDetectorTests.cs
@@ -1,4 +1,5 @@
using ClaudeDo.Installer.Core;
+using ClaudeDo.Releases;
namespace ClaudeDo.Installer.Tests;
diff --git a/tests/ClaudeDo.Installer.Tests/ChecksumVerifierTests.cs b/tests/ClaudeDo.Releases.Tests/ChecksumVerifierTests.cs
similarity index 97%
rename from tests/ClaudeDo.Installer.Tests/ChecksumVerifierTests.cs
rename to tests/ClaudeDo.Releases.Tests/ChecksumVerifierTests.cs
index d72a0d5..4023d9d 100644
--- a/tests/ClaudeDo.Installer.Tests/ChecksumVerifierTests.cs
+++ b/tests/ClaudeDo.Releases.Tests/ChecksumVerifierTests.cs
@@ -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
{
diff --git a/tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj b/tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj
new file mode 100644
index 0000000..fb0bb98
--- /dev/null
+++ b/tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj
@@ -0,0 +1,20 @@
+
+
+ net8.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/ClaudeDo.Installer.Tests/FakeHttpMessageHandler.cs b/tests/ClaudeDo.Releases.Tests/FakeHttpMessageHandler.cs
similarity index 96%
rename from tests/ClaudeDo.Installer.Tests/FakeHttpMessageHandler.cs
rename to tests/ClaudeDo.Releases.Tests/FakeHttpMessageHandler.cs
index 960a8fb..dbd5260 100644
--- a/tests/ClaudeDo.Installer.Tests/FakeHttpMessageHandler.cs
+++ b/tests/ClaudeDo.Releases.Tests/FakeHttpMessageHandler.cs
@@ -1,7 +1,7 @@
using System.Net;
using System.Net.Http;
-namespace ClaudeDo.Installer.Tests;
+namespace ClaudeDo.Releases.Tests;
internal sealed class FakeHttpMessageHandler : HttpMessageHandler
{
diff --git a/tests/ClaudeDo.Installer.Tests/ReleaseClientTests.cs b/tests/ClaudeDo.Releases.Tests/ReleaseClientTests.cs
similarity index 98%
rename from tests/ClaudeDo.Installer.Tests/ReleaseClientTests.cs
rename to tests/ClaudeDo.Releases.Tests/ReleaseClientTests.cs
index 954e513..1faf924 100644
--- a/tests/ClaudeDo.Installer.Tests/ReleaseClientTests.cs
+++ b/tests/ClaudeDo.Releases.Tests/ReleaseClientTests.cs
@@ -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
{
diff --git a/tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs b/tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs
new file mode 100644
index 0000000..01d1c82
--- /dev/null
+++ b/tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs
@@ -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 GetLatestReleaseAsync(CancellationToken ct)
+ {
+ if (Throw) throw new HttpRequestException("boom");
+ return Task.FromResult(Release);
+ }
+
+ public Task DownloadAsync(string url, string destPath, IProgress 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 GetLatestReleaseAsync(CancellationToken ct) => Task.FromResult(null);
+
+ public async Task DownloadAsync(string url, string destPath, IProgress 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(_ => { }), 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(_ => { }), 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();
+ }
+}
diff --git a/tests/ClaudeDo.Releases.Tests/VersionComparerTests.cs b/tests/ClaudeDo.Releases.Tests/VersionComparerTests.cs
new file mode 100644
index 0000000..5aba89f
--- /dev/null
+++ b/tests/ClaudeDo.Releases.Tests/VersionComparerTests.cs
@@ -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);
+ }
+}
diff --git a/tests/ClaudeDo.Ui.Tests/Services/InstallerLocatorTests.cs b/tests/ClaudeDo.Ui.Tests/Services/InstallerLocatorTests.cs
new file mode 100644
index 0000000..4ddc948
--- /dev/null
+++ b/tests/ClaudeDo.Ui.Tests/Services/InstallerLocatorTests.cs
@@ -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));
+ }
+}
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);
+ }
+}