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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,6 @@
|
|||||||
|
# Local dev worktrees (created by using-git-worktrees skill)
|
||||||
|
.worktrees/
|
||||||
|
|
||||||
# .NET build output
|
# .NET build output
|
||||||
bin/
|
bin/
|
||||||
obj/
|
obj/
|
||||||
|
|||||||
@@ -5,10 +5,12 @@
|
|||||||
<Project Path="src/ClaudeDo.Ui/ClaudeDo.Ui.csproj" />
|
<Project Path="src/ClaudeDo.Ui/ClaudeDo.Ui.csproj" />
|
||||||
<Project Path="src/ClaudeDo.Worker/ClaudeDo.Worker.csproj" />
|
<Project Path="src/ClaudeDo.Worker/ClaudeDo.Worker.csproj" />
|
||||||
<Project Path="src/ClaudeDo.Installer/ClaudeDo.Installer.csproj" />
|
<Project Path="src/ClaudeDo.Installer/ClaudeDo.Installer.csproj" />
|
||||||
|
<Project Path="src/ClaudeDo.Releases/ClaudeDo.Releases.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/tests/">
|
<Folder Name="/tests/">
|
||||||
<Project Path="tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj" />
|
<Project Path="tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj" />
|
||||||
<Project Path="tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj" />
|
<Project Path="tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj" />
|
||||||
<Project Path="tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj" />
|
<Project Path="tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj" />
|
||||||
|
<Project Path="tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
16
docs/open.md
16
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.
|
9. **Tag-Negation (3.4)** — wenn der Bedarf konkret wird.
|
||||||
|
|
||||||
Punkte 1–3 sind ein realistischer Block für eine Session.
|
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-<version>-win-x64.zip`, `ClaudeDo.Installer-<version>.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).
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Git;
|
using ClaudeDo.Data.Git;
|
||||||
|
using ClaudeDo.Releases;
|
||||||
using ClaudeDo.Ui;
|
using ClaudeDo.Ui;
|
||||||
using ClaudeDo.Ui.Services;
|
using ClaudeDo.Ui.Services;
|
||||||
using ClaudeDo.Ui.ViewModels;
|
using ClaudeDo.Ui.ViewModels;
|
||||||
@@ -9,6 +10,8 @@ using ClaudeDo.Ui.ViewModels.Modals;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using System;
|
using System;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Reflection;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
namespace ClaudeDo.App;
|
namespace ClaudeDo.App;
|
||||||
@@ -75,6 +78,17 @@ sealed class Program
|
|||||||
sc.AddSingleton<GitService>();
|
sc.AddSingleton<GitService>();
|
||||||
sc.AddSingleton(sp => new WorkerClient(sp.GetRequiredService<AppSettings>().SignalRUrl));
|
sc.AddSingleton(sp => new WorkerClient(sp.GetRequiredService<AppSettings>().SignalRUrl));
|
||||||
|
|
||||||
|
// Release check + installer update
|
||||||
|
sc.AddSingleton<HttpClient>(_ => new HttpClient { Timeout = TimeSpan.FromSeconds(10) });
|
||||||
|
sc.AddSingleton<IReleaseClient>(sp => new ReleaseClient(sp.GetRequiredService<HttpClient>()));
|
||||||
|
sc.AddSingleton<InstallerLocator>();
|
||||||
|
sc.AddSingleton(sp =>
|
||||||
|
{
|
||||||
|
var releases = sp.GetRequiredService<IReleaseClient>();
|
||||||
|
var version = Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "0.0.0.0";
|
||||||
|
return new UpdateCheckService(releases, version);
|
||||||
|
});
|
||||||
|
|
||||||
// ViewModels
|
// ViewModels
|
||||||
sc.AddTransient<WorktreeModalViewModel>();
|
sc.AddTransient<WorktreeModalViewModel>();
|
||||||
sc.AddTransient<SettingsModalViewModel>();
|
sc.AddTransient<SettingsModalViewModel>();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Net.Http;
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
using ClaudeDo.Installer.Core;
|
using ClaudeDo.Installer.Core;
|
||||||
|
using ClaudeDo.Releases;
|
||||||
using ClaudeDo.Installer.Pages.InstallPage;
|
using ClaudeDo.Installer.Pages.InstallPage;
|
||||||
using ClaudeDo.Installer.Pages.PathsPage;
|
using ClaudeDo.Installer.Pages.PathsPage;
|
||||||
using ClaudeDo.Installer.Pages.ServicePage;
|
using ClaudeDo.Installer.Pages.ServicePage;
|
||||||
@@ -21,6 +22,104 @@ public partial class App : Application
|
|||||||
{
|
{
|
||||||
base.OnStartup(e);
|
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 "<old-path>"
|
||||||
|
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<long>(_ => { }),
|
||||||
|
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();
|
_services = BuildServices();
|
||||||
|
|
||||||
var context = _services.GetRequiredService<InstallContext>();
|
var context = _services.GetRequiredService<InstallContext>();
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\ClaudeDo.Data\ClaudeDo.Data.csproj" />
|
<ProjectReference Include="..\ClaudeDo.Data\ClaudeDo.Data.csproj" />
|
||||||
|
<ProjectReference Include="..\ClaudeDo.Releases\ClaudeDo.Releases.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using ClaudeDo.Releases;
|
||||||
|
|
||||||
namespace ClaudeDo.Installer.Core;
|
namespace ClaudeDo.Installer.Core;
|
||||||
|
|
||||||
public sealed record DetectedState(
|
public sealed record DetectedState(
|
||||||
@@ -31,7 +33,9 @@ public sealed class InstallModeDetector
|
|||||||
return new DetectedState(InstallerMode.Config, manifest, null, null);
|
return new DetectedState(InstallerMode.Config, manifest, null, null);
|
||||||
|
|
||||||
var latestVersion = release.TagName.TrimStart('v', 'V');
|
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)
|
if (newer)
|
||||||
return new DetectedState(InstallerMode.Update, manifest, release, latestVersion);
|
return new DetectedState(InstallerMode.Update, manifest, release, latestVersion);
|
||||||
|
|
||||||
@@ -41,16 +45,4 @@ public sealed class InstallModeDetector
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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
|
|
||||||
/// <paramref name="unparseable"/> is set so the UI can surface a hint.
|
|
||||||
/// </summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.IO.Compression;
|
using System.IO.Compression;
|
||||||
using ClaudeDo.Installer.Core;
|
using ClaudeDo.Installer.Core;
|
||||||
|
using ClaudeDo.Releases;
|
||||||
|
|
||||||
namespace ClaudeDo.Installer.Steps;
|
namespace ClaudeDo.Installer.Steps;
|
||||||
|
|
||||||
|
|||||||
25
src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml
Normal file
25
src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<Window x:Class="ClaudeDo.Installer.Views.SelfUpdatePromptWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
Title="ClaudeDo Installer Update"
|
||||||
|
Width="460" Height="200"
|
||||||
|
WindowStartupLocation="CenterScreen"
|
||||||
|
ResizeMode="NoResize"
|
||||||
|
Background="#1a1a1a" Foreground="#f0f0f0">
|
||||||
|
<Grid Margin="20">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<TextBlock Grid.Row="0" FontSize="16" FontWeight="SemiBold" Text="A newer installer is available"/>
|
||||||
|
<TextBlock Grid.Row="1" Margin="0,8,0,0" TextWrapping="Wrap" x:Name="DetailText"/>
|
||||||
|
<TextBlock Grid.Row="2" Margin="0,12,0,0" TextWrapping="Wrap" Foreground="#a0a0a0" x:Name="ProgressText" Visibility="Collapsed"/>
|
||||||
|
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right">
|
||||||
|
<Button x:Name="UpdateBtn" Content="Update" MinWidth="90" Margin="4,0" Padding="10,4" Click="UpdateBtn_Click" IsDefault="True"/>
|
||||||
|
<Button x:Name="ContinueBtn" Content="Continue anyway" MinWidth="140" Margin="4,0" Padding="10,4" Click="ContinueBtn_Click"/>
|
||||||
|
<Button x:Name="CancelBtn" Content="Cancel" MinWidth="90" Margin="4,0" Padding="10,4" Click="CancelBtn_Click" IsCancel="True"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
42
src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml.cs
Normal file
42
src/ClaudeDo.Installer/Views/SelfUpdatePromptWindow.xaml.cs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Windows;
|
using System.Windows;
|
||||||
using ClaudeDo.Installer.Core;
|
using ClaudeDo.Installer.Core;
|
||||||
|
using ClaudeDo.Releases;
|
||||||
using ClaudeDo.Installer.Steps;
|
using ClaudeDo.Installer.Steps;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
namespace ClaudeDo.Installer.Core;
|
namespace ClaudeDo.Releases;
|
||||||
|
|
||||||
public static class ChecksumVerifier
|
public static class ChecksumVerifier
|
||||||
{
|
{
|
||||||
8
src/ClaudeDo.Releases/ClaudeDo.Releases.csproj
Normal file
8
src/ClaudeDo.Releases/ClaudeDo.Releases.csproj
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace ClaudeDo.Installer.Core;
|
namespace ClaudeDo.Releases;
|
||||||
|
|
||||||
public sealed record ReleaseAsset(string Name, string BrowserDownloadUrl, long Size);
|
public sealed record ReleaseAsset(string Name, string BrowserDownloadUrl, long Size);
|
||||||
|
|
||||||
@@ -2,7 +2,7 @@ using System.IO;
|
|||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace ClaudeDo.Installer.Core;
|
namespace ClaudeDo.Releases;
|
||||||
|
|
||||||
public sealed class ReleaseClient : IReleaseClient
|
public sealed class ReleaseClient : IReleaseClient
|
||||||
{
|
{
|
||||||
15
src/ClaudeDo.Releases/SelfUpdateResult.cs
Normal file
15
src/ClaudeDo.Releases/SelfUpdateResult.cs
Normal file
@@ -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);
|
||||||
126
src/ClaudeDo.Releases/SelfUpdater.cs
Normal file
126
src/ClaudeDo.Releases/SelfUpdater.cs
Normal file
@@ -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-(?<version>[\d\.]+)\.exe$", RegexOptions.IgnoreCase)]
|
||||||
|
private static partial Regex InstallerAssetRegex();
|
||||||
|
|
||||||
|
public static InstallerAssetMatch? FindInstallerAsset(IEnumerable<ReleaseAsset> 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<SelfUpdateDecision> 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<bool> HandleReplaceSelfAsync(
|
||||||
|
string oldPath,
|
||||||
|
string currentExePath,
|
||||||
|
Func<string, bool> 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<string?> DownloadAndVerifyAsync(
|
||||||
|
IReleaseClient releases,
|
||||||
|
ReleaseAsset installerAsset,
|
||||||
|
ReleaseAsset checksumsAsset,
|
||||||
|
string tempDir,
|
||||||
|
IProgress<long> 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<long>(_ => { }), 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/ClaudeDo.Releases/VersionComparer.cs
Normal file
18
src/ClaudeDo.Releases/VersionComparer.cs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\ClaudeDo.Data\ClaudeDo.Data.csproj" />
|
<ProjectReference Include="..\ClaudeDo.Data\ClaudeDo.Data.csproj" />
|
||||||
|
<ProjectReference Include="..\ClaudeDo.Releases\ClaudeDo.Releases.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -9,6 +10,7 @@
|
|||||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
||||||
|
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<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 Avalonia.Threading;
|
||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
@@ -13,6 +16,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
|||||||
public TasksIslandViewModel? Tasks { get; }
|
public TasksIslandViewModel? Tasks { get; }
|
||||||
public DetailsIslandViewModel? Details { get; }
|
public DetailsIslandViewModel? Details { get; }
|
||||||
public WorkerClient? Worker { get; }
|
public WorkerClient? Worker { get; }
|
||||||
|
public UpdateCheckService UpdateCheck => _updateCheck;
|
||||||
|
|
||||||
public string ConnectionText =>
|
public string ConnectionText =>
|
||||||
Worker?.IsConnected == true ? "Online"
|
Worker?.IsConnected == true ? "Online"
|
||||||
@@ -21,6 +25,14 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
|||||||
|
|
||||||
public bool IsOffline => Worker?.IsConnected != true && Worker?.IsReconnecting != true;
|
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]
|
[ObservableProperty]
|
||||||
private double _windowWidth = 1280;
|
private double _windowWidth = 1280;
|
||||||
|
|
||||||
@@ -79,9 +91,13 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
|||||||
ListsIslandViewModel lists,
|
ListsIslandViewModel lists,
|
||||||
TasksIslandViewModel tasks,
|
TasksIslandViewModel tasks,
|
||||||
DetailsIslandViewModel details,
|
DetailsIslandViewModel details,
|
||||||
WorkerClient worker)
|
WorkerClient worker,
|
||||||
|
UpdateCheckService updateCheck,
|
||||||
|
InstallerLocator installerLocator)
|
||||||
{
|
{
|
||||||
Lists = lists; Tasks = tasks; Details = details; Worker = worker;
|
Lists = lists; Tasks = tasks; Details = details; Worker = worker;
|
||||||
|
_updateCheck = updateCheck;
|
||||||
|
_installerLocator = installerLocator;
|
||||||
Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList);
|
Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList);
|
||||||
Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask);
|
Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask);
|
||||||
Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync();
|
Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync();
|
||||||
@@ -109,5 +125,74 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
|||||||
Dispatcher.UIThread.Post(ClearWorkerLog);
|
Dispatcher.UIThread.Post(ClearWorkerLog);
|
||||||
};
|
};
|
||||||
_ = Lists.LoadAsync();
|
_ = 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="Shift+OemQuestion" Command="{Binding FocusSearchCommand}"/>
|
||||||
<KeyBinding Gesture="Ctrl+N" Command="{Binding FocusAddTaskCommand}"/>
|
<KeyBinding Gesture="Ctrl+N" Command="{Binding FocusAddTaskCommand}"/>
|
||||||
</Window.KeyBindings>
|
</Window.KeyBindings>
|
||||||
<Grid RowDefinitions="36,*,22">
|
<Grid RowDefinitions="36,Auto,*,22">
|
||||||
<!-- Custom title bar -->
|
<!-- Custom title bar -->
|
||||||
<Border Grid.Row="0"
|
<Border Grid.Row="0"
|
||||||
Background="{DynamicResource DeepBrush}"
|
Background="{DynamicResource DeepBrush}"
|
||||||
@@ -58,6 +58,17 @@
|
|||||||
Foreground="{DynamicResource TextDimBrush}"
|
Foreground="{DynamicResource TextDimBrush}"
|
||||||
LetterSpacing="1.4"
|
LetterSpacing="1.4"
|
||||||
VerticalAlignment="Center"/>
|
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>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Middle: draggable strip -->
|
<!-- Middle: draggable strip -->
|
||||||
@@ -81,8 +92,47 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</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 -->
|
<!-- Background gradient layer -->
|
||||||
<Border Grid.Row="1">
|
<Border Grid.Row="2">
|
||||||
<Border.Background>
|
<Border.Background>
|
||||||
<RadialGradientBrush Center="50%,50%" GradientOrigin="50%,50%" RadiusX="70%" RadiusY="70%">
|
<RadialGradientBrush Center="50%,50%" GradientOrigin="50%,50%" RadiusX="70%" RadiusY="70%">
|
||||||
<GradientStop Offset="0" Color="{StaticResource DeepColor}" />
|
<GradientStop Offset="0" Color="{StaticResource DeepColor}" />
|
||||||
@@ -92,7 +142,7 @@
|
|||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Three islands -->
|
<!-- 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">
|
<Border Grid.Column="0" Classes="island" Margin="7">
|
||||||
<islands:ListsIslandView DataContext="{Binding Lists}"/>
|
<islands:ListsIslandView DataContext="{Binding Lists}"/>
|
||||||
</Border>
|
</Border>
|
||||||
@@ -106,7 +156,7 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- Footer: connection status -->
|
<!-- Footer: connection status -->
|
||||||
<Border Grid.Row="2"
|
<Border Grid.Row="3"
|
||||||
Background="{DynamicResource DeepBrush}"
|
Background="{DynamicResource DeepBrush}"
|
||||||
BorderBrush="{DynamicResource LineBrush}"
|
BorderBrush="{DynamicResource LineBrush}"
|
||||||
BorderThickness="0,1,0,0">
|
BorderThickness="0,1,0,0">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.IO;
|
|||||||
using System.IO.Compression;
|
using System.IO.Compression;
|
||||||
using ClaudeDo.Installer.Core;
|
using ClaudeDo.Installer.Core;
|
||||||
using ClaudeDo.Installer.Steps;
|
using ClaudeDo.Installer.Steps;
|
||||||
|
using ClaudeDo.Releases;
|
||||||
|
|
||||||
namespace ClaudeDo.Installer.Tests;
|
namespace ClaudeDo.Installer.Tests;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using ClaudeDo.Installer.Core;
|
using ClaudeDo.Installer.Core;
|
||||||
|
using ClaudeDo.Releases;
|
||||||
|
|
||||||
namespace ClaudeDo.Installer.Tests;
|
namespace ClaudeDo.Installer.Tests;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using ClaudeDo.Installer.Core;
|
using ClaudeDo.Releases;
|
||||||
|
|
||||||
namespace ClaudeDo.Installer.Tests;
|
namespace ClaudeDo.Releases.Tests;
|
||||||
|
|
||||||
public sealed class ChecksumVerifierTests : IDisposable
|
public sealed class ChecksumVerifierTests : IDisposable
|
||||||
{
|
{
|
||||||
20
tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj
Normal file
20
tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../../src/ClaudeDo.Releases/ClaudeDo.Releases.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
|
|
||||||
namespace ClaudeDo.Installer.Tests;
|
namespace ClaudeDo.Releases.Tests;
|
||||||
|
|
||||||
internal sealed class FakeHttpMessageHandler : HttpMessageHandler
|
internal sealed class FakeHttpMessageHandler : HttpMessageHandler
|
||||||
{
|
{
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using ClaudeDo.Installer.Core;
|
using ClaudeDo.Releases;
|
||||||
|
|
||||||
namespace ClaudeDo.Installer.Tests;
|
namespace ClaudeDo.Releases.Tests;
|
||||||
|
|
||||||
public sealed class ReleaseClientTests
|
public sealed class ReleaseClientTests
|
||||||
{
|
{
|
||||||
256
tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs
Normal file
256
tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs
Normal file
@@ -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<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (Throw) throw new HttpRequestException("boom");
|
||||||
|
return Task.FromResult(Release);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task DownloadAsync(string url, string destPath, IProgress<long> 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<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct) => Task.FromResult<GiteaRelease?>(null);
|
||||||
|
|
||||||
|
public async Task DownloadAsync(string url, string destPath, IProgress<long> 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<long>(_ => { }), 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<long>(_ => { }), 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
30
tests/ClaudeDo.Releases.Tests/VersionComparerTests.cs
Normal file
30
tests/ClaudeDo.Releases.Tests/VersionComparerTests.cs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
tests/ClaudeDo.Ui.Tests/Services/InstallerLocatorTests.cs
Normal file
58
tests/ClaudeDo.Ui.Tests/Services/InstallerLocatorTests.cs
Normal file
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
62
tests/ClaudeDo.Ui.Tests/Services/UpdateCheckServiceTests.cs
Normal file
62
tests/ClaudeDo.Ui.Tests/Services/UpdateCheckServiceTests.cs
Normal file
@@ -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<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (Throw) throw new HttpRequestException();
|
||||||
|
return Task.FromResult(Release);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task DownloadAsync(string url, string destPath, IProgress<long> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user