From e1be072b97bdd6b02f5da6b188485de931ec51c4 Mon Sep 17 00:00:00 2001 From: lincube Date: Tue, 10 Mar 2026 16:35:43 +0800 Subject: [PATCH] 0.5.11 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 插件市场UI优化 --- LanMountainDesktop/App.axaml | 17 - LanMountainDesktop/App.axaml.cs | 100 ++- LanMountainDesktop/Localization/en-US.json | 13 + LanMountainDesktop/Localization/zh-CN.json | 13 + .../plugins/PluginMarketEmbeddedView.cs | 814 ++++++++++++------ .../plugins/PluginMarketIconService.cs | 47 + 6 files changed, 716 insertions(+), 288 deletions(-) create mode 100644 LanMountainDesktop/plugins/PluginMarketIconService.cs diff --git a/LanMountainDesktop/App.axaml b/LanMountainDesktop/App.axaml index af30398..1eb2491 100644 --- a/LanMountainDesktop/App.axaml +++ b/LanMountainDesktop/App.axaml @@ -16,23 +16,6 @@ - - - - - - - - - - - - - - - - diff --git a/LanMountainDesktop/App.axaml.cs b/LanMountainDesktop/App.axaml.cs index 521e28b..68084d8 100644 --- a/LanMountainDesktop/App.axaml.cs +++ b/LanMountainDesktop/App.axaml.cs @@ -1,4 +1,5 @@ -using Avalonia; +using Avalonia; +using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Data.Core; using Avalonia.Data.Core.Plugins; @@ -6,6 +7,7 @@ using System; using System.Diagnostics; using System.Linq; using Avalonia.Markup.Xaml; +using Avalonia.Platform; using Avalonia.Threading; using LanMountainDesktop.Services; using LanMountainDesktop.ViewModels; @@ -16,7 +18,11 @@ namespace LanMountainDesktop; public partial class App : Application { + private readonly AppSettingsService _appSettingsService = new(); + private readonly LocalizationService _localizationService = new(); + private SettingsWindow? _traySettingsWindow; + private TrayIcons? _trayIcons; private PluginRuntimeService? _pluginRuntimeService; public PluginRuntimeService? PluginRuntimeService => _pluginRuntimeService; @@ -32,13 +38,20 @@ public partial class App : Application { LinuxDesktopEntryInstaller.EnsureInstalled(); InitializePluginRuntime(); + AppSettingsService.SettingsSaved += OnAppSettingsSaved; + InitializeTrayIcon(); if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { - // Avoid duplicate validations from both Avalonia and the CommunityToolkit. + // Avoid duplicate validations from both Avalonia and the CommunityToolkit. // More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins DisableAvaloniaDataAnnotationValidation(); desktop.ShutdownMode = Avalonia.Controls.ShutdownMode.OnExplicitShutdown; + desktop.Exit += (_, _) => + { + AppSettingsService.SettingsSaved -= OnAppSettingsSaved; + DisposeTrayIcon(); + }; desktop.MainWindow = new MainWindow { DataContext = new MainWindowViewModel(), @@ -50,6 +63,8 @@ public partial class App : Application private void OnTrayExitClick(object? sender, EventArgs e) { + DisposeTrayIcon(); + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { desktop.Shutdown(); @@ -99,6 +114,17 @@ public partial class App : Application AppRestartService.TryRestartApplication(); } + private void OnAppSettingsSaved(string _) + { + Dispatcher.UIThread.Post(() => + { + if (_trayIcons is not null) + { + InitializeTrayIcon(); + } + }, DispatcherPriority.Background); + } + private void DisableAvaloniaDataAnnotationValidation() { // Get an array of plugins to remove @@ -152,4 +178,74 @@ public partial class App : Application Debug.WriteLine($"[PluginRuntime] Failed to initialize plugin runtime: {ex}"); } } + + private void InitializeTrayIcon() + { + try + { + DisposeTrayIcon(); + + using var iconStream = AssetLoader.Open(new Uri("avares://LanMountainDesktop/Assets/avalonia-logo.ico")); + var trayIcon = new TrayIcon + { + Icon = new WindowIcon(iconStream), + ToolTipText = L("tray.tooltip", "LanMountainDesktop"), + Menu = BuildTrayMenu(), + IsVisible = true + }; + + _trayIcons = [trayIcon]; + TrayIcon.SetIcons(this, _trayIcons); + } + catch (Exception ex) + { + Debug.WriteLine($"[TrayIcon] Failed to initialize tray icon: {ex}"); + } + } + + private NativeMenu BuildTrayMenu() + { + var menu = new NativeMenu(); + + var settingsItem = new NativeMenuItem(L("tray.menu.settings", "")); + settingsItem.Click += OnTraySettingsClick; + menu.Items.Add(settingsItem); + + menu.Items.Add(new NativeMenuItemSeparator()); + + var restartItem = new NativeMenuItem(L("tray.menu.restart", "Ӧ")); + restartItem.Click += OnTrayRestartClick; + menu.Items.Add(restartItem); + + menu.Items.Add(new NativeMenuItemSeparator()); + + var exitItem = new NativeMenuItem(L("tray.menu.exit", "˳Ӧ")); + exitItem.Click += OnTrayExitClick; + menu.Items.Add(exitItem); + + return menu; + } + + private void DisposeTrayIcon() + { + if (_trayIcons is null) + { + return; + } + + TrayIcon.SetIcons(this, null); + foreach (var trayIcon in _trayIcons) + { + trayIcon.Dispose(); + } + + _trayIcons = null; + } + + private string L(string key, string fallback) + { + var snapshot = _appSettingsService.Load(); + var languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); + return _localizationService.GetString(languageCode, key, fallback); + } } diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json index 0656856..10e9436 100644 --- a/LanMountainDesktop/Localization/en-US.json +++ b/LanMountainDesktop/Localization/en-US.json @@ -1,5 +1,9 @@ { "app.title": "LanMountainDesktop", + "tray.tooltip": "LanMountainDesktop", + "tray.menu.settings": "Settings", + "tray.menu.restart": "Restart App", + "tray.menu.exit": "Exit App", "button.back_to_windows": "Back to Windows", "tooltip.back_to_windows": "Back to Windows", "tooltip.open_settings": "Settings", @@ -364,6 +368,15 @@ "market.detail.min_host_version": "Minimum Host Version", "market.detail.installed_version": "Installed Version", "market.detail.not_installed": "Not installed", + "market.detail.readme": "README", + "market.detail.plugin_information": "Plugin Information", + "market.detail.author_subtitle_format": "By {0}", + "market.detail.package_size": "Package Size", + "market.detail.published_at": "Published At", + "market.detail.updated_at": "Updated At", + "market.detail.tags": "Tags", + "market.detail.project": "Project", + "market.detail.state": "Install State", "market.detail.market_source": "Market Source", "market.detail.homepage": "Homepage", "market.detail.repository": "Repository", diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json index d05f4a3..9f77f5a 100644 --- a/LanMountainDesktop/Localization/zh-CN.json +++ b/LanMountainDesktop/Localization/zh-CN.json @@ -1,5 +1,9 @@ { "app.title": "LanMountainDesktop", + "tray.tooltip": "LanMountainDesktop", + "tray.menu.settings": "设置", + "tray.menu.restart": "重启应用", + "tray.menu.exit": "退出应用", "button.back_to_windows": "回到Windows", "tooltip.back_to_windows": "回到Windows", "tooltip.open_settings": "设置", @@ -364,6 +368,15 @@ "market.detail.min_host_version": "最低宿主版本", "market.detail.installed_version": "已安装版本", "market.detail.not_installed": "未安装", + "market.detail.readme": "README", + "market.detail.plugin_information": "插件信息", + "market.detail.author_subtitle_format": "作者:{0}", + "market.detail.package_size": "包大小", + "market.detail.published_at": "首次发布", + "market.detail.updated_at": "最近更新", + "market.detail.tags": "标签", + "market.detail.project": "项目", + "market.detail.state": "安装状态", "market.detail.market_source": "市场源", "market.detail.homepage": "主页", "market.detail.repository": "仓库", diff --git a/LanMountainDesktop/plugins/PluginMarketEmbeddedView.cs b/LanMountainDesktop/plugins/PluginMarketEmbeddedView.cs index 1793665..781e17e 100644 --- a/LanMountainDesktop/plugins/PluginMarketEmbeddedView.cs +++ b/LanMountainDesktop/plugins/PluginMarketEmbeddedView.cs @@ -6,9 +6,11 @@ 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; @@ -17,7 +19,12 @@ 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("#1F0EA5E9")); + 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")); @@ -28,6 +35,7 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable private readonly AirAppMarketIndexService _indexService; private readonly AirAppMarketInstallService _installService; private readonly AirAppMarketReadmeService _readmeService; + private readonly AirAppMarketIconService _iconService; private readonly Version? _hostVersion; private readonly TextBox _searchTextBox; @@ -41,8 +49,11 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable private Dictionary _installedPlugins = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _readmeContents = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _readmeErrors = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _iconBitmaps = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _loadingIconPluginIds = new(StringComparer.OrdinalIgnoreCase); private string _marketSourceDisplay = AirAppMarketDefaults.DefaultIndexUrl; private string? _loadingReadmePluginId; + private string? _installingPluginId; private bool _isRefreshing; private bool _isInstalling; private bool _hasLoadedOnce; @@ -54,12 +65,13 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable _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 = 240, - Watermark = T("market.toolbar.search_placeholder", "搜索插件") + MinWidth = 260, + Watermark = T("market.toolbar.search_placeholder", "Search plugins") }; _searchTextBox.PropertyChanged += (_, e) => { @@ -71,14 +83,14 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable _refreshButton = new Button { - Content = T("market.toolbar.refresh", "刷新"), - HorizontalAlignment = HorizontalAlignment.Left + Content = T("market.toolbar.refresh", "Refresh"), + HorizontalAlignment = HorizontalAlignment.Right }; _refreshButton.Click += OnRefreshClick; _statusTextBlock = new TextBlock { - Text = T("market.status.loading", "正在加载官方插件市场…"), + Text = T("market.status.loading", "Loading the official plugin market..."), TextWrapping = TextWrapping.Wrap, Foreground = WarningBrush }; @@ -88,7 +100,7 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable Spacing = 10 }; - _detailBorder = CreatePanelShell(); + _detailBorder = CreatePanelShell(18); Content = BuildLayout(); AttachedToVisualTree += async (_, _) => @@ -119,6 +131,13 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable public void Dispose() { + foreach (var bitmap in _iconBitmaps.Values) + { + bitmap?.Dispose(); + } + + _iconBitmaps.Clear(); + _iconService.Dispose(); _readmeService.Dispose(); _installService.Dispose(); _indexService.Dispose(); @@ -133,40 +152,35 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable }; 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(new StackPanel - { - Spacing = 8, - Children = - { - new StackPanel - { - Orientation = Orientation.Horizontal, - Spacing = 10, - Children = - { - _searchTextBox, - _refreshButton - } - }, - _statusTextBlock - } - }); + toolbar.Children.Add(actionRow); + toolbar.Children.Add(_statusTextBlock); + Grid.SetRow(_statusTextBlock, 1); var contentGrid = new Grid { - ColumnDefinitions = new ColumnDefinitions("360,*"), + ColumnDefinitions = new ColumnDefinitions("430,*"), ColumnSpacing = 16 }; - var listShell = CreatePanelShell(); + var listShell = CreatePanelShell(14); listShell.Child = new ScrollViewer { - Content = _pluginListHost + Content = _pluginListHost, + HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled }; contentGrid.Children.Add(listShell); @@ -194,7 +208,7 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable _isRefreshing = true; _refreshButton.IsEnabled = false; - SetStatus(T("market.status.loading", "正在加载官方插件市场…"), WarningBrush); + SetStatus(T("market.status.loading", "Loading the official plugin market..."), WarningBrush); try { @@ -206,7 +220,10 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable _document = null; _selectedPlugin = null; SetStatus( - F("market.status.load_failed_format", "加载插件市场失败:{0}", result.ErrorMessage ?? T("market.detail.unknown", "未知错误")), + F( + "market.status.load_failed_format", + "Failed to load the plugin market: {0}", + result.ErrorMessage ?? T("market.detail.unknown", "Unknown")), ErrorBrush); RebuildSurface(); return; @@ -219,12 +236,12 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable var statusMessage = result.Source == AirAppMarketLoadSource.Cache ? F( "market.status.loaded_cache_format", - "官方源不可用,已从缓存加载 {0} 个插件。原因:{1}", + "Official source unavailable. Loaded {0} plugin(s) from cache. Reason: {1}", result.Document.Plugins.Count, - result.WarningMessage ?? T("market.detail.unknown", "未知错误")) + result.WarningMessage ?? T("market.detail.unknown", "Unknown")) : F( "market.status.loaded_network_format", - "已从官方源加载 {0} 个插件。", + "Loaded {0} plugin(s) from the official source.", result.Document.Plugins.Count); SetStatus(statusMessage, result.Source == AirAppMarketLoadSource.Cache ? WarningBrush : SuccessBrush); @@ -241,18 +258,18 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable private void RebuildSurface() { var filteredPlugins = GetFilteredPlugins(); - if (filteredPlugins.Count > 0) - { - _selectedPlugin = ResolveSelectedPlugin(_selectedPlugin?.Id, filteredPlugins); - } - else - { - _selectedPlugin = null; - } + _selectedPlugin = filteredPlugins.Count > 0 + ? ResolveSelectedPlugin(_selectedPlugin?.Id, filteredPlugins) + : null; BuildPluginList(filteredPlugins); BuildDetailPanel(); + _ = EnsureReadmeLoadedAsync(_selectedPlugin); + foreach (var plugin in filteredPlugins) + { + _ = EnsureIconLoadedAsync(plugin); + } } private List GetFilteredPlugins() @@ -263,13 +280,12 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable } var query = (_searchTextBox.Text ?? string.Empty).Trim(); - var source = _document.Plugins; if (string.IsNullOrWhiteSpace(query)) { - return source.ToList(); + return _document.Plugins.ToList(); } - return source + return _document.Plugins .Where(plugin => plugin.Name.Contains(query, StringComparison.OrdinalIgnoreCase) || plugin.Description.Contains(query, StringComparison.OrdinalIgnoreCase) || @@ -285,99 +301,323 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable if (_document is null) { - _pluginListHost.Children.Add(CreateEmptyState(T("market.list.empty", "插件市场尚未加载。"))); + _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", "没有匹配的插件。"))); + _pluginListHost.Children.Add(CreateEmptyState(T("market.list.no_results", "No plugins match the current search."))); return; } foreach (var plugin in plugins) { - _pluginListHost.Children.Add(CreatePluginCard(plugin)); + _pluginListHost.Children.Add(CreatePluginListItem(plugin)); } } - private Control CreatePluginCard(AirAppMarketPluginEntry 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 { - HorizontalContentAlignment = HorizontalAlignment.Stretch, - Padding = new Thickness(0), - Background = Brushes.Transparent, - BorderThickness = new Thickness(0), - Content = new Border - { - Background = isSelected ? SelectedSurfaceBrush : SurfaceBrush, - CornerRadius = new CornerRadius(16), - Padding = new Thickness(14), - Child = new StackPanel - { - Spacing = 10, - Children = - { - new Grid - { - ColumnDefinitions = new ColumnDefinitions("Auto,*"), - ColumnSpacing = 12, - Children = - { - CreateMonogramIcon(plugin.Name, 42), - new StackPanel - { - Spacing = 4, - Children = - { - new TextBlock - { - Text = plugin.Name, - FontSize = 16, - FontWeight = FontWeight.SemiBold, - TextWrapping = TextWrapping.Wrap - }, - new TextBlock - { - Text = F("market.card.subtitle_format", "{0} · v{1}", plugin.Author, plugin.Version), - Foreground = Brushes.Gray, - TextWrapping = TextWrapping.Wrap - } - } - } - } - }, - new TextBlock - { - Text = plugin.Description, - TextWrapping = TextWrapping.Wrap, - MaxHeight = 56 - }, - new StackPanel - { - Orientation = Orientation.Horizontal, - Spacing = 8, - Children = - { - CreateStateChip(T(StateKey(installState), StateFallback(installState))), - CreateStateChip(installedPlugin?.IsLoaded == true - ? T("market.card.loaded", "已加载") - : T("market.card.pending_restart", "需重启")), - new TextBlock - { - Text = string.Join(" ", plugin.Tags.Take(3)), - VerticalAlignment = VerticalAlignment.Center, - Foreground = Brushes.Gray - } - } - } - } - } - } + 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 (_, _) => @@ -385,126 +625,17 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable _selectedPlugin = plugin; RebuildSurface(); await EnsureReadmeLoadedAsync(plugin); + await InstallSelectedPluginAsync(plugin); }; return button; } - private void BuildDetailPanel() + private async Task SelectPluginAsync(AirAppMarketPluginEntry plugin) { - if (_selectedPlugin is null) - { - _detailBorder.Child = CreateEmptyState(T("market.detail.placeholder", "从左侧选择一个插件以查看详情。")); - return; - } - - var plugin = _selectedPlugin; - var installState = ResolveInstallState(plugin, out var installedPlugin); - var isCompatible = IsCompatibleWithHost(plugin); - var installButton = new Button - { - Content = _isInstalling - ? T("market.button.installing", "安装中…") - : T(ButtonKey(installState), ButtonFallback(installState)), - IsEnabled = !_isInstalling && isCompatible && installState != AirAppMarketInstallState.Installed, - HorizontalAlignment = HorizontalAlignment.Left, - MinWidth = 120 - }; - installButton.Click += async (_, _) => await InstallSelectedPluginAsync(plugin); - - var detailPanel = new StackPanel - { - Spacing = 14, - Children = - { - new Grid - { - ColumnDefinitions = new ColumnDefinitions("Auto,*"), - ColumnSpacing = 14, - Children = - { - CreateMonogramIcon(plugin.Name, 64), - new StackPanel - { - Spacing = 4, - Children = - { - new TextBlock - { - Text = plugin.Name, - FontSize = 24, - FontWeight = FontWeight.SemiBold, - TextWrapping = TextWrapping.Wrap - }, - new TextBlock - { - Text = plugin.Description, - TextWrapping = TextWrapping.Wrap - } - } - } - } - }, - new StackPanel - { - Orientation = Orientation.Horizontal, - Spacing = 8, - Children = - { - CreateStateChip(T(StateKey(installState), StateFallback(installState))), - CreateStateChip(plugin.GetVersionSummary()), - CreateStateChip(string.Join(", ", plugin.Tags)) - } - }, - installButton, - CreateInfoRow(T("market.detail.author", "作者"), plugin.Author), - CreateInfoRow(T("market.detail.version", "版本"), plugin.Version), - CreateInfoRow(T("market.detail.api_version", "API 版本"), plugin.ApiVersion), - CreateInfoRow(T("market.detail.min_host_version", "最低宿主版本"), plugin.MinHostVersion), - CreateInfoRow(T("market.detail.installed_version", "当前已安装版本"), installedPlugin?.Manifest.Version ?? T("market.detail.not_installed", "未安装")), - CreateInfoRow(T("market.detail.market_source", "市场源"), _marketSourceDisplay), - CreateInfoRow(T("market.detail.project", "Project"), plugin.ProjectUrl), - CreateInfoRow(T("market.detail.homepage", "主页"), plugin.HomepageUrl), - CreateInfoRow(T("market.detail.repository", "仓库"), plugin.RepositoryUrl), - new TextBlock - { - Text = T("market.detail.readme", "README"), - FontSize = 18, - FontWeight = FontWeight.SemiBold - }, - new Border - { - Background = SurfaceBrush, - CornerRadius = new CornerRadius(16), - Padding = new Thickness(14), - Child = new TextBlock - { - Text = GetReadmeContent(plugin), - TextWrapping = TextWrapping.Wrap - } - } - } - }; - - if (!isCompatible) - { - detailPanel.Children.Insert( - 3, - new TextBlock - { - Text = F( - "market.status.host_incompatible_format", - "当前宿主版本过低,至少需要 {0}。", - plugin.MinHostVersion), - Foreground = ErrorBrush, - TextWrapping = TextWrapping.Wrap - }); - } - - _detailBorder.Child = new ScrollViewer - { - Content = detailPanel - }; + _selectedPlugin = plugin; + RebuildSurface(); + await EnsureReadmeLoadedAsync(plugin); } private async Task InstallSelectedPluginAsync(AirAppMarketPluginEntry plugin) @@ -515,9 +646,11 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable } _isInstalling = true; + _installingPluginId = plugin.Id; BuildDetailPanel(); + BuildPluginList(GetFilteredPlugins()); SetStatus( - F("market.status.installing_format", "正在下载并暂存插件“{0}”…", plugin.Name), + F("market.status.installing_format", "Downloading and staging plugin '{0}'...", plugin.Name), WarningBrush); try @@ -528,8 +661,8 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable SetStatus( F( "market.status.install_failed_format", - "安装插件失败:{0}", - result.ErrorMessage ?? T("market.detail.unknown", "未知错误")), + "Failed to install plugin: {0}", + result.ErrorMessage ?? T("market.detail.unknown", "Unknown")), ErrorBrush); return; } @@ -538,7 +671,7 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable SetStatus( F( "market.status.install_success_format", - "插件“{0}”已暂存完成,重启应用后生效。", + "Plugin '{0}' has been staged. Restart the app to apply it.", result.Manifest.Name), SuccessBrush); RebuildSurface(); @@ -546,7 +679,8 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable finally { _isInstalling = false; - BuildDetailPanel(); + _installingPluginId = null; + RebuildSurface(); } } @@ -584,6 +718,30 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable } } + 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)) @@ -676,13 +834,13 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable return (leftVersion ?? new Version(0, 0, 0)).CompareTo(rightVersion ?? new Version(0, 0, 0)); } - private Border CreatePanelShell() + private Border CreatePanelShell(double padding) { return new Border { Background = SurfaceBrush, CornerRadius = new CornerRadius(18), - Padding = new Thickness(16) + Padding = new Thickness(padding) }; } @@ -692,6 +850,8 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable { Background = SurfaceBrush, CornerRadius = new CornerRadius(16), + BorderBrush = CardBorderBrush, + BorderThickness = new Thickness(1), Padding = new Thickness(18), Child = new TextBlock { @@ -701,32 +861,68 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable }; } - private Border CreateMonogramIcon(string text, double size) + private Control CreatePluginIcon(AirAppMarketPluginEntry plugin, double size) { - var glyph = string.IsNullOrWhiteSpace(text) ? "?" : text.Trim()[0].ToString().ToUpperInvariant(); - return new Border + if (!_iconBitmaps.ContainsKey(plugin.Id)) { - Width = size, - Height = size, - CornerRadius = new CornerRadius(size / 2), - Background = new SolidColorBrush(Color.Parse("#FF0EA5E9")), - Child = new TextBlock + _ = 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(16, size * 0.36), + 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 = new SolidColorBrush(Color.Parse("#22000000")), + Background = ChipBrush, CornerRadius = new CornerRadius(999), Padding = new Thickness(10, 4), Child = new TextBlock @@ -737,28 +933,108 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable }; } - private Control CreateInfoRow(string label, string value) + private TextBlock CreateSectionTitle(string text) { - return new StackPanel + return new TextBlock { - Spacing = 4, - Children = + 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 { - new TextBlock + Spacing = 6, + Children = { - Text = label, - FontSize = 12, - Foreground = Brushes.Gray - }, - new TextBlock - { - Text = string.IsNullOrWhiteSpace(value) ? T("market.detail.unknown", "未知") : value, - TextWrapping = TextWrapping.Wrap + 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(); @@ -784,9 +1060,9 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable { return state switch { - AirAppMarketInstallState.UpdateAvailable => "可更新", - AirAppMarketInstallState.Installed => "已安装", - _ => "未安装" + AirAppMarketInstallState.UpdateAvailable => "Update available", + AirAppMarketInstallState.Installed => "Installed", + _ => "Not installed" }; } @@ -804,9 +1080,9 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable { return state switch { - AirAppMarketInstallState.UpdateAvailable => "更新", - AirAppMarketInstallState.Installed => "已安装", - _ => "安装" + AirAppMarketInstallState.UpdateAvailable => "Update", + AirAppMarketInstallState.Installed => "Installed", + _ => "Install" }; } } diff --git a/LanMountainDesktop/plugins/PluginMarketIconService.cs b/LanMountainDesktop/plugins/PluginMarketIconService.cs new file mode 100644 index 0000000..38a0947 --- /dev/null +++ b/LanMountainDesktop/plugins/PluginMarketIconService.cs @@ -0,0 +1,47 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Media.Imaging; + +namespace LanMountainDesktop.Views.SettingsPages; + +internal sealed class AirAppMarketIconService : IDisposable +{ + private readonly HttpClient _httpClient; + + public AirAppMarketIconService() + { + _httpClient = new HttpClient + { + Timeout = TimeSpan.FromSeconds(20) + }; + _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0"); + } + + public async Task LoadAsync( + AirAppMarketPluginEntry plugin, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(plugin); + + if (AirAppMarketDefaults.TryResolveWorkspaceFile(plugin.IconUrl, out var localIconPath)) + { + return new Bitmap(localIconPath); + } + + using var response = await _httpClient.GetAsync(plugin.IconUrl, cancellationToken); + response.EnsureSuccessStatusCode(); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + using var memory = new MemoryStream(); + await stream.CopyToAsync(memory, cancellationToken); + memory.Position = 0; + return new Bitmap(memory); + } + + public void Dispose() + { + _httpClient.Dispose(); + } +}