插件市场
This commit is contained in:
lincube
2026-03-10 09:55:49 +08:00
parent d33d8d3391
commit cdffaa16eb
45 changed files with 4483 additions and 365 deletions

View File

@@ -10,6 +10,7 @@
目录结构:
- `docs/`:插件开发文档、打包文档
- `plugins/`:第一方插件项目,例如插件市场插件
- `releases/`:已经打包完成、可直接分享与安装的 `.laapp` 插件包
- `samples/`:示例插件,其中 `LanMountainDesktop.SamplePlugin` 是示例开发插件
- `standards/`:插件标准文件与模板

View File

@@ -0,0 +1,41 @@
namespace LanMountainDesktop.PluginMarketplace;
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;
}
}
}

View File

@@ -0,0 +1,77 @@
using System.Net.Http.Headers;
namespace LanMountainDesktop.PluginMarketplace;
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;
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, 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,
networkError?.Message,
null);
}
catch (Exception cacheEx)
{
return new AirAppMarketLoadResult(
false,
null,
null,
null,
$"{networkError?.Message ?? "Unknown network error"} | Cached index invalid: {cacheEx.Message}");
}
}
return new AirAppMarketLoadResult(false, null, null, null, networkError?.Message ?? "Unknown network error");
}
public void Dispose()
{
_httpClient.Dispose();
}
}

View File

@@ -0,0 +1,83 @@
using System.Security.Cryptography;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.PluginMarketplace;
internal sealed class AirAppMarketInstallService : IDisposable
{
private readonly IPluginPackageManager _packageManager;
private readonly HttpClient _httpClient;
private readonly string _downloadsDirectory;
public AirAppMarketInstallService(IPluginPackageManager packageManager, string dataDirectory)
{
_packageManager = packageManager;
_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
{
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 installResult = _packageManager.InstallPackage(downloadPath);
return new AirAppMarketInstallResult(true, installResult, 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());
}
}

View File

@@ -0,0 +1,299 @@
using System.Globalization;
using System.Text.Json;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.PluginMarketplace;
internal static class AirAppMarketDefaults
{
public const string DefaultIndexUrl =
"https://raw.githubusercontent.com/wwiinnddyy/LanMountainDesktop/main/airappmarket/index.json";
}
internal enum AirAppMarketLoadSource
{
Network = 0,
Cache = 1
}
internal enum AirAppMarketInstallState
{
NotInstalled = 0,
UpdateAvailable = 1,
Installed = 2
}
internal sealed record AirAppMarketLoadResult(
bool Success,
AirAppMarketIndexDocument? Document,
AirAppMarketLoadSource? Source,
string? WarningMessage,
string? ErrorMessage);
internal sealed record AirAppMarketInstallResult(
bool Success,
PluginPackageInstallResult? InstallResult,
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);
}
}

View File

@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>1.0.0</Version>
<EnableDynamicLoading>true</EnableDynamicLoading>
<OutputPath>bin\$(Configuration)\$(TargetFramework)\content\</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<PluginPackageOutputDirectory>..\..\..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\</PluginPackageOutputDirectory>
<PluginPackagePath>$(PluginPackageOutputDirectory)$(AssemblyName).laapp</PluginPackagePath>
<PluginReleaseOutputDirectory>..\..\releases\</PluginReleaseOutputDirectory>
<PluginReleasePackagePath>$(PluginReleaseOutputDirectory)$(AssemblyName).$(Version).laapp</PluginReleasePackagePath>
<LegacyLoosePluginOutputDirectory>..\..\..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\PluginMarketplace\</LegacyLoosePluginOutputDirectory>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" Private="false" />
<None Include="plugin.json" CopyToOutputDirectory="PreserveNewest" />
<None Include="Localization\*.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<Target Name="CreateLaappPackage" AfterTargets="Build">
<MakeDir Directories="$(PluginPackageOutputDirectory)" />
<MakeDir Directories="$(PluginReleaseOutputDirectory)" />
<RemoveDir Directories="$(LegacyLoosePluginOutputDirectory)" />
<Delete Files="$(PluginPackagePath)" TreatErrorsAsWarnings="true" />
<Delete Files="$(PluginReleasePackagePath)" TreatErrorsAsWarnings="true" />
<ZipDirectory SourceDirectory="$(OutputPath)" DestinationFile="$(PluginPackagePath)" />
<Copy SourceFiles="$(PluginPackagePath)" DestinationFiles="$(PluginReleasePackagePath)" />
</Target>
</Project>

View File

@@ -0,0 +1,37 @@
{
"market.page_title": "Plugin Marketplace",
"market.toolbar.search_placeholder": "Search plugins",
"market.toolbar.refresh": "Refresh",
"market.status.loading": "Loading the official plugin marketplace...",
"market.status.loaded_network_format": "Loaded {0} plugin(s) from the official source.",
"market.status.loaded_cache_format": "Official source unavailable. Loaded {0} plugin(s) from cache. Reason: {1}",
"market.status.load_failed_format": "Failed to load plugin marketplace: {0}",
"market.status.installing_format": "Downloading and staging plugin '{0}'...",
"market.status.install_success_format": "Plugin '{0}' has been staged. Restart the app to apply it.",
"market.status.install_failed_format": "Failed to install plugin: {0}",
"market.status.host_incompatible_format": "This host is too old. Version {0} or newer is required.",
"market.list.empty": "The plugin marketplace has not been loaded yet.",
"market.list.no_results": "No plugins match the current search.",
"market.card.subtitle_format": "{0} · v{1}",
"market.card.loaded": "Loaded",
"market.card.pending_restart": "Restart required",
"market.detail.placeholder": "Select a plugin from the left to inspect details.",
"market.detail.author": "Author",
"market.detail.version": "Version",
"market.detail.api_version": "API Version",
"market.detail.min_host_version": "Minimum Host Version",
"market.detail.installed_version": "Installed Version",
"market.detail.not_installed": "Not installed",
"market.detail.market_source": "Market Source",
"market.detail.homepage": "Homepage",
"market.detail.repository": "Repository",
"market.detail.release_notes": "Release Notes",
"market.detail.state.not_installed": "Not installed",
"market.detail.state.update_available": "Update available",
"market.detail.state.installed": "Installed",
"market.detail.unknown": "Unknown",
"market.button.install": "Install",
"market.button.update": "Update",
"market.button.installed": "Installed",
"market.button.installing": "Installing..."
}

View File

@@ -0,0 +1,37 @@
{
"market.page_title": "插件市场",
"market.toolbar.search_placeholder": "搜索插件",
"market.toolbar.refresh": "刷新",
"market.status.loading": "正在加载官方插件市场…",
"market.status.loaded_network_format": "已从官方源加载 {0} 个插件。",
"market.status.loaded_cache_format": "官方源不可用,已从缓存加载 {0} 个插件。原因:{1}",
"market.status.load_failed_format": "加载插件市场失败:{0}",
"market.status.installing_format": "正在下载并暂存插件“{0}”…",
"market.status.install_success_format": "插件“{0}”已暂存完成,重启应用后生效。",
"market.status.install_failed_format": "安装插件失败:{0}",
"market.status.host_incompatible_format": "当前宿主版本过低,至少需要 {0}。",
"market.list.empty": "插件市场尚未加载。",
"market.list.no_results": "没有匹配的插件。",
"market.card.subtitle_format": "{0} · v{1}",
"market.card.loaded": "已加载",
"market.card.pending_restart": "需重启",
"market.detail.placeholder": "从左侧选择一个插件以查看详情。",
"market.detail.author": "作者",
"market.detail.version": "版本",
"market.detail.api_version": "API 版本",
"market.detail.min_host_version": "最低宿主版本",
"market.detail.installed_version": "当前已安装版本",
"market.detail.not_installed": "未安装",
"market.detail.market_source": "市场源",
"market.detail.homepage": "主页",
"market.detail.repository": "仓库",
"market.detail.release_notes": "发布说明",
"market.detail.state.not_installed": "未安装",
"market.detail.state.update_available": "可更新",
"market.detail.state.installed": "已安装",
"market.detail.unknown": "未知",
"market.button.install": "安装",
"market.button.update": "更新",
"market.button.installed": "已安装",
"market.button.installing": "安装中…"
}

View File

@@ -0,0 +1,47 @@
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.PluginMarketplace;
[PluginEntrance]
public sealed class PluginMarketplacePlugin : PluginBase, IDisposable
{
private AirAppMarketIndexService? _indexService;
private AirAppMarketInstallService? _installService;
public override void Initialize(IPluginContext context)
{
Directory.CreateDirectory(context.DataDirectory);
var localizer = PluginLocalizer.Create(context);
var packageManager = context.GetService<IPluginPackageManager>()
?? throw new InvalidOperationException(
"The host does not expose IPluginPackageManager. LanMountainDesktop.PluginMarketplace requires a newer host build.");
var cacheService = new AirAppMarketCacheService(context.DataDirectory);
_indexService = new AirAppMarketIndexService(cacheService);
_installService = new AirAppMarketInstallService(packageManager, context.DataDirectory);
context.RegisterService(cacheService);
context.RegisterService(_indexService);
context.RegisterService(_installService);
context.RegisterSettingsPage(new PluginSettingsPageRegistration(
"marketplace",
localizer.GetString("market.page_title", "插件市场"),
() => new PluginMarketplaceSettingsView(
context,
localizer,
packageManager,
_indexService,
_installService),
sortOrder: -100));
}
public void Dispose()
{
_installService?.Dispose();
_indexService?.Dispose();
_installService = null;
_indexService = null;
}
}

View File

@@ -0,0 +1,727 @@
using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.PluginMarketplace;
internal sealed class PluginMarketplaceSettingsView : UserControl
{
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 PluginLocalizer _localizer;
private readonly IPluginPackageManager _packageManager;
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, InstalledPluginInfo> _installedPlugins = new(StringComparer.OrdinalIgnoreCase);
private bool _isRefreshing;
private bool _isInstalling;
private bool _hasLoadedOnce;
public PluginMarketplaceSettingsView(
IPluginContext context,
PluginLocalizer localizer,
IPluginPackageManager packageManager,
AirAppMarketIndexService indexService,
AirAppMarketInstallService installService)
{
_localizer = localizer;
_packageManager = packageManager;
_indexService = indexService;
_installService = installService;
_hostVersion = context.TryGetProperty<string>(PluginHostPropertyKeys.HostVersion, out var hostVersionText) &&
AirAppMarketIndexDocument.TryParseVersion(hostVersionText, out var parsedHostVersion)
? parsedHostVersion
: null;
_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();
};
}
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
{
_installedPlugins = _packageManager
.GetInstalledPlugins()
.ToDictionary(plugin => plugin.Manifest.Id, StringComparer.OrdinalIgnoreCase);
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;
_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", "市场源"), AirAppMarketDefaults.DefaultIndexUrl),
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.InstallResult is null)
{
SetStatus(
F(
"market.status.install_failed_format",
"安装插件失败:{0}",
result.ErrorMessage ?? T("market.detail.unknown", "未知错误")),
ErrorBrush);
return;
}
_installedPlugins = _packageManager
.GetInstalledPlugins()
.ToDictionary(item => item.Manifest.Id, StringComparer.OrdinalIgnoreCase);
SetStatus(
F(
"market.status.install_success_format",
"插件“{0}”已暂存完成,重启应用后生效。",
result.InstallResult.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 InstalledPluginInfo? 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)
{
return _localizer.GetString(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 => "已安装",
_ => "安装"
};
}
}

View File

@@ -0,0 +1,9 @@
{
"id": "LanMountainDesktop.PluginMarketplace",
"name": "LanMountain Plugin Marketplace",
"description": "Official plugin marketplace for browsing and installing LanMountainDesktop plugins.",
"author": "LanMountainDesktop",
"version": "1.0.0",
"apiVersion": "1.0.0",
"entranceAssembly": "LanMountainDesktop.PluginMarketplace.dll"
}