acdream/src/AcDream.Core/Plugins/ScopedPluginHost.cs

635 lines
21 KiB
C#

using AcDream.Plugin.Abstractions;
namespace AcDream.Core.Plugins;
/// <summary>
/// Per-plugin host view that owns every registration made through the public
/// event/selection/UI surfaces. Disposal is the host's rollback boundary: it removes
/// registrations even when plugin Initialize/Enable/Disable code throws.
/// </summary>
internal sealed class ScopedPluginHost : IPluginHost, IDisposable
{
private readonly IPluginHost _inner;
private readonly ScopedEvents _events;
private readonly ScopedSelectionService _selection;
private readonly ScopedUiRegistry _ui;
private readonly ScopedPluginStorage _storage;
private readonly ScopedPluginCommandRegistry _commands;
private readonly ScopedLootClassifierRegistry _lootClassifiers;
private bool _disposed;
internal ScopedPluginHost(
IPluginHost inner,
string pluginId,
string pluginDisplayName)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
ArgumentException.ThrowIfNullOrWhiteSpace(pluginId);
ArgumentException.ThrowIfNullOrWhiteSpace(pluginDisplayName);
_events = new ScopedEvents(inner.Events);
_selection = new ScopedSelectionService(inner.Selection);
_ui = new ScopedUiRegistry(
inner.Ui,
new PluginUiOwner(pluginId, pluginDisplayName));
_storage = new ScopedPluginStorage(inner.Storage, pluginId);
_commands = new ScopedPluginCommandRegistry(inner.Commands);
_lootClassifiers = new ScopedLootClassifierRegistry(
inner.LootClassifiers,
pluginId,
pluginDisplayName);
}
public bool HasUi => _inner.HasUi;
public IPluginLogger Log => _inner.Log;
public IGameState State => _inner.State;
public IEvents Events => _events;
public ISelectionService Selection => _selection;
public IUiRegistry Ui => _ui;
public IPluginStorage Storage => _storage;
public IPluginCommandRegistry Commands => _commands;
public IPluginLootClassifierRegistry LootClassifiers => _lootClassifiers;
/// <summary>
/// Delegated rather than scoped, unlike <see cref="Events"/>,
/// <see cref="Selection"/> and <see cref="Ui"/>.
/// </summary>
/// <remarks>
/// Automation has no registrations to roll back — it is reads plus a cast
/// call. What actually stops a disabled plugin acting is that its
/// <c>Events.Tick</c> subscription is revoked with the scoped event source,
/// so its buff loop stops being driven. A plugin that cached the surface
/// could still issue a cast from some other callback; that is acceptable
/// while plugins are first-party and trusted, and the fix if that changes
/// is a revocable wrapper here, not a change at the call site.
/// </remarks>
public IAutomationSurface Automation => _inner.Automation;
private sealed class ScopedPluginStorage(
IPluginStorage inner,
string pluginId) : IPluginStorage
{
public bool IsAvailable => inner.IsAvailable;
public string? ReadText(string key) =>
inner.ReadText(ScopedKey(key));
public IReadOnlyList<string> List(string prefix)
{
string scopedPrefix = ScopedKey(prefix);
string ownerPrefix = pluginId + Path.DirectorySeparatorChar;
return inner.List(scopedPrefix)
.Select(key => key.Replace('/', Path.DirectorySeparatorChar))
.Where(key => key.StartsWith(
ownerPrefix,
OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal))
.Select(key => key[ownerPrefix.Length..]
.Replace(Path.DirectorySeparatorChar, '/'))
.ToArray();
}
public void WriteText(string key, string content) =>
inner.WriteText(ScopedKey(key), content);
public bool Delete(string key) => inner.Delete(ScopedKey(key));
private static string ValidateKey(string key)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
if (Path.IsPathRooted(key)
|| key.Contains("..", StringComparison.Ordinal)
|| key.Contains('\\'))
{
throw new ArgumentException("Invalid plugin storage key.", nameof(key));
}
return key.Replace('/', Path.DirectorySeparatorChar);
}
private string ScopedKey(string key) =>
Path.Combine(pluginId, ValidateKey(key));
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_events.Dispose();
_selection.Dispose();
_ui.Dispose();
_commands.Dispose();
_lootClassifiers.Dispose();
}
private sealed class ScopedLootClassifierRegistry(
IPluginLootClassifierRegistry inner,
string pluginId,
string pluginDisplayName)
: IPluginLootClassifierRegistry,
IDisposable
{
private readonly object _gate = new();
private readonly List<IDisposable> _registrations = [];
private bool _disposed;
public IReadOnlyList<PluginLootClassifierInfo> Available =>
inner.Available;
public IDisposable Register(
string classifierId,
string displayName,
IPluginLootClassifier classifier)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentException.ThrowIfNullOrWhiteSpace(classifierId);
string local = classifierId.Trim();
if (local.Contains('/') || local.Contains('\\'))
{
throw new ArgumentException(
"A classifier id cannot contain a path separator.",
nameof(classifierId));
}
string effectiveName = string.IsNullOrWhiteSpace(displayName)
? pluginDisplayName
: displayName.Trim();
IDisposable registration = inner.Register(
$"{pluginId}/{local}",
effectiveName,
classifier);
lock (_gate)
{
if (!_disposed)
{
_registrations.Add(registration);
return registration;
}
}
registration.Dispose();
throw new ObjectDisposedException(nameof(ScopedLootClassifierRegistry));
}
public bool TryClassify(
string classifierId,
in PluginLootClassificationContext context,
out PluginLootClassification classification) =>
inner.TryClassify(classifierId, context, out classification);
public bool TryNotifyLooted(
string classifierId,
in PluginLootedItem item) =>
inner.TryNotifyLooted(classifierId, item);
public bool TryNotifyItemRemoved(
string classifierId,
uint objectId) =>
inner.TryNotifyItemRemoved(classifierId, objectId);
public void Dispose()
{
IDisposable[] registrations;
lock (_gate)
{
if (_disposed)
return;
_disposed = true;
registrations = _registrations.ToArray();
_registrations.Clear();
}
for (int index = registrations.Length - 1; index >= 0; index--)
registrations[index].Dispose();
}
}
private sealed class ScopedPluginCommandRegistry(IPluginCommandRegistry inner)
: IPluginCommandRegistry,
IDisposable
{
private readonly object _gate = new();
private readonly List<IDisposable> _registrations = [];
private bool _disposed;
public IDisposable Register(string verb, Action<PluginCommand> handler)
{
ObjectDisposedException.ThrowIf(_disposed, this);
IDisposable registration = inner.Register(verb, handler);
lock (_gate)
{
if (!_disposed)
{
_registrations.Add(registration);
return registration;
}
}
registration.Dispose();
throw new ObjectDisposedException(nameof(ScopedPluginCommandRegistry));
}
public void Dispose()
{
IDisposable[] registrations;
lock (_gate)
{
if (_disposed)
return;
_disposed = true;
registrations = _registrations.ToArray();
_registrations.Clear();
}
for (int index = registrations.Length - 1; index >= 0; index--)
registrations[index].Dispose();
}
}
private sealed class ScopedSelectionService(ISelectionService inner)
: ISelectionService,
IDisposable
{
private readonly object _gate = new();
private readonly List<Action<SelectionChangedEvent>> _registrations = [];
private bool _disposed;
public uint? SelectedObjectId => inner.SelectedObjectId;
public uint? PreviousObjectId => inner.PreviousObjectId;
public event Action<SelectionChangedEvent> Changed
{
add
{
ArgumentNullException.ThrowIfNull(value);
try
{
inner.Changed += value;
}
catch
{
try { inner.Changed -= value; }
catch { }
throw;
}
lock (_gate)
{
if (!_disposed)
{
_registrations.Add(value);
return;
}
}
try { inner.Changed -= value; }
catch { }
throw new ObjectDisposedException(nameof(ScopedSelectionService));
}
remove
{
if (value is null)
return;
inner.Changed -= value;
lock (_gate)
RemoveLast(value);
}
}
public bool Select(uint objectId)
{
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
return inner.Select(objectId);
}
}
public bool Clear()
{
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
return inner.Clear();
}
}
public void Dispose()
{
Action<SelectionChangedEvent>[] registrations;
lock (_gate)
{
if (_disposed)
return;
_disposed = true;
registrations = _registrations.ToArray();
_registrations.Clear();
}
for (int index = registrations.Length - 1; index >= 0; index--)
{
try { inner.Changed -= registrations[index]; }
catch { }
}
}
private void RemoveLast(Action<SelectionChangedEvent> handler)
{
for (int index = _registrations.Count - 1; index >= 0; index--)
{
if (_registrations[index] != handler)
continue;
_registrations.RemoveAt(index);
return;
}
}
}
private sealed class ScopedEvents(IEvents inner) : IEvents, IDisposable
{
private readonly object _gate = new();
private readonly List<Action<WorldEntitySnapshot>> _registrations = [];
private readonly List<Action<double>> _tickRegistrations = [];
private bool _disposed;
/// <summary>
/// Same transactional revoke contract as <see cref="EntitySpawned"/>.
/// Revoking this on teardown is what actually stops a disabled plugin
/// acting on the world: no tick, no automation loop.
/// </summary>
public event Action<double> Tick
{
add
{
ArgumentNullException.ThrowIfNull(value);
try
{
inner.Tick += value;
}
catch
{
try { inner.Tick -= value; }
catch { }
throw;
}
lock (_gate)
{
if (!_disposed)
{
_tickRegistrations.Add(value);
return;
}
}
try { inner.Tick -= value; }
catch { }
throw new ObjectDisposedException(nameof(ScopedEvents));
}
remove
{
if (value is null)
return;
inner.Tick -= value;
lock (_gate)
{
for (int index = _tickRegistrations.Count - 1; index >= 0; index--)
{
if (_tickRegistrations[index] != value)
continue;
_tickRegistrations.RemoveAt(index);
break;
}
}
}
}
public event Action<WorldEntitySnapshot> EntitySpawned
{
add
{
ArgumentNullException.ThrowIfNull(value);
try
{
inner.EntitySpawned += value;
}
catch
{
// A custom event source may mutate before its add accessor
// faults. Best-effort removal keeps the scope transactional.
try { inner.EntitySpawned -= value; }
catch { }
throw;
}
lock (_gate)
{
if (!_disposed)
{
_registrations.Add(value);
return;
}
}
// Disposal may race the host subscription call. In that case
// the disposal snapshot could not see this registration, so
// the attaching thread must roll it back before returning.
try { inner.EntitySpawned -= value; }
catch { }
throw new ObjectDisposedException(nameof(ScopedEvents));
}
remove
{
if (value is null)
return;
inner.EntitySpawned -= value;
lock (_gate)
RemoveLast(value);
}
}
public void Dispose()
{
Action<WorldEntitySnapshot>[] registrations;
Action<double>[] tickRegistrations;
lock (_gate)
{
if (_disposed)
return;
_disposed = true;
registrations = _registrations.ToArray();
_registrations.Clear();
tickRegistrations = _tickRegistrations.ToArray();
_tickRegistrations.Clear();
}
for (int index = registrations.Length - 1; index >= 0; index--)
{
try { inner.EntitySpawned -= registrations[index]; }
catch { }
}
for (int index = tickRegistrations.Length - 1; index >= 0; index--)
{
try { inner.Tick -= tickRegistrations[index]; }
catch { }
}
}
private void RemoveLast(Action<WorldEntitySnapshot> handler)
{
for (int index = _registrations.Count - 1; index >= 0; index--)
{
if (_registrations[index] != handler)
continue;
_registrations.RemoveAt(index);
return;
}
}
}
private sealed class ScopedUiRegistry : IUiRegistry, IDisposable
{
private readonly IScopedUiRegistry _inner;
private readonly PluginUiOwner _owner;
private readonly object _gate = new();
private readonly List<IDisposable> _registrations = [];
private bool _disposed;
internal ScopedUiRegistry(IUiRegistry inner, PluginUiOwner owner)
{
_inner = inner as IScopedUiRegistry
?? throw new InvalidOperationException(
"Plugin hosts must expose an IScopedUiRegistry so UI registrations can be rolled back.");
_owner = owner;
}
public void AddMarkupPanel(string markupPath, object binding)
{
AddRegistration(_inner.RegisterPanel(
_owner,
new PluginPanelDescriptor(
Path.GetFileNameWithoutExtension(markupPath),
_owner.DisplayName),
markupPath,
binding));
}
public void AddPanel(
PluginPanelDescriptor descriptor,
string markupPath,
object binding)
{
ArgumentNullException.ThrowIfNull(descriptor);
AddRegistration(_inner.RegisterPanel(
_owner,
descriptor,
markupPath,
binding));
}
public IDisposable RegisterPanel(
PluginPanelDescriptor descriptor,
string markupPath,
object binding)
{
ArgumentNullException.ThrowIfNull(descriptor);
return TrackRegistration(_inner.RegisterPanel(
_owner,
descriptor,
markupPath,
binding));
}
public IDisposable RegisterPanelContent(
PluginPanelDescriptor descriptor,
string markupContent,
object binding)
{
ArgumentNullException.ThrowIfNull(descriptor);
return TrackRegistration(_inner.RegisterPanelContent(
_owner,
descriptor,
markupContent,
binding));
}
public bool ViewExists(string viewName) =>
_inner.ViewExists(_owner, viewName);
public bool IsViewVisible(string viewName) =>
_inner.IsViewVisible(_owner, viewName);
public bool ControlExists(string viewName, string controlName) =>
_inner.ControlExists(_owner, viewName, controlName);
public bool SetControlLabel(
string viewName,
string controlName,
string label) =>
_inner.SetControlLabel(_owner, viewName, controlName, label);
public bool SetControlVisible(
string viewName,
string controlName,
bool visible) =>
_inner.SetControlVisible(_owner, viewName, controlName, visible);
private void AddRegistration(IDisposable registration)
{
lock (_gate)
{
if (!_disposed)
{
_registrations.Add(registration);
return;
}
}
registration.Dispose();
throw new ObjectDisposedException(nameof(ScopedUiRegistry));
}
private IDisposable TrackRegistration(IDisposable registration)
{
lock (_gate)
{
if (!_disposed)
{
_registrations.Add(registration);
return new IndividualRegistration(this, registration);
}
}
registration.Dispose();
throw new ObjectDisposedException(nameof(ScopedUiRegistry));
}
private void RemoveRegistration(IDisposable registration)
{
lock (_gate)
{
if (!_registrations.Remove(registration))
return;
}
registration.Dispose();
}
public void Dispose()
{
IDisposable[] registrations;
lock (_gate)
{
if (_disposed)
return;
_disposed = true;
registrations = _registrations.ToArray();
_registrations.Clear();
}
for (int index = registrations.Length - 1; index >= 0; index--)
{
try { registrations[index].Dispose(); }
catch { }
}
}
private sealed class IndividualRegistration(
ScopedUiRegistry owner,
IDisposable registration) : IDisposable
{
private ScopedUiRegistry? _owner = owner;
public void Dispose() => Interlocked.Exchange(ref _owner, null)?
.RemoveRegistration(registration);
}
}
}