Add plugin error notifications, per-plugin event invocation wrappers

This commit is contained in:
goaaats 2025-05-01 20:47:03 +02:00
parent 1913a4cd2c
commit ddf0a97c83
11 changed files with 358 additions and 85 deletions

View file

@ -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>

View file

@ -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();
}

View file

@ -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);
}
}
}

View file

@ -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();
}

View file

@ -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()
{

View file

@ -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.");

View file

@ -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,

View 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;
}
}

View file

@ -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>

View file

@ -152,7 +152,7 @@ internal static class ServiceManager
#if DEBUG
lock (LoadedServices)
{
ProvideAllServices()
ProvideAllServices();
}
return;

View file

@ -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;