acdream/src/AcDream.Core/Plugins/PluginManifest.cs
Erik cd6eefd0ba feat(plugins): enforce apiVersion; launcher plugins default ON with "none" opt-out
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>
2026-08-20 21:28:04 +02:00

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) { }
}