Files
LanMountainDesktop/LanMountainDesktop/plugins/PluginMarketEmbeddedView.cs
lincube 5003ff1be2 0.5.12
2026-03-10 21:25:47 +08:00

1092 lines
35 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.SettingsPages;
internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
{
private static readonly IBrush SurfaceBrush = new SolidColorBrush(Color.Parse("#14000000"));
private static readonly IBrush SelectedSurfaceBrush = new SolidColorBrush(Color.Parse("#1A0EA5E9"));
private static readonly IBrush CardBorderBrush = new SolidColorBrush(Color.Parse("#24FFFFFF"));
private static readonly IBrush SelectedBorderBrush = new SolidColorBrush(Color.Parse("#7C0EA5E9"));
private static readonly IBrush IconSurfaceBrush = new SolidColorBrush(Color.Parse("#221E3A8A"));
private static readonly IBrush ChipBrush = new SolidColorBrush(Color.Parse("#22000000"));
private static readonly IBrush MutedBrush = new SolidColorBrush(Color.Parse("#CC94A3B8"));
private static readonly IBrush SuccessBrush = new SolidColorBrush(Color.Parse("#FF0F766E"));
private static readonly IBrush WarningBrush = new SolidColorBrush(Color.Parse("#FF9A6700"));
private static readonly IBrush ErrorBrush = new SolidColorBrush(Color.Parse("#FFC42B1C"));
private readonly AppSettingsService _appSettingsService = new();
private readonly LocalizationService _localizationService = new();
private readonly PluginRuntimeService _runtime;
private readonly AirAppMarketIndexService _indexService;
private readonly AirAppMarketInstallService _installService;
private readonly AirAppMarketReadmeService _readmeService;
private readonly AirAppMarketIconService _iconService;
private readonly Version? _hostVersion;
private readonly TextBox _searchTextBox;
private readonly Button _refreshButton;
private readonly TextBlock _statusTextBlock;
private readonly StackPanel _pluginListHost;
private readonly Border _detailBorder;
private AirAppMarketIndexDocument? _document;
private AirAppMarketPluginEntry? _selectedPlugin;
private Dictionary<string, PluginCatalogEntry> _installedPlugins = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, string> _readmeContents = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, string> _readmeErrors = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, Bitmap?> _iconBitmaps = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _loadingIconPluginIds = new(StringComparer.OrdinalIgnoreCase);
private string _marketSourceDisplay = AirAppMarketDefaults.DefaultIndexUrl;
private string? _loadingReadmePluginId;
private string? _installingPluginId;
private bool _isRefreshing;
private bool _isInstalling;
private bool _hasLoadedOnce;
public PluginMarketEmbeddedView(PluginRuntimeService runtime)
{
_runtime = runtime;
var dataDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop",
"AirAppMarket");
_indexService = new AirAppMarketIndexService(new AirAppMarketCacheService(dataDirectory));
_installService = new AirAppMarketInstallService(runtime, dataDirectory);
_readmeService = new AirAppMarketReadmeService();
_iconService = new AirAppMarketIconService();
_hostVersion = typeof(App).Assembly.GetName().Version;
_searchTextBox = new TextBox
{
MinWidth = 260,
Watermark = T("market.toolbar.search_placeholder", "Search plugins")
};
_searchTextBox.PropertyChanged += (_, e) =>
{
if (e.Property == TextBox.TextProperty)
{
RebuildSurface();
}
};
_refreshButton = new Button
{
Content = T("market.toolbar.refresh", "Refresh"),
HorizontalAlignment = HorizontalAlignment.Right
};
_refreshButton.Click += OnRefreshClick;
_statusTextBlock = new TextBlock
{
Text = T("market.status.loading", "Loading the official plugin market..."),
TextWrapping = TextWrapping.Wrap,
Foreground = WarningBrush
};
_pluginListHost = new StackPanel
{
Spacing = 10
};
_detailBorder = CreatePanelShell(18);
Content = BuildLayout();
AttachedToVisualTree += async (_, _) =>
{
if (_hasLoadedOnce)
{
return;
}
_hasLoadedOnce = true;
await RefreshAsync();
};
}
public void RefreshInstalledSnapshot()
{
_installedPlugins = _runtime.Catalog
.ToDictionary(entry => entry.Manifest.Id, StringComparer.OrdinalIgnoreCase);
RebuildSurface();
}
public void RefreshLocalization()
{
_searchTextBox.Watermark = T("market.toolbar.search_placeholder", "Search plugins");
_refreshButton.Content = T("market.toolbar.refresh", "Refresh");
RebuildSurface();
}
public void Dispose()
{
foreach (var bitmap in _iconBitmaps.Values)
{
bitmap?.Dispose();
}
_iconBitmaps.Clear();
_iconService.Dispose();
_readmeService.Dispose();
_installService.Dispose();
_indexService.Dispose();
}
private Control BuildLayout()
{
var root = new Grid
{
RowDefinitions = new RowDefinitions("Auto,*"),
RowSpacing = 16
};
var toolbar = new Grid
{
RowDefinitions = new RowDefinitions("Auto,Auto"),
RowSpacing = 8
};
var actionRow = new Grid
{
ColumnDefinitions = new ColumnDefinitions("*,Auto"),
ColumnSpacing = 12
};
actionRow.Children.Add(_searchTextBox);
actionRow.Children.Add(_refreshButton);
Grid.SetColumn(_refreshButton, 1);
toolbar.Children.Add(actionRow);
toolbar.Children.Add(_statusTextBlock);
Grid.SetRow(_statusTextBlock, 1);
var contentGrid = new Grid
{
ColumnDefinitions = new ColumnDefinitions("430,*"),
ColumnSpacing = 16
};
var listShell = CreatePanelShell(14);
listShell.Child = new ScrollViewer
{
Content = _pluginListHost,
HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled
};
contentGrid.Children.Add(listShell);
contentGrid.Children.Add(_detailBorder);
Grid.SetColumn(_detailBorder, 1);
root.Children.Add(toolbar);
root.Children.Add(contentGrid);
Grid.SetRow(contentGrid, 1);
return root;
}
private async void OnRefreshClick(object? sender, RoutedEventArgs e)
{
await RefreshAsync();
}
private async Task RefreshAsync()
{
if (_isRefreshing)
{
return;
}
_isRefreshing = true;
_refreshButton.IsEnabled = false;
SetStatus(T("market.status.loading", "Loading the official plugin market..."), WarningBrush);
try
{
RefreshInstalledSnapshot();
var result = await _indexService.LoadAsync();
if (!result.Success || result.Document is null)
{
_document = null;
_selectedPlugin = null;
SetStatus(
F(
"market.status.load_failed_format",
"Failed to load the plugin market: {0}",
result.ErrorMessage ?? T("market.detail.unknown", "Unknown")),
ErrorBrush);
RebuildSurface();
return;
}
_document = result.Document;
_marketSourceDisplay = result.SourceLocation ?? AirAppMarketDefaults.DefaultIndexUrl;
_selectedPlugin = ResolveSelectedPlugin(_selectedPlugin?.Id, result.Document.Plugins);
var statusMessage = result.Source == AirAppMarketLoadSource.Cache
? F(
"market.status.loaded_cache_format",
"Official source unavailable. Loaded {0} plugin(s) from cache. Reason: {1}",
result.Document.Plugins.Count,
result.WarningMessage ?? T("market.detail.unknown", "Unknown"))
: F(
"market.status.loaded_network_format",
"Loaded {0} plugin(s) from the official source.",
result.Document.Plugins.Count);
SetStatus(statusMessage, result.Source == AirAppMarketLoadSource.Cache ? WarningBrush : SuccessBrush);
RebuildSurface();
await EnsureReadmeLoadedAsync(_selectedPlugin);
}
finally
{
_isRefreshing = false;
_refreshButton.IsEnabled = true;
}
}
private void RebuildSurface()
{
var filteredPlugins = GetFilteredPlugins();
_selectedPlugin = filteredPlugins.Count > 0
? ResolveSelectedPlugin(_selectedPlugin?.Id, filteredPlugins)
: null;
BuildPluginList(filteredPlugins);
BuildDetailPanel();
_ = EnsureReadmeLoadedAsync(_selectedPlugin);
foreach (var plugin in filteredPlugins)
{
_ = EnsureIconLoadedAsync(plugin);
}
}
private List<AirAppMarketPluginEntry> GetFilteredPlugins()
{
if (_document is null)
{
return [];
}
var query = (_searchTextBox.Text ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(query))
{
return _document.Plugins.ToList();
}
return _document.Plugins
.Where(plugin =>
plugin.Name.Contains(query, StringComparison.OrdinalIgnoreCase) ||
plugin.Description.Contains(query, StringComparison.OrdinalIgnoreCase) ||
plugin.Author.Contains(query, StringComparison.OrdinalIgnoreCase) ||
plugin.Id.Contains(query, StringComparison.OrdinalIgnoreCase) ||
plugin.Tags.Any(tag => tag.Contains(query, StringComparison.OrdinalIgnoreCase)))
.ToList();
}
private void BuildPluginList(IReadOnlyList<AirAppMarketPluginEntry> plugins)
{
_pluginListHost.Children.Clear();
if (_document is null)
{
_pluginListHost.Children.Add(CreateEmptyState(T("market.list.empty", "The plugin market has not been loaded yet.")));
return;
}
if (plugins.Count == 0)
{
_pluginListHost.Children.Add(CreateEmptyState(T("market.list.no_results", "No plugins match the current search.")));
return;
}
foreach (var plugin in plugins)
{
_pluginListHost.Children.Add(CreatePluginListItem(plugin));
}
}
private Control CreatePluginListItem(AirAppMarketPluginEntry plugin)
{
var installState = ResolveInstallState(plugin, out var installedPlugin);
var isCompatible = IsCompatibleWithHost(plugin);
var isSelected = string.Equals(_selectedPlugin?.Id, plugin.Id, StringComparison.OrdinalIgnoreCase);
var titleBlock = new TextBlock
{
Text = plugin.Name,
FontSize = 16,
FontWeight = FontWeight.SemiBold,
TextWrapping = TextWrapping.Wrap
};
var subtitleBlock = new TextBlock
{
Text = F("market.card.subtitle_format", "{0} | v{1}", plugin.Author, plugin.Version),
Foreground = MutedBrush,
TextWrapping = TextWrapping.Wrap
};
var descriptionBlock = new TextBlock
{
Text = plugin.Description,
TextWrapping = TextWrapping.Wrap,
MaxHeight = 40
};
var chips = CreateChipWrapPanel(
CreateStateChip(T(StateKey(installState), StateFallback(installState))));
if (installedPlugin is not null)
{
chips.Children.Add(CreateStateChip(installedPlugin.IsLoaded
? T("market.card.loaded", "Loaded")
: T("market.card.pending_restart", "Restart required")));
}
foreach (var tag in plugin.Tags.Take(3))
{
chips.Children.Add(CreateStateChip(tag));
}
var summaryStack = new StackPanel
{
Spacing = 6,
VerticalAlignment = VerticalAlignment.Center,
Children =
{
titleBlock,
subtitleBlock,
descriptionBlock,
chips
}
};
var selectGrid = new Grid
{
ColumnDefinitions = new ColumnDefinitions("Auto,*"),
ColumnSpacing = 14,
Children =
{
CreatePluginIcon(plugin, 56),
summaryStack
}
};
Grid.SetColumn(summaryStack, 1);
var selectButton = new Button
{
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
Padding = new Thickness(0),
HorizontalContentAlignment = HorizontalAlignment.Stretch,
Content = selectGrid
};
selectButton.Click += async (_, _) => await SelectPluginAsync(plugin);
var rightPanel = new StackPanel
{
Spacing = 8,
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Top,
Children =
{
CreateInstallButton(plugin, installState, isCompatible, 96),
new TextBlock
{
Text = installedPlugin is null
? string.Empty
: installedPlugin.IsLoaded
? T("market.card.loaded", "Loaded")
: T("market.card.pending_restart", "Restart required"),
FontSize = 12,
Foreground = MutedBrush,
HorizontalAlignment = HorizontalAlignment.Right,
IsVisible = installedPlugin is not null
}
}
};
var layoutGrid = new Grid
{
ColumnDefinitions = new ColumnDefinitions("*,Auto"),
ColumnSpacing = 14,
Children =
{
selectButton,
rightPanel
}
};
Grid.SetColumn(rightPanel, 1);
return new Border
{
Background = isSelected ? SelectedSurfaceBrush : SurfaceBrush,
BorderBrush = isSelected ? SelectedBorderBrush : CardBorderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(18),
Padding = new Thickness(14),
Child = layoutGrid
};
}
private void BuildDetailPanel()
{
if (_selectedPlugin is null)
{
_detailBorder.Child = CreateEmptyState(T("market.detail.placeholder", "Select a plugin on the left to inspect details."));
return;
}
var plugin = _selectedPlugin;
var installState = ResolveInstallState(plugin, out var installedPlugin);
var isCompatible = IsCompatibleWithHost(plugin);
var headerSummary = new StackPanel
{
Spacing = 6,
Children =
{
new TextBlock
{
Text = plugin.Name,
FontSize = 26,
FontWeight = FontWeight.SemiBold,
TextWrapping = TextWrapping.Wrap
},
new TextBlock
{
Text = F("market.detail.author_subtitle_format", "By {0}", plugin.Author),
Foreground = MutedBrush,
TextWrapping = TextWrapping.Wrap
},
new TextBlock
{
Text = plugin.Description,
TextWrapping = TextWrapping.Wrap
}
}
};
var headerChips = CreateChipWrapPanel(
CreateStateChip(T(StateKey(installState), StateFallback(installState))),
CreateStateChip(plugin.GetVersionSummary()));
foreach (var tag in plugin.Tags)
{
headerChips.Children.Add(CreateStateChip(tag));
}
headerSummary.Children.Add(headerChips);
var headerGrid = new Grid
{
ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"),
ColumnSpacing = 16,
Children =
{
CreatePluginIcon(plugin, 76),
headerSummary,
CreateInstallButton(plugin, installState, isCompatible, 120)
}
};
Grid.SetColumn(headerSummary, 1);
Grid.SetColumn(headerGrid.Children[2], 2);
var detailPanel = new StackPanel
{
Spacing = 18,
Children =
{
headerGrid
}
};
if (!isCompatible)
{
detailPanel.Children.Add(new Border
{
Background = new SolidColorBrush(Color.Parse("#24FFC42B1C")),
CornerRadius = new CornerRadius(14),
Padding = new Thickness(12),
Child = new TextBlock
{
Text = F(
"market.status.host_incompatible_format",
"This host is too old. Version {0} or newer is required.",
plugin.MinHostVersion),
Foreground = ErrorBrush,
TextWrapping = TextWrapping.Wrap
}
});
}
detailPanel.Children.Add(CreateSectionTitle(T("market.detail.readme", "README")));
detailPanel.Children.Add(new Border
{
Background = SurfaceBrush,
BorderBrush = CardBorderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(16),
Padding = new Thickness(16),
Child = new TextBlock
{
Text = GetReadmeContent(plugin),
TextWrapping = TextWrapping.Wrap
}
});
detailPanel.Children.Add(CreateSectionTitle(T("market.detail.plugin_information", "Plugin Information")));
detailPanel.Children.Add(CreatePluginInfoSection(plugin, installedPlugin, installState));
_detailBorder.Child = new ScrollViewer
{
Content = detailPanel,
HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled
};
}
private Control CreatePluginInfoSection(
AirAppMarketPluginEntry plugin,
PluginCatalogEntry? installedPlugin,
AirAppMarketInstallState installState)
{
var infoPanel = new StackPanel
{
Spacing = 14
};
var cardWrap = new WrapPanel
{
Orientation = Orientation.Horizontal
};
foreach (var card in new[]
{
CreateInfoCard(T("market.detail.version", "Version"), $"v{plugin.Version}"),
CreateInfoCard(T("market.detail.installed_version", "Installed Version"), installedPlugin?.Manifest.Version ?? T("market.detail.not_installed", "Not installed")),
CreateInfoCard(T("market.detail.api_version", "API Version"), plugin.ApiVersion),
CreateInfoCard(T("market.detail.min_host_version", "Minimum Host Version"), plugin.MinHostVersion),
CreateInfoCard(T("market.detail.package_size", "Package Size"), FormatPackageSize(plugin.PackageSizeBytes)),
CreateInfoCard(T("market.detail.published_at", "Published At"), FormatTimestamp(plugin.PublishedAt)),
CreateInfoCard(T("market.detail.updated_at", "Updated At"), FormatTimestamp(plugin.UpdatedAt)),
CreateInfoCard(T("market.detail.state", "Install State"), T(StateKey(installState), StateFallback(installState)))
})
{
cardWrap.Children.Add(card);
}
infoPanel.Children.Add(cardWrap);
infoPanel.Children.Add(CreateInfoRow(T("market.detail.tags", "Tags"), plugin.Tags.Count == 0 ? T("market.detail.unknown", "Unknown") : string.Join(", ", plugin.Tags)));
infoPanel.Children.Add(CreateInfoRow(T("market.detail.project", "Project"), plugin.ProjectUrl));
infoPanel.Children.Add(CreateInfoRow(T("market.detail.homepage", "Homepage"), plugin.HomepageUrl));
infoPanel.Children.Add(CreateInfoRow(T("market.detail.repository", "Repository"), plugin.RepositoryUrl));
infoPanel.Children.Add(CreateInfoRow(T("market.detail.market_source", "Market Source"), _marketSourceDisplay));
if (!string.IsNullOrWhiteSpace(plugin.ReleaseNotes))
{
infoPanel.Children.Add(CreateInfoRow(T("market.detail.release_notes", "Release Notes"), plugin.ReleaseNotes));
}
return infoPanel;
}
private Button CreateInstallButton(
AirAppMarketPluginEntry plugin,
AirAppMarketInstallState installState,
bool isCompatible,
double minWidth)
{
var isThisInstalling = _isInstalling &&
string.Equals(_installingPluginId, plugin.Id, StringComparison.OrdinalIgnoreCase);
var button = new Button
{
Content = isThisInstalling
? T("market.button.installing", "Installing...")
: T(ButtonKey(installState), ButtonFallback(installState)),
IsEnabled = !_isInstalling && isCompatible && installState != AirAppMarketInstallState.Installed,
MinWidth = minWidth,
HorizontalAlignment = HorizontalAlignment.Right
};
button.Click += async (_, _) =>
{
_selectedPlugin = plugin;
RebuildSurface();
await EnsureReadmeLoadedAsync(plugin);
await InstallSelectedPluginAsync(plugin);
};
return button;
}
private async Task SelectPluginAsync(AirAppMarketPluginEntry plugin)
{
_selectedPlugin = plugin;
RebuildSurface();
await EnsureReadmeLoadedAsync(plugin);
}
private async Task InstallSelectedPluginAsync(AirAppMarketPluginEntry plugin)
{
if (_isInstalling)
{
return;
}
_isInstalling = true;
_installingPluginId = plugin.Id;
BuildDetailPanel();
BuildPluginList(GetFilteredPlugins());
SetStatus(
F("market.status.installing_format", "Downloading and staging plugin '{0}'...", plugin.Name),
WarningBrush);
try
{
var result = await _installService.InstallAsync(plugin);
if (!result.Success || result.Manifest is null)
{
SetStatus(
F(
"market.status.install_failed_format",
"Failed to install plugin: {0}",
result.ErrorMessage ?? T("market.detail.unknown", "Unknown")),
ErrorBrush);
return;
}
RefreshInstalledSnapshot();
SetStatus(
F(
"market.status.install_success_format",
"Plugin '{0}' has been staged. Restart the app to apply it.",
result.Manifest.Name),
SuccessBrush);
RebuildSurface();
}
finally
{
_isInstalling = false;
_installingPluginId = null;
RebuildSurface();
}
}
private async Task EnsureReadmeLoadedAsync(AirAppMarketPluginEntry? plugin)
{
if (plugin is null ||
_readmeContents.ContainsKey(plugin.Id) ||
string.Equals(_loadingReadmePluginId, plugin.Id, StringComparison.OrdinalIgnoreCase))
{
return;
}
_loadingReadmePluginId = plugin.Id;
_readmeErrors.Remove(plugin.Id);
BuildDetailPanel();
try
{
var readme = await _readmeService.LoadAsync(plugin);
_readmeContents[plugin.Id] = string.IsNullOrWhiteSpace(readme)
? T("market.detail.readme_empty", "README is empty.")
: readme.Trim();
}
catch (Exception ex)
{
_readmeErrors[plugin.Id] = ex.Message;
}
finally
{
_loadingReadmePluginId = null;
if (string.Equals(_selectedPlugin?.Id, plugin.Id, StringComparison.OrdinalIgnoreCase))
{
BuildDetailPanel();
}
}
}
private async Task EnsureIconLoadedAsync(AirAppMarketPluginEntry? plugin)
{
if (plugin is null ||
_iconBitmaps.ContainsKey(plugin.Id) ||
!_loadingIconPluginIds.Add(plugin.Id))
{
return;
}
try
{
_iconBitmaps[plugin.Id] = await _iconService.LoadAsync(plugin);
}
catch
{
_iconBitmaps[plugin.Id] = null;
}
finally
{
_loadingIconPluginIds.Remove(plugin.Id);
RebuildSurface();
}
}
private string GetReadmeContent(AirAppMarketPluginEntry plugin)
{
if (_readmeContents.TryGetValue(plugin.Id, out var readme))
{
return readme;
}
if (_readmeErrors.TryGetValue(plugin.Id, out var error))
{
return F(
"market.detail.readme_error_format",
"README could not be loaded: {0}",
error);
}
if (string.Equals(_loadingReadmePluginId, plugin.Id, StringComparison.OrdinalIgnoreCase))
{
return T("market.detail.readme_loading", "Loading README...");
}
return plugin.ReleaseNotes;
}
private AirAppMarketPluginEntry? ResolveSelectedPlugin(
string? selectedPluginId,
IReadOnlyList<AirAppMarketPluginEntry> plugins)
{
if (plugins.Count == 0)
{
return null;
}
if (!string.IsNullOrWhiteSpace(selectedPluginId))
{
var existing = plugins.FirstOrDefault(plugin =>
string.Equals(plugin.Id, selectedPluginId, StringComparison.OrdinalIgnoreCase));
if (existing is not null)
{
return existing;
}
}
return plugins[0];
}
private AirAppMarketInstallState ResolveInstallState(
AirAppMarketPluginEntry plugin,
out PluginCatalogEntry? installedPlugin)
{
if (!_installedPlugins.TryGetValue(plugin.Id, out installedPlugin))
{
return AirAppMarketInstallState.NotInstalled;
}
return CompareVersions(plugin.Version, installedPlugin.Manifest.Version) > 0
? AirAppMarketInstallState.UpdateAvailable
: AirAppMarketInstallState.Installed;
}
private bool IsCompatibleWithHost(AirAppMarketPluginEntry plugin)
{
if (_hostVersion is null ||
!AirAppMarketIndexDocument.TryParseVersion(plugin.MinHostVersion, out var minHostVersion) ||
minHostVersion is null)
{
return true;
}
return _hostVersion >= minHostVersion;
}
private void SetStatus(string message, IBrush foreground)
{
_statusTextBlock.Text = message;
_statusTextBlock.Foreground = foreground;
}
private static int CompareVersions(string? left, string? right)
{
if (!AirAppMarketIndexDocument.TryParseVersion(left, out var leftVersion))
{
leftVersion = new Version(0, 0, 0);
}
if (!AirAppMarketIndexDocument.TryParseVersion(right, out var rightVersion))
{
rightVersion = new Version(0, 0, 0);
}
return (leftVersion ?? new Version(0, 0, 0)).CompareTo(rightVersion ?? new Version(0, 0, 0));
}
private Border CreatePanelShell(double padding)
{
return new Border
{
Background = SurfaceBrush,
CornerRadius = new CornerRadius(18),
Padding = new Thickness(padding)
};
}
private Control CreateEmptyState(string text)
{
return new Border
{
Background = SurfaceBrush,
CornerRadius = new CornerRadius(16),
BorderBrush = CardBorderBrush,
BorderThickness = new Thickness(1),
Padding = new Thickness(18),
Child = new TextBlock
{
Text = text,
TextWrapping = TextWrapping.Wrap
}
};
}
private Control CreatePluginIcon(AirAppMarketPluginEntry plugin, double size)
{
if (!_iconBitmaps.ContainsKey(plugin.Id))
{
_ = EnsureIconLoadedAsync(plugin);
}
Control iconChild;
if (_iconBitmaps.TryGetValue(plugin.Id, out var bitmap) && bitmap is not null)
{
iconChild = new Image
{
Source = bitmap,
Stretch = Stretch.UniformToFill
};
}
else
{
var glyph = string.IsNullOrWhiteSpace(plugin.Name) ? "?" : plugin.Name.Trim()[0].ToString().ToUpperInvariant();
iconChild = new TextBlock
{
Text = glyph,
FontSize = Math.Max(18, size * 0.32),
FontWeight = FontWeight.Bold,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
TextAlignment = TextAlignment.Center
};
}
return new Border
{
Width = size,
Height = size,
CornerRadius = new CornerRadius(Math.Max(12, size * 0.24)),
ClipToBounds = true,
Background = IconSurfaceBrush,
Child = iconChild
};
}
private WrapPanel CreateChipWrapPanel(params Control[] chips)
{
var panel = new WrapPanel
{
Orientation = Orientation.Horizontal
};
foreach (var chip in chips)
{
chip.Margin = new Thickness(0, 0, 8, 8);
panel.Children.Add(chip);
}
return panel;
}
private Border CreateStateChip(string text)
{
return new Border
{
Background = ChipBrush,
CornerRadius = new CornerRadius(999),
Padding = new Thickness(10, 4),
Child = new TextBlock
{
Text = text,
FontSize = 12
}
};
}
private TextBlock CreateSectionTitle(string text)
{
return new TextBlock
{
Text = text,
FontSize = 18,
FontWeight = FontWeight.SemiBold
};
}
private Border CreateInfoCard(string label, string value)
{
return new Border
{
Width = 190,
Margin = new Thickness(0, 0, 12, 12),
Background = SurfaceBrush,
BorderBrush = CardBorderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(14),
Padding = new Thickness(14),
Child = new StackPanel
{
Spacing = 6,
Children =
{
new TextBlock
{
Text = label,
FontSize = 12,
Foreground = MutedBrush
},
new TextBlock
{
Text = string.IsNullOrWhiteSpace(value) ? T("market.detail.unknown", "Unknown") : value,
TextWrapping = TextWrapping.Wrap
}
}
}
};
}
private Control CreateInfoRow(string label, string value)
{
return new Border
{
Background = SurfaceBrush,
BorderBrush = CardBorderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(14),
Padding = new Thickness(14),
Child = new StackPanel
{
Spacing = 6,
Children =
{
new TextBlock
{
Text = label,
FontSize = 12,
Foreground = MutedBrush
},
new TextBlock
{
Text = string.IsNullOrWhiteSpace(value) ? T("market.detail.unknown", "Unknown") : value,
TextWrapping = TextWrapping.Wrap
}
}
}
};
}
private static string FormatPackageSize(long packageSizeBytes)
{
var size = packageSizeBytes;
string[] units = ["B", "KB", "MB", "GB"];
var unitIndex = 0;
decimal display = size;
while (display >= 1024 && unitIndex < units.Length - 1)
{
display /= 1024;
unitIndex++;
}
return string.Format(
CultureInfo.CurrentCulture,
display >= 10 || unitIndex == 0 ? "{0:0} {1}" : "{0:0.0} {1}",
display,
units[unitIndex]);
}
private static string FormatTimestamp(DateTimeOffset timestamp)
{
if (timestamp == default)
{
return string.Empty;
}
return timestamp.ToLocalTime().ToString("yyyy-MM-dd HH:mm", CultureInfo.CurrentCulture);
}
private string T(string key, string fallback)
{
var snapshot = _appSettingsService.Load();
return _localizationService.GetString(snapshot.LanguageCode, key, fallback);
}
private string F(string key, string fallback, params object[] args)
{
return string.Format(CultureInfo.CurrentCulture, T(key, fallback), args);
}
private static string StateKey(AirAppMarketInstallState state)
{
return state switch
{
AirAppMarketInstallState.UpdateAvailable => "market.detail.state.update_available",
AirAppMarketInstallState.Installed => "market.detail.state.installed",
_ => "market.detail.state.not_installed"
};
}
private static string StateFallback(AirAppMarketInstallState state)
{
return state switch
{
AirAppMarketInstallState.UpdateAvailable => "Update available",
AirAppMarketInstallState.Installed => "Installed",
_ => "Not installed"
};
}
private static string ButtonKey(AirAppMarketInstallState state)
{
return state switch
{
AirAppMarketInstallState.UpdateAvailable => "market.button.update",
AirAppMarketInstallState.Installed => "market.button.installed",
_ => "market.button.install"
};
}
private static string ButtonFallback(AirAppMarketInstallState state)
{
return state switch
{
AirAppMarketInstallState.UpdateAvailable => "Update",
AirAppMarketInstallState.Installed => "Installed",
_ => "Install"
};
}
}