From 26ff11b16b58ecae88c548d45702d3079f1dd86a Mon Sep 17 00:00:00 2001 From: lincube Date: Tue, 24 Mar 2026 23:15:32 +0800 Subject: [PATCH] 0.7.8 --- .../Services/Settings/SettingsContracts.cs | 321 +++++- .../Settings/SettingsDomainServices.cs | 212 +++- .../PluginMarketSettingsPageViewModels.cs | 26 +- .../Views/Components/IfengNewsWidget.axaml.cs | 2 +- .../plugins/PluginMarketInstallService.cs | 266 +++-- .../plugins/PluginMarketModels.cs | 983 ++++++++++++++++-- .../PluginMarketReleaseResolverService.cs | 42 +- 7 files changed, 1619 insertions(+), 233 deletions(-) diff --git a/LanMountainDesktop/Services/Settings/SettingsContracts.cs b/LanMountainDesktop/Services/Settings/SettingsContracts.cs index 3c716ac..32dc224 100644 --- a/LanMountainDesktop/Services/Settings/SettingsContracts.cs +++ b/LanMountainDesktop/Services/Settings/SettingsContracts.cs @@ -1,13 +1,16 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using LanMountainDesktop.Models; using LanMountainDesktop.PluginSdk; using LanMountainDesktop.Services; +using LanMountainDesktop.Services.PluginMarket; using LanMountainDesktop.Settings.Core; -namespace LanMountainDesktop.Services.Settings; +namespace LanMountainDesktop.Services.Settings +{ public enum WallpaperMediaType { @@ -66,10 +69,272 @@ public sealed record UpdateSettingsState( long? PendingUpdatePublishedAtUtcMs, long? LastUpdateCheckUtcMs); public sealed record PluginManagementSettingsState(IReadOnlyList DisabledPluginIds); +public enum PluginPackageSourceKind +{ + ReleaseAsset = 0, + RawFallback = 1, + WorkspaceLocal = 2 +} + +public sealed record PluginCatalogSourceInfo( + string Id, + string Name, + string? Description, + string? SourceUrl, + string? CachePath, + bool IsOfficial, + int Priority); + +public sealed record PluginCatalogSharedContractInfo( + string Id, + string Version, + string AssemblyName); + +public sealed record PluginCapabilityInfo( + string Id, + string? Version, + string? AssemblyName); + +public sealed record PluginPackageSourceInfo( + PluginPackageSourceKind Kind, + string Url, + string Sha256, + long PackageSizeBytes); + +public sealed record PluginCatalogManifestInfo( + string Id, + string Name, + string Description, + string Author, + string Version, + string ApiVersion, + string EntranceAssembly, + IReadOnlyList SharedContracts); + +public sealed record PluginCatalogCompatibilityInfo( + string MinHostVersion, + string ApiVersion); + +public sealed record PluginCatalogRepositoryInfo( + string IconUrl, + string ProjectUrl, + string ReadmeUrl, + string HomepageUrl, + string RepositoryUrl, + IReadOnlyList Tags, + string ReleaseNotes); + +public sealed record PluginCatalogPublicationInfo( + string ReleaseTag, + string ReleaseAssetName, + DateTimeOffset PublishedAt, + DateTimeOffset UpdatedAt, + long PackageSizeBytes, + string Sha256, + string? Md5); + +public sealed record PluginCatalogItemInfo( + PluginCatalogManifestInfo Manifest, + PluginCatalogCompatibilityInfo Compatibility, + PluginCatalogRepositoryInfo Repository, + PluginCatalogPublicationInfo Publication, + IReadOnlyList PackageSources, + IReadOnlyList Capabilities) +{ + public string Id => Manifest.Id; + + public string Name => Manifest.Name; + + public string Description => Manifest.Description; + + public string Author => Manifest.Author; + + public string Version => Manifest.Version; + + public string ApiVersion => Manifest.ApiVersion; + + public string MinHostVersion => Compatibility.MinHostVersion; + + public string DownloadUrl => PackageSources.FirstOrDefault()?.Url ?? string.Empty; + + public string Sha256 => Publication.Sha256; + + public long PackageSizeBytes => Publication.PackageSizeBytes; + + public string IconUrl => Repository.IconUrl; + + public string ProjectUrl => Repository.ProjectUrl; + + public string ReadmeUrl => Repository.ReadmeUrl; + + public string HomepageUrl => Repository.HomepageUrl; + + public string RepositoryUrl => Repository.RepositoryUrl; + + public IReadOnlyList Tags => Repository.Tags; + + public IReadOnlyList SharedContracts => Manifest.SharedContracts; + + public IReadOnlyList Dependencies => + Manifest.SharedContracts + .Select(contract => new PluginCatalogDependencyInfo( + contract.Id, + contract.Version, + contract.AssemblyName)) + .ToArray(); + + public DateTimeOffset PublishedAt => Publication.PublishedAt; + + public DateTimeOffset UpdatedAt => Publication.UpdatedAt; + + public string ReleaseTag => Publication.ReleaseTag; + + public string ReleaseAssetName => Publication.ReleaseAssetName; + + public string ReleaseNotes => Repository.ReleaseNotes; + + public static implicit operator PluginMarketPluginInfo(PluginCatalogItemInfo item) + { + return new PluginMarketPluginInfo( + item.Id, + item.Name, + item.Description, + item.Author, + item.Version, + item.ApiVersion, + item.MinHostVersion, + item.DownloadUrl, + item.ReleaseTag, + item.ReleaseAssetName, + item.IconUrl, + item.ReadmeUrl, + item.HomepageUrl, + item.RepositoryUrl, + item.Tags.ToArray(), + item.Dependencies.Select(dependency => new PluginMarketDependencyInfo( + dependency.Id, + dependency.Version, + dependency.AssemblyName)).ToArray(), + item.PublishedAt, + item.UpdatedAt); + } + + public static implicit operator PluginCatalogItemInfo(PluginMarketPluginInfo plugin) + { + return new PluginCatalogItemInfo( + new PluginCatalogManifestInfo( + plugin.Id, + plugin.Name, + plugin.Description, + plugin.Author, + plugin.Version, + plugin.ApiVersion, + string.Empty, + plugin.Dependencies + .Select(dependency => new PluginCatalogSharedContractInfo( + dependency.Id, + dependency.Version, + dependency.AssemblyName)) + .ToArray()), + new PluginCatalogCompatibilityInfo( + plugin.MinHostVersion, + plugin.ApiVersion), + new PluginCatalogRepositoryInfo( + plugin.IconUrl, + plugin.RepositoryUrl, + plugin.ReadmeUrl, + plugin.HomepageUrl, + plugin.RepositoryUrl, + plugin.Tags, + string.Empty), + new PluginCatalogPublicationInfo( + plugin.ReleaseTag, + plugin.ReleaseAssetName, + plugin.PublishedAt, + plugin.UpdatedAt, + 0, + string.Empty, + null), + string.IsNullOrWhiteSpace(plugin.DownloadUrl) + ? [] + : [ + new PluginPackageSourceInfo( + string.IsNullOrWhiteSpace(plugin.ReleaseTag) + ? PluginPackageSourceKind.RawFallback + : PluginPackageSourceKind.ReleaseAsset, + plugin.DownloadUrl, + string.Empty, + 0) + ], + []); + } +} + +public sealed record PluginCatalogIndexResult( + bool Success, + IReadOnlyList Plugins, + IReadOnlyList Sources, + string? Source, + string? SourceLocation, + string? WarningMessage, + string? ErrorMessage) +{ + public static implicit operator PluginMarketIndexResult(PluginCatalogIndexResult result) + { + return new PluginMarketIndexResult( + result.Success, + result.Plugins.Select(plugin => (PluginMarketPluginInfo)plugin).ToArray(), + result.Source, + result.SourceLocation, + result.WarningMessage, + result.ErrorMessage); + } +} + +public sealed record PluginInstallDiagnostic( + string Code, + string Message, + string? Details = null); + +public sealed record PluginCatalogInstallResult( + bool Success, + string? PluginId, + string? PluginName, + PluginManifest? InstalledManifest, + IReadOnlyList Diagnostics, + string? ErrorMessage) +{ + public static implicit operator PluginMarketInstallResult(PluginCatalogInstallResult result) + { + return new PluginMarketInstallResult( + result.Success, + result.PluginId, + result.PluginName, + result.ErrorMessage); + } +} + +public sealed record PluginCatalogDependencyInfo( + string Id, + string Version, + string AssemblyName) +{ + public static implicit operator PluginMarketDependencyInfo(PluginCatalogDependencyInfo dependency) + { + return new PluginMarketDependencyInfo( + dependency.Id, + dependency.Version, + dependency.AssemblyName); + } +} + +[Obsolete("Use PluginCatalogSharedContractInfo and PluginCatalogItemInfo instead.")] public sealed record PluginMarketDependencyInfo( string Id, string Version, string AssemblyName); + +[Obsolete("Use PluginCatalogItemInfo instead.")] public sealed record PluginMarketPluginInfo( string Id, string Name, @@ -89,6 +354,8 @@ public sealed record PluginMarketPluginInfo( IReadOnlyList Dependencies, DateTimeOffset PublishedAt, DateTimeOffset UpdatedAt); + +[Obsolete("Use PluginCatalogIndexResult instead.")] public sealed record PluginMarketIndexResult( bool Success, IReadOnlyList Plugins, @@ -96,12 +363,39 @@ public sealed record PluginMarketIndexResult( string? SourceLocation, string? WarningMessage, string? ErrorMessage); + +[Obsolete("Use PluginCatalogInstallResult instead.")] public sealed record PluginMarketInstallResult( bool Success, string? PluginId, string? PluginName, string? ErrorMessage); +public interface IPluginCatalogSourceProvider +{ + Task LoadCatalogAsync(CancellationToken cancellationToken = default); +} + +public interface IPluginCatalogService : IPluginCatalogSourceProvider +{ + Task InstallAsync(string pluginId, CancellationToken cancellationToken = default); +} + +public interface IPackageSourceResolver +{ + IReadOnlyList ResolveSources(PluginCatalogItemInfo item); +} + +public interface IPluginCompatibilityEvaluator +{ + PluginInstallDiagnostic? Evaluate(PluginCatalogItemInfo item, Version? hostVersion); +} + +public interface IPluginInstallOrchestrator +{ + Task InstallAsync(PluginCatalogItemInfo item, CancellationToken cancellationToken = default); +} + public interface IGridSettingsService { GridSettingsState Get(); @@ -223,10 +517,17 @@ public interface IPluginManagementSettingsService bool DeleteInstalledPlugin(string pluginId); } -public interface IPluginMarketSettingsService +public interface IPluginCatalogSettingsService : IPluginCatalogSourceProvider +{ + new Task LoadCatalogAsync(CancellationToken cancellationToken = default); + Task InstallAsync(string pluginId, CancellationToken cancellationToken = default); +} + +[Obsolete("Use IPluginCatalogSettingsService instead.")] +public interface IPluginMarketSettingsService : IPluginCatalogSettingsService { Task LoadIndexAsync(CancellationToken cancellationToken = default); - Task InstallAsync(string pluginId, CancellationToken cancellationToken = default); + new Task InstallAsync(string pluginId, CancellationToken cancellationToken = default); } public interface IApplicationInfoService @@ -252,6 +553,20 @@ public interface ISettingsFacadeService ILauncherCatalogService LauncherCatalog { get; } ILauncherPolicyService LauncherPolicy { get; } IPluginManagementSettingsService PluginManagement { get; } + IPluginCatalogSettingsService PluginCatalog { get; } + [Obsolete("Use PluginCatalog instead.")] IPluginMarketSettingsService PluginMarket { get; } IApplicationInfoService ApplicationInfo { get; } } + +} + +namespace LanMountainDesktop.Services.PluginMarket +{ + internal enum PluginPackageSourceKind + { + ReleaseAsset = 0, + RawFallback = 1, + WorkspaceLocal = 2 + } +} diff --git a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs index 41a683f..2df8c4b 100644 --- a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs +++ b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs @@ -870,14 +870,41 @@ internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService _installService = new AirAppMarketInstallService(_pluginRuntimeService, dataRoot); } - public async Task LoadIndexAsync(CancellationToken cancellationToken = default) + public Task LoadCatalogAsync(CancellationToken cancellationToken = default) { - var result = await _indexService.LoadAsync(cancellationToken); + return LoadCatalogCoreAsync(cancellationToken); + } + + async Task IPluginMarketSettingsService.LoadIndexAsync(CancellationToken cancellationToken) + { + return await LoadCatalogCoreAsync(cancellationToken).ConfigureAwait(false); + } + + public Task InstallAsync( + string pluginId, + CancellationToken cancellationToken = default) + { + return InstallCatalogCoreAsync(pluginId, cancellationToken); + } + + async Task IPluginMarketSettingsService.InstallAsync( + string pluginId, + CancellationToken cancellationToken) + { + return await InstallCatalogCoreAsync(pluginId, cancellationToken).ConfigureAwait(false); + } + + private async Task LoadCatalogCoreAsync(CancellationToken cancellationToken = default) + { + var result = await _indexService.LoadAsync(cancellationToken).ConfigureAwait(false); + var sources = BuildCatalogSources(result.Source?.ToString(), result.SourceLocation, result.WarningMessage); if (!result.Success || result.Document is null) { - return new PluginMarketIndexResult( + _cachedPlugins.Clear(); + return new PluginCatalogIndexResult( false, [], + sources, result.Source?.ToString(), result.SourceLocation, result.WarningMessage, @@ -889,81 +916,189 @@ internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService .Select(entry => { _cachedPlugins[entry.Id] = entry; - return new PluginMarketPluginInfo( - entry.Id, - entry.Name, - entry.Description, - entry.Author, - entry.Version, - entry.ApiVersion, - entry.MinHostVersion, - entry.DownloadUrl, - entry.ReleaseTag, - entry.ReleaseAssetName, - entry.IconUrl, - entry.ReadmeUrl, - entry.HomepageUrl, - entry.RepositoryUrl, - entry.Tags, - entry.SharedContracts - .Select(contract => new PluginMarketDependencyInfo( - contract.Id, - contract.Version, - contract.AssemblyName)) - .ToArray(), - entry.PublishedAt, - entry.UpdatedAt); + return MapCatalogItem(entry); }) .ToArray(); - return new PluginMarketIndexResult( + return new PluginCatalogIndexResult( true, plugins, + sources, result.Source?.ToString(), result.SourceLocation, result.WarningMessage, null); } - public async Task InstallAsync( + private async Task InstallCatalogCoreAsync( string pluginId, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(pluginId)) { - return new PluginMarketInstallResult(false, null, null, "Plugin id is required."); + return new PluginCatalogInstallResult( + false, + null, + null, + null, + [new PluginInstallDiagnostic("invalid_request", "Plugin id is required.")], + "Plugin id is required."); } if (_installService is null || _pluginRuntimeService is null) { - return new PluginMarketInstallResult( + return new PluginCatalogInstallResult( false, pluginId, null, + null, + [new PluginInstallDiagnostic("runtime_unavailable", "Plugin runtime is unavailable.")], "Plugin runtime is unavailable."); } if (!_cachedPlugins.TryGetValue(pluginId, out var entry)) { - var load = await LoadIndexAsync(cancellationToken); + var load = await LoadCatalogCoreAsync(cancellationToken).ConfigureAwait(false); if (!load.Success) { - return new PluginMarketInstallResult(false, pluginId, null, load.ErrorMessage); + return new PluginCatalogInstallResult( + false, + pluginId, + null, + null, + [new PluginInstallDiagnostic("catalog_load_failed", load.ErrorMessage ?? "Failed to load the plugin catalog.")], + load.ErrorMessage); } if (!_cachedPlugins.TryGetValue(pluginId, out entry)) { - return new PluginMarketInstallResult(false, pluginId, null, "Plugin was not found in market index."); + return new PluginCatalogInstallResult( + false, + pluginId, + null, + null, + [new PluginInstallDiagnostic("not_found", "Plugin was not found in the official catalog.")], + "Plugin was not found in the official catalog."); } } - var result = await _installService.InstallAsync(entry, cancellationToken); + var result = await _installService.InstallAsync(entry, cancellationToken).ConfigureAwait(false); if (!result.Success) { - return new PluginMarketInstallResult(false, entry.Id, entry.Name, result.ErrorMessage); + return new PluginCatalogInstallResult( + false, + entry.Id, + entry.Name, + null, + [new PluginInstallDiagnostic("install_failed", result.ErrorMessage ?? "Plugin install failed.")], + result.ErrorMessage); } - return new PluginMarketInstallResult(true, result.Manifest?.Id ?? entry.Id, result.Manifest?.Name ?? entry.Name, null); + return new PluginCatalogInstallResult( + true, + result.Manifest?.Id ?? entry.Id, + result.Manifest?.Name ?? entry.Name, + result.Manifest, + [], + null); + } + + private static PluginCatalogItemInfo MapCatalogItem(AirAppMarketPluginEntry entry) + { + var manifest = new PluginCatalogManifestInfo( + entry.Id, + entry.Name, + entry.Description, + entry.Author, + entry.Version, + entry.ApiVersion, + string.Empty, + entry.SharedContracts + .Select(contract => new PluginCatalogSharedContractInfo( + contract.Id, + contract.Version, + contract.AssemblyName)) + .ToArray()); + + var compatibility = new PluginCatalogCompatibilityInfo( + entry.MinHostVersion, + entry.ApiVersion); + + var repository = new PluginCatalogRepositoryInfo( + entry.IconUrl, + entry.ProjectUrl, + entry.ReadmeUrl, + entry.HomepageUrl, + entry.RepositoryUrl, + entry.Tags.ToArray(), + entry.ReleaseNotes); + + var publication = new PluginCatalogPublicationInfo( + entry.ReleaseTag, + entry.ReleaseAssetName, + entry.PublishedAt, + entry.UpdatedAt, + entry.PackageSizeBytes, + entry.Sha256, + null); + + var sources = BuildPackageSources(entry); + + return new PluginCatalogItemInfo( + manifest, + compatibility, + repository, + publication, + sources, + []); + } + + private static IReadOnlyList BuildPackageSources(AirAppMarketPluginEntry entry) + { + if (string.IsNullOrWhiteSpace(entry.DownloadUrl)) + { + return []; + } + + var sourceKind = entry.HasReleaseDownloadMetadata + ? PluginPackageSourceKind.ReleaseAsset + : PluginPackageSourceKind.RawFallback; + + return + [ + new PluginPackageSourceInfo( + sourceKind, + entry.DownloadUrl, + entry.Sha256, + entry.PackageSizeBytes) + ]; + } + + private static IReadOnlyList BuildCatalogSources( + string? sourceId, + string? sourceLocation, + string? warningMessage) + { + if (string.IsNullOrWhiteSpace(sourceId) && string.IsNullOrWhiteSpace(sourceLocation)) + { + return []; + } + + var normalizedSourceId = string.IsNullOrWhiteSpace(sourceId) + ? "plugin-catalog" + : sourceId.Trim(); + + return + [ + new PluginCatalogSourceInfo( + normalizedSourceId, + normalizedSourceId, + string.IsNullOrWhiteSpace(warningMessage) ? null : warningMessage.Trim(), + string.IsNullOrWhiteSpace(sourceLocation) ? null : sourceLocation.Trim(), + null, + true, + 0) + ]; } public void Dispose() @@ -1054,6 +1189,7 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl _pluginManagementSettingsService = new PluginManagementSettingsService(Settings, pluginRuntimeService); PluginManagement = _pluginManagementSettingsService; _pluginMarketSettingsService = new PluginMarketSettingsService(pluginRuntimeService); + PluginCatalog = _pluginMarketSettingsService; PluginMarket = _pluginMarketSettingsService; ApplicationInfo = new ApplicationInfoService(); } @@ -1086,6 +1222,8 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl public IPluginManagementSettingsService PluginManagement { get; } + public IPluginCatalogSettingsService PluginCatalog { get; } + public IPluginMarketSettingsService PluginMarket { get; } public IApplicationInfoService ApplicationInfo { get; } diff --git a/LanMountainDesktop/ViewModels/PluginMarketSettingsPageViewModels.cs b/LanMountainDesktop/ViewModels/PluginMarketSettingsPageViewModels.cs index 9a64931..6dd99ad 100644 --- a/LanMountainDesktop/ViewModels/PluginMarketSettingsPageViewModels.cs +++ b/LanMountainDesktop/ViewModels/PluginMarketSettingsPageViewModels.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Globalization; @@ -31,7 +31,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase private bool _isLoadingIcon; public PluginMarketItemViewModel( - PluginMarketPluginInfo plugin, + PluginCatalogItemInfo plugin, LocalizationService localizationService, string languageCode) { @@ -46,7 +46,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase ActionTooltip = L("market.button.install", "Install"); } - public PluginMarketPluginInfo Info { get; } + public PluginCatalogItemInfo Info { get; } public string PluginId => Info.Id; @@ -64,7 +64,11 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase public string ReadmeUrl => Info.ReadmeUrl; - public IReadOnlyList Dependencies => Info.Dependencies; + public IReadOnlyList Dependencies => Info.SharedContracts; + + public IReadOnlyList PackageSources => Info.PackageSources; + + public IReadOnlyList Capabilities => Info.Capabilities; public string IconFallbackText { get; } @@ -259,7 +263,7 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase _readmeService = readmeService; _primaryActionAsync = primaryActionAsync; - Dependencies = new ObservableCollection(item.Dependencies); + Dependencies = new ObservableCollection(item.Dependencies); VersionLabel = L("market.detail.version", "Version"); PublisherLabel = L("market.detail.author", "Author"); ApiVersionLabel = L("market.detail.api_version", "API Version"); @@ -271,7 +275,7 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase public PluginMarketItemViewModel Item { get; } - public ObservableCollection Dependencies { get; } + public ObservableCollection Dependencies { get; } public string DrawerTitle => Item.Name; @@ -306,6 +310,10 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase public bool HasReadmeContent => !IsReadmeLoading && !HasReadmeError && !string.IsNullOrWhiteSpace(ReadmeMarkdown); + public IReadOnlyList PackageSources => Item.PackageSources; + + public IReadOnlyList Capabilities => Item.Capabilities; + public async Task InitializeAsync() { if (_isInitialized) @@ -370,6 +378,7 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase { private readonly ISettingsFacadeService _settingsFacade; + private readonly IPluginCatalogSettingsService _pluginCatalog; private readonly LocalizationService _localizationService; private readonly AirAppMarketIconService _iconService; private readonly AirAppMarketReadmeService _readmeService; @@ -386,6 +395,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase AirAppMarketReadmeService readmeService) { _settingsFacade = settingsFacade; + _pluginCatalog = _settingsFacade.PluginCatalog; _localizationService = localizationService; _iconService = iconService; _readmeService = readmeService; @@ -468,7 +478,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase StatusMessage = L("market.status.loading", "Loading the official plugin market..."); RefreshInstalledSnapshot(); - var result = await _settingsFacade.PluginMarket.LoadIndexAsync(); + var result = await _pluginCatalog.LoadCatalogAsync(); if (!result.Success) { _hasLoadedMarket = false; @@ -559,7 +569,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase L("market.status.installing_format", "Downloading and staging plugin '{0}'..."), item.Name); - var result = await _settingsFacade.PluginMarket.InstallAsync(item.PluginId); + var result = await _pluginCatalog.InstallAsync(item.PluginId); if (result.Success) { RefreshInstalledSnapshot(); diff --git a/LanMountainDesktop/Views/Components/IfengNewsWidget.axaml.cs b/LanMountainDesktop/Views/Components/IfengNewsWidget.axaml.cs index 9716c05..ad8b740 100644 --- a/LanMountainDesktop/Views/Components/IfengNewsWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/IfengNewsWidget.axaml.cs @@ -198,7 +198,7 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe foreach (var visual in _itemVisuals) { - visual.Host.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#F7F8FA")); + visual.Host.Background = Brushes.Transparent; visual.TitleTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327")); } diff --git a/LanMountainDesktop/plugins/PluginMarketInstallService.cs b/LanMountainDesktop/plugins/PluginMarketInstallService.cs index bd35ea1..8c3c38b 100644 --- a/LanMountainDesktop/plugins/PluginMarketInstallService.cs +++ b/LanMountainDesktop/plugins/PluginMarketInstallService.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Http; @@ -12,6 +13,8 @@ namespace LanMountainDesktop.Services.PluginMarket; internal sealed class AirAppMarketInstallService : IDisposable { + private const string HelperExecutableName = "LanMountainDesktop.PluginsInstallHelper.exe"; + private readonly PluginRuntimeService _runtime; private readonly PluginsInstallHelperClient _helperClient = new(); private readonly HttpClient _httpClient; @@ -38,107 +41,226 @@ internal sealed class AirAppMarketInstallService : IDisposable { ArgumentNullException.ThrowIfNull(plugin); - Directory.CreateDirectory(_downloadsDirectory); - var downloadPath = Path.Combine( - _downloadsDirectory, - $"{SanitizeFileName(plugin.Id)}-{SanitizeFileName(plugin.Version)}.laapp"); - - try + if (OperatingSystem.IsWindows()) { - AppLogger.Info( - "PluginMarket", - $"Starting install. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{downloadPath}'."); - var resolvedDownloadUrl = await _releaseResolverService.ResolveDownloadUrlAsync(plugin, cancellationToken); - AppLogger.Info( - "PluginMarket", - $"Resolved download url for '{plugin.Id}' to '{resolvedDownloadUrl}'."); - - if (AirAppMarketDefaults.TryResolveWorkspaceFile(resolvedDownloadUrl, out var localPackagePath)) + var helperPath = ResolveHelperPath(); + if (!File.Exists(helperPath)) { - var localCopyResult = await _downloadService.DownloadAsync( - localPackagePath, - downloadPath, - new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes), - cancellationToken: cancellationToken); - if (!localCopyResult.Success) - { - return new AirAppMarketInstallResult(false, null, localCopyResult.ErrorMessage); - } - } - else - { - var downloadResult = await _downloadService.DownloadAsync( - resolvedDownloadUrl, - downloadPath, - new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes), - cancellationToken: cancellationToken); - if (!downloadResult.Success) - { - return new AirAppMarketInstallResult(false, null, downloadResult.ErrorMessage); - } - } - - var actualSize = new FileInfo(downloadPath).Length; - string actualHash; - await using (var hashStream = File.OpenRead(downloadPath)) - { - var hashBytes = await SHA256.HashDataAsync(hashStream, cancellationToken); - actualHash = Convert.ToHexString(hashBytes).ToLowerInvariant(); - } - - if (!string.Equals(actualHash, plugin.Sha256, StringComparison.OrdinalIgnoreCase)) - { - AppLogger.Error( - "PluginMarket", - $"SHA-256 verification failed. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadUrl='{resolvedDownloadUrl}'; DownloadPath='{downloadPath}'; ExpectedHash='{plugin.Sha256}'; ActualHash='{actualHash}'; ExpectedSize='{plugin.PackageSizeBytes}'; ActualSize='{actualSize}'."); - File.Delete(downloadPath); return new AirAppMarketInstallResult( false, null, - $"SHA-256 mismatch. Expected {plugin.Sha256}, actual {actualHash}. Expected size {plugin.PackageSizeBytes}, actual size {actualSize}. Source {resolvedDownloadUrl}."); + $"Plugins install helper was not found at '{helperPath}'."); + } + } + + Directory.CreateDirectory(_downloadsDirectory); + var sources = plugin.GetPackageSourcesInInstallOrder(); + if (sources.Count == 0) + { + return new AirAppMarketInstallResult( + false, + null, + "Plugin does not declare any package sources."); + } + + AppLogger.Info( + "PluginMarket", + $"Starting install. PluginId='{plugin.Id}'; Version='{plugin.Version}'; Sources='{string.Join(", ", sources.Select(source => source.SourceKind.ToString()))}'."); + + var sourceErrors = new List(); + foreach (var source in sources) + { + var attemptResult = await TryInstallFromSourceAsync(plugin, source, cancellationToken).ConfigureAwait(false); + if (attemptResult.Success) + { + return new AirAppMarketInstallResult(true, attemptResult.Manifest, null); + } + + if (attemptResult.Fatal) + { + return new AirAppMarketInstallResult(false, null, attemptResult.ErrorMessage); + } + + if (!string.IsNullOrWhiteSpace(attemptResult.ErrorMessage)) + { + sourceErrors.Add($"{source.SourceKind}: {attemptResult.ErrorMessage}"); + } + } + + var combinedMessage = sourceErrors.Count == 0 + ? $"Failed to install plugin '{plugin.Id}' from all available package sources." + : $"Failed to install plugin '{plugin.Id}' from all available package sources. {string.Join(" ", sourceErrors)}"; + return new AirAppMarketInstallResult(false, null, combinedMessage); + } + + private async Task TryInstallFromSourceAsync( + AirAppMarketPluginEntry plugin, + AirAppMarketPluginPackageSourceEntry source, + CancellationToken cancellationToken = default) + { + var attemptPath = Path.Combine( + _downloadsDirectory, + $"{SanitizeFileName(plugin.Id)}-{SanitizeFileName(plugin.Version)}-{SanitizeFileName(source.SourceKind.ToString())}-{Guid.NewGuid():N}.laapp"); + + try + { + var resolvedDownloadUrl = await _releaseResolverService.ResolveDownloadUrlAsync(plugin, source, cancellationToken).ConfigureAwait(false); + AppLogger.Warn( + "PluginMarket", + $"Resolved package source for '{plugin.Id}' to '{resolvedDownloadUrl}' using '{source.SourceKind}'."); + + var acquireResult = await AcquirePackageAsync(plugin, source, resolvedDownloadUrl, attemptPath, cancellationToken).ConfigureAwait(false); + if (!acquireResult.Success) + { + TryDeleteFile(attemptPath); + return new AirAppMarketInstallAttemptResult(false, false, null, acquireResult.ErrorMessage); + } + + var verificationResult = await VerifyPackageAsync(plugin, attemptPath, cancellationToken).ConfigureAwait(false); + if (!verificationResult.Success) + { + TryDeleteFile(attemptPath); + return new AirAppMarketInstallAttemptResult(false, false, null, verificationResult.ErrorMessage); } PluginManifest manifest; if (OperatingSystem.IsWindows()) { var helperResult = await _helperClient.InstallPackageAsync( - downloadPath, + attemptPath, _runtime.PluginsDirectory, - cancellationToken); + cancellationToken).ConfigureAwait(false); if (!helperResult.Success || string.IsNullOrWhiteSpace(helperResult.InstalledPackagePath)) { - return new AirAppMarketInstallResult( - false, - null, - helperResult.ErrorMessage ?? "Plugins install helper failed."); + var helperMessage = helperResult.ErrorMessage ?? "Plugins install helper failed."; + AppLogger.Error( + "PluginMarket", + $"Windows install helper failed for plugin '{plugin.Id}' from source '{source.SourceKind}'. Message='{helperMessage}'."); + return new AirAppMarketInstallAttemptResult(false, true, null, helperMessage); } manifest = _runtime.RegisterInstalledPluginPackage(helperResult.InstalledPackagePath); } else { - manifest = _runtime.InstallPluginPackage(downloadPath); + manifest = _runtime.InstallPluginPackage(attemptPath); } AppLogger.Info( "PluginMarket", - $"Install staged successfully. PluginId='{manifest.Id}'; InstalledName='{manifest.Name}'; PackagePath='{downloadPath}'."); - return new AirAppMarketInstallResult(true, manifest, null); + $"Install staged successfully. PluginId='{manifest.Id}'; InstalledName='{manifest.Name}'; PackagePath='{attemptPath}'; SourceKind='{source.SourceKind}'."); + return new AirAppMarketInstallAttemptResult(true, true, manifest, null); } catch (OperationCanceledException) { AppLogger.Warn( "PluginMarket", - $"Install canceled. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{downloadPath}'."); + $"Install canceled. PluginId='{plugin.Id}'; Version='{plugin.Version}'; SourceKind='{source.SourceKind}'; DownloadPath='{attemptPath}'."); throw; } catch (Exception ex) { AppLogger.Error( "PluginMarket", - $"Install failed. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{downloadPath}'.", + $"Install attempt failed. PluginId='{plugin.Id}'; Version='{plugin.Version}'; SourceKind='{source.SourceKind}'; DownloadPath='{attemptPath}'.", ex); - return new AirAppMarketInstallResult(false, null, ex.Message); + TryDeleteFile(attemptPath); + return new AirAppMarketInstallAttemptResult(false, false, null, ex.Message); + } + } + + private async Task AcquirePackageAsync( + AirAppMarketPluginEntry plugin, + AirAppMarketPluginPackageSourceEntry source, + string resolvedDownloadUrl, + string attemptPath, + CancellationToken cancellationToken) + { + if (AirAppMarketDefaults.TryResolveWorkspaceFile(resolvedDownloadUrl, out var localPackagePath)) + { + if (source.SourceKind == PluginPackageSourceKind.WorkspaceLocal) + { + AppLogger.Info( + "PluginMarket", + $"Copying workspace package for '{plugin.Id}' from '{localPackagePath}' to '{attemptPath}'."); + } + + var localCopyResult = await _downloadService.DownloadAsync( + localPackagePath, + attemptPath, + new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes), + cancellationToken: cancellationToken).ConfigureAwait(false); + if (!localCopyResult.Success) + { + return new AirAppMarketAcquisitionResult(false, localCopyResult.ErrorMessage); + } + + return new AirAppMarketAcquisitionResult(true, null); + } + + if (source.SourceKind == PluginPackageSourceKind.WorkspaceLocal) + { + return new AirAppMarketAcquisitionResult( + false, + $"Workspace package source '{source.Url}' could not be resolved to a local file."); + } + + var downloadResult = await _downloadService.DownloadAsync( + resolvedDownloadUrl, + attemptPath, + new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes), + cancellationToken: cancellationToken).ConfigureAwait(false); + if (!downloadResult.Success) + { + return new AirAppMarketAcquisitionResult(false, downloadResult.ErrorMessage); + } + + return new AirAppMarketAcquisitionResult(true, null); + } + + private async Task VerifyPackageAsync( + AirAppMarketPluginEntry plugin, + string attemptPath, + CancellationToken cancellationToken) + { + var actualSize = new FileInfo(attemptPath).Length; + string actualHash; + await using (var hashStream = File.OpenRead(attemptPath)) + { + var hashBytes = await SHA256.HashDataAsync(hashStream, cancellationToken).ConfigureAwait(false); + actualHash = Convert.ToHexString(hashBytes).ToLowerInvariant(); + } + + if (actualSize != plugin.PackageSizeBytes || !string.Equals(actualHash, plugin.Sha256, StringComparison.OrdinalIgnoreCase)) + { + AppLogger.Error( + "PluginMarket", + $"Package verification failed. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{attemptPath}'; ExpectedHash='{plugin.Sha256}'; ActualHash='{actualHash}'; 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}."); + } + + return new AirAppMarketVerificationResult(true, null); + } + + private static string ResolveHelperPath() + { + return Path.Combine(AppContext.BaseDirectory, "PluginsInstallHelper", HelperExecutableName); + } + + private static void TryDeleteFile(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch + { + // Ignore cleanup failures for temporary install artifacts. } } @@ -152,4 +274,18 @@ internal sealed class AirAppMarketInstallService : IDisposable var invalidChars = Path.GetInvalidFileNameChars(); return new string(value.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray()); } + + private sealed record AirAppMarketInstallAttemptResult( + bool Success, + bool Fatal, + PluginManifest? Manifest, + string? ErrorMessage); + + private sealed record AirAppMarketAcquisitionResult( + bool Success, + string? ErrorMessage); + + private sealed record AirAppMarketVerificationResult( + bool Success, + string? ErrorMessage); } diff --git a/LanMountainDesktop/plugins/PluginMarketModels.cs b/LanMountainDesktop/plugins/PluginMarketModels.cs index 6ca2277..b02280c 100644 --- a/LanMountainDesktop/plugins/PluginMarketModels.cs +++ b/LanMountainDesktop/plugins/PluginMarketModels.cs @@ -13,6 +13,22 @@ internal static class AirAppMarketDefaults public const string DefaultIndexUrl = "https://raw.githubusercontent.com/wwiinnddyy/LanAirApp/main/airappmarket/index.json"; + public static string BuildGitHubRawUrl( + string owner, + string repositoryName, + string branch, + string relativePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(owner); + ArgumentException.ThrowIfNullOrWhiteSpace(repositoryName); + ArgumentException.ThrowIfNullOrWhiteSpace(branch); + ArgumentException.ThrowIfNullOrWhiteSpace(relativePath); + + return string.Create( + CultureInfo.InvariantCulture, + $"https://raw.githubusercontent.com/{owner.Trim()}/{repositoryName.Trim()}/{branch.Trim().TrimStart('/')}/{relativePath.Trim().TrimStart('/').Replace(Path.DirectorySeparatorChar, '/').Replace(Path.AltDirectorySeparatorChar, '/')}"); + } + public static string BuildGitHubReleaseDownloadUrl( string owner, string repositoryName, @@ -39,10 +55,31 @@ internal static class AirAppMarketDefaults { localPath = string.Empty; + if (File.Exists(url)) + { + localPath = Path.GetFullPath(url); + return true; + } + + if (Uri.TryCreate(url, UriKind.Absolute, out var fileUri) && + fileUri.IsFile) + { + var filePath = fileUri.LocalPath; + if (File.Exists(filePath)) + { + localPath = Path.GetFullPath(filePath); + return true; + } + } + string repositoryName; string relativePath; - if (TryParseGitHubReleaseDownloadUrl(url, out repositoryName, out var releaseAssetName)) + if (TryParseWorkspaceUrl(url, out repositoryName, out relativePath)) + { + // Already parsed from workspace://{repository}/{relativePath}. + } + else if (TryParseGitHubReleaseDownloadUrl(url, out repositoryName, out var releaseAssetName)) { relativePath = releaseAssetName; } @@ -148,6 +185,72 @@ internal static class AirAppMarketDefaults return !string.IsNullOrWhiteSpace(repositoryName) && !string.IsNullOrWhiteSpace(relativePath); } + private static bool TryParseWorkspaceUrl( + 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.Scheme, "workspace", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + repositoryName = uri.Host; + var path = Uri.UnescapeDataString(uri.AbsolutePath).TrimStart('/'); + if (string.IsNullOrWhiteSpace(path)) + { + return false; + } + + relativePath = path.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); + return !string.IsNullOrWhiteSpace(repositoryName) && !string.IsNullOrWhiteSpace(relativePath); + } + + public static bool TryParsePackageSourceKind(string? value, out PluginPackageSourceKind kind) + { + kind = PluginPackageSourceKind.ReleaseAsset; + var normalized = AirAppMarketIndexDocument.NormalizeValue(value); + if (string.IsNullOrWhiteSpace(normalized)) + { + return false; + } + + if (Enum.TryParse(normalized, ignoreCase: true, out kind)) + { + return true; + } + + switch (normalized) + { + case "releaseAsset": + kind = PluginPackageSourceKind.ReleaseAsset; + return true; + case "rawFallback": + kind = PluginPackageSourceKind.RawFallback; + return true; + case "workspaceLocal": + kind = PluginPackageSourceKind.WorkspaceLocal; + return true; + default: + return false; + } + } + + public static int GetPackageSourceOrder(PluginPackageSourceKind kind) + { + return kind switch + { + PluginPackageSourceKind.ReleaseAsset => 0, + PluginPackageSourceKind.RawFallback => 1, + PluginPackageSourceKind.WorkspaceLocal => 2, + _ => int.MaxValue + }; + } + private static bool TryParseGitHubReleaseDownloadUrl( string url, out string repositoryName, @@ -475,8 +578,392 @@ internal sealed class AirAppMarketPluginDependencyEntry } } +internal sealed class AirAppMarketPluginManifestEntry +{ + 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 EntranceAssembly { get; init; } = string.Empty; + + public List SharedContracts { get; init; } = []; + + public AirAppMarketPluginManifestEntry ValidateAndNormalize(string sourceName) + { + return new AirAppMarketPluginManifestEntry + { + Id = AirAppMarketIndexDocument.NormalizeValue(Id) + ?? throw new InvalidOperationException($"Market index '{sourceName}' is missing manifest.id."), + Name = AirAppMarketIndexDocument.NormalizeValue(Name) + ?? throw new InvalidOperationException($"Market index '{sourceName}' is missing manifest.name."), + Description = AirAppMarketIndexDocument.NormalizeValue(Description) + ?? throw new InvalidOperationException($"Market index '{sourceName}' is missing manifest.description."), + Author = AirAppMarketIndexDocument.NormalizeValue(Author) + ?? throw new InvalidOperationException($"Market index '{sourceName}' is missing manifest.author."), + Version = AirAppMarketIndexDocument.NormalizeVersion(Version, nameof(Version), sourceName), + ApiVersion = AirAppMarketIndexDocument.NormalizeVersion(ApiVersion, nameof(ApiVersion), sourceName), + EntranceAssembly = AirAppMarketIndexDocument.NormalizeValue(EntranceAssembly) ?? string.Empty, + SharedContracts = NormalizeDependencies(sourceName, SharedContracts) + }; + } + + private static List NormalizeDependencies( + string sourceName, + IReadOnlyList? dependencies) + { + var normalizedDependencies = new List((dependencies ?? []).Count); + var seenDependencies = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var dependency in dependencies ?? []) + { + var normalizedDependency = dependency.ValidateAndNormalize(sourceName); + var dependencyKey = $"{normalizedDependency.Id}@{normalizedDependency.Version}"; + if (!seenDependencies.Add(dependencyKey)) + { + throw new InvalidOperationException( + $"Market index '{sourceName}' declares duplicate dependency '{dependencyKey}' in plugin manifest."); + } + + normalizedDependencies.Add(normalizedDependency); + } + + return normalizedDependencies; + } +} + +internal sealed class AirAppMarketPluginCompatibilityEntry +{ + public string MinHostVersion { get; init; } = string.Empty; + + public string PluginApiVersion { get; init; } = string.Empty; + + public AirAppMarketPluginCompatibilityEntry ValidateAndNormalize(string sourceName) + { + return new AirAppMarketPluginCompatibilityEntry + { + MinHostVersion = AirAppMarketIndexDocument.NormalizeVersion( + MinHostVersion, + nameof(MinHostVersion), + sourceName), + PluginApiVersion = AirAppMarketIndexDocument.NormalizeVersion( + PluginApiVersion, + nameof(PluginApiVersion), + sourceName) + }; + } +} + +internal sealed class AirAppMarketPluginRepositoryEntry +{ + public string IconUrl { 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; + + public List Tags { get; init; } = []; + + public string ReleaseNotes { get; init; } = string.Empty; + + 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 normalizedTags = (Tags ?? []) + .Select(AirAppMarketIndexDocument.NormalizeValue) + .Where(tag => !string.IsNullOrWhiteSpace(tag)) + .Select(tag => tag!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(tag => tag, StringComparer.OrdinalIgnoreCase) + .ToList(); + + return new AirAppMarketPluginRepositoryEntry + { + IconUrl = normalizedIconUrl, + ProjectUrl = normalizedProjectUrl, + ReadmeUrl = normalizedReadmeUrl, + HomepageUrl = normalizedHomepageUrl, + RepositoryUrl = normalizedRepositoryUrl, + Tags = normalizedTags, + ReleaseNotes = AirAppMarketIndexDocument.NormalizeValue(ReleaseNotes) + ?? throw new InvalidOperationException($"Market index '{sourceName}' is missing repository.releaseNotes.") + }; + } +} + +internal sealed class AirAppMarketPluginPackageSourceEntry +{ + public string Kind { get; init; } = string.Empty; + + public string Url { get; init; } = string.Empty; + + public PluginPackageSourceKind SourceKind { get; init; } = PluginPackageSourceKind.ReleaseAsset; + + public AirAppMarketPluginPackageSourceEntry ValidateAndNormalize(string sourceName, string pluginId) + { + var normalizedKind = AirAppMarketIndexDocument.NormalizeValue(Kind) + ?? throw new InvalidOperationException( + $"Market index '{sourceName}' is missing package source kind for plugin '{pluginId}'."); + if (!AirAppMarketDefaults.TryParsePackageSourceKind(normalizedKind, out var sourceKind)) + { + throw new InvalidOperationException( + $"Market index '{sourceName}' declares invalid package source kind '{normalizedKind}' for plugin '{pluginId}'."); + } + + var normalizedUrl = AirAppMarketIndexDocument.NormalizeValue(Url) + ?? throw new InvalidOperationException( + $"Market index '{sourceName}' is missing package source url for plugin '{pluginId}'."); + EnsurePackageSourceUrl(normalizedUrl, sourceName, pluginId); + + return new AirAppMarketPluginPackageSourceEntry + { + Kind = sourceKind switch + { + PluginPackageSourceKind.ReleaseAsset => "releaseAsset", + PluginPackageSourceKind.RawFallback => "rawFallback", + PluginPackageSourceKind.WorkspaceLocal => "workspaceLocal", + _ => normalizedKind + }, + Url = normalizedUrl, + SourceKind = sourceKind + }; + } + + internal static void EnsurePackageSourceUrl(string url, string sourceName, string pluginId) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + { + if (File.Exists(url)) + { + return; + } + + throw new InvalidOperationException( + $"Market index '{sourceName}' declares invalid package source url '{url}' for plugin '{pluginId}'."); + } + + if (uri.IsFile || + uri.Scheme == Uri.UriSchemeHttp || + uri.Scheme == Uri.UriSchemeHttps || + string.Equals(uri.Scheme, "workspace", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + throw new InvalidOperationException( + $"Market index '{sourceName}' declares unsupported package source url scheme '{uri.Scheme}' for plugin '{pluginId}'."); + } +} + +internal sealed class AirAppMarketPluginPublicationEntry +{ + public string ReleaseTag { get; init; } = string.Empty; + + public string ReleaseAssetName { get; init; } = string.Empty; + + public DateTimeOffset PublishedAt { get; init; } + + public DateTimeOffset UpdatedAt { get; init; } + + public long PackageSizeBytes { get; init; } + + public string Sha256 { get; init; } = string.Empty; + + public string Md5 { get; init; } = string.Empty; + + public List PackageSources { get; init; } = []; + + 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) + { + throw new InvalidOperationException( + $"Market index '{sourceName}' is missing valid publication timestamps for plugin '{pluginId}'."); + } + + 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))) + { + throw new InvalidOperationException( + $"Market index '{sourceName}' declares invalid SHA-256 '{normalizedSha256}' for plugin '{pluginId}'."); + } + + var normalizedMd5 = AirAppMarketIndexDocument.NormalizeValue(Md5)?.ToLowerInvariant() ?? string.Empty; + if (!string.IsNullOrWhiteSpace(normalizedMd5) && + (normalizedMd5.Length != 32 || normalizedMd5.Any(ch => !Uri.IsHexDigit(ch)))) + { + throw new InvalidOperationException( + $"Market index '{sourceName}' declares invalid MD5 '{normalizedMd5}' for plugin '{pluginId}'."); + } + + var normalizedPackageSources = NormalizePackageSources(PackageSources, sourceName, pluginId); + + return new AirAppMarketPluginPublicationEntry + { + ReleaseTag = normalizedReleaseTag, + ReleaseAssetName = normalizedReleaseAssetName, + PublishedAt = PublishedAt, + UpdatedAt = UpdatedAt, + PackageSizeBytes = PackageSizeBytes, + Sha256 = normalizedSha256, + Md5 = normalizedMd5, + PackageSources = normalizedPackageSources + }; + } + + private static List NormalizePackageSources( + IReadOnlyList? packageSources, + string sourceName, + string pluginId) + { + var normalizedSources = new List((packageSources ?? []).Count); + var seenKinds = new HashSet(); + var previousOrder = -1; + foreach (var source in packageSources ?? []) + { + var normalizedSource = source.ValidateAndNormalize(sourceName, pluginId); + var order = AirAppMarketDefaults.GetPackageSourceOrder(normalizedSource.SourceKind); + if (order < previousOrder) + { + throw new InvalidOperationException( + $"Market index '{sourceName}' declares packageSources out of order for plugin '{pluginId}'. Expected releaseAsset -> rawFallback -> workspaceLocal."); + } + + previousOrder = order; + if (!seenKinds.Add(normalizedSource.SourceKind)) + { + throw new InvalidOperationException( + $"Market index '{sourceName}' declares duplicate package source kind '{normalizedSource.Kind}' for plugin '{pluginId}'."); + } + + normalizedSources.Add(normalizedSource); + } + + return normalizedSources; + } +} + +internal sealed class AirAppMarketPluginCapabilitiesEntry +{ + public List SharedContracts { get; init; } = []; + + public List DesktopComponents { get; init; } = []; + + public List SettingsSections { get; init; } = []; + + public List Exports { get; init; } = []; + + public List MessageTypes { get; init; } = []; + + public AirAppMarketPluginCapabilitiesEntry ValidateAndNormalize(string sourceName) + { + return new AirAppMarketPluginCapabilitiesEntry + { + SharedContracts = NormalizeDependencies(sourceName, SharedContracts), + DesktopComponents = NormalizeValues(DesktopComponents), + SettingsSections = NormalizeValues(SettingsSections), + Exports = NormalizeValues(Exports), + MessageTypes = NormalizeValues(MessageTypes) + }; + } + + private static List NormalizeDependencies( + string sourceName, + IReadOnlyList? dependencies) + { + var normalizedDependencies = new List((dependencies ?? []).Count); + var seenDependencies = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var dependency in dependencies ?? []) + { + var normalizedDependency = dependency.ValidateAndNormalize(sourceName); + var key = $"{normalizedDependency.Id}@{normalizedDependency.Version}"; + if (!seenDependencies.Add(key)) + { + throw new InvalidOperationException( + $"Market index '{sourceName}' declares duplicate capability dependency '{key}'."); + } + + normalizedDependencies.Add(normalizedDependency); + } + + return normalizedDependencies; + } + + private static List NormalizeValues(IReadOnlyList? values) + { + return (values ?? []) + .Select(AirAppMarketIndexDocument.NormalizeValue) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(value => value, StringComparer.OrdinalIgnoreCase) + .ToList(); + } +} + internal sealed class AirAppMarketPluginEntry { + public string PluginId { get; init; } = string.Empty; + + public AirAppMarketPluginManifestEntry? Manifest { get; init; } + + public AirAppMarketPluginCompatibilityEntry? Compatibility { get; init; } + + public AirAppMarketPluginRepositoryEntry? Repository { get; init; } + + public AirAppMarketPluginPublicationEntry? Publication { get; init; } + + public AirAppMarketPluginCapabilitiesEntry? Capabilities { get; init; } + public string Id { get; init; } = string.Empty; public string Name { get; init; } = string.Empty; @@ -515,6 +1002,10 @@ internal sealed class AirAppMarketPluginEntry public List SharedContracts { get; init; } = []; + public List PackageSources { get; init; } = []; + + public string Md5 { get; init; } = string.Empty; + public DateTimeOffset PublishedAt { get; init; } public DateTimeOffset UpdatedAt { get; init; } @@ -527,128 +1018,208 @@ internal sealed class AirAppMarketPluginEntry public AirAppMarketPluginEntry ValidateAndNormalize(string sourceName) { - var normalizedTags = (Tags ?? []) - .Select(tag => AirAppMarketIndexDocument.NormalizeValue(tag)) + var normalizedManifest = HasManifestData(Manifest) + ? Manifest!.ValidateAndNormalize(sourceName) + : null; + var normalizedCompatibility = HasCompatibilityData(Compatibility) + ? Compatibility!.ValidateAndNormalize(sourceName) + : null; + var normalizedRepository = HasRepositoryData(Repository) + ? Repository!.ValidateAndNormalize(sourceName) + : null; + var normalizedCapabilities = HasCapabilitiesData(Capabilities) + ? Capabilities!.ValidateAndNormalize(sourceName) + : null; + var resolvedPluginId = FirstNonEmpty( + normalizedManifest?.Id, + AirAppMarketIndexDocument.NormalizeValue(PluginId), + AirAppMarketIndexDocument.NormalizeValue(Id)) + ?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin id."); + var normalizedPublication = HasPublicationData(Publication) + ? 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, + sourceName, + resolvedPluginId, + resolvedReleaseTag, + resolvedReleaseAssetName, + resolvedRepositoryUrl, + AirAppMarketIndexDocument.NormalizeValue(DownloadUrl)); + if (resolvedPackageSources.Count == 0) + { + throw new InvalidOperationException( + $"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) + { + throw new InvalidOperationException( + $"Market index '{sourceName}' is missing valid publish timestamps for plugin '{resolvedPluginId}'."); + } + + var resolvedDependencies = NormalizeDependencies( + normalizedManifest?.SharedContracts, + normalizedCapabilities?.SharedContracts, + SharedContracts, + sourceName, + resolvedPluginId); + var resolvedTags = (normalizedRepository?.Tags ?? Tags ?? []) + .Select(AirAppMarketIndexDocument.NormalizeValue) .Where(tag => !string.IsNullOrWhiteSpace(tag)) .Select(tag => tag!) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(tag => tag, StringComparer.OrdinalIgnoreCase) .ToList(); - var normalizedDependencies = new List((SharedContracts ?? []).Count); - var seenDependencies = new HashSet(StringComparer.OrdinalIgnoreCase); - - foreach (var dependency in SharedContracts ?? []) - { - var normalizedDependency = dependency.ValidateAndNormalize(sourceName); - var dependencyKey = $"{normalizedDependency.Id}@{normalizedDependency.Version}"; - if (!seenDependencies.Add(dependencyKey)) - { - throw new InvalidOperationException( - $"Market index '{sourceName}' declares duplicate dependency '{dependencyKey}' for plugin '{Id}'."); - } - - normalizedDependencies.Add(normalizedDependency); - } - - var normalizedSha = AirAppMarketIndexDocument.NormalizeValue(Sha256)?.ToLowerInvariant() + var resolvedReleaseNotes = FirstNonEmpty( + normalizedRepository?.ReleaseNotes, + AirAppMarketIndexDocument.NormalizeValue(ReleaseNotes)) ?? 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 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)}'."); - 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); - 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); - - 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) - { - 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}'."); - } + $"Market index '{sourceName}' is missing release notes for plugin '{resolvedPluginId}'."); 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, - ReleaseTag = normalizedReleaseTag ?? string.Empty, - ReleaseAssetName = normalizedReleaseAssetName ?? string.Empty, - ProjectUrl = normalizedProjectUrl, - ReadmeUrl = normalizedReadmeUrl, - HomepageUrl = normalizedHomepageUrl, - RepositoryUrl = normalizedRepositoryUrl, - Tags = normalizedTags, - SharedContracts = normalizedDependencies, - PublishedAt = PublishedAt, - UpdatedAt = UpdatedAt, - ReleaseNotes = AirAppMarketIndexDocument.NormalizeValue(ReleaseNotes) - ?? throw new InvalidOperationException($"Market index '{sourceName}' is missing release notes for plugin '{Id}'.") + PluginId = resolvedPluginId, + Manifest = normalizedManifest, + Compatibility = normalizedCompatibility, + Repository = normalizedRepository, + Publication = normalizedPublication, + Capabilities = normalizedCapabilities, + Id = resolvedPluginId, + Name = resolvedName, + Description = resolvedDescription, + Author = resolvedAuthor, + Version = resolvedVersion, + ApiVersion = resolvedApiVersion, + MinHostVersion = resolvedMinHostVersion, + DownloadUrl = resolvedDownloadUrl, + Sha256 = resolvedSha256, + Md5 = resolvedMd5, + PackageSizeBytes = resolvedPackageSize, + IconUrl = resolvedIconUrl, + ReleaseTag = resolvedReleaseTag ?? string.Empty, + ReleaseAssetName = resolvedReleaseAssetName ?? string.Empty, + ProjectUrl = resolvedProjectUrl, + ReadmeUrl = resolvedReadmeUrl, + HomepageUrl = resolvedHomepageUrl, + RepositoryUrl = resolvedRepositoryUrl, + Tags = resolvedTags, + SharedContracts = resolvedDependencies, + PackageSources = resolvedPackageSources, + PublishedAt = resolvedPublishedAt, + UpdatedAt = resolvedUpdatedAt, + ReleaseNotes = resolvedReleaseNotes }; } @@ -661,4 +1232,188 @@ internal sealed class AirAppMarketPluginEntry ApiVersion, MinHostVersion); } + + public IReadOnlyList GetPackageSourcesInInstallOrder() + { + if (PackageSources.Count > 0) + { + return PackageSources + .OrderBy(source => AirAppMarketDefaults.GetPackageSourceOrder(source.SourceKind)) + .ToList(); + } + + if (string.IsNullOrWhiteSpace(DownloadUrl)) + { + return []; + } + + var sourceKind = HasReleaseDownloadMetadata + ? PluginPackageSourceKind.ReleaseAsset + : PluginPackageSourceKind.RawFallback; + return + [ + new AirAppMarketPluginPackageSourceEntry + { + Kind = sourceKind switch + { + PluginPackageSourceKind.ReleaseAsset => "releaseAsset", + PluginPackageSourceKind.RawFallback => "rawFallback", + PluginPackageSourceKind.WorkspaceLocal => "workspaceLocal", + _ => "rawFallback" + }, + Url = DownloadUrl, + SourceKind = sourceKind + } + ]; + } + + private static bool HasManifestData(AirAppMarketPluginManifestEntry? manifest) + { + return manifest is not null && + (!string.IsNullOrWhiteSpace(manifest.Id) || + !string.IsNullOrWhiteSpace(manifest.Name) || + !string.IsNullOrWhiteSpace(manifest.Version)); + } + + private static bool HasCompatibilityData(AirAppMarketPluginCompatibilityEntry? compatibility) + { + return compatibility is not null && + (!string.IsNullOrWhiteSpace(compatibility.MinHostVersion) || + !string.IsNullOrWhiteSpace(compatibility.PluginApiVersion)); + } + + private static bool HasRepositoryData(AirAppMarketPluginRepositoryEntry? repository) + { + return repository is not null && + (!string.IsNullOrWhiteSpace(repository.IconUrl) || + !string.IsNullOrWhiteSpace(repository.ProjectUrl) || + !string.IsNullOrWhiteSpace(repository.RepositoryUrl)); + } + + private static bool HasPublicationData(AirAppMarketPluginPublicationEntry? publication) + { + return publication is not null && + (!string.IsNullOrWhiteSpace(publication.ReleaseTag) || + !string.IsNullOrWhiteSpace(publication.ReleaseAssetName) || + publication.PackageSources.Count > 0); + } + + private static bool HasCapabilitiesData(AirAppMarketPluginCapabilitiesEntry? capabilities) + { + return capabilities is not null && + (capabilities.SharedContracts.Count > 0 || + capabilities.DesktopComponents.Count > 0 || + capabilities.SettingsSections.Count > 0 || + capabilities.Exports.Count > 0 || + capabilities.MessageTypes.Count > 0); + } + + private static List NormalizeDependencies( + IReadOnlyList? manifestDependencies, + IReadOnlyList? capabilityDependencies, + IReadOnlyList? legacyDependencies, + string sourceName, + string pluginId) + { + IReadOnlyList dependencies = manifestDependencies is { Count: > 0 } + ? manifestDependencies + : capabilityDependencies is { Count: > 0 } + ? capabilityDependencies + : legacyDependencies ?? []; + + var normalizedDependencies = new List(dependencies.Count); + var seenDependencies = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var dependency in dependencies) + { + var normalizedDependency = dependency.ValidateAndNormalize(sourceName); + var dependencyKey = $"{normalizedDependency.Id}@{normalizedDependency.Version}"; + if (!seenDependencies.Add(dependencyKey)) + { + throw new InvalidOperationException( + $"Market index '{sourceName}' declares duplicate dependency '{dependencyKey}' for plugin '{pluginId}'."); + } + + normalizedDependencies.Add(normalizedDependency); + } + + return normalizedDependencies; + } + + private static List NormalizePackageSources( + IReadOnlyList? packageSources, + string sourceName, + string pluginId, + string? releaseTag, + string? releaseAssetName, + string repositoryUrl, + string? legacyDownloadUrl) + { + var normalizedSources = new List((packageSources ?? []).Count + 1); + foreach (var source in packageSources ?? []) + { + normalizedSources.Add(source.ValidateAndNormalize(sourceName, pluginId)); + } + + if (normalizedSources.Count > 0) + { + return normalizedSources + .OrderBy(source => AirAppMarketDefaults.GetPackageSourceOrder(source.SourceKind)) + .ToList(); + } + + 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" + }, + Url = normalizedLegacyDownloadUrl, + SourceKind = legacyKind + }; + 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; + } + + 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; + } } diff --git a/LanMountainDesktop/plugins/PluginMarketReleaseResolverService.cs b/LanMountainDesktop/plugins/PluginMarketReleaseResolverService.cs index a29e0b0..19a4fcb 100644 --- a/LanMountainDesktop/plugins/PluginMarketReleaseResolverService.cs +++ b/LanMountainDesktop/plugins/PluginMarketReleaseResolverService.cs @@ -22,14 +22,46 @@ internal sealed class AirAppMarketReleaseResolverService { ArgumentNullException.ThrowIfNull(plugin); - if (!plugin.HasReleaseDownloadMetadata) + var firstSource = plugin.GetPackageSourcesInInstallOrder().FirstOrDefault(); + if (firstSource is null) { return plugin.DownloadUrl; } + return await ResolveDownloadUrlAsync(plugin, firstSource, cancellationToken).ConfigureAwait(false); + } + + public async Task ResolveDownloadUrlAsync( + AirAppMarketPluginEntry plugin, + AirAppMarketPluginPackageSourceEntry source, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(plugin); + ArgumentNullException.ThrowIfNull(source); + + return source.SourceKind switch + { + PluginPackageSourceKind.ReleaseAsset => await ResolveReleaseAssetDownloadUrlAsync(plugin, source, cancellationToken).ConfigureAwait(false), + PluginPackageSourceKind.RawFallback => source.Url, + PluginPackageSourceKind.WorkspaceLocal => source.Url, + _ => source.Url + }; + } + + private async Task ResolveReleaseAssetDownloadUrlAsync( + AirAppMarketPluginEntry plugin, + AirAppMarketPluginPackageSourceEntry source, + CancellationToken cancellationToken) + { + var sourceUrl = source.Url; + if (!plugin.HasReleaseDownloadMetadata) + { + return sourceUrl; + } + if (!TryGetRepositoryIdentity(plugin, out var owner, out var repositoryName)) { - return plugin.DownloadUrl; + return sourceUrl; } var releaseDownloadUrl = AirAppMarketDefaults.BuildGitHubReleaseDownloadUrl( @@ -46,15 +78,15 @@ internal sealed class AirAppMarketReleaseResolverService try { using var updateService = new GitHubReleaseUpdateService(owner, repositoryName, _httpClient); - var release = await updateService.GetReleaseByTagAsync(plugin.ReleaseTag, cancellationToken); + var release = await updateService.GetReleaseByTagAsync(plugin.ReleaseTag, cancellationToken).ConfigureAwait(false); var asset = release?.Assets.FirstOrDefault(candidate => string.Equals(candidate.Name, plugin.ReleaseAssetName, StringComparison.OrdinalIgnoreCase)); - return asset?.BrowserDownloadUrl ?? plugin.DownloadUrl; + return asset?.BrowserDownloadUrl ?? releaseDownloadUrl; } catch { - return plugin.DownloadUrl; + return releaseDownloadUrl; } }