feat(mosstank): add VTank-style automation PoC
This commit is contained in:
parent
f6fe0f2a4f
commit
4e6e9bc9d9
212 changed files with 49462 additions and 416 deletions
|
|
@ -13,14 +13,30 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
|
|||
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)
|
||||
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);
|
||||
_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;
|
||||
|
|
@ -29,6 +45,9 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
|
|||
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"/>,
|
||||
|
|
@ -45,6 +64,48 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
|
|||
/// </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)
|
||||
|
|
@ -53,6 +114,127 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
|
|||
_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)
|
||||
|
|
@ -297,22 +479,92 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
|
|||
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)
|
||||
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)
|
||||
{
|
||||
IDisposable registration = _inner.RegisterMarkupPanel(
|
||||
AddRegistration(_inner.RegisterPanel(
|
||||
_owner,
|
||||
new PluginPanelDescriptor(
|
||||
Path.GetFileNameWithoutExtension(markupPath),
|
||||
_owner.DisplayName),
|
||||
markupPath,
|
||||
binding);
|
||||
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)
|
||||
|
|
@ -326,6 +578,31 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
|
|||
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;
|
||||
|
|
@ -344,5 +621,15 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
|
|||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class IndividualRegistration(
|
||||
ScopedUiRegistry owner,
|
||||
IDisposable registration) : IDisposable
|
||||
{
|
||||
private ScopedUiRegistry? _owner = owner;
|
||||
|
||||
public void Dispose() => Interlocked.Exchange(ref _owner, null)?
|
||||
.RemoveRegistration(registration);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue