diff --git a/src/SharpFM.Plugin/IUpdateCheckable.cs b/src/SharpFM.Plugin/IUpdateCheckable.cs
new file mode 100644
index 0000000..6954c85
--- /dev/null
+++ b/src/SharpFM.Plugin/IUpdateCheckable.cs
@@ -0,0 +1,29 @@
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace SharpFM.Plugin;
+
+///
+/// Opt-in capability for plugins that have a remote update channel. Implementing
+/// this interface is independent of — a plugin opts in by
+/// implementing both. Plugins that ship with the host binary (or otherwise have
+/// no separate release cadence) should not implement this; the host will simply
+/// display without a check-for-updates affordance.
+///
+public interface IUpdateCheckable
+{
+ ///
+ /// Query the plugin's update channel and return what the host should surface
+ /// in the About dialog. Implementations choose the channel (public manifest,
+ /// licensing endpoint, anonymous GitHub releases for a public repo, etc.) —
+ /// the host has no knowledge of any specific plugin's source.
+ ///
+ ///
+ /// Implementations should fail silently on network errors, returning a
+ /// result with set to
+ /// false rather than throwing. A thrown exception is treated by the
+ /// host as a check failure and silently suppressed.
+ ///
+ ///
+ Task CheckForUpdatesAsync(CancellationToken ct);
+}
diff --git a/src/SharpFM.Plugin/UpdateCheckResult.cs b/src/SharpFM.Plugin/UpdateCheckResult.cs
new file mode 100644
index 0000000..3eb9bf2
--- /dev/null
+++ b/src/SharpFM.Plugin/UpdateCheckResult.cs
@@ -0,0 +1,30 @@
+using System;
+
+namespace SharpFM.Plugin;
+
+///
+/// Outcome of an call.
+/// All three optional fields may be null when
+/// is false .
+///
+///
+/// true when the channel reports a newer version than what the plugin
+/// is currently running.
+///
+///
+/// Human-readable version string of the latest release (e.g. "2.0.0" ).
+/// Format is the plugin's choice; the host displays it verbatim.
+///
+///
+/// Where to point the user to obtain the update — typically a release notes
+/// or download page. Opened in the system browser on click.
+///
+///
+/// Optional short summary surfaced alongside the version (e.g. release
+/// highlights). The host shows it as plain text; do not include markup.
+///
+public sealed record UpdateCheckResult(
+ bool UpdateAvailable,
+ string? LatestVersion,
+ Uri? ReleaseUrl,
+ string? Notes);
diff --git a/src/SharpFM/Dialogs/AboutDialog.axaml b/src/SharpFM/Dialogs/AboutDialog.axaml
new file mode 100644
index 0000000..a7e7c12
--- /dev/null
+++ b/src/SharpFM/Dialogs/AboutDialog.axaml
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/SharpFM/Dialogs/AboutDialog.axaml.cs b/src/SharpFM/Dialogs/AboutDialog.axaml.cs
new file mode 100644
index 0000000..be18a95
--- /dev/null
+++ b/src/SharpFM/Dialogs/AboutDialog.axaml.cs
@@ -0,0 +1,78 @@
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Threading;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Markup.Xaml;
+using SharpFM.ViewModels;
+
+namespace SharpFM.Dialogs;
+
+///
+/// Help → About window. Surfaces host version and the list of loaded plugins.
+/// Each entry that opted in via IUpdateCheckable gets a check-for-updates
+/// affordance; checks run against whatever channel the implementer chose.
+///
+[ExcludeFromCodeCoverage]
+public partial class AboutDialog : Window
+{
+ private AboutViewModel? _vm;
+
+ public AboutDialog()
+ {
+ AvaloniaXamlLoader.Load(this);
+
+ this.FindControl("closeButton")!.Click += (_, _) => Close();
+ this.FindControl("hostCheckButton")!.Click += OnHostCheckClick;
+ this.FindControl("hostOpenUpdateButton")!.Click += OnHostOpenUpdateClick;
+ this.FindControl("hostHomepageButton")!.Click += OnHostHomepageClick;
+ }
+
+ public void Configure(AboutViewModel vm)
+ {
+ _vm = vm;
+ DataContext = vm;
+ vm.Host.PropertyChanged += (_, e) =>
+ {
+ if (e.PropertyName == nameof(AboutEntryViewModel.UpdateUrl))
+ this.FindControl("hostOpenUpdateButton")!.IsVisible = vm.Host.UpdateUrl is not null;
+ };
+ }
+
+ private async void OnHostCheckClick(object? sender, RoutedEventArgs e)
+ {
+ if (_vm is null) return;
+ await _vm.Host.CheckAsync(CancellationToken.None);
+ }
+
+ private void OnHostOpenUpdateClick(object? sender, RoutedEventArgs e)
+ {
+ if (_vm?.Host.UpdateUrl is { } url) OpenInBrowser(url.ToString());
+ }
+
+ private void OnHostHomepageClick(object? sender, RoutedEventArgs e)
+ {
+ if (_vm is null) return;
+ OpenInBrowser(_vm.HostHomepageUrl.ToString());
+ }
+
+ private async void OnPluginCheckClick(object? sender, RoutedEventArgs e)
+ {
+ if (sender is Button { Tag: AboutEntryViewModel entry })
+ {
+ await entry.CheckAsync(CancellationToken.None);
+ }
+ }
+
+ private static void OpenInBrowser(string url)
+ {
+ try
+ {
+ Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
+ }
+ catch
+ {
+ // Browser unavailable / sandboxed — silently no-op.
+ }
+ }
+}
diff --git a/src/SharpFM/MainWindow.axaml b/src/SharpFM/MainWindow.axaml
index 52fa61f..b883bf8 100644
--- a/src/SharpFM/MainWindow.axaml
+++ b/src/SharpFM/MainWindow.axaml
@@ -76,6 +76,9 @@
+
+
+
diff --git a/src/SharpFM/MainWindow.axaml.cs b/src/SharpFM/MainWindow.axaml.cs
index 565655d..4973758 100644
--- a/src/SharpFM/MainWindow.axaml.cs
+++ b/src/SharpFM/MainWindow.axaml.cs
@@ -9,6 +9,7 @@
using AvaloniaEdit;
using AvaloniaEdit.Editing;
using SharpFM.Diagnostics;
+using SharpFM.Dialogs;
using SharpFM.Plugin;
using SharpFM.Plugin.UI;
using SharpFM.PluginManager;
@@ -38,6 +39,11 @@ public MainWindow()
if (rawClipboard != null)
rawClipboard.Click += (_, _) => new RawClipboardWindow().Show(this);
+ // "About SharpFM..." menu item
+ var about = this.FindControl("aboutMenuItem");
+ if (about != null)
+ about.Click += (_, _) => ShowAbout();
+
// Tunnel-phase Ctrl+V — see OnPreviewKeyDown.
AddHandler(KeyDownEvent, OnPreviewKeyDown, RoutingStrategies.Tunnel);
@@ -202,6 +208,31 @@ private void ShowPluginManager()
window.ShowDialog(this);
}
+ // Shared across About-dialog opens so we don't churn sockets. The dialog
+ // can't open often enough for the static lifetime to matter; this just
+ // keeps GitHub's per-IP rate-limit accounting consistent across opens.
+ private static readonly System.Net.Http.HttpClient AboutHttpClient = new()
+ {
+ Timeout = TimeSpan.FromSeconds(10),
+ };
+
+ private void ShowAbout()
+ {
+ if (DataContext is not MainWindowViewModel vm) return;
+
+ var hostChecker = new HostUpdateCheck(AboutHttpClient, MainWindowViewModel.Version);
+ var aboutVm = new AboutViewModel(
+ "SharpFM",
+ MainWindowViewModel.Version,
+ hostChecker,
+ new Uri("https://github.com/fuzzzerd/SharpFM"),
+ vm.AllPlugins);
+
+ var dialog = new AboutDialog();
+ dialog.Configure(aboutVm);
+ dialog.ShowDialog(this);
+ }
+
// --- Tree / tab interaction ---
// Walk the visual tree up from the tapped item to find the clip node that
diff --git a/src/SharpFM/Services/HostUpdateCheck.cs b/src/SharpFM/Services/HostUpdateCheck.cs
new file mode 100644
index 0000000..7bb2a51
--- /dev/null
+++ b/src/SharpFM/Services/HostUpdateCheck.cs
@@ -0,0 +1,121 @@
+using System;
+using System.Net;
+using System.Net.Http;
+using System.Net.Http.Json;
+using System.Text.Json.Serialization;
+using System.Threading;
+using System.Threading.Tasks;
+using SharpFM.Plugin;
+
+namespace SharpFM.Services;
+
+///
+/// Host-side update check against the public SharpFM releases feed on GitHub.
+/// Implements so the About dialog can iterate
+/// host + plugins uniformly. Anonymous request — no auth header, no telemetry.
+///
+public sealed class HostUpdateCheck : IUpdateCheckable
+{
+ /// Public releases endpoint for the host app. Anonymous, rate-limited at 60/hour/IP.
+ public const string ReleasesEndpoint = "https://api.github.com/repos/fuzzzerd/SharpFM/releases/latest";
+
+ private readonly HttpClient _http;
+ private readonly string _runningVersion;
+
+ public HostUpdateCheck(HttpClient http, string runningVersion)
+ {
+ _http = http;
+ _runningVersion = runningVersion;
+ if (_http.DefaultRequestHeaders.UserAgent.Count == 0)
+ {
+ // GitHub's API rejects requests without a User-Agent.
+ _http.DefaultRequestHeaders.UserAgent.ParseAdd("SharpFM-update-check");
+ }
+ }
+
+ public async Task CheckForUpdatesAsync(CancellationToken ct)
+ {
+ try
+ {
+ using var response = await _http.GetAsync(ReleasesEndpoint, ct).ConfigureAwait(false);
+ if (response.StatusCode != HttpStatusCode.OK)
+ {
+ return NoUpdate;
+ }
+
+ var release = await response.Content.ReadFromJsonAsync(ct).ConfigureAwait(false);
+ if (release is null || string.IsNullOrEmpty(release.TagName))
+ {
+ return NoUpdate;
+ }
+
+ var latest = StripVPrefix(release.TagName);
+ var running = StripBuildMetadata(_runningVersion);
+
+ if (!IsNewer(latest, running))
+ {
+ return new UpdateCheckResult(false, latest, ParseUri(release.HtmlUrl), release.Body);
+ }
+
+ return new UpdateCheckResult(true, latest, ParseUri(release.HtmlUrl), release.Body);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch
+ {
+ return NoUpdate;
+ }
+ }
+
+ private static readonly UpdateCheckResult NoUpdate = new(false, null, null, null);
+
+ private static string StripVPrefix(string tag) =>
+ tag.StartsWith('v') || tag.StartsWith('V') ? tag[1..] : tag;
+
+ private static string StripBuildMetadata(string version)
+ {
+ var plus = version.IndexOf('+');
+ return plus < 0 ? version : version[..plus];
+ }
+
+ private static Uri? ParseUri(string? value) =>
+ Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri : null;
+
+ ///
+ /// SemVer-aware "is latest strictly greater than running" comparison for the
+ /// MAJOR.MINOR.PATCH core. Pre-release qualifiers are ignored — the
+ /// /releases/latest endpoint already excludes them.
+ ///
+ internal static bool IsNewer(string latest, string running)
+ {
+ var l = CoreParts(latest);
+ var r = CoreParts(running);
+ for (var i = 0; i < 3; i++)
+ {
+ if (l[i] > r[i]) return true;
+ if (l[i] < r[i]) return false;
+ }
+ return false;
+ }
+
+ private static int[] CoreParts(string version)
+ {
+ // Drop any pre-release qualifier ("-beta.3") before splitting.
+ var dash = version.IndexOf('-');
+ var core = dash < 0 ? version : version[..dash];
+ var parts = core.Split('.', 4);
+ var result = new int[3];
+ for (var i = 0; i < 3 && i < parts.Length; i++)
+ {
+ int.TryParse(parts[i], out result[i]);
+ }
+ return result;
+ }
+
+ private sealed record ReleasePayload(
+ [property: JsonPropertyName("tag_name")] string? TagName,
+ [property: JsonPropertyName("html_url")] string? HtmlUrl,
+ [property: JsonPropertyName("body")] string? Body);
+}
diff --git a/src/SharpFM/ViewModels/AboutEntryViewModel.cs b/src/SharpFM/ViewModels/AboutEntryViewModel.cs
new file mode 100644
index 0000000..33e7f9c
--- /dev/null
+++ b/src/SharpFM/ViewModels/AboutEntryViewModel.cs
@@ -0,0 +1,93 @@
+using System;
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using SharpFM.Plugin;
+
+namespace SharpFM.ViewModels;
+
+///
+/// One row in the About dialog — the host itself or a loaded plugin. Holds
+/// static metadata (display name, version) and, when the underlying component
+/// implements , drives the on-demand update
+/// check whose outcome is surfaced via and
+/// .
+///
+public sealed class AboutEntryViewModel : INotifyPropertyChanged
+{
+ private readonly IUpdateCheckable? _checker;
+
+ public AboutEntryViewModel(string displayName, string version, IUpdateCheckable? checker)
+ {
+ DisplayName = displayName;
+ Version = version;
+ _checker = checker;
+ }
+
+ public string DisplayName { get; }
+ public string Version { get; }
+
+ ///
+ /// true when the underlying component opted in to update checks by
+ /// implementing . The view binds a
+ /// "Check for updates" affordance to this.
+ ///
+ public bool CanCheckForUpdates => _checker is not null;
+
+ private string? _status;
+ public string? Status
+ {
+ get => _status;
+ private set { _status = value; NotifyPropertyChanged(); }
+ }
+
+ private Uri? _updateUrl;
+ public Uri? UpdateUrl
+ {
+ get => _updateUrl;
+ private set { _updateUrl = value; NotifyPropertyChanged(); }
+ }
+
+ ///
+ /// Run the underlying update check and reflect the outcome on
+ /// / . Exceptions other than
+ /// are swallowed so a broken
+ /// channel can't break the dialog.
+ ///
+ public async Task CheckAsync(CancellationToken ct)
+ {
+ if (_checker is null) return;
+
+ Status = "Checking…";
+ UpdateUrl = null;
+
+ try
+ {
+ var result = await _checker.CheckForUpdatesAsync(ct).ConfigureAwait(true);
+ if (result.UpdateAvailable && result.LatestVersion is not null)
+ {
+ Status = $"Update available: {result.LatestVersion}";
+ UpdateUrl = result.ReleaseUrl;
+ }
+ else
+ {
+ Status = "Up to date.";
+ UpdateUrl = null;
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch
+ {
+ Status = "Could not check for updates.";
+ UpdateUrl = null;
+ }
+ }
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+ private void NotifyPropertyChanged([CallerMemberName] string propertyName = "") =>
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+}
diff --git a/src/SharpFM/ViewModels/AboutViewModel.cs b/src/SharpFM/ViewModels/AboutViewModel.cs
new file mode 100644
index 0000000..c8df0d5
--- /dev/null
+++ b/src/SharpFM/ViewModels/AboutViewModel.cs
@@ -0,0 +1,36 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using SharpFM.Plugin;
+
+namespace SharpFM.ViewModels;
+
+///
+/// Backing model for the About dialog. Holds one entry for the host itself
+/// and one entry per loaded plugin. Plugins that implement
+/// get a check-for-updates affordance; ones
+/// that don't simply show .
+///
+public sealed class AboutViewModel
+{
+ public AboutEntryViewModel Host { get; }
+ public ObservableCollection Plugins { get; }
+ public Uri HostHomepageUrl { get; }
+
+ public AboutViewModel(
+ string hostName,
+ string hostVersion,
+ IUpdateCheckable hostChecker,
+ Uri hostHomepageUrl,
+ IEnumerable plugins)
+ {
+ Host = new AboutEntryViewModel(hostName, hostVersion, hostChecker);
+ HostHomepageUrl = hostHomepageUrl;
+ Plugins = new ObservableCollection(
+ plugins.Select(p => new AboutEntryViewModel(
+ p.DisplayName,
+ p.Version,
+ p as IUpdateCheckable)));
+ }
+}
diff --git a/tests/SharpFM.Plugin.Tests/PluginInterfaceTests.cs b/tests/SharpFM.Plugin.Tests/PluginInterfaceTests.cs
index 0f2cdcf..19261e3 100644
--- a/tests/SharpFM.Plugin.Tests/PluginInterfaceTests.cs
+++ b/tests/SharpFM.Plugin.Tests/PluginInterfaceTests.cs
@@ -27,6 +27,53 @@ public void IPanelPlugin_Extends_IDisposable()
Assert.True(typeof(IDisposable).IsAssignableFrom(typeof(IPanelPlugin)));
}
+ // --- IUpdateCheckable opt-in capability ---
+
+ [Fact]
+ public void IUpdateCheckable_IsPublicInterface()
+ {
+ var t = typeof(IUpdateCheckable);
+ Assert.True(t.IsInterface);
+ Assert.True(t.IsPublic);
+ }
+
+ [Fact]
+ public void IUpdateCheckable_DoesNotExtend_IPlugin()
+ {
+ // Capability interface, not a plugin sub-shape. A plugin opts in by
+ // implementing both IPlugin and IUpdateCheckable.
+ Assert.False(typeof(IPlugin).IsAssignableFrom(typeof(IUpdateCheckable)));
+ }
+
+ [Fact]
+ public void IUpdateCheckable_DeclaresCheckForUpdatesAsync()
+ {
+ var method = typeof(IUpdateCheckable).GetMethod("CheckForUpdatesAsync");
+ Assert.NotNull(method);
+ Assert.Equal(typeof(Task), method!.ReturnType);
+ var parameters = method.GetParameters();
+ Assert.Single(parameters);
+ Assert.Equal(typeof(CancellationToken), parameters[0].ParameterType);
+ }
+
+ [Fact]
+ public void UpdateCheckResult_RecordEquality()
+ {
+ var a = new UpdateCheckResult(true, "2.0.0", new Uri("https://example.com/r/2.0.0"), "notes");
+ var b = new UpdateCheckResult(true, "2.0.0", new Uri("https://example.com/r/2.0.0"), "notes");
+ Assert.Equal(a, b);
+ }
+
+ [Fact]
+ public void UpdateCheckResult_NullablesAllowed_WhenNoUpdate()
+ {
+ var none = new UpdateCheckResult(false, null, null, null);
+ Assert.False(none.UpdateAvailable);
+ Assert.Null(none.LatestVersion);
+ Assert.Null(none.ReleaseUrl);
+ Assert.Null(none.Notes);
+ }
+
// --- ClipData record ---
[Fact]
diff --git a/tests/SharpFM.Tests/Services/HostUpdateCheckTests.cs b/tests/SharpFM.Tests/Services/HostUpdateCheckTests.cs
new file mode 100644
index 0000000..894c3f7
--- /dev/null
+++ b/tests/SharpFM.Tests/Services/HostUpdateCheckTests.cs
@@ -0,0 +1,162 @@
+using System;
+using System.Net;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using SharpFM.Plugin;
+using SharpFM.Services;
+using Xunit;
+
+namespace SharpFM.Tests.Services;
+
+public class HostUpdateCheckTests
+{
+ private static HostUpdateCheck CheckerWith(HttpResponseMessage response, string runningVersion = "1.0.0")
+ {
+ var handler = new FakeHandler(_ => Task.FromResult(response));
+ return new HostUpdateCheck(new HttpClient(handler), runningVersion);
+ }
+
+ private static HostUpdateCheck CheckerThrowing(Exception ex, string runningVersion = "1.0.0")
+ {
+ var handler = new FakeHandler(_ => throw ex);
+ return new HostUpdateCheck(new HttpClient(handler), runningVersion);
+ }
+
+ private static HttpResponseMessage ReleaseResponse(string tagName, string url = "https://example.invalid/r", string? body = null)
+ {
+ var json = $$"""
+ {"tag_name":"{{tagName}}","html_url":"{{url}}","body":{{(body is null ? "null" : "\"" + body + "\"")}}}
+ """;
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"),
+ };
+ }
+
+ [Fact]
+ public async Task ReturnsUpdate_WhenLatestIsNewer()
+ {
+ var checker = CheckerWith(ReleaseResponse("v2.1.0"), runningVersion: "2.0.0");
+
+ var result = await checker.CheckForUpdatesAsync(CancellationToken.None);
+
+ Assert.True(result.UpdateAvailable);
+ Assert.Equal("2.1.0", result.LatestVersion);
+ Assert.Equal(new Uri("https://example.invalid/r"), result.ReleaseUrl);
+ }
+
+ [Fact]
+ public async Task ReturnsNoUpdate_WhenLatestEqualsRunning()
+ {
+ var checker = CheckerWith(ReleaseResponse("v2.0.0"), runningVersion: "2.0.0");
+
+ var result = await checker.CheckForUpdatesAsync(CancellationToken.None);
+
+ Assert.False(result.UpdateAvailable);
+ }
+
+ [Fact]
+ public async Task ReturnsNoUpdate_WhenLatestIsOlder()
+ {
+ var checker = CheckerWith(ReleaseResponse("v1.9.0"), runningVersion: "2.0.0");
+
+ var result = await checker.CheckForUpdatesAsync(CancellationToken.None);
+
+ Assert.False(result.UpdateAvailable);
+ }
+
+ [Fact]
+ public async Task ReturnsNoUpdate_OnHttp404()
+ {
+ var checker = CheckerWith(new HttpResponseMessage(HttpStatusCode.NotFound));
+
+ var result = await checker.CheckForUpdatesAsync(CancellationToken.None);
+
+ Assert.False(result.UpdateAvailable);
+ Assert.Null(result.LatestVersion);
+ }
+
+ [Fact]
+ public async Task ReturnsNoUpdate_OnNetworkException()
+ {
+ var checker = CheckerThrowing(new HttpRequestException("no network"));
+
+ var result = await checker.CheckForUpdatesAsync(CancellationToken.None);
+
+ Assert.False(result.UpdateAvailable);
+ }
+
+ [Fact]
+ public async Task ReturnsNoUpdate_OnMalformedJson()
+ {
+ var bad = new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("not json", System.Text.Encoding.UTF8, "application/json"),
+ };
+ var checker = CheckerWith(bad);
+
+ var result = await checker.CheckForUpdatesAsync(CancellationToken.None);
+
+ Assert.False(result.UpdateAvailable);
+ }
+
+ [Fact]
+ public async Task PropagatesCancellation()
+ {
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+ var checker = CheckerThrowing(new OperationCanceledException());
+
+ await Assert.ThrowsAnyAsync(
+ () => checker.CheckForUpdatesAsync(cts.Token));
+ }
+
+ [Fact]
+ public async Task StripsCommitSuffix_FromRunningVersion()
+ {
+ // MinVer's AssemblyInformationalVersion looks like "2.0.0-beta.0+abc123".
+ // The build-metadata suffix after '+' must be stripped before comparison.
+ var checker = CheckerWith(ReleaseResponse("v2.0.0"), runningVersion: "2.0.0+abc123");
+
+ var result = await checker.CheckForUpdatesAsync(CancellationToken.None);
+
+ Assert.False(result.UpdateAvailable);
+ }
+
+ [Fact]
+ public async Task StripsVPrefix_FromReleaseTag()
+ {
+ var checker = CheckerWith(ReleaseResponse("v1.0.0"), runningVersion: "1.0.0");
+
+ var result = await checker.CheckForUpdatesAsync(CancellationToken.None);
+
+ Assert.False(result.UpdateAvailable);
+ Assert.Equal("1.0.0", result.LatestVersion);
+ }
+
+ [Fact]
+ public async Task CarriesReleaseNotes_WhenBodyPresent()
+ {
+ var checker = CheckerWith(ReleaseResponse("v2.0.0", body: "shiny new release"), runningVersion: "1.0.0");
+
+ var result = await checker.CheckForUpdatesAsync(CancellationToken.None);
+
+ Assert.True(result.UpdateAvailable);
+ Assert.Equal("shiny new release", result.Notes);
+ }
+
+ [Fact]
+ public void ImplementsIUpdateCheckable()
+ {
+ Assert.True(typeof(IUpdateCheckable).IsAssignableFrom(typeof(HostUpdateCheck)));
+ }
+
+ private sealed class FakeHandler : HttpMessageHandler
+ {
+ private readonly Func> _send;
+ public FakeHandler(Func> send) => _send = send;
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
+ _send(request);
+ }
+}
diff --git a/tests/SharpFM.Tests/ViewModels/AboutEntryViewModelTests.cs b/tests/SharpFM.Tests/ViewModels/AboutEntryViewModelTests.cs
new file mode 100644
index 0000000..fd5d677
--- /dev/null
+++ b/tests/SharpFM.Tests/ViewModels/AboutEntryViewModelTests.cs
@@ -0,0 +1,110 @@
+using System;
+using System.ComponentModel;
+using System.Threading;
+using System.Threading.Tasks;
+using SharpFM.Plugin;
+using SharpFM.ViewModels;
+using Xunit;
+
+namespace SharpFM.Tests.ViewModels;
+
+public class AboutEntryViewModelTests
+{
+ [Fact]
+ public void CanCheckForUpdates_IsFalse_WhenNoCheckerProvided()
+ {
+ var entry = new AboutEntryViewModel("Bundled Plugin", "1.0.0", checker: null);
+
+ Assert.False(entry.CanCheckForUpdates);
+ }
+
+ [Fact]
+ public void CanCheckForUpdates_IsTrue_WhenCheckerProvided()
+ {
+ var entry = new AboutEntryViewModel("Host", "1.0.0", new StubChecker(new UpdateCheckResult(false, null, null, null)));
+
+ Assert.True(entry.CanCheckForUpdates);
+ }
+
+ [Fact]
+ public async Task CheckAsync_SetsStatus_WhenUpdateAvailable()
+ {
+ var entry = new AboutEntryViewModel("Host", "1.0.0",
+ new StubChecker(new UpdateCheckResult(true, "2.0.0", new Uri("https://example.invalid/r"), "release notes")));
+
+ await entry.CheckAsync(CancellationToken.None);
+
+ Assert.Contains("2.0.0", entry.Status);
+ Assert.Equal(new Uri("https://example.invalid/r"), entry.UpdateUrl);
+ }
+
+ [Fact]
+ public async Task CheckAsync_SetsStatus_WhenUpToDate()
+ {
+ var entry = new AboutEntryViewModel("Host", "2.0.0",
+ new StubChecker(new UpdateCheckResult(false, "2.0.0", null, null)));
+
+ await entry.CheckAsync(CancellationToken.None);
+
+ Assert.Contains("up to date", entry.Status, StringComparison.OrdinalIgnoreCase);
+ Assert.Null(entry.UpdateUrl);
+ }
+
+ [Fact]
+ public async Task CheckAsync_FailsSilently_OnException()
+ {
+ var entry = new AboutEntryViewModel("Host", "1.0.0", new ThrowingChecker());
+
+ await entry.CheckAsync(CancellationToken.None);
+
+ Assert.NotNull(entry.Status);
+ Assert.DoesNotContain("Exception", entry.Status, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task CheckAsync_RaisesPropertyChanged_ForStatus()
+ {
+ var entry = new AboutEntryViewModel("Host", "1.0.0",
+ new StubChecker(new UpdateCheckResult(false, null, null, null)));
+
+ var changed = false;
+ ((INotifyPropertyChanged)entry).PropertyChanged += (_, e) =>
+ {
+ if (e.PropertyName == nameof(AboutEntryViewModel.Status)) changed = true;
+ };
+
+ await entry.CheckAsync(CancellationToken.None);
+
+ Assert.True(changed);
+ }
+
+ [Fact]
+ public async Task CheckAsync_PropagatesCancellation()
+ {
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+ var entry = new AboutEntryViewModel("Host", "1.0.0", new CancellableChecker());
+
+ await Assert.ThrowsAnyAsync(() => entry.CheckAsync(cts.Token));
+ }
+
+ private sealed class StubChecker(UpdateCheckResult result) : IUpdateCheckable
+ {
+ public Task CheckForUpdatesAsync(CancellationToken ct) => Task.FromResult(result);
+ }
+
+ private sealed class ThrowingChecker : IUpdateCheckable
+ {
+ public Task CheckForUpdatesAsync(CancellationToken ct) =>
+ throw new InvalidOperationException("boom");
+ }
+
+ private sealed class CancellableChecker : IUpdateCheckable
+ {
+ public Task CheckForUpdatesAsync(CancellationToken ct)
+ {
+ ct.ThrowIfCancellationRequested();
+ return Task.FromResult(new UpdateCheckResult(false, null, null, null));
+ }
+ }
+}
diff --git a/tests/SharpFM.Tests/ViewModels/AboutViewModelTests.cs b/tests/SharpFM.Tests/ViewModels/AboutViewModelTests.cs
new file mode 100644
index 0000000..6a3671e
--- /dev/null
+++ b/tests/SharpFM.Tests/ViewModels/AboutViewModelTests.cs
@@ -0,0 +1,90 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using SharpFM.Plugin;
+using SharpFM.ViewModels;
+using Xunit;
+
+namespace SharpFM.Tests.ViewModels;
+
+public class AboutViewModelTests
+{
+ [Fact]
+ public void Host_AlwaysCheckable()
+ {
+ var vm = new AboutViewModel(
+ "SharpFM", "2.0.0", new StubChecker(),
+ new Uri("https://example.invalid"),
+ Array.Empty());
+
+ Assert.True(vm.Host.CanCheckForUpdates);
+ }
+
+ [Fact]
+ public void Plugins_WithoutUpdateChannel_AreNotCheckable()
+ {
+ var plugins = new IPlugin[] { new BundledPlugin("Bundled", "1.0.0") };
+
+ var vm = new AboutViewModel(
+ "SharpFM", "2.0.0", new StubChecker(),
+ new Uri("https://example.invalid"),
+ plugins);
+
+ Assert.Single(vm.Plugins);
+ Assert.False(vm.Plugins[0].CanCheckForUpdates);
+ Assert.Equal("Bundled", vm.Plugins[0].DisplayName);
+ Assert.Equal("1.0.0", vm.Plugins[0].Version);
+ }
+
+ [Fact]
+ public void Plugins_WithUpdateChannel_AreCheckable()
+ {
+ var plugins = new IPlugin[] { new UpdatablePlugin("Updatable", "1.0.0") };
+
+ var vm = new AboutViewModel(
+ "SharpFM", "2.0.0", new StubChecker(),
+ new Uri("https://example.invalid"),
+ plugins);
+
+ Assert.Single(vm.Plugins);
+ Assert.True(vm.Plugins[0].CanCheckForUpdates);
+ }
+
+ [Fact]
+ public void HostHomepage_IsExposed()
+ {
+ var url = new Uri("https://example.invalid/releases");
+ var vm = new AboutViewModel(
+ "SharpFM", "2.0.0", new StubChecker(),
+ url, Array.Empty());
+
+ Assert.Equal(url, vm.HostHomepageUrl);
+ }
+
+ private sealed class StubChecker : IUpdateCheckable
+ {
+ public Task CheckForUpdatesAsync(CancellationToken ct) =>
+ Task.FromResult(new UpdateCheckResult(false, null, null, null));
+ }
+
+ private class BundledPlugin(string name, string version) : IPlugin
+ {
+ public string Id => name;
+ public string DisplayName => name;
+ public string Description => "";
+ public string Version { get; } = version;
+ public IReadOnlyList KeyBindings => Array.Empty();
+ public IReadOnlyList MenuActions => Array.Empty();
+ public PluginConfigSchema ConfigSchema => PluginConfigSchema.Empty;
+ public void Initialize(IPluginHost host) { }
+ public void OnConfigChanged(IReadOnlyDictionary values) { }
+ public void Dispose() { }
+ }
+
+ private sealed class UpdatablePlugin(string name, string version) : BundledPlugin(name, version), IUpdateCheckable
+ {
+ public Task CheckForUpdatesAsync(CancellationToken ct) =>
+ Task.FromResult(new UpdateCheckResult(false, null, null, null));
+ }
+}