Files
HellionChat/HellionChat/Plugin.cs
T
JonKazama-Hellion 4000bbd199
Security / scan (push) Successful in 12s
chore: reformat after editorconfig update
Updated .editorconfig to set indent_style=space and indent_size=4 for C# files. Reformat all .cs files to apply the new indentation settings. No code logic changes, just whitespace reformatting.
also updated some comments in files in shorter and Precise way. No logic changes, just comment rewording for clarity and conciseness.
2026-05-10 19:54:39 +02:00

721 lines
26 KiB
C#
Executable File

using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Runtime.ExceptionServices;
using Dalamud.Bindings.ImGui;
using Dalamud.Game.ClientState.Conditions;
using Dalamud.Interface.ImGuiFileDialog;
using Dalamud.Interface.Windowing;
using Dalamud.IoC;
using Dalamud.Plugin;
using Dalamud.Plugin.Services;
using HellionChat.Ipc;
using HellionChat.Resources;
using HellionChat.Ui;
using HellionChat.Util;
namespace HellionChat;
// ReSharper disable once ClassNeverInstantiated.Global
public sealed class Plugin : IAsyncDalamudPlugin
{
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);
// Phase-2 services are constructed in LoadAsync; null! shape is kept
// consistent across all properties for clarity.
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!;
// Idempotency guard — Dalamud may fire DisposeAsync twice in a reload race.
private int _disposeStarted;
internal int DeferredSaveFrames = -1;
// Serialises retention sweeps so a manual trigger and the 24h auto-sweep
// can't run in parallel. Volatile because the ImGui thread reads it outside
// the lock to gate the manual button.
internal readonly object RetentionSweepLock = new();
internal volatile bool RetentionSweepRunning;
internal DateTime GameStarted { get; }
// Tab management lives here rather than in ChatLogWindow 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()
{
// Phase-1 ctor: bootstrap-essentials only (conflict gate, config load,
// language + ImGui init). All service/window allocation lives in LoadAsync.
// Block load if upstream Chat 2 is active — prevents IPC collisions
// and double-replacement of the in-game chat window.
ChatTwoConflictDetector.ThrowIfChatTwoIsLoaded(Interface);
GameStarted = Process.GetCurrentProcess().StartTime.ToUniversalTime();
// Migrate config + database from upstream ChatTwo on first start.
MigrateFromChatTwoLayout();
Config = Interface.GetPluginConfig() as Configuration ?? new Configuration();
// Schema gate: v1.4.3 requires config v16. Users on older schemas
// must install v1.4.2 first to run the migration chain.
if (Config.Version < 16)
{
throw new InvalidOperationException(
$"HellionChat v1.4.3 requires config schema v16, got v{Config.Version}. "
+ "Please install v1.4.2 first to migrate the configuration, then upgrade to v1.4.3."
);
}
// Drop session-only Auto-Tell-Tabs that a previous crash may have persisted.
Config.Tabs.RemoveAll(t => t.IsTempTab);
LanguageChanged(Interface.UiLanguage);
ImGuiUtil.Initialize(this);
DeferredSaveFrames = -1;
}
public async Task LoadAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
// Default tab layout on fresh install. Tells are handled by
// Auto-Tell-Tabs; Novice Network has no preset tab by design.
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);
}
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);
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();
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;
cancellationToken.ThrowIfCancellationRequested();
Commands.Initialise();
// Daily retention sweep — fire-and-forget, skips when disabled
// or already ran within the past 24 hours.
RunRetentionSweepIfDue();
if (Config.ShowEmotes)
_ = EmoteCache.LoadData();
if (Interface.Reason is not PluginLoadReason.Boot)
MessageManager.FilterAllTabsAsync();
Interface.UiBuilder.DisableCutsceneUiHide = true;
Interface.UiBuilder.DisableGposeUiHide = true;
#if !DEBUG
// Fire-and-forget — first auto-translate use may have a sub-second
// hitch if the cache hasn't filled yet, but avoids blocking load.
_ = Task.Run(AutoTranslate.PreloadCache, cancellationToken);
#endif
cancellationToken.ThrowIfCancellationRequested();
// Hooks last — all services and windows must be live before
// the first Draw / FrameworkUpdate tick fires.
Framework.Update += FrameworkUpdate;
Interface.UiBuilder.Draw += Draw;
Interface.LanguageChanged += LanguageChanged;
Interface.UiBuilder.OpenMainUi += OpenMainUi;
}
catch
{
try
{
await DisposeAsync().ConfigureAwait(false);
}
catch
{ /* keep original failure */
}
throw;
}
}
[SuppressMessage("ReSharper", "ConditionalAccessQualifierIsNonNullableAccordingToAPIContract")]
public async ValueTask DisposeAsync()
{
// Idempotency guard — second call short-circuits on reload race.
if (Interlocked.Exchange(ref _disposeStarted, 1) != 0)
return;
Exception? failure = null;
// Unsubscribe hooks first — 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);
// Flush a pending DeferredSave — FrameworkUpdate won't fire it anymore.
failure = CaptureFailure(
failure,
() =>
{
if (DeferredSaveFrames >= 0)
{
SaveConfig();
DeferredSaveFrames = -1;
}
}
);
// Unsubscribe AutoTellTabs before MessageManager goes away.
failure = CaptureFailure(failure, () => AutoTellTabsService?.Dispose());
// MessageManager has its own async dispose path (DB flush, thread shutdown).
if (MessageManager is not null)
{
failure = await CaptureFailureAsync(
failure,
() => MessageManager.DisposeAsync().AsTask()
)
.ConfigureAwait(false);
}
// Game-function / IPC / window cleanup must run on the framework thread.
try
{
await Framework
.RunOnFrameworkThread(() =>
{
failure = CaptureFailure(
failure,
() => GameFunctions.GameFunctions.SetChatInteractable(true)
);
// IPC subscribers before windows — prevents a final IPC event
// from reaching a half-torn ChatLogWindow.
failure = CaptureFailure(failure, () => HonorificService?.Dispose());
failure = CaptureFailure(failure, () => TypingIpc?.Dispose());
failure = CaptureFailure(failure, () => ExtraChat?.Dispose());
failure = CaptureFailure(failure, () => Ipc?.Dispose());
failure = CaptureFailure(failure, () => WindowSystem?.RemoveAllWindows());
failure = CaptureFailure(failure, () => ChatLogWindow?.Dispose());
failure = CaptureFailure(failure, () => DbViewer?.Dispose());
failure = CaptureFailure(failure, () => InputPreview?.Dispose());
failure = CaptureFailure(failure, () => SettingsWindow?.Dispose());
failure = CaptureFailure(failure, () => DebuggerWindow?.Dispose());
failure = CaptureFailure(failure, () => SeStringDebugger?.Dispose());
})
.ConfigureAwait(false);
}
catch (Exception ex)
{
failure ??= ex;
}
// Pure-memory cleanups — no Framework / UI / IPC touch.
failure = CaptureFailure(failure, () => Functions?.Dispose());
failure = CaptureFailure(failure, () => Commands?.Dispose());
failure = CaptureFailure(failure, () => EmoteCache.Dispose());
if (failure is not null)
ExceptionDispatchInfo.Capture(failure).Throw();
}
// Run cleanup actions individually so a single failure doesn't strand
// the remaining teardown steps.
private static Exception? CaptureFailure(Exception? failure, Action action)
{
try
{
action();
}
catch (Exception ex)
{
failure ??= ex;
}
return failure;
}
private static async ValueTask<Exception?> CaptureFailureAsync(
Exception? failure,
Func<Task> action
)
{
try
{
await action().ConfigureAwait(false);
}
catch (Exception ex)
{
failure ??= ex;
}
return failure;
}
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;
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;
}
if (!Directory.Exists(legacyConfigDir))
return;
try
{
Directory.CreateDirectory(ourConfigDir);
// Move each file individually so a single locked file (e.g. the
// SQLite db while ChatTwo is still loaded) doesn't abort the rest.
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)
{
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()
{
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 the sweep runs.
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 so a stuck sweep never blocks plugin unload.
new Thread(() =>
{
// Bail early if a manual sweep is already in flight.
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 clear+refilter on the framework thread — FilterAllTabsAsync
// is fire-and-forget and would race the next sweep cycle.
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 is always active; Classic is a theme, not a disabled state.
using IDisposable _style = HellionStyle.PushGlobal(
ThemeRegistry.Active,
Config.WindowOpacity
);
ChatLogWindow.BeginFrame();
if (Config.HideInLoadingScreens && Condition[ConditionFlag.BetweenAreas])
{
ChatLogWindow.FinalizeFrame();
TypingIpc.Update();
return;
}
// Hide all plugin windows while the New Game+ menu is open.
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()
{
// Strip session-only Auto-Tell-Tabs before serialization; restore after.
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];
// Seeds example-theme.json into the themes dir on first run.
// Skipped if any custom JSON already exists.
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();
}
}
}