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