This commit is contained in:
lincube
2026-03-24 23:15:32 +08:00
parent b83cfb47b0
commit 26ff11b16b
7 changed files with 1619 additions and 233 deletions

View File

@@ -1,13 +1,16 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using LanMountainDesktop.Models; using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk; using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services; using LanMountainDesktop.Services;
using LanMountainDesktop.Services.PluginMarket;
using LanMountainDesktop.Settings.Core; using LanMountainDesktop.Settings.Core;
namespace LanMountainDesktop.Services.Settings; namespace LanMountainDesktop.Services.Settings
{
public enum WallpaperMediaType public enum WallpaperMediaType
{ {
@@ -66,10 +69,272 @@ public sealed record UpdateSettingsState(
long? PendingUpdatePublishedAtUtcMs, long? PendingUpdatePublishedAtUtcMs,
long? LastUpdateCheckUtcMs); long? LastUpdateCheckUtcMs);
public sealed record PluginManagementSettingsState(IReadOnlyList<string> DisabledPluginIds); public sealed record PluginManagementSettingsState(IReadOnlyList<string> 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<PluginCatalogSharedContractInfo> SharedContracts);
public sealed record PluginCatalogCompatibilityInfo(
string MinHostVersion,
string ApiVersion);
public sealed record PluginCatalogRepositoryInfo(
string IconUrl,
string ProjectUrl,
string ReadmeUrl,
string HomepageUrl,
string RepositoryUrl,
IReadOnlyList<string> 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<PluginPackageSourceInfo> PackageSources,
IReadOnlyList<PluginCapabilityInfo> 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<string> Tags => Repository.Tags;
public IReadOnlyList<PluginCatalogSharedContractInfo> SharedContracts => Manifest.SharedContracts;
public IReadOnlyList<PluginCatalogDependencyInfo> 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<PluginCatalogItemInfo> Plugins,
IReadOnlyList<PluginCatalogSourceInfo> 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<PluginInstallDiagnostic> 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( public sealed record PluginMarketDependencyInfo(
string Id, string Id,
string Version, string Version,
string AssemblyName); string AssemblyName);
[Obsolete("Use PluginCatalogItemInfo instead.")]
public sealed record PluginMarketPluginInfo( public sealed record PluginMarketPluginInfo(
string Id, string Id,
string Name, string Name,
@@ -89,6 +354,8 @@ public sealed record PluginMarketPluginInfo(
IReadOnlyList<PluginMarketDependencyInfo> Dependencies, IReadOnlyList<PluginMarketDependencyInfo> Dependencies,
DateTimeOffset PublishedAt, DateTimeOffset PublishedAt,
DateTimeOffset UpdatedAt); DateTimeOffset UpdatedAt);
[Obsolete("Use PluginCatalogIndexResult instead.")]
public sealed record PluginMarketIndexResult( public sealed record PluginMarketIndexResult(
bool Success, bool Success,
IReadOnlyList<PluginMarketPluginInfo> Plugins, IReadOnlyList<PluginMarketPluginInfo> Plugins,
@@ -96,12 +363,39 @@ public sealed record PluginMarketIndexResult(
string? SourceLocation, string? SourceLocation,
string? WarningMessage, string? WarningMessage,
string? ErrorMessage); string? ErrorMessage);
[Obsolete("Use PluginCatalogInstallResult instead.")]
public sealed record PluginMarketInstallResult( public sealed record PluginMarketInstallResult(
bool Success, bool Success,
string? PluginId, string? PluginId,
string? PluginName, string? PluginName,
string? ErrorMessage); string? ErrorMessage);
public interface IPluginCatalogSourceProvider
{
Task<PluginCatalogIndexResult> LoadCatalogAsync(CancellationToken cancellationToken = default);
}
public interface IPluginCatalogService : IPluginCatalogSourceProvider
{
Task<PluginCatalogInstallResult> InstallAsync(string pluginId, CancellationToken cancellationToken = default);
}
public interface IPackageSourceResolver
{
IReadOnlyList<PluginPackageSourceInfo> ResolveSources(PluginCatalogItemInfo item);
}
public interface IPluginCompatibilityEvaluator
{
PluginInstallDiagnostic? Evaluate(PluginCatalogItemInfo item, Version? hostVersion);
}
public interface IPluginInstallOrchestrator
{
Task<PluginCatalogInstallResult> InstallAsync(PluginCatalogItemInfo item, CancellationToken cancellationToken = default);
}
public interface IGridSettingsService public interface IGridSettingsService
{ {
GridSettingsState Get(); GridSettingsState Get();
@@ -223,10 +517,17 @@ public interface IPluginManagementSettingsService
bool DeleteInstalledPlugin(string pluginId); bool DeleteInstalledPlugin(string pluginId);
} }
public interface IPluginMarketSettingsService public interface IPluginCatalogSettingsService : IPluginCatalogSourceProvider
{
new Task<PluginCatalogIndexResult> LoadCatalogAsync(CancellationToken cancellationToken = default);
Task<PluginCatalogInstallResult> InstallAsync(string pluginId, CancellationToken cancellationToken = default);
}
[Obsolete("Use IPluginCatalogSettingsService instead.")]
public interface IPluginMarketSettingsService : IPluginCatalogSettingsService
{ {
Task<PluginMarketIndexResult> LoadIndexAsync(CancellationToken cancellationToken = default); Task<PluginMarketIndexResult> LoadIndexAsync(CancellationToken cancellationToken = default);
Task<PluginMarketInstallResult> InstallAsync(string pluginId, CancellationToken cancellationToken = default); new Task<PluginMarketInstallResult> InstallAsync(string pluginId, CancellationToken cancellationToken = default);
} }
public interface IApplicationInfoService public interface IApplicationInfoService
@@ -252,6 +553,20 @@ public interface ISettingsFacadeService
ILauncherCatalogService LauncherCatalog { get; } ILauncherCatalogService LauncherCatalog { get; }
ILauncherPolicyService LauncherPolicy { get; } ILauncherPolicyService LauncherPolicy { get; }
IPluginManagementSettingsService PluginManagement { get; } IPluginManagementSettingsService PluginManagement { get; }
IPluginCatalogSettingsService PluginCatalog { get; }
[Obsolete("Use PluginCatalog instead.")]
IPluginMarketSettingsService PluginMarket { get; } IPluginMarketSettingsService PluginMarket { get; }
IApplicationInfoService ApplicationInfo { get; } IApplicationInfoService ApplicationInfo { get; }
} }
}
namespace LanMountainDesktop.Services.PluginMarket
{
internal enum PluginPackageSourceKind
{
ReleaseAsset = 0,
RawFallback = 1,
WorkspaceLocal = 2
}
}

View File

@@ -870,14 +870,41 @@ internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService
_installService = new AirAppMarketInstallService(_pluginRuntimeService, dataRoot); _installService = new AirAppMarketInstallService(_pluginRuntimeService, dataRoot);
} }
public async Task<PluginMarketIndexResult> LoadIndexAsync(CancellationToken cancellationToken = default) public Task<PluginCatalogIndexResult> LoadCatalogAsync(CancellationToken cancellationToken = default)
{ {
var result = await _indexService.LoadAsync(cancellationToken); return LoadCatalogCoreAsync(cancellationToken);
}
async Task<PluginMarketIndexResult> IPluginMarketSettingsService.LoadIndexAsync(CancellationToken cancellationToken)
{
return await LoadCatalogCoreAsync(cancellationToken).ConfigureAwait(false);
}
public Task<PluginCatalogInstallResult> InstallAsync(
string pluginId,
CancellationToken cancellationToken = default)
{
return InstallCatalogCoreAsync(pluginId, cancellationToken);
}
async Task<PluginMarketInstallResult> IPluginMarketSettingsService.InstallAsync(
string pluginId,
CancellationToken cancellationToken)
{
return await InstallCatalogCoreAsync(pluginId, cancellationToken).ConfigureAwait(false);
}
private async Task<PluginCatalogIndexResult> 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) if (!result.Success || result.Document is null)
{ {
return new PluginMarketIndexResult( _cachedPlugins.Clear();
return new PluginCatalogIndexResult(
false, false,
[], [],
sources,
result.Source?.ToString(), result.Source?.ToString(),
result.SourceLocation, result.SourceLocation,
result.WarningMessage, result.WarningMessage,
@@ -889,81 +916,189 @@ internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService
.Select(entry => .Select(entry =>
{ {
_cachedPlugins[entry.Id] = entry; _cachedPlugins[entry.Id] = entry;
return new PluginMarketPluginInfo( return MapCatalogItem(entry);
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);
}) })
.ToArray(); .ToArray();
return new PluginMarketIndexResult( return new PluginCatalogIndexResult(
true, true,
plugins, plugins,
sources,
result.Source?.ToString(), result.Source?.ToString(),
result.SourceLocation, result.SourceLocation,
result.WarningMessage, result.WarningMessage,
null); null);
} }
public async Task<PluginMarketInstallResult> InstallAsync( private async Task<PluginCatalogInstallResult> InstallCatalogCoreAsync(
string pluginId, string pluginId,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
if (string.IsNullOrWhiteSpace(pluginId)) 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) if (_installService is null || _pluginRuntimeService is null)
{ {
return new PluginMarketInstallResult( return new PluginCatalogInstallResult(
false, false,
pluginId, pluginId,
null, null,
null,
[new PluginInstallDiagnostic("runtime_unavailable", "Plugin runtime is unavailable.")],
"Plugin runtime is unavailable."); "Plugin runtime is unavailable.");
} }
if (!_cachedPlugins.TryGetValue(pluginId, out var entry)) if (!_cachedPlugins.TryGetValue(pluginId, out var entry))
{ {
var load = await LoadIndexAsync(cancellationToken); var load = await LoadCatalogCoreAsync(cancellationToken).ConfigureAwait(false);
if (!load.Success) 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)) 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) 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<PluginPackageSourceInfo> 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<PluginCatalogSourceInfo> 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() public void Dispose()
@@ -1054,6 +1189,7 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl
_pluginManagementSettingsService = new PluginManagementSettingsService(Settings, pluginRuntimeService); _pluginManagementSettingsService = new PluginManagementSettingsService(Settings, pluginRuntimeService);
PluginManagement = _pluginManagementSettingsService; PluginManagement = _pluginManagementSettingsService;
_pluginMarketSettingsService = new PluginMarketSettingsService(pluginRuntimeService); _pluginMarketSettingsService = new PluginMarketSettingsService(pluginRuntimeService);
PluginCatalog = _pluginMarketSettingsService;
PluginMarket = _pluginMarketSettingsService; PluginMarket = _pluginMarketSettingsService;
ApplicationInfo = new ApplicationInfoService(); ApplicationInfo = new ApplicationInfoService();
} }
@@ -1086,6 +1222,8 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl
public IPluginManagementSettingsService PluginManagement { get; } public IPluginManagementSettingsService PluginManagement { get; }
public IPluginCatalogSettingsService PluginCatalog { get; }
public IPluginMarketSettingsService PluginMarket { get; } public IPluginMarketSettingsService PluginMarket { get; }
public IApplicationInfoService ApplicationInfo { get; } public IApplicationInfoService ApplicationInfo { get; }

View File

@@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Globalization; using System.Globalization;
@@ -31,7 +31,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
private bool _isLoadingIcon; private bool _isLoadingIcon;
public PluginMarketItemViewModel( public PluginMarketItemViewModel(
PluginMarketPluginInfo plugin, PluginCatalogItemInfo plugin,
LocalizationService localizationService, LocalizationService localizationService,
string languageCode) string languageCode)
{ {
@@ -46,7 +46,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
ActionTooltip = L("market.button.install", "Install"); ActionTooltip = L("market.button.install", "Install");
} }
public PluginMarketPluginInfo Info { get; } public PluginCatalogItemInfo Info { get; }
public string PluginId => Info.Id; public string PluginId => Info.Id;
@@ -64,7 +64,11 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
public string ReadmeUrl => Info.ReadmeUrl; public string ReadmeUrl => Info.ReadmeUrl;
public IReadOnlyList<PluginMarketDependencyInfo> Dependencies => Info.Dependencies; public IReadOnlyList<PluginCatalogSharedContractInfo> Dependencies => Info.SharedContracts;
public IReadOnlyList<PluginPackageSourceInfo> PackageSources => Info.PackageSources;
public IReadOnlyList<PluginCapabilityInfo> Capabilities => Info.Capabilities;
public string IconFallbackText { get; } public string IconFallbackText { get; }
@@ -259,7 +263,7 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase
_readmeService = readmeService; _readmeService = readmeService;
_primaryActionAsync = primaryActionAsync; _primaryActionAsync = primaryActionAsync;
Dependencies = new ObservableCollection<PluginMarketDependencyInfo>(item.Dependencies); Dependencies = new ObservableCollection<PluginCatalogSharedContractInfo>(item.Dependencies);
VersionLabel = L("market.detail.version", "Version"); VersionLabel = L("market.detail.version", "Version");
PublisherLabel = L("market.detail.author", "Author"); PublisherLabel = L("market.detail.author", "Author");
ApiVersionLabel = L("market.detail.api_version", "API Version"); ApiVersionLabel = L("market.detail.api_version", "API Version");
@@ -271,7 +275,7 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase
public PluginMarketItemViewModel Item { get; } public PluginMarketItemViewModel Item { get; }
public ObservableCollection<PluginMarketDependencyInfo> Dependencies { get; } public ObservableCollection<PluginCatalogSharedContractInfo> Dependencies { get; }
public string DrawerTitle => Item.Name; public string DrawerTitle => Item.Name;
@@ -306,6 +310,10 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase
public bool HasReadmeContent => !IsReadmeLoading && !HasReadmeError && !string.IsNullOrWhiteSpace(ReadmeMarkdown); public bool HasReadmeContent => !IsReadmeLoading && !HasReadmeError && !string.IsNullOrWhiteSpace(ReadmeMarkdown);
public IReadOnlyList<PluginPackageSourceInfo> PackageSources => Item.PackageSources;
public IReadOnlyList<PluginCapabilityInfo> Capabilities => Item.Capabilities;
public async Task InitializeAsync() public async Task InitializeAsync()
{ {
if (_isInitialized) if (_isInitialized)
@@ -370,6 +378,7 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase
public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
{ {
private readonly ISettingsFacadeService _settingsFacade; private readonly ISettingsFacadeService _settingsFacade;
private readonly IPluginCatalogSettingsService _pluginCatalog;
private readonly LocalizationService _localizationService; private readonly LocalizationService _localizationService;
private readonly AirAppMarketIconService _iconService; private readonly AirAppMarketIconService _iconService;
private readonly AirAppMarketReadmeService _readmeService; private readonly AirAppMarketReadmeService _readmeService;
@@ -386,6 +395,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
AirAppMarketReadmeService readmeService) AirAppMarketReadmeService readmeService)
{ {
_settingsFacade = settingsFacade; _settingsFacade = settingsFacade;
_pluginCatalog = _settingsFacade.PluginCatalog;
_localizationService = localizationService; _localizationService = localizationService;
_iconService = iconService; _iconService = iconService;
_readmeService = readmeService; _readmeService = readmeService;
@@ -468,7 +478,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
StatusMessage = L("market.status.loading", "Loading the official plugin market..."); StatusMessage = L("market.status.loading", "Loading the official plugin market...");
RefreshInstalledSnapshot(); RefreshInstalledSnapshot();
var result = await _settingsFacade.PluginMarket.LoadIndexAsync(); var result = await _pluginCatalog.LoadCatalogAsync();
if (!result.Success) if (!result.Success)
{ {
_hasLoadedMarket = false; _hasLoadedMarket = false;
@@ -559,7 +569,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
L("market.status.installing_format", "Downloading and staging plugin '{0}'..."), L("market.status.installing_format", "Downloading and staging plugin '{0}'..."),
item.Name); item.Name);
var result = await _settingsFacade.PluginMarket.InstallAsync(item.PluginId); var result = await _pluginCatalog.InstallAsync(item.PluginId);
if (result.Success) if (result.Success)
{ {
RefreshInstalledSnapshot(); RefreshInstalledSnapshot();

View File

@@ -198,7 +198,7 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
foreach (var visual in _itemVisuals) 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")); visual.TitleTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
} }

View File

@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Net.Http; using System.Net.Http;
@@ -12,6 +13,8 @@ namespace LanMountainDesktop.Services.PluginMarket;
internal sealed class AirAppMarketInstallService : IDisposable internal sealed class AirAppMarketInstallService : IDisposable
{ {
private const string HelperExecutableName = "LanMountainDesktop.PluginsInstallHelper.exe";
private readonly PluginRuntimeService _runtime; private readonly PluginRuntimeService _runtime;
private readonly PluginsInstallHelperClient _helperClient = new(); private readonly PluginsInstallHelperClient _helperClient = new();
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
@@ -38,107 +41,226 @@ internal sealed class AirAppMarketInstallService : IDisposable
{ {
ArgumentNullException.ThrowIfNull(plugin); ArgumentNullException.ThrowIfNull(plugin);
Directory.CreateDirectory(_downloadsDirectory); if (OperatingSystem.IsWindows())
var downloadPath = Path.Combine(
_downloadsDirectory,
$"{SanitizeFileName(plugin.Id)}-{SanitizeFileName(plugin.Version)}.laapp");
try
{ {
AppLogger.Info( var helperPath = ResolveHelperPath();
"PluginMarket", if (!File.Exists(helperPath))
$"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 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( return new AirAppMarketInstallResult(
false, false,
null, 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<string>();
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<AirAppMarketInstallAttemptResult> 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; PluginManifest manifest;
if (OperatingSystem.IsWindows()) if (OperatingSystem.IsWindows())
{ {
var helperResult = await _helperClient.InstallPackageAsync( var helperResult = await _helperClient.InstallPackageAsync(
downloadPath, attemptPath,
_runtime.PluginsDirectory, _runtime.PluginsDirectory,
cancellationToken); cancellationToken).ConfigureAwait(false);
if (!helperResult.Success || string.IsNullOrWhiteSpace(helperResult.InstalledPackagePath)) if (!helperResult.Success || string.IsNullOrWhiteSpace(helperResult.InstalledPackagePath))
{ {
return new AirAppMarketInstallResult( var helperMessage = helperResult.ErrorMessage ?? "Plugins install helper failed.";
false, AppLogger.Error(
null, "PluginMarket",
helperResult.ErrorMessage ?? "Plugins install helper failed."); $"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); manifest = _runtime.RegisterInstalledPluginPackage(helperResult.InstalledPackagePath);
} }
else else
{ {
manifest = _runtime.InstallPluginPackage(downloadPath); manifest = _runtime.InstallPluginPackage(attemptPath);
} }
AppLogger.Info( AppLogger.Info(
"PluginMarket", "PluginMarket",
$"Install staged successfully. PluginId='{manifest.Id}'; InstalledName='{manifest.Name}'; PackagePath='{downloadPath}'."); $"Install staged successfully. PluginId='{manifest.Id}'; InstalledName='{manifest.Name}'; PackagePath='{attemptPath}'; SourceKind='{source.SourceKind}'.");
return new AirAppMarketInstallResult(true, manifest, null); return new AirAppMarketInstallAttemptResult(true, true, manifest, null);
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
AppLogger.Warn( AppLogger.Warn(
"PluginMarket", "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; throw;
} }
catch (Exception ex) catch (Exception ex)
{ {
AppLogger.Error( AppLogger.Error(
"PluginMarket", "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); ex);
return new AirAppMarketInstallResult(false, null, ex.Message); TryDeleteFile(attemptPath);
return new AirAppMarketInstallAttemptResult(false, false, null, ex.Message);
}
}
private async Task<AirAppMarketAcquisitionResult> 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<AirAppMarketVerificationResult> 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(); var invalidChars = Path.GetInvalidFileNameChars();
return new string(value.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray()); 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);
} }

File diff suppressed because it is too large Load Diff

View File

@@ -22,14 +22,46 @@ internal sealed class AirAppMarketReleaseResolverService
{ {
ArgumentNullException.ThrowIfNull(plugin); ArgumentNullException.ThrowIfNull(plugin);
if (!plugin.HasReleaseDownloadMetadata) var firstSource = plugin.GetPackageSourcesInInstallOrder().FirstOrDefault();
if (firstSource is null)
{ {
return plugin.DownloadUrl; return plugin.DownloadUrl;
} }
return await ResolveDownloadUrlAsync(plugin, firstSource, cancellationToken).ConfigureAwait(false);
}
public async Task<string> 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<string> 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)) if (!TryGetRepositoryIdentity(plugin, out var owner, out var repositoryName))
{ {
return plugin.DownloadUrl; return sourceUrl;
} }
var releaseDownloadUrl = AirAppMarketDefaults.BuildGitHubReleaseDownloadUrl( var releaseDownloadUrl = AirAppMarketDefaults.BuildGitHubReleaseDownloadUrl(
@@ -46,15 +78,15 @@ internal sealed class AirAppMarketReleaseResolverService
try try
{ {
using var updateService = new GitHubReleaseUpdateService(owner, repositoryName, _httpClient); 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 => var asset = release?.Assets.FirstOrDefault(candidate =>
string.Equals(candidate.Name, plugin.ReleaseAssetName, StringComparison.OrdinalIgnoreCase)); string.Equals(candidate.Name, plugin.ReleaseAssetName, StringComparison.OrdinalIgnoreCase));
return asset?.BrowserDownloadUrl ?? plugin.DownloadUrl; return asset?.BrowserDownloadUrl ?? releaseDownloadUrl;
} }
catch catch
{ {
return plugin.DownloadUrl; return releaseDownloadUrl;
} }
} }