Two gaps from the MossTank shipment review. **apiVersion was declared in every manifest and checked by nothing.** The loader now refuses an unsupported contract BEFORE loading any code from the plugin — checking after the fact is not equivalent, because by then the assembly is in a collectible context and the mismatch surfaces as a type-load or missing-member failure from inside the plugin, which reads like the plugin is broken rather than built for a different host. PluginApi (Current / MinimumSupported) lives in Plugin.Abstractions beside the contract it versions, and the refusal is a distinct PluginApiVersionException so callers can tell "update the client or the plugin" from "this plugin is broken". The tests pin the ordering too: a manifest with a future apiVersion AND a missing dll must fail on the version, a supported one on the dll. **A launcher-launched client loaded no plugins until the user typed ids.** LA5 distinguishes an omitted allow-list (load all) from an explicit empty one (load none); a fresh character profile's list is empty, so it composed to load-none. Direct launches pass null and load everything -- which is why the gap never showed in development: the two launch paths disagreed and the launcher was the one users get. This REVERSES the LA5 default deliberately: "nothing configured" now composes to the omitted list, so plugins are on by default, including ones installed later. The opt-out is kept -- losing it would be a real regression for stripped sessions -- respelled as the literal id "none", and the launcher's plugin box says so. The cross-host shared fixture composes its explicit-load-none case through the new spelling, keeping the reader-side contract tests (App and Headless both preserve an explicit empty list) exactly as they were. Complete Release suite: 14,469 tests pass on the standard hermetic lane filter, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
81 lines
2.5 KiB
C#
81 lines
2.5 KiB
C#
using System.Text.Json;
|
|
|
|
namespace AcDream.Core.Plugins;
|
|
|
|
public sealed record PluginManifest(
|
|
string Id,
|
|
string DisplayName,
|
|
string Version,
|
|
string EntryDll,
|
|
int ApiVersion,
|
|
IReadOnlyList<string> Dependencies)
|
|
{
|
|
public static PluginManifest Parse(string json)
|
|
{
|
|
PluginManifestDto? dto;
|
|
try
|
|
{
|
|
dto = JsonSerializer.Deserialize<PluginManifestDto>(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");
|
|
|
|
return new PluginManifest(
|
|
dto.Id!,
|
|
dto.DisplayName!,
|
|
dto.Version!,
|
|
dto.EntryDll!,
|
|
dto.ApiVersion,
|
|
dto.Dependencies ?? Array.Empty<string>());
|
|
}
|
|
|
|
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<string>? Dependencies { get; set; }
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A plugin declared a contract version this build cannot load. Distinct from
|
|
/// <see cref="PluginManifestException"/> (a malformed manifest) because the
|
|
/// remedy differs: this one means update the plugin or the client.
|
|
/// </summary>
|
|
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) { }
|
|
}
|