using System.Text.Json;
namespace AcDream.Core.Plugins;
/// Host facility an entry assembly declares in plugin.json.
public enum PluginKind
{
Gameplay,
RenderPack,
}
public sealed record PluginManifest(
string Id,
string DisplayName,
string Version,
string EntryDll,
int ApiVersion,
IReadOnlyList Dependencies)
{
///
/// Declared entry-point kinds. Old manifests omit kinds and remain
/// gameplay plugins, preserving the pre-render-pack loading contract.
///
public IReadOnlyList Kinds { get; init; } = [PluginKind.Gameplay];
public PluginManifest(
string Id,
string DisplayName,
string Version,
string EntryDll,
int ApiVersion,
IReadOnlyList Dependencies,
IReadOnlyList Kinds)
: this(Id, DisplayName, Version, EntryDll, ApiVersion, Dependencies)
{
ArgumentNullException.ThrowIfNull(Kinds);
if (Kinds.Count == 0)
throw new ArgumentException("At least one plugin kind is required.", nameof(Kinds));
this.Kinds = Kinds
.Distinct()
.ToArray();
}
public bool Declares(PluginKind kind) => Kinds.Contains(kind);
public static PluginManifest Parse(string json)
{
PluginManifestDto? dto;
try
{
dto = JsonSerializer.Deserialize(json, JsonOptions);
}
catch (JsonException ex)
{
throw new PluginManifestException($"invalid json: {ex.Message}", ex);
}
if (dto is null)
throw new PluginManifestException("manifest is empty");
Require(dto.Id, "id");
Require(dto.DisplayName, "displayName");
Require(dto.Version, "version");
Require(dto.EntryDll, "entryDll");
if (dto.ApiVersion <= 0)
throw new PluginManifestException("apiVersion must be >= 1");
IReadOnlyList kinds = ParseKinds(dto.Kinds);
return new PluginManifest(
dto.Id!,
dto.DisplayName!,
dto.Version!,
dto.EntryDll!,
dto.ApiVersion,
dto.Dependencies ?? Array.Empty(),
kinds);
}
private static IReadOnlyList ParseKinds(IReadOnlyList? values)
{
if (values is null)
return [PluginKind.Gameplay];
if (values.Count == 0)
throw new PluginManifestException("kinds must contain at least one entry");
var kinds = new List(values.Count);
foreach (string? value in values)
{
if (string.IsNullOrWhiteSpace(value)
|| !Enum.TryParse(value, ignoreCase: true, out PluginKind kind)
|| !Enum.IsDefined(kind))
{
throw new PluginManifestException(
$"unknown plugin kind: {value ?? ""}");
}
if (!kinds.Contains(kind))
kinds.Add(kind);
}
return kinds;
}
private static void Require(string? value, string jsonFieldName)
{
if (string.IsNullOrWhiteSpace(value))
throw new PluginManifestException($"missing required field: {jsonFieldName}");
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
};
private sealed class PluginManifestDto
{
public string? Id { get; set; }
public string? DisplayName { get; set; }
public string? Version { get; set; }
public string? EntryDll { get; set; }
public int ApiVersion { get; set; }
public IReadOnlyList? Dependencies { get; set; }
public IReadOnlyList? Kinds { get; set; }
}
}
///
/// A plugin declared a contract version this build cannot load. Distinct from
/// (a malformed manifest) because the
/// remedy differs: this one means update the plugin or the client.
///
public sealed class PluginApiVersionException : Exception
{
public PluginApiVersionException(string message) : base(message) { }
}
public sealed class PluginManifestException : Exception
{
public PluginManifestException(string message) : base(message) { }
public PluginManifestException(string message, Exception inner) : base(message, inner) { }
}