2026-03-09 12:27:33 +08:00
|
|
|
using System.Text.Json;
|
|
|
|
|
|
|
|
|
|
namespace LanMountainDesktop.PluginSdk;
|
|
|
|
|
|
|
|
|
|
public sealed record PluginManifest(
|
|
|
|
|
string Id,
|
|
|
|
|
string Name,
|
|
|
|
|
string EntranceAssembly,
|
|
|
|
|
string? Description = null,
|
|
|
|
|
string? Author = null,
|
|
|
|
|
string? Version = null,
|
2026-03-12 09:22:03 +08:00
|
|
|
string? ApiVersion = null,
|
|
|
|
|
IReadOnlyList<PluginSharedContractReference>? SharedContracts = null)
|
2026-03-09 12:27:33 +08:00
|
|
|
{
|
|
|
|
|
private static readonly JsonSerializerOptions SerializerOptions = new()
|
|
|
|
|
{
|
|
|
|
|
PropertyNameCaseInsensitive = true,
|
|
|
|
|
ReadCommentHandling = JsonCommentHandling.Skip,
|
|
|
|
|
AllowTrailingCommas = true
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
public static PluginManifest Load(string manifestPath)
|
|
|
|
|
{
|
|
|
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(manifestPath);
|
|
|
|
|
|
|
|
|
|
using var stream = File.OpenRead(manifestPath);
|
|
|
|
|
return Load(stream, manifestPath);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static PluginManifest Load(Stream stream, string sourceName)
|
|
|
|
|
{
|
|
|
|
|
ArgumentNullException.ThrowIfNull(stream);
|
|
|
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(sourceName);
|
|
|
|
|
|
|
|
|
|
var manifest = JsonSerializer.Deserialize<PluginManifest>(stream, SerializerOptions);
|
|
|
|
|
if (manifest is null)
|
|
|
|
|
{
|
|
|
|
|
throw new InvalidOperationException($"Failed to deserialize plugin manifest '{sourceName}'.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return manifest.NormalizeAndValidate(sourceName);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public string ResolveEntranceAssemblyPath(string manifestPath)
|
|
|
|
|
{
|
|
|
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(manifestPath);
|
|
|
|
|
|
|
|
|
|
if (Path.IsPathRooted(EntranceAssembly))
|
|
|
|
|
{
|
|
|
|
|
return Path.GetFullPath(EntranceAssembly);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var manifestDirectory = Path.GetDirectoryName(Path.GetFullPath(manifestPath))
|
|
|
|
|
?? throw new InvalidOperationException($"Failed to determine the directory of '{manifestPath}'.");
|
|
|
|
|
|
|
|
|
|
return Path.GetFullPath(Path.Combine(manifestDirectory, EntranceAssembly));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private PluginManifest NormalizeAndValidate(string manifestPath)
|
|
|
|
|
{
|
2026-03-12 09:22:03 +08:00
|
|
|
var normalizedSharedContracts = NormalizeSharedContracts(manifestPath, SharedContracts);
|
2026-03-09 12:27:33 +08:00
|
|
|
var normalized = this with
|
|
|
|
|
{
|
|
|
|
|
Id = RequireValue(Id, nameof(Id), manifestPath),
|
|
|
|
|
Name = RequireValue(Name, nameof(Name), manifestPath),
|
|
|
|
|
EntranceAssembly = RequireValue(EntranceAssembly, nameof(EntranceAssembly), manifestPath),
|
|
|
|
|
Description = NormalizeOptionalValue(Description),
|
|
|
|
|
Author = NormalizeOptionalValue(Author),
|
|
|
|
|
Version = NormalizeOptionalValue(Version),
|
2026-03-12 09:22:03 +08:00
|
|
|
ApiVersion = NormalizeOptionalValue(ApiVersion) ?? PluginSdkInfo.ApiVersion,
|
|
|
|
|
SharedContracts = normalizedSharedContracts
|
2026-03-09 12:27:33 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (!System.Version.TryParse(normalized.ApiVersion, out var requestedVersion))
|
|
|
|
|
{
|
|
|
|
|
throw new InvalidOperationException(
|
|
|
|
|
$"Plugin manifest '{manifestPath}' declares an invalid API version '{normalized.ApiVersion}'.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!System.Version.TryParse(PluginSdkInfo.ApiVersion, out var currentVersion))
|
|
|
|
|
{
|
|
|
|
|
throw new InvalidOperationException($"Plugin SDK API version '{PluginSdkInfo.ApiVersion}' is invalid.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (requestedVersion.Major != currentVersion.Major)
|
|
|
|
|
{
|
|
|
|
|
throw new InvalidOperationException(
|
2026-03-13 22:20:12 +08:00
|
|
|
$"Plugin '{normalized.Id}' targets API version '{normalized.ApiVersion}' (major {requestedVersion.Major}), " +
|
|
|
|
|
$"but the host provides '{PluginSdkInfo.ApiVersion}' (major {currentVersion.Major}). " +
|
|
|
|
|
$"This host only supports v{currentVersion.Major}.x plugins. " +
|
|
|
|
|
$"Migrate the plugin to API {PluginSdkInfo.ApiVersion} and rebuild the package.");
|
2026-03-12 09:22:03 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return normalized;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static IReadOnlyList<PluginSharedContractReference> NormalizeSharedContracts(
|
|
|
|
|
string manifestPath,
|
|
|
|
|
IReadOnlyList<PluginSharedContractReference>? sharedContracts)
|
|
|
|
|
{
|
|
|
|
|
if (sharedContracts is null || sharedContracts.Count == 0)
|
|
|
|
|
{
|
|
|
|
|
return Array.Empty<PluginSharedContractReference>();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var normalized = new List<PluginSharedContractReference>(sharedContracts.Count);
|
|
|
|
|
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
|
|
|
|
|
|
foreach (var contract in sharedContracts)
|
|
|
|
|
{
|
|
|
|
|
if (contract is null)
|
|
|
|
|
{
|
|
|
|
|
throw new InvalidOperationException(
|
|
|
|
|
$"Plugin manifest '{manifestPath}' contains a null shared contract declaration.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var normalizedContract = contract.NormalizeAndValidate(manifestPath);
|
|
|
|
|
var contractKey = $"{normalizedContract.Id}@{normalizedContract.Version}";
|
|
|
|
|
if (!seenIds.Add(contractKey))
|
|
|
|
|
{
|
|
|
|
|
throw new InvalidOperationException(
|
|
|
|
|
$"Plugin manifest '{manifestPath}' declares duplicate shared contract '{contractKey}'.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
normalized.Add(normalizedContract);
|
2026-03-09 12:27:33 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return normalized;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string RequireValue(string? value, string propertyName, string manifestPath)
|
|
|
|
|
{
|
|
|
|
|
var normalized = NormalizeOptionalValue(value);
|
|
|
|
|
if (string.IsNullOrWhiteSpace(normalized))
|
|
|
|
|
{
|
|
|
|
|
throw new InvalidOperationException(
|
|
|
|
|
$"Plugin manifest '{manifestPath}' is missing required property '{propertyName}'.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return normalized;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string? NormalizeOptionalValue(string? value)
|
|
|
|
|
{
|
|
|
|
|
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
|
|
|
|
}
|
|
|
|
|
}
|