From f6d3794d874585f7948f0415c48a03a79d204030 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Sun, 17 May 2026 02:44:54 +0200 Subject: [PATCH] feat(di): scaffold Microsoft.Extensions.Hosting container (DI-1 + DI-1b) Lays down the DI foundation that v1.5.x will run on top of, without flipping the switch on Plugin.cs yet (that move follows in C3). The new files compile alongside the existing bootstrap but no caller resolves the host, so the live behaviour is byte-identical to v1.4.10. What's new: - PluginHostFactory.cs: HostBuilder.Build(plugin, dependencies) registers ~46 services across Block A (21 Dalamud singletons), Block B (14 HellionChat services plus FileDialogManager), Block C (8 windows), plus Plugin and PluginLifecycle. Service-class bodies are untouched - Plugin-backref ctors go through factory lambdas. - PluginLifecycle.cs: thin IAsyncDisposable wrapping the host's StartAsync/StopAsync, with idempotent dispose and framework-thread Host.Dispose. The Host is assigned via a property setter from Plugin.ctor; HellionChat deviates from Lightless' Func-delegate pattern because the schema gate must run before Build. - Infrastructure/Logging/{DalamudLogger, DalamudLoggingProvider, DalamudLoggingProviderExtensions}.cs: ILogger -> IPluginLog bridge, ported from Lightless without the mod-sync hasModifiedGameFiles flag and without the LightlessConfigService log-level coupling. - Infrastructure/Hosting/InitHostedServices.cs: seven IHostedService adapters around the existing init methods (FontManager.BuildFonts, ThemeRegistry warmup+switch, IpcManager/TypingIpc/ExtraChat eager resolve, MessageManager.FilterAllTabsAsync, AutoTellTabsService .Initialize). Adapter style rather than inlining ": IHostedService" on the service classes per the DI-2a "service bodies untouched" constraint. Plan drift noted for cycle closure: MessageStore stays inside MessageManager.ctor (not a standalone container singleton) because MessageManager.ctor allocates it directly today; promoting it would double-construct the SQLite handle. AutoTellTabsService reads it via MessageManager.Store inside its factory lambda. --- .../Hosting/InitHostedServices.cs | 103 ++++++++++ .../Infrastructure/Logging/DalamudLogger.cs | 62 ++++++ .../Logging/DalamudLoggingProvider.cs | 44 +++++ .../DalamudLoggingProviderExtensions.cs | 23 +++ HellionChat/PluginHostFactory.cs | 184 ++++++++++++++++++ HellionChat/PluginLifecycle.cs | 124 ++++++++++++ 6 files changed, 540 insertions(+) create mode 100644 HellionChat/Infrastructure/Hosting/InitHostedServices.cs create mode 100644 HellionChat/Infrastructure/Logging/DalamudLogger.cs create mode 100644 HellionChat/Infrastructure/Logging/DalamudLoggingProvider.cs create mode 100644 HellionChat/Infrastructure/Logging/DalamudLoggingProviderExtensions.cs create mode 100644 HellionChat/PluginHostFactory.cs create mode 100644 HellionChat/PluginLifecycle.cs diff --git a/HellionChat/Infrastructure/Hosting/InitHostedServices.cs b/HellionChat/Infrastructure/Hosting/InitHostedServices.cs new file mode 100644 index 0000000..d7932e5 --- /dev/null +++ b/HellionChat/Infrastructure/Hosting/InitHostedServices.cs @@ -0,0 +1,103 @@ +using Dalamud.Plugin; +using HellionChat.Ipc; +using HellionChat.Themes; +using Microsoft.Extensions.Hosting; + +namespace HellionChat.Infrastructure.Hosting; + +// Adapter shells around the IHostedService contract so the host can resolve +// the underlying singletons eagerly and trigger their existing init methods +// without modifying the service class bodies (DI-2a constraint). Bodies that +// stay empty here still serve a purpose: the host resolves the service when +// it instantiates the hosted service, which forces the ctor (IPC subscribe +// for IpcManager / TypingIpc / ExtraChat) to run during StartAsync instead of +// lazily on first GetRequiredService. + +internal sealed class FontManagerInitHostedService(FontManager fontManager) : IHostedService +{ + public Task StartAsync(CancellationToken cancellationToken) + { + fontManager.BuildFonts(); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} + +internal sealed class ThemeRegistryInitHostedService(ThemeRegistry registry) : IHostedService +{ + public Task StartAsync(CancellationToken cancellationToken) + { + // Materialise the lazy AllCustom enumerable so the slug lookup hits a + // warm cache; otherwise the first Switch falls through to the built-in + // default when Config.Theme points at a custom slug. + foreach (var _ in registry.AllCustom()) { } + registry.Switch(Plugin.Config.Theme); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} + +// IPC subscribers do their wiring in the ctor today, so StartAsync stays a +// no-op — the value of registering them as hosted services is that the host +// resolves them eagerly during Build, which triggers the ctor work. Moving +// the body into StartAsync is a DI-2b follow-up after the service ctors are +// allowed to change. + +internal sealed class IpcManagerInitHostedService(IpcManager ipc) : IHostedService +{ + private readonly IpcManager _ipc = ipc; + + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} + +internal sealed class TypingIpcInitHostedService(TypingIpc typingIpc) : IHostedService +{ + private readonly TypingIpc _typingIpc = typingIpc; + + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} + +internal sealed class ExtraChatInitHostedService(ExtraChat extraChat) : IHostedService +{ + private readonly ExtraChat _extraChat = extraChat; + + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} + +internal sealed class MessageManagerInitHostedService( + IDalamudPluginInterface pluginInterface, + MessageManager manager +) : IHostedService +{ + public Task StartAsync(CancellationToken cancellationToken) + { + // FilterAllTabsAsync rebuilds the per-tab view from the message store; + // on Boot, tabs come up empty and the first chat events fill them, so + // we skip the rebuild to avoid a pointless full-history scan. + if (pluginInterface.Reason is not PluginLoadReason.Boot) + manager.FilterAllTabsAsync(); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} + +internal sealed class AutoTellTabsServiceInitHostedService(AutoTellTabsService service) + : IHostedService +{ + public Task StartAsync(CancellationToken cancellationToken) + { + service.Initialize(); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/HellionChat/Infrastructure/Logging/DalamudLogger.cs b/HellionChat/Infrastructure/Logging/DalamudLogger.cs new file mode 100644 index 0000000..0d73412 --- /dev/null +++ b/HellionChat/Infrastructure/Logging/DalamudLogger.cs @@ -0,0 +1,62 @@ +using System.Text; +using Dalamud.Plugin.Services; +using Microsoft.Extensions.Logging; + +namespace HellionChat.Infrastructure.Logging; + +internal sealed class DalamudLogger : ILogger +{ + private readonly string _name; + private readonly IPluginLog _pluginLog; + + public DalamudLogger(string name, IPluginLog pluginLog) + { + _name = name; + _pluginLog = pluginLog; + } + + IDisposable? ILogger.BeginScope(TState state) => default!; + + // Filtering happens in Dalamud's /xllog. Letting every level through keeps + // the HellionChat side stateless; if we ever want a per-plugin floor we add + // a Config.LogLevel and tighten this method. + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter + ) + { + if (!IsEnabled(logLevel)) + return; + + if ((int)logLevel <= (int)LogLevel.Information) + { + _pluginLog.Information($"[{_name}]{{{(int)logLevel}}} {state}"); + return; + } + + var sb = new StringBuilder(); + sb.Append($"[{_name}]{{{(int)logLevel}}} {state} {exception?.Message}"); + if (!string.IsNullOrWhiteSpace(exception?.StackTrace)) + sb.AppendLine(exception.StackTrace); + + var inner = exception?.InnerException; + while (inner != null) + { + sb.AppendLine($"InnerException {inner}: {inner.Message}"); + sb.AppendLine(inner.StackTrace); + inner = inner.InnerException; + } + + if (logLevel == LogLevel.Warning) + _pluginLog.Warning(sb.ToString()); + else if (logLevel == LogLevel.Error) + _pluginLog.Error(sb.ToString()); + else + _pluginLog.Fatal(sb.ToString()); + } +} diff --git a/HellionChat/Infrastructure/Logging/DalamudLoggingProvider.cs b/HellionChat/Infrastructure/Logging/DalamudLoggingProvider.cs new file mode 100644 index 0000000..8988b6c --- /dev/null +++ b/HellionChat/Infrastructure/Logging/DalamudLoggingProvider.cs @@ -0,0 +1,44 @@ +using System.Collections.Concurrent; +using Dalamud.Plugin.Services; +using Microsoft.Extensions.Logging; + +namespace HellionChat.Infrastructure.Logging; + +[ProviderAlias("Dalamud")] +public sealed class DalamudLoggingProvider : ILoggerProvider +{ + private readonly ConcurrentDictionary _loggers = new( + StringComparer.OrdinalIgnoreCase + ); + + private readonly IPluginLog _pluginLog; + + public DalamudLoggingProvider(IPluginLog pluginLog) + { + _pluginLog = pluginLog; + } + + public ILogger CreateLogger(string categoryName) + { + // Category-name normalisation mirrors Lightless: take the leaf type + // name, then either ellipsis-trim long ones or left-pad short ones to + // 15 chars so the xllog column stays aligned across services. + var catName = categoryName.Split(".", StringSplitOptions.RemoveEmptyEntries).Last(); + if (catName.Length > 15) + catName = string.Concat( + catName.AsSpan(0, 6), + "...", + catName.AsSpan(catName.Length - 6, 6) + ); + else + catName = catName.PadLeft(15); + + return _loggers.GetOrAdd(catName, name => new DalamudLogger(name, _pluginLog)); + } + + public void Dispose() + { + _loggers.Clear(); + GC.SuppressFinalize(this); + } +} diff --git a/HellionChat/Infrastructure/Logging/DalamudLoggingProviderExtensions.cs b/HellionChat/Infrastructure/Logging/DalamudLoggingProviderExtensions.cs new file mode 100644 index 0000000..86c840d --- /dev/null +++ b/HellionChat/Infrastructure/Logging/DalamudLoggingProviderExtensions.cs @@ -0,0 +1,23 @@ +using Dalamud.Plugin.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; + +namespace HellionChat.Infrastructure.Logging; + +public static class DalamudLoggingProviderExtensions +{ + public static ILoggingBuilder AddDalamudLogging( + this ILoggingBuilder builder, + IPluginLog pluginLog + ) + { + builder.ClearProviders(); + builder.Services.TryAddEnumerable( + ServiceDescriptor.Singleton( + _ => new DalamudLoggingProvider(pluginLog) + ) + ); + return builder; + } +} diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs new file mode 100644 index 0000000..f1cec76 --- /dev/null +++ b/HellionChat/PluginHostFactory.cs @@ -0,0 +1,184 @@ +using Dalamud.Interface.ImGuiFileDialog; +using Dalamud.Plugin; +using Dalamud.Plugin.Services; +using HellionChat.Infrastructure.Hosting; +using HellionChat.Infrastructure.Logging; +using HellionChat.Ipc; +using HellionChat.Themes; +using HellionChat.Ui; +using HellionChat.Util; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace HellionChat; + +// Builds the generic-host DI container that drives v1.5.0+. The factory is +// invoked synchronously from Plugin.ctor (after the schema gate clears) so the +// container exists before PluginLifecycle.LoadAsync runs. See plan §1 for the +// deliberate divergence from Lightless' deferred Func-delegate pattern. +internal static class PluginHostFactory +{ + public static IHost Build(Plugin plugin, PluginHostDependencies dependencies) + { + return new HostBuilder() + .UseContentRoot(dependencies.PluginInterface.ConfigDirectory.FullName) + .ConfigureLogging(logging => + { + logging.ClearProviders(); + logging.AddDalamudLogging(dependencies.PluginLog); + logging.SetMinimumLevel(LogLevel.Trace); + }) + .ConfigureServices(services => ConfigureServices(services, plugin, dependencies)) + .Build(); + } + + private static void ConfigureServices( + IServiceCollection services, + Plugin plugin, + PluginHostDependencies dependencies + ) + { + // ----------------------------------------------------------------- + // Block A — Dalamud-Services (21 [PluginService] singletons, plus the + // dependencies record itself). Registered by-interface so consumer + // ctors can resolve them without touching Plugin statics. + // ----------------------------------------------------------------- + services.AddSingleton(dependencies); + services.AddSingleton(dependencies.PluginInterface); + services.AddSingleton(dependencies.PluginLog); + services.AddSingleton(dependencies.ChatGui); + services.AddSingleton(dependencies.ClientState); + services.AddSingleton(dependencies.CommandManager); + services.AddSingleton(dependencies.Condition); + services.AddSingleton(dependencies.DataManager); + services.AddSingleton(dependencies.Framework); + services.AddSingleton(dependencies.GameGui); + services.AddSingleton(dependencies.KeyState); + services.AddSingleton(dependencies.ObjectTable); + services.AddSingleton(dependencies.PartyList); + services.AddSingleton(dependencies.TargetManager); + services.AddSingleton(dependencies.TextureProvider); + services.AddSingleton(dependencies.GameInteropProvider); + services.AddSingleton(dependencies.GameConfig); + services.AddSingleton(dependencies.Notification); + services.AddSingleton(dependencies.AddonLifecycle); + services.AddSingleton(dependencies.PlayerState); + services.AddSingleton(dependencies.Evaluator); + services.AddSingleton(dependencies.SelfTestRegistry); + + // ----------------------------------------------------------------- + // Self-reference. Plugin owns the [PluginService] static surface and + // is already constructed by Dalamud before this factory runs, so we + // register the existing instance instead of letting the container + // build one. + // ----------------------------------------------------------------- + services.AddSingleton(plugin); + services.AddSingleton(plugin.WindowSystem); + + // PluginLifecycle is a thin orchestrator over IHost; Plugin.ctor pulls + // it via GetRequiredService() immediately after Build. + services.AddSingleton(); + + // ----------------------------------------------------------------- + // Block B — HellionChat singletons (14 + 1 FileDialogManager adapter). + // Service-class bodies stay untouched in v1.5.0 per the DI-2a + // constraint; ctors that need a Plugin backref go through a factory + // lambda that resolves Plugin from the container. + // ----------------------------------------------------------------- + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(sp => new ThemeRegistry( + Path.Combine( + sp.GetRequiredService().ConfigDirectory.FullName, + "themes" + ) + )); + + services.AddSingleton(sp => new GameFunctions.GameFunctions( + sp.GetRequiredService() + )); + services.AddSingleton(sp => new TypingIpc(sp.GetRequiredService())); + + services.AddSingleton(sp => new Integrations.HonorificService( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService() + )); + + services.AddSingleton(sp => new MessageManager(sp.GetRequiredService())); + + // AutoTellTabsService pulls MessageStore through MessageManager.Store + // because MessageStore is still allocated inside MessageManager.ctor + // (DI-2a leaves that body untouched). Promoting MessageStore to its + // own container singleton would double-construct the SQLite handle. + services.AddSingleton(sp => + { + var pluginRef = sp.GetRequiredService(); + var manager = sp.GetRequiredService(); + return new AutoTellTabsService(pluginRef, manager, manager.Store); + }); + + // ----------------------------------------------------------------- + // Block C — Windows (8, each takes Plugin or ChatLogWindow). The + // host never AddWindow()s them; PluginLifecycle does that on the + // framework thread once C3 wires it up (see plan §2 service order). + // ----------------------------------------------------------------- + services.AddSingleton(sp => new ChatLogWindow(sp.GetRequiredService())); + services.AddSingleton(sp => new SettingsWindow(sp.GetRequiredService())); + services.AddSingleton(sp => new DbViewer(sp.GetRequiredService())); + services.AddSingleton(sp => new InputPreview(sp.GetRequiredService())); + services.AddSingleton(sp => new CommandHelpWindow(sp.GetRequiredService())); + services.AddSingleton(sp => new SeStringDebugger(sp.GetRequiredService())); + services.AddSingleton(sp => new DebuggerWindow(sp.GetRequiredService())); + services.AddSingleton(sp => new FirstRunWizard(sp.GetRequiredService())); + + // ----------------------------------------------------------------- + // Hosted-service adapters — IHostedService is the host's only "after + // bootstrap, before user interaction" hook, so we register thin + // wrappers that call the existing init methods (BuildFonts, Switch, + // FilterAllTabsAsync, Initialize) without modifying the service + // bodies. Plan §2 documents why this is adapter-style instead of + // making the services themselves implement IHostedService (Lightless' + // pattern) — DI-2a leaves service classes untouched. + // ----------------------------------------------------------------- + services.AddHostedService(); + services.AddHostedService(); + services.AddHostedService(); + services.AddHostedService(); + services.AddHostedService(); + services.AddHostedService(); + services.AddHostedService(); + } +} + +internal sealed record PluginHostDependencies( + IDalamudPluginInterface PluginInterface, + IPluginLog PluginLog, + IChatGui ChatGui, + IClientState ClientState, + ICommandManager CommandManager, + ICondition Condition, + IDataManager DataManager, + IFramework Framework, + IGameGui GameGui, + IKeyState KeyState, + IObjectTable ObjectTable, + IPartyList PartyList, + ITargetManager TargetManager, + ITextureProvider TextureProvider, + IGameInteropProvider GameInteropProvider, + IGameConfig GameConfig, + INotificationManager Notification, + IAddonLifecycle AddonLifecycle, + IPlayerState PlayerState, + ISeStringEvaluator Evaluator, + ISelfTestRegistry SelfTestRegistry +); diff --git a/HellionChat/PluginLifecycle.cs b/HellionChat/PluginLifecycle.cs new file mode 100644 index 0000000..dd2e151 --- /dev/null +++ b/HellionChat/PluginLifecycle.cs @@ -0,0 +1,124 @@ +using System.Runtime.ExceptionServices; +using Dalamud.Plugin.Services; +using Microsoft.Extensions.Hosting; + +namespace HellionChat; + +// Orchestrates Host.StartAsync / StopAsync + dispose on the framework thread. +// The Host itself is built sync in Plugin.ctor (before the schema gate clears) +// and assigned via the property setter; PluginLifecycle never builds it +// itself, which is why HellionChat skips Lightless' Func-delegate indirection +// (see plan §9 risk "Bewusste Abweichung von Lightless"). +internal sealed class PluginLifecycle : IAsyncDisposable +{ + private readonly IFramework _framework; + + private int _disposeStarted; + private bool _hostStartRequested; + + public PluginLifecycle(IFramework framework) + { + _framework = framework; + } + + // Plugin.ctor fills this immediately after PluginHostFactory.Build and + // before invoking LoadAsync; LoadAsync may NRE-suppress on Host! safely. + public IHost? Host { get; set; } + + public async Task LoadAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + _hostStartRequested = true; + await Host!.StartAsync(cancellationToken).ConfigureAwait(false); + } + catch + { + try + { + await DisposeAsync().ConfigureAwait(false); + } + catch + { + // Swallow secondary dispose failure so the original load throw wins. + } + + throw; + } + } + + public async ValueTask DisposeAsync() + { + // Idempotency guard — Dalamud may fire DisposeAsync twice in a reload race. + if (Interlocked.Exchange(ref _disposeStarted, 1) != 0) + return; + + Exception? failure = null; + + if (_hostStartRequested && Host is not null) + failure = await CaptureFailureAsync(failure, () => Host.StopAsync()) + .ConfigureAwait(false); + + failure = await DisposeHostOnFrameworkThreadAsync(failure).ConfigureAwait(false); + + ThrowIfFailed(failure); + } + + private async Task DisposeHostOnFrameworkThreadAsync(Exception? failure) + { + try + { + await _framework + .RunOnFrameworkThread(() => + { + failure = CaptureFailure(failure, () => Host?.Dispose()); + }) + .ConfigureAwait(false); + } + catch (Exception ex) + { + failure ??= ex; + } + + return failure; + } + + private static Exception? CaptureFailure(Exception? failure, Action action) + { + try + { + action(); + } + catch (Exception ex) + { + failure ??= ex; + } + + return failure; + } + + private static async ValueTask CaptureFailureAsync( + Exception? failure, + Func action + ) + { + try + { + await action().ConfigureAwait(false); + } + catch (Exception ex) + { + failure ??= ex; + } + + return failure; + } + + private static void ThrowIfFailed(Exception? failure) + { + if (failure is not null) + ExceptionDispatchInfo.Capture(failure).Throw(); + } +}