feat(plugins): complete Campaign LA5 cross-host hosting
This commit is contained in:
parent
6c4cd2bbc6
commit
95f4be94db
26 changed files with 1630 additions and 99 deletions
|
|
@ -18,6 +18,7 @@ public sealed class AppPluginHost : IPluginHost
|
|||
Ui = ui;
|
||||
}
|
||||
|
||||
public bool HasUi => true;
|
||||
public IPluginLogger Log { get; }
|
||||
public IGameState State { get; }
|
||||
public IEvents Events { get; }
|
||||
|
|
|
|||
78
src/AcDream.App/Plugins/GraphicalPluginSession.cs
Normal file
78
src/AcDream.App/Plugins/GraphicalPluginSession.cs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
using AcDream.Core.Plugins;
|
||||
using AcDream.Platform;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
using AcDream.Runtime.Session;
|
||||
|
||||
namespace AcDream.App.Plugins;
|
||||
|
||||
/// <summary>
|
||||
/// Graphical-host composition for one plugin set. The shared
|
||||
/// <see cref="PluginSession"/> owns discovery and collectible lifetimes; this
|
||||
/// adapter supplies the graphical roots and translates outcomes into the
|
||||
/// launcher status stream.
|
||||
/// </summary>
|
||||
internal sealed class GraphicalPluginSession : IDisposable
|
||||
{
|
||||
private readonly PluginSession _plugins;
|
||||
|
||||
private GraphicalPluginSession(PluginSession plugins)
|
||||
{
|
||||
_plugins = plugins;
|
||||
}
|
||||
|
||||
internal int LoadedCount => _plugins.LoadedCount;
|
||||
|
||||
internal IReadOnlyList<WeakReference> CaptureLoadContextWeakReferences() =>
|
||||
_plugins.CaptureLoadContextWeakReferences();
|
||||
|
||||
internal static GraphicalPluginSession Start(
|
||||
ApplicationPathSet paths,
|
||||
IReadOnlyList<string>? allowList,
|
||||
string sessionId,
|
||||
IPluginHost host,
|
||||
SessionStatusWriter statusWriter)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
|
||||
ArgumentNullException.ThrowIfNull(host);
|
||||
ArgumentNullException.ThrowIfNull(statusWriter);
|
||||
|
||||
var plugins = new PluginSession(
|
||||
host,
|
||||
status => Report(statusWriter, sessionId, status));
|
||||
try
|
||||
{
|
||||
plugins.Start(
|
||||
[
|
||||
Path.Combine(AppContext.BaseDirectory, "plugins"),
|
||||
paths.PluginsDirectory,
|
||||
],
|
||||
allowList);
|
||||
return new GraphicalPluginSession(plugins);
|
||||
}
|
||||
catch
|
||||
{
|
||||
plugins.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => _plugins.Dispose();
|
||||
|
||||
private static void Report(
|
||||
SessionStatusWriter writer,
|
||||
string sessionId,
|
||||
PluginSessionStatus status)
|
||||
{
|
||||
if (status.Kind == PluginSessionStatusKind.Loaded)
|
||||
{
|
||||
writer.PluginLoaded(sessionId, status.Plugin);
|
||||
return;
|
||||
}
|
||||
|
||||
writer.PluginFailed(
|
||||
sessionId,
|
||||
status.Plugin,
|
||||
status.Error ?? "plugin failed");
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,6 @@ using AcDream.App.Credentials;
|
|||
using AcDream.App.Plugins;
|
||||
using AcDream.App.Platform;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.Core.Plugins;
|
||||
using AcDream.Platform;
|
||||
using Serilog;
|
||||
|
||||
|
|
@ -162,76 +161,15 @@ var host = new AppPluginHost(
|
|||
worldEvents,
|
||||
window.Selection,
|
||||
uiRegistry);
|
||||
|
||||
var loaded = new List<LoadedPlugin>();
|
||||
var loadedPluginIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
StringComparer pathComparer =
|
||||
graphicalPlatform.OperatingSystem
|
||||
== GraphicalHostOperatingSystem.Windows
|
||||
? StringComparer.OrdinalIgnoreCase
|
||||
: StringComparer.Ordinal;
|
||||
string[] pluginRoots =
|
||||
[
|
||||
.. new[]
|
||||
{
|
||||
Path.Combine(AppContext.BaseDirectory, "plugins"),
|
||||
applicationPaths.PluginsDirectory,
|
||||
}.Distinct(pathComparer),
|
||||
];
|
||||
|
||||
foreach (string pluginsDir in pluginRoots)
|
||||
{
|
||||
Log.Information("scanning plugins in {PluginsDir}", pluginsDir);
|
||||
foreach (var result in PluginDiscovery.Scan(pluginsDir))
|
||||
{
|
||||
if (!result.Success)
|
||||
{
|
||||
Log.Warning(
|
||||
"plugin discovery failed for {Dir}: {Error}",
|
||||
result.PluginDirectory,
|
||||
result.Error);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (loadedPluginIds.Contains(result.Manifest!.Id))
|
||||
{
|
||||
Log.Warning(
|
||||
"skipping duplicate plugin id {Id} from {Dir}",
|
||||
result.Manifest.Id,
|
||||
result.PluginDirectory);
|
||||
continue;
|
||||
}
|
||||
|
||||
var loadResult = PluginLoader.Load(
|
||||
result.PluginDirectory,
|
||||
result.Manifest,
|
||||
host);
|
||||
if (!loadResult.Success)
|
||||
{
|
||||
Log.Warning(
|
||||
"plugin load failed for {Id}: {Error}",
|
||||
result.Manifest.Id,
|
||||
loadResult.Error);
|
||||
continue;
|
||||
}
|
||||
|
||||
loadedPluginIds.Add(result.Manifest.Id);
|
||||
loaded.Add(loadResult);
|
||||
Log.Information(
|
||||
"loaded plugin {Id} ({DisplayName})",
|
||||
result.Manifest.Id,
|
||||
result.Manifest.DisplayName);
|
||||
}
|
||||
}
|
||||
using var pluginSession = GraphicalPluginSession.Start(
|
||||
applicationPaths,
|
||||
runtimeOptions.Plugins,
|
||||
runtimeOptions.SessionId ?? "app",
|
||||
host,
|
||||
window.StatusWriter);
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var plugin in loaded)
|
||||
{
|
||||
try { plugin.Plugin!.Enable(); }
|
||||
catch (Exception ex) { Log.Error(ex, "plugin enable failed: {Id}", plugin.Manifest.Id); }
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
window.Run();
|
||||
|
|
@ -244,11 +182,7 @@ try
|
|||
}
|
||||
finally
|
||||
{
|
||||
foreach (var plugin in loaded)
|
||||
{
|
||||
try { plugin.Plugin!.Disable(); }
|
||||
catch (Exception ex) { Log.Error(ex, "plugin disable failed: {Id}", plugin.Manifest.Id); }
|
||||
}
|
||||
pluginSession.Dispose();
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -362,6 +362,7 @@ public sealed class GameWindow :
|
|||
private RuntimeActionState _runtimeActions => _runtime.ActionOwner;
|
||||
public AcDream.Core.Selection.SelectionState Selection =>
|
||||
_runtimeActions.Selection;
|
||||
internal SessionStatusWriter StatusWriter => _statusWriter;
|
||||
public AcDream.Core.Chat.ChatLog Chat => _runtimeCommunication.Chat;
|
||||
public AcDream.Core.Chat.TurbineChatState TurbineChat =>
|
||||
_runtimeCommunication.TurbineChat;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ public static class PluginLoader
|
|||
/// </summary>
|
||||
public static LoadedPlugin Load(string pluginDirectory, PluginManifest manifest, IPluginHost host)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(pluginDirectory);
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
ArgumentNullException.ThrowIfNull(host);
|
||||
|
||||
var dllPath = Path.Combine(pluginDirectory, manifest.EntryDll);
|
||||
if (!File.Exists(dllPath))
|
||||
return new LoadedPlugin(
|
||||
|
|
@ -22,9 +26,11 @@ public static class PluginLoader
|
|||
LoadContext: null,
|
||||
Error: new FileNotFoundException($"entry dll not found: {dllPath}", dllPath));
|
||||
|
||||
PluginAssemblyLoadContext? alc = null;
|
||||
IAcDreamPlugin? instance = null;
|
||||
try
|
||||
{
|
||||
var alc = new PluginAssemblyLoadContext(pluginDirectory, dllPath);
|
||||
alc = new PluginAssemblyLoadContext(pluginDirectory, dllPath);
|
||||
var asm = alc.LoadFromAssemblyPath(dllPath);
|
||||
|
||||
IEnumerable<Type> types;
|
||||
|
|
@ -41,19 +47,29 @@ public static class PluginLoader
|
|||
.FirstOrDefault(t => !t.IsAbstract && typeof(IAcDreamPlugin).IsAssignableFrom(t));
|
||||
|
||||
if (pluginType is null)
|
||||
{
|
||||
alc.Unload();
|
||||
return new LoadedPlugin(
|
||||
manifest,
|
||||
Plugin: null,
|
||||
LoadContext: null,
|
||||
Error: new InvalidOperationException(
|
||||
$"no IAcDreamPlugin implementation found in {manifest.EntryDll}"));
|
||||
}
|
||||
|
||||
var instance = (IAcDreamPlugin)Activator.CreateInstance(pluginType)!;
|
||||
instance = (IAcDreamPlugin)Activator.CreateInstance(pluginType)!;
|
||||
instance.Initialize(host);
|
||||
return new LoadedPlugin(manifest, instance, alc, Error: null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Initialize may have attached host callbacks before it failed.
|
||||
// Give that partial instance the same best-effort cleanup chance
|
||||
// as an Enable failure before releasing the collectible context.
|
||||
try { instance?.Disable(); }
|
||||
catch { }
|
||||
try { alc?.Unload(); }
|
||||
catch { }
|
||||
return new LoadedPlugin(manifest, Plugin: null, LoadContext: null, Error: ex);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
361
src/AcDream.Core/Plugins/PluginSession.cs
Normal file
361
src/AcDream.Core/Plugins/PluginSession.cs
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace AcDream.Core.Plugins;
|
||||
|
||||
public enum PluginSessionStatusKind
|
||||
{
|
||||
Loaded,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Final startup outcome for one configured plugin id. Hosts translate these
|
||||
/// outcomes into their own diagnostics and the Campaign LA status stream.
|
||||
/// </summary>
|
||||
public readonly record struct PluginSessionStatus(
|
||||
string Plugin,
|
||||
PluginSessionStatusKind Kind,
|
||||
string? Error = null);
|
||||
|
||||
/// <summary>
|
||||
/// One host/session-scoped plugin lifetime. Discovery, allow-listing,
|
||||
/// initialize/enable, failure isolation, reverse-order disable, and collectible
|
||||
/// load-context release are shared by graphical and no-window hosts so their
|
||||
/// configured plugin-set semantics cannot drift.
|
||||
/// </summary>
|
||||
public sealed class PluginSession : IDisposable
|
||||
{
|
||||
private readonly IPluginHost _host;
|
||||
private readonly Action<PluginSessionStatus>? _report;
|
||||
private readonly List<LoadedPlugin> _loaded = [];
|
||||
private bool _started;
|
||||
private bool _disposed;
|
||||
|
||||
public PluginSession(
|
||||
IPluginHost host,
|
||||
Action<PluginSessionStatus>? report = null)
|
||||
{
|
||||
_host = host ?? throw new ArgumentNullException(nameof(host));
|
||||
_report = report;
|
||||
}
|
||||
|
||||
public int LoadedCount => _loaded.Count;
|
||||
|
||||
public IReadOnlyList<string> LoadedPluginIds =>
|
||||
_loaded.Select(static plugin => plugin.Manifest.Id).ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// Discovers and starts the configured set exactly once. A
|
||||
/// <see langword="null"/> allow-list loads every discovered id; an explicit
|
||||
/// empty list loads none. Matching and duplicate-id handling are
|
||||
/// ordinal-ignore-case on every operating system because plugin ids are
|
||||
/// logical identifiers, not paths.
|
||||
/// </summary>
|
||||
public void Start(
|
||||
IEnumerable<string> pluginRoots,
|
||||
IReadOnlyList<string>? allowList)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(pluginRoots);
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (_started)
|
||||
throw new InvalidOperationException("The plugin session has already started.");
|
||||
_started = true;
|
||||
|
||||
string[] roots = DistinctRoots(pluginRoots);
|
||||
string[]? requested = allowList is null
|
||||
? null
|
||||
: allowList
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
if (requested is { Length: 0 })
|
||||
return;
|
||||
|
||||
var candidates = new Dictionary<string, List<PluginDiscoveryResult>>(
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
var errors = new Dictionary<string, List<Exception>>(
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
var discoveredOrder = new List<string>();
|
||||
HashSet<string>? requestedSet = requested is null
|
||||
? null
|
||||
: new HashSet<string>(requested, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (string root in roots)
|
||||
{
|
||||
IReadOnlyList<PluginDiscoveryResult> results;
|
||||
try
|
||||
{
|
||||
results = PluginDiscovery.Scan(root);
|
||||
}
|
||||
catch (Exception error) when (IsDiscoveryFailure(error))
|
||||
{
|
||||
SafeLog(
|
||||
static (log, message, exception) =>
|
||||
log.Error(message, exception),
|
||||
$"plugin discovery failed for root '{root}'",
|
||||
error);
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (PluginDiscoveryResult result in results)
|
||||
{
|
||||
if (!result.Success)
|
||||
{
|
||||
string directoryId = Path.GetFileName(
|
||||
Path.TrimEndingDirectorySeparator(result.PluginDirectory));
|
||||
if (string.IsNullOrWhiteSpace(directoryId)
|
||||
|| (requestedSet is not null
|
||||
&& !requestedSet.Contains(directoryId)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
AddOrdered(discoveredOrder, directoryId);
|
||||
AddError(
|
||||
errors,
|
||||
directoryId,
|
||||
result.Error ?? new InvalidOperationException(
|
||||
"plugin discovery failed"));
|
||||
continue;
|
||||
}
|
||||
|
||||
string id = result.Manifest!.Id;
|
||||
if (requestedSet is not null && !requestedSet.Contains(id))
|
||||
continue;
|
||||
AddOrdered(discoveredOrder, id);
|
||||
if (!candidates.TryGetValue(id, out List<PluginDiscoveryResult>? list))
|
||||
{
|
||||
list = [];
|
||||
candidates.Add(id, list);
|
||||
}
|
||||
list.Add(result);
|
||||
}
|
||||
}
|
||||
|
||||
IEnumerable<string> loadOrder = requested is null
|
||||
? discoveredOrder
|
||||
: requested;
|
||||
foreach (string id in loadOrder)
|
||||
LoadOne(id, candidates, errors);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test/diagnostic observation of the exact collectible contexts currently
|
||||
/// owned by this session. The returned weak references do not delay unload.
|
||||
/// </summary>
|
||||
public IReadOnlyList<WeakReference> CaptureLoadContextWeakReferences() =>
|
||||
_loaded
|
||||
.Select(static plugin => new WeakReference(plugin.LoadContext!))
|
||||
.ToArray();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_disposed = true;
|
||||
|
||||
for (int index = _loaded.Count - 1; index >= 0; index--)
|
||||
{
|
||||
LoadedPlugin loaded = _loaded[index];
|
||||
try
|
||||
{
|
||||
loaded.Plugin!.Disable();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
SafeLog(
|
||||
static (log, message, exception) =>
|
||||
log.Error(message, exception),
|
||||
$"plugin disable failed: {loaded.Manifest.Id}",
|
||||
error);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
loaded.LoadContext!.Unload();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
SafeLog(
|
||||
static (log, message, exception) =>
|
||||
log.Error(message, exception),
|
||||
$"plugin unload failed: {loaded.Manifest.Id}",
|
||||
error);
|
||||
}
|
||||
}
|
||||
|
||||
// Drop both plugin instances and AssemblyLoadContext references. The
|
||||
// CLR completes collectible unload after no plugin-owned object remains
|
||||
// reachable and a normal GC cycle observes the contexts.
|
||||
_loaded.Clear();
|
||||
}
|
||||
|
||||
private void LoadOne(
|
||||
string id,
|
||||
IReadOnlyDictionary<string, List<PluginDiscoveryResult>> candidates,
|
||||
Dictionary<string, List<Exception>> errors)
|
||||
{
|
||||
if (candidates.TryGetValue(id, out List<PluginDiscoveryResult>? available))
|
||||
{
|
||||
foreach (PluginDiscoveryResult candidate in available)
|
||||
{
|
||||
LoadedPlugin loaded = PluginLoader.Load(
|
||||
candidate.PluginDirectory,
|
||||
candidate.Manifest!,
|
||||
_host);
|
||||
if (!loaded.Success)
|
||||
{
|
||||
AddError(
|
||||
errors,
|
||||
id,
|
||||
loaded.Error ?? new InvalidOperationException(
|
||||
"plugin load failed"));
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
loaded.Plugin!.Enable();
|
||||
_loaded.Add(loaded);
|
||||
SafeLog(
|
||||
static (log, message, _) => log.Info(message),
|
||||
$"plugin loaded: {loaded.Manifest.Id} "
|
||||
+ $"({loaded.Manifest.DisplayName})",
|
||||
null);
|
||||
Report(new PluginSessionStatus(
|
||||
loaded.Manifest.Id,
|
||||
PluginSessionStatusKind.Loaded));
|
||||
return;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
AddError(errors, id, error);
|
||||
ReleaseFailedEnable(loaded);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!errors.TryGetValue(id, out List<Exception>? failures)
|
||||
|| failures.Count == 0)
|
||||
{
|
||||
failures =
|
||||
[
|
||||
new FileNotFoundException(
|
||||
$"plugin '{id}' was not found in the configured plugin roots."),
|
||||
];
|
||||
}
|
||||
|
||||
string errorText = string.Join(
|
||||
" | ",
|
||||
failures.Select(Describe));
|
||||
Report(new PluginSessionStatus(
|
||||
id,
|
||||
PluginSessionStatusKind.Failed,
|
||||
errorText));
|
||||
SafeLog(
|
||||
static (log, message, _) => log.Warn(message),
|
||||
$"plugin failed: {id}: {errorText}",
|
||||
null);
|
||||
}
|
||||
|
||||
private void ReleaseFailedEnable(LoadedPlugin loaded)
|
||||
{
|
||||
try
|
||||
{
|
||||
loaded.Plugin!.Disable();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
SafeLog(
|
||||
static (log, message, exception) =>
|
||||
log.Error(message, exception),
|
||||
$"plugin cleanup after enable failure failed: {loaded.Manifest.Id}",
|
||||
error);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
loaded.LoadContext!.Unload();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
SafeLog(
|
||||
static (log, message, exception) =>
|
||||
log.Error(message, exception),
|
||||
$"plugin unload after enable failure failed: {loaded.Manifest.Id}",
|
||||
error);
|
||||
}
|
||||
}
|
||||
|
||||
private void Report(PluginSessionStatus status)
|
||||
{
|
||||
if (_report is null)
|
||||
return;
|
||||
try
|
||||
{
|
||||
_report(status);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
SafeLog(
|
||||
static (log, message, exception) =>
|
||||
log.Error(message, exception),
|
||||
$"plugin status observer failed for {status.Plugin}",
|
||||
error);
|
||||
}
|
||||
}
|
||||
|
||||
private void SafeLog(
|
||||
Action<IPluginLogger, string, Exception?> write,
|
||||
string message,
|
||||
Exception? error)
|
||||
{
|
||||
try { write(_host.Log, message, error); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
private static string[] DistinctRoots(IEnumerable<string> roots)
|
||||
{
|
||||
StringComparer comparer = OperatingSystem.IsWindows()
|
||||
? StringComparer.OrdinalIgnoreCase
|
||||
: StringComparer.Ordinal;
|
||||
return roots
|
||||
.Where(static root => !string.IsNullOrWhiteSpace(root))
|
||||
.Select(Path.GetFullPath)
|
||||
.Distinct(comparer)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static void AddOrdered(List<string> ordered, string id)
|
||||
{
|
||||
if (!ordered.Contains(id, StringComparer.OrdinalIgnoreCase))
|
||||
ordered.Add(id);
|
||||
}
|
||||
|
||||
private static void AddError(
|
||||
Dictionary<string, List<Exception>> errors,
|
||||
string id,
|
||||
Exception error)
|
||||
{
|
||||
if (!errors.TryGetValue(id, out List<Exception>? list))
|
||||
{
|
||||
list = [];
|
||||
errors.Add(id, list);
|
||||
}
|
||||
list.Add(error);
|
||||
}
|
||||
|
||||
private static string Describe(Exception error)
|
||||
{
|
||||
Exception root = error.GetBaseException();
|
||||
return string.IsNullOrWhiteSpace(root.Message)
|
||||
? root.GetType().Name
|
||||
: root.Message;
|
||||
}
|
||||
|
||||
private static bool IsDiscoveryFailure(Exception error) =>
|
||||
error is IOException
|
||||
or UnauthorizedAccessException
|
||||
or ArgumentException
|
||||
or NotSupportedException
|
||||
or System.Security.SecurityException;
|
||||
}
|
||||
|
|
@ -56,6 +56,11 @@ internal sealed class HeadlessProcessHost : IDisposable
|
|||
paths.ConfigDirectory);
|
||||
var sessions = new List<HeadlessSessionHost>(
|
||||
configuration.Sessions.Count);
|
||||
string[] pluginRoots =
|
||||
[
|
||||
Path.Combine(AppContext.BaseDirectory, "plugins"),
|
||||
paths.PluginsDirectory,
|
||||
];
|
||||
HeadlessProcessContentOwner? content = null;
|
||||
HeadlessProcessResourceSampler? resources = null;
|
||||
// FA6: constructed unconditionally — cheap, and every non-gate
|
||||
|
|
@ -104,7 +109,8 @@ internal sealed class HeadlessProcessHost : IDisposable
|
|||
sessionOperations,
|
||||
timeProvider,
|
||||
contentLease: contentLease,
|
||||
gateCoordinator: gateCoordinator));
|
||||
gateCoordinator: gateCoordinator,
|
||||
pluginRoots: pluginRoots));
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using AcDream.Headless.Configuration;
|
||||
using AcDream.Headless.Credentials;
|
||||
using AcDream.Headless.Diagnostics;
|
||||
using AcDream.Headless.Plugins;
|
||||
using AcDream.Headless.Policies;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
|
|
@ -155,6 +156,7 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
private readonly IDisposable _hostLease;
|
||||
private readonly IHeadlessBotPolicy _policy;
|
||||
private readonly IDisposable _policySubscription;
|
||||
private readonly HeadlessPluginSession _pluginSession;
|
||||
private readonly LiveSessionHost _liveSession;
|
||||
private readonly RuntimeLocalPlayerFrameController _localPlayerFrame;
|
||||
private readonly HeadlessProcessContentOwner.HeadlessProcessContentLease?
|
||||
|
|
@ -238,7 +240,8 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
contentLease = null,
|
||||
IHeadlessBotPolicy? policyOverride = null,
|
||||
IRuntimePlacementProjectionSink? placementSinkOverride = null,
|
||||
FellowshipAllegianceGateCoordinator? gateCoordinator = null)
|
||||
FellowshipAllegianceGateCoordinator? gateCoordinator = null,
|
||||
IEnumerable<string>? pluginRoots = null)
|
||||
{
|
||||
_descriptor = descriptor
|
||||
?? throw new ArgumentNullException(nameof(descriptor));
|
||||
|
|
@ -263,6 +266,7 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
IDisposable? hostLease = null;
|
||||
IHeadlessBotPolicy? policy = null;
|
||||
IDisposable? policySubscription = null;
|
||||
HeadlessPluginSession? pluginSession = null;
|
||||
try
|
||||
{
|
||||
var gameplay = new HeadlessGameplayOperations();
|
||||
|
|
@ -297,6 +301,13 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
// descriptor.StatusFile is unset — every call site below stays
|
||||
// unconditional.
|
||||
var statusWriter = new SessionStatusWriter(descriptor.StatusFile);
|
||||
pluginSession = HeadlessPluginSession.Start(
|
||||
runtime,
|
||||
diagnostics,
|
||||
statusWriter,
|
||||
descriptor.Id,
|
||||
pluginRoots ?? [],
|
||||
descriptor.Plugins);
|
||||
var liveSession = new LiveSessionHost(
|
||||
runtime.Session,
|
||||
new LiveSessionHostBindings(
|
||||
|
|
@ -402,9 +413,11 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
_hostLease = hostLease;
|
||||
_policy = policy;
|
||||
_policySubscription = policySubscription;
|
||||
_pluginSession = pluginSession;
|
||||
}
|
||||
catch
|
||||
{
|
||||
pluginSession?.Dispose();
|
||||
policySubscription?.Dispose();
|
||||
policy?.Dispose();
|
||||
hostLease?.Dispose();
|
||||
|
|
@ -427,6 +440,7 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
/// production code uses to reach the same state.
|
||||
/// </summary>
|
||||
internal HeadlessCharacterOptionsSeeder? OptionsSeeder => _optionsSeeder;
|
||||
internal HeadlessPluginSession Plugins => _pluginSession;
|
||||
internal string SessionId => _descriptor.Id;
|
||||
internal string ActiveCharacterName { get; private set; } =
|
||||
string.Empty;
|
||||
|
|
@ -638,22 +652,26 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
_disposeStage++;
|
||||
break;
|
||||
case 4:
|
||||
_hostLease.Dispose();
|
||||
_pluginSession.Dispose();
|
||||
_disposeStage++;
|
||||
break;
|
||||
case 5:
|
||||
_credential.Dispose();
|
||||
_hostLease.Dispose();
|
||||
_disposeStage++;
|
||||
break;
|
||||
case 6:
|
||||
Runtime.Dispose();
|
||||
_credential.Dispose();
|
||||
_disposeStage++;
|
||||
break;
|
||||
case 7:
|
||||
_contentLease?.Dispose();
|
||||
Runtime.Dispose();
|
||||
_disposeStage++;
|
||||
break;
|
||||
case 8:
|
||||
_contentLease?.Dispose();
|
||||
_disposeStage++;
|
||||
break;
|
||||
case 9:
|
||||
_diagnostics.Message(
|
||||
_descriptor.Id,
|
||||
"disposed",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ internal sealed record HeadlessPathSet(
|
|||
string DataDirectory,
|
||||
string CacheDirectory)
|
||||
{
|
||||
internal string PluginsDirectory =>
|
||||
Path.Combine(DataDirectory, "plugins");
|
||||
|
||||
internal static HeadlessPathSet Resolve(
|
||||
HeadlessPathOverrides overrides,
|
||||
IHeadlessPlatformEnvironment? platform = null)
|
||||
|
|
|
|||
150
src/AcDream.Headless/Plugins/HeadlessPluginHost.cs
Normal file
150
src/AcDream.Headless/Plugins/HeadlessPluginHost.cs
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
using AcDream.Plugin.Abstractions;
|
||||
using AcDream.Runtime;
|
||||
|
||||
namespace AcDream.Headless.Plugins;
|
||||
|
||||
/// <summary>
|
||||
/// No-window plugin surface over one exact <see cref="GameRuntime"/>. State is
|
||||
/// projected on demand from Runtime's canonical entity view, events come from
|
||||
/// Runtime's ordered event source, and selection is the exact J5 action owner;
|
||||
/// this adapter owns no gameplay mirror.
|
||||
/// </summary>
|
||||
internal sealed class HeadlessPluginHost
|
||||
: IPluginHost,
|
||||
IGameState,
|
||||
IEvents,
|
||||
IRuntimeEventObserver,
|
||||
IDisposable
|
||||
{
|
||||
private readonly GameRuntime _runtime;
|
||||
private readonly IDisposable _eventSubscription;
|
||||
private readonly object _eventGate = new();
|
||||
private Action<WorldEntitySnapshot>? _entitySpawned;
|
||||
private bool _disposed;
|
||||
|
||||
internal HeadlessPluginHost(
|
||||
GameRuntime runtime,
|
||||
IPluginLogger logger)
|
||||
{
|
||||
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
||||
Log = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_eventSubscription = runtime.Subscribe(this);
|
||||
}
|
||||
|
||||
public bool HasUi => false;
|
||||
public IPluginLogger Log { get; }
|
||||
public IGameState State => this;
|
||||
public IEvents Events => this;
|
||||
public ISelectionService Selection => _runtime.ActionOwner.Selection;
|
||||
public IUiRegistry Ui => NoOpUiRegistry.Instance;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable point-in-time values produced directly from Runtime on each
|
||||
/// read. The caller owns the returned snapshot list; this host retains no
|
||||
/// entity collection and therefore cannot become a second gameplay owner.
|
||||
/// </summary>
|
||||
public IReadOnlyList<WorldEntitySnapshot> Entities
|
||||
{
|
||||
get
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
var visitor = new SnapshotVisitor(_runtime);
|
||||
_runtime.Entities.Visit(visitor);
|
||||
return visitor.Snapshots;
|
||||
}
|
||||
}
|
||||
|
||||
public event Action<WorldEntitySnapshot> EntitySpawned
|
||||
{
|
||||
add
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
lock (_eventGate)
|
||||
_entitySpawned += value;
|
||||
|
||||
// Match the graphical WorldEvents contract: a late subscriber
|
||||
// immediately observes the canonical world that exists now.
|
||||
foreach (WorldEntitySnapshot snapshot in Entities)
|
||||
Invoke(value, snapshot);
|
||||
}
|
||||
remove
|
||||
{
|
||||
if (value is null)
|
||||
return;
|
||||
lock (_eventGate)
|
||||
_entitySpawned -= value;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_eventSubscription.Dispose();
|
||||
_disposed = true;
|
||||
lock (_eventGate)
|
||||
_entitySpawned = null;
|
||||
}
|
||||
|
||||
public void OnEntity(in RuntimeEntityDelta delta)
|
||||
{
|
||||
if (_disposed || delta.Change != RuntimeEntityChange.Registered)
|
||||
return;
|
||||
Action<WorldEntitySnapshot>? handlers;
|
||||
lock (_eventGate)
|
||||
handlers = _entitySpawned;
|
||||
if (handlers is null)
|
||||
return;
|
||||
|
||||
WorldEntitySnapshot snapshot = Convert(_runtime, delta.Entity);
|
||||
foreach (Action<WorldEntitySnapshot> handler
|
||||
in handlers.GetInvocationList().Cast<Action<WorldEntitySnapshot>>())
|
||||
{
|
||||
Invoke(handler, snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
public void OnLifecycle(in RuntimeLifecycleDelta delta) { }
|
||||
public void OnCommand(in RuntimeCommandDelta delta) { }
|
||||
public void OnInventory(in RuntimeInventoryDelta delta) { }
|
||||
public void OnChat(in RuntimeChatDelta delta) { }
|
||||
public void OnMovement(in RuntimeMovementDelta delta) { }
|
||||
public void OnPortal(in RuntimePortalDelta delta) { }
|
||||
public void OnCombat(in RuntimeCombatDelta delta) { }
|
||||
|
||||
private static WorldEntitySnapshot Convert(
|
||||
GameRuntime runtime,
|
||||
in RuntimeEntitySnapshot entity)
|
||||
{
|
||||
uint sourceId = runtime.EntityObjects.Entities.TryGetActive(
|
||||
entity.Identity.ServerGuid,
|
||||
out AcDream.Runtime.Entities.RuntimeEntityRecord record)
|
||||
? record.Snapshot.SetupTableId ?? 0u
|
||||
: 0u;
|
||||
return new WorldEntitySnapshot(
|
||||
entity.Identity.LocalEntityId,
|
||||
sourceId,
|
||||
entity.Position?.Frame.Origin ?? default,
|
||||
entity.Position?.Frame.Orientation
|
||||
?? System.Numerics.Quaternion.Identity);
|
||||
}
|
||||
|
||||
private static void Invoke(
|
||||
Action<WorldEntitySnapshot> handler,
|
||||
WorldEntitySnapshot snapshot)
|
||||
{
|
||||
try { handler(snapshot); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
private sealed class SnapshotVisitor(GameRuntime runtime)
|
||||
: IRuntimeEntityVisitor
|
||||
{
|
||||
internal List<WorldEntitySnapshot> Snapshots { get; } =
|
||||
new(runtime.Entities.Count);
|
||||
|
||||
public void Visit(in RuntimeEntitySnapshot entity) =>
|
||||
Snapshots.Add(Convert(runtime, entity));
|
||||
}
|
||||
}
|
||||
43
src/AcDream.Headless/Plugins/HeadlessPluginLogger.cs
Normal file
43
src/AcDream.Headless/Plugins/HeadlessPluginLogger.cs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
using AcDream.Headless.Diagnostics;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace AcDream.Headless.Plugins;
|
||||
|
||||
internal sealed class HeadlessPluginLogger : IPluginLogger
|
||||
{
|
||||
private readonly HeadlessDiagnosticWriter _diagnostics;
|
||||
private readonly string _sessionId;
|
||||
private readonly Func<ulong> _generation;
|
||||
|
||||
internal HeadlessPluginLogger(
|
||||
HeadlessDiagnosticWriter diagnostics,
|
||||
string sessionId,
|
||||
Func<ulong> generation)
|
||||
{
|
||||
_diagnostics = diagnostics
|
||||
?? throw new ArgumentNullException(nameof(diagnostics));
|
||||
_sessionId = sessionId
|
||||
?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_generation = generation
|
||||
?? throw new ArgumentNullException(nameof(generation));
|
||||
}
|
||||
|
||||
public void Info(string message) => Write("info", message);
|
||||
public void Warn(string message) => Write("warn", message);
|
||||
|
||||
public void Error(string message, Exception? exception = null)
|
||||
{
|
||||
if (exception is not null)
|
||||
{
|
||||
_diagnostics.Failure(_sessionId, "plugin", exception);
|
||||
return;
|
||||
}
|
||||
Write("error", message);
|
||||
}
|
||||
|
||||
private void Write(string level, string message) =>
|
||||
_diagnostics.Message(
|
||||
_sessionId,
|
||||
$"plugin-{level}:{message}",
|
||||
_generation());
|
||||
}
|
||||
109
src/AcDream.Headless/Plugins/HeadlessPluginSession.cs
Normal file
109
src/AcDream.Headless/Plugins/HeadlessPluginSession.cs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
using AcDream.Core.Plugins;
|
||||
using AcDream.Headless.Diagnostics;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Session;
|
||||
|
||||
namespace AcDream.Headless.Plugins;
|
||||
|
||||
/// <summary>
|
||||
/// Headless composition wrapper that keeps plugin disable/unsubscribe/unload
|
||||
/// ahead of canonical Runtime disposal.
|
||||
/// </summary>
|
||||
internal sealed class HeadlessPluginSession : IDisposable
|
||||
{
|
||||
private readonly HeadlessPluginHost _host;
|
||||
private readonly PluginSession _plugins;
|
||||
private int _disposeStage;
|
||||
private bool _disposed;
|
||||
|
||||
private HeadlessPluginSession(
|
||||
HeadlessPluginHost host,
|
||||
PluginSession plugins)
|
||||
{
|
||||
_host = host;
|
||||
_plugins = plugins;
|
||||
}
|
||||
|
||||
internal int LoadedCount => _plugins.LoadedCount;
|
||||
internal IPluginHost Host => _host;
|
||||
|
||||
internal IReadOnlyList<WeakReference> CaptureLoadContextWeakReferences() =>
|
||||
_plugins.CaptureLoadContextWeakReferences();
|
||||
|
||||
internal static HeadlessPluginSession Start(
|
||||
GameRuntime runtime,
|
||||
HeadlessDiagnosticWriter diagnostics,
|
||||
SessionStatusWriter statusWriter,
|
||||
string sessionId,
|
||||
IEnumerable<string> roots,
|
||||
IReadOnlyList<string>? allowList)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtime);
|
||||
ArgumentNullException.ThrowIfNull(diagnostics);
|
||||
ArgumentNullException.ThrowIfNull(statusWriter);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
|
||||
ArgumentNullException.ThrowIfNull(roots);
|
||||
|
||||
var host = new HeadlessPluginHost(
|
||||
runtime,
|
||||
new HeadlessPluginLogger(
|
||||
diagnostics,
|
||||
sessionId,
|
||||
() => runtime.Generation.Value));
|
||||
var plugins = new PluginSession(
|
||||
host,
|
||||
status => Report(statusWriter, sessionId, status));
|
||||
try
|
||||
{
|
||||
plugins.Start(roots, allowList);
|
||||
return new HeadlessPluginSession(host, plugins);
|
||||
}
|
||||
catch
|
||||
{
|
||||
plugins.Dispose();
|
||||
host.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
while (!_disposed)
|
||||
{
|
||||
switch (_disposeStage)
|
||||
{
|
||||
case 0:
|
||||
_plugins.Dispose();
|
||||
_disposeStage++;
|
||||
break;
|
||||
case 1:
|
||||
_host.Dispose();
|
||||
_disposeStage++;
|
||||
_disposed = true;
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
"Unknown headless plugin teardown stage.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void Report(
|
||||
SessionStatusWriter writer,
|
||||
string sessionId,
|
||||
PluginSessionStatus status)
|
||||
{
|
||||
if (status.Kind == PluginSessionStatusKind.Loaded)
|
||||
{
|
||||
writer.PluginLoaded(sessionId, status.Plugin);
|
||||
return;
|
||||
}
|
||||
writer.PluginFailed(
|
||||
sessionId,
|
||||
status.Plugin,
|
||||
status.Error ?? "plugin failed");
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,14 @@ namespace AcDream.Plugin.Abstractions;
|
|||
/// </summary>
|
||||
public interface IPluginHost
|
||||
{
|
||||
/// <summary>
|
||||
/// <see langword="true"/> when <see cref="Ui"/> registrations can be
|
||||
/// projected by this host. No-window hosts return <see langword="false"/>
|
||||
/// and expose <see cref="NoOpUiRegistry.Instance"/> so a plugin may keep
|
||||
/// one code path while deliberately omitting presentation work.
|
||||
/// </summary>
|
||||
bool HasUi { get; }
|
||||
|
||||
IPluginLogger Log { get; }
|
||||
IGameState State { get; }
|
||||
IEvents Events { get; }
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ namespace AcDream.Plugin.Abstractions;
|
|||
/// <summary>
|
||||
/// Plugin-facing UI registration. A plugin ships a markup file (KSML-style) +
|
||||
/// a binding object exposing the data properties the markup binds to, and
|
||||
/// registers it from <c>Enable()</c>. Calls made before the GL window opens are
|
||||
/// buffered and drained once the UI host exists.
|
||||
/// registers it from <c>Enable()</c>. Graphical hosts buffer registrations until
|
||||
/// their retained UI exists. A host whose <see cref="IPluginHost.HasUi"/> is
|
||||
/// <see langword="false"/> exposes <see cref="NoOpUiRegistry.Instance"/> and
|
||||
/// intentionally discards registrations.
|
||||
/// </summary>
|
||||
public interface IUiRegistry
|
||||
{
|
||||
|
|
@ -12,3 +14,21 @@ public interface IUiRegistry
|
|||
/// <param name="binding">Object whose properties the markup's {Bindings} resolve against.</param>
|
||||
void AddMarkupPanel(string markupPath, object binding);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// BCL-only UI sink for no-window plugin hosts. It intentionally retains
|
||||
/// neither markup paths nor binding objects, so a UI registration cannot keep
|
||||
/// a plugin assembly alive after its collectible load context is unloaded.
|
||||
/// </summary>
|
||||
public sealed class NoOpUiRegistry : IUiRegistry
|
||||
{
|
||||
public static NoOpUiRegistry Instance { get; } = new();
|
||||
|
||||
private NoOpUiRegistry()
|
||||
{
|
||||
}
|
||||
|
||||
public void AddMarkupPanel(string markupPath, object binding)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@ namespace AcDream.Runtime.Session;
|
|||
/// is a single shared-stdout JSONL diagnostics stream with no per-session
|
||||
/// file; this class writes one file per session, meant to be read by an
|
||||
/// external process (the launcher) rather than scraped from console output.
|
||||
/// Event shapes are versioned (<c>"v":1</c>) so a future event kind
|
||||
/// (<c>pluginLoaded</c>/<c>pluginFailed</c>, LA5) can be added without
|
||||
/// breaking an existing reader.
|
||||
/// Event shapes are versioned (<c>"v":1</c>); LA5's
|
||||
/// <c>pluginLoaded</c>/<c>pluginFailed</c> additions use that same envelope
|
||||
/// without breaking an existing reader.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
|
|
@ -29,9 +29,11 @@ namespace AcDream.Runtime.Session;
|
|||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <strong>Never write credential material into this stream.</strong> Every
|
||||
/// event method below takes only identifiers, names, and counts — there is no
|
||||
/// parameter shape that could carry a password, by construction.
|
||||
/// <strong>Never write credential material into this stream.</strong> LA5's
|
||||
/// <see cref="PluginFailed"/> diagnostic is caller-supplied text, so hosts may
|
||||
/// pass only the plugin lifecycle failure and must never append session
|
||||
/// credentials or other secrets. Neither plugin host exposes credentials
|
||||
/// through <c>IPluginHost</c>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
|
|
@ -205,6 +207,27 @@ public sealed class SessionStatusWriter
|
|||
characterName,
|
||||
});
|
||||
|
||||
public void PluginLoaded(string sessionId, string plugin) =>
|
||||
Write(new
|
||||
{
|
||||
v = VocabularyVersion,
|
||||
e = "pluginLoaded",
|
||||
t = Now(),
|
||||
sessionId,
|
||||
plugin,
|
||||
});
|
||||
|
||||
public void PluginFailed(string sessionId, string plugin, string error) =>
|
||||
Write(new
|
||||
{
|
||||
v = VocabularyVersion,
|
||||
e = "pluginFailed",
|
||||
t = Now(),
|
||||
sessionId,
|
||||
plugin,
|
||||
error,
|
||||
});
|
||||
|
||||
public void Disconnected(string sessionId, string reason)
|
||||
{
|
||||
if (!IsEnabled)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue