mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 09:14:25 +08:00
0.5.8
插件市场
This commit is contained in:
44
LanMountainDesktop/plugins/PluginMarketCacheService.cs
Normal file
44
LanMountainDesktop/plugins/PluginMarketCacheService.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace LanMountainDesktop.Views.SettingsPages;
|
||||
|
||||
internal sealed class AirAppMarketCacheService
|
||||
{
|
||||
private readonly string _cacheDirectory;
|
||||
|
||||
public AirAppMarketCacheService(string dataDirectory)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(dataDirectory);
|
||||
_cacheDirectory = Path.Combine(dataDirectory, "cache");
|
||||
}
|
||||
|
||||
public string CacheFilePath => Path.Combine(_cacheDirectory, "index.json");
|
||||
|
||||
public void SaveIndexJson(string json)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(json);
|
||||
Directory.CreateDirectory(_cacheDirectory);
|
||||
File.WriteAllText(CacheFilePath, json);
|
||||
}
|
||||
|
||||
public bool TryReadIndexJson(out string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(CacheFilePath))
|
||||
{
|
||||
json = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
json = File.ReadAllText(CacheFilePath);
|
||||
return !string.IsNullOrWhiteSpace(json);
|
||||
}
|
||||
catch
|
||||
{
|
||||
json = string.Empty;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
745
LanMountainDesktop/plugins/PluginMarketEmbeddedView.cs
Normal file
745
LanMountainDesktop/plugins/PluginMarketEmbeddedView.cs
Normal file
@@ -0,0 +1,745 @@
|
||||
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.Interactivity;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
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("#1F0EA5E9"));
|
||||
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 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 string _marketSourceDisplay = AirAppMarketDefaults.DefaultIndexUrl;
|
||||
private bool _isRefreshing;
|
||||
private bool _isInstalling;
|
||||
private bool _hasLoadedOnce;
|
||||
|
||||
public PluginMarketEmbeddedView(PluginRuntimeService runtime)
|
||||
{
|
||||
_runtime = runtime;
|
||||
var dataDirectory = Path.Combine(AppContext.BaseDirectory, "Data", "AirAppMarket");
|
||||
_indexService = new AirAppMarketIndexService(new AirAppMarketCacheService(dataDirectory));
|
||||
_installService = new AirAppMarketInstallService(runtime, dataDirectory);
|
||||
_hostVersion = typeof(App).Assembly.GetName().Version;
|
||||
|
||||
_searchTextBox = new TextBox
|
||||
{
|
||||
MinWidth = 240,
|
||||
Watermark = T("market.toolbar.search_placeholder", "搜索插件")
|
||||
};
|
||||
_searchTextBox.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.Property == TextBox.TextProperty)
|
||||
{
|
||||
RebuildSurface();
|
||||
}
|
||||
};
|
||||
|
||||
_refreshButton = new Button
|
||||
{
|
||||
Content = T("market.toolbar.refresh", "刷新"),
|
||||
HorizontalAlignment = HorizontalAlignment.Left
|
||||
};
|
||||
_refreshButton.Click += OnRefreshClick;
|
||||
|
||||
_statusTextBlock = new TextBlock
|
||||
{
|
||||
Text = T("market.status.loading", "正在加载官方插件市场…"),
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Foreground = WarningBrush
|
||||
};
|
||||
|
||||
_pluginListHost = new StackPanel
|
||||
{
|
||||
Spacing = 10
|
||||
};
|
||||
|
||||
_detailBorder = CreatePanelShell();
|
||||
|
||||
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()
|
||||
{
|
||||
_installService.Dispose();
|
||||
_indexService.Dispose();
|
||||
}
|
||||
|
||||
private Control BuildLayout()
|
||||
{
|
||||
var root = new Grid
|
||||
{
|
||||
RowDefinitions = new RowDefinitions("Auto,*"),
|
||||
RowSpacing = 16
|
||||
};
|
||||
|
||||
var toolbar = new Grid
|
||||
{
|
||||
ColumnDefinitions = new ColumnDefinitions("*,Auto"),
|
||||
ColumnSpacing = 12
|
||||
};
|
||||
|
||||
toolbar.Children.Add(new StackPanel
|
||||
{
|
||||
Spacing = 8,
|
||||
Children =
|
||||
{
|
||||
new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
Spacing = 10,
|
||||
Children =
|
||||
{
|
||||
_searchTextBox,
|
||||
_refreshButton
|
||||
}
|
||||
},
|
||||
_statusTextBlock
|
||||
}
|
||||
});
|
||||
|
||||
var contentGrid = new Grid
|
||||
{
|
||||
ColumnDefinitions = new ColumnDefinitions("360,*"),
|
||||
ColumnSpacing = 16
|
||||
};
|
||||
|
||||
var listShell = CreatePanelShell();
|
||||
listShell.Child = new ScrollViewer
|
||||
{
|
||||
Content = _pluginListHost
|
||||
};
|
||||
|
||||
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", "正在加载官方插件市场…"), 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", "加载插件市场失败:{0}", result.ErrorMessage ?? T("market.detail.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",
|
||||
"官方源不可用,已从缓存加载 {0} 个插件。原因:{1}",
|
||||
result.Document.Plugins.Count,
|
||||
result.WarningMessage ?? T("market.detail.unknown", "未知错误"))
|
||||
: F(
|
||||
"market.status.loaded_network_format",
|
||||
"已从官方源加载 {0} 个插件。",
|
||||
result.Document.Plugins.Count);
|
||||
|
||||
SetStatus(statusMessage, result.Source == AirAppMarketLoadSource.Cache ? WarningBrush : SuccessBrush);
|
||||
RebuildSurface();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isRefreshing = false;
|
||||
_refreshButton.IsEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void RebuildSurface()
|
||||
{
|
||||
var filteredPlugins = GetFilteredPlugins();
|
||||
if (filteredPlugins.Count > 0)
|
||||
{
|
||||
_selectedPlugin = ResolveSelectedPlugin(_selectedPlugin?.Id, filteredPlugins);
|
||||
}
|
||||
else
|
||||
{
|
||||
_selectedPlugin = null;
|
||||
}
|
||||
|
||||
BuildPluginList(filteredPlugins);
|
||||
BuildDetailPanel();
|
||||
}
|
||||
|
||||
private List<AirAppMarketPluginEntry> GetFilteredPlugins()
|
||||
{
|
||||
if (_document is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var query = (_searchTextBox.Text ?? string.Empty).Trim();
|
||||
var source = _document.Plugins;
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return source.ToList();
|
||||
}
|
||||
|
||||
return source
|
||||
.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", "插件市场尚未加载。")));
|
||||
return;
|
||||
}
|
||||
|
||||
if (plugins.Count == 0)
|
||||
{
|
||||
_pluginListHost.Children.Add(CreateEmptyState(T("market.list.no_results", "没有匹配的插件。")));
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var plugin in plugins)
|
||||
{
|
||||
_pluginListHost.Children.Add(CreatePluginCard(plugin));
|
||||
}
|
||||
}
|
||||
|
||||
private Control CreatePluginCard(AirAppMarketPluginEntry plugin)
|
||||
{
|
||||
var installState = ResolveInstallState(plugin, out var installedPlugin);
|
||||
var isSelected = string.Equals(_selectedPlugin?.Id, 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
button.Click += (_, _) =>
|
||||
{
|
||||
_selectedPlugin = plugin;
|
||||
RebuildSurface();
|
||||
};
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
private void BuildDetailPanel()
|
||||
{
|
||||
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.homepage", "主页"), plugin.HomepageUrl),
|
||||
CreateInfoRow(T("market.detail.repository", "仓库"), plugin.RepositoryUrl),
|
||||
new TextBlock
|
||||
{
|
||||
Text = T("market.detail.release_notes", "发布说明"),
|
||||
FontSize = 18,
|
||||
FontWeight = FontWeight.SemiBold
|
||||
},
|
||||
new Border
|
||||
{
|
||||
Background = SurfaceBrush,
|
||||
CornerRadius = new CornerRadius(16),
|
||||
Padding = new Thickness(14),
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = plugin.ReleaseNotes,
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
private async Task InstallSelectedPluginAsync(AirAppMarketPluginEntry plugin)
|
||||
{
|
||||
if (_isInstalling)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isInstalling = true;
|
||||
BuildDetailPanel();
|
||||
SetStatus(
|
||||
F("market.status.installing_format", "正在下载并暂存插件“{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",
|
||||
"安装插件失败:{0}",
|
||||
result.ErrorMessage ?? T("market.detail.unknown", "未知错误")),
|
||||
ErrorBrush);
|
||||
return;
|
||||
}
|
||||
|
||||
RefreshInstalledSnapshot();
|
||||
SetStatus(
|
||||
F(
|
||||
"market.status.install_success_format",
|
||||
"插件“{0}”已暂存完成,重启应用后生效。",
|
||||
result.Manifest.Name),
|
||||
SuccessBrush);
|
||||
RebuildSurface();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isInstalling = false;
|
||||
BuildDetailPanel();
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
return new Border
|
||||
{
|
||||
Background = SurfaceBrush,
|
||||
CornerRadius = new CornerRadius(18),
|
||||
Padding = new Thickness(16)
|
||||
};
|
||||
}
|
||||
|
||||
private Control CreateEmptyState(string text)
|
||||
{
|
||||
return new Border
|
||||
{
|
||||
Background = SurfaceBrush,
|
||||
CornerRadius = new CornerRadius(16),
|
||||
Padding = new Thickness(18),
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = text,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private Border CreateMonogramIcon(string text, double size)
|
||||
{
|
||||
var glyph = string.IsNullOrWhiteSpace(text) ? "?" : text.Trim()[0].ToString().ToUpperInvariant();
|
||||
return new Border
|
||||
{
|
||||
Width = size,
|
||||
Height = size,
|
||||
CornerRadius = new CornerRadius(size / 2),
|
||||
Background = new SolidColorBrush(Color.Parse("#FF0EA5E9")),
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = glyph,
|
||||
FontSize = Math.Max(16, size * 0.36),
|
||||
FontWeight = FontWeight.Bold,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
TextAlignment = TextAlignment.Center
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private Border CreateStateChip(string text)
|
||||
{
|
||||
return new Border
|
||||
{
|
||||
Background = new SolidColorBrush(Color.Parse("#22000000")),
|
||||
CornerRadius = new CornerRadius(999),
|
||||
Padding = new Thickness(10, 4),
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = text,
|
||||
FontSize = 12
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private Control CreateInfoRow(string label, string value)
|
||||
{
|
||||
return new StackPanel
|
||||
{
|
||||
Spacing = 4,
|
||||
Children =
|
||||
{
|
||||
new TextBlock
|
||||
{
|
||||
Text = label,
|
||||
FontSize = 12,
|
||||
Foreground = Brushes.Gray
|
||||
},
|
||||
new TextBlock
|
||||
{
|
||||
Text = string.IsNullOrWhiteSpace(value) ? T("market.detail.unknown", "未知") : value,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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 => "可更新",
|
||||
AirAppMarketInstallState.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 => "更新",
|
||||
AirAppMarketInstallState.Installed => "已安装",
|
||||
_ => "安装"
|
||||
};
|
||||
}
|
||||
}
|
||||
121
LanMountainDesktop/plugins/PluginMarketIndexService.cs
Normal file
121
LanMountainDesktop/plugins/PluginMarketIndexService.cs
Normal file
@@ -0,0 +1,121 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LanMountainDesktop.Views.SettingsPages;
|
||||
|
||||
internal sealed class AirAppMarketIndexService : IDisposable
|
||||
{
|
||||
private readonly AirAppMarketCacheService _cacheService;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public AirAppMarketIndexService(AirAppMarketCacheService cacheService)
|
||||
{
|
||||
_cacheService = cacheService;
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(20)
|
||||
};
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
|
||||
_httpClient.DefaultRequestHeaders.Accept.Add(
|
||||
new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
}
|
||||
|
||||
public async Task<AirAppMarketLoadResult> LoadAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
Exception? networkError = null;
|
||||
|
||||
if (AirAppMarketDefaults.TryGetWorkspaceIndexPath() is { } localIndexPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(localIndexPath, cancellationToken);
|
||||
var document = AirAppMarketIndexDocument.Load(json, localIndexPath);
|
||||
_cacheService.SaveIndexJson(json);
|
||||
return new AirAppMarketLoadResult(
|
||||
true,
|
||||
document,
|
||||
AirAppMarketLoadSource.Local,
|
||||
localIndexPath,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
networkError = ex;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(
|
||||
AirAppMarketDefaults.DefaultIndexUrl,
|
||||
cancellationToken);
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var document = AirAppMarketIndexDocument.Load(json, AirAppMarketDefaults.DefaultIndexUrl);
|
||||
_cacheService.SaveIndexJson(json);
|
||||
return new AirAppMarketLoadResult(
|
||||
true,
|
||||
document,
|
||||
AirAppMarketLoadSource.Network,
|
||||
AirAppMarketDefaults.DefaultIndexUrl,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
networkError = ex;
|
||||
}
|
||||
|
||||
if (_cacheService.TryReadIndexJson(out var cachedJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
var cachedDocument = AirAppMarketIndexDocument.Load(cachedJson, _cacheService.CacheFilePath);
|
||||
return new AirAppMarketLoadResult(
|
||||
true,
|
||||
cachedDocument,
|
||||
AirAppMarketLoadSource.Cache,
|
||||
_cacheService.CacheFilePath,
|
||||
networkError?.Message,
|
||||
null);
|
||||
}
|
||||
catch (Exception cacheEx)
|
||||
{
|
||||
return new AirAppMarketLoadResult(
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
$"{networkError?.Message ?? "Unknown network error"} | Cached index invalid: {cacheEx.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return new AirAppMarketLoadResult(
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
networkError?.Message ?? "Unknown network error");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
}
|
||||
97
LanMountainDesktop/plugins/PluginMarketInstallService.cs
Normal file
97
LanMountainDesktop/plugins/PluginMarketInstallService.cs
Normal file
@@ -0,0 +1,97 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Security.Cryptography;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.SettingsPages;
|
||||
|
||||
internal sealed class AirAppMarketInstallService : IDisposable
|
||||
{
|
||||
private readonly PluginRuntimeService _runtime;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly string _downloadsDirectory;
|
||||
|
||||
public AirAppMarketInstallService(PluginRuntimeService runtime, string dataDirectory)
|
||||
{
|
||||
_runtime = runtime;
|
||||
_downloadsDirectory = Path.Combine(dataDirectory, "downloads");
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
Timeout = TimeSpan.FromMinutes(2)
|
||||
};
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
|
||||
}
|
||||
|
||||
public async Task<AirAppMarketInstallResult> InstallAsync(
|
||||
AirAppMarketPluginEntry plugin,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plugin);
|
||||
|
||||
Directory.CreateDirectory(_downloadsDirectory);
|
||||
var downloadPath = Path.Combine(
|
||||
_downloadsDirectory,
|
||||
$"{SanitizeFileName(plugin.Id)}-{SanitizeFileName(plugin.Version)}.laapp");
|
||||
|
||||
try
|
||||
{
|
||||
if (AirAppMarketDefaults.TryResolveWorkspaceFile(plugin.DownloadUrl, out var localPackagePath))
|
||||
{
|
||||
await using var sourceStream = File.OpenRead(localPackagePath);
|
||||
await using var destinationStream = File.Create(downloadPath);
|
||||
await sourceStream.CopyToAsync(destinationStream, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(
|
||||
plugin.DownloadUrl,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
await using var destinationStream = File.Create(downloadPath);
|
||||
await responseStream.CopyToAsync(destinationStream, cancellationToken);
|
||||
}
|
||||
|
||||
await using var hashStream = File.OpenRead(downloadPath);
|
||||
var hashBytes = await SHA256.HashDataAsync(hashStream, cancellationToken);
|
||||
var actualHash = Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
if (!string.Equals(actualHash, plugin.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
File.Delete(downloadPath);
|
||||
return new AirAppMarketInstallResult(
|
||||
false,
|
||||
null,
|
||||
$"SHA-256 mismatch. Expected {plugin.Sha256}, actual {actualHash}.");
|
||||
}
|
||||
|
||||
var manifest = _runtime.InstallPluginPackage(downloadPath);
|
||||
return new AirAppMarketInstallResult(true, manifest, null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new AirAppMarketInstallResult(false, null, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
|
||||
private static string SanitizeFileName(string value)
|
||||
{
|
||||
var invalidChars = Path.GetInvalidFileNameChars();
|
||||
return new string(value.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
|
||||
}
|
||||
}
|
||||
361
LanMountainDesktop/plugins/PluginMarketModels.cs
Normal file
361
LanMountainDesktop/plugins/PluginMarketModels.cs
Normal file
@@ -0,0 +1,361 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.Views.SettingsPages;
|
||||
|
||||
internal static class AirAppMarketDefaults
|
||||
{
|
||||
public const string DefaultIndexUrl =
|
||||
"https://raw.githubusercontent.com/wwiinnddyy/LanAirApp/main/airappmarket/index.json";
|
||||
|
||||
private const string RawGitHubLanAirAppPathPrefix = "/wwiinnddyy/LanAirApp/main/";
|
||||
|
||||
public static string? TryGetWorkspaceIndexPath()
|
||||
{
|
||||
var repositoryRoot = TryGetWorkspaceLanAirAppRepositoryRoot();
|
||||
if (repositoryRoot is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var candidatePath = Path.Combine(repositoryRoot, "airappmarket", "index.json");
|
||||
return File.Exists(candidatePath) ? candidatePath : null;
|
||||
}
|
||||
|
||||
public static bool TryResolveWorkspaceFile(string url, out string localPath)
|
||||
{
|
||||
localPath = string.Empty;
|
||||
|
||||
var repositoryRoot = TryGetWorkspaceLanAirAppRepositoryRoot();
|
||||
if (repositoryRoot is null ||
|
||||
!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
|
||||
!string.Equals(uri.Host, "raw.githubusercontent.com", StringComparison.OrdinalIgnoreCase) ||
|
||||
!uri.AbsolutePath.StartsWith(RawGitHubLanAirAppPathPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var relativePath = Uri.UnescapeDataString(uri.AbsolutePath[RawGitHubLanAirAppPathPrefix.Length..])
|
||||
.Replace('/', Path.DirectorySeparatorChar);
|
||||
var candidatePath = Path.GetFullPath(Path.Combine(repositoryRoot, relativePath));
|
||||
if (!File.Exists(candidatePath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
localPath = candidatePath;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? TryGetWorkspaceLanAirAppRepositoryRoot()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current is not null)
|
||||
{
|
||||
var candidate = Path.Combine(current.FullName, "LanAirApp");
|
||||
if (File.Exists(Path.Combine(candidate, "airappmarket", "index.json")))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
internal enum AirAppMarketLoadSource
|
||||
{
|
||||
Local = 0,
|
||||
Network = 1,
|
||||
Cache = 2
|
||||
}
|
||||
|
||||
internal enum AirAppMarketInstallState
|
||||
{
|
||||
NotInstalled = 0,
|
||||
UpdateAvailable = 1,
|
||||
Installed = 2
|
||||
}
|
||||
|
||||
internal sealed record AirAppMarketLoadResult(
|
||||
bool Success,
|
||||
AirAppMarketIndexDocument? Document,
|
||||
AirAppMarketLoadSource? Source,
|
||||
string? SourceLocation,
|
||||
string? WarningMessage,
|
||||
string? ErrorMessage);
|
||||
|
||||
internal sealed record AirAppMarketInstallResult(
|
||||
bool Success,
|
||||
PluginManifest? Manifest,
|
||||
string? ErrorMessage);
|
||||
|
||||
internal sealed class AirAppMarketIndexDocument
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
|
||||
public string SchemaVersion { get; init; } = string.Empty;
|
||||
|
||||
public string SourceId { get; init; } = string.Empty;
|
||||
|
||||
public string SourceName { get; init; } = string.Empty;
|
||||
|
||||
public DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
public List<AirAppMarketPluginEntry> Plugins { get; init; } = [];
|
||||
|
||||
public static AirAppMarketIndexDocument Load(string json, string sourceName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(json);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sourceName);
|
||||
|
||||
var document = JsonSerializer.Deserialize<AirAppMarketIndexDocument>(
|
||||
json.TrimStart('\uFEFF'),
|
||||
SerializerOptions);
|
||||
|
||||
if (document is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to parse market index '{sourceName}'.");
|
||||
}
|
||||
|
||||
return document.ValidateAndNormalize(sourceName);
|
||||
}
|
||||
|
||||
private AirAppMarketIndexDocument ValidateAndNormalize(string sourceName)
|
||||
{
|
||||
var plugins = Plugins ?? [];
|
||||
var normalizedPlugins = new List<AirAppMarketPluginEntry>(plugins.Count);
|
||||
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var plugin in plugins)
|
||||
{
|
||||
var normalizedPlugin = plugin.ValidateAndNormalize(sourceName);
|
||||
if (!seenIds.Add(normalizedPlugin.Id))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' contains duplicate plugin id '{normalizedPlugin.Id}'.");
|
||||
}
|
||||
|
||||
normalizedPlugins.Add(normalizedPlugin);
|
||||
}
|
||||
|
||||
return new AirAppMarketIndexDocument
|
||||
{
|
||||
SchemaVersion = RequireValue(SchemaVersion, nameof(SchemaVersion), sourceName),
|
||||
SourceId = RequireValue(SourceId, nameof(SourceId), sourceName),
|
||||
SourceName = RequireValue(SourceName, nameof(SourceName), sourceName),
|
||||
GeneratedAt = GeneratedAt == default
|
||||
? throw new InvalidOperationException($"Market index '{sourceName}' is missing a valid generatedAt timestamp.")
|
||||
: GeneratedAt,
|
||||
Plugins = normalizedPlugins
|
||||
.OrderBy(plugin => plugin.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static string RequireValue(string? value, string propertyName, string sourceName)
|
||||
{
|
||||
var normalized = NormalizeValue(value);
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
throw new InvalidOperationException($"Market index '{sourceName}' is missing required property '{propertyName}'.");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
internal static string? NormalizeValue(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
internal static string NormalizeVersion(string? value, string propertyName, string sourceName)
|
||||
{
|
||||
var normalized = RequireValue(value, propertyName, sourceName);
|
||||
if (!TryParseVersion(normalized, out _))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' declares invalid version '{normalized}' for '{propertyName}'.");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
internal static void EnsureUrl(string url, string propertyName, string sourceName)
|
||||
{
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
|
||||
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' declares invalid URL '{url}' for '{propertyName}'.");
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool TryParseVersion(string? value, out Version? version)
|
||||
{
|
||||
version = null;
|
||||
var normalized = NormalizeValue(value);
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalized.StartsWith("v", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
normalized = normalized[1..];
|
||||
}
|
||||
|
||||
var separatorIndex = normalized.IndexOfAny(['-', '+', ' ']);
|
||||
if (separatorIndex > 0)
|
||||
{
|
||||
normalized = normalized[..separatorIndex];
|
||||
}
|
||||
|
||||
if (!Version.TryParse(normalized, out var parsed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
version = new Version(
|
||||
Math.Max(0, parsed.Major),
|
||||
Math.Max(0, parsed.Minor),
|
||||
Math.Max(0, parsed.Build));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AirAppMarketPluginEntry
|
||||
{
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
public string Description { get; init; } = string.Empty;
|
||||
|
||||
public string Author { get; init; } = string.Empty;
|
||||
|
||||
public string Version { get; init; } = string.Empty;
|
||||
|
||||
public string ApiVersion { get; init; } = string.Empty;
|
||||
|
||||
public string MinHostVersion { get; init; } = string.Empty;
|
||||
|
||||
public string DownloadUrl { get; init; } = string.Empty;
|
||||
|
||||
public string Sha256 { get; init; } = string.Empty;
|
||||
|
||||
public long PackageSizeBytes { get; init; }
|
||||
|
||||
public string IconUrl { get; init; } = string.Empty;
|
||||
|
||||
public string HomepageUrl { get; init; } = string.Empty;
|
||||
|
||||
public string RepositoryUrl { get; init; } = string.Empty;
|
||||
|
||||
public List<string> Tags { get; init; } = [];
|
||||
|
||||
public DateTimeOffset PublishedAt { get; init; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
public string ReleaseNotes { get; init; } = string.Empty;
|
||||
|
||||
public AirAppMarketPluginEntry ValidateAndNormalize(string sourceName)
|
||||
{
|
||||
var normalizedTags = (Tags ?? [])
|
||||
.Select(tag => AirAppMarketIndexDocument.NormalizeValue(tag))
|
||||
.Where(tag => !string.IsNullOrWhiteSpace(tag))
|
||||
.Select(tag => tag!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(tag => tag, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
var normalizedSha = AirAppMarketIndexDocument.NormalizeValue(Sha256)?.ToLowerInvariant()
|
||||
?? throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' is missing required property '{nameof(Sha256)}'.");
|
||||
|
||||
if (normalizedSha.Length != 64 || normalizedSha.Any(ch => !Uri.IsHexDigit(ch)))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' declares invalid SHA-256 '{normalizedSha}' for plugin '{Id}'.");
|
||||
}
|
||||
|
||||
var normalizedDownloadUrl = AirAppMarketIndexDocument.NormalizeValue(DownloadUrl)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' is missing required property '{nameof(DownloadUrl)}'.");
|
||||
var normalizedIconUrl = AirAppMarketIndexDocument.NormalizeValue(IconUrl)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' is missing required property '{nameof(IconUrl)}'.");
|
||||
var normalizedHomepageUrl = AirAppMarketIndexDocument.NormalizeValue(HomepageUrl)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' is missing required property '{nameof(HomepageUrl)}'.");
|
||||
var normalizedRepositoryUrl = AirAppMarketIndexDocument.NormalizeValue(RepositoryUrl)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' is missing required property '{nameof(RepositoryUrl)}'.");
|
||||
|
||||
AirAppMarketIndexDocument.EnsureUrl(normalizedDownloadUrl, nameof(DownloadUrl), sourceName);
|
||||
AirAppMarketIndexDocument.EnsureUrl(normalizedIconUrl, nameof(IconUrl), sourceName);
|
||||
AirAppMarketIndexDocument.EnsureUrl(normalizedHomepageUrl, nameof(HomepageUrl), sourceName);
|
||||
AirAppMarketIndexDocument.EnsureUrl(normalizedRepositoryUrl, nameof(RepositoryUrl), sourceName);
|
||||
|
||||
if (PackageSizeBytes <= 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' declares invalid packageSizeBytes '{PackageSizeBytes}' for plugin '{Id}'.");
|
||||
}
|
||||
|
||||
if (PublishedAt == default || UpdatedAt == default)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' is missing valid publish timestamps for plugin '{Id}'.");
|
||||
}
|
||||
|
||||
return new AirAppMarketPluginEntry
|
||||
{
|
||||
Id = AirAppMarketIndexDocument.NormalizeValue(Id)
|
||||
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin id."),
|
||||
Name = AirAppMarketIndexDocument.NormalizeValue(Name)
|
||||
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin name."),
|
||||
Description = AirAppMarketIndexDocument.NormalizeValue(Description)
|
||||
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin description."),
|
||||
Author = AirAppMarketIndexDocument.NormalizeValue(Author)
|
||||
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin author."),
|
||||
Version = AirAppMarketIndexDocument.NormalizeVersion(Version, nameof(Version), sourceName),
|
||||
ApiVersion = AirAppMarketIndexDocument.NormalizeVersion(ApiVersion, nameof(ApiVersion), sourceName),
|
||||
MinHostVersion = AirAppMarketIndexDocument.NormalizeVersion(MinHostVersion, nameof(MinHostVersion), sourceName),
|
||||
DownloadUrl = normalizedDownloadUrl,
|
||||
Sha256 = normalizedSha,
|
||||
PackageSizeBytes = PackageSizeBytes,
|
||||
IconUrl = normalizedIconUrl,
|
||||
HomepageUrl = normalizedHomepageUrl,
|
||||
RepositoryUrl = normalizedRepositoryUrl,
|
||||
Tags = normalizedTags,
|
||||
PublishedAt = PublishedAt,
|
||||
UpdatedAt = UpdatedAt,
|
||||
ReleaseNotes = AirAppMarketIndexDocument.NormalizeValue(ReleaseNotes)
|
||||
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing release notes for plugin '{Id}'.")
|
||||
};
|
||||
}
|
||||
|
||||
public string GetVersionSummary()
|
||||
{
|
||||
return string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"v{0} | API {1} | Host >= {2}",
|
||||
Version,
|
||||
ApiVersion,
|
||||
MinHostVersion);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
{
|
||||
private readonly PluginLoader _loader;
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly IServiceProvider _hostServices;
|
||||
private readonly IPluginPackageManager _packageManager;
|
||||
private readonly List<LoadedPlugin> _loadedPlugins = [];
|
||||
private readonly List<PluginLoadResult> _loadResults = [];
|
||||
private readonly List<PluginCatalogEntry> _catalog = [];
|
||||
@@ -27,6 +29,8 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
public PluginRuntimeService()
|
||||
{
|
||||
PluginsDirectory = Path.Combine(AppContext.BaseDirectory, "Extensions", "Plugins");
|
||||
_packageManager = new PluginRuntimePackageManager(this);
|
||||
_hostServices = new PluginHostServiceProvider(_packageManager);
|
||||
_loader = new PluginLoader(CreateOptions());
|
||||
}
|
||||
|
||||
@@ -96,11 +100,11 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
PluginCatalogSourceKind.Package => _loader.LoadFromPackage(
|
||||
candidate.SourcePath,
|
||||
PluginsDirectory,
|
||||
services: null,
|
||||
services: _hostServices,
|
||||
hostProperties),
|
||||
_ => _loader.LoadFromManifest(
|
||||
candidate.SourcePath,
|
||||
services: null,
|
||||
services: _hostServices,
|
||||
hostProperties)
|
||||
};
|
||||
|
||||
@@ -179,6 +183,24 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
}
|
||||
|
||||
public PluginManifest InstallPluginPackage(string packagePath)
|
||||
{
|
||||
return InstallPluginPackageCore(packagePath).Manifest;
|
||||
}
|
||||
|
||||
internal IReadOnlyList<InstalledPluginInfo> GetInstalledPluginsSnapshot()
|
||||
{
|
||||
return _catalog
|
||||
.OrderBy(entry => entry.Manifest.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(entry => new InstalledPluginInfo(
|
||||
entry.Manifest,
|
||||
entry.IsEnabled,
|
||||
entry.IsLoaded,
|
||||
entry.IsPackage,
|
||||
entry.ErrorMessage))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private PluginPackageInstallResult InstallPluginPackageCore(string packagePath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packagePath);
|
||||
|
||||
@@ -197,7 +219,7 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
Directory.CreateDirectory(PluginsDirectory);
|
||||
|
||||
var manifest = ReadManifestFromPackage(fullPackagePath);
|
||||
RemoveExistingPluginPackages(manifest.Id, fullPackagePath);
|
||||
var replacedExisting = RemoveExistingPluginPackages(manifest.Id, fullPackagePath);
|
||||
|
||||
var destinationPath = Path.Combine(PluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
|
||||
if (!string.Equals(fullPackagePath, Path.GetFullPath(destinationPath), StringComparison.OrdinalIgnoreCase))
|
||||
@@ -205,7 +227,10 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
File.Copy(fullPackagePath, destinationPath, overwrite: true);
|
||||
}
|
||||
|
||||
return manifest;
|
||||
UpdateCatalogAfterPackageInstall(manifest, destinationPath);
|
||||
PendingRestartStateService.SetPending(PendingRestartStateService.PluginCatalogReason, true);
|
||||
|
||||
return new PluginPackageInstallResult(manifest, replacedExisting, RestartRequired: true);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -303,8 +328,9 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}");
|
||||
}
|
||||
|
||||
private void RemoveExistingPluginPackages(string pluginId, string packagePathToKeep)
|
||||
private bool RemoveExistingPluginPackages(string pluginId, string packagePathToKeep)
|
||||
{
|
||||
var replacedExisting = false;
|
||||
foreach (var existingPackagePath in EnumerateCandidatePaths($"*{PluginSdkInfo.PackageFileExtension}"))
|
||||
{
|
||||
if (string.Equals(
|
||||
@@ -324,12 +350,40 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
}
|
||||
|
||||
File.Delete(existingPackagePath);
|
||||
replacedExisting = true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore unrelated or invalid packages during replacement.
|
||||
}
|
||||
}
|
||||
|
||||
return replacedExisting;
|
||||
}
|
||||
|
||||
private void UpdateCatalogAfterPackageInstall(PluginManifest manifest, string destinationPath)
|
||||
{
|
||||
var isEnabled = !GetDisabledPluginIds().Contains(manifest.Id);
|
||||
var entry = new PluginCatalogEntry(
|
||||
manifest,
|
||||
destinationPath,
|
||||
IsPackage: true,
|
||||
IsEnabled: isEnabled,
|
||||
IsLoaded: false,
|
||||
ErrorMessage: null,
|
||||
SettingsPageCount: 0,
|
||||
WidgetCount: 0);
|
||||
|
||||
for (var i = 0; i < _catalog.Count; i++)
|
||||
{
|
||||
if (string.Equals(_catalog[i].Manifest.Id, manifest.Id, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_catalog[i] = entry;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_catalog.Add(entry);
|
||||
}
|
||||
|
||||
private static string BuildInstalledPackageFileName(string pluginId)
|
||||
@@ -395,4 +449,41 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
string SourcePath,
|
||||
PluginManifest Manifest,
|
||||
PluginCatalogSourceKind SourceKind);
|
||||
|
||||
private sealed class PluginHostServiceProvider : IServiceProvider
|
||||
{
|
||||
private readonly IPluginPackageManager _packageManager;
|
||||
|
||||
public PluginHostServiceProvider(IPluginPackageManager packageManager)
|
||||
{
|
||||
_packageManager = packageManager;
|
||||
}
|
||||
|
||||
public object? GetService(Type serviceType)
|
||||
{
|
||||
return serviceType == typeof(IPluginPackageManager)
|
||||
? _packageManager
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class PluginRuntimePackageManager : IPluginPackageManager
|
||||
{
|
||||
private readonly PluginRuntimeService _runtimeService;
|
||||
|
||||
public PluginRuntimePackageManager(PluginRuntimeService runtimeService)
|
||||
{
|
||||
_runtimeService = runtimeService;
|
||||
}
|
||||
|
||||
public IReadOnlyList<InstalledPluginInfo> GetInstalledPlugins()
|
||||
{
|
||||
return _runtimeService.GetInstalledPluginsSnapshot();
|
||||
}
|
||||
|
||||
public PluginPackageInstallResult InstallPackage(string packagePath)
|
||||
{
|
||||
return _runtimeService.InstallPluginPackageCore(packagePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ public partial class PluginSettingsPage : UserControl
|
||||
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private PluginMarketEmbeddedView? _pluginMarketView;
|
||||
private string? _packageImportStatusMessage;
|
||||
private bool _packageImportStatusIsError;
|
||||
|
||||
@@ -33,6 +34,13 @@ public partial class PluginSettingsPage : UserControl
|
||||
public void RefreshFromRuntime()
|
||||
{
|
||||
var runtime = (Application.Current as App)?.PluginRuntimeService;
|
||||
PluginMarketSettingsExpander.Header = L("settings.plugins.market_header", "Official Market");
|
||||
PluginMarketSettingsExpander.Description = L(
|
||||
"settings.plugins.market_desc",
|
||||
"Browse plugins from the official LanAirApp source and stage installs.");
|
||||
PluginMarketDescriptionTextBlock.Text = L(
|
||||
"settings.plugins.market_hint",
|
||||
"Use the official market source hosted in LanAirApp to discover and stage plugin installs.");
|
||||
UpdateInstallerUi(runtime);
|
||||
if (runtime is null)
|
||||
{
|
||||
@@ -40,13 +48,33 @@ public partial class PluginSettingsPage : UserControl
|
||||
PluginRuntimeSummaryPanel.Children.Clear();
|
||||
PluginCatalogItemsHost.Children.Clear();
|
||||
PluginRestartHintTextBlock.IsVisible = false;
|
||||
PluginMarketContentHost.Content = CreateSummaryLine(
|
||||
L("settings.plugins.market_unavailable", "Plugin runtime is not available, so the official market cannot be opened right now."));
|
||||
return;
|
||||
}
|
||||
|
||||
EnsurePluginMarketView(runtime);
|
||||
_pluginMarketView?.RefreshLocalization();
|
||||
_pluginMarketView?.RefreshInstalledSnapshot();
|
||||
BuildRuntimeSummary(runtime);
|
||||
BuildPluginCatalog(runtime);
|
||||
}
|
||||
|
||||
private void EnsurePluginMarketView(PluginRuntimeService runtime)
|
||||
{
|
||||
if (_pluginMarketView is null)
|
||||
{
|
||||
_pluginMarketView = new PluginMarketEmbeddedView(runtime);
|
||||
PluginMarketContentHost.Content = _pluginMarketView;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ReferenceEquals(PluginMarketContentHost.Content, _pluginMarketView))
|
||||
{
|
||||
PluginMarketContentHost.Content = _pluginMarketView;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateInstallerUi(PluginRuntimeService? runtime)
|
||||
{
|
||||
InstallPluginPackageButton.Content = L("settings.plugins.install_button", "Open .laapp package");
|
||||
@@ -140,8 +168,7 @@ public partial class PluginSettingsPage : UserControl
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
|
||||
enabledToggle.Checked += (_, _) => OnPluginEnableChanged(runtime, entry, true);
|
||||
enabledToggle.Unchecked += (_, _) => OnPluginEnableChanged(runtime, entry, false);
|
||||
enabledToggle.IsCheckedChanged += (_, _) => OnPluginEnableChanged(runtime, entry, enabledToggle.IsChecked == true);
|
||||
|
||||
var header = new Grid
|
||||
{
|
||||
@@ -247,9 +274,6 @@ public partial class PluginSettingsPage : UserControl
|
||||
}
|
||||
|
||||
var manifest = runtime.InstallPluginPackage(temporaryPackagePath);
|
||||
runtime.LoadInstalledPlugins();
|
||||
RefreshPluginNavigation(topLevel);
|
||||
PendingRestartStateService.SetPending(PendingRestartStateService.PluginCatalogReason, true);
|
||||
RefreshFromRuntime();
|
||||
SetPackageImportStatus(
|
||||
F(
|
||||
|
||||
@@ -82,5 +82,25 @@
|
||||
</ui:SettingsExpander.Footer>
|
||||
</ui:SettingsExpander>
|
||||
</Border>
|
||||
|
||||
<Border Classes="settings-expander-shell">
|
||||
<ui:SettingsExpander x:Name="PluginMarketSettingsExpander"
|
||||
Header="Official Market"
|
||||
Description="Browse plugins from the official LanAirApp source and stage installs."
|
||||
IsExpanded="True">
|
||||
<ui:SettingsExpander.IconSource>
|
||||
<ui:FontIconSource Glyph="" FontFamily="{StaticResource SymbolThemeFontFamily}" />
|
||||
</ui:SettingsExpander.IconSource>
|
||||
<ui:SettingsExpander.Footer>
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock x:Name="PluginMarketDescriptionTextBlock"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
TextWrapping="Wrap"
|
||||
Text="Use the official market source hosted in LanAirApp to discover and stage plugin installs." />
|
||||
<ContentControl x:Name="PluginMarketContentHost" />
|
||||
</StackPanel>
|
||||
</ui:SettingsExpander.Footer>
|
||||
</ui:SettingsExpander>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using FluentAvalonia.UI.Controls;
|
||||
using FluentIcons.Avalonia.Fluent;
|
||||
using FluentIcons.Common;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
@@ -18,7 +17,7 @@ public partial class SettingsWindow
|
||||
|
||||
private void InitializePluginSettingsNavigation()
|
||||
{
|
||||
if (_pluginSettingsPageHosts.Count > 0 || SettingsNavView?.MenuItems is null)
|
||||
if (_pluginSettingsPageHosts.Count > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -32,6 +31,7 @@ public partial class SettingsWindow
|
||||
|
||||
if (contributions is not { Length: > 0 })
|
||||
{
|
||||
SettingsPluginNavSection.IsVisible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -39,31 +39,23 @@ public partial class SettingsWindow
|
||||
.GroupBy(contribution => contribution.Plugin.Manifest.Id, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var insertIndex = SettingsNavView.MenuItems.IndexOf(SettingsNavPluginsItem) + 1;
|
||||
foreach (var contribution in contributions)
|
||||
{
|
||||
var tag = BuildPluginSettingsTag(contribution);
|
||||
var navigationTitle = BuildPluginSettingsNavigationTitle(contribution, pageCountsByPluginId);
|
||||
var navItem = new NavigationViewItem
|
||||
{
|
||||
Content = navigationTitle,
|
||||
Tag = tag,
|
||||
IconSource = new FluentIcons.Avalonia.Fluent.SymbolIconSource
|
||||
{
|
||||
Symbol = FluentIcons.Common.Symbol.PuzzlePiece,
|
||||
IconVariant = FluentIcons.Common.IconVariant.Regular
|
||||
}
|
||||
};
|
||||
|
||||
var navItem = CreateSettingsNavItem(tag, Symbol.PuzzlePiece, navigationTitle);
|
||||
ToolTip.SetTip(navItem, $"{contribution.Plugin.Manifest.Name} - {contribution.Registration.Title}");
|
||||
|
||||
SettingsNavView.MenuItems.Insert(insertIndex++, navItem);
|
||||
SettingsPluginNavHost.Children.Add(navItem);
|
||||
_pluginSettingsNavItems[tag] = navItem;
|
||||
|
||||
var pageHost = CreatePluginSettingsPageHost(contribution);
|
||||
pageHost.IsVisible = false;
|
||||
SettingsContentPagesHost.Children.Add(pageHost);
|
||||
_pluginSettingsPageHosts[tag] = pageHost;
|
||||
}
|
||||
|
||||
SettingsPluginNavSection.IsVisible = SettingsPluginNavHost.Children.Count > 0;
|
||||
}
|
||||
|
||||
private static string BuildPluginSettingsTag(PluginSettingsPageContribution contribution)
|
||||
@@ -141,43 +133,48 @@ public partial class SettingsWindow
|
||||
|
||||
internal void RefreshPluginSettingsNavigation()
|
||||
{
|
||||
if (SettingsNavView?.MenuItems is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var pair in _pluginSettingsPageHosts.ToArray())
|
||||
{
|
||||
var navItem = SettingsNavView.MenuItems
|
||||
.OfType<NavigationViewItem>()
|
||||
.FirstOrDefault(item => string.Equals(item.Tag?.ToString(), pair.Key, StringComparison.OrdinalIgnoreCase));
|
||||
if (navItem is not null)
|
||||
if (_pluginSettingsNavItems.TryGetValue(pair.Key, out var navItem))
|
||||
{
|
||||
SettingsNavView.MenuItems.Remove(navItem);
|
||||
SettingsPluginNavHost.Children.Remove(navItem);
|
||||
}
|
||||
|
||||
SettingsContentPagesHost.Children.Remove(pair.Value);
|
||||
}
|
||||
|
||||
_pluginSettingsPageHosts.Clear();
|
||||
_pluginSettingsNavItems.Clear();
|
||||
SettingsPluginNavSection.IsVisible = false;
|
||||
InitializePluginSettingsNavigation();
|
||||
|
||||
if (GetSettingsNavItem(_selectedSettingsTabTag) is null)
|
||||
{
|
||||
SelectSettingsTab("Plugins", persistSelection: false);
|
||||
}
|
||||
else
|
||||
{
|
||||
SelectSettingsTab(_selectedSettingsTabTag, persistSelection: false);
|
||||
}
|
||||
}
|
||||
|
||||
private string? GetSelectedSettingsTabTag()
|
||||
{
|
||||
return (SettingsNavView?.SelectedItem as NavigationViewItem)?.Tag?.ToString();
|
||||
return _selectedSettingsTabTag;
|
||||
}
|
||||
|
||||
private int ResolveSelectedSettingsTabIndex()
|
||||
{
|
||||
if (SettingsNavView?.SelectedItem is null || SettingsNavView.MenuItems is null)
|
||||
var selectedTag = GetSelectedSettingsTabTag();
|
||||
if (string.IsNullOrWhiteSpace(selectedTag))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
for (var i = 0; i < SettingsNavView.MenuItems.Count; i++)
|
||||
var buttons = EnumerateSettingsNavItems().ToList();
|
||||
for (var i = 0; i < buttons.Count; i++)
|
||||
{
|
||||
if (ReferenceEquals(SettingsNavView.MenuItems[i], SettingsNavView.SelectedItem))
|
||||
if (string.Equals(buttons[i].Tag?.ToString(), selectedTag, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return i;
|
||||
}
|
||||
@@ -188,30 +185,21 @@ public partial class SettingsWindow
|
||||
|
||||
private void RestoreSettingsTabSelection(AppSettingsSnapshot snapshot)
|
||||
{
|
||||
if (SettingsNavView?.MenuItems is null || SettingsNavView.MenuItems.Count == 0)
|
||||
var buttons = EnumerateSettingsNavItems().ToList();
|
||||
if (buttons.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(snapshot.SettingsTabTag))
|
||||
if (!string.IsNullOrWhiteSpace(snapshot.SettingsTabTag) &&
|
||||
GetSettingsNavItem(snapshot.SettingsTabTag) is not null)
|
||||
{
|
||||
var taggedItem = SettingsNavView.MenuItems
|
||||
.OfType<NavigationViewItem>()
|
||||
.FirstOrDefault(item => string.Equals(item.Tag?.ToString(), snapshot.SettingsTabTag, StringComparison.OrdinalIgnoreCase));
|
||||
if (taggedItem is not null)
|
||||
{
|
||||
SettingsNavView.SelectedItem = taggedItem;
|
||||
return;
|
||||
}
|
||||
SelectSettingsTab(snapshot.SettingsTabTag, persistSelection: false);
|
||||
return;
|
||||
}
|
||||
|
||||
var safeIndex = Math.Clamp(snapshot.SettingsTabIndex, 0, Math.Max(0, SettingsNavView.MenuItems.Count - 1));
|
||||
if (SettingsNavView.MenuItems[safeIndex] is NavigationViewItem navItem)
|
||||
{
|
||||
SettingsNavView.SelectedItem = navItem;
|
||||
}
|
||||
var safeIndex = Math.Clamp(snapshot.SettingsTabIndex, 0, Math.Max(0, buttons.Count - 1));
|
||||
var button = buttons[safeIndex];
|
||||
SelectSettingsTab(button.Tag?.ToString() ?? "Wallpaper", persistSelection: false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user