mirror of
https://github.com/goatcorp/Dalamud.git
synced 2025-12-12 10:17:22 +01:00
Add plugin error notifications, per-plugin event invocation wrappers
This commit is contained in:
parent
1913a4cd2c
commit
ddf0a97c83
11 changed files with 358 additions and 85 deletions
|
|
@ -12,6 +12,12 @@ internal sealed class DevPluginSettings
|
|||
/// </summary>
|
||||
public bool StartOnBoot { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether we should show notifications for errors this plugin
|
||||
/// is creating.
|
||||
/// </summary>
|
||||
public bool NotifyForErrors { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether this plugin should automatically reload on file change.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,47 @@ namespace Dalamud.Console;
|
|||
|
||||
#pragma warning disable Dalamud001
|
||||
|
||||
/// <summary>
|
||||
/// Utility functions for the console manager.
|
||||
/// </summary>
|
||||
internal static partial class ConsoleManagerPluginUtil
|
||||
{
|
||||
private static readonly string[] ReservedNamespaces = ["dalamud", "xl", "plugin"];
|
||||
|
||||
/// <summary>
|
||||
/// Get a sanitized namespace name from a plugin's internal name.
|
||||
/// </summary>
|
||||
/// <param name="pluginInternalName">The plugin's internal name.</param>
|
||||
/// <returns>A sanitized namespace.</returns>
|
||||
public static string GetSanitizedNamespaceName(string pluginInternalName)
|
||||
{
|
||||
// Must be lowercase
|
||||
pluginInternalName = pluginInternalName.ToLowerInvariant();
|
||||
|
||||
// Remove all non-alphabetic characters
|
||||
pluginInternalName = NonAlphaRegex().Replace(pluginInternalName, string.Empty);
|
||||
|
||||
// Remove reserved namespaces from the start or end
|
||||
foreach (var reservedNamespace in ReservedNamespaces)
|
||||
{
|
||||
if (pluginInternalName.StartsWith(reservedNamespace))
|
||||
{
|
||||
pluginInternalName = pluginInternalName[reservedNamespace.Length..];
|
||||
}
|
||||
|
||||
if (pluginInternalName.EndsWith(reservedNamespace))
|
||||
{
|
||||
pluginInternalName = pluginInternalName[..^reservedNamespace.Length];
|
||||
}
|
||||
}
|
||||
|
||||
return pluginInternalName;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"[^a-z]")]
|
||||
private static partial Regex NonAlphaRegex();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plugin-scoped version of the console service.
|
||||
/// </summary>
|
||||
|
|
@ -130,44 +171,3 @@ internal class ConsoleManagerPluginScoped : IConsole, IInternalDisposableService
|
|||
return command;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Utility functions for the console manager.
|
||||
/// </summary>
|
||||
internal static partial class ConsoleManagerPluginUtil
|
||||
{
|
||||
private static readonly string[] ReservedNamespaces = ["dalamud", "xl", "plugin"];
|
||||
|
||||
/// <summary>
|
||||
/// Get a sanitized namespace name from a plugin's internal name.
|
||||
/// </summary>
|
||||
/// <param name="pluginInternalName">The plugin's internal name.</param>
|
||||
/// <returns>A sanitized namespace.</returns>
|
||||
public static string GetSanitizedNamespaceName(string pluginInternalName)
|
||||
{
|
||||
// Must be lowercase
|
||||
pluginInternalName = pluginInternalName.ToLowerInvariant();
|
||||
|
||||
// Remove all non-alphabetic characters
|
||||
pluginInternalName = NonAlphaRegex().Replace(pluginInternalName, string.Empty);
|
||||
|
||||
// Remove reserved namespaces from the start or end
|
||||
foreach (var reservedNamespace in ReservedNamespaces)
|
||||
{
|
||||
if (pluginInternalName.StartsWith(reservedNamespace))
|
||||
{
|
||||
pluginInternalName = pluginInternalName[reservedNamespace.Length..];
|
||||
}
|
||||
|
||||
if (pluginInternalName.EndsWith(reservedNamespace))
|
||||
{
|
||||
pluginInternalName = pluginInternalName[..^reservedNamespace.Length];
|
||||
}
|
||||
}
|
||||
|
||||
return pluginInternalName;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"[^a-z]")]
|
||||
private static partial Regex NonAlphaRegex();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ using Dalamud.Hooking;
|
|||
using Dalamud.IoC;
|
||||
using Dalamud.IoC.Internal;
|
||||
using Dalamud.Logging.Internal;
|
||||
using Dalamud.Plugin.Internal;
|
||||
using Dalamud.Plugin.Services;
|
||||
using Dalamud.Utility;
|
||||
|
||||
|
|
@ -504,14 +505,18 @@ internal sealed class Framework : IInternalDisposableService, IFramework
|
|||
#pragma warning restore SA1015
|
||||
internal class FrameworkPluginScoped : IInternalDisposableService, IFramework
|
||||
{
|
||||
private readonly PluginErrorHandler pluginErrorHandler;
|
||||
|
||||
[ServiceManager.ServiceDependency]
|
||||
private readonly Framework frameworkService = Service<Framework>.Get();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FrameworkPluginScoped"/> class.
|
||||
/// </summary>
|
||||
internal FrameworkPluginScoped()
|
||||
/// <param name="pluginErrorHandler">Error handler instance.</param>
|
||||
internal FrameworkPluginScoped(PluginErrorHandler pluginErrorHandler)
|
||||
{
|
||||
this.pluginErrorHandler = pluginErrorHandler;
|
||||
this.frameworkService.Update += this.OnUpdateForward;
|
||||
}
|
||||
|
||||
|
|
@ -604,7 +609,7 @@ internal class FrameworkPluginScoped : IInternalDisposableService, IFramework
|
|||
}
|
||||
else
|
||||
{
|
||||
this.Update?.Invoke(framework);
|
||||
this.pluginErrorHandler.InvokeAndCatch(this.Update, $"{nameof(IFramework)}::{nameof(IFramework.Update)}", framework);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -308,8 +308,14 @@ internal class DalamudInterface : IInternalDisposableService
|
|||
/// <summary>
|
||||
/// Opens the <see cref="ConsoleWindow"/>.
|
||||
/// </summary>
|
||||
public void OpenLogWindow()
|
||||
/// <param name="textFilter">The filter to set, if not null.</param>
|
||||
public void OpenLogWindow(string? textFilter = "")
|
||||
{
|
||||
if (textFilter != null)
|
||||
{
|
||||
this.consoleWindow.TextFilter = textFilter;
|
||||
}
|
||||
|
||||
this.consoleWindow.IsOpen = true;
|
||||
this.consoleWindow.BringToFront();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -123,6 +123,19 @@ internal class ConsoleWindow : Window, IDisposable
|
|||
/// <summary>Gets the queue where log entries that are not processed yet are stored.</summary>
|
||||
public static ConcurrentQueue<(string Line, LogEvent LogEvent)> NewLogEntries { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the current text filter.
|
||||
/// </summary>
|
||||
public string TextFilter
|
||||
{
|
||||
get => this.textFilter;
|
||||
set
|
||||
{
|
||||
this.textFilter = value;
|
||||
this.RecompileLogFilter();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void OnOpen()
|
||||
{
|
||||
|
|
@ -621,6 +634,12 @@ internal class ConsoleWindow : Window, IDisposable
|
|||
2048,
|
||||
ImGuiInputTextFlags.EnterReturnsTrue | ImGuiInputTextFlags.AutoSelectAll)
|
||||
|| ImGui.IsItemDeactivatedAfterEdit())
|
||||
{
|
||||
this.RecompileLogFilter();
|
||||
}
|
||||
}
|
||||
|
||||
private void RecompileLogFilter()
|
||||
{
|
||||
this.compiledLogFilter = null;
|
||||
this.exceptionLogFilter = null;
|
||||
|
|
@ -638,7 +657,6 @@ internal class ConsoleWindow : Window, IDisposable
|
|||
foreach (var log in this.logText)
|
||||
log.HighlightMatches = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawSettingsPopup()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3569,6 +3569,24 @@ internal class PluginInstallerWindow : Window, IDisposable
|
|||
{
|
||||
ImGui.SetTooltip(Locs.PluginButtonToolTip_AutomaticReloading);
|
||||
}
|
||||
|
||||
// Error Notifications
|
||||
ImGui.PushStyleColor(ImGuiCol.Button, plugin.NotifyForErrors ? greenColor : redColor);
|
||||
ImGui.PushStyleColor(ImGuiCol.ButtonHovered, plugin.NotifyForErrors ? greenColor : redColor);
|
||||
|
||||
ImGui.SameLine();
|
||||
if (ImGuiComponents.IconButton(FontAwesomeIcon.Bolt))
|
||||
{
|
||||
plugin.NotifyForErrors ^= true;
|
||||
configuration.QueueSave();
|
||||
}
|
||||
|
||||
ImGui.PopStyleColor(2);
|
||||
|
||||
if (ImGui.IsItemHovered())
|
||||
{
|
||||
ImGui.SetTooltip(Locs.PluginButtonToolTip_NotifyForErrors);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -4239,6 +4257,8 @@ internal class PluginInstallerWindow : Window, IDisposable
|
|||
|
||||
public static string PluginButtonToolTip_AutomaticReloading => Loc.Localize("InstallerAutomaticReloading", "Automatic reloading");
|
||||
|
||||
public static string PluginButtonToolTip_NotifyForErrors => Loc.Localize("InstallerNotifyForErrors", "Show Dalamud notifications when this plugin is creating errors");
|
||||
|
||||
public static string PluginButtonToolTip_DeletePlugin => Loc.Localize("InstallerDeletePlugin ", "Delete plugin");
|
||||
|
||||
public static string PluginButtonToolTip_DeletePluginRestricted => Loc.Localize("InstallerDeletePluginRestricted", "Cannot delete right now - please restart the game.");
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using Dalamud.IoC;
|
||||
using Dalamud.IoC.Internal;
|
||||
using Dalamud.Plugin.Internal;
|
||||
using Dalamud.Plugin.Internal.Types;
|
||||
using Dalamud.Plugin.Services;
|
||||
|
||||
|
|
@ -20,6 +21,7 @@ namespace Dalamud.Logging;
|
|||
internal class ScopedPluginLogService : IServiceType, IPluginLog
|
||||
{
|
||||
private readonly LocalPlugin localPlugin;
|
||||
private readonly PluginErrorHandler errorHandler;
|
||||
|
||||
private readonly LoggingLevelSwitch levelSwitch;
|
||||
|
||||
|
|
@ -27,9 +29,11 @@ internal class ScopedPluginLogService : IServiceType, IPluginLog
|
|||
/// Initializes a new instance of the <see cref="ScopedPluginLogService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="localPlugin">The plugin that owns this service.</param>
|
||||
internal ScopedPluginLogService(LocalPlugin localPlugin)
|
||||
/// <param name="errorHandler">Error notifier service.</param>
|
||||
internal ScopedPluginLogService(LocalPlugin localPlugin, PluginErrorHandler errorHandler)
|
||||
{
|
||||
this.localPlugin = localPlugin;
|
||||
this.errorHandler = errorHandler;
|
||||
|
||||
this.levelSwitch = new LoggingLevelSwitch(this.GetDefaultLevel());
|
||||
|
||||
|
|
@ -110,6 +114,9 @@ internal class ScopedPluginLogService : IServiceType, IPluginLog
|
|||
/// <inheritdoc />
|
||||
public void Write(LogEventLevel level, Exception? exception, string messageTemplate, params object[] values)
|
||||
{
|
||||
if (level == LogEventLevel.Error)
|
||||
this.errorHandler.NotifyError();
|
||||
|
||||
this.Logger.Write(
|
||||
level,
|
||||
exception: exception,
|
||||
|
|
|
|||
198
Dalamud/Plugin/Internal/PluginErrorHandler.cs
Normal file
198
Dalamud/Plugin/Internal/PluginErrorHandler.cs
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq.Expressions;
|
||||
|
||||
using Dalamud.Interface;
|
||||
using Dalamud.Interface.ImGuiNotification;
|
||||
using Dalamud.Interface.ImGuiNotification.Internal;
|
||||
using Dalamud.Interface.Internal;
|
||||
using Dalamud.Plugin.Internal.Types;
|
||||
|
||||
using ImGuiNET;
|
||||
|
||||
using Serilog;
|
||||
|
||||
namespace Dalamud.Plugin.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Service responsible for notifying the user when a plugin is creating errors.
|
||||
/// </summary>
|
||||
[ServiceManager.ScopedService]
|
||||
internal class PluginErrorHandler : IServiceType
|
||||
{
|
||||
private readonly LocalPlugin plugin;
|
||||
private readonly NotificationManager notificationManager;
|
||||
private readonly DalamudInterface di;
|
||||
|
||||
private readonly Dictionary<Type, Delegate> invokerCache = new();
|
||||
|
||||
private DateTime lastErrorTime = DateTime.MinValue;
|
||||
private IActiveNotification? activeNotification;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PluginErrorHandler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="plugin">The plugin we are notifying for.</param>
|
||||
/// <param name="notificationManager">The notification manager.</param>
|
||||
/// <param name="di">The dalamud interface class.</param>
|
||||
[ServiceManager.ServiceConstructor]
|
||||
public PluginErrorHandler(LocalPlugin plugin, NotificationManager notificationManager, DalamudInterface di)
|
||||
{
|
||||
this.plugin = plugin;
|
||||
this.notificationManager = notificationManager;
|
||||
this.di = di;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invoke the specified delegate and catch any exceptions that occur.
|
||||
/// Writes an error message to the log if an exception occurs and shows
|
||||
/// a notification if the plugin is a dev plugin and the user has enabled error notifications.
|
||||
/// </summary>
|
||||
/// <param name="eventHandler">The delegate to invoke.</param>
|
||||
/// <param name="hint">A hint to show about the origin of the exception if an error occurs.</param>
|
||||
/// <param name="args">Arguments to the event handler.</param>
|
||||
/// <typeparam name="TDelegate">The type of the delegate.</typeparam>
|
||||
/// <returns>Whether invocation was successful/did not throw an exception.</returns>
|
||||
public bool InvokeAndCatch<TDelegate>(
|
||||
TDelegate? eventHandler,
|
||||
string hint,
|
||||
params object[] args)
|
||||
where TDelegate : Delegate
|
||||
{
|
||||
if (eventHandler == null)
|
||||
return true;
|
||||
|
||||
try
|
||||
{
|
||||
var invoker = this.GetInvoker<TDelegate>();
|
||||
invoker(eventHandler, args);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, $"[{this.plugin.InternalName}] Exception in event handler {{EventHandlerName}}", hint);
|
||||
this.NotifyError();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Show a notification, if the plugin is a dev plugin and the user has enabled error notifications.
|
||||
/// This function has a cooldown built-in.
|
||||
/// </summary>
|
||||
public void NotifyError()
|
||||
{
|
||||
if (this.plugin is not LocalDevPlugin devPlugin)
|
||||
return;
|
||||
|
||||
if (!devPlugin.NotifyForErrors)
|
||||
return;
|
||||
|
||||
// If the notification is already active, we don't need to show it again.
|
||||
if (this.activeNotification is { DismissReason: null })
|
||||
return;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
if (now - this.lastErrorTime < TimeSpan.FromMinutes(2))
|
||||
return;
|
||||
|
||||
this.lastErrorTime = now;
|
||||
|
||||
var creatingErrorsText = $"{devPlugin.Name} is creating errors";
|
||||
var notification = new Notification()
|
||||
{
|
||||
Title = creatingErrorsText,
|
||||
Icon = INotificationIcon.From(FontAwesomeIcon.Bolt),
|
||||
Type = NotificationType.Error,
|
||||
InitialDuration = TimeSpan.FromSeconds(15),
|
||||
MinimizedText = creatingErrorsText,
|
||||
Content = $"The plugin '{devPlugin.Name}' is creating errors. Click 'Show console' to learn more.",
|
||||
RespectUiHidden = false,
|
||||
};
|
||||
|
||||
this.activeNotification = this.notificationManager.AddNotification(notification);
|
||||
this.activeNotification.DrawActions += _ =>
|
||||
{
|
||||
if (ImGui.Button("Show console"))
|
||||
{
|
||||
this.di.OpenLogWindow(this.plugin.InternalName);
|
||||
this.activeNotification.DismissNow();
|
||||
}
|
||||
|
||||
if (ImGui.IsItemHovered())
|
||||
{
|
||||
ImGui.SetTooltip("Show the console filtered to this plugin");
|
||||
}
|
||||
|
||||
ImGui.SameLine();
|
||||
|
||||
if (ImGui.Button("Disable notifications"))
|
||||
{
|
||||
devPlugin.NotifyForErrors = false;
|
||||
this.activeNotification.DismissNow();
|
||||
}
|
||||
|
||||
if (ImGui.IsItemHovered())
|
||||
{
|
||||
ImGui.SetTooltip("Disable error notifications for this plugin");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static Action<TDelegate, object[]> CreateInvoker<TDelegate>() where TDelegate : Delegate
|
||||
{
|
||||
var delegateType = typeof(TDelegate);
|
||||
var method = delegateType.GetMethod("Invoke");
|
||||
if (method == null)
|
||||
throw new InvalidOperationException($"Delegate {delegateType} does not have an Invoke method.");
|
||||
|
||||
var parameters = method.GetParameters();
|
||||
|
||||
// Create parameters for the lambda
|
||||
var delegateParam = Expression.Parameter(delegateType, "d");
|
||||
var argsParam = Expression.Parameter(typeof(object[]), "args");
|
||||
|
||||
// Create expressions to convert array elements to parameter types
|
||||
var callArgs = new Expression[parameters.Length];
|
||||
for (int i = 0; i < parameters.Length; i++)
|
||||
{
|
||||
var paramType = parameters[i].ParameterType;
|
||||
var arrayAccess = Expression.ArrayIndex(argsParam, Expression.Constant(i));
|
||||
callArgs[i] = Expression.Convert(arrayAccess, paramType);
|
||||
}
|
||||
|
||||
// Create the delegate invocation expression
|
||||
var callExpr = Expression.Call(delegateParam, method, callArgs);
|
||||
|
||||
// If return type is not void, discard the result
|
||||
Expression bodyExpr;
|
||||
if (method.ReturnType != typeof(void))
|
||||
{
|
||||
// Create a block that executes the call and then returns void
|
||||
bodyExpr = Expression.Block(
|
||||
Expression.Call(delegateParam, method, callArgs),
|
||||
Expression.Empty());
|
||||
}
|
||||
else
|
||||
{
|
||||
bodyExpr = callExpr;
|
||||
}
|
||||
|
||||
// Compile and return the lambda
|
||||
var lambda = Expression.Lambda<Action<TDelegate, object[]>>(
|
||||
bodyExpr, delegateParam, argsParam);
|
||||
return lambda.Compile();
|
||||
}
|
||||
|
||||
private Action<TDelegate, object[]> GetInvoker<TDelegate>() where TDelegate : Delegate
|
||||
{
|
||||
var delegateType = typeof(TDelegate);
|
||||
|
||||
if (!this.invokerCache.TryGetValue(delegateType, out var cachedInvoker))
|
||||
{
|
||||
cachedInvoker = CreateInvoker<TDelegate>();
|
||||
this.invokerCache[delegateType] = cachedInvoker;
|
||||
}
|
||||
|
||||
return (Action<TDelegate, object[]>)cachedInvoker;
|
||||
}
|
||||
}
|
||||
|
|
@ -86,6 +86,16 @@ internal sealed class LocalDevPlugin : LocalPlugin
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether users should be notified when this plugin
|
||||
/// is causing errors.
|
||||
/// </summary>
|
||||
public bool NotifyForErrors
|
||||
{
|
||||
get => this.devSettings.NotifyForErrors;
|
||||
set => this.devSettings.NotifyForErrors = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an ID uniquely identifying this specific instance of a devPlugin.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -152,7 +152,7 @@ internal static class ServiceManager
|
|||
#if DEBUG
|
||||
lock (LoadedServices)
|
||||
{
|
||||
ProvideAllServices()
|
||||
ProvideAllServices();
|
||||
}
|
||||
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ namespace Dalamud;
|
|||
[SuppressMessage("ReSharper", "StaticMemberInGenericType", Justification = "Service container static type")]
|
||||
internal static class Service<T> where T : IServiceType
|
||||
{
|
||||
// TODO: Service<T> should only work with singleton services. Trying to call Service<T>.Get() on a scoped service should
|
||||
// be a compile-time error.
|
||||
|
||||
private static readonly ServiceManager.ServiceAttribute ServiceAttribute;
|
||||
private static TaskCompletionSource<T> instanceTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private static List<Type>? dependencyServices;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue