935 lines
42 KiB
C#
Executable File
935 lines
42 KiB
C#
Executable File
using System.Diagnostics;
|
|
using System.Diagnostics.CodeAnalysis;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using HellionChat.Ipc;
|
|
using HellionChat.Resources;
|
|
using HellionChat.Ui;
|
|
using HellionChat.Util;
|
|
using Dalamud.Game.ClientState.Conditions;
|
|
using Dalamud.Interface.Windowing;
|
|
using Dalamud.IoC;
|
|
using Dalamud.Plugin;
|
|
using Dalamud.Plugin.Services;
|
|
using Dalamud.Bindings.ImGui;
|
|
using Dalamud.Interface.ImGuiFileDialog;
|
|
|
|
namespace HellionChat;
|
|
|
|
// ReSharper disable once ClassNeverInstantiated.Global
|
|
public sealed class Plugin : IDalamudPlugin
|
|
{
|
|
public const string PluginName = "Hellion Chat";
|
|
|
|
[PluginService] public static IPluginLog Log { get; private set; } = null!;
|
|
[PluginService] public static IDalamudPluginInterface Interface { get; private set; } = null!;
|
|
[PluginService] public static IChatGui ChatGui { get; private set; } = null!;
|
|
[PluginService] public static IClientState ClientState { get; private set; } = null!;
|
|
[PluginService] public static ICommandManager CommandManager { get; private set; } = null!;
|
|
[PluginService] public static ICondition Condition { get; private set; } = null!;
|
|
[PluginService] public static IDataManager DataManager { get; private set; } = null!;
|
|
[PluginService] public static IFramework Framework { get; private set; } = null!;
|
|
[PluginService] public static IGameGui GameGui { get; private set; } = null!;
|
|
[PluginService] public static IKeyState KeyState { get; private set; } = null!;
|
|
[PluginService] public static IObjectTable ObjectTable { get; private set; } = null!;
|
|
[PluginService] public static IPartyList PartyList { get; private set; } = null!;
|
|
[PluginService] public static ITargetManager TargetManager { get; private set; } = null!;
|
|
[PluginService] public static ITextureProvider TextureProvider { get; private set; } = null!;
|
|
[PluginService] public static IGameInteropProvider GameInteropProvider { get; private set; } = null!;
|
|
[PluginService] public static IGameConfig GameConfig { get; private set; } = null!;
|
|
[PluginService] public static INotificationManager Notification { get; private set; } = null!;
|
|
[PluginService] public static IAddonLifecycle AddonLifecycle { get; private set; } = null!;
|
|
[PluginService] public static IPlayerState PlayerState { get; private set; } = null!;
|
|
[PluginService] public static ISeStringEvaluator Evaluator { get; private set; } = null!;
|
|
[PluginService] public static ISelfTestRegistry SelfTestRegistry { get; private set; } = null!;
|
|
|
|
public static Configuration Config = null!;
|
|
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; }
|
|
internal Themes.ThemeRegistry ThemeRegistry { get; private set; } = null!;
|
|
internal Ui.StatusBar StatusBar { get; private set; } = null!;
|
|
internal Integrations.HonorificService HonorificService { get; private set; } = null!;
|
|
|
|
internal int DeferredSaveFrames = -1;
|
|
|
|
// Serialises retention sweeps. The 24h auto-sweep on plugin load and
|
|
// the manual button in the Privacy tab both run on background threads;
|
|
// without this gate, hitting the manual button moments after a fresh
|
|
// plugin start would launch two sweeps in parallel and the second one
|
|
// would just re-do work the first one already finished. The lock guards
|
|
// the flag — the flag check itself bails before we touch the database.
|
|
// Volatile because the ImGui thread reads the flag outside the lock to
|
|
// gate the manual button; without it the JIT may cache the value in a
|
|
// register and miss the background-thread update.
|
|
internal readonly object RetentionSweepLock = new();
|
|
internal volatile bool RetentionSweepRunning;
|
|
|
|
internal DateTime GameStarted { get; }
|
|
|
|
// Tab management needs to happen outside the chatlog window class for access reasons
|
|
internal int LastTab { get; set; }
|
|
internal int? WantedTab { get; set; }
|
|
internal Tab CurrentTab
|
|
{
|
|
get
|
|
{
|
|
var i = LastTab;
|
|
return i > -1 && i < Config.Tabs.Count ? Config.Tabs[i] : new Tab();
|
|
}
|
|
}
|
|
|
|
public Plugin()
|
|
{
|
|
// 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);
|
|
|
|
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)
|
|
{
|
|
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();
|
|
|
|
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);
|
|
|
|
// SelfTest hooks live alongside the live registry — the steps
|
|
// poll Active per frame and need the registry already wired.
|
|
SelfTestRegistry.RegisterTestSteps([
|
|
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?
|
|
|
|
// Hellion Chat — Auto-Tell-Tabs service. Subscribes to the
|
|
// MessageManager's MessageProcessed event for live tells and
|
|
// to ClientState.Logout for the cleanup pass. Created after
|
|
// MessageManager so the constructor can hand off the live
|
|
// store and event source.
|
|
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);
|
|
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);
|
|
|
|
// Open the wizard on a fresh install. Existing ChatTwo users have
|
|
// FirstRunCompleted set to true by the v6→v7 migration above.
|
|
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();
|
|
|
|
Framework.Update += FrameworkUpdate;
|
|
Interface.UiBuilder.Draw += Draw;
|
|
Interface.LanguageChanged += LanguageChanged;
|
|
// Hellion Chat — surface a "main UI" entry point so Dalamud's
|
|
// plugin list shows the Open-Plugin button. Settings is the
|
|
// 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)
|
|
{
|
|
Log.Error(ex, "Plugin load threw an error, turning off plugin");
|
|
Dispose();
|
|
|
|
// Re-throw the exception to fail the plugin load.
|
|
throw;
|
|
}
|
|
}
|
|
|
|
// Suppressing this warning because Dispose() is called in Plugin() if the
|
|
// load fails, so some values may not be initialized.
|
|
[SuppressMessage("ReSharper", "ConditionalAccessQualifierIsNonNullableAccordingToAPIContract")]
|
|
public void Dispose()
|
|
{
|
|
Interface.UiBuilder.OpenMainUi -= OpenMainUi;
|
|
Interface.LanguageChanged -= LanguageChanged;
|
|
Interface.UiBuilder.Draw -= Draw;
|
|
Framework.Update -= FrameworkUpdate;
|
|
GameFunctions.GameFunctions.SetChatInteractable(true);
|
|
|
|
// FrameworkUpdate would have fired the pending save in N frames,
|
|
// but we just unsubscribed it. -1 is the idle sentinel.
|
|
if (DeferredSaveFrames >= 0)
|
|
{
|
|
SaveConfig();
|
|
DeferredSaveFrames = -1;
|
|
}
|
|
|
|
HonorificService?.Dispose();
|
|
|
|
WindowSystem?.RemoveAllWindows();
|
|
ChatLogWindow?.Dispose();
|
|
DbViewer?.Dispose();
|
|
InputPreview?.Dispose();
|
|
SettingsWindow?.Dispose();
|
|
DebuggerWindow?.Dispose();
|
|
SeStringDebugger?.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();
|
|
|
|
EmoteCache.Dispose();
|
|
}
|
|
|
|
// Reads HellionThemeWindowOpacity from the pre-v13 backup the v12→v13
|
|
// block writes alongside the live config. Null when absent, unreadable,
|
|
// or schema-incompatible — all valid steady states (fresh install,
|
|
// backup pruned, pre-v12 config). Errors log at Warning so a corrupted
|
|
// backup stays visible in /xllog without breaking the migration.
|
|
private static float? TryReadPreV13ThemeOpacity()
|
|
{
|
|
var pluginConfigsDir = Interface.ConfigDirectory.Parent?.FullName;
|
|
if (pluginConfigsDir is null)
|
|
return null;
|
|
|
|
var backupPath = Path.Combine(pluginConfigsDir, $"{Interface.InternalName}.json.pre-v13-backup");
|
|
if (!File.Exists(backupPath))
|
|
return null;
|
|
|
|
try
|
|
{
|
|
using var stream = File.OpenRead(backupPath);
|
|
using var doc = System.Text.Json.JsonDocument.Parse(stream);
|
|
if (doc.RootElement.TryGetProperty("HellionThemeWindowOpacity", out var prop)
|
|
&& prop.ValueKind == System.Text.Json.JsonValueKind.Number
|
|
&& prop.TryGetSingle(out var value))
|
|
{
|
|
return value;
|
|
}
|
|
return null;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Warning(ex, "HellionChat: pre-v13 backup lookup failed, defaulting WindowOpacity");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static void MigrateFromChatTwoLayout()
|
|
{
|
|
var pluginConfigsDir = Interface.ConfigDirectory.Parent?.FullName;
|
|
if (pluginConfigsDir is null)
|
|
return;
|
|
|
|
var legacyConfigFile = Path.Combine(pluginConfigsDir, "ChatTwo.json");
|
|
var legacyConfigDir = Path.Combine(pluginConfigsDir, "ChatTwo");
|
|
var ourConfigFile = Path.Combine(pluginConfigsDir, "HellionChat.json");
|
|
var ourConfigDir = Interface.ConfigDirectory.FullName;
|
|
|
|
// Track whether anything legitimately blocked us. The most common
|
|
// cause is upstream Chat 2 still being loaded — its SQLite handle
|
|
// keeps chat-sqlite.db locked and File.Move throws IOException.
|
|
var lockedBlocker = false;
|
|
|
|
try
|
|
{
|
|
if (!File.Exists(ourConfigFile) && File.Exists(legacyConfigFile))
|
|
{
|
|
File.Move(legacyConfigFile, ourConfigFile);
|
|
Log.Information($"HellionChat: migrated config file {legacyConfigFile} → {ourConfigFile}");
|
|
}
|
|
}
|
|
catch (IOException e)
|
|
{
|
|
Log.Warning(e, $"HellionChat: config file move blocked, leaving {legacyConfigFile} in place");
|
|
lockedBlocker = true;
|
|
}
|
|
|
|
// The plugin's ConfigDirectory may already exist on first load
|
|
// (Dalamud creates it), so check at the file level instead of
|
|
// skipping when the directory is present. Move every legacy
|
|
// entry whose target name is not occupied yet, then remove the
|
|
// source dir if it ends up empty. Each move is wrapped on its
|
|
// own so a single locked file (the SQLite db while ChatTwo still
|
|
// runs) does not abandon the rest of the migration.
|
|
if (!Directory.Exists(legacyConfigDir))
|
|
return;
|
|
|
|
try
|
|
{
|
|
Directory.CreateDirectory(ourConfigDir);
|
|
|
|
foreach (var file in Directory.EnumerateFiles(legacyConfigDir))
|
|
{
|
|
var target = Path.Combine(ourConfigDir, Path.GetFileName(file));
|
|
if (File.Exists(target))
|
|
continue;
|
|
try
|
|
{
|
|
File.Move(file, target);
|
|
Log.Information($"HellionChat: migrated file {file} → {target}");
|
|
}
|
|
catch (IOException e)
|
|
{
|
|
Log.Warning(e, $"HellionChat: file move blocked for {file}, will retry on next load");
|
|
lockedBlocker = true;
|
|
}
|
|
}
|
|
|
|
foreach (var dir in Directory.EnumerateDirectories(legacyConfigDir))
|
|
{
|
|
var target = Path.Combine(ourConfigDir, Path.GetFileName(dir));
|
|
if (Directory.Exists(target))
|
|
continue;
|
|
try
|
|
{
|
|
Directory.Move(dir, target);
|
|
Log.Information($"HellionChat: migrated subdir {dir} → {target}");
|
|
}
|
|
catch (IOException e)
|
|
{
|
|
Log.Warning(e, $"HellionChat: subdir move blocked for {dir}, will retry on next load");
|
|
lockedBlocker = true;
|
|
}
|
|
}
|
|
|
|
if (!Directory.EnumerateFileSystemEntries(legacyConfigDir).Any())
|
|
{
|
|
Directory.Delete(legacyConfigDir);
|
|
Log.Information($"HellionChat: removed empty legacy dir {legacyConfigDir}");
|
|
}
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Log.Error(e, "HellionChat: layout migration failed, continuing with whatever exists");
|
|
}
|
|
|
|
if (lockedBlocker)
|
|
{
|
|
// Surface the most common cause to the user as a notification
|
|
// so they don't think Hellion Chat lost their history when in
|
|
// fact upstream Chat 2 was still holding the database file.
|
|
Notification.AddNotification(new Dalamud.Interface.ImGuiNotification.Notification
|
|
{
|
|
Title = "Hellion Chat",
|
|
Content = "Could not migrate the Chat 2 database — the file appears to be in use. " +
|
|
"Disable Chat 2, fully close the game, then start it again. " +
|
|
"See the README troubleshooting section if the issue persists.",
|
|
Type = Dalamud.Interface.ImGuiNotification.NotificationType.Warning,
|
|
InitialDuration = TimeSpan.FromSeconds(30),
|
|
});
|
|
}
|
|
}
|
|
|
|
private void OpenMainUi()
|
|
{
|
|
// Settings is the most useful landing surface — same target as the
|
|
// Configure button. SettingsWindow.Toggle is internal and already
|
|
// wired to OpenConfigUi, so re-using IsOpen keeps both entry points
|
|
// behaviourally identical.
|
|
SettingsWindow.IsOpen = !SettingsWindow.IsOpen;
|
|
}
|
|
|
|
private void RunRetentionSweepIfDue()
|
|
{
|
|
if (!Config.RetentionEnabled)
|
|
return;
|
|
if (DateTimeOffset.UtcNow - Config.RetentionLastRunAt < TimeSpan.FromHours(24))
|
|
return;
|
|
|
|
// Snapshot the policy so the user can edit settings while we run.
|
|
// Spec defaults form the baseline; explicit user overrides win.
|
|
var policy = new Dictionary<int, int>();
|
|
foreach (var (type, days) in Privacy.PrivacyDefaults.DefaultRetentionDays)
|
|
policy[(int)(ushort)type] = days;
|
|
foreach (var (type, days) in Config.RetentionPerChannelDays)
|
|
policy[(int)(ushort)type] = days;
|
|
var defaultDays = Config.RetentionDefaultDays;
|
|
|
|
// IsBackground = true for the same reason as PendingMessageThread:
|
|
// a stuck sweep must never block plugin unload. RunRetentionSweepIfDue
|
|
// guards the run-frequency, and the sweep itself uses the framework's
|
|
// cooperative cancellation pattern. The background flag is the safety
|
|
// net if the sweep ever takes longer than expected.
|
|
new Thread(() =>
|
|
{
|
|
// Bail out cheaply if a manual sweep is already in flight; the
|
|
// lock around the actual work would queue us up otherwise and
|
|
// we would just re-do whatever the manual run already did.
|
|
lock (RetentionSweepLock)
|
|
{
|
|
if (RetentionSweepRunning)
|
|
return;
|
|
RetentionSweepRunning = true;
|
|
}
|
|
|
|
try
|
|
{
|
|
var deleted = MessageManager.Store.DeleteByRetentionPolicy(policy, defaultDays);
|
|
Config.RetentionLastRunAt = DateTimeOffset.UtcNow;
|
|
SaveConfig();
|
|
|
|
if (deleted > 0)
|
|
{
|
|
Log.Information($"Retention sweep deleted {deleted} expired messages.");
|
|
// Run the clear+refilter synchronously on the framework thread.
|
|
// Earlier this called FilterAllTabsAsync(), which is fire-and-forget
|
|
// — the .Wait() here would return as soon as the inner Task.Run was
|
|
// dispatched, racing the next sweep cycle against the still-running
|
|
// filter pass. See AUDIT-2026-05-05 [QUAL-02].
|
|
Framework.Run(() =>
|
|
{
|
|
MessageManager.ClearAllTabs();
|
|
MessageManager.FilterAllTabs();
|
|
}).Wait();
|
|
}
|
|
else
|
|
{
|
|
Log.Information("Retention sweep ran, nothing expired.");
|
|
}
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Log.Error(e, "Retention sweep failed");
|
|
}
|
|
finally
|
|
{
|
|
lock (RetentionSweepLock)
|
|
RetentionSweepRunning = false;
|
|
}
|
|
}) { IsBackground = true }.Start();
|
|
}
|
|
|
|
private void Draw()
|
|
{
|
|
// Theme-Engine ist ab v14 immer aktiv; Klassik ist jetzt ein eigenes
|
|
// Theme statt einem deaktivierten Hellion-Theme. Active wird einmal
|
|
// pro Frame aus der Registry gelesen.
|
|
using IDisposable _style = HellionStyle.PushGlobal(ThemeRegistry.Active, Config.WindowOpacity);
|
|
|
|
ChatLogWindow.BeginFrame();
|
|
|
|
if (Config.HideInLoadingScreens && Condition[ConditionFlag.BetweenAreas])
|
|
{
|
|
ChatLogWindow.FinalizeFrame();
|
|
TypingIpc.Update();
|
|
return;
|
|
}
|
|
|
|
// v1.0.2 — global skip while the New Game+ menu (QuestRedo addon) is
|
|
// open. Hides every plugin window in one shot (chat log, pop-outs,
|
|
// settings, db viewer, etc.), matching the LoadingScreens pattern.
|
|
if (Config.HideInNewGamePlusMenu && GameFunctions.GameFunctions.IsAddonInteractable(GameFunctions.GameFunctions.NewGamePlusAddonName))
|
|
{
|
|
ChatLogWindow.FinalizeFrame();
|
|
TypingIpc.Update();
|
|
return;
|
|
}
|
|
|
|
ChatLogWindow.HideStateCheck();
|
|
|
|
Interface.UiBuilder.DisableUserUiHide = !Config.HideWhenUiHidden;
|
|
ChatLogWindow.DefaultText = ImGui.GetStyle().Colors[(int) ImGuiCol.Text];
|
|
|
|
using ((Config.FontsEnabled ? FontManager.RegularFont : FontManager.Axis).Push())
|
|
WindowSystem.Draw();
|
|
|
|
ChatLogWindow.FinalizeFrame();
|
|
TypingIpc.Update();
|
|
|
|
FileDialogManager.Draw();
|
|
}
|
|
|
|
internal void SaveConfig()
|
|
{
|
|
// Hellion Chat — Auto-Tell-Tabs are session-only. Strip them out
|
|
// before serialization so a crash mid-session can never persist
|
|
// them. We snapshot the full tab list first and restore it after
|
|
// the save, preserving the user's order and open conversations.
|
|
var snapshot = Config.Tabs.ToList();
|
|
Config.Tabs.RemoveAll(t => t.IsTempTab);
|
|
|
|
Interface.SavePluginConfig(Config);
|
|
|
|
Config.Tabs.Clear();
|
|
Config.Tabs.AddRange(snapshot);
|
|
}
|
|
|
|
internal void LanguageChanged(string langCode)
|
|
{
|
|
var info = Config.LanguageOverride is LanguageOverride.None
|
|
? new CultureInfo(langCode)
|
|
: new CultureInfo(Config.LanguageOverride.Code());
|
|
|
|
Language.Culture = info;
|
|
HellionStrings.Culture = info;
|
|
}
|
|
|
|
private static readonly string[] ChatAddonNames =
|
|
[
|
|
"ChatLog",
|
|
"ChatLogPanel_0",
|
|
"ChatLogPanel_1",
|
|
"ChatLogPanel_2",
|
|
"ChatLogPanel_3"
|
|
];
|
|
|
|
private void FrameworkUpdate(IFramework framework)
|
|
{
|
|
if (DeferredSaveFrames >= 0 && DeferredSaveFrames-- == 0)
|
|
SaveConfig();
|
|
|
|
if (!Config.HideChat)
|
|
return;
|
|
|
|
foreach (var name in ChatAddonNames)
|
|
if (GameFunctions.GameFunctions.IsAddonInteractable(name))
|
|
GameFunctions.GameFunctions.SetAddonInteractable(name, false);
|
|
}
|
|
|
|
public static bool InBattle => Condition[ConditionFlag.InCombat];
|
|
public static bool GposeActive => Condition[ConditionFlag.WatchingCutscene];
|
|
public static bool CutsceneActive => Condition[ConditionFlag.OccupiedInCutSceneEvent] || Condition[ConditionFlag.WatchingCutscene78];
|
|
|
|
// v1.1.0 — wenn der themes/-Ordner leer ist, schreiben wir die embedded
|
|
// example-theme.json als Vorlage rein. Bestehende User-Customs werden
|
|
// nicht angefasst (existing JSONs lassen den Block überspringen).
|
|
private static void SeedExampleThemeIfEmpty(string dir)
|
|
{
|
|
if (Directory.EnumerateFiles(dir, "*.json").Any())
|
|
return;
|
|
|
|
var examplePath = Path.Combine(dir, "example-theme.json");
|
|
var resourceStream = typeof(Plugin).Assembly.GetManifestResourceStream("HellionChat.Themes.Builtin.example-theme.json");
|
|
if (resourceStream is null)
|
|
{
|
|
Log.Warning("Themes example template not found in assembly resources; skipping seed.");
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
using var fileStream = File.Create(examplePath);
|
|
resourceStream.CopyTo(fileStream);
|
|
Log.Information($"Seeded example-theme.json into {dir}");
|
|
}
|
|
catch (IOException ex)
|
|
{
|
|
Log.Warning(ex, "Failed to seed example-theme.json; user can create custom themes manually.");
|
|
}
|
|
finally
|
|
{
|
|
resourceStream.Dispose();
|
|
}
|
|
}
|
|
}
|