mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 09:14:25 +08:00
0.5.9
中文与插件市场
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
42
LanMountainDesktop/plugins/PluginMarketReadmeService.cs
Normal file
42
LanMountainDesktop/plugins/PluginMarketReadmeService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user