Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/SharpFM.Plugin/IUpdateCheckable.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using System.Threading;
using System.Threading.Tasks;

namespace SharpFM.Plugin;

/// <summary>
/// Opt-in capability for plugins that have a remote update channel. Implementing
/// this interface is independent of <see cref="IPlugin"/> — 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 <see cref="IPlugin.Version"/> without a check-for-updates affordance.
/// </summary>
public interface IUpdateCheckable
{
/// <summary>
/// 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.
///
/// <para>
/// Implementations should fail silently on network errors, returning a
/// result with <see cref="UpdateCheckResult.UpdateAvailable"/> set to
/// <c>false</c> rather than throwing. A thrown exception is treated by the
/// host as a check failure and silently suppressed.
/// </para>
/// </summary>
Task<UpdateCheckResult> CheckForUpdatesAsync(CancellationToken ct);
}
30 changes: 30 additions & 0 deletions src/SharpFM.Plugin/UpdateCheckResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using System;

namespace SharpFM.Plugin;

/// <summary>
/// Outcome of an <see cref="IUpdateCheckable.CheckForUpdatesAsync"/> call.
/// All three optional fields may be <c>null</c> when
/// <see cref="UpdateAvailable"/> is <c>false</c>.
/// </summary>
/// <param name="UpdateAvailable">
/// <c>true</c> when the channel reports a newer version than what the plugin
/// is currently running.
/// </param>
/// <param name="LatestVersion">
/// Human-readable version string of the latest release (e.g. <c>"2.0.0"</c>).
/// Format is the plugin's choice; the host displays it verbatim.
/// </param>
/// <param name="ReleaseUrl">
/// Where to point the user to obtain the update — typically a release notes
/// or download page. Opened in the system browser on click.
/// </param>
/// <param name="Notes">
/// Optional short summary surfaced alongside the version (e.g. release
/// highlights). The host shows it as plain text; do not include markup.
/// </param>
public sealed record UpdateCheckResult(
bool UpdateAvailable,
string? LatestVersion,
Uri? ReleaseUrl,
string? Notes);
102 changes: 102 additions & 0 deletions src/SharpFM/Dialogs/AboutDialog.axaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<Window
x:Class="SharpFM.Dialogs.AboutDialog"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:SharpFM.ViewModels"
Title="About SharpFM"
Width="480"
x:DataType="vm:AboutViewModel"
CanResize="False"
SizeToContent="Height"
WindowStartupLocation="CenterOwner">

<Grid Margin="20" RowDefinitions="Auto,Auto,Auto,Auto,Auto,*,Auto">

<TextBlock
Grid.Row="0"
Classes="Fluent2Title"
Text="{Binding Host.DisplayName}" />

<TextBlock
Grid.Row="1"
Margin="0,0,0,12"
Classes="Fluent2Body"
Opacity="0.7"
Text="{Binding Host.Version}" />

<StackPanel
Grid.Row="2"
Margin="0,0,0,4"
Orientation="Horizontal"
Spacing="8">
<Button
x:Name="hostCheckButton"
Classes="Fluent2"
Content="Check for updates" />
<Button
x:Name="hostOpenUpdateButton"
Classes="Fluent2"
Content="View release"
IsVisible="False" />
<Button
x:Name="hostHomepageButton"
Classes="Fluent2"
Content="GitHub" />
</StackPanel>

<TextBlock
Grid.Row="3"
Margin="0,0,0,12"
Classes="Fluent2Caption"
IsVisible="{Binding Host.Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Text="{Binding Host.Status}" />

<TextBlock
Grid.Row="4"
Margin="0,8,0,8"
Classes="Fluent2Subtitle"
IsVisible="{Binding Plugins.Count}"
Text="Loaded plugins" />

<ItemsControl Grid.Row="5" ItemsSource="{Binding Plugins}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:AboutEntryViewModel">
<Grid Margin="0,4" ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock
Classes="Fluent2Body"
FontWeight="SemiBold"
Text="{Binding DisplayName}" />
<TextBlock
Classes="Fluent2Caption"
Opacity="0.7"
Text="{Binding Version}" />
<TextBlock
Classes="Fluent2Caption"
IsVisible="{Binding Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Opacity="0.7"
Text="{Binding Status}" />
</StackPanel>
<Button
Grid.Column="1"
Classes="Fluent2"
Click="OnPluginCheckClick"
Content="Check for updates"
IsVisible="{Binding CanCheckForUpdates}"
Tag="{Binding}" />
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>

<Button
x:Name="closeButton"
Grid.Row="6"
Margin="0,16,0,0"
HorizontalAlignment="Right"
Classes="Fluent2Primary"
Content="Close" />

</Grid>

</Window>
78 changes: 78 additions & 0 deletions src/SharpFM/Dialogs/AboutDialog.axaml.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Help → About window. Surfaces host version and the list of loaded plugins.
/// Each entry that opted in via <c>IUpdateCheckable</c> gets a check-for-updates
/// affordance; checks run against whatever channel the implementer chose.
/// </summary>
[ExcludeFromCodeCoverage]
public partial class AboutDialog : Window
{
private AboutViewModel? _vm;

public AboutDialog()
{
AvaloniaXamlLoader.Load(this);

this.FindControl<Button>("closeButton")!.Click += (_, _) => Close();
this.FindControl<Button>("hostCheckButton")!.Click += OnHostCheckClick;
this.FindControl<Button>("hostOpenUpdateButton")!.Click += OnHostOpenUpdateClick;
this.FindControl<Button>("hostHomepageButton")!.Click += OnHostHomepageClick;
}

public void Configure(AboutViewModel vm)
{
_vm = vm;
DataContext = vm;
vm.Host.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(AboutEntryViewModel.UpdateUrl))
this.FindControl<Button>("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.
}
}
}
3 changes: 3 additions & 0 deletions src/SharpFM/MainWindow.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@
<!-- Plugin menu items are added dynamically in code-behind -->
<MenuItem x:Name="managePluginsMenuItem" Header="Manage Plugins..." />
</MenuItem>
<MenuItem Header="_Help">
<MenuItem x:Name="aboutMenuItem" Header="About SharpFM..." />
</MenuItem>
</Menu>

<!-- Status bar -->
Expand Down
31 changes: 31 additions & 0 deletions src/SharpFM/MainWindow.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -38,6 +39,11 @@ public MainWindow()
if (rawClipboard != null)
rawClipboard.Click += (_, _) => new RawClipboardWindow().Show(this);

// "About SharpFM..." menu item
var about = this.FindControl<MenuItem>("aboutMenuItem");
if (about != null)
about.Click += (_, _) => ShowAbout();

// Tunnel-phase Ctrl+V — see OnPreviewKeyDown.
AddHandler(KeyDownEvent, OnPreviewKeyDown, RoutingStrategies.Tunnel);

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading