中文与插件市场
This commit is contained in:
lincube
2026-03-10 12:14:49 +08:00
parent cdffaa16eb
commit 85f7a18cbc
24 changed files with 804 additions and 1443 deletions

View File

@@ -27,6 +27,7 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
private readonly PluginRuntimeService _runtime;
private readonly AirAppMarketIndexService _indexService;
private readonly AirAppMarketInstallService _installService;
private readonly AirAppMarketReadmeService _readmeService;
private readonly Version? _hostVersion;
private readonly TextBox _searchTextBox;
@@ -38,7 +39,10 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
private AirAppMarketIndexDocument? _document;
private AirAppMarketPluginEntry? _selectedPlugin;
private Dictionary<string, PluginCatalogEntry> _installedPlugins = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, string> _readmeContents = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, string> _readmeErrors = new(StringComparer.OrdinalIgnoreCase);
private string _marketSourceDisplay = AirAppMarketDefaults.DefaultIndexUrl;
private string? _loadingReadmePluginId;
private bool _isRefreshing;
private bool _isInstalling;
private bool _hasLoadedOnce;
@@ -49,6 +53,7 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
var dataDirectory = Path.Combine(AppContext.BaseDirectory, "Data", "AirAppMarket");
_indexService = new AirAppMarketIndexService(new AirAppMarketCacheService(dataDirectory));
_installService = new AirAppMarketInstallService(runtime, dataDirectory);
_readmeService = new AirAppMarketReadmeService();
_hostVersion = typeof(App).Assembly.GetName().Version;
_searchTextBox = new TextBox
@@ -114,6 +119,7 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
public void Dispose()
{
_readmeService.Dispose();
_installService.Dispose();
_indexService.Dispose();
}
@@ -223,6 +229,7 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
SetStatus(statusMessage, result.Source == AirAppMarketLoadSource.Cache ? WarningBrush : SuccessBrush);
RebuildSurface();
await EnsureReadmeLoadedAsync(_selectedPlugin);
}
finally
{
@@ -245,6 +252,7 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
BuildPluginList(filteredPlugins);
BuildDetailPanel();
_ = EnsureReadmeLoadedAsync(_selectedPlugin);
}
private List<AirAppMarketPluginEntry> GetFilteredPlugins()
@@ -372,10 +380,11 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
}
};
button.Click += (_, _) =>
button.Click += async (_, _) =>
{
_selectedPlugin = plugin;
RebuildSurface();
await EnsureReadmeLoadedAsync(plugin);
};
return button;
@@ -454,11 +463,12 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
CreateInfoRow(T("market.detail.min_host_version", "最低宿主版本"), plugin.MinHostVersion),
CreateInfoRow(T("market.detail.installed_version", "当前已安装版本"), installedPlugin?.Manifest.Version ?? T("market.detail.not_installed", "未安装")),
CreateInfoRow(T("market.detail.market_source", "市场源"), _marketSourceDisplay),
CreateInfoRow(T("market.detail.project", "Project"), plugin.ProjectUrl),
CreateInfoRow(T("market.detail.homepage", "主页"), plugin.HomepageUrl),
CreateInfoRow(T("market.detail.repository", "仓库"), plugin.RepositoryUrl),
new TextBlock
{
Text = T("market.detail.release_notes", "发布说明"),
Text = T("market.detail.readme", "README"),
FontSize = 18,
FontWeight = FontWeight.SemiBold
},
@@ -469,7 +479,7 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
Padding = new Thickness(14),
Child = new TextBlock
{
Text = plugin.ReleaseNotes,
Text = GetReadmeContent(plugin),
TextWrapping = TextWrapping.Wrap
}
}
@@ -540,6 +550,63 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
}
}
private async Task EnsureReadmeLoadedAsync(AirAppMarketPluginEntry? plugin)
{
if (plugin is null ||
_readmeContents.ContainsKey(plugin.Id) ||
string.Equals(_loadingReadmePluginId, plugin.Id, StringComparison.OrdinalIgnoreCase))
{
return;
}
_loadingReadmePluginId = plugin.Id;
_readmeErrors.Remove(plugin.Id);
BuildDetailPanel();
try
{
var readme = await _readmeService.LoadAsync(plugin);
_readmeContents[plugin.Id] = string.IsNullOrWhiteSpace(readme)
? T("market.detail.readme_empty", "README is empty.")
: readme.Trim();
}
catch (Exception ex)
{
_readmeErrors[plugin.Id] = ex.Message;
}
finally
{
_loadingReadmePluginId = null;
if (string.Equals(_selectedPlugin?.Id, plugin.Id, StringComparison.OrdinalIgnoreCase))
{
BuildDetailPanel();
}
}
}
private string GetReadmeContent(AirAppMarketPluginEntry plugin)
{
if (_readmeContents.TryGetValue(plugin.Id, out var readme))
{
return readme;
}
if (_readmeErrors.TryGetValue(plugin.Id, out var error))
{
return F(
"market.detail.readme_error_format",
"README could not be loaded: {0}",
error);
}
if (string.Equals(_loadingReadmePluginId, plugin.Id, StringComparison.OrdinalIgnoreCase))
{
return T("market.detail.readme_loading", "Loading README...");
}
return plugin.ReleaseNotes;
}
private AirAppMarketPluginEntry? ResolveSelectedPlugin(
string? selectedPluginId,
IReadOnlyList<AirAppMarketPluginEntry> plugins)

View File

@@ -14,6 +14,7 @@ internal sealed class AirAppMarketInstallService : IDisposable
{
private readonly PluginRuntimeService _runtime;
private readonly HttpClient _httpClient;
private readonly AirAppMarketReleaseResolverService _releaseResolverService;
private readonly string _downloadsDirectory;
public AirAppMarketInstallService(PluginRuntimeService runtime, string dataDirectory)
@@ -25,6 +26,7 @@ internal sealed class AirAppMarketInstallService : IDisposable
Timeout = TimeSpan.FromMinutes(2)
};
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
_releaseResolverService = new AirAppMarketReleaseResolverService(_httpClient);
}
public async Task<AirAppMarketInstallResult> InstallAsync(
@@ -40,7 +42,9 @@ internal sealed class AirAppMarketInstallService : IDisposable
try
{
if (AirAppMarketDefaults.TryResolveWorkspaceFile(plugin.DownloadUrl, out var localPackagePath))
var resolvedDownloadUrl = await _releaseResolverService.ResolveDownloadUrlAsync(plugin, cancellationToken);
if (AirAppMarketDefaults.TryResolveWorkspaceFile(resolvedDownloadUrl, out var localPackagePath))
{
await using var sourceStream = File.OpenRead(localPackagePath);
await using var destinationStream = File.Create(downloadPath);
@@ -49,7 +53,7 @@ internal sealed class AirAppMarketInstallService : IDisposable
else
{
using var response = await _httpClient.GetAsync(
plugin.DownloadUrl,
resolvedDownloadUrl,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
response.EnsureSuccessStatusCode();

View File

@@ -13,11 +13,25 @@ internal static class AirAppMarketDefaults
public const string DefaultIndexUrl =
"https://raw.githubusercontent.com/wwiinnddyy/LanAirApp/main/airappmarket/index.json";
private const string RawGitHubLanAirAppPathPrefix = "/wwiinnddyy/LanAirApp/main/";
public static string BuildGitHubReleaseDownloadUrl(
string owner,
string repositoryName,
string releaseTag,
string assetName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(owner);
ArgumentException.ThrowIfNullOrWhiteSpace(repositoryName);
ArgumentException.ThrowIfNullOrWhiteSpace(releaseTag);
ArgumentException.ThrowIfNullOrWhiteSpace(assetName);
return string.Create(
CultureInfo.InvariantCulture,
$"https://github.com/{owner.Trim()}/{repositoryName.Trim()}/releases/download/{Uri.EscapeDataString(releaseTag.Trim())}/{Uri.EscapeDataString(assetName.Trim())}");
}
public static string? TryGetWorkspaceIndexPath()
{
var repositoryRoot = TryGetWorkspaceLanAirAppRepositoryRoot();
var repositoryRoot = TryGetWorkspaceRepositoryRoot("LanAirApp");
if (repositoryRoot is null)
{
return null;
@@ -31,17 +45,24 @@ internal static class AirAppMarketDefaults
{
localPath = string.Empty;
var repositoryRoot = TryGetWorkspaceLanAirAppRepositoryRoot();
if (repositoryRoot is null ||
!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
!string.Equals(uri.Host, "raw.githubusercontent.com", StringComparison.OrdinalIgnoreCase) ||
!uri.AbsolutePath.StartsWith(RawGitHubLanAirAppPathPrefix, StringComparison.OrdinalIgnoreCase))
string repositoryName;
string relativePath;
if (TryParseGitHubReleaseDownloadUrl(url, out repositoryName, out var releaseAssetName))
{
relativePath = releaseAssetName;
}
else if (!TryParseRawGitHubUrl(url, out repositoryName, out relativePath))
{
return false;
}
var repositoryRoot = TryGetWorkspaceRepositoryRoot(repositoryName);
if (repositoryRoot is null)
{
return false;
}
var relativePath = Uri.UnescapeDataString(uri.AbsolutePath[RawGitHubLanAirAppPathPrefix.Length..])
.Replace('/', Path.DirectorySeparatorChar);
var candidatePath = Path.GetFullPath(Path.Combine(repositoryRoot, relativePath));
if (!File.Exists(candidatePath))
{
@@ -52,13 +73,39 @@ internal static class AirAppMarketDefaults
return true;
}
private static string? TryGetWorkspaceLanAirAppRepositoryRoot()
public static bool TryParseGitHubRepositoryUrl(
string? url,
out string owner,
out string repositoryName)
{
owner = string.Empty;
repositoryName = string.Empty;
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
!string.Equals(uri.Host, "github.com", StringComparison.OrdinalIgnoreCase))
{
return false;
}
var segments = uri.AbsolutePath
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (segments.Length != 2)
{
return false;
}
owner = segments[0];
repositoryName = segments[1];
return !string.IsNullOrWhiteSpace(owner) && !string.IsNullOrWhiteSpace(repositoryName);
}
private static string? TryGetWorkspaceRepositoryRoot(string repositoryName)
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
var candidate = Path.Combine(current.FullName, "LanAirApp");
if (File.Exists(Path.Combine(candidate, "airappmarket", "index.json")))
var candidate = Path.Combine(current.FullName, repositoryName);
if (Directory.Exists(candidate))
{
return candidate;
}
@@ -68,6 +115,60 @@ internal static class AirAppMarketDefaults
return null;
}
private static bool TryParseRawGitHubUrl(
string url,
out string repositoryName,
out string relativePath)
{
repositoryName = string.Empty;
relativePath = string.Empty;
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
!string.Equals(uri.Host, "raw.githubusercontent.com", StringComparison.OrdinalIgnoreCase))
{
return false;
}
var segments = uri.AbsolutePath
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (segments.Length < 4)
{
return false;
}
repositoryName = segments[1];
relativePath = Path.Combine(segments[3..]).Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
return !string.IsNullOrWhiteSpace(repositoryName) && !string.IsNullOrWhiteSpace(relativePath);
}
private static bool TryParseGitHubReleaseDownloadUrl(
string url,
out string repositoryName,
out string assetName)
{
repositoryName = string.Empty;
assetName = string.Empty;
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
!string.Equals(uri.Host, "github.com", StringComparison.OrdinalIgnoreCase))
{
return false;
}
var segments = uri.AbsolutePath
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (segments.Length != 6 ||
!string.Equals(segments[2], "releases", StringComparison.OrdinalIgnoreCase) ||
!string.Equals(segments[3], "download", StringComparison.OrdinalIgnoreCase))
{
return false;
}
repositoryName = segments[1];
assetName = Uri.UnescapeDataString(segments[5]);
return !string.IsNullOrWhiteSpace(repositoryName) && !string.IsNullOrWhiteSpace(assetName);
}
}
internal enum AirAppMarketLoadSource
@@ -193,6 +294,24 @@ internal sealed class AirAppMarketIndexDocument
return normalized;
}
internal static string NormalizeReleaseTag(string? value, string propertyName, string sourceName)
{
var normalized = RequireValue(value, propertyName, sourceName);
if (!normalized.StartsWith("v", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
$"Market index '{sourceName}' declares invalid release tag '{normalized}' for '{propertyName}'. Expected format 'v1.2.3'.");
}
if (!TryParseVersion(normalized[1..], out _))
{
throw new InvalidOperationException(
$"Market index '{sourceName}' declares invalid release tag '{normalized}' for '{propertyName}'.");
}
return normalized;
}
internal static void EnsureUrl(string url, string propertyName, string sourceName)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
@@ -203,6 +322,24 @@ internal sealed class AirAppMarketIndexDocument
}
}
internal static string NormalizeGitHubRepositoryUrl(
string url,
string propertyName,
string sourceName)
{
EnsureUrl(url, propertyName, sourceName);
if (!AirAppMarketDefaults.TryParseGitHubRepositoryUrl(url, out var owner, out var repositoryName))
{
throw new InvalidOperationException(
$"Market index '{sourceName}' declares invalid GitHub repository url '{url}' for '{propertyName}'.");
}
return string.Create(
CultureInfo.InvariantCulture,
$"https://github.com/{owner}/{repositoryName}");
}
internal static bool TryParseVersion(string? value, out Version? version)
{
version = null;
@@ -260,6 +397,14 @@ internal sealed class AirAppMarketPluginEntry
public string IconUrl { get; init; } = string.Empty;
public string ReleaseTag { get; init; } = string.Empty;
public string ReleaseAssetName { get; init; } = string.Empty;
public string ProjectUrl { get; init; } = string.Empty;
public string ReadmeUrl { get; init; } = string.Empty;
public string HomepageUrl { get; init; } = string.Empty;
public string RepositoryUrl { get; init; } = string.Empty;
@@ -272,6 +417,10 @@ internal sealed class AirAppMarketPluginEntry
public string ReleaseNotes { get; init; } = string.Empty;
public bool HasReleaseDownloadMetadata =>
!string.IsNullOrWhiteSpace(ReleaseTag) &&
!string.IsNullOrWhiteSpace(ReleaseAssetName);
public AirAppMarketPluginEntry ValidateAndNormalize(string sourceName)
{
var normalizedTags = (Tags ?? [])
@@ -298,6 +447,14 @@ internal sealed class AirAppMarketPluginEntry
var normalizedIconUrl = AirAppMarketIndexDocument.NormalizeValue(IconUrl)
?? throw new InvalidOperationException(
$"Market index '{sourceName}' is missing required property '{nameof(IconUrl)}'.");
var normalizedReleaseTag = AirAppMarketIndexDocument.NormalizeValue(ReleaseTag);
var normalizedReleaseAssetName = AirAppMarketIndexDocument.NormalizeValue(ReleaseAssetName);
var normalizedProjectUrl = AirAppMarketIndexDocument.NormalizeValue(ProjectUrl)
?? throw new InvalidOperationException(
$"Market index '{sourceName}' is missing required property '{nameof(ProjectUrl)}'.");
var normalizedReadmeUrl = AirAppMarketIndexDocument.NormalizeValue(ReadmeUrl)
?? throw new InvalidOperationException(
$"Market index '{sourceName}' is missing required property '{nameof(ReadmeUrl)}'.");
var normalizedHomepageUrl = AirAppMarketIndexDocument.NormalizeValue(HomepageUrl)
?? throw new InvalidOperationException(
$"Market index '{sourceName}' is missing required property '{nameof(HomepageUrl)}'.");
@@ -307,8 +464,30 @@ internal sealed class AirAppMarketPluginEntry
AirAppMarketIndexDocument.EnsureUrl(normalizedDownloadUrl, nameof(DownloadUrl), sourceName);
AirAppMarketIndexDocument.EnsureUrl(normalizedIconUrl, nameof(IconUrl), sourceName);
normalizedProjectUrl = AirAppMarketIndexDocument.NormalizeGitHubRepositoryUrl(
normalizedProjectUrl,
nameof(ProjectUrl),
sourceName);
normalizedRepositoryUrl = AirAppMarketIndexDocument.NormalizeGitHubRepositoryUrl(
normalizedRepositoryUrl,
nameof(RepositoryUrl),
sourceName);
AirAppMarketIndexDocument.EnsureUrl(normalizedReadmeUrl, nameof(ReadmeUrl), sourceName);
AirAppMarketIndexDocument.EnsureUrl(normalizedHomepageUrl, nameof(HomepageUrl), sourceName);
AirAppMarketIndexDocument.EnsureUrl(normalizedRepositoryUrl, nameof(RepositoryUrl), sourceName);
if (string.IsNullOrWhiteSpace(normalizedReleaseTag) != string.IsNullOrWhiteSpace(normalizedReleaseAssetName))
{
throw new InvalidOperationException(
$"Market index '{sourceName}' must declare both '{nameof(ReleaseTag)}' and '{nameof(ReleaseAssetName)}' together for plugin '{Id}'.");
}
if (!string.IsNullOrWhiteSpace(normalizedReleaseTag))
{
normalizedReleaseTag = AirAppMarketIndexDocument.NormalizeReleaseTag(
normalizedReleaseTag,
nameof(ReleaseTag),
sourceName);
}
if (PackageSizeBytes <= 0)
{
@@ -339,6 +518,10 @@ internal sealed class AirAppMarketPluginEntry
Sha256 = normalizedSha,
PackageSizeBytes = PackageSizeBytes,
IconUrl = normalizedIconUrl,
ReleaseTag = normalizedReleaseTag ?? string.Empty,
ReleaseAssetName = normalizedReleaseAssetName ?? string.Empty,
ProjectUrl = normalizedProjectUrl,
ReadmeUrl = normalizedReadmeUrl,
HomepageUrl = normalizedHomepageUrl,
RepositoryUrl = normalizedRepositoryUrl,
Tags = normalizedTags,

View File

@@ -0,0 +1,42 @@
using System;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace LanMountainDesktop.Views.SettingsPages;
internal sealed class AirAppMarketReadmeService : IDisposable
{
private readonly HttpClient _httpClient;
public AirAppMarketReadmeService()
{
_httpClient = new HttpClient
{
Timeout = TimeSpan.FromSeconds(20)
};
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
}
public async Task<string> LoadAsync(
AirAppMarketPluginEntry 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()
{
_httpClient.Dispose();
}
}

View File

@@ -0,0 +1,72 @@
using System;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.SettingsPages;
internal sealed class AirAppMarketReleaseResolverService
{
private readonly HttpClient _httpClient;
public AirAppMarketReleaseResolverService(HttpClient httpClient)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
}
public async Task<string> ResolveDownloadUrlAsync(
AirAppMarketPluginEntry plugin,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(plugin);
if (!plugin.HasReleaseDownloadMetadata)
{
return plugin.DownloadUrl;
}
if (!TryGetRepositoryIdentity(plugin, out var owner, out var repositoryName))
{
return plugin.DownloadUrl;
}
var releaseDownloadUrl = AirAppMarketDefaults.BuildGitHubReleaseDownloadUrl(
owner,
repositoryName,
plugin.ReleaseTag,
plugin.ReleaseAssetName);
if (AirAppMarketDefaults.TryResolveWorkspaceFile(releaseDownloadUrl, out _))
{
return releaseDownloadUrl;
}
try
{
using var updateService = new GitHubReleaseUpdateService(owner, repositoryName, _httpClient);
var release = await updateService.GetReleaseByTagAsync(plugin.ReleaseTag, cancellationToken);
var asset = release?.Assets.FirstOrDefault(candidate =>
string.Equals(candidate.Name, plugin.ReleaseAssetName, StringComparison.OrdinalIgnoreCase));
return asset?.BrowserDownloadUrl ?? plugin.DownloadUrl;
}
catch
{
return plugin.DownloadUrl;
}
}
private static bool TryGetRepositoryIdentity(
AirAppMarketPluginEntry plugin,
out string owner,
out string repositoryName)
{
owner = string.Empty;
repositoryName = string.Empty;
return AirAppMarketDefaults.TryParseGitHubRepositoryUrl(plugin.RepositoryUrl, out owner, out repositoryName) ||
AirAppMarketDefaults.TryParseGitHubRepositoryUrl(plugin.ProjectUrl, out owner, out repositoryName);
}
}

View File

@@ -1,33 +1,57 @@
# 宿主侧插件运行时
这个目录用于归档阑山桌面宿主侧的插件相关实现。
## 中文
职责范围:
- 已安装插件的发现
- `.laapp` 安装包安装与替换
- 插件运行时加载
- 插件贡献的设置页与桌面组件接入
- 宿主侧插件设置页的安装、显示与刷新
本目录保存阑山桌面宿主程序中的插件运行时实现。
### 主要职责
- 发现已安装插件
- 安装和替换 `.laapp` 插件包
- 加载插件程序集
- 接入插件贡献的设置页和桌面组件
- 在宿主设置界面中展示插件与市场信息
### 市场安装优先级
1. 宿主先连接 `LanAirApp/airappmarket/index.json`
2. 当条目同时提供 `releaseTag``releaseAssetName` 时,宿主优先按精确标签读取插件仓库的 GitHub Release 资产。
3. 如果 Release 不存在、资产缺失、GitHub API 失败,或当前是本地工作区测试但找不到远程资产,宿主会退回 `downloadUrl` 指向的仓库根目录 `.laapp`
4. 插件介绍始终读取仓库根目录 `README.md`
5. 安装完成后只做暂存,重启后生效,不在运行时热重载市场安装插件。
### 核心文件
当前宿主侧核心文件:
- `PluginLoader.cs`
- `PluginLoadContext.cs`
- `PluginLoaderOptions.cs`
- `PluginLoadResult.cs`
- `LoadedPlugin.cs`
- `PluginRuntimeService.cs`
- `PluginContributions.cs`
- `PluginCatalogEntry.cs`
- `PluginSettingsPage.axaml`
- `PluginSettingsPage.Host.cs`
- `MainWindow.PluginSettingsHost.cs`
- `SettingsWindow.PluginSettingsHost.cs`
- `MainWindow.PluginSettingsLocalization.cs`
- `SettingsWindow.PluginSettingsLocalization.cs`
- `MainWindow.PluginSettingsControls.cs`
- `SettingsWindow.PluginSettingsControls.cs`
- `PluginMarketIndexService.cs`
- `PluginMarketInstallService.cs`
说明:
- 插件开发标准、插件打包工具、示例插件与开发文档统一放在仓库根目录下的 `LanAirApp/`
- 宿主本体的插件加载、解析、安装与插件设置页接入逻辑统一放在 `LanMountainDesktop/plugins/`
- `LanMountainDesktop.PluginSdk` 只保留插件作者需要引用的契约、清单模型和扩展注册接口
### 与 `LanAirApp` 的分工
- `LanAirApp` 负责插件开发文档、示例、市场索引和校验工具。
- 宿主目录负责运行时发现、安装、加载和界面接入。
## English
This directory contains the host-side plugin runtime for LanMountainDesktop.
### Responsibilities
- discover installed plugins
- install and replace `.laapp` packages
- load plugin assemblies
- integrate plugin settings pages and desktop components
- expose market and plugin management in the host UI
### Market install order
1. The host reads `LanAirApp/airappmarket/index.json`.
2. If an entry declares both `releaseTag` and `releaseAssetName`, the host first resolves the exact GitHub Release asset.
3. If Release resolution fails, the host falls back to the repository root `.laapp` from `downloadUrl`.
4. Plugin details always come from the repository root `README.md`.
5. Market installs are staged and take effect after restart.