diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index d4d0a5c..348aad4 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -15,6 +15,8 @@ using HellionChat.Resources; using HellionChat.Ui; using HellionChat.Util; using Microsoft.Data.Sqlite; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; namespace HellionChat; @@ -123,6 +125,12 @@ public sealed class Plugin : IAsyncDalamudPlugin // isolation. Wired immediately after Dalamud injects Log. internal static IPluginLogProxy LogProxy { get; private set; } = null!; + // Container drives the v1.5.0 bootstrap. Both are nullable so DisposeAsync + // stays safe if Phase-1 (Host build) throws before they get assigned - + // Dalamud fires DisposeAsync regardless of how far the ctor got. + private readonly IHost? _host; + private readonly PluginLifecycle? _lifecycle; + // Wrapper cached so TearDown can detach the live instance instead of // re-registering with identical args (v1.4.9 ISSUE-1 cleanup). private CommandWrapper? _hellionSettingsCmd; @@ -212,6 +220,75 @@ public sealed class Plugin : IAsyncDalamudPlugin ImGuiUtil.Initialize(this); DeferredSaveFrames = -1; + + // Custom themes dir + seed run before the container builds so the + // ThemeRegistry factory lambda finds the directory ready and the + // example theme stays in place if the user has not touched it. + var customThemesDir = Path.Combine(Interface.ConfigDirectory.FullName, "themes"); + Directory.CreateDirectory(customThemesDir); + SeedExampleThemeIfEmpty(customThemesDir); + + // Phase-1: build the generic host and pull singletons out into the + // Plugin.X surface so consumers untouched by DI-2a keep working. The + // host stays sync here because the schema gate above must run before + // services allocate; deferring the build to LoadAsync (Lightless' + // pattern) would mean the gate fires after the container is alive. + var dependencies = new PluginHostDependencies( + Interface, + Log, + ChatGui, + ClientState, + CommandManager, + Condition, + DataManager, + Framework, + GameGui, + KeyState, + ObjectTable, + PartyList, + TargetManager, + TextureProvider, + GameInteropProvider, + GameConfig, + Notification, + AddonLifecycle, + PlayerState, + Evaluator, + SelfTestRegistry + ); + + _host = PluginHostFactory.Build(this, dependencies); + _lifecycle = _host.Services.GetRequiredService(); + _lifecycle.Host = _host; + + // Plugin.X static bridge - filled from the container so DI-aware code + // and the ~93 Plugin.X consumer sites read the same instances. + PlatformUtil = _host.Services.GetRequiredService(); + LogProxy = _host.Services.GetRequiredService(); + FileDialogManager = _host.Services.GetRequiredService(); + + // Resolve order matters: block-B services first so the windows can + // read Plugin.MessageManager etc. from their own ctors without NREs. + FontManager = _host.Services.GetRequiredService(); + ThemeRegistry = _host.Services.GetRequiredService(); + Commands = _host.Services.GetRequiredService(); + Functions = _host.Services.GetRequiredService(); + Ipc = _host.Services.GetRequiredService(); + TypingIpc = _host.Services.GetRequiredService(); + ExtraChat = _host.Services.GetRequiredService(); + HonorificService = _host.Services.GetRequiredService(); + StatusBar = _host.Services.GetRequiredService(); + MessageManager = _host.Services.GetRequiredService(); + AutoTellTabsService = _host.Services.GetRequiredService(); + + ChatLogWindow = _host.Services.GetRequiredService(); + SettingsWindow = _host.Services.GetRequiredService(); + DbViewer = _host.Services.GetRequiredService(); + InputPreview = _host.Services.GetRequiredService(); + CommandHelpWindow = _host.Services.GetRequiredService(); + SeStringDebugger = _host.Services.GetRequiredService(); + DebuggerWindow = _host.Services.GetRequiredService(); + FirstRunWizard = _host.Services.GetRequiredService(); } public async Task LoadAsync(CancellationToken cancellationToken) @@ -233,66 +310,17 @@ public sealed class Plugin : IAsyncDalamudPlugin cancellationToken.ThrowIfCancellationRequested(); - // BuildFonts registers handles with Dalamud's FontAtlas; the atlas - // rebuilds async a few frames later (visible "font-pop" on first load). - FontManager = new FontManager(); - FontManager.BuildFonts(); - - // ThemeRegistry must be wired before the first Draw tick. - var customThemesDir = Path.Combine(Interface.ConfigDirectory.FullName, "themes"); - Directory.CreateDirectory(customThemesDir); - SeedExampleThemeIfEmpty(customThemesDir); - ThemeRegistry = new Themes.ThemeRegistry(customThemesDir); - // Warm up the custom-theme cache before the first Switch. - // LoadCustomBySlug is a reverse-lookup over _customCache; on a - // cold cache a Config.Theme that points at a custom slug would - // fall through to the built-in default. AllCustom is a lazy - // enumerable, so iterate it explicitly to materialise the cache. - foreach (var _ in ThemeRegistry.AllCustom()) { } - ThemeRegistry.Switch(Config.Theme); - - cancellationToken.ThrowIfCancellationRequested(); - - // Service allocations — order encodes dependencies. - // HonorificService registers IPC subscribers early to catch - // Ready/Disposing events from the first frame. - FileDialogManager = new FileDialogManager(); - Commands = new Commands(); - Functions = new GameFunctions.GameFunctions(this); - Ipc = new IpcManager(); - TypingIpc = new TypingIpc(this); - ExtraChat = new ExtraChat(); - HonorificService = new Integrations.HonorificService(Interface, Log, Framework); - StatusBar = new Ui.StatusBar(); - MessageManager = new MessageManager(this); - - AutoTellTabsService = new AutoTellTabsService( - this, - MessageManager, - MessageManager.Store - ); - AutoTellTabsService.Initialize(); + // Container drives service init now: Host.StartAsync triggers the + // IHostedService adapters (FontManager.BuildFonts, ThemeRegistry + // cache warmup + Switch, IPC eager-resolve, MessageManager + // FilterAllTabsAsync, AutoTellTabsService.Initialize). Window + // registration with WindowSystem runs on the framework thread + // inside PluginLifecycle.LoadAsync after StartAsync returns. + if (_lifecycle is not null) + await _lifecycle.LoadAsync(cancellationToken).ConfigureAwait(false); SelfTestRegistry.RegisterTestSteps([new SelfTests.ThemeSwitchSelfTestStep(this)]); - ChatLogWindow = new ChatLogWindow(this); - SettingsWindow = new SettingsWindow(this); - DbViewer = new DbViewer(this); - InputPreview = new InputPreview(ChatLogWindow); - CommandHelpWindow = new CommandHelpWindow(ChatLogWindow); - SeStringDebugger = new SeStringDebugger(this); - DebuggerWindow = new DebuggerWindow(this); - FirstRunWizard = new FirstRunWizard(this); - - WindowSystem.AddWindow(ChatLogWindow); - WindowSystem.AddWindow(SettingsWindow); - WindowSystem.AddWindow(DbViewer); - WindowSystem.AddWindow(InputPreview); - WindowSystem.AddWindow(CommandHelpWindow); - WindowSystem.AddWindow(SeStringDebugger); - WindowSystem.AddWindow(DebuggerWindow); - WindowSystem.AddWindow(FirstRunWizard); - if (!Config.FirstRunCompleted) FirstRunWizard.IsOpen = true; @@ -313,8 +341,8 @@ public sealed class Plugin : IAsyncDalamudPlugin if (Config.ShowEmotes) _ = EmoteCache.LoadData(); - if (Interface.Reason is not PluginLoadReason.Boot) - MessageManager.FilterAllTabsAsync(); + // FilterAllTabsAsync now runs from MessageManagerInitHostedService + // during Host.StartAsync (same Reason-not-Boot guard there). // Kick the FTS5 rebuild worker if Migrate4 just added the schema or // a previous run was cut short (InitFtsReadyCache leaves _ftsReady @@ -557,6 +585,16 @@ public sealed class Plugin : IAsyncDalamudPlugin // Static input history would otherwise survive the plugin reload. failure = CaptureFailure(failure, InputHistoryService.Reset); + // Lifecycle stops the host (HostedService.StopAsync) and disposes it + // on the framework thread. Container reaches the same singletons that + // the manual block above already disposed; second Dispose() is a no-op + // for the IDisposable services we own. + if (_lifecycle is not null) + { + failure = await CaptureFailureAsync(failure, () => _lifecycle.DisposeAsync().AsTask()) + .ConfigureAwait(false); + } + if (failure is not null) ExceptionDispatchInfo.Capture(failure).Throw(); } diff --git a/HellionChat/PluginLifecycle.cs b/HellionChat/PluginLifecycle.cs index dd2e151..8497cbf 100644 --- a/HellionChat/PluginLifecycle.cs +++ b/HellionChat/PluginLifecycle.cs @@ -12,13 +12,15 @@ namespace HellionChat; internal sealed class PluginLifecycle : IAsyncDisposable { private readonly IFramework _framework; + private readonly Plugin _plugin; private int _disposeStarted; private bool _hostStartRequested; - public PluginLifecycle(IFramework framework) + public PluginLifecycle(IFramework framework, Plugin plugin) { _framework = framework; + _plugin = plugin; } // Plugin.ctor fills this immediately after PluginHostFactory.Build and @@ -33,6 +35,13 @@ internal sealed class PluginLifecycle : IAsyncDisposable { _hostStartRequested = true; await Host!.StartAsync(cancellationToken).ConfigureAwait(false); + + // WindowSystem.AddWindow mutates an internal List<>; v1.4.9 Stage-2 + // verified the list is non-thread-safe, so we marshal the entire + // registration block to the framework thread. + await _framework + .RunOnFrameworkThread(() => RegisterWindows(_plugin)) + .ConfigureAwait(false); } catch { @@ -49,6 +58,18 @@ internal sealed class PluginLifecycle : IAsyncDisposable } } + private static void RegisterWindows(Plugin plugin) + { + plugin.WindowSystem.AddWindow(plugin.ChatLogWindow); + plugin.WindowSystem.AddWindow(plugin.SettingsWindow); + plugin.WindowSystem.AddWindow(plugin.DbViewer); + plugin.WindowSystem.AddWindow(plugin.InputPreview); + plugin.WindowSystem.AddWindow(plugin.CommandHelpWindow); + plugin.WindowSystem.AddWindow(plugin.SeStringDebugger); + plugin.WindowSystem.AddWindow(plugin.DebuggerWindow); + plugin.WindowSystem.AddWindow(plugin.FirstRunWizard); + } + public async ValueTask DisposeAsync() { // Idempotency guard — Dalamud may fire DisposeAsync twice in a reload race.