From 60b8a94ab291813fb3e33091f1ae159bf6895d2a Mon Sep 17 00:00:00 2001 From: pas2704 Date: Sat, 7 Jun 2025 11:54:12 -0400 Subject: [PATCH] Support reloading local plugins Added drag and drop functionality to sources --- CringePlugins/CringePlugins.csproj | 5 ++ CringePlugins/Loader/LocalLoadContext.cs | 16 ++++ .../Loader/PluginAssemblyLoadContext.cs | 6 +- CringePlugins/Loader/PluginInstance.cs | 81 ++++++++++++++++++- CringePlugins/Loader/PluginsLifetime.cs | 24 +++++- CringePlugins/Render/RenderHandler.cs | 39 ++++++--- CringePlugins/Ui/PluginListComponent.cs | 41 +++++++++- 7 files changed, 191 insertions(+), 21 deletions(-) create mode 100644 CringePlugins/Loader/LocalLoadContext.cs diff --git a/CringePlugins/CringePlugins.csproj b/CringePlugins/CringePlugins.csproj index f3d679a..af38301 100644 --- a/CringePlugins/CringePlugins.csproj +++ b/CringePlugins/CringePlugins.csproj @@ -21,6 +21,11 @@ + + + + + diff --git a/CringePlugins/Loader/LocalLoadContext.cs b/CringePlugins/Loader/LocalLoadContext.cs new file mode 100644 index 0000000..fbdcf16 --- /dev/null +++ b/CringePlugins/Loader/LocalLoadContext.cs @@ -0,0 +1,16 @@ +using CringeBootstrap.Abstractions; +using System.Reflection; + +namespace CringePlugins.Loader; +internal class LocalLoadContext(ICoreLoadContext parentContext, string entrypointPath) : PluginAssemblyLoadContext(parentContext, entrypointPath) +{ + //use MemoryStream so the file can be written over, and check for .pdb + protected override Assembly LoadAssemblyFile(string path) + { + var pdbFile = Path.ChangeExtension(path, ".pdb"); + + return File.Exists(pdbFile) + ? LoadFromStream(new MemoryStream(File.ReadAllBytes(path)), new MemoryStream(File.ReadAllBytes(pdbFile))) + : LoadFromStream(new MemoryStream(File.ReadAllBytes(path))); + } +} diff --git a/CringePlugins/Loader/PluginAssemblyLoadContext.cs b/CringePlugins/Loader/PluginAssemblyLoadContext.cs index b8b7bfc..3fdeb71 100644 --- a/CringePlugins/Loader/PluginAssemblyLoadContext.cs +++ b/CringePlugins/Loader/PluginAssemblyLoadContext.cs @@ -30,7 +30,7 @@ internal class PluginAssemblyLoadContext : DerivedAssemblyLoadContext if (_assembly is not null) return _assembly; - _assembly = LoadFromAssemblyPath(_entrypointPath); + _assembly = LoadAssemblyFile(_entrypointPath); var module = _assembly.GetMainModule(); @@ -50,7 +50,7 @@ internal class PluginAssemblyLoadContext : DerivedAssemblyLoadContext protected override Assembly? Load(AssemblyName assemblyName) { if (_dependencyResolver.ResolveAssemblyToPath(assemblyName) is { } path) - return LoadFromAssemblyPath(path); + return LoadAssemblyFile(path); return base.Load(assemblyName); } @@ -63,6 +63,8 @@ internal class PluginAssemblyLoadContext : DerivedAssemblyLoadContext return base.LoadUnmanagedDll(unmanagedDllName); } + protected virtual Assembly LoadAssemblyFile(string path) => LoadFromAssemblyPath(path); + private static void OnUnload(AssemblyLoadContext context) { if (context is not PluginAssemblyLoadContext pluginContext) diff --git a/CringePlugins/Loader/PluginInstance.cs b/CringePlugins/Loader/PluginInstance.cs index 82834d0..85cc68b 100644 --- a/CringePlugins/Loader/PluginInstance.cs +++ b/CringePlugins/Loader/PluginInstance.cs @@ -3,17 +3,26 @@ using System.Runtime.Loader; using CringeBootstrap.Abstractions; using CringePlugins.Utils; using NLog; +using Sandbox; +using Sandbox.Game.World; using SharedCringe.Loader; +using VRage; +using VRage.Game; +using VRage.Game.ObjectBuilder; using VRage.Plugins; namespace CringePlugins.Loader; -internal sealed class PluginInstance(PluginMetadata metadata, string entrypointPath) +internal sealed class PluginInstance(PluginMetadata metadata, string entrypointPath, bool local) { public bool HasConfig => _openConfigAction != null; + public bool IsReloading => _disposeTcs?.Task.IsCompleted == false; + + public bool IsLocal => local; private PluginAssemblyLoadContext? _context; private IPlugin? _instance; + private TaskCompletionSource<(DerivedAssemblyLoadContext OldContext, DerivedAssemblyLoadContext NewContext)>? _disposeTcs; private Action? _openConfigAction; public PluginWrapper? WrappedInstance { get; private set; } @@ -21,7 +30,7 @@ internal sealed class PluginInstance(PluginMetadata metadata, string entrypointP private static readonly ILogger Log = LogManager.GetCurrentClassLogger(); public PluginMetadata Metadata { get; } = metadata; - public PluginInstance(string entrypointPath) : this(PluginMetadata.ReadFromEntrypoint(entrypointPath), entrypointPath) + public PluginInstance(string entrypointPath, bool local) : this(PluginMetadata.ReadFromEntrypoint(entrypointPath), entrypointPath, local) { } @@ -30,7 +39,7 @@ internal sealed class PluginInstance(PluginMetadata metadata, string entrypointP if (AssemblyLoadContext.GetLoadContext(typeof(PluginInstance).Assembly) is not ICoreLoadContext parentContext) throw new NotSupportedException("Plugin instantiation is not supported in this context"); - _context = new PluginAssemblyLoadContext(parentContext, entrypointPath); + _context = local ? new LocalLoadContext(parentContext, entrypointPath) : new PluginAssemblyLoadContext(parentContext, entrypointPath); contextBuilder.Add(_context); var entrypoint = _context.LoadEntrypoint(); @@ -86,4 +95,70 @@ internal sealed class PluginInstance(PluginMetadata metadata, string entrypointP Log.Error(ex, "Error opening config"); } } + + public Task<(DerivedAssemblyLoadContext OldContext, DerivedAssemblyLoadContext NewContext)> ReloadAsync() + { + if (!local) + throw new NotSupportedException("Reload is only supported for local plugins"); + + if (_disposeTcs != null) + return _disposeTcs.Task; + + var tcs = new TaskCompletionSource<(DerivedAssemblyLoadContext OldContext, DerivedAssemblyLoadContext NewContext)>(); + + _disposeTcs = tcs; + MySandboxGame.Static.Invoke(ReloadInternal, "PluginInstance.Reload"); + return tcs.Task; + } + private void ReloadInternal() + { + if (_disposeTcs is null) + throw new InvalidOperationException("Must call Reload first"); + + Log.Info("Reloading local plugin {Name}", Metadata.Name); + + if (_context is null) + throw new InvalidOperationException("Must call Instantiate first"); + + MyPlugins.m_plugins.Remove(WrappedInstance); + if (_instance is IHandleInputPlugin) + MyPlugins.m_handleInputPlugins.Remove(WrappedInstance); + + if (MySession.Static is { } session) + { + foreach (var kvp in session.m_sessionComponents) + { + if (kvp.Key.Assembly == WrappedInstance!.InstanceType.Assembly) + { + session.UnregisterComponent(kvp.Value); + } + } + } + MyGlobalTypeMetadata.Static.m_assemblies.Remove(WrappedInstance!.InstanceType.Assembly); + MyDefinitionManagerBase.m_registered.Remove(WrappedInstance!.InstanceType.Assembly); + MyDefinitionManagerBase.m_registeredAssemblies.Remove(WrappedInstance!.InstanceType.Assembly); + MyXmlSerializerManager.m_registeredAssemblies.Remove(WrappedInstance!.InstanceType.Assembly); + + _openConfigAction = null; + WrappedInstance?.Dispose(); + WrappedInstance = null; + _instance = null; + + _context.Unload(); + var oldContext = _context; + + var builder = ImmutableArray.CreateBuilder(); + Instantiate(builder); + RegisterLifetime(); + WrappedInstance!.Init(MySandboxGame.Static); + Log.Info("Plugin Init: {Metadata}", WrappedInstance.ToString()); + + MyGlobalTypeMetadata.Static.RegisterAssembly(WrappedInstance!.InstanceType.Assembly); + MySession.Static?.RegisterComponentsFromAssembly(WrappedInstance!.InstanceType.Assembly, true); + + _disposeTcs.SetResult((oldContext, builder[0])); + _disposeTcs = null; + + Log.Info("Reloaded local plugin {Name}", Metadata.Name); + } } \ No newline at end of file diff --git a/CringePlugins/Loader/PluginsLifetime.cs b/CringePlugins/Loader/PluginsLifetime.cs index 355944e..eaa834e 100644 --- a/CringePlugins/Loader/PluginsLifetime.cs +++ b/CringePlugins/Loader/PluginsLifetime.cs @@ -18,6 +18,7 @@ namespace CringePlugins.Loader; internal class PluginsLifetime(ConfigHandler configHandler, HttpClient client) : IPluginsLifetime { public static ImmutableArray Contexts { get; private set; } = []; + private static readonly Lock ContextsLock = new(); private static readonly Logger Log = LogManager.GetCurrentClassLogger(); @@ -70,6 +71,23 @@ internal class PluginsLifetime(ConfigHandler configHandler, HttpClient client) : RenderHandler.Current.RegisterComponent(new PluginListComponent(_configReference, _launcherConfig, sourceMapping, MyFileSystem.ExePath, _plugins)); } + public static async Task ReloadPlugin(PluginInstance instance) + { + try + { + var (oldContext, newContext) = await instance.ReloadAsync(); + + lock(ContextsLock) + { + Contexts = Contexts.Remove(oldContext).Add(newContext); + } + } + catch (Exception e) + { + Log.Error(e, "Failed to reload plugin {Plugin}", instance.Metadata); + } + } + public void RegisterLifetime() { var contextBuilder = Contexts.ToBuilder(); @@ -158,17 +176,17 @@ internal class PluginsLifetime(ConfigHandler configHandler, HttpClient client) : var path = files[0].FullName[..^".deps.json".Length] + ".dll"; - LoadComponent(plugins, path); + LoadComponent(plugins, path, null, true); } _plugins = plugins.ToImmutable(); } - private static void LoadComponent(ImmutableArray.Builder plugins, string path, PluginMetadata? metadata = null) + private static void LoadComponent(ImmutableArray.Builder plugins, string path, PluginMetadata? metadata = null, bool local = false) { try { - plugins.Add(metadata is null ? new PluginInstance(path) : new(metadata, path)); + plugins.Add(metadata is null ? new PluginInstance(path, local) : new(metadata, path, local)); } catch (Exception e) { diff --git a/CringePlugins/Render/RenderHandler.cs b/CringePlugins/Render/RenderHandler.cs index 5cb413c..9379b06 100644 --- a/CringePlugins/Render/RenderHandler.cs +++ b/CringePlugins/Render/RenderHandler.cs @@ -14,7 +14,8 @@ public sealed class RenderHandler : IRootRenderComponent public static RenderHandler Current => _current ?? throw new InvalidOperationException("Render is not yet initialized"); public static IGuiHandler GuiHandler => _guiHandler ?? throw new InvalidOperationException("Render is not yet initialized"); - private readonly ConcurrentBag _components = []; + private readonly List _components = []; + private readonly Lock _componentsLock = new(); internal RenderHandler(IGuiHandler guiHandler) { @@ -24,7 +25,24 @@ public sealed class RenderHandler : IRootRenderComponent public void RegisterComponent(TComponent instance) where TComponent : IRenderComponent { - _components.Add(new ComponentRegistration(typeof(TComponent), instance)); + lock (_componentsLock) + _components.Add(new ComponentRegistration(typeof(TComponent), instance)); + } + + public void UnregisterComponent(TComponent instance) where TComponent : IRenderComponent + { + lock (_componentsLock) + { + for (var i = 0; i < _components.Count; i++) + { + var (instanceType, renderComponent) = _components[i]; + if (renderComponent.Equals(instance) && instanceType == typeof(TComponent)) + { + _components.RemoveAtFast(i); + return; + } + } + } } void IRenderComponent.OnFrame() @@ -33,15 +51,18 @@ public sealed class RenderHandler : IRootRenderComponent ImGui.ShowDemoWindow(); #endif - foreach (var (instanceType, renderComponent) in _components) + lock (_componentsLock) { - try + foreach (var (instanceType, renderComponent) in _components) { - renderComponent.OnFrame(); - } - catch (Exception e) - { - Log.Error(e, "Component {TypeName} failed to render a new frame", instanceType); + try + { + renderComponent.OnFrame(); + } + catch (Exception e) + { + Log.Error(e, "Component {TypeName} failed to render a new frame", instanceType); + } } } } diff --git a/CringePlugins/Ui/PluginListComponent.cs b/CringePlugins/Ui/PluginListComponent.cs index 0eded09..c34c035 100644 --- a/CringePlugins/Ui/PluginListComponent.cs +++ b/CringePlugins/Ui/PluginListComponent.cs @@ -127,11 +127,14 @@ internal class PluginListComponent : IRenderComponent TableNextColumn(); BeginDisabled(!plugin.HasConfig); if (plugin.WrappedInstance?.LastException is not null) + { PushStyleColor(plugin.HasConfig ? ImGuiCol.Text : ImGuiCol.TextDisabled, - plugin.HasConfig ? Color.Red.ToFloat4() : Color.DarkRed.ToFloat4()); - if (Selectable(plugin.Metadata.Name, false, ImGuiSelectableFlags.SpanAllColumns)) + plugin.IsReloading ? Color.Yellow.ToFloat4() : plugin.HasConfig ? Color.Red.ToFloat4() : Color.DarkRed.ToFloat4()); + } + + if (Selectable(plugin.Metadata.Name, false, ImGuiSelectableFlags.SpanAllColumns) && !plugin.IsReloading) plugin.OpenConfig(); - if (plugin.WrappedInstance?.LastException is not null) + if (plugin.WrappedInstance?.LastException is not null && !plugin.IsReloading) { PopStyleColor(); if (IsItemHovered(ImGuiHoveredFlags.ForTooltip)) @@ -139,6 +142,18 @@ internal class PluginListComponent : IRenderComponent $"{plugin.WrappedInstance.LastException.GetType()}: {plugin.WrappedInstance.LastException.Message}"); } EndDisabled(); + + if (!plugin.IsReloading && BeginPopupContextItem($"##{plugin.Metadata.Name}ContextMenu")) + { + BeginDisabled(!plugin.IsLocal); + if (Button("Reload")) + { + PluginsLifetime.ReloadPlugin(plugin).ConfigureAwait(false); + } + EndDisabled(); + EndPopup(); + } + TableNextColumn(); Text(plugin.Metadata.Version.ToString()); TableNextColumn(); @@ -182,11 +197,29 @@ internal class PluginListComponent : IRenderComponent TableNextColumn(); - if (Selectable(source.Name, index == _selectedSource?.index, ImGuiSelectableFlags.SpanAllColumns)) + if (Selectable($"{source.Name}##{index}", index == _selectedSource?.index, ImGuiSelectableFlags.SpanAllColumns)) { _selectedSource = (source, index); } + if (IsItemActive() && !IsItemHovered()) + { + var nextIndex = index + (GetMouseDragDelta(0).Y < 0 ? -1 : 1); + if (nextIndex >= 0 && nextIndex < _packagesConfig.Value.Sources.Length) + { + var builder = _packagesConfig.Value.Sources.ToBuilder(); + + builder[index] = builder[nextIndex]; + builder[nextIndex] = source; + + _packagesConfig.Value = _packagesConfig.Value with + { + Sources = builder.ToImmutable() + }; + _selectedSource = (source, nextIndex); + } + } + TableNextColumn(); Text(source.Url);