mirror of
https://github.com/Stone-Red-Code/DesktopMagic.git
synced 2026-09-04 08:56:15 +02:00
Add hot reload support for plugins using FileSystemWatcher
This commit is contained in:
@@ -35,9 +35,12 @@ public partial class PluginWindow : Window, IPluginWindow
|
|||||||
private Thread? pluginThread;
|
private Thread? pluginThread;
|
||||||
private System.Timers.Timer? updateTimer;
|
private System.Timers.Timer? updateTimer;
|
||||||
private Plugin? pluginClassInstance;
|
private Plugin? pluginClassInstance;
|
||||||
private readonly AssemblyLoadContext assemblyLoadContext;
|
private AssemblyLoadContext assemblyLoadContext;
|
||||||
|
|
||||||
private CancellationTokenSource? pluginCancellationTokenSource;
|
private CancellationTokenSource? pluginCancellationTokenSource;
|
||||||
|
private FileSystemWatcher? pluginFileWatcher;
|
||||||
|
private System.Timers.Timer? reloadDebounceTimer;
|
||||||
|
private bool isReloading = false;
|
||||||
|
|
||||||
public bool IsRunning { get; private set; } = true;
|
public bool IsRunning { get; private set; } = true;
|
||||||
public PluginMetadata PluginMetadata { get; private set; }
|
public PluginMetadata PluginMetadata { get; private set; }
|
||||||
@@ -90,20 +93,13 @@ public partial class PluginWindow : Window, IPluginWindow
|
|||||||
|
|
||||||
PluginFolderPath = pluginFolderPath;
|
PluginFolderPath = pluginFolderPath;
|
||||||
|
|
||||||
assemblyLoadContext = new AssemblyLoadContext(pluginMetadata.Name, isCollectible: true);
|
assemblyLoadContext = CreateAssemblyLoadContext();
|
||||||
assemblyLoadContext.Resolving += (context, assemblyName) =>
|
|
||||||
|
// Initialize hot reload watcher if plugin supports unloading and is external
|
||||||
|
if (pluginMetadata.SupportsUnloading && !string.IsNullOrEmpty(pluginFolderPath))
|
||||||
{
|
{
|
||||||
string assemblyPath = Path.Combine(PluginFolderPath, assemblyName.Name + ".dll");
|
InitializeHotReload();
|
||||||
if (File.Exists(assemblyPath))
|
|
||||||
{
|
|
||||||
return context.LoadFromAssemblyPath(assemblyPath);
|
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
_ = assemblyLoadContext.LoadFromAssemblyName(assemblyName);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public PluginWindow(Plugin pluginClassInstance, PluginMetadata pluginMetadata, PluginSettings settings) : this(pluginMetadata, settings, string.Empty)
|
public PluginWindow(Plugin pluginClassInstance, PluginMetadata pluginMetadata, PluginSettings settings) : this(pluginMetadata, settings, string.Empty)
|
||||||
@@ -111,6 +107,177 @@ public partial class PluginWindow : Window, IPluginWindow
|
|||||||
this.pluginClassInstance = pluginClassInstance;
|
this.pluginClassInstance = pluginClassInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private AssemblyLoadContext CreateAssemblyLoadContext()
|
||||||
|
{
|
||||||
|
AssemblyLoadContext context = new(PluginMetadata.Name, isCollectible: true);
|
||||||
|
context.Resolving += (ctx, assemblyName) =>
|
||||||
|
{
|
||||||
|
string assemblyPath = Path.Combine(PluginFolderPath, assemblyName.Name + ".dll");
|
||||||
|
if (File.Exists(assemblyPath))
|
||||||
|
{
|
||||||
|
return ctx.LoadFromAssemblyPath(assemblyPath);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_ = ctx.LoadFromAssemblyName(assemblyName);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializeHotReload()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
pluginFileWatcher = new FileSystemWatcher(PluginFolderPath)
|
||||||
|
{
|
||||||
|
Filter = "main.dll",
|
||||||
|
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size,
|
||||||
|
EnableRaisingEvents = false
|
||||||
|
};
|
||||||
|
|
||||||
|
pluginFileWatcher.Changed += OnPluginFileChanged;
|
||||||
|
|
||||||
|
reloadDebounceTimer = new System.Timers.Timer(500); // 500ms debounce
|
||||||
|
reloadDebounceTimer.Elapsed += async (s, e) =>
|
||||||
|
{
|
||||||
|
reloadDebounceTimer!.Stop();
|
||||||
|
await ReloadPlugin();
|
||||||
|
};
|
||||||
|
|
||||||
|
App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - Hot reload enabled", source: "Plugin");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
App.Logger.LogWarn($"\"{PluginMetadata.Name}\" - Failed to initialize hot reload: {ex.Message}", source: "Plugin");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPluginFileChanged(object sender, FileSystemEventArgs e)
|
||||||
|
{
|
||||||
|
if (isReloading)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - Plugin file changed, scheduling reload", source: "Plugin");
|
||||||
|
|
||||||
|
// Debounce multiple file change events
|
||||||
|
reloadDebounceTimer?.Stop();
|
||||||
|
reloadDebounceTimer?.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task StopPlugin(bool unloadAssembly = true)
|
||||||
|
{
|
||||||
|
App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - Stopping plugin", source: "Plugin");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
pluginCancellationTokenSource?.Cancel();
|
||||||
|
|
||||||
|
if (pluginClassInstance is AsyncPlugin asyncPluginStop)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await asyncPluginStop.StopAsync(pluginCancellationTokenSource?.Token ?? CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
App.Logger.LogError($"\"{PluginMetadata.Name}\" - Error stopping async plugin: {ex.Message}", source: "Plugin");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pluginClassInstance is not null)
|
||||||
|
{
|
||||||
|
SaveState(pluginClassInstance);
|
||||||
|
pluginClassInstance.Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
App.Logger.LogError($"\"{PluginMetadata.Name}\" - Error stopping plugin: {ex}", source: "Plugin");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unloadAssembly)
|
||||||
|
{
|
||||||
|
assemblyLoadContext.Unload();
|
||||||
|
}
|
||||||
|
|
||||||
|
pluginCancellationTokenSource?.Dispose();
|
||||||
|
pluginCancellationTokenSource = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReloadPlugin()
|
||||||
|
{
|
||||||
|
if (isReloading || !IsRunning)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isReloading = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - Reloading plugin", source: "Plugin");
|
||||||
|
|
||||||
|
// Stop the file watcher during reload
|
||||||
|
if (pluginFileWatcher != null)
|
||||||
|
{
|
||||||
|
pluginFileWatcher.EnableRaisingEvents = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTimer?.Stop();
|
||||||
|
|
||||||
|
await StopPlugin(unloadAssembly: true);
|
||||||
|
|
||||||
|
// Wait for file to be fully written and released
|
||||||
|
await Task.Delay(200);
|
||||||
|
|
||||||
|
// Clear the current instance
|
||||||
|
pluginClassInstance = null;
|
||||||
|
|
||||||
|
// Create a new AssemblyLoadContext since the old one was unloaded
|
||||||
|
assemblyLoadContext = CreateAssemblyLoadContext();
|
||||||
|
|
||||||
|
// Show busy indicator
|
||||||
|
await Dispatcher.InvokeAsync(() => busyMask.IsBusy = true);
|
||||||
|
|
||||||
|
// Reload the plugin
|
||||||
|
await ExecuteSource();
|
||||||
|
|
||||||
|
// Hide busy indicator
|
||||||
|
await Dispatcher.InvokeAsync(() => busyMask.IsBusy = false);
|
||||||
|
|
||||||
|
App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - Plugin reloaded successfully", source: "Plugin");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
App.Logger.LogError($"\"{PluginMetadata.Name}\" - Failed to reload plugin: {ex}", source: "Plugin");
|
||||||
|
|
||||||
|
await Dispatcher.InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox
|
||||||
|
{
|
||||||
|
Title = $"Reload Error \"{PluginMetadata.Name}\"",
|
||||||
|
Content = $"Failed to reload plugin:\n{ex.Message}",
|
||||||
|
CloseButtonText = "Ok"
|
||||||
|
};
|
||||||
|
_ = await messageBox.ShowDialogAsync();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
isReloading = false;
|
||||||
|
|
||||||
|
// Re-enable file watcher
|
||||||
|
if (pluginFileWatcher != null && IsRunning)
|
||||||
|
{
|
||||||
|
pluginFileWatcher.EnableRaisingEvents = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void UpdatePluginWindow()
|
public void UpdatePluginWindow()
|
||||||
{
|
{
|
||||||
UpdateTimer_Elapsed(updateTimer, null);
|
UpdateTimer_Elapsed(updateTimer, null);
|
||||||
@@ -240,6 +407,13 @@ public partial class PluginWindow : Window, IPluginWindow
|
|||||||
PluginLoaded?.Invoke();
|
PluginLoaded?.Invoke();
|
||||||
|
|
||||||
_ = await Dispatcher.InvokeAsync(() => busyMask.IsBusy = false);
|
_ = await Dispatcher.InvokeAsync(() => busyMask.IsBusy = false);
|
||||||
|
|
||||||
|
// Enable hot reload after initial load
|
||||||
|
if (pluginFileWatcher != null)
|
||||||
|
{
|
||||||
|
pluginFileWatcher.EnableRaisingEvents = true;
|
||||||
|
App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - Hot reload monitoring started", source: "Plugin");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ThemeChanged()
|
private void ThemeChanged()
|
||||||
@@ -693,40 +867,23 @@ public partial class PluginWindow : Window, IPluginWindow
|
|||||||
|
|
||||||
private void Window_Closing(object sender, System.ComponentModel.CancelEventArgs e)
|
private void Window_Closing(object sender, System.ComponentModel.CancelEventArgs e)
|
||||||
{
|
{
|
||||||
App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - Stopping plugin", source: "Plugin");
|
|
||||||
IsRunning = false;
|
IsRunning = false;
|
||||||
|
|
||||||
try
|
// Cleanup hot reload resources
|
||||||
|
if (pluginFileWatcher != null)
|
||||||
{
|
{
|
||||||
pluginCancellationTokenSource?.Cancel();
|
pluginFileWatcher.EnableRaisingEvents = false;
|
||||||
|
pluginFileWatcher.Changed -= OnPluginFileChanged;
|
||||||
if (pluginClassInstance is AsyncPlugin asyncPluginStop)
|
pluginFileWatcher.Dispose();
|
||||||
{
|
pluginFileWatcher = null;
|
||||||
try
|
|
||||||
{
|
|
||||||
_ = asyncPluginStop.StopAsync(pluginCancellationTokenSource?.Token ?? CancellationToken.None);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
App.Logger.LogError($"\"{PluginMetadata.Name}\" - {ex}", source: "Plugin");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pluginClassInstance is not null)
|
reloadDebounceTimer?.Stop();
|
||||||
{
|
reloadDebounceTimer?.Dispose();
|
||||||
SaveState(pluginClassInstance);
|
reloadDebounceTimer = null;
|
||||||
pluginClassInstance.Stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
App.Logger.LogError($"\"{PluginMetadata.Name}\" - {ex}", source: "Plugin");
|
|
||||||
}
|
|
||||||
|
|
||||||
assemblyLoadContext.Unload();
|
// Stop plugin using the shared method (no need to await in synchronous event handler)
|
||||||
|
_ = StopPlugin(unloadAssembly: true);
|
||||||
pluginCancellationTokenSource?.Dispose();
|
|
||||||
pluginCancellationTokenSource = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#region Window Events
|
#region Window Events
|
||||||
|
|||||||
Reference in New Issue
Block a user