更新功能优化、插件市场优化,反正就是优化了很多东西
This commit is contained in:
lincube
2026-03-25 11:27:30 +08:00
parent 74703582e7
commit 372b5b7adc
28 changed files with 1360 additions and 572 deletions

View File

@@ -0,0 +1,391 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.Services.PluginMarket;
internal sealed class AirAppMarketMetadataResolverService : IDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
private readonly HttpClient _httpClient;
private readonly bool _ownsHttpClient;
private readonly ConcurrentDictionary<string, string> _defaultBranchCache = new(StringComparer.OrdinalIgnoreCase);
public AirAppMarketMetadataResolverService(HttpClient? httpClient = null)
{
if (httpClient is null)
{
_httpClient = new HttpClient
{
Timeout = TimeSpan.FromSeconds(20)
};
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
_httpClient.DefaultRequestHeaders.Accept.Add(
new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
_ownsHttpClient = true;
}
else
{
_httpClient = httpClient;
_ownsHttpClient = false;
}
}
public async Task<AirAppMarketIndexDocument> EnrichAsync(
AirAppMarketIndexDocument document,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(document);
if (document.Plugins.Count == 0)
{
return document;
}
var enrichedPlugins = new List<AirAppMarketPluginEntry>(document.Plugins.Count);
foreach (var plugin in document.Plugins)
{
enrichedPlugins.Add(await EnrichPluginAsync(plugin, cancellationToken).ConfigureAwait(false));
}
return new AirAppMarketIndexDocument
{
SchemaVersion = document.SchemaVersion,
SourceId = document.SourceId,
SourceName = document.SourceName,
GeneratedAt = document.GeneratedAt,
Contracts = document.Contracts,
Plugins = enrichedPlugins
};
}
public void Dispose()
{
if (_ownsHttpClient)
{
_httpClient.Dispose();
}
}
private async Task<AirAppMarketPluginEntry> EnrichPluginAsync(
AirAppMarketPluginEntry entry,
CancellationToken cancellationToken)
{
if (!AirAppMarketDefaults.TryParseGitHubRepositoryUrl(entry.RepositoryUrl, out var owner, out var repositoryName) &&
!AirAppMarketDefaults.TryParseGitHubRepositoryUrl(entry.ProjectUrl, out owner, out repositoryName))
{
return entry;
}
var branchCandidates = await GetBranchCandidatesAsync(owner, repositoryName, cancellationToken).ConfigureAwait(false);
PluginManifest? manifest = null;
AirAppMarketRepositoryTemplate? template = null;
foreach (var branch in branchCandidates)
{
manifest ??= await TryLoadPluginManifestAsync(owner, repositoryName, branch, cancellationToken).ConfigureAwait(false);
template ??= await TryLoadTemplateAsync(owner, repositoryName, branch, cancellationToken).ConfigureAwait(false);
if (manifest is not null && template is not null)
{
break;
}
}
var repository = entry.Repository ?? new AirAppMarketPluginRepositoryEntry();
var resolvedManifest = manifest;
var resolvedPackageSources = entry.PackageSources.Count > 0
? entry.PackageSources
: entry.Publication?.PackageSources ?? [];
var firstPackageSourceUrl = resolvedPackageSources.FirstOrDefault()?.Url ?? entry.DownloadUrl;
return new AirAppMarketPluginEntry
{
PluginId = AirAppMarketIndexDocument.NormalizeValue(entry.PluginId) ?? entry.PluginId,
Manifest = resolvedManifest is null
? entry.Manifest
: new AirAppMarketPluginManifestEntry
{
Id = resolvedManifest.Id,
Name = resolvedManifest.Name,
Description = resolvedManifest.Description ?? string.Empty,
Author = resolvedManifest.Author ?? string.Empty,
Version = resolvedManifest.Version ?? string.Empty,
ApiVersion = resolvedManifest.ApiVersion ?? string.Empty,
EntranceAssembly = resolvedManifest.EntranceAssembly,
SharedContracts = resolvedManifest.SharedContracts?
.Select(contract => new AirAppMarketPluginDependencyEntry
{
Id = contract.Id,
Version = contract.Version,
AssemblyName = contract.AssemblyName
})
.ToList()
?? []
},
Compatibility = entry.Compatibility is not null || template is not null || !string.IsNullOrWhiteSpace(entry.MinHostVersion) || !string.IsNullOrWhiteSpace(entry.ApiVersion)
? new AirAppMarketPluginCompatibilityEntry
{
MinHostVersion = FirstNonEmpty(
template?.MinHostVersion,
entry.MinHostVersion),
PluginApiVersion = FirstNonEmpty(
resolvedManifest?.ApiVersion,
entry.ApiVersion)
?? string.Empty
}
: null,
Repository = new AirAppMarketPluginRepositoryEntry
{
IconUrl = FirstNonEmpty(template?.IconUrl, repository.IconUrl, entry.IconUrl) ?? string.Empty,
ProjectUrl = FirstNonEmpty(template?.ProjectUrl, repository.ProjectUrl, entry.ProjectUrl) ?? string.Empty,
ReadmeUrl = FirstNonEmpty(template?.ReadmeUrl, repository.ReadmeUrl, entry.ReadmeUrl) ?? string.Empty,
HomepageUrl = FirstNonEmpty(template?.HomepageUrl, repository.HomepageUrl, entry.HomepageUrl) ?? string.Empty,
RepositoryUrl = FirstNonEmpty(template?.RepositoryUrl, repository.RepositoryUrl, entry.RepositoryUrl, entry.ProjectUrl)
?? string.Empty,
Tags = FirstNonEmptyList(template?.Tags, repository.Tags, entry.Tags),
ReleaseNotes = FirstNonEmpty(template?.ReleaseNotes, repository.ReleaseNotes, entry.ReleaseNotes) ?? string.Empty
},
Publication = entry.Publication,
Capabilities = entry.Capabilities,
Id = FirstNonEmpty(resolvedManifest?.Id, entry.Id, entry.PluginId) ?? entry.PluginId,
Name = FirstNonEmpty(resolvedManifest?.Name, entry.Name) ?? string.Empty,
Description = FirstNonEmpty(resolvedManifest?.Description, entry.Description) ?? string.Empty,
Author = FirstNonEmpty(resolvedManifest?.Author, entry.Author) ?? string.Empty,
Version = FirstNonEmpty(resolvedManifest?.Version, entry.Version) ?? string.Empty,
ApiVersion = FirstNonEmpty(resolvedManifest?.ApiVersion, entry.ApiVersion) ?? string.Empty,
MinHostVersion = FirstNonEmpty(template?.MinHostVersion, entry.MinHostVersion) ?? string.Empty,
DownloadUrl = FirstNonEmpty(firstPackageSourceUrl, entry.DownloadUrl) ?? string.Empty,
Sha256 = entry.Sha256,
PackageSizeBytes = entry.PackageSizeBytes,
IconUrl = FirstNonEmpty(template?.IconUrl, repository.IconUrl, entry.IconUrl) ?? string.Empty,
ReleaseTag = entry.ReleaseTag,
ReleaseAssetName = entry.ReleaseAssetName,
ProjectUrl = FirstNonEmpty(template?.ProjectUrl, repository.ProjectUrl, entry.ProjectUrl) ?? string.Empty,
ReadmeUrl = FirstNonEmpty(template?.ReadmeUrl, repository.ReadmeUrl, entry.ReadmeUrl) ?? string.Empty,
HomepageUrl = FirstNonEmpty(template?.HomepageUrl, repository.HomepageUrl, entry.HomepageUrl) ?? string.Empty,
RepositoryUrl = FirstNonEmpty(template?.RepositoryUrl, repository.RepositoryUrl, entry.RepositoryUrl, entry.ProjectUrl)
?? string.Empty,
Tags = FirstNonEmptyList(template?.Tags, repository.Tags, entry.Tags),
SharedContracts = resolvedManifest?.SharedContracts
?.Select(contract => new AirAppMarketPluginDependencyEntry
{
Id = contract.Id,
Version = contract.Version,
AssemblyName = contract.AssemblyName
})
.ToList()
?? entry.SharedContracts,
PackageSources = resolvedPackageSources,
Md5 = entry.Md5,
PublishedAt = entry.PublishedAt,
UpdatedAt = entry.UpdatedAt,
ReleaseNotes = FirstNonEmpty(template?.ReleaseNotes, repository.ReleaseNotes, entry.ReleaseNotes) ?? string.Empty
};
}
private async Task<PluginManifest?> TryLoadPluginManifestAsync(
string owner,
string repositoryName,
string branch,
CancellationToken cancellationToken)
{
var candidateUrl = AirAppMarketDefaults.BuildGitHubRawUrl(owner, repositoryName, branch, "plugin.json");
var text = await TryReadTextAsync(candidateUrl, cancellationToken).ConfigureAwait(false);
if (text is null)
{
return null;
}
try
{
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(text));
return PluginManifest.Load(stream, candidateUrl);
}
catch
{
return null;
}
}
private async Task<AirAppMarketRepositoryTemplate?> TryLoadTemplateAsync(
string owner,
string repositoryName,
string branch,
CancellationToken cancellationToken)
{
var candidateUrl = AirAppMarketDefaults.BuildGitHubRawUrl(owner, repositoryName, branch, "airappmarket-entry.template.json");
var text = await TryReadTextAsync(candidateUrl, cancellationToken).ConfigureAwait(false);
if (text is null)
{
return null;
}
try
{
return JsonSerializer.Deserialize<AirAppMarketRepositoryTemplate>(text, JsonOptions);
}
catch
{
return null;
}
}
private async Task<IReadOnlyList<string>> GetBranchCandidatesAsync(
string owner,
string repositoryName,
CancellationToken cancellationToken)
{
var candidates = new List<string>(4);
if (_defaultBranchCache.TryGetValue(FormatRepositoryKey(owner, repositoryName), out var cachedBranch) &&
!string.IsNullOrWhiteSpace(cachedBranch))
{
candidates.Add(cachedBranch);
}
else
{
var defaultBranch = await TryGetDefaultBranchAsync(owner, repositoryName, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(defaultBranch))
{
_defaultBranchCache[FormatRepositoryKey(owner, repositoryName)] = defaultBranch;
candidates.Add(defaultBranch);
}
}
candidates.Add("main");
candidates.Add("master");
return candidates
.Where(branch => !string.IsNullOrWhiteSpace(branch))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private async Task<string?> TryGetDefaultBranchAsync(
string owner,
string repositoryName,
CancellationToken cancellationToken)
{
var url = $"https://api.github.com/repos/{owner}/{repositoryName}";
try
{
using var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
var responseText = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
return null;
}
using var document = JsonDocument.Parse(responseText);
if (document.RootElement.TryGetProperty("default_branch", out var branchNode))
{
return AirAppMarketIndexDocument.NormalizeValue(branchNode.GetString());
}
}
catch
{
// Fallback to conventional branches.
}
return null;
}
private async Task<string?> TryReadTextAsync(string url, CancellationToken cancellationToken)
{
if (AirAppMarketDefaults.TryResolveWorkspaceFile(url, out var localPath))
{
try
{
return await File.ReadAllTextAsync(localPath, cancellationToken).ConfigureAwait(false);
}
catch
{
return null;
}
}
try
{
using var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
return null;
}
return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
}
catch
{
return null;
}
}
private static string FormatRepositoryKey(string owner, string repositoryName)
{
return $"{owner.Trim()}/{repositoryName.Trim()}";
}
private static string? FirstNonEmpty(params string?[] values)
{
foreach (var value in values)
{
var normalized = AirAppMarketIndexDocument.NormalizeValue(value);
if (!string.IsNullOrWhiteSpace(normalized))
{
return normalized;
}
}
return null;
}
private static List<string> FirstNonEmptyList(params IReadOnlyList<string>?[] lists)
{
foreach (var list in lists)
{
if (list is null || list.Count == 0)
{
continue;
}
var normalized = list
.Select(AirAppMarketIndexDocument.NormalizeValue)
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(value => value, StringComparer.OrdinalIgnoreCase)
.ToList();
if (normalized.Count > 0)
{
return normalized;
}
}
return [];
}
private sealed record AirAppMarketRepositoryTemplate(
string? MinHostVersion,
string? IconUrl,
string? ProjectUrl,
string? ReadmeUrl,
string? HomepageUrl,
string? RepositoryUrl,
List<string>? Tags,
string? ReleaseNotes);
}

View File

@@ -41,7 +41,7 @@ public sealed class AirAppMarketIconService : IDisposable
}
public async Task<Bitmap> LoadAsync(
LanMountainDesktop.Services.Settings.PluginMarketPluginInfo plugin,
LanMountainDesktop.Services.Settings.PluginCatalogItemInfo plugin,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(plugin);

View File

@@ -10,6 +10,7 @@ namespace LanMountainDesktop.Services.PluginMarket;
internal sealed class AirAppMarketIndexService : IDisposable
{
private readonly AirAppMarketCacheService _cacheService;
private readonly AirAppMarketMetadataResolverService _metadataResolver;
private readonly HttpClient _httpClient;
public AirAppMarketIndexService(AirAppMarketCacheService cacheService)
@@ -22,6 +23,7 @@ internal sealed class AirAppMarketIndexService : IDisposable
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
_httpClient.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/json"));
_metadataResolver = new AirAppMarketMetadataResolverService(_httpClient);
}
public async Task<AirAppMarketLoadResult> LoadAsync(CancellationToken cancellationToken = default)
@@ -34,6 +36,7 @@ internal sealed class AirAppMarketIndexService : IDisposable
{
var json = await File.ReadAllTextAsync(localIndexPath, cancellationToken).ConfigureAwait(false);
var document = AirAppMarketIndexDocument.Load(json, localIndexPath);
document = await _metadataResolver.EnrichAsync(document, cancellationToken).ConfigureAwait(false);
_cacheService.SaveIndexJson(json);
return new AirAppMarketLoadResult(
true,
@@ -66,6 +69,7 @@ internal sealed class AirAppMarketIndexService : IDisposable
response.EnsureSuccessStatusCode();
var document = AirAppMarketIndexDocument.Load(json, AirAppMarketDefaults.DefaultIndexUrl);
document = await _metadataResolver.EnrichAsync(document, cancellationToken).ConfigureAwait(false);
_cacheService.SaveIndexJson(json);
return new AirAppMarketLoadResult(
true,
@@ -93,6 +97,7 @@ internal sealed class AirAppMarketIndexService : IDisposable
try
{
var cachedDocument = AirAppMarketIndexDocument.Load(cachedJson, _cacheService.CacheFilePath);
cachedDocument = await _metadataResolver.EnrichAsync(cachedDocument, cancellationToken).ConfigureAwait(false);
return new AirAppMarketLoadResult(
true,
cachedDocument,
@@ -124,6 +129,7 @@ internal sealed class AirAppMarketIndexService : IDisposable
public void Dispose()
{
_metadataResolver.Dispose();
_httpClient.Dispose();
}
}

View File

@@ -188,7 +188,7 @@ internal sealed class AirAppMarketInstallService : IDisposable
var localCopyResult = await _downloadService.DownloadAsync(
localPackagePath,
attemptPath,
new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes),
new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes > 0 ? plugin.PackageSizeBytes : null),
cancellationToken: cancellationToken).ConfigureAwait(false);
if (!localCopyResult.Success)
{
@@ -208,7 +208,7 @@ internal sealed class AirAppMarketInstallService : IDisposable
var downloadResult = await _downloadService.DownloadAsync(
resolvedDownloadUrl,
attemptPath,
new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes),
new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes > 0 ? plugin.PackageSizeBytes : null),
cancellationToken: cancellationToken).ConfigureAwait(false);
if (!downloadResult.Success)
{
@@ -231,14 +231,25 @@ internal sealed class AirAppMarketInstallService : IDisposable
actualHash = Convert.ToHexString(hashBytes).ToLowerInvariant();
}
if (actualSize != plugin.PackageSizeBytes || !string.Equals(actualHash, plugin.Sha256, StringComparison.OrdinalIgnoreCase))
if (plugin.PackageSizeBytes > 0 && actualSize != plugin.PackageSizeBytes)
{
AppLogger.Error(
"PluginMarket",
$"Package verification failed. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{attemptPath}'; ExpectedHash='{plugin.Sha256}'; ActualHash='{actualHash}'; ExpectedSize='{plugin.PackageSizeBytes}'; ActualSize='{actualSize}'.");
$"Package verification failed. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{attemptPath}'; ExpectedSize='{plugin.PackageSizeBytes}'; ActualSize='{actualSize}'.");
return new AirAppMarketVerificationResult(
false,
$"Package verification failed. Expected SHA-256 {plugin.Sha256}, actual {actualHash}. Expected size {plugin.PackageSizeBytes}, actual size {actualSize}.");
$"Package verification failed. Expected size {plugin.PackageSizeBytes}, actual size {actualSize}.");
}
if (!string.IsNullOrWhiteSpace(plugin.Sha256) &&
!string.Equals(actualHash, plugin.Sha256, StringComparison.OrdinalIgnoreCase))
{
AppLogger.Error(
"PluginMarket",
$"Package hash verification failed. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{attemptPath}'; ExpectedHash='{plugin.Sha256}'; ActualHash='{actualHash}'.");
return new AirAppMarketVerificationResult(
false,
$"Package verification failed. Expected SHA-256 {plugin.Sha256}, actual {actualHash}.");
}
return new AirAppMarketVerificationResult(true, null);

View File

@@ -678,30 +678,39 @@ internal sealed class AirAppMarketPluginRepositoryEntry
public AirAppMarketPluginRepositoryEntry ValidateAndNormalize(string sourceName)
{
var normalizedIconUrl = AirAppMarketIndexDocument.NormalizeValue(IconUrl)
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing repository.iconUrl.");
AirAppMarketIndexDocument.EnsureUrl(normalizedIconUrl, nameof(IconUrl), sourceName);
var normalizedProjectUrl = AirAppMarketIndexDocument.NormalizeGitHubRepositoryUrl(
AirAppMarketIndexDocument.NormalizeValue(ProjectUrl)
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing repository.projectUrl."),
nameof(ProjectUrl),
sourceName);
var normalizedReadmeUrl = AirAppMarketIndexDocument.NormalizeValue(ReadmeUrl)
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing repository.readmeUrl.");
AirAppMarketIndexDocument.EnsureUrl(normalizedReadmeUrl, nameof(ReadmeUrl), sourceName);
var normalizedHomepageUrl = AirAppMarketIndexDocument.NormalizeValue(HomepageUrl)
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing repository.homepageUrl.");
AirAppMarketIndexDocument.EnsureUrl(normalizedHomepageUrl, nameof(HomepageUrl), sourceName);
var normalizedRepositoryUrl = AirAppMarketIndexDocument.NormalizeGitHubRepositoryUrl(
AirAppMarketIndexDocument.NormalizeValue(RepositoryUrl)
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing repository.repositoryUrl."),
nameof(RepositoryUrl),
sourceName);
var normalizedIconUrl = AirAppMarketIndexDocument.NormalizeValue(IconUrl) ?? string.Empty;
if (!string.IsNullOrWhiteSpace(normalizedIconUrl))
{
AirAppMarketIndexDocument.EnsureUrl(normalizedIconUrl, nameof(IconUrl), sourceName);
}
var normalizedProjectUrl = AirAppMarketIndexDocument.NormalizeValue(ProjectUrl) ?? string.Empty;
if (!string.IsNullOrWhiteSpace(normalizedProjectUrl))
{
AirAppMarketIndexDocument.NormalizeGitHubRepositoryUrl(
normalizedProjectUrl,
nameof(ProjectUrl),
sourceName);
}
var normalizedReadmeUrl = AirAppMarketIndexDocument.NormalizeValue(ReadmeUrl) ?? string.Empty;
if (!string.IsNullOrWhiteSpace(normalizedReadmeUrl))
{
AirAppMarketIndexDocument.EnsureUrl(normalizedReadmeUrl, nameof(ReadmeUrl), sourceName);
}
var normalizedHomepageUrl = AirAppMarketIndexDocument.NormalizeValue(HomepageUrl) ?? string.Empty;
if (!string.IsNullOrWhiteSpace(normalizedHomepageUrl))
{
AirAppMarketIndexDocument.EnsureUrl(normalizedHomepageUrl, nameof(HomepageUrl), sourceName);
}
var normalizedTags = (Tags ?? [])
.Select(AirAppMarketIndexDocument.NormalizeValue)
.Where(tag => !string.IsNullOrWhiteSpace(tag))
@@ -718,8 +727,7 @@ internal sealed class AirAppMarketPluginRepositoryEntry
HomepageUrl = normalizedHomepageUrl,
RepositoryUrl = normalizedRepositoryUrl,
Tags = normalizedTags,
ReleaseNotes = AirAppMarketIndexDocument.NormalizeValue(ReleaseNotes)
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing repository.releaseNotes.")
ReleaseNotes = AirAppMarketIndexDocument.NormalizeValue(ReleaseNotes) ?? string.Empty
};
}
}
@@ -808,30 +816,20 @@ internal sealed class AirAppMarketPluginPublicationEntry
public AirAppMarketPluginPublicationEntry ValidateAndNormalize(string sourceName, string pluginId)
{
var normalizedReleaseTag = AirAppMarketIndexDocument.NormalizeReleaseTag(
ReleaseTag,
nameof(ReleaseTag),
sourceName);
var normalizedReleaseAssetName = AirAppMarketIndexDocument.NormalizeValue(ReleaseAssetName)
?? throw new InvalidOperationException(
$"Market index '{sourceName}' is missing publication.releaseAssetName for plugin '{pluginId}'.");
if (PublishedAt == default || UpdatedAt == default)
var normalizedPackageSources = NormalizePackageSources(PackageSources, sourceName, pluginId);
var normalizedReleaseTag = AirAppMarketIndexDocument.NormalizeValue(ReleaseTag) ?? string.Empty;
if (!string.IsNullOrWhiteSpace(normalizedReleaseTag))
{
throw new InvalidOperationException(
$"Market index '{sourceName}' is missing valid publication timestamps for plugin '{pluginId}'.");
normalizedReleaseTag = AirAppMarketIndexDocument.NormalizeReleaseTag(
normalizedReleaseTag,
nameof(ReleaseTag),
sourceName);
}
if (PackageSizeBytes <= 0)
{
throw new InvalidOperationException(
$"Market index '{sourceName}' declares invalid packageSizeBytes '{PackageSizeBytes}' for plugin '{pluginId}'.");
}
var normalizedSha256 = AirAppMarketIndexDocument.NormalizeValue(Sha256)?.ToLowerInvariant()
?? throw new InvalidOperationException(
$"Market index '{sourceName}' is missing publication.sha256 for plugin '{pluginId}'.");
if (normalizedSha256.Length != 64 || normalizedSha256.Any(ch => !Uri.IsHexDigit(ch)))
var normalizedReleaseAssetName = AirAppMarketIndexDocument.NormalizeValue(ReleaseAssetName) ?? string.Empty;
var normalizedSha256 = AirAppMarketIndexDocument.NormalizeValue(Sha256)?.ToLowerInvariant() ?? string.Empty;
if (!string.IsNullOrWhiteSpace(normalizedSha256) &&
(normalizedSha256.Length != 64 || normalizedSha256.Any(ch => !Uri.IsHexDigit(ch))))
{
throw new InvalidOperationException(
$"Market index '{sourceName}' declares invalid SHA-256 '{normalizedSha256}' for plugin '{pluginId}'.");
@@ -845,8 +843,6 @@ internal sealed class AirAppMarketPluginPublicationEntry
$"Market index '{sourceName}' declares invalid MD5 '{normalizedMd5}' for plugin '{pluginId}'.");
}
var normalizedPackageSources = NormalizePackageSources(PackageSources, sourceName, pluginId);
return new AirAppMarketPluginPublicationEntry
{
ReleaseTag = normalizedReleaseTag,
@@ -1039,120 +1035,10 @@ internal sealed class AirAppMarketPluginEntry
? Publication!.ValidateAndNormalize(sourceName, resolvedPluginId)
: null;
var resolvedName = FirstNonEmpty(
normalizedManifest?.Name,
AirAppMarketIndexDocument.NormalizeValue(Name))
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin name.");
var resolvedDescription = FirstNonEmpty(
normalizedManifest?.Description,
AirAppMarketIndexDocument.NormalizeValue(Description))
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin description.");
var resolvedAuthor = FirstNonEmpty(
normalizedManifest?.Author,
AirAppMarketIndexDocument.NormalizeValue(Author))
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin author.");
var resolvedVersion = AirAppMarketIndexDocument.NormalizeVersion(
FirstNonEmpty(normalizedManifest?.Version, Version),
nameof(Version),
sourceName);
var resolvedApiVersion = AirAppMarketIndexDocument.NormalizeVersion(
FirstNonEmpty(
normalizedCompatibility?.PluginApiVersion,
normalizedManifest?.ApiVersion,
ApiVersion),
nameof(ApiVersion),
sourceName);
var resolvedMinHostVersion = AirAppMarketIndexDocument.NormalizeVersion(
FirstNonEmpty(normalizedCompatibility?.MinHostVersion, MinHostVersion),
nameof(MinHostVersion),
sourceName);
var resolvedIconUrl = FirstNonEmpty(
normalizedRepository?.IconUrl,
AirAppMarketIndexDocument.NormalizeValue(IconUrl))
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin iconUrl.");
AirAppMarketIndexDocument.EnsureUrl(resolvedIconUrl, nameof(IconUrl), sourceName);
var resolvedProjectUrl = AirAppMarketIndexDocument.NormalizeGitHubRepositoryUrl(
FirstNonEmpty(
normalizedRepository?.ProjectUrl,
AirAppMarketIndexDocument.NormalizeValue(ProjectUrl))
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin projectUrl."),
nameof(ProjectUrl),
sourceName);
var resolvedReadmeUrl = FirstNonEmpty(
normalizedRepository?.ReadmeUrl,
AirAppMarketIndexDocument.NormalizeValue(ReadmeUrl))
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin readmeUrl.");
AirAppMarketIndexDocument.EnsureUrl(resolvedReadmeUrl, nameof(ReadmeUrl), sourceName);
var resolvedHomepageUrl = FirstNonEmpty(
normalizedRepository?.HomepageUrl,
AirAppMarketIndexDocument.NormalizeValue(HomepageUrl))
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin homepageUrl.");
AirAppMarketIndexDocument.EnsureUrl(resolvedHomepageUrl, nameof(HomepageUrl), sourceName);
var resolvedRepositoryUrl = AirAppMarketIndexDocument.NormalizeGitHubRepositoryUrl(
FirstNonEmpty(
normalizedRepository?.RepositoryUrl,
AirAppMarketIndexDocument.NormalizeValue(RepositoryUrl))
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin repositoryUrl."),
nameof(RepositoryUrl),
sourceName);
var resolvedReleaseTag = FirstNonEmpty(
normalizedPublication?.ReleaseTag,
AirAppMarketIndexDocument.NormalizeValue(ReleaseTag));
var resolvedReleaseAssetName = FirstNonEmpty(
normalizedPublication?.ReleaseAssetName,
AirAppMarketIndexDocument.NormalizeValue(ReleaseAssetName));
if (string.IsNullOrWhiteSpace(resolvedReleaseTag) != string.IsNullOrWhiteSpace(resolvedReleaseAssetName))
{
throw new InvalidOperationException(
$"Market index '{sourceName}' must declare both '{nameof(ReleaseTag)}' and '{nameof(ReleaseAssetName)}' together for plugin '{resolvedPluginId}'.");
}
if (!string.IsNullOrWhiteSpace(resolvedReleaseTag))
{
resolvedReleaseTag = AirAppMarketIndexDocument.NormalizeReleaseTag(
resolvedReleaseTag,
nameof(ReleaseTag),
sourceName);
}
var resolvedPackageSize = normalizedPublication?.PackageSizeBytes ?? PackageSizeBytes;
if (resolvedPackageSize <= 0)
{
throw new InvalidOperationException(
$"Market index '{sourceName}' declares invalid packageSizeBytes '{resolvedPackageSize}' for plugin '{resolvedPluginId}'.");
}
var resolvedSha256 = FirstNonEmpty(
normalizedPublication?.Sha256,
AirAppMarketIndexDocument.NormalizeValue(Sha256)?.ToLowerInvariant())
?? throw new InvalidOperationException(
$"Market index '{sourceName}' is missing SHA-256 for plugin '{resolvedPluginId}'.");
if (resolvedSha256.Length != 64 || resolvedSha256.Any(ch => !Uri.IsHexDigit(ch)))
{
throw new InvalidOperationException(
$"Market index '{sourceName}' declares invalid SHA-256 '{resolvedSha256}' for plugin '{resolvedPluginId}'.");
}
var resolvedMd5 = FirstNonEmpty(
normalizedPublication?.Md5,
AirAppMarketIndexDocument.NormalizeValue(Md5)?.ToLowerInvariant())
?? string.Empty;
if (!string.IsNullOrWhiteSpace(resolvedMd5) &&
(resolvedMd5.Length != 32 || resolvedMd5.Any(ch => !Uri.IsHexDigit(ch))))
{
throw new InvalidOperationException(
$"Market index '{sourceName}' declares invalid MD5 '{resolvedMd5}' for plugin '{resolvedPluginId}'.");
}
var resolvedPackageSources = NormalizePackageSources(
normalizedPublication?.PackageSources,
normalizedPublication?.PackageSources ?? PackageSources,
sourceName,
resolvedPluginId,
resolvedReleaseTag,
resolvedReleaseAssetName,
resolvedRepositoryUrl,
AirAppMarketIndexDocument.NormalizeValue(DownloadUrl));
if (resolvedPackageSources.Count == 0)
{
@@ -1160,19 +1046,84 @@ internal sealed class AirAppMarketPluginEntry
$"Market index '{sourceName}' is missing package sources for plugin '{resolvedPluginId}'.");
}
var resolvedDownloadUrl = resolvedPackageSources[0].Url;
var resolvedPublishedAt = normalizedPublication?.PublishedAt ?? PublishedAt;
var resolvedUpdatedAt = normalizedPublication?.UpdatedAt ?? UpdatedAt;
if (resolvedPublishedAt == default || resolvedUpdatedAt == default)
var resolvedRepositoryUrl = FirstNonEmpty(
normalizedRepository?.RepositoryUrl,
AirAppMarketIndexDocument.NormalizeValue(RepositoryUrl));
if (string.IsNullOrWhiteSpace(resolvedRepositoryUrl))
{
throw new InvalidOperationException(
$"Market index '{sourceName}' is missing valid publish timestamps for plugin '{resolvedPluginId}'.");
throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin repositoryUrl.");
}
var resolvedDownloadUrl = FirstNonEmpty(
resolvedPackageSources.FirstOrDefault()?.Url,
AirAppMarketIndexDocument.NormalizeValue(DownloadUrl))
?? string.Empty;
var resolvedName = FirstNonEmpty(
normalizedManifest?.Name,
AirAppMarketIndexDocument.NormalizeValue(Name))
?? string.Empty;
var resolvedDescription = FirstNonEmpty(
normalizedManifest?.Description,
AirAppMarketIndexDocument.NormalizeValue(Description))
?? string.Empty;
var resolvedAuthor = FirstNonEmpty(
normalizedManifest?.Author,
AirAppMarketIndexDocument.NormalizeValue(Author))
?? string.Empty;
var resolvedVersion = FirstNonEmpty(
normalizedManifest?.Version,
AirAppMarketIndexDocument.NormalizeValue(Version))
?? string.Empty;
var resolvedApiVersion = FirstNonEmpty(
normalizedCompatibility?.PluginApiVersion,
normalizedManifest?.ApiVersion,
AirAppMarketIndexDocument.NormalizeValue(ApiVersion))
?? string.Empty;
var resolvedMinHostVersion = FirstNonEmpty(
normalizedCompatibility?.MinHostVersion,
AirAppMarketIndexDocument.NormalizeValue(MinHostVersion))
?? string.Empty;
var resolvedIconUrl = FirstNonEmpty(
normalizedRepository?.IconUrl,
AirAppMarketIndexDocument.NormalizeValue(IconUrl))
?? string.Empty;
var resolvedProjectUrl = FirstNonEmpty(
normalizedRepository?.ProjectUrl,
AirAppMarketIndexDocument.NormalizeValue(ProjectUrl))
?? string.Empty;
var resolvedReadmeUrl = FirstNonEmpty(
normalizedRepository?.ReadmeUrl,
AirAppMarketIndexDocument.NormalizeValue(ReadmeUrl))
?? string.Empty;
var resolvedHomepageUrl = FirstNonEmpty(
normalizedRepository?.HomepageUrl,
AirAppMarketIndexDocument.NormalizeValue(HomepageUrl))
?? string.Empty;
var resolvedReleaseTag = FirstNonEmpty(
normalizedPublication?.ReleaseTag,
AirAppMarketIndexDocument.NormalizeValue(ReleaseTag))
?? string.Empty;
var resolvedReleaseAssetName = FirstNonEmpty(
normalizedPublication?.ReleaseAssetName,
AirAppMarketIndexDocument.NormalizeValue(ReleaseAssetName))
?? string.Empty;
var resolvedPackageSize = normalizedPublication?.PackageSizeBytes ?? PackageSizeBytes;
var resolvedSha256 = FirstNonEmpty(
normalizedPublication?.Sha256,
AirAppMarketIndexDocument.NormalizeValue(Sha256)?.ToLowerInvariant())
?? string.Empty;
var resolvedMd5 = FirstNonEmpty(
normalizedPublication?.Md5,
AirAppMarketIndexDocument.NormalizeValue(Md5)?.ToLowerInvariant())
?? string.Empty;
var resolvedPublishedAt = normalizedPublication?.PublishedAt ?? PublishedAt;
var resolvedUpdatedAt = normalizedPublication?.UpdatedAt ?? UpdatedAt;
var resolvedDependencies = NormalizeDependencies(
normalizedManifest?.SharedContracts,
normalizedCapabilities?.SharedContracts,
SharedContracts,
normalizedManifest?.SharedContracts ?? SharedContracts,
sourceName,
resolvedPluginId);
var resolvedTags = (normalizedRepository?.Tags ?? Tags ?? [])
@@ -1185,8 +1136,7 @@ internal sealed class AirAppMarketPluginEntry
var resolvedReleaseNotes = FirstNonEmpty(
normalizedRepository?.ReleaseNotes,
AirAppMarketIndexDocument.NormalizeValue(ReleaseNotes))
?? throw new InvalidOperationException(
$"Market index '{sourceName}' is missing release notes for plugin '{resolvedPluginId}'.");
?? string.Empty;
return new AirAppMarketPluginEntry
{
@@ -1225,6 +1175,13 @@ internal sealed class AirAppMarketPluginEntry
public string GetVersionSummary()
{
if (string.IsNullOrWhiteSpace(Version) &&
string.IsNullOrWhiteSpace(ApiVersion) &&
string.IsNullOrWhiteSpace(MinHostVersion))
{
return "Unknown";
}
return string.Format(
CultureInfo.InvariantCulture,
"v{0} | API {1} | Host >= {2}",
@@ -1309,21 +1266,13 @@ internal sealed class AirAppMarketPluginEntry
}
private static List<AirAppMarketPluginDependencyEntry> NormalizeDependencies(
IReadOnlyList<AirAppMarketPluginDependencyEntry>? manifestDependencies,
IReadOnlyList<AirAppMarketPluginDependencyEntry>? capabilityDependencies,
IReadOnlyList<AirAppMarketPluginDependencyEntry>? legacyDependencies,
IReadOnlyList<AirAppMarketPluginDependencyEntry>? dependencies,
string sourceName,
string pluginId)
{
IReadOnlyList<AirAppMarketPluginDependencyEntry> dependencies = manifestDependencies is { Count: > 0 }
? manifestDependencies
: capabilityDependencies is { Count: > 0 }
? capabilityDependencies
: legacyDependencies ?? [];
var normalizedDependencies = new List<AirAppMarketPluginDependencyEntry>(dependencies.Count);
var normalizedDependencies = new List<AirAppMarketPluginDependencyEntry>((dependencies ?? []).Count);
var seenDependencies = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var dependency in dependencies)
foreach (var dependency in dependencies ?? [])
{
var normalizedDependency = dependency.ValidateAndNormalize(sourceName);
var dependencyKey = $"{normalizedDependency.Id}@{normalizedDependency.Version}";
@@ -1343,9 +1292,6 @@ internal sealed class AirAppMarketPluginEntry
IReadOnlyList<AirAppMarketPluginPackageSourceEntry>? packageSources,
string sourceName,
string pluginId,
string? releaseTag,
string? releaseAssetName,
string repositoryUrl,
string? legacyDownloadUrl)
{
var normalizedSources = new List<AirAppMarketPluginPackageSourceEntry>((packageSources ?? []).Count + 1);
@@ -1364,42 +1310,16 @@ internal sealed class AirAppMarketPluginEntry
var normalizedLegacyDownloadUrl = AirAppMarketIndexDocument.NormalizeValue(legacyDownloadUrl);
if (!string.IsNullOrWhiteSpace(normalizedLegacyDownloadUrl))
{
var legacyKind = !string.IsNullOrWhiteSpace(releaseTag) && !string.IsNullOrWhiteSpace(releaseAssetName)
? PluginPackageSourceKind.ReleaseAsset
: PluginPackageSourceKind.RawFallback;
var legacySource = new AirAppMarketPluginPackageSourceEntry
{
Kind = legacyKind switch
{
PluginPackageSourceKind.ReleaseAsset => "releaseAsset",
PluginPackageSourceKind.RawFallback => "rawFallback",
PluginPackageSourceKind.WorkspaceLocal => "workspaceLocal",
_ => "rawFallback"
},
Kind = "rawFallback",
Url = normalizedLegacyDownloadUrl,
SourceKind = legacyKind
SourceKind = PluginPackageSourceKind.RawFallback
};
normalizedSources.Add(legacySource.ValidateAndNormalize(sourceName, pluginId));
return normalizedSources;
}
if (!string.IsNullOrWhiteSpace(releaseTag) &&
!string.IsNullOrWhiteSpace(releaseAssetName) &&
AirAppMarketDefaults.TryParseGitHubRepositoryUrl(repositoryUrl, out var owner, out var repositoryName))
{
var releaseUrl = AirAppMarketDefaults.BuildGitHubReleaseDownloadUrl(
owner,
repositoryName,
releaseTag,
releaseAssetName);
normalizedSources.Add(new AirAppMarketPluginPackageSourceEntry
{
Kind = "releaseAsset",
Url = releaseUrl,
SourceKind = PluginPackageSourceKind.ReleaseAsset
}.ValidateAndNormalize(sourceName, pluginId));
}
return normalizedSources;
}

View File

@@ -36,7 +36,7 @@ public sealed class AirAppMarketReadmeService : IDisposable
}
public async Task<string> LoadAsync(
LanMountainDesktop.Services.Settings.PluginMarketPluginInfo plugin,
LanMountainDesktop.Services.Settings.PluginCatalogItemInfo plugin,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(plugin);