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
|
|
@ -251,6 +251,21 @@ public sealed class ClientObject
|
|||
/// <summary>Retail <c>PublicWeenieDesc._spellID</c>; used by caster endowments.</summary>
|
||||
public uint? SpellId { get; set; }
|
||||
/// <summary>
|
||||
/// Spell ids retained from this item's latest successful
|
||||
/// <c>IdentifyObjectResponse</c> SpellBook block. These are item spells,
|
||||
/// not the local character's learned spellbook; VTank uses them to
|
||||
/// classify cast-on-strike weapons and item-cast debuffs.
|
||||
/// </summary>
|
||||
public IReadOnlyList<uint> AppraisedSpellIds { get; internal set; } =
|
||||
Array.Empty<uint>();
|
||||
/// <summary>
|
||||
/// Monotonic millisecond tick at which the latest successful identify
|
||||
/// response was received. This is Decal's per-world-object
|
||||
/// <c>LastIdTime</c>, retained on the canonical object so it disappears
|
||||
/// with that exact object lifetime.
|
||||
/// </summary>
|
||||
public int LastAppraisalTimeMs { get; internal set; }
|
||||
/// <summary>
|
||||
/// Retail <c>PublicWeenieDesc._cooldown_id</c>. Positive values name a
|
||||
/// shared item-cooldown group whose player enchantment id is
|
||||
/// <c>CooldownId + 0x8000</c>.
|
||||
|
|
|
|||
|
|
@ -774,6 +774,45 @@ public sealed class ClientObjectTable
|
|||
public bool UpdateProperties(uint itemId, PropertyBundle incoming)
|
||||
{
|
||||
if (!_objects.TryGetValue(itemId, out var item)) return false;
|
||||
MergeProperties(item, incoming);
|
||||
ApplyCooldownProperties(item, incoming);
|
||||
ObjectUpdated?.Invoke(item);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Atomically retains every successful item-appraisal result: the typed
|
||||
/// property tables and the per-item SpellBook block. Publishing one update
|
||||
/// prevents observers from seeing properties without their matching spell
|
||||
/// manifest (or the reverse).
|
||||
/// </summary>
|
||||
public bool UpdateAppraisal(
|
||||
uint itemId,
|
||||
PropertyBundle incoming,
|
||||
IReadOnlyList<uint> spellIds,
|
||||
double receivedAtSeconds = 0d)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(incoming);
|
||||
ArgumentNullException.ThrowIfNull(spellIds);
|
||||
if (!_objects.TryGetValue(itemId, out var item)) return false;
|
||||
MergeProperties(item, incoming);
|
||||
item.AppraisedSpellIds = spellIds.Count == 0
|
||||
? Array.Empty<uint>()
|
||||
: spellIds.ToArray();
|
||||
if (double.IsFinite(receivedAtSeconds) && receivedAtSeconds >= 0d)
|
||||
{
|
||||
long milliseconds = checked((long)Math.Round(
|
||||
receivedAtSeconds * 1000d,
|
||||
MidpointRounding.AwayFromZero));
|
||||
item.LastAppraisalTimeMs = unchecked((int)milliseconds);
|
||||
}
|
||||
ApplyCooldownProperties(item, incoming);
|
||||
ObjectUpdated?.Invoke(item);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void MergeProperties(ClientObject item, PropertyBundle incoming)
|
||||
{
|
||||
foreach (var kv in incoming.Ints) item.Properties.Ints[kv.Key] = kv.Value;
|
||||
foreach (var kv in incoming.Int64s) item.Properties.Int64s[kv.Key] = kv.Value;
|
||||
foreach (var kv in incoming.Bools) item.Properties.Bools[kv.Key] = kv.Value;
|
||||
|
|
@ -781,9 +820,6 @@ public sealed class ClientObjectTable
|
|||
foreach (var kv in incoming.Strings) item.Properties.Strings[kv.Key] = kv.Value;
|
||||
foreach (var kv in incoming.DataIds) item.Properties.DataIds[kv.Key] = kv.Value;
|
||||
foreach (var kv in incoming.InstanceIds) item.Properties.InstanceIds[kv.Key] = kv.Value;
|
||||
ApplyCooldownProperties(item, incoming);
|
||||
ObjectUpdated?.Invoke(item);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -2383,7 +2383,11 @@ public sealed class PhysicsEngine
|
|||
body is not null ? PhysicsResolveCapture.Snapshot(body) : null);
|
||||
}
|
||||
|
||||
return resolveResult;
|
||||
return resolveResult with
|
||||
{
|
||||
LastCollidedObjectId = ci.LastCollidedObjectGuid ?? 0u,
|
||||
CollidedWithEnvironment = ci.CollidedWithEnvironment,
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
|
|||
|
|
@ -58,4 +58,16 @@ public readonly record struct ResolveResult(
|
|||
/// <summary>Full cell that owns <see cref="ContactPlane"/>.</summary>
|
||||
uint ContactPlaneCellId = 0,
|
||||
/// <summary>Whether the accepted contact plane is water.</summary>
|
||||
bool ContactPlaneIsWater = false);
|
||||
bool ContactPlaneIsWater = false)
|
||||
{
|
||||
/// <summary>
|
||||
/// Last live object touched by this transition, or zero for environment-
|
||||
/// only/no collision. This is detached collision evidence, not an impact
|
||||
/// side effect; projectile-awareness callers use it to distinguish the
|
||||
/// designated target from an intervening creature or prop.
|
||||
/// </summary>
|
||||
public uint LastCollidedObjectId { get; init; }
|
||||
|
||||
/// <summary>Whether resident environment geometry blocked the sweep.</summary>
|
||||
public bool CollidedWithEnvironment { get; init; }
|
||||
}
|
||||
|
|
|
|||
142
src/AcDream.Core/Plugins/PluginCommandRegistry.cs
Normal file
142
src/AcDream.Core/Plugins/PluginCommandRegistry.cs
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace AcDream.Core.Plugins;
|
||||
|
||||
/// <summary>
|
||||
/// Shared host implementation of the additive plugin-command contract.
|
||||
/// Registrations are exact leases; callbacks are invoked outside the registry
|
||||
/// lock so a handler may submit chat or unregister itself without deadlocking.
|
||||
/// </summary>
|
||||
public sealed class PluginCommandRegistry : IPluginCommandRegistry
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<string, Registration> _registrations =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Action<string, Exception>? _onFailure;
|
||||
|
||||
public PluginCommandRegistry(Action<string, Exception>? onFailure = null)
|
||||
{
|
||||
_onFailure = onFailure;
|
||||
}
|
||||
|
||||
public IDisposable Register(string verb, Action<PluginCommand> handler)
|
||||
{
|
||||
string normalized = NormalizeVerb(verb);
|
||||
ArgumentNullException.ThrowIfNull(handler);
|
||||
var registration = new Registration(this, normalized, handler);
|
||||
lock (_gate)
|
||||
{
|
||||
if (_registrations.ContainsKey(normalized))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin command '{normalized}' is already registered.");
|
||||
}
|
||||
_registrations.Add(normalized, registration);
|
||||
}
|
||||
return registration;
|
||||
}
|
||||
|
||||
/// <summary>Try to consume one complete command-shaped line.</summary>
|
||||
public bool TryHandle(string rawText)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawText))
|
||||
return false;
|
||||
string trimmed = rawText.Trim();
|
||||
if (trimmed.Length < 2 || trimmed[0] is not ('/' or '@'))
|
||||
return false;
|
||||
|
||||
int separator = trimmed.IndexOfAny([' ', '\t'], 1);
|
||||
string verb = separator < 0
|
||||
? trimmed[1..]
|
||||
: trimmed[1..separator];
|
||||
if (verb.Length == 0)
|
||||
return false;
|
||||
|
||||
Registration? registration;
|
||||
lock (_gate)
|
||||
_registrations.TryGetValue(verb, out registration);
|
||||
if (registration is null)
|
||||
return false;
|
||||
|
||||
string arguments = separator < 0
|
||||
? string.Empty
|
||||
: trimmed[(separator + 1)..].Trim();
|
||||
try
|
||||
{
|
||||
registration.Invoke(new PluginCommand(
|
||||
registration.Verb,
|
||||
arguments,
|
||||
trimmed));
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
try
|
||||
{
|
||||
_onFailure?.Invoke(registration.Verb, error);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Diagnostics observe plugin code; they cannot poison chat.
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string NormalizeVerb(string verb)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(verb);
|
||||
string normalized = verb.Trim().TrimStart('/', '@');
|
||||
if (normalized.Length is < 1 or > 32
|
||||
|| normalized.Any(static value => !char.IsLetterOrDigit(value)))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Plugin command verbs must contain 1-32 letters or digits.",
|
||||
nameof(verb));
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private void Remove(Registration expected)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_registrations.TryGetValue(expected.Verb, out Registration? current)
|
||||
&& ReferenceEquals(current, expected))
|
||||
{
|
||||
_registrations.Remove(expected.Verb);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Registration(
|
||||
PluginCommandRegistry owner,
|
||||
string verb,
|
||||
Action<PluginCommand> handler) : IDisposable
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private PluginCommandRegistry? _owner = owner;
|
||||
private Action<PluginCommand>? _handler = handler;
|
||||
|
||||
internal string Verb { get; } = verb;
|
||||
|
||||
internal void Invoke(PluginCommand command)
|
||||
{
|
||||
Action<PluginCommand>? callback;
|
||||
lock (_gate)
|
||||
callback = _handler;
|
||||
callback?.Invoke(command);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
PluginCommandRegistry? currentOwner;
|
||||
lock (_gate)
|
||||
{
|
||||
currentOwner = _owner;
|
||||
_owner = null;
|
||||
_handler = null;
|
||||
}
|
||||
currentOwner?.Remove(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
151
src/AcDream.Core/Plugins/PluginLootClassifierRegistry.cs
Normal file
151
src/AcDream.Core/Plugins/PluginLootClassifierRegistry.cs
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace AcDream.Core.Plugins;
|
||||
|
||||
/// <summary>Process-local transactional registry for external loot plugins.</summary>
|
||||
public sealed class PluginLootClassifierRegistry : IPluginLootClassifierRegistry
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<string, Entry> _entries =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public IReadOnlyList<PluginLootClassifierInfo> Available
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _entries.Values
|
||||
.Select(static entry => entry.Info)
|
||||
.OrderBy(static info => info.DisplayName,
|
||||
StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(static info => info.Id,
|
||||
StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public IDisposable Register(
|
||||
string classifierId,
|
||||
string displayName,
|
||||
IPluginLootClassifier classifier)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(classifierId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(displayName);
|
||||
ArgumentNullException.ThrowIfNull(classifier);
|
||||
string id = classifierId.Trim();
|
||||
var entry = new Entry(
|
||||
new PluginLootClassifierInfo(id, displayName.Trim()),
|
||||
classifier);
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_entries.TryAdd(id, entry))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Loot classifier '{id}' is already registered.");
|
||||
}
|
||||
}
|
||||
return new Registration(this, id, entry);
|
||||
}
|
||||
|
||||
public bool TryClassify(
|
||||
string classifierId,
|
||||
in PluginLootClassificationContext context,
|
||||
out PluginLootClassification classification)
|
||||
{
|
||||
Entry? entry;
|
||||
lock (_gate)
|
||||
_entries.TryGetValue(classifierId ?? string.Empty, out entry);
|
||||
if (entry is null)
|
||||
{
|
||||
classification = default;
|
||||
return false;
|
||||
}
|
||||
try
|
||||
{
|
||||
classification = entry.Classifier.Classify(context);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
classification = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryNotifyLooted(
|
||||
string classifierId,
|
||||
in PluginLootedItem item)
|
||||
{
|
||||
if (!TryGetClassifier(classifierId, out IPluginLootClassifier classifier))
|
||||
return false;
|
||||
try
|
||||
{
|
||||
classifier.OnLooted(item);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryNotifyItemRemoved(string classifierId, uint objectId)
|
||||
{
|
||||
if (!TryGetClassifier(classifierId, out IPluginLootClassifier classifier))
|
||||
return false;
|
||||
try
|
||||
{
|
||||
classifier.OnItemRemoved(objectId);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryGetClassifier(
|
||||
string classifierId,
|
||||
out IPluginLootClassifier classifier)
|
||||
{
|
||||
classifier = null!;
|
||||
if (string.IsNullOrWhiteSpace(classifierId))
|
||||
return false;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_entries.TryGetValue(classifierId.Trim(), out Entry? entry))
|
||||
return false;
|
||||
classifier = entry.Classifier;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private void Remove(string id, Entry expected)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_entries.TryGetValue(id, out Entry? current)
|
||||
&& ReferenceEquals(current, expected))
|
||||
{
|
||||
_entries.Remove(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record Entry(
|
||||
PluginLootClassifierInfo Info,
|
||||
IPluginLootClassifier Classifier);
|
||||
|
||||
private sealed class Registration(
|
||||
PluginLootClassifierRegistry owner,
|
||||
string id,
|
||||
Entry entry) : IDisposable
|
||||
{
|
||||
private PluginLootClassifierRegistry? _owner = owner;
|
||||
|
||||
public void Dispose() => Interlocked.Exchange(ref _owner, null)?
|
||||
.Remove(id, entry);
|
||||
}
|
||||
}
|
||||
|
|
@ -258,7 +258,10 @@ public sealed class PluginSession : IDisposable
|
|||
{
|
||||
foreach (PluginDiscoveryResult candidate in available)
|
||||
{
|
||||
var scope = new ScopedPluginHost(_host);
|
||||
var scope = new ScopedPluginHost(
|
||||
_host,
|
||||
candidate.Manifest!.Id,
|
||||
candidate.Manifest.DisplayName);
|
||||
ScopedRenderPackRegistry? renderPackScope =
|
||||
candidate.Manifest!.Declares(PluginKind.RenderPack)
|
||||
&& _renderPacks is not null
|
||||
|
|
|
|||
|
|
@ -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