From a531973c0dc7fe3e0d78348ddadf9a2e4340d5ce Mon Sep 17 00:00:00 2001 From: JonKazama-Hellion Date: Fri, 8 May 2026 19:23:53 +0200 Subject: [PATCH] Refactor Plugin to IAsyncDalamudPlugin two-phase load --- HellionChat/Plugin.cs | 957 +++++++++++++++++++++++------------------- 1 file changed, 530 insertions(+), 427 deletions(-) diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index f7a4d42..0d05778 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; +using System.Runtime.ExceptionServices; using HellionChat.Ipc; using HellionChat.Resources; using HellionChat.Ui; @@ -17,7 +18,7 @@ using Dalamud.Interface.ImGuiFileDialog; namespace HellionChat; // ReSharper disable once ClassNeverInstantiated.Global -public sealed class Plugin : IDalamudPlugin +public sealed class Plugin : IAsyncDalamudPlugin { public const string PluginName = "Hellion Chat"; @@ -47,27 +48,35 @@ public sealed class Plugin : IDalamudPlugin public static FileDialogManager FileDialogManager { get; private set; } = null!; public readonly WindowSystem WindowSystem = new(PluginName); - public SettingsWindow SettingsWindow { get; } - public ChatLogWindow ChatLogWindow { get; } - public DbViewer DbViewer { get; } - public InputPreview InputPreview { get; } - public CommandHelpWindow CommandHelpWindow { get; } - public SeStringDebugger SeStringDebugger { get; } - public FirstRunWizard FirstRunWizard { get; } - public DebuggerWindow DebuggerWindow { get; } - internal Commands Commands { get; } - internal GameFunctions.GameFunctions Functions { get; } - internal MessageManager MessageManager { get; } - internal AutoTellTabsService AutoTellTabsService { get; } - internal IpcManager Ipc { get; } - internal ExtraChat ExtraChat { get; } - internal TypingIpc TypingIpc { get; } - internal FontManager FontManager { get; } + // v1.4.3: Phase-2 services need private setters now that LoadAsync + // owns their construction. Phase-1-only services (Commands, Functions, + // Ipc, ExtraChat, TypingIpc) keep their setters for symmetry. + public SettingsWindow SettingsWindow { get; private set; } = null!; + public ChatLogWindow ChatLogWindow { get; private set; } = null!; + public DbViewer DbViewer { get; private set; } = null!; + public InputPreview InputPreview { get; private set; } = null!; + public CommandHelpWindow CommandHelpWindow { get; private set; } = null!; + public SeStringDebugger SeStringDebugger { get; private set; } = null!; + public FirstRunWizard FirstRunWizard { get; private set; } = null!; + public DebuggerWindow DebuggerWindow { get; private set; } = null!; + + internal Commands Commands { get; private set; } = null!; + internal GameFunctions.GameFunctions Functions { get; private set; } = null!; + internal MessageManager MessageManager { get; private set; } = null!; + internal AutoTellTabsService AutoTellTabsService { get; private set; } = null!; + internal IpcManager Ipc { get; private set; } = null!; + internal ExtraChat ExtraChat { get; private set; } = null!; + internal TypingIpc TypingIpc { get; private set; } = null!; + internal FontManager FontManager { get; private set; } = null!; internal Themes.ThemeRegistry ThemeRegistry { get; private set; } = null!; internal Ui.StatusBar StatusBar { get; private set; } = null!; internal Integrations.HonorificService HonorificService { get; private set; } = null!; + // (B3) Lightless idempotency guard — Dalamud may fire DisposeAsync twice + // in a reload race; second call short-circuits. + private int _disposeStarted; + internal int DeferredSaveFrames = -1; // Serialises retention sweeps. The 24h auto-sweep on plugin load and @@ -98,362 +107,409 @@ public sealed class Plugin : IDalamudPlugin public Plugin() { + // v1.4.3 Phase 1 — sync ctor: only work that doesn't touch + // Theme/Font/MessageManager/UI-windows lives here. Async Phase 2 + // (LoadAsync) owns those. Hooks subscribe at the END of LoadAsync + // so Dalamud's first Draw tick never sees null services (B1). + // Refuse to start if upstream Chat 2 is loaded — prevents IPC // channel collisions and double-replacement of the in-game chat // window. Throwing here makes Dalamud abort the load cleanly with // our localized message instead of crashing FFXIV mid-frame. ChatTwoConflictDetector.ThrowIfChatTwoIsLoaded(Interface); + GameStarted = Process.GetCurrentProcess().StartTime.ToUniversalTime(); + + // Hellion Chat: take over config + database from upstream ChatTwo + // before Dalamud loads our plugin config. Idempotent: only acts on + // the first start where the legacy paths exist and ours don't. + MigrateFromChatTwoLayout(); + + Config = Interface.GetPluginConfig() as Configuration ?? new Configuration(); + + // Hellion Chat — Auto-Tell-Tabs Defense-in-Depth. SaveConfig + // already strips temp tabs before persistence, but a previous + // crash or external write could have left them in the JSON. + // Drop them on load to guarantee the session-only invariant. + Config.Tabs.RemoveAll(t => t.IsTempTab); + + // Hellion Chat v9 → v10 — wipes the configuration so the new 8-tab + // layout starts from defaults instead of mapping every previous setting + // to its new position. Backup-Failure ist non-fatal, der Wipe läuft + // trotzdem; dem User fehlt dann nur das manuelle Restore-Sicherheitsnetz. + if (Config.Version < 10) + { + var pluginConfigsDir = Interface.ConfigDirectory.Parent?.FullName; + if (pluginConfigsDir is not null) + { + var liveConfigPath = Path.Combine(pluginConfigsDir, $"{Interface.InternalName}.json"); + var backupPath = Path.Combine(pluginConfigsDir, $"{Interface.InternalName}.json.pre-v10-backup"); + + try + { + if (File.Exists(liveConfigPath)) + { + File.Copy(liveConfigPath, backupPath, overwrite: true); + } + } + catch (Exception ex) + { + Log.Warning(ex, "HellionChat: pre-v10 config backup failed"); + } + } + + Config = new Configuration + { + Version = 10, + FirstRunCompleted = true, + }; + SaveConfig(); + + Notification.AddNotification(new Dalamud.Interface.ImGuiNotification.Notification + { + Title = HellionStrings.SettingsRefactor_Migration_Title, + Content = HellionStrings.SettingsRefactor_Migration_Content, + Type = Dalamud.Interface.ImGuiNotification.NotificationType.Info, + InitialDuration = TimeSpan.FromSeconds(25), + }); + } + + // Hellion Chat v10 → v11 — adds the global Configuration.PopOutInputEnabled + // master switch and SeenPopOutInputHint flag for the v0.6.0 pop-out + // input feature. Lightweight migration: defaults both fields, + // no user-facing notification because the change is opt-in only. + if (Config.Version < 11) + { + Config.PopOutInputEnabled = false; + Config.SeenPopOutInputHint = false; + Config.Version = 11; + SaveConfig(); + Log.Information( + "Migrated config v10 → v11: PopOutInputEnabled added (global, default off), " + + "SeenPopOutInputHint added (default false)"); + } + + // Hellion Chat v11 → v12 — flips Configuration.PopOutInputEnabled from + // the v0.6.0 opt-in default (false) to opt-out (true) per v0.6.1 UX + // polish. Hard-flip is a deliberate design call (see Spec section 5.7); + // users are notified via the v0.6.1 hint banner (SeenPopOutHeaderHint + // reset). Re-toggle after migration is preserved because this block + // only fires for Version < 12. + if (Config.Version < 12) + { + Config.PopOutInputEnabled = true; + Config.SeenPopOutHeaderHint = false; + Config.Version = 12; + SaveConfig(); + Log.Information( + "Migrated config v11 → v12: PopOutInputEnabled hard-flipped to true (v0.6.1 default), " + + "SeenPopOutHeaderHint reset to false (v0.6.1 banner re-armed)"); + } + + // Hellion Chat v12 → v13 — hard-resets the tab layout to the + // sharpened v1.0.0 defaults (5 thematic tabs, see TabsUtil and + // the default-fill block below). Existing tab state is wiped + // because per-channel mapping from the old General preset to + // the new General/System split would be ambiguous and would + // produce subtly wrong results for users who tweaked the old + // layout. A timestamped backup of the live config is written + // alongside it as a manual restore safety net. The wipe scope + // is intentionally narrow: only Config.Tabs is reset; Privacy, + // Retention, Theme and every other knob keeps its current value. + if (Config.Version < 13) + { + var pluginConfigsDir = Interface.ConfigDirectory.Parent?.FullName; + if (pluginConfigsDir is not null) + { + var liveConfigPath = Path.Combine(pluginConfigsDir, $"{Interface.InternalName}.json"); + var backupPath = Path.Combine(pluginConfigsDir, $"{Interface.InternalName}.json.pre-v13-backup"); + + try + { + if (File.Exists(liveConfigPath)) + File.Copy(liveConfigPath, backupPath, overwrite: true); + } + catch (Exception ex) + { + Log.Warning(ex, "HellionChat: pre-v13 config backup failed"); + } + } + + Config.Tabs.Clear(); + Config.Version = 13; + SaveConfig(); + + Log.Information( + "Migrated config v12 → v13: tab layout hard-reset to v1.0.0 defaults; " + + "pre-v13 config backup written next to the live file. " + + "Default tabs will be populated by the Tabs.Count == 0 block."); + + Notification.AddNotification(new Dalamud.Interface.ImGuiNotification.Notification + { + Title = HellionStrings.SettingsRefactor_Migration_Title, + Content = HellionStrings.SettingsRefactor_Migration_Content, + Type = Dalamud.Interface.ImGuiNotification.NotificationType.Info, + InitialDuration = TimeSpan.FromSeconds(25), + }); + } + + // Hellion Chat v13 → v14 — theme-engine migration. Alle User landen + // auf "hellion-arctic" als neues Default-Theme; die alte + // HellionThemeEnabled-Flag wird deprecated und nur noch ein Release + // als Safety-Net im JSON behalten. Window-Opacity wandert von + // HellionThemeWindowOpacity in das neue WindowOpacity-Feld. + // + // v1.4.0 (F5.4): Pre-v13-Backup wird gelesen, HellionThemeWindowOpacity + // ins neue Feld gezogen. Override nur wenn WindowOpacity noch beim + // Default sitzt — sonst hat der User in der Zwischenzeit (z.B. via + // WindowAlpha → WindowOpacity in v15→v16) explizit etwas gesetzt. + if (Config.Version < 14) + { + Config.Theme = "hellion-arctic"; + + var oldThemeOpacity = TryReadPreV13ThemeOpacity(); + if (oldThemeOpacity is { } legacy + && Math.Abs(Config.WindowOpacity - 0.85f) < 0.001f) + { + Config.WindowOpacity = Math.Clamp(legacy, 0.5f, 1.0f); + Log.Information( + $"Migrated pre-v13 HellionThemeWindowOpacity {legacy} to WindowOpacity {Config.WindowOpacity}"); + } + + Config.ReduceMotion = false; + Config.UseCompactDensity = false; + Config.Version = 14; + SaveConfig(); + Log.Information( + "Migrated config v13 → v14: theme engine introduced, all users land on hellion-arctic; " + + "pick chat2-classic in Settings → Themes for the upstream look"); + } + + if (Config.Version < 15) + { + // v1.2.0 — keine Datenmigration nötig. Removal der deprecated + // Theme-Felder ist reine Schema-Bereinigung (System.Text.Json + // ignoriert unbekannte Felder im JSON, daher kein Crash bei + // Configs die noch HellionThemeEnabled/HellionThemeWindowOpacity + // serialisiert haben — die Werte verfallen einfach). + Config.Version = 15; + SaveConfig(); + Log.Information( + "Migrated config v14 → v15: legacy theme fields removed " + + "(HellionThemeEnabled, HellionThemeWindowOpacity)"); + } + + // Hellion Chat v15 → v16 — Settings Cleanup. Re-Sortierung der + // Tabs auf der UI-Seite (datenneutral). 4 tote Felder verfallen + // beim System.Text.Json-Deserialize (OverrideStyle, ChosenStyle, + // WindowAlpha, ShowThemeQuickPicker — sind alle nicht mehr im + // Configuration-Schema definiert). WindowAlpha wird zuvor auf + // WindowOpacity gemappt damit User die ihn gesetzt hatten ihre + // Transparenz-Einstellung behalten. + if (Config.Version < 16) + { + var pluginConfigsDir = Interface.ConfigDirectory.Parent?.FullName; + var liveConfigPath = pluginConfigsDir is not null + ? Path.Combine(pluginConfigsDir, $"{Interface.InternalName}.json") + : null; + + // Backup-Datei neben der live Config — Pattern aus v13 Branch. + if (pluginConfigsDir is not null && liveConfigPath is not null) + { + var backupPath = Path.Combine(pluginConfigsDir, $"{Interface.InternalName}.json.pre-v16-backup"); + try + { + if (File.Exists(liveConfigPath)) + File.Copy(liveConfigPath, backupPath, overwrite: true); + } + catch (Exception ex) + { + Log.Warning(ex, "HellionChat: pre-v16 config backup failed"); + } + } + + // Pre-v16 Felder einmalig roh aus dem JSON lesen, da sie nicht + // mehr im Configuration-Schema sind (und damit aus Config nicht + // mehr abrufbar). WindowAlpha → WindowOpacity Mapping nur wenn + // User WindowOpacity noch nicht selbst angefasst hat (Default + // 0.85), sonst gewinnt der User-Wert. + float oldWindowAlpha = 100f; + bool oldOverrideStyle = false; + if (liveConfigPath is not null) + { + try + { + if (File.Exists(liveConfigPath)) + { + var rawJson = File.ReadAllText(liveConfigPath); + using var doc = System.Text.Json.JsonDocument.Parse(rawJson); + if (doc.RootElement.TryGetProperty("WindowAlpha", out var alphaProp) + && alphaProp.ValueKind == System.Text.Json.JsonValueKind.Number) + { + oldWindowAlpha = alphaProp.GetSingle(); + } + if (doc.RootElement.TryGetProperty("OverrideStyle", out var ovProp) + && ovProp.ValueKind is System.Text.Json.JsonValueKind.True) + { + oldOverrideStyle = true; + } + } + } + catch (Exception ex) + { + Log.Warning(ex, "HellionChat: pre-v16 legacy-field lookup failed, defaults assumed"); + } + } + + // TEST-MIRROR: Hellion Build test/_Helpers/MigrationLogic.cs (local-only repo) + // If you change the formula here, change the mirror — tests assert the contract. + if (oldWindowAlpha != 100f + && Math.Abs(Config.WindowOpacity - 0.85f) < 0.001f) + { + Config.WindowOpacity = Math.Clamp(oldWindowAlpha / 100f, 0.5f, 1.0f); + Log.Information( + $"Migrated WindowAlpha {oldWindowAlpha} to WindowOpacity {Config.WindowOpacity}"); + } + else if (oldWindowAlpha != 100f) + { + Log.Information( + $"Skipped WindowAlpha→WindowOpacity migration: WindowOpacity already user-set " + + $"({Config.WindowOpacity}), legacy WindowAlpha value {oldWindowAlpha} dropped."); + } + + if (oldOverrideStyle) + { + Notification.AddNotification(new Dalamud.Interface.ImGuiNotification.Notification + { + Title = "Hellion Chat 1.2.1", + Content = HellionStrings.Migration_v16_OverrideStyle_Toast, + Type = Dalamud.Interface.ImGuiNotification.NotificationType.Info, + InitialDuration = TimeSpan.FromSeconds(25), + }); + } + + // v1.2.1 Default-Bumps für UX-Verbesserungen. Pattern: nur + // migrieren wenn der User noch auf dem alten Default ist. + // Bei bool-Werten ist die Erkennung pragmatisch — wer den + // alten Default aktiv ausgeschaltet hatte, erlebt das als + // Regression und stellt es einmal in den Settings zurück. + // Der Trade-Off ist akzeptabel weil die alten Defaults in + // v1.2.0 erst neu eingeführt wurden und kaum jemand aktiv + // umgeschaltet hat. + if (!Config.UseCompactDensity) + { + Config.UseCompactDensity = true; + Log.Information("v16 default-bump: UseCompactDensity false → true"); + } + if (!Config.HideInNewGamePlusMenu) + { + Config.HideInNewGamePlusMenu = true; + Log.Information("v16 default-bump: HideInNewGamePlusMenu false → true"); + } + if (!Config.HideSameTimestamps) + { + Config.HideSameTimestamps = true; + Log.Information("v16 default-bump: HideSameTimestamps false → true"); + } + if (Config.MaxLinesToRender == 5000) + { + Config.MaxLinesToRender = 2500; + Log.Information("v16 default-bump: MaxLinesToRender 5000 → 2500"); + } + if (Config.ChatColours.Count == 0) + { + foreach (var (channel, colour) in Resources.ChatColourPresets.All["Hellion"].Colours) + Config.ChatColours[channel] = colour; + Log.Information("v16 default-bump: ChatColours empty → Hellion brand preset"); + } + + Config.Version = 16; + SaveConfig(); + Log.Information( + "Migrated config v15 → v16: settings cleanup, " + + "OverrideStyle/ChosenStyle/WindowAlpha/ShowThemeQuickPicker dropped from schema"); + } + + // Hellion v1.0.0 default tab layout. Five thematically separated + // tabs: General catches the immediate-surroundings public chat + // (Say/Yell/Shout) only; System absorbs the rest of the technical + // and gameplay-event noise; FreeCompany, Group and Linkshell each + // own their respective channel set. Tells are not in a static + // tab anymore — Auto-Tell-Tabs spawns dedicated per-conversation + // tabs on demand. Novice-Network gets no preset tab; users who + // want it can add HellionBeginner from Settings → Tabs. + if (Config.Tabs.Count == 0) + { + Config.Tabs.Add(TabsUtil.VanillaGeneral); + Config.Tabs.Add(TabsUtil.HellionSystem); + Config.Tabs.Add(TabsUtil.HellionFreeCompany); + Config.Tabs.Add(TabsUtil.HellionParty); + Config.Tabs.Add(TabsUtil.HellionLinkshell); + } + + LanguageChanged(Interface.UiLanguage); + ImGuiUtil.Initialize(this); + + FileDialogManager = new FileDialogManager(); + + // Phase-1 services: pure allocation, no Theme/Font/UI touch. + Commands = new Commands(); + Functions = new GameFunctions.GameFunctions(this); + Ipc = new IpcManager(); + TypingIpc = new TypingIpc(this); + ExtraChat = new ExtraChat(); + + // Plugin integrations register their IPC subscribers up-front so + // Ready/Disposing events from the target plugins are caught from + // the very first frame, even if the user's Honorific reloads + // mid-session. See HellionChat/Integrations/HonorificService.cs. + HonorificService = new Integrations.HonorificService(Interface, Log, Framework); + + StatusBar = new Ui.StatusBar(); + + Interface.UiBuilder.DisableCutsceneUiHide = true; + Interface.UiBuilder.DisableGposeUiHide = true; + + // Hellion Chat — daily retention sweep, off-thread so it never + // blocks plugin load. Skips itself when disabled or already ran + // within the past 24 hours. Pure fire-and-forget on a background + // thread, so it doesn't depend on Phase-2 services being live. + RunRetentionSweepIfDue(); + + if (Config.ShowEmotes) + _ = EmoteCache.LoadData(); // Fire-and-forget intentional, exceptions are caught inside + } + + public async Task LoadAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + try { - GameStarted = Process.GetCurrentProcess().StartTime.ToUniversalTime(); - - // Hellion Chat: take over config + database from upstream ChatTwo - // before Dalamud loads our plugin config. Idempotent: only acts on - // the first start where the legacy paths exist and ours don't. - MigrateFromChatTwoLayout(); - - Config = Interface.GetPluginConfig() as Configuration ?? new Configuration(); - - // Hellion Chat — Auto-Tell-Tabs Defense-in-Depth. SaveConfig - // already strips temp tabs before persistence, but a previous - // crash or external write could have left them in the JSON. - // Drop them on load to guarantee the session-only invariant. - Config.Tabs.RemoveAll(t => t.IsTempTab); - - // Hellion Chat v9 → v10 — wipes the configuration so the new 8-tab - // layout starts from defaults instead of mapping every previous setting - // to its new position. Backup-Failure ist non-fatal, der Wipe läuft - // trotzdem; dem User fehlt dann nur das manuelle Restore-Sicherheitsnetz. - if (Config.Version < 10) + // Group A: Font + Theme parallel (Q1=A). Both are CPU-bound, + // independent, and dominate the load-time profile. Everything + // else stays sequential to keep ordering simple. + // Q3=B transition: BuildFonts() is sync today; Task 5 converts + // FontManager itself to BuildFontsAsync. + var fontTask = Task.Run(() => { - var pluginConfigsDir = Interface.ConfigDirectory.Parent?.FullName; - if (pluginConfigsDir is not null) - { - var liveConfigPath = Path.Combine(pluginConfigsDir, $"{Interface.InternalName}.json"); - var backupPath = Path.Combine(pluginConfigsDir, $"{Interface.InternalName}.json.pre-v10-backup"); + FontManager = new FontManager(); + FontManager.BuildFonts(); + }, cancellationToken); - try - { - if (File.Exists(liveConfigPath)) - { - File.Copy(liveConfigPath, backupPath, overwrite: true); - } - } - catch (Exception ex) - { - Log.Warning(ex, "HellionChat: pre-v10 config backup failed"); - } - } - - Config = new Configuration - { - Version = 10, - FirstRunCompleted = true, - }; - SaveConfig(); - - Notification.AddNotification(new Dalamud.Interface.ImGuiNotification.Notification - { - Title = HellionStrings.SettingsRefactor_Migration_Title, - Content = HellionStrings.SettingsRefactor_Migration_Content, - Type = Dalamud.Interface.ImGuiNotification.NotificationType.Info, - InitialDuration = TimeSpan.FromSeconds(25), - }); - } - - // Hellion Chat v10 → v11 — adds the global Configuration.PopOutInputEnabled - // master switch and SeenPopOutInputHint flag for the v0.6.0 pop-out - // input feature. Lightweight migration: defaults both fields, - // no user-facing notification because the change is opt-in only. - if (Config.Version < 11) + var themeTask = Task.Run(() => { - Config.PopOutInputEnabled = false; - Config.SeenPopOutInputHint = false; - Config.Version = 11; - SaveConfig(); - Log.Information( - "Migrated config v10 → v11: PopOutInputEnabled added (global, default off), " + - "SeenPopOutInputHint added (default false)"); - } + // v1.1.0 — Theme-Engine init. Custom-Themes liegen in + // pluginConfigs/HellionChat/themes/, lazy geladen beim ersten Get. + var customThemesDir = Path.Combine(Interface.ConfigDirectory.FullName, "themes"); + Directory.CreateDirectory(customThemesDir); + SeedExampleThemeIfEmpty(customThemesDir); + ThemeRegistry = new Themes.ThemeRegistry(customThemesDir); + ThemeRegistry.Switch(Config.Theme); + }, cancellationToken); - // Hellion Chat v11 → v12 — flips Configuration.PopOutInputEnabled from - // the v0.6.0 opt-in default (false) to opt-out (true) per v0.6.1 UX - // polish. Hard-flip is a deliberate design call (see Spec section 5.7); - // users are notified via the v0.6.1 hint banner (SeenPopOutHeaderHint - // reset). Re-toggle after migration is preserved because this block - // only fires for Version < 12. - if (Config.Version < 12) - { - Config.PopOutInputEnabled = true; - Config.SeenPopOutHeaderHint = false; - Config.Version = 12; - SaveConfig(); - Log.Information( - "Migrated config v11 → v12: PopOutInputEnabled hard-flipped to true (v0.6.1 default), " + - "SeenPopOutHeaderHint reset to false (v0.6.1 banner re-armed)"); - } - - // Hellion Chat v12 → v13 — hard-resets the tab layout to the - // sharpened v1.0.0 defaults (5 thematic tabs, see TabsUtil and - // the default-fill block below). Existing tab state is wiped - // because per-channel mapping from the old General preset to - // the new General/System split would be ambiguous and would - // produce subtly wrong results for users who tweaked the old - // layout. A timestamped backup of the live config is written - // alongside it as a manual restore safety net. The wipe scope - // is intentionally narrow: only Config.Tabs is reset; Privacy, - // Retention, Theme and every other knob keeps its current value. - if (Config.Version < 13) - { - var pluginConfigsDir = Interface.ConfigDirectory.Parent?.FullName; - if (pluginConfigsDir is not null) - { - var liveConfigPath = Path.Combine(pluginConfigsDir, $"{Interface.InternalName}.json"); - var backupPath = Path.Combine(pluginConfigsDir, $"{Interface.InternalName}.json.pre-v13-backup"); - - try - { - if (File.Exists(liveConfigPath)) - File.Copy(liveConfigPath, backupPath, overwrite: true); - } - catch (Exception ex) - { - Log.Warning(ex, "HellionChat: pre-v13 config backup failed"); - } - } - - Config.Tabs.Clear(); - Config.Version = 13; - SaveConfig(); - - Log.Information( - "Migrated config v12 → v13: tab layout hard-reset to v1.0.0 defaults; " + - "pre-v13 config backup written next to the live file. " + - "Default tabs will be populated by the Tabs.Count == 0 block."); - - Notification.AddNotification(new Dalamud.Interface.ImGuiNotification.Notification - { - Title = HellionStrings.SettingsRefactor_Migration_Title, - Content = HellionStrings.SettingsRefactor_Migration_Content, - Type = Dalamud.Interface.ImGuiNotification.NotificationType.Info, - InitialDuration = TimeSpan.FromSeconds(25), - }); - } - - // Hellion Chat v13 → v14 — theme-engine migration. Alle User landen - // auf "hellion-arctic" als neues Default-Theme; die alte - // HellionThemeEnabled-Flag wird deprecated und nur noch ein Release - // als Safety-Net im JSON behalten. Window-Opacity wandert von - // HellionThemeWindowOpacity in das neue WindowOpacity-Feld. - // - // v1.4.0 (F5.4): Pre-v13-Backup wird gelesen, HellionThemeWindowOpacity - // ins neue Feld gezogen. Override nur wenn WindowOpacity noch beim - // Default sitzt — sonst hat der User in der Zwischenzeit (z.B. via - // WindowAlpha → WindowOpacity in v15→v16) explizit etwas gesetzt. - if (Config.Version < 14) - { - Config.Theme = "hellion-arctic"; - - var oldThemeOpacity = TryReadPreV13ThemeOpacity(); - if (oldThemeOpacity is { } legacy - && Math.Abs(Config.WindowOpacity - 0.85f) < 0.001f) - { - Config.WindowOpacity = Math.Clamp(legacy, 0.5f, 1.0f); - Log.Information( - $"Migrated pre-v13 HellionThemeWindowOpacity {legacy} to WindowOpacity {Config.WindowOpacity}"); - } - - Config.ReduceMotion = false; - Config.UseCompactDensity = false; - Config.Version = 14; - SaveConfig(); - Log.Information( - "Migrated config v13 → v14: theme engine introduced, all users land on hellion-arctic; " + - "pick chat2-classic in Settings → Themes for the upstream look"); - } - - if (Config.Version < 15) - { - // v1.2.0 — keine Datenmigration nötig. Removal der deprecated - // Theme-Felder ist reine Schema-Bereinigung (System.Text.Json - // ignoriert unbekannte Felder im JSON, daher kein Crash bei - // Configs die noch HellionThemeEnabled/HellionThemeWindowOpacity - // serialisiert haben — die Werte verfallen einfach). - Config.Version = 15; - SaveConfig(); - Log.Information( - "Migrated config v14 → v15: legacy theme fields removed " + - "(HellionThemeEnabled, HellionThemeWindowOpacity)"); - } - - // Hellion Chat v15 → v16 — Settings Cleanup. Re-Sortierung der - // Tabs auf der UI-Seite (datenneutral). 4 tote Felder verfallen - // beim System.Text.Json-Deserialize (OverrideStyle, ChosenStyle, - // WindowAlpha, ShowThemeQuickPicker — sind alle nicht mehr im - // Configuration-Schema definiert). WindowAlpha wird zuvor auf - // WindowOpacity gemappt damit User die ihn gesetzt hatten ihre - // Transparenz-Einstellung behalten. - if (Config.Version < 16) - { - var pluginConfigsDir = Interface.ConfigDirectory.Parent?.FullName; - var liveConfigPath = pluginConfigsDir is not null - ? Path.Combine(pluginConfigsDir, $"{Interface.InternalName}.json") - : null; - - // Backup-Datei neben der live Config — Pattern aus v13 Branch. - if (pluginConfigsDir is not null && liveConfigPath is not null) - { - var backupPath = Path.Combine(pluginConfigsDir, $"{Interface.InternalName}.json.pre-v16-backup"); - try - { - if (File.Exists(liveConfigPath)) - File.Copy(liveConfigPath, backupPath, overwrite: true); - } - catch (Exception ex) - { - Log.Warning(ex, "HellionChat: pre-v16 config backup failed"); - } - } - - // Pre-v16 Felder einmalig roh aus dem JSON lesen, da sie nicht - // mehr im Configuration-Schema sind (und damit aus Config nicht - // mehr abrufbar). WindowAlpha → WindowOpacity Mapping nur wenn - // User WindowOpacity noch nicht selbst angefasst hat (Default - // 0.85), sonst gewinnt der User-Wert. - float oldWindowAlpha = 100f; - bool oldOverrideStyle = false; - if (liveConfigPath is not null) - { - try - { - if (File.Exists(liveConfigPath)) - { - var rawJson = File.ReadAllText(liveConfigPath); - using var doc = System.Text.Json.JsonDocument.Parse(rawJson); - if (doc.RootElement.TryGetProperty("WindowAlpha", out var alphaProp) - && alphaProp.ValueKind == System.Text.Json.JsonValueKind.Number) - { - oldWindowAlpha = alphaProp.GetSingle(); - } - if (doc.RootElement.TryGetProperty("OverrideStyle", out var ovProp) - && ovProp.ValueKind is System.Text.Json.JsonValueKind.True) - { - oldOverrideStyle = true; - } - } - } - catch (Exception ex) - { - Log.Warning(ex, "HellionChat: pre-v16 legacy-field lookup failed, defaults assumed"); - } - } - - // TEST-MIRROR: Hellion Build test/_Helpers/MigrationLogic.cs (local-only repo) - // If you change the formula here, change the mirror — tests assert the contract. - if (oldWindowAlpha != 100f - && Math.Abs(Config.WindowOpacity - 0.85f) < 0.001f) - { - Config.WindowOpacity = Math.Clamp(oldWindowAlpha / 100f, 0.5f, 1.0f); - Log.Information( - $"Migrated WindowAlpha {oldWindowAlpha} to WindowOpacity {Config.WindowOpacity}"); - } - else if (oldWindowAlpha != 100f) - { - Log.Information( - $"Skipped WindowAlpha→WindowOpacity migration: WindowOpacity already user-set " + - $"({Config.WindowOpacity}), legacy WindowAlpha value {oldWindowAlpha} dropped."); - } - - if (oldOverrideStyle) - { - Notification.AddNotification(new Dalamud.Interface.ImGuiNotification.Notification - { - Title = "Hellion Chat 1.2.1", - Content = HellionStrings.Migration_v16_OverrideStyle_Toast, - Type = Dalamud.Interface.ImGuiNotification.NotificationType.Info, - InitialDuration = TimeSpan.FromSeconds(25), - }); - } - - // v1.2.1 Default-Bumps für UX-Verbesserungen. Pattern: nur - // migrieren wenn der User noch auf dem alten Default ist. - // Bei bool-Werten ist die Erkennung pragmatisch — wer den - // alten Default aktiv ausgeschaltet hatte, erlebt das als - // Regression und stellt es einmal in den Settings zurück. - // Der Trade-Off ist akzeptabel weil die alten Defaults in - // v1.2.0 erst neu eingeführt wurden und kaum jemand aktiv - // umgeschaltet hat. - if (!Config.UseCompactDensity) - { - Config.UseCompactDensity = true; - Log.Information("v16 default-bump: UseCompactDensity false → true"); - } - if (!Config.HideInNewGamePlusMenu) - { - Config.HideInNewGamePlusMenu = true; - Log.Information("v16 default-bump: HideInNewGamePlusMenu false → true"); - } - if (!Config.HideSameTimestamps) - { - Config.HideSameTimestamps = true; - Log.Information("v16 default-bump: HideSameTimestamps false → true"); - } - if (Config.MaxLinesToRender == 5000) - { - Config.MaxLinesToRender = 2500; - Log.Information("v16 default-bump: MaxLinesToRender 5000 → 2500"); - } - if (Config.ChatColours.Count == 0) - { - foreach (var (channel, colour) in Resources.ChatColourPresets.All["Hellion"].Colours) - Config.ChatColours[channel] = colour; - Log.Information("v16 default-bump: ChatColours empty → Hellion brand preset"); - } - - Config.Version = 16; - SaveConfig(); - Log.Information( - "Migrated config v15 → v16: settings cleanup, " + - "OverrideStyle/ChosenStyle/WindowAlpha/ShowThemeQuickPicker dropped from schema"); - } - - // Hellion v1.0.0 default tab layout. Five thematically separated - // tabs: General catches the immediate-surroundings public chat - // (Say/Yell/Shout) only; System absorbs the rest of the technical - // and gameplay-event noise; FreeCompany, Group and Linkshell each - // own their respective channel set. Tells are not in a static - // tab anymore — Auto-Tell-Tabs spawns dedicated per-conversation - // tabs on demand. Novice-Network gets no preset tab; users who - // want it can add HellionBeginner from Settings → Tabs. - if (Config.Tabs.Count == 0) - { - Config.Tabs.Add(TabsUtil.VanillaGeneral); - Config.Tabs.Add(TabsUtil.HellionSystem); - Config.Tabs.Add(TabsUtil.HellionFreeCompany); - Config.Tabs.Add(TabsUtil.HellionParty); - Config.Tabs.Add(TabsUtil.HellionLinkshell); - } - - LanguageChanged(Interface.UiLanguage); - ImGuiUtil.Initialize(this); - - FileDialogManager = new FileDialogManager(); - - Commands = new Commands(); - Functions = new GameFunctions.GameFunctions(this); - Ipc = new IpcManager(); - TypingIpc = new TypingIpc(this); - ExtraChat = new ExtraChat(); - FontManager = new FontManager(); - - // v1.1.0 — Theme-Engine init. Custom-Themes liegen in - // pluginConfigs/HellionChat/themes/, lazy geladen beim ersten Get. - var customThemesDir = Path.Combine(Interface.ConfigDirectory.FullName, "themes"); - Directory.CreateDirectory(customThemesDir); - SeedExampleThemeIfEmpty(customThemesDir); - ThemeRegistry = new Themes.ThemeRegistry(customThemesDir); - ThemeRegistry.Switch(Config.Theme); + await Task.WhenAll(fontTask, themeTask).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); // SelfTest hooks live alongside the live registry — the steps // poll Active per frame and need the registry already wired. @@ -461,15 +517,7 @@ public sealed class Plugin : IDalamudPlugin new SelfTests.ThemeSwitchSelfTestStep(this), ]); - // Plugin integrations register their IPC subscribers up-front so - // Ready/Disposing events from the target plugins are caught from - // the very first frame, even if the user's Honorific reloads - // mid-session. See HellionChat/Integrations/HonorificService.cs. - HonorificService = new Integrations.HonorificService(Interface, Log, Framework); - - StatusBar = new Ui.StatusBar(); - - MessageManager = new MessageManager(this); // Does it require UI? + MessageManager = new MessageManager(this); // Hellion Chat — Auto-Tell-Tabs service. Subscribes to the // MessageManager's MessageProcessed event for live tells and @@ -479,11 +527,6 @@ public sealed class Plugin : IDalamudPlugin AutoTellTabsService = new AutoTellTabsService(this, MessageManager, MessageManager.Store); AutoTellTabsService.Initialize(); - // Hellion Chat — daily retention sweep, off-thread so it never - // blocks plugin load. Skips itself when disabled or already ran - // within the past 24 hours. - RunRetentionSweepIfDue(); - ChatLogWindow = new ChatLogWindow(this); SettingsWindow = new SettingsWindow(this); DbViewer = new DbViewer(this); @@ -507,17 +550,24 @@ public sealed class Plugin : IDalamudPlugin if (!Config.FirstRunCompleted) FirstRunWizard.IsOpen = true; - FontManager.BuildFonts(); - - Interface.UiBuilder.DisableCutsceneUiHide = true; - Interface.UiBuilder.DisableGposeUiHide = true; - // let all the other components register, then initialize commands Commands.Initialise(); if (Interface.Reason is not PluginLoadReason.Boot) MessageManager.FilterAllTabsAsync(); + #if !DEBUG + // Avoid 300ms hitch when sending first message by preloading the + // auto-translate cache. Don't do this in debug because it makes + // profiling difficult. + AutoTranslate.PreloadCache(); + #endif + + cancellationToken.ThrowIfCancellationRequested(); + + // (B1) Hooks last: every service and window must be live before + // Dalamud fires our first Draw / FrameworkUpdate tick. Anything + // earlier risks rendering against null FontManager / ThemeRegistry. Framework.Update += FrameworkUpdate; Interface.UiBuilder.Draw += Draw; Interface.LanguageChanged += LanguageChanged; @@ -526,68 +576,121 @@ public sealed class Plugin : IDalamudPlugin // most useful landing place; OpenConfigUi is already wired to // the same toggle inside SettingsWindow. Interface.UiBuilder.OpenMainUi += OpenMainUi; - - if (Config.ShowEmotes) - _ = EmoteCache.LoadData(); // Fire-and-forget intentional, exceptions are caught inside - - #if !DEBUG - // Avoid 300ms hitch when sending first message by preloading the - // auto-translate cache. Don't do this in debug because it makes - // profiling difficult. - AutoTranslate.PreloadCache(); - #endif } - catch (Exception ex) + catch { - Log.Error(ex, "Plugin load threw an error, turning off plugin"); - Dispose(); - - // Re-throw the exception to fail the plugin load. + // Mirror the v1.4.0 load-failure recovery: hand off to DisposeAsync + // so partially-built services are torn down. Swallow the cleanup + // exception so the original load failure stays the visible cause. + try { await DisposeAsync().ConfigureAwait(false); } + catch { /* keep original failure */ } throw; } } - // Suppressing this warning because Dispose() is called in Plugin() if the - // load fails, so some values may not be initialized. + // Suppressing this warning because DisposeAsync may run after a partial + // LoadAsync, so some properties may not be initialized. [SuppressMessage("ReSharper", "ConditionalAccessQualifierIsNonNullableAccordingToAPIContract")] - public void Dispose() + public async ValueTask DisposeAsync() { - Interface.UiBuilder.OpenMainUi -= OpenMainUi; - Interface.LanguageChanged -= LanguageChanged; - Interface.UiBuilder.Draw -= Draw; - Framework.Update -= FrameworkUpdate; - GameFunctions.GameFunctions.SetChatInteractable(true); + // (B3) Idempotency guard — Dalamud may reload-race us; second + // call short-circuits so we don't double-dispose services. + if (Interlocked.Exchange(ref _disposeStarted, 1) != 0) + return; - // FrameworkUpdate would have fired the pending save in N frames, - // but we just unsubscribed it. -1 is the idle sentinel. - if (DeferredSaveFrames >= 0) + Exception? failure = null; + + // Hooks unsubscribe FIRST so no Draw / FrameworkUpdate / LanguageChanged + // tick can fire while we're tearing services down. Mirrors the + // hooks-last subscribe order in LoadAsync. + failure = CaptureFailure(failure, () => Interface.UiBuilder.OpenMainUi -= OpenMainUi); + failure = CaptureFailure(failure, () => Interface.LanguageChanged -= LanguageChanged); + failure = CaptureFailure(failure, () => Interface.UiBuilder.Draw -= Draw); + failure = CaptureFailure(failure, () => Framework.Update -= FrameworkUpdate); + + // v1.4.0 F5.3 — flush a pending DeferredSave before service teardown, + // since FrameworkUpdate just got unsubscribed and won't fire it. + failure = CaptureFailure(failure, () => { - SaveConfig(); - DeferredSaveFrames = -1; + if (DeferredSaveFrames >= 0) + { + SaveConfig(); + DeferredSaveFrames = -1; + } + }); + + // Auto-Tell-Tabs unsubscribes from MessageProcessed before MessageManager + // goes away. Pure-memory cleanup, no framework-thread pflicht. + failure = CaptureFailure(failure, () => AutoTellTabsService?.Dispose()); + + // v1.4.0 F6.2 — MessageManager has its own async dispose path + // (DB flush, pending-message thread shutdown). Run it before the + // framework-block so the worker threads are quiesced first. + if (MessageManager is not null) + { + failure = await CaptureFailureAsync(failure, () => MessageManager.DisposeAsync().AsTask()) + .ConfigureAwait(false); } - HonorificService?.Dispose(); + // (B4) Game-Function / IPC / UI-Window cleanup MUST run on the + // framework thread. WindowSystem mutations and IPC subscriber + // disposes touch Dalamud state that's only safe from the framework. + // Worker-thread DisposeAsync would race the next Draw tick. + failure = await CaptureFailureAsync(failure, async () => + { + await Framework.RunOnFrameworkThread(() => + { + // Game-Functions first — other services may still query + // chat-interactable state during their Dispose. + try { GameFunctions.GameFunctions.SetChatInteractable(true); } catch { /* swallowed */ } - WindowSystem?.RemoveAllWindows(); - ChatLogWindow?.Dispose(); - DbViewer?.Dispose(); - InputPreview?.Dispose(); - SettingsWindow?.Dispose(); - DebuggerWindow?.Dispose(); - SeStringDebugger?.Dispose(); + // IPC subscribers — dispose before windows so any final + // event firing from the IPC source can't reach a half-torn + // ChatLogWindow. + HonorificService?.Dispose(); + TypingIpc?.Dispose(); + ExtraChat?.Dispose(); + Ipc?.Dispose(); - TypingIpc?.Dispose(); - ExtraChat?.Dispose(); - Ipc?.Dispose(); - // Dispose the Auto-Tell-Tabs service before MessageManager so it - // can cleanly unsubscribe from the MessageProcessed event before - // its source goes away. - AutoTellTabsService?.Dispose(); - MessageManager?.DisposeAsync().AsTask().Wait(); - Functions?.Dispose(); - Commands?.Dispose(); + // Windows — RemoveAllWindows first, then per-window Dispose. + // Order matches the pre-v1.4.3 Dispose body byte-for-byte. + // CommandHelpWindow and FirstRunWizard don't implement + // IDisposable; their resources are reclaimed via WindowSystem. + WindowSystem?.RemoveAllWindows(); + ChatLogWindow?.Dispose(); + DbViewer?.Dispose(); + InputPreview?.Dispose(); + SettingsWindow?.Dispose(); + DebuggerWindow?.Dispose(); + SeStringDebugger?.Dispose(); + }).ConfigureAwait(false); + }).ConfigureAwait(false); - EmoteCache.Dispose(); + // Pure-memory cleanups — no Framework / UI / IPC touch, so they + // run on whatever thread DisposeAsync resumes on. + failure = CaptureFailure(failure, () => Functions?.Dispose()); + failure = CaptureFailure(failure, () => Commands?.Dispose()); + failure = CaptureFailure(failure, () => EmoteCache.Dispose()); + + if (failure is not null) + ExceptionDispatchInfo.Capture(failure).Throw(); + } + + // Lightless-pattern capture helpers: run cleanup, remember the FIRST + // exception, keep going. Without these one mid-teardown failure would + // skip every cleanup behind it and leave services half-torn. + 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; } // Reads HellionThemeWindowOpacity from the pre-v13 backup the v12→v13