mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
settings_re9
This commit is contained in:
@@ -6,5 +6,6 @@ public enum SettingsPageCategory
|
|||||||
Appearance = 10,
|
Appearance = 10,
|
||||||
Components = 20,
|
Components = 20,
|
||||||
Plugins = 30,
|
Plugins = 30,
|
||||||
|
PluginMarket = 35,
|
||||||
About = 40
|
About = 40
|
||||||
}
|
}
|
||||||
|
|||||||
47
LanMountainDesktop/Helpers/PluginMarketMarkdownHelper.cs
Normal file
47
LanMountainDesktop/Helpers/PluginMarketMarkdownHelper.cs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
using System;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Markdown.Avalonia;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Helpers;
|
||||||
|
|
||||||
|
public static class PluginMarketMarkdownHelper
|
||||||
|
{
|
||||||
|
private static Markdown.Avalonia.Markdown? _engine;
|
||||||
|
|
||||||
|
public static ICommand OpenLinkCommand { get; } = new RelayCommand<object?>(OpenLink);
|
||||||
|
|
||||||
|
public static Markdown.Avalonia.Markdown Engine => _engine ??= new Markdown.Avalonia.Markdown
|
||||||
|
{
|
||||||
|
HyperlinkCommand = OpenLinkCommand
|
||||||
|
};
|
||||||
|
|
||||||
|
private static void OpenLink(object? parameter)
|
||||||
|
{
|
||||||
|
var url = parameter switch
|
||||||
|
{
|
||||||
|
Uri uri => uri.ToString(),
|
||||||
|
string text => text,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(url))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = url,
|
||||||
|
UseShellExecute = true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore browser launch failures inside the markdown viewer.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@
|
|||||||
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.319" />
|
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.319" />
|
||||||
<PackageReference Include="Material.Avalonia" Version="3.13.4" />
|
<PackageReference Include="Material.Avalonia" Version="3.13.4" />
|
||||||
<PackageReference Include="Material.Icons.Avalonia" Version="2.4.1" />
|
<PackageReference Include="Material.Icons.Avalonia" Version="2.4.1" />
|
||||||
|
<PackageReference Include="ClassIsland.Markdown.Avalonia" Version="11.0.3.4" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.0" />
|
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.0" />
|
||||||
|
|||||||
@@ -359,9 +359,10 @@
|
|||||||
"settings.plugins.runtime_desc": "Review plugin runtime state and load results.",
|
"settings.plugins.runtime_desc": "Review plugin runtime state and load results.",
|
||||||
"settings.plugins.runtime_hint": "This page shows discovery status, load results, and runtime diagnostics for installed plugins.",
|
"settings.plugins.runtime_hint": "This page shows discovery status, load results, and runtime diagnostics for installed plugins.",
|
||||||
"settings.plugins.runtime_status": "Plugin runtime status will appear here after plugin discovery completes.",
|
"settings.plugins.runtime_status": "Plugin runtime status will appear here after plugin discovery completes.",
|
||||||
"settings.plugins.description": "Manage installed plugins and browse marketplace packages.",
|
"settings.plugins.description": "Manage installed plugins and review their runtime state.",
|
||||||
"settings.plugins.initial_status": "Refresh plugin state to see the latest installed and marketplace entries.",
|
"settings.plugins.initial_status": "Refresh plugin state to see the latest installed plugins.",
|
||||||
"settings.plugins.refresh_button": "Refresh Plugins",
|
"settings.plugins.refresh_button": "Refresh Plugins",
|
||||||
|
"settings.plugins.refresh_success_installed_format": "Loaded {0} installed plugins.",
|
||||||
"settings.plugins.refresh_success_format": "Loaded {0} installed plugins and {1} marketplace entries.",
|
"settings.plugins.refresh_success_format": "Loaded {0} installed plugins and {1} marketplace entries.",
|
||||||
"settings.plugins.refresh_failed": "Failed to load plugin market index.",
|
"settings.plugins.refresh_failed": "Failed to load plugin market index.",
|
||||||
"settings.plugins.marketplace_header": "Marketplace",
|
"settings.plugins.marketplace_header": "Marketplace",
|
||||||
@@ -437,7 +438,7 @@
|
|||||||
"market.card.loaded": "Loaded",
|
"market.card.loaded": "Loaded",
|
||||||
"market.card.pending_restart": "Restart required",
|
"market.card.pending_restart": "Restart required",
|
||||||
"market.detail.placeholder": "Select a plugin on the left to inspect details.",
|
"market.detail.placeholder": "Select a plugin on the left to inspect details.",
|
||||||
"market.detail.author": "Author",
|
"market.detail.author": "Publisher",
|
||||||
"market.detail.version": "Version",
|
"market.detail.version": "Version",
|
||||||
"market.detail.api_version": "API Version",
|
"market.detail.api_version": "API Version",
|
||||||
"market.detail.min_host_version": "Minimum Host Version",
|
"market.detail.min_host_version": "Minimum Host Version",
|
||||||
@@ -456,6 +457,11 @@
|
|||||||
"market.detail.homepage": "Homepage",
|
"market.detail.homepage": "Homepage",
|
||||||
"market.detail.repository": "Repository",
|
"market.detail.repository": "Repository",
|
||||||
"market.detail.release_notes": "Release Notes",
|
"market.detail.release_notes": "Release Notes",
|
||||||
|
"market.detail.dependencies": "Dependencies",
|
||||||
|
"market.detail.dependencies_empty": "No shared contract dependencies were declared by this plugin.",
|
||||||
|
"market.detail.readme_loading": "Loading README...",
|
||||||
|
"market.detail.readme_empty": "README is empty.",
|
||||||
|
"market.detail.readme_error_format": "README could not be loaded: {0}",
|
||||||
"market.detail.state.not_installed": "Not installed",
|
"market.detail.state.not_installed": "Not installed",
|
||||||
"market.detail.state.update_available": "Update available",
|
"market.detail.state.update_available": "Update available",
|
||||||
"market.detail.state.installed": "Installed",
|
"market.detail.state.installed": "Installed",
|
||||||
@@ -464,6 +470,7 @@
|
|||||||
"market.button.update": "Update",
|
"market.button.update": "Update",
|
||||||
"market.button.installed": "Installed",
|
"market.button.installed": "Installed",
|
||||||
"market.button.installing": "Installing...",
|
"market.button.installing": "Installing...",
|
||||||
|
"market.button.restart": "Restart to apply",
|
||||||
"button.component_library": "Edit Desktop",
|
"button.component_library": "Edit Desktop",
|
||||||
"tooltip.component_library": "Edit Desktop",
|
"tooltip.component_library": "Edit Desktop",
|
||||||
"component_library.title": "Widgets",
|
"component_library.title": "Widgets",
|
||||||
|
|||||||
@@ -364,9 +364,10 @@
|
|||||||
"settings.plugins.runtime_desc": "查看插件运行时状态、加载结果与诊断信息。",
|
"settings.plugins.runtime_desc": "查看插件运行时状态、加载结果与诊断信息。",
|
||||||
"settings.plugins.runtime_hint": "这里展示已安装插件的发现结果、加载状态和运行时诊断信息。",
|
"settings.plugins.runtime_hint": "这里展示已安装插件的发现结果、加载状态和运行时诊断信息。",
|
||||||
"settings.plugins.runtime_status": "插件扫描完成后,运行时状态会显示在这里。",
|
"settings.plugins.runtime_status": "插件扫描完成后,运行时状态会显示在这里。",
|
||||||
"settings.plugins.description": "管理已安装插件并浏览插件市场。",
|
"settings.plugins.description": "管理已安装插件并查看其运行时状态。",
|
||||||
"settings.plugins.initial_status": "刷新插件状态以查看最新的已安装插件和市场条目。",
|
"settings.plugins.initial_status": "刷新插件状态以查看最新的已安装插件。",
|
||||||
"settings.plugins.refresh_button": "刷新插件",
|
"settings.plugins.refresh_button": "刷新插件",
|
||||||
|
"settings.plugins.refresh_success_installed_format": "已加载 {0} 个已安装插件。",
|
||||||
"settings.plugins.refresh_success_format": "已加载 {0} 个已安装插件和 {1} 个市场条目。",
|
"settings.plugins.refresh_success_format": "已加载 {0} 个已安装插件和 {1} 个市场条目。",
|
||||||
"settings.plugins.refresh_failed": "加载插件市场索引失败。",
|
"settings.plugins.refresh_failed": "加载插件市场索引失败。",
|
||||||
"settings.plugins.marketplace_header": "插件市场",
|
"settings.plugins.marketplace_header": "插件市场",
|
||||||
@@ -442,7 +443,7 @@
|
|||||||
"market.card.loaded": "已加载",
|
"market.card.loaded": "已加载",
|
||||||
"market.card.pending_restart": "需要重启",
|
"market.card.pending_restart": "需要重启",
|
||||||
"market.detail.placeholder": "从左侧选择一个插件以查看详情。",
|
"market.detail.placeholder": "从左侧选择一个插件以查看详情。",
|
||||||
"market.detail.author": "作者",
|
"market.detail.author": "发行者",
|
||||||
"market.detail.version": "版本",
|
"market.detail.version": "版本",
|
||||||
"market.detail.api_version": "API 版本",
|
"market.detail.api_version": "API 版本",
|
||||||
"market.detail.min_host_version": "最低宿主版本",
|
"market.detail.min_host_version": "最低宿主版本",
|
||||||
@@ -461,6 +462,11 @@
|
|||||||
"market.detail.homepage": "主页",
|
"market.detail.homepage": "主页",
|
||||||
"market.detail.repository": "仓库",
|
"market.detail.repository": "仓库",
|
||||||
"market.detail.release_notes": "发布说明",
|
"market.detail.release_notes": "发布说明",
|
||||||
|
"market.detail.dependencies": "依赖项",
|
||||||
|
"market.detail.dependencies_empty": "该插件没有声明 SharedContracts 依赖项。",
|
||||||
|
"market.detail.readme_loading": "正在加载 README...",
|
||||||
|
"market.detail.readme_empty": "README 为空。",
|
||||||
|
"market.detail.readme_error_format": "README 加载失败:{0}",
|
||||||
"market.detail.state.not_installed": "未安装",
|
"market.detail.state.not_installed": "未安装",
|
||||||
"market.detail.state.update_available": "可更新",
|
"market.detail.state.update_available": "可更新",
|
||||||
"market.detail.state.installed": "已安装",
|
"market.detail.state.installed": "已安装",
|
||||||
@@ -469,6 +475,7 @@
|
|||||||
"market.button.update": "更新",
|
"market.button.update": "更新",
|
||||||
"market.button.installed": "已安装",
|
"market.button.installed": "已安装",
|
||||||
"market.button.installing": "安装中...",
|
"market.button.installing": "安装中...",
|
||||||
|
"market.button.restart": "重启后应用",
|
||||||
"button.component_library": "桌面编辑",
|
"button.component_library": "桌面编辑",
|
||||||
"tooltip.component_library": "桌面编辑",
|
"tooltip.component_library": "桌面编辑",
|
||||||
"component_library.title": "桌面编辑",
|
"component_library.title": "桌面编辑",
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ public sealed record WeatherSettingsState(
|
|||||||
public sealed record RegionSettingsState(string LanguageCode, string? TimeZoneId);
|
public sealed record RegionSettingsState(string LanguageCode, string? TimeZoneId);
|
||||||
public sealed record UpdateSettingsState(bool AutoCheckUpdates, bool IncludePrereleaseUpdates, string UpdateChannel);
|
public sealed record UpdateSettingsState(bool AutoCheckUpdates, bool IncludePrereleaseUpdates, string UpdateChannel);
|
||||||
public sealed record PluginManagementSettingsState(IReadOnlyList<string> DisabledPluginIds);
|
public sealed record PluginManagementSettingsState(IReadOnlyList<string> DisabledPluginIds);
|
||||||
|
public sealed record PluginMarketDependencyInfo(
|
||||||
|
string Id,
|
||||||
|
string Version,
|
||||||
|
string AssemblyName);
|
||||||
public sealed record PluginMarketPluginInfo(
|
public sealed record PluginMarketPluginInfo(
|
||||||
string Id,
|
string Id,
|
||||||
string Name,
|
string Name,
|
||||||
@@ -55,6 +59,7 @@ public sealed record PluginMarketPluginInfo(
|
|||||||
string HomepageUrl,
|
string HomepageUrl,
|
||||||
string RepositoryUrl,
|
string RepositoryUrl,
|
||||||
IReadOnlyList<string> Tags,
|
IReadOnlyList<string> Tags,
|
||||||
|
IReadOnlyList<PluginMarketDependencyInfo> Dependencies,
|
||||||
DateTimeOffset PublishedAt,
|
DateTimeOffset PublishedAt,
|
||||||
DateTimeOffset UpdatedAt);
|
DateTimeOffset UpdatedAt);
|
||||||
public sealed record PluginMarketIndexResult(
|
public sealed record PluginMarketIndexResult(
|
||||||
|
|||||||
@@ -725,6 +725,12 @@ internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService
|
|||||||
entry.HomepageUrl,
|
entry.HomepageUrl,
|
||||||
entry.RepositoryUrl,
|
entry.RepositoryUrl,
|
||||||
entry.Tags,
|
entry.Tags,
|
||||||
|
entry.SharedContracts
|
||||||
|
.Select(contract => new PluginMarketDependencyInfo(
|
||||||
|
contract.Id,
|
||||||
|
contract.Version,
|
||||||
|
contract.AssemblyName))
|
||||||
|
.ToArray(),
|
||||||
entry.PublishedAt,
|
entry.PublishedAt,
|
||||||
entry.UpdatedAt);
|
entry.UpdatedAt);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using System.Reflection;
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using LanMountainDesktop.PluginSdk;
|
using LanMountainDesktop.PluginSdk;
|
||||||
using LanMountainDesktop.Plugins;
|
using LanMountainDesktop.Plugins;
|
||||||
|
using LanMountainDesktop.Services.PluginMarket;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
using LanMountainDesktop.ViewModels;
|
using LanMountainDesktop.ViewModels;
|
||||||
using LanMountainDesktop.Views.SettingsPages;
|
using LanMountainDesktop.Views.SettingsPages;
|
||||||
@@ -180,6 +181,8 @@ internal sealed class SettingsPageRegistry : ISettingsPageRegistry, IDisposable
|
|||||||
services.AddSingleton(_localizationService);
|
services.AddSingleton(_localizationService);
|
||||||
services.AddSingleton<ILocationService>(_ => HostLocationServiceProvider.GetOrCreate());
|
services.AddSingleton<ILocationService>(_ => HostLocationServiceProvider.GetOrCreate());
|
||||||
services.AddSingleton<WeatherLocationRefreshService>();
|
services.AddSingleton<WeatherLocationRefreshService>();
|
||||||
|
services.AddSingleton<AirAppMarketIconService>();
|
||||||
|
services.AddSingleton<AirAppMarketReadmeService>();
|
||||||
|
|
||||||
var pluginRuntime = _pluginRuntimeAccessor();
|
var pluginRuntime = _pluginRuntimeAccessor();
|
||||||
if (pluginRuntime is not null)
|
if (pluginRuntime is not null)
|
||||||
|
|||||||
@@ -238,6 +238,38 @@
|
|||||||
<Setter Property="Background" Value="{DynamicResource AdaptiveAccentLightBrush}" />
|
<Setter Property="Background" Value="{DynamicResource AdaptiveAccentLightBrush}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.plugin-market-row-button">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.plugin-market-row-button:pointerover">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.plugin-market-icon-button">
|
||||||
|
<Setter Property="Width" Value="36" />
|
||||||
|
<Setter Property="Height" Value="36" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
<Setter Property="CornerRadius" Value="10" />
|
||||||
|
<Setter Property="MinHeight" Value="36" />
|
||||||
|
<Setter Property="Background" Value="{DynamicResource AdaptiveButtonBackgroundBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveButtonBorderBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.plugin-market-icon-button:pointerover">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource AdaptiveSurfaceRaisedBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.plugin-market-icon-button fi|SymbolIcon">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||||
|
<Setter Property="FontSize" Value="16" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Separator.settings-separator">
|
<Style Selector="Separator.settings-separator">
|
||||||
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassPanelBorderBrush}" />
|
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassPanelBorderBrush}" />
|
||||||
<Setter Property="Margin" Value="0,18" />
|
<Setter Property="Margin" Value="0,18" />
|
||||||
|
|||||||
@@ -0,0 +1,664 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia.Media.Imaging;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using FluentIcons.Common;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
using LanMountainDesktop.Services.PluginMarket;
|
||||||
|
using LanMountainDesktop.Services.Settings;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.ViewModels;
|
||||||
|
|
||||||
|
public enum PluginMarketPrimaryActionState
|
||||||
|
{
|
||||||
|
Install,
|
||||||
|
Update,
|
||||||
|
RestartRequired,
|
||||||
|
Installed,
|
||||||
|
Incompatible
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed partial class PluginMarketItemViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly LocalizationService _localizationService;
|
||||||
|
private readonly string _languageCode;
|
||||||
|
private bool _isLoadingIcon;
|
||||||
|
|
||||||
|
public PluginMarketItemViewModel(
|
||||||
|
PluginMarketPluginInfo plugin,
|
||||||
|
LocalizationService localizationService,
|
||||||
|
string languageCode)
|
||||||
|
{
|
||||||
|
Info = plugin;
|
||||||
|
_localizationService = localizationService;
|
||||||
|
_languageCode = languageCode;
|
||||||
|
DeveloperInfo = ResolveDeveloperInfo();
|
||||||
|
IconFallbackText = string.IsNullOrWhiteSpace(plugin.Name)
|
||||||
|
? "?"
|
||||||
|
: plugin.Name.Trim()[0].ToString().ToUpperInvariant();
|
||||||
|
ActionSymbol = Symbol.ArrowDownload;
|
||||||
|
ActionTooltip = L("market.button.install", "Install");
|
||||||
|
}
|
||||||
|
|
||||||
|
public PluginMarketPluginInfo Info { get; }
|
||||||
|
|
||||||
|
public string PluginId => Info.Id;
|
||||||
|
|
||||||
|
public string Name => Info.Name;
|
||||||
|
|
||||||
|
public string Description => Info.Description;
|
||||||
|
|
||||||
|
public string Author => Info.Author;
|
||||||
|
|
||||||
|
public string Version => Info.Version;
|
||||||
|
|
||||||
|
public string ApiVersion => Info.ApiVersion;
|
||||||
|
|
||||||
|
public string MinHostVersion => Info.MinHostVersion;
|
||||||
|
|
||||||
|
public string ReadmeUrl => Info.ReadmeUrl;
|
||||||
|
|
||||||
|
public IReadOnlyList<PluginMarketDependencyInfo> Dependencies => Info.Dependencies;
|
||||||
|
|
||||||
|
public string IconFallbackText { get; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private Bitmap? _iconBitmap;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _developerInfo = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private Symbol _actionSymbol;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _actionTooltip = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _isActionEnabled = true;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _isInstalling;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _isInstalled;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _isUpdateAvailable;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _requiresRestart;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _isCompatibleWithHost = true;
|
||||||
|
|
||||||
|
public bool HasIcon => IconBitmap is not null;
|
||||||
|
|
||||||
|
public PluginMarketPrimaryActionState ActionState { get; private set; }
|
||||||
|
|
||||||
|
partial void OnIconBitmapChanged(Bitmap? value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(HasIcon));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task EnsureIconLoadedAsync(AirAppMarketIconService iconService)
|
||||||
|
{
|
||||||
|
if (_isLoadingIcon || IconBitmap is not null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isLoadingIcon = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
IconBitmap = await iconService.LoadAsync(Info);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
IconBitmap = null;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isLoadingIcon = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetInstalling(bool value)
|
||||||
|
{
|
||||||
|
IsInstalling = value;
|
||||||
|
RefreshActionPresentation();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ApplyInstallState(InstalledPluginInfo? installedPlugin, Version? hostVersion)
|
||||||
|
{
|
||||||
|
var isCompatible = hostVersion is null
|
||||||
|
|| !System.Version.TryParse(MinHostVersion, out var minHostVersion)
|
||||||
|
|| hostVersion >= minHostVersion;
|
||||||
|
|
||||||
|
var isInstalled = installedPlugin is not null;
|
||||||
|
var isUpdateAvailable = installedPlugin is not null && CompareVersions(Version, installedPlugin.Manifest.Version) > 0;
|
||||||
|
var requiresRestart = installedPlugin is not null &&
|
||||||
|
installedPlugin.IsEnabled &&
|
||||||
|
!installedPlugin.IsLoaded &&
|
||||||
|
string.IsNullOrWhiteSpace(installedPlugin.ErrorMessage);
|
||||||
|
|
||||||
|
IsCompatibleWithHost = isCompatible;
|
||||||
|
IsInstalled = isInstalled;
|
||||||
|
IsUpdateAvailable = isUpdateAvailable;
|
||||||
|
RequiresRestart = requiresRestart;
|
||||||
|
DeveloperInfo = ResolveDeveloperInfo();
|
||||||
|
RefreshActionPresentation();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshActionPresentation()
|
||||||
|
{
|
||||||
|
if (IsInstalling)
|
||||||
|
{
|
||||||
|
ActionState = IsUpdateAvailable ? PluginMarketPrimaryActionState.Update : PluginMarketPrimaryActionState.Install;
|
||||||
|
ActionSymbol = Symbol.ArrowClockwise;
|
||||||
|
ActionTooltip = L("market.button.installing", "Installing...");
|
||||||
|
IsActionEnabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsCompatibleWithHost)
|
||||||
|
{
|
||||||
|
ActionState = PluginMarketPrimaryActionState.Incompatible;
|
||||||
|
ActionSymbol = Symbol.Warning;
|
||||||
|
ActionTooltip = string.Format(
|
||||||
|
CultureInfo.CurrentCulture,
|
||||||
|
L("market.status.host_incompatible_format", "This host is too old. Version {0} or newer is required."),
|
||||||
|
MinHostVersion);
|
||||||
|
IsActionEnabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RequiresRestart)
|
||||||
|
{
|
||||||
|
ActionState = PluginMarketPrimaryActionState.RestartRequired;
|
||||||
|
ActionSymbol = Symbol.ArrowClockwise;
|
||||||
|
ActionTooltip = L("market.button.restart", "Restart to apply");
|
||||||
|
IsActionEnabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsUpdateAvailable)
|
||||||
|
{
|
||||||
|
ActionState = PluginMarketPrimaryActionState.Update;
|
||||||
|
ActionSymbol = Symbol.ArrowSync;
|
||||||
|
ActionTooltip = L("market.button.update", "Update");
|
||||||
|
IsActionEnabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsInstalled)
|
||||||
|
{
|
||||||
|
ActionState = PluginMarketPrimaryActionState.Installed;
|
||||||
|
ActionSymbol = Symbol.CheckmarkCircle;
|
||||||
|
ActionTooltip = L("market.button.installed", "Installed");
|
||||||
|
IsActionEnabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ActionState = PluginMarketPrimaryActionState.Install;
|
||||||
|
ActionSymbol = Symbol.ArrowDownload;
|
||||||
|
ActionTooltip = L("market.button.install", "Install");
|
||||||
|
IsActionEnabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ResolveDeveloperInfo()
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(Author)
|
||||||
|
? L("settings.plugins.publisher_unknown", "Unknown publisher")
|
||||||
|
: Author;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int CompareVersions(string? left, string? right)
|
||||||
|
{
|
||||||
|
if (!System.Version.TryParse(left, out var leftVersion))
|
||||||
|
{
|
||||||
|
leftVersion = new Version(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!System.Version.TryParse(right, out var rightVersion))
|
||||||
|
{
|
||||||
|
rightVersion = new Version(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return leftVersion.CompareTo(rightVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string L(string key, string fallback)
|
||||||
|
=> _localizationService.GetString(_languageCode, key, fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed partial class PluginMarketDetailViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly LocalizationService _localizationService;
|
||||||
|
private readonly string _languageCode;
|
||||||
|
private readonly AirAppMarketReadmeService _readmeService;
|
||||||
|
private readonly Func<PluginMarketItemViewModel, Task> _primaryActionAsync;
|
||||||
|
private bool _isInitialized;
|
||||||
|
|
||||||
|
public PluginMarketDetailViewModel(
|
||||||
|
PluginMarketItemViewModel item,
|
||||||
|
LocalizationService localizationService,
|
||||||
|
string languageCode,
|
||||||
|
AirAppMarketReadmeService readmeService,
|
||||||
|
Func<PluginMarketItemViewModel, Task> primaryActionAsync)
|
||||||
|
{
|
||||||
|
Item = item;
|
||||||
|
_localizationService = localizationService;
|
||||||
|
_languageCode = languageCode;
|
||||||
|
_readmeService = readmeService;
|
||||||
|
_primaryActionAsync = primaryActionAsync;
|
||||||
|
|
||||||
|
Dependencies = new ObservableCollection<PluginMarketDependencyInfo>(item.Dependencies);
|
||||||
|
VersionLabel = L("market.detail.version", "Version");
|
||||||
|
PublisherLabel = L("market.detail.author", "Author");
|
||||||
|
ApiVersionLabel = L("market.detail.api_version", "API Version");
|
||||||
|
MinHostVersionLabel = L("market.detail.min_host_version", "Minimum Host Version");
|
||||||
|
ReadmeHeader = L("market.detail.readme", "README");
|
||||||
|
DependenciesHeader = L("market.detail.dependencies", "Dependencies");
|
||||||
|
EmptyDependenciesText = L("market.detail.dependencies_empty", "No dependencies were declared by this plugin.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public PluginMarketItemViewModel Item { get; }
|
||||||
|
|
||||||
|
public ObservableCollection<PluginMarketDependencyInfo> Dependencies { get; }
|
||||||
|
|
||||||
|
public string DrawerTitle => Item.Name;
|
||||||
|
|
||||||
|
public string VersionLabel { get; }
|
||||||
|
|
||||||
|
public string PublisherLabel { get; }
|
||||||
|
|
||||||
|
public string ApiVersionLabel { get; }
|
||||||
|
|
||||||
|
public string MinHostVersionLabel { get; }
|
||||||
|
|
||||||
|
public string ReadmeHeader { get; }
|
||||||
|
|
||||||
|
public string DependenciesHeader { get; }
|
||||||
|
|
||||||
|
public string EmptyDependenciesText { get; }
|
||||||
|
|
||||||
|
public string ReadmeLoadingText => L("market.detail.readme_loading", "Loading README...");
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _readmeMarkdown = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _isReadmeLoading;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string? _readmeError;
|
||||||
|
|
||||||
|
public bool HasDependencies => Dependencies.Count > 0;
|
||||||
|
|
||||||
|
public bool HasReadmeError => !string.IsNullOrWhiteSpace(ReadmeError);
|
||||||
|
|
||||||
|
public bool HasReadmeContent => !IsReadmeLoading && !HasReadmeError && !string.IsNullOrWhiteSpace(ReadmeMarkdown);
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
if (_isInitialized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isInitialized = true;
|
||||||
|
IsReadmeLoading = true;
|
||||||
|
OnReadmeStateChanged();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var content = await _readmeService.LoadAsync(Item.Info);
|
||||||
|
ReadmeMarkdown = string.IsNullOrWhiteSpace(content)
|
||||||
|
? L("market.detail.readme_empty", "README is empty.")
|
||||||
|
: content;
|
||||||
|
ReadmeError = null;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
ReadmeMarkdown = string.Empty;
|
||||||
|
ReadmeError = string.Format(
|
||||||
|
CultureInfo.CurrentCulture,
|
||||||
|
L("market.detail.readme_error_format", "README could not be loaded: {0}"),
|
||||||
|
ex.Message);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsReadmeLoading = false;
|
||||||
|
OnReadmeStateChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private Task PerformPrimaryActionAsync()
|
||||||
|
{
|
||||||
|
return _primaryActionAsync(Item);
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnReadmeMarkdownChanged(string value)
|
||||||
|
{
|
||||||
|
OnReadmeStateChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnReadmeErrorChanged(string? value)
|
||||||
|
{
|
||||||
|
OnReadmeStateChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnReadmeStateChanged()
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(HasReadmeContent));
|
||||||
|
OnPropertyChanged(nameof(HasReadmeError));
|
||||||
|
OnPropertyChanged(nameof(HasDependencies));
|
||||||
|
}
|
||||||
|
|
||||||
|
private string L(string key, string fallback)
|
||||||
|
=> _localizationService.GetString(_languageCode, key, fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly ISettingsFacadeService _settingsFacade;
|
||||||
|
private readonly LocalizationService _localizationService;
|
||||||
|
private readonly AirAppMarketIconService _iconService;
|
||||||
|
private readonly AirAppMarketReadmeService _readmeService;
|
||||||
|
private readonly string _languageCode;
|
||||||
|
private readonly Dictionary<string, InstalledPluginInfo> _installedPlugins = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly Version? _hostVersion;
|
||||||
|
private bool _isInitialized;
|
||||||
|
private bool _hasLoadedMarket;
|
||||||
|
|
||||||
|
public PluginMarketSettingsPageViewModel(
|
||||||
|
ISettingsFacadeService settingsFacade,
|
||||||
|
LocalizationService localizationService,
|
||||||
|
AirAppMarketIconService iconService,
|
||||||
|
AirAppMarketReadmeService readmeService)
|
||||||
|
{
|
||||||
|
_settingsFacade = settingsFacade;
|
||||||
|
_localizationService = localizationService;
|
||||||
|
_iconService = iconService;
|
||||||
|
_readmeService = readmeService;
|
||||||
|
_languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode);
|
||||||
|
Version.TryParse(_settingsFacade.ApplicationInfo.GetAppVersionText(), out _hostVersion);
|
||||||
|
RefreshLocalizedText();
|
||||||
|
StatusMessage = L("market.status.loading", "Loading the official plugin market...");
|
||||||
|
}
|
||||||
|
|
||||||
|
public event Action<string?>? RestartRequested;
|
||||||
|
|
||||||
|
public event Action<PluginMarketItemViewModel>? DetailsRequested;
|
||||||
|
|
||||||
|
public ObservableCollection<PluginMarketItemViewModel> MarketPlugins { get; } = [];
|
||||||
|
|
||||||
|
public ObservableCollection<PluginMarketItemViewModel> FilteredPlugins { get; } = [];
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _statusMessage = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _isBusy;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _pageTitle = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _pageDescription = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _searchPlaceholder = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _refreshButtonText = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _emptyStateText = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _showEmptyState;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _restartRequiredMessage = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _searchText = string.Empty;
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
if (_isInitialized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isInitialized = true;
|
||||||
|
await RefreshAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public PluginMarketDetailViewModel CreateDetailViewModel(PluginMarketItemViewModel item)
|
||||||
|
{
|
||||||
|
return new PluginMarketDetailViewModel(
|
||||||
|
item,
|
||||||
|
_localizationService,
|
||||||
|
_languageCode,
|
||||||
|
_readmeService,
|
||||||
|
ExecutePrimaryActionAsync);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task RefreshAsync()
|
||||||
|
{
|
||||||
|
if (IsBusy)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
IsBusy = true;
|
||||||
|
StatusMessage = L("market.status.loading", "Loading the official plugin market...");
|
||||||
|
RefreshInstalledSnapshot();
|
||||||
|
|
||||||
|
var result = await _settingsFacade.PluginMarket.LoadIndexAsync();
|
||||||
|
if (!result.Success)
|
||||||
|
{
|
||||||
|
_hasLoadedMarket = false;
|
||||||
|
MarketPlugins.Clear();
|
||||||
|
FilteredPlugins.Clear();
|
||||||
|
ShowEmptyState = true;
|
||||||
|
EmptyStateText = string.IsNullOrWhiteSpace(result.ErrorMessage)
|
||||||
|
? L("market.list.empty", "The plugin market has not been loaded yet.")
|
||||||
|
: result.ErrorMessage;
|
||||||
|
StatusMessage = string.IsNullOrWhiteSpace(result.ErrorMessage)
|
||||||
|
? L("market.status.load_failed_format", "Failed to load the plugin market: Unknown")
|
||||||
|
: string.Format(
|
||||||
|
CultureInfo.CurrentCulture,
|
||||||
|
L("market.status.load_failed_format", "Failed to load the plugin market: {0}"),
|
||||||
|
result.ErrorMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_hasLoadedMarket = true;
|
||||||
|
MarketPlugins.Clear();
|
||||||
|
foreach (var plugin in result.Plugins)
|
||||||
|
{
|
||||||
|
var item = new PluginMarketItemViewModel(plugin, _localizationService, _languageCode);
|
||||||
|
item.ApplyInstallState(ResolveInstalledPlugin(plugin.Id), _hostVersion);
|
||||||
|
MarketPlugins.Add(item);
|
||||||
|
_ = item.EnsureIconLoadedAsync(_iconService);
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplyFilter();
|
||||||
|
|
||||||
|
StatusMessage = string.Equals(result.Source, "Cache", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? string.Format(
|
||||||
|
CultureInfo.CurrentCulture,
|
||||||
|
L("market.status.loaded_cache_format", "Official source unavailable. Loaded {0} plugin(s) from cache. Reason: {1}"),
|
||||||
|
MarketPlugins.Count,
|
||||||
|
result.WarningMessage ?? L("market.detail.unknown", "Unknown"))
|
||||||
|
: string.Format(
|
||||||
|
CultureInfo.CurrentCulture,
|
||||||
|
L("market.status.loaded_network_format", "Loaded {0} plugin(s) from the official source."),
|
||||||
|
MarketPlugins.Count);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void OpenDetails(PluginMarketItemViewModel? item)
|
||||||
|
{
|
||||||
|
if (item is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DetailsRequested?.Invoke(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private Task ExecutePrimaryActionAsync(PluginMarketItemViewModel? item)
|
||||||
|
{
|
||||||
|
return item is null ? Task.CompletedTask : ExecutePrimaryActionCoreAsync(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ExecutePrimaryActionCoreAsync(PluginMarketItemViewModel item)
|
||||||
|
{
|
||||||
|
if (item.IsInstalling)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.ActionState == PluginMarketPrimaryActionState.RestartRequired)
|
||||||
|
{
|
||||||
|
RestartRequested?.Invoke(RestartRequiredMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item.IsActionEnabled)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
item.SetInstalling(true);
|
||||||
|
StatusMessage = string.Format(
|
||||||
|
CultureInfo.CurrentCulture,
|
||||||
|
L("market.status.installing_format", "Downloading and staging plugin '{0}'..."),
|
||||||
|
item.Name);
|
||||||
|
|
||||||
|
var result = await _settingsFacade.PluginMarket.InstallAsync(item.PluginId);
|
||||||
|
if (result.Success)
|
||||||
|
{
|
||||||
|
RefreshInstalledSnapshot();
|
||||||
|
RefreshItemStates();
|
||||||
|
StatusMessage = string.Format(
|
||||||
|
CultureInfo.CurrentCulture,
|
||||||
|
L("market.status.install_success_format", "Plugin '{0}' has been staged. Restart the app to apply it."),
|
||||||
|
result.PluginName ?? item.Name);
|
||||||
|
RestartRequested?.Invoke(RestartRequiredMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
StatusMessage = string.IsNullOrWhiteSpace(result.ErrorMessage)
|
||||||
|
? string.Format(
|
||||||
|
CultureInfo.CurrentCulture,
|
||||||
|
L("market.status.install_failed_format", "Failed to install plugin: {0}"),
|
||||||
|
item.Name)
|
||||||
|
: string.Format(
|
||||||
|
CultureInfo.CurrentCulture,
|
||||||
|
L("market.status.install_failed_format", "Failed to install plugin: {0}"),
|
||||||
|
result.ErrorMessage);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
item.SetInstalling(false);
|
||||||
|
RefreshItemStates();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnSearchTextChanged(string value)
|
||||||
|
{
|
||||||
|
ApplyFilter();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshItemStates()
|
||||||
|
{
|
||||||
|
foreach (var item in MarketPlugins)
|
||||||
|
{
|
||||||
|
item.ApplyInstallState(ResolveInstalledPlugin(item.PluginId), _hostVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplyFilter();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshInstalledSnapshot()
|
||||||
|
{
|
||||||
|
_installedPlugins.Clear();
|
||||||
|
foreach (var plugin in _settingsFacade.PluginManagement.GetInstalledPlugins())
|
||||||
|
{
|
||||||
|
_installedPlugins[plugin.Manifest.Id] = plugin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private InstalledPluginInfo? ResolveInstalledPlugin(string pluginId)
|
||||||
|
{
|
||||||
|
return _installedPlugins.TryGetValue(pluginId, out var installedPlugin)
|
||||||
|
? installedPlugin
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyFilter()
|
||||||
|
{
|
||||||
|
FilteredPlugins.Clear();
|
||||||
|
|
||||||
|
IEnumerable<PluginMarketItemViewModel> filtered = MarketPlugins;
|
||||||
|
var query = SearchText?.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(query))
|
||||||
|
{
|
||||||
|
filtered = filtered.Where(item =>
|
||||||
|
item.Name.Contains(query, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
item.Description.Contains(query, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
item.Author.Contains(query, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
item.PluginId.Contains(query, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
item.Info.Tags.Any(tag => tag.Contains(query, StringComparison.OrdinalIgnoreCase)));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var item in filtered)
|
||||||
|
{
|
||||||
|
FilteredPlugins.Add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
ShowEmptyState = FilteredPlugins.Count == 0;
|
||||||
|
EmptyStateText = !_hasLoadedMarket
|
||||||
|
? L("market.list.empty", "The plugin market has not been loaded yet.")
|
||||||
|
: string.IsNullOrWhiteSpace(query)
|
||||||
|
? L("settings.plugins.marketplace_empty", "No marketplace plugins are available right now.")
|
||||||
|
: L("market.list.no_results", "No plugins match the current search.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshLocalizedText()
|
||||||
|
{
|
||||||
|
PageTitle = L("settings.plugin_market.title", "Plugin Market");
|
||||||
|
PageDescription = L("settings.plugin_market.subtitle", "Browse plugins from the official LanAirApp source and stage installs.");
|
||||||
|
SearchPlaceholder = L("market.toolbar.search_placeholder", "Search plugins");
|
||||||
|
RefreshButtonText = L("market.toolbar.refresh", "Refresh");
|
||||||
|
RestartRequiredMessage = L("settings.plugins.restart_required", "Plugin changes take effect after restart.");
|
||||||
|
EmptyStateText = L("market.list.empty", "The plugin market has not been loaded yet.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private string L(string key, string fallback)
|
||||||
|
=> _localizationService.GetString(_languageCode, key, fallback);
|
||||||
|
}
|
||||||
@@ -834,31 +834,6 @@ public sealed partial class InstalledPluginItemViewModel : ViewModelBase
|
|||||||
private bool _isEnabled;
|
private bool _isEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class PluginMarketItemViewModel
|
|
||||||
{
|
|
||||||
public PluginMarketItemViewModel(PluginMarketPluginInfo plugin)
|
|
||||||
{
|
|
||||||
PluginId = plugin.Id;
|
|
||||||
Name = plugin.Name;
|
|
||||||
Description = plugin.Description;
|
|
||||||
Version = plugin.Version;
|
|
||||||
Author = plugin.Author;
|
|
||||||
ApiVersion = plugin.ApiVersion;
|
|
||||||
}
|
|
||||||
|
|
||||||
public string PluginId { get; }
|
|
||||||
|
|
||||||
public string Name { get; }
|
|
||||||
|
|
||||||
public string Description { get; }
|
|
||||||
|
|
||||||
public string Version { get; }
|
|
||||||
|
|
||||||
public string Author { get; }
|
|
||||||
|
|
||||||
public string ApiVersion { get; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed partial class PluginsSettingsPageViewModel : ViewModelBase
|
public sealed partial class PluginsSettingsPageViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
private readonly ISettingsFacadeService _settingsFacade;
|
private readonly ISettingsFacadeService _settingsFacade;
|
||||||
@@ -879,8 +854,6 @@ public sealed partial class PluginsSettingsPageViewModel : ViewModelBase
|
|||||||
|
|
||||||
public ObservableCollection<InstalledPluginItemViewModel> InstalledPlugins { get; } = [];
|
public ObservableCollection<InstalledPluginItemViewModel> InstalledPlugins { get; } = [];
|
||||||
|
|
||||||
public ObservableCollection<PluginMarketItemViewModel> MarketPlugins { get; } = [];
|
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _statusMessage = string.Empty;
|
private string _statusMessage = string.Empty;
|
||||||
|
|
||||||
@@ -899,27 +872,18 @@ public sealed partial class PluginsSettingsPageViewModel : ViewModelBase
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _installedHeader = string.Empty;
|
private string _installedHeader = string.Empty;
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private string _marketplaceHeader = string.Empty;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _deleteButtonText = string.Empty;
|
private string _deleteButtonText = string.Empty;
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private string _installButtonText = string.Empty;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _emptyInstalledText = string.Empty;
|
private string _emptyInstalledText = string.Empty;
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private string _emptyMarketplaceText = string.Empty;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _restartRequiredMessage = string.Empty;
|
private string _restartRequiredMessage = string.Empty;
|
||||||
|
|
||||||
public async Task InitializeAsync()
|
public async Task InitializeAsync()
|
||||||
{
|
{
|
||||||
if (InstalledPlugins.Count > 0 || MarketPlugins.Count > 0)
|
if (InstalledPlugins.Count > 0)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -945,29 +909,12 @@ public sealed partial class PluginsSettingsPageViewModel : ViewModelBase
|
|||||||
InstalledPlugins.Add(new InstalledPluginItemViewModel(plugin));
|
InstalledPlugins.Add(new InstalledPluginItemViewModel(plugin));
|
||||||
}
|
}
|
||||||
|
|
||||||
MarketPlugins.Clear();
|
|
||||||
var marketResult = await _settingsFacade.PluginMarket.LoadIndexAsync();
|
|
||||||
if (marketResult.Success)
|
|
||||||
{
|
|
||||||
foreach (var plugin in marketResult.Plugins)
|
|
||||||
{
|
|
||||||
MarketPlugins.Add(new PluginMarketItemViewModel(plugin));
|
|
||||||
}
|
|
||||||
|
|
||||||
StatusMessage = string.Format(
|
StatusMessage = string.Format(
|
||||||
CultureInfo.CurrentCulture,
|
CultureInfo.CurrentCulture,
|
||||||
L(
|
L(
|
||||||
"settings.plugins.refresh_success_format",
|
"settings.plugins.refresh_success_installed_format",
|
||||||
"Loaded {0} installed plugins and {1} marketplace entries."),
|
"Loaded {0} installed plugins."),
|
||||||
InstalledPlugins.Count,
|
InstalledPlugins.Count);
|
||||||
MarketPlugins.Count);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
StatusMessage = string.IsNullOrWhiteSpace(marketResult.ErrorMessage)
|
|
||||||
? L("settings.plugins.refresh_failed", "Failed to load plugin market index.")
|
|
||||||
: marketResult.ErrorMessage;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -1034,55 +981,14 @@ public sealed partial class PluginsSettingsPageViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task InstallPluginAsync(PluginMarketItemViewModel? item)
|
|
||||||
{
|
|
||||||
if (item is null || IsBusy)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
IsBusy = true;
|
|
||||||
var result = await _settingsFacade.PluginMarket.InstallAsync(item.PluginId);
|
|
||||||
if (result.Success)
|
|
||||||
{
|
|
||||||
StatusMessage = string.Format(
|
|
||||||
CultureInfo.CurrentCulture,
|
|
||||||
L(
|
|
||||||
"settings.plugins.install_success_format",
|
|
||||||
"Installed plugin '{0}'. Restart the app to apply newly added settings pages and widgets."),
|
|
||||||
item.Name);
|
|
||||||
RestartRequested?.Invoke();
|
|
||||||
await RefreshAsync();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
StatusMessage = string.IsNullOrWhiteSpace(result.ErrorMessage)
|
|
||||||
? string.Format(
|
|
||||||
CultureInfo.CurrentCulture,
|
|
||||||
L("settings.plugins.install_failed_name_format", "Failed to install '{0}'."),
|
|
||||||
item.Name)
|
|
||||||
: result.ErrorMessage;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
IsBusy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RefreshLocalizedText()
|
private void RefreshLocalizedText()
|
||||||
{
|
{
|
||||||
PageTitle = L("settings.plugins.title", "Plugins");
|
PageTitle = L("settings.plugins.title", "Plugins");
|
||||||
PageDescription = L("settings.plugins.description", "Manage installed plugins and discover marketplace packages.");
|
PageDescription = L("settings.plugins.description", "Manage installed plugins and review their runtime state.");
|
||||||
RefreshButtonText = L("settings.plugins.refresh_button", "Refresh Plugins");
|
RefreshButtonText = L("settings.plugins.refresh_button", "Refresh Plugins");
|
||||||
InstalledHeader = L("settings.plugins.installed_header", "Installed Plugins");
|
InstalledHeader = L("settings.plugins.installed_header", "Installed Plugins");
|
||||||
MarketplaceHeader = L("settings.plugins.marketplace_header", "Marketplace");
|
|
||||||
DeleteButtonText = L("settings.plugins.delete_button_short", "Delete");
|
DeleteButtonText = L("settings.plugins.delete_button_short", "Delete");
|
||||||
InstallButtonText = L("settings.plugins.install_button_short", "Install");
|
|
||||||
EmptyInstalledText = L("settings.plugins.empty", "No plugins found.");
|
EmptyInstalledText = L("settings.plugins.empty", "No plugins found.");
|
||||||
EmptyMarketplaceText = L("settings.plugins.marketplace_empty", "No marketplace plugins available.");
|
|
||||||
RestartRequiredMessage = L("settings.plugins.restart_required", "Plugin changes take effect after restart.");
|
RestartRequiredMessage = L("settings.plugins.restart_required", "Plugin changes take effect after restart.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:LanMountainDesktop.ViewModels"
|
||||||
|
xmlns:mdxaml="https://github.com/whistyun/Markdown.Avalonia"
|
||||||
|
xmlns:helpers="using:LanMountainDesktop.Helpers"
|
||||||
|
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
|
||||||
|
x:Class="LanMountainDesktop.Views.SettingsPages.PluginMarketDetailDrawer"
|
||||||
|
x:DataType="vm:PluginMarketDetailViewModel">
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
|
<StackPanel Classes="settings-page-container"
|
||||||
|
Margin="0,0,0,8">
|
||||||
|
<Border Classes="settings-section-card">
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto"
|
||||||
|
ColumnSpacing="14">
|
||||||
|
<Border Classes="settings-section-card-icon-host"
|
||||||
|
Width="64"
|
||||||
|
Height="64"
|
||||||
|
Padding="0"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<Grid>
|
||||||
|
<Image IsVisible="{Binding Item.HasIcon}"
|
||||||
|
Source="{Binding Item.IconBitmap}"
|
||||||
|
Stretch="UniformToFill" />
|
||||||
|
<TextBlock IsVisible="{Binding !Item.HasIcon}"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
FontSize="20"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Text="{Binding Item.IconFallbackText}" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="1"
|
||||||
|
Spacing="4"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<TextBlock Classes="settings-card-header"
|
||||||
|
Margin="0"
|
||||||
|
Text="{Binding Item.Name}" />
|
||||||
|
<TextBlock Classes="settings-item-description"
|
||||||
|
Text="{Binding Item.DeveloperInfo}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Button Grid.Column="2"
|
||||||
|
Classes="plugin-market-icon-button"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Command="{Binding PerformPrimaryActionCommand}"
|
||||||
|
IsEnabled="{Binding Item.IsActionEnabled}"
|
||||||
|
ToolTip.Tip="{Binding Item.ActionTooltip}">
|
||||||
|
<fi:SymbolIcon Symbol="{Binding Item.ActionSymbol}" />
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Classes="settings-section-card">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<Grid ColumnDefinitions="Auto,*"
|
||||||
|
ColumnSpacing="12">
|
||||||
|
<TextBlock Classes="settings-item-label"
|
||||||
|
Text="{Binding VersionLabel}" />
|
||||||
|
<TextBlock Grid.Column="1"
|
||||||
|
Classes="settings-item-description"
|
||||||
|
Text="{Binding Item.Version}" />
|
||||||
|
</Grid>
|
||||||
|
<Grid ColumnDefinitions="Auto,*"
|
||||||
|
ColumnSpacing="12">
|
||||||
|
<TextBlock Classes="settings-item-label"
|
||||||
|
Text="{Binding PublisherLabel}" />
|
||||||
|
<TextBlock Grid.Column="1"
|
||||||
|
Classes="settings-item-description"
|
||||||
|
Text="{Binding Item.DeveloperInfo}" />
|
||||||
|
</Grid>
|
||||||
|
<Grid ColumnDefinitions="Auto,*"
|
||||||
|
ColumnSpacing="12">
|
||||||
|
<TextBlock Classes="settings-item-label"
|
||||||
|
Text="{Binding MinHostVersionLabel}" />
|
||||||
|
<TextBlock Grid.Column="1"
|
||||||
|
Classes="settings-item-description"
|
||||||
|
Text="{Binding Item.MinHostVersion}" />
|
||||||
|
</Grid>
|
||||||
|
<Grid ColumnDefinitions="Auto,*"
|
||||||
|
ColumnSpacing="12">
|
||||||
|
<TextBlock Classes="settings-item-label"
|
||||||
|
Text="{Binding ApiVersionLabel}" />
|
||||||
|
<TextBlock Grid.Column="1"
|
||||||
|
Classes="settings-item-description"
|
||||||
|
Text="{Binding Item.ApiVersion}" />
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Classes="settings-section-card">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<TextBlock Classes="settings-card-header"
|
||||||
|
Margin="0"
|
||||||
|
Text="{Binding ReadmeHeader}" />
|
||||||
|
|
||||||
|
<TextBlock Classes="settings-item-description"
|
||||||
|
IsVisible="{Binding IsReadmeLoading}"
|
||||||
|
Text="{Binding ReadmeLoadingText}" />
|
||||||
|
<TextBlock Classes="settings-item-description"
|
||||||
|
IsVisible="{Binding HasReadmeError}"
|
||||||
|
Text="{Binding ReadmeError}"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
<mdxaml:MarkdownScrollViewer IsVisible="{Binding HasReadmeContent}"
|
||||||
|
Markdown="{Binding ReadmeMarkdown}"
|
||||||
|
Engine="{x:Static helpers:PluginMarketMarkdownHelper.Engine}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Classes="settings-section-card">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<TextBlock Classes="settings-card-header"
|
||||||
|
Margin="0"
|
||||||
|
Text="{Binding DependenciesHeader}" />
|
||||||
|
|
||||||
|
<TextBlock Classes="settings-item-description"
|
||||||
|
IsVisible="{Binding !HasDependencies}"
|
||||||
|
Text="{Binding EmptyDependenciesText}"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
|
||||||
|
<ItemsControl IsVisible="{Binding HasDependencies}"
|
||||||
|
ItemsSource="{Binding Dependencies}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Border Classes="settings-list-item">
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Classes="settings-item-label"
|
||||||
|
Text="{Binding Id}" />
|
||||||
|
<TextBlock Classes="settings-item-description"
|
||||||
|
Text="{Binding AssemblyName}" />
|
||||||
|
<TextBlock Classes="settings-item-description"
|
||||||
|
Text="{Binding Version, StringFormat=v{0}}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using LanMountainDesktop.ViewModels;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Views.SettingsPages;
|
||||||
|
|
||||||
|
public partial class PluginMarketDetailDrawer : UserControl
|
||||||
|
{
|
||||||
|
public PluginMarketDetailDrawer()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public PluginMarketDetailDrawer(PluginMarketDetailViewModel viewModel)
|
||||||
|
{
|
||||||
|
DataContext = viewModel;
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:LanMountainDesktop.ViewModels"
|
||||||
|
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||||
|
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
|
||||||
|
x:Class="LanMountainDesktop.Views.SettingsPages.PluginMarketSettingsPage"
|
||||||
|
x:Name="Root"
|
||||||
|
x:DataType="vm:PluginMarketSettingsPageViewModel">
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
|
<StackPanel Classes="settings-page-container">
|
||||||
|
<ui:SettingsExpander Header="{Binding RefreshButtonText}"
|
||||||
|
Description="{Binding StatusMessage}">
|
||||||
|
<ui:SettingsExpander.IconSource>
|
||||||
|
<fi:SymbolIconSource Symbol="ShoppingBag" />
|
||||||
|
</ui:SettingsExpander.IconSource>
|
||||||
|
<ui:SettingsExpander.Footer>
|
||||||
|
<Grid ColumnDefinitions="*,Auto"
|
||||||
|
ColumnSpacing="12">
|
||||||
|
<TextBox Text="{Binding SearchText}"
|
||||||
|
Watermark="{Binding SearchPlaceholder}" />
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Command="{Binding RefreshCommand}"
|
||||||
|
Content="{Binding RefreshButtonText}" />
|
||||||
|
</Grid>
|
||||||
|
</ui:SettingsExpander.Footer>
|
||||||
|
</ui:SettingsExpander>
|
||||||
|
|
||||||
|
<TextBlock Classes="settings-item-description"
|
||||||
|
IsVisible="{Binding ShowEmptyState}"
|
||||||
|
Margin="0,4,0,14"
|
||||||
|
Text="{Binding EmptyStateText}"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
|
||||||
|
<ListBox ItemsSource="{Binding FilteredPlugins}"
|
||||||
|
BorderThickness="0"
|
||||||
|
Padding="0"
|
||||||
|
Background="Transparent">
|
||||||
|
<ListBox.Styles>
|
||||||
|
<Style Selector="ListBoxItem">
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
<Setter Property="Margin" Value="0,0,0,10" />
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
</Style>
|
||||||
|
</ListBox.Styles>
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:PluginMarketItemViewModel">
|
||||||
|
<Border Classes="settings-list-item">
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto"
|
||||||
|
ColumnSpacing="14">
|
||||||
|
<Border Classes="settings-section-card-icon-host"
|
||||||
|
Width="52"
|
||||||
|
Height="52"
|
||||||
|
Padding="0"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<Grid>
|
||||||
|
<Image IsVisible="{Binding HasIcon}"
|
||||||
|
Source="{Binding IconBitmap}"
|
||||||
|
Stretch="UniformToFill" />
|
||||||
|
<TextBlock IsVisible="{Binding !HasIcon}"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
FontSize="18"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Text="{Binding IconFallbackText}" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Classes="plugin-market-row-button"
|
||||||
|
Command="{Binding #Root.DataContext.OpenDetailsCommand}"
|
||||||
|
CommandParameter="{Binding}">
|
||||||
|
<StackPanel Spacing="4"
|
||||||
|
HorizontalAlignment="Stretch">
|
||||||
|
<TextBlock Classes="settings-item-label"
|
||||||
|
Text="{Binding Name}" />
|
||||||
|
<TextBlock Classes="settings-item-description"
|
||||||
|
Text="{Binding DeveloperInfo}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button Grid.Column="2"
|
||||||
|
Classes="plugin-market-icon-button"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Command="{Binding #Root.DataContext.ExecutePrimaryActionCommand}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
IsEnabled="{Binding IsActionEnabled}"
|
||||||
|
ToolTip.Tip="{Binding ActionTooltip}">
|
||||||
|
<fi:SymbolIcon Symbol="{Binding ActionSymbol}" />
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
using LanMountainDesktop.Services.PluginMarket;
|
||||||
|
using LanMountainDesktop.Services.Settings;
|
||||||
|
using LanMountainDesktop.ViewModels;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Views.SettingsPages;
|
||||||
|
|
||||||
|
[SettingsPageInfo(
|
||||||
|
"plugin-market",
|
||||||
|
"Plugin Market",
|
||||||
|
SettingsPageCategory.PluginMarket,
|
||||||
|
IconKey = "ShoppingBag",
|
||||||
|
SortOrder = 35,
|
||||||
|
TitleLocalizationKey = "settings.plugin_market.title",
|
||||||
|
DescriptionLocalizationKey = "settings.plugin_market.subtitle")]
|
||||||
|
public partial class PluginMarketSettingsPage : SettingsPageBase
|
||||||
|
{
|
||||||
|
public PluginMarketSettingsPage()
|
||||||
|
: this(CreateDefaultViewModel())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public PluginMarketSettingsPage(PluginMarketSettingsPageViewModel viewModel)
|
||||||
|
{
|
||||||
|
ViewModel = viewModel;
|
||||||
|
ViewModel.RestartRequested += OnRestartRequested;
|
||||||
|
ViewModel.DetailsRequested += OnDetailsRequested;
|
||||||
|
DataContext = ViewModel;
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public PluginMarketSettingsPageViewModel ViewModel { get; }
|
||||||
|
|
||||||
|
public override async void OnNavigatedTo(object? parameter)
|
||||||
|
{
|
||||||
|
await ViewModel.InitializeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PluginMarketSettingsPageViewModel CreateDefaultViewModel()
|
||||||
|
{
|
||||||
|
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
||||||
|
var localizationService = new LocalizationService();
|
||||||
|
return new PluginMarketSettingsPageViewModel(
|
||||||
|
settingsFacade,
|
||||||
|
localizationService,
|
||||||
|
new AirAppMarketIconService(),
|
||||||
|
new AirAppMarketReadmeService());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnRestartRequested(string? reason)
|
||||||
|
{
|
||||||
|
RequestRestart(reason ?? ViewModel.RestartRequiredMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnDetailsRequested(PluginMarketItemViewModel item)
|
||||||
|
{
|
||||||
|
var detailViewModel = ViewModel.CreateDetailViewModel(item);
|
||||||
|
var drawer = new PluginMarketDetailDrawer(detailViewModel);
|
||||||
|
OpenDrawer(drawer, detailViewModel.DrawerTitle);
|
||||||
|
await detailViewModel.InitializeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,8 +9,6 @@
|
|||||||
x:DataType="vm:PluginsSettingsPageViewModel">
|
x:DataType="vm:PluginsSettingsPageViewModel">
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel Classes="settings-page-container">
|
<StackPanel Classes="settings-page-container">
|
||||||
|
|
||||||
<!-- 刷新按钮和状态 -->
|
|
||||||
<ui:SettingsExpander Header="{Binding RefreshButtonText}"
|
<ui:SettingsExpander Header="{Binding RefreshButtonText}"
|
||||||
Description="{Binding StatusMessage}">
|
Description="{Binding StatusMessage}">
|
||||||
<ui:SettingsExpander.IconSource>
|
<ui:SettingsExpander.IconSource>
|
||||||
@@ -24,7 +22,6 @@
|
|||||||
|
|
||||||
<Separator Classes="settings-separator" />
|
<Separator Classes="settings-separator" />
|
||||||
|
|
||||||
<!-- 已安装插件分组 -->
|
|
||||||
<controls:IconText Icon="PuzzleCube"
|
<controls:IconText Icon="PuzzleCube"
|
||||||
Text="{Binding InstalledHeader}"
|
Text="{Binding InstalledHeader}"
|
||||||
Margin="0,0,0,4" />
|
Margin="0,0,0,4" />
|
||||||
@@ -56,37 +53,6 @@
|
|||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
|
|
||||||
<Separator Classes="settings-separator" />
|
|
||||||
|
|
||||||
<!-- 市场插件分组 -->
|
|
||||||
<controls:IconText Icon="ShoppingBag"
|
|
||||||
Text="{Binding MarketplaceHeader}"
|
|
||||||
Margin="0,0,0,4" />
|
|
||||||
|
|
||||||
<ItemsControl ItemsSource="{Binding MarketPlugins}">
|
|
||||||
<ItemsControl.ItemTemplate>
|
|
||||||
<DataTemplate x:DataType="vm:PluginMarketItemViewModel">
|
|
||||||
<ui:SettingsExpander>
|
|
||||||
<ui:SettingsExpander.IconSource>
|
|
||||||
<fi:SymbolIconSource Symbol="ShoppingBag" />
|
|
||||||
</ui:SettingsExpander.IconSource>
|
|
||||||
<ui:SettingsExpander.Header>
|
|
||||||
<StackPanel>
|
|
||||||
<TextBlock FontWeight="SemiBold" Text="{Binding Name}" />
|
|
||||||
<TextBlock Opacity="0.76" FontSize="12" Text="{Binding Description}" />
|
|
||||||
</StackPanel>
|
|
||||||
</ui:SettingsExpander.Header>
|
|
||||||
<ui:SettingsExpander.Footer>
|
|
||||||
<Button Command="{Binding #Root.DataContext.InstallPluginCommand}"
|
|
||||||
CommandParameter="{Binding}"
|
|
||||||
Content="{Binding #Root.DataContext.InstallButtonText}" />
|
|
||||||
</ui:SettingsExpander.Footer>
|
|
||||||
</ui:SettingsExpander>
|
|
||||||
</DataTemplate>
|
|
||||||
</ItemsControl.ItemTemplate>
|
|
||||||
</ItemsControl>
|
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -638,6 +638,7 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
|
|||||||
"Apps" => Symbol.Apps,
|
"Apps" => Symbol.Apps,
|
||||||
"GridDots" => Symbol.GridDots,
|
"GridDots" => Symbol.GridDots,
|
||||||
"PuzzlePiece" => Symbol.PuzzlePiece,
|
"PuzzlePiece" => Symbol.PuzzlePiece,
|
||||||
|
"ShoppingBag" => Symbol.ShoppingBag,
|
||||||
"Info" => Symbol.Info,
|
"Info" => Symbol.Info,
|
||||||
"ArrowSync" => Symbol.ArrowSync,
|
"ArrowSync" => Symbol.ArrowSync,
|
||||||
_ => Symbol.Settings
|
_ => Symbol.Settings
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ using Avalonia.Media.Imaging;
|
|||||||
|
|
||||||
namespace LanMountainDesktop.Services.PluginMarket;
|
namespace LanMountainDesktop.Services.PluginMarket;
|
||||||
|
|
||||||
internal sealed class AirAppMarketIconService : IDisposable
|
public sealed class AirAppMarketIconService : IDisposable
|
||||||
{
|
{
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ internal sealed class AirAppMarketIconService : IDisposable
|
|||||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
|
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Bitmap> LoadAsync(
|
internal async Task<Bitmap> LoadAsync(
|
||||||
AirAppMarketPluginEntry plugin,
|
AirAppMarketPluginEntry plugin,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -40,6 +40,26 @@ internal sealed class AirAppMarketIconService : IDisposable
|
|||||||
return new Bitmap(memory);
|
return new Bitmap(memory);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Bitmap> LoadAsync(
|
||||||
|
LanMountainDesktop.Services.Settings.PluginMarketPluginInfo 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()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_httpClient.Dispose();
|
_httpClient.Dispose();
|
||||||
|
|||||||
@@ -438,6 +438,29 @@ internal sealed class AirAppMarketSharedContractEntry
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal sealed class AirAppMarketPluginDependencyEntry
|
||||||
|
{
|
||||||
|
public string Id { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string Version { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string AssemblyName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public AirAppMarketPluginDependencyEntry ValidateAndNormalize(string sourceName)
|
||||||
|
{
|
||||||
|
return new AirAppMarketPluginDependencyEntry
|
||||||
|
{
|
||||||
|
Id = AirAppMarketIndexDocument.NormalizeValue(Id)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"Market index '{sourceName}' is missing dependency id for a plugin entry."),
|
||||||
|
Version = AirAppMarketIndexDocument.NormalizeVersion(Version, nameof(Version), sourceName),
|
||||||
|
AssemblyName = AirAppMarketIndexDocument.NormalizeValue(AssemblyName)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"Market index '{sourceName}' is missing assemblyName for dependency '{Id}'.")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal sealed class AirAppMarketPluginEntry
|
internal sealed class AirAppMarketPluginEntry
|
||||||
{
|
{
|
||||||
public string Id { get; init; } = string.Empty;
|
public string Id { get; init; } = string.Empty;
|
||||||
@@ -476,6 +499,8 @@ internal sealed class AirAppMarketPluginEntry
|
|||||||
|
|
||||||
public List<string> Tags { get; init; } = [];
|
public List<string> Tags { get; init; } = [];
|
||||||
|
|
||||||
|
public List<AirAppMarketPluginDependencyEntry> SharedContracts { get; init; } = [];
|
||||||
|
|
||||||
public DateTimeOffset PublishedAt { get; init; }
|
public DateTimeOffset PublishedAt { get; init; }
|
||||||
|
|
||||||
public DateTimeOffset UpdatedAt { get; init; }
|
public DateTimeOffset UpdatedAt { get; init; }
|
||||||
@@ -495,6 +520,21 @@ internal sealed class AirAppMarketPluginEntry
|
|||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
.OrderBy(tag => tag, StringComparer.OrdinalIgnoreCase)
|
.OrderBy(tag => tag, StringComparer.OrdinalIgnoreCase)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
var normalizedDependencies = new List<AirAppMarketPluginDependencyEntry>((SharedContracts ?? []).Count);
|
||||||
|
var seenDependencies = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var dependency in SharedContracts ?? [])
|
||||||
|
{
|
||||||
|
var normalizedDependency = dependency.ValidateAndNormalize(sourceName);
|
||||||
|
var dependencyKey = $"{normalizedDependency.Id}@{normalizedDependency.Version}";
|
||||||
|
if (!seenDependencies.Add(dependencyKey))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Market index '{sourceName}' declares duplicate dependency '{dependencyKey}' for plugin '{Id}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedDependencies.Add(normalizedDependency);
|
||||||
|
}
|
||||||
|
|
||||||
var normalizedSha = AirAppMarketIndexDocument.NormalizeValue(Sha256)?.ToLowerInvariant()
|
var normalizedSha = AirAppMarketIndexDocument.NormalizeValue(Sha256)?.ToLowerInvariant()
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
@@ -590,6 +630,7 @@ internal sealed class AirAppMarketPluginEntry
|
|||||||
HomepageUrl = normalizedHomepageUrl,
|
HomepageUrl = normalizedHomepageUrl,
|
||||||
RepositoryUrl = normalizedRepositoryUrl,
|
RepositoryUrl = normalizedRepositoryUrl,
|
||||||
Tags = normalizedTags,
|
Tags = normalizedTags,
|
||||||
|
SharedContracts = normalizedDependencies,
|
||||||
PublishedAt = PublishedAt,
|
PublishedAt = PublishedAt,
|
||||||
UpdatedAt = UpdatedAt,
|
UpdatedAt = UpdatedAt,
|
||||||
ReleaseNotes = AirAppMarketIndexDocument.NormalizeValue(ReleaseNotes)
|
ReleaseNotes = AirAppMarketIndexDocument.NormalizeValue(ReleaseNotes)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ using System.Threading.Tasks;
|
|||||||
|
|
||||||
namespace LanMountainDesktop.Services.PluginMarket;
|
namespace LanMountainDesktop.Services.PluginMarket;
|
||||||
|
|
||||||
internal sealed class AirAppMarketReadmeService : IDisposable
|
public sealed class AirAppMarketReadmeService : IDisposable
|
||||||
{
|
{
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ internal sealed class AirAppMarketReadmeService : IDisposable
|
|||||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
|
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> LoadAsync(
|
internal async Task<string> LoadAsync(
|
||||||
AirAppMarketPluginEntry plugin,
|
AirAppMarketPluginEntry plugin,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -35,6 +35,22 @@ internal sealed class AirAppMarketReadmeService : IDisposable
|
|||||||
return await response.Content.ReadAsStringAsync(cancellationToken);
|
return await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<string> LoadAsync(
|
||||||
|
LanMountainDesktop.Services.Settings.PluginMarketPluginInfo plugin,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(plugin);
|
||||||
|
|
||||||
|
if (AirAppMarketDefaults.TryResolveWorkspaceFile(plugin.ReadmeUrl, out var localReadmePath))
|
||||||
|
{
|
||||||
|
return await File.ReadAllTextAsync(localReadmePath, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var response = await _httpClient.GetAsync(plugin.ReadmeUrl, cancellationToken);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
return await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_httpClient.Dispose();
|
_httpClient.Dispose();
|
||||||
|
|||||||
Reference in New Issue
Block a user