From 4d54eabdacab2149f607776a6f111500810cd9b4 Mon Sep 17 00:00:00 2001 From: JonKazama-Hellion Date: Tue, 5 May 2026 07:25:47 +0200 Subject: [PATCH] chore: code quality sweep 2026-05-04 / 2026-05-05 General code-quality and robustness pass across the plugin: thread- safety on IPC state, resource-disposal cleanups, input validation, defensive null-checks and a few small UX glitches. Compliance docs (THIRD_PARTY_NOTICES, PRIVACY, COPYRIGHT) refreshed to v1.0.3. Highlights - ExtraChat IPC state synchronised across threads - ChatLogWindow autocomplete no longer leaks the unmanaged ImGuiListClipper allocation - ChatLogWindow + Popout style stack stays balanced when config toggles mid-frame - Retention sweep and privacy cleanup wait for the actual filter pass instead of the fire-and-forget Task that started it - Configuration.LatestVersion bumped to 13 to match the active migration path - GameFunctions placeholder buffer guarded against oversized replacement names - TellTarget.IsSet, ResolveTempInputChannel, InputPreview, IconUtil, Lender, Payloads, ExtraPayload all hardened against null / empty / EOF / cycle inputs - FontManager Lodestone download stays in scope for a follow-up (timeout + lazy init pending) - AutoTranslate replaced the msvcrt.dll memcmp P/Invoke with a managed Span comparison - Privacy cleanup worker thread marked IsBackground = true - Database cleanup now removes both legacy files in one click - Tell-target name redacted in the verbose debug log Compliance - THIRD_PARTY_NOTICES: last-reviewed bumped to v1.0.3, Pidgin 3.5.1, SQLitePCLRaw.lib.e_sqlite3 3.50.3 listed as direct dependency with CVE-2025-6965 / CVE-2025-7709 rationale - PRIVACY: last-reviewed bumped to v1.0.3, BetterTTV trigger wording clarified (list fetch at startup vs. on-demand image fetch) - COPYRIGHT: upstream attribution range widened Build: 0 warnings, 0 errors. No behavioural changes that would alter existing user configuration or stored chat history. --- COPYRIGHT | 2 +- HellionChat/Configuration.cs | 7 +- HellionChat/GameFunctions/Chat.cs | 6 +- HellionChat/GameFunctions/GameFunctions.cs | 14 +- HellionChat/GameFunctions/Types/TellTarget.cs | 2 +- HellionChat/Ipc/ExtraChat.cs | 13 +- HellionChat/MessageManager.cs | 4 + HellionChat/Plugin.cs | 7 +- HellionChat/Ui/ChatInputBar.cs | 2 +- HellionChat/Ui/ChatLogWindow.cs | 142 +++++++++++------- HellionChat/Ui/CommandHelpWindow.cs | 7 +- HellionChat/Ui/InputPreview.cs | 5 +- HellionChat/Ui/Popout.cs | 21 ++- HellionChat/Ui/SeStringDebugger.cs | 11 +- HellionChat/Ui/SettingsTabs/Chat.cs | 10 +- HellionChat/Ui/SettingsTabs/Database.cs | 4 +- HellionChat/Ui/SettingsTabs/Privacy.cs | 13 +- HellionChat/Util/AutoTranslate.cs | 9 +- HellionChat/Util/ExtraPayload.cs | 2 + HellionChat/Util/IconUtil.cs | 25 ++- HellionChat/Util/Lender.cs | 2 +- HellionChat/Util/MemoryUtil.cs | 13 ++ HellionChat/Util/Payloads.cs | 2 + HellionChat/Util/StringUtil.cs | 4 +- PRIVACY.md | 15 +- docs/THIRD_PARTY_NOTICES.md | 7 +- 26 files changed, 251 insertions(+), 98 deletions(-) diff --git a/COPYRIGHT b/COPYRIGHT index 410b3d1..ebd16d4 100644 --- a/COPYRIGHT +++ b/COPYRIGHT @@ -1,6 +1,6 @@ HellionChat — a privacy-focused fork of ChatTwo for FINAL FANTASY XIV -Copyright (c) 2024-2025 Infiziert90 (Infi) and Anna Clemens (ascclemens) +Copyright (c) 2022-2026 Infiziert90 (Infi) and Anna Clemens (ascclemens) Original ChatTwo authors and copyright holders of the upstream plugin this fork is built on. Their work covers the message store, the channel filtering, the sidebar tab system, the FFXIV chat diff --git a/HellionChat/Configuration.cs b/HellionChat/Configuration.cs index f1f9c85..fbe7fdc 100755 --- a/HellionChat/Configuration.cs +++ b/HellionChat/Configuration.cs @@ -34,7 +34,7 @@ public class ConfigKeyBind [Serializable] public class Configuration : IPluginConfiguration { - private const int LatestVersion = 12; + private const int LatestVersion = 13; public int Version { get; set; } = LatestVersion; @@ -279,7 +279,10 @@ public class Configuration : IPluginConfiguration MaxLinesToRender = other.MaxLinesToRender; Use24HourClock = other.Use24HourClock; ShowEmotes = other.ShowEmotes; - BlockedEmotes = other.BlockedEmotes; + // Deep-copy the set so the live and mutable Configuration instances don't share state + // — a HashSet reference assignment would cause edits in the settings window to leak + // into the live config before the user clicks Save. + BlockedEmotes = new HashSet(other.BlockedEmotes); FontsEnabled = other.FontsEnabled; ItalicEnabled = other.ItalicEnabled; ExtraGlyphRanges = other.ExtraGlyphRanges; diff --git a/HellionChat/GameFunctions/Chat.cs b/HellionChat/GameFunctions/Chat.cs index 20790ab..4c714c7 100755 --- a/HellionChat/GameFunctions/Chat.cs +++ b/HellionChat/GameFunctions/Chat.cs @@ -252,7 +252,7 @@ internal sealed unsafe class Chat : IDisposable { playerName = SeString.Parse(agent->TellPlayerName).TextValue; worldId = agent->TellWorldId; - Plugin.Log.Debug($"Detected tell target '{playerName}'@{worldId}"); + Plugin.Log.Debug($"Detected tell target '[redacted]'@{worldId}"); } Plugin.CurrentTab.CurrentChannel = new UsedChannel @@ -400,7 +400,9 @@ internal sealed unsafe class Chat : IDisposable } var idx = RotateLinkshell(currentIndex, rotate, channel == InputChannel.Linkshell1 ? ValidLinkshell : ValidCrossLinkshell); - return channel + idx; + // RotateLinkshell returns null when no valid linkshell is found within 8 iterations. + // Forward the null so the caller can keep the existing channel instead of crashing on nullable arithmetic. + return idx is null ? null : channel + idx.Value; } default: return channel; diff --git a/HellionChat/GameFunctions/GameFunctions.cs b/HellionChat/GameFunctions/GameFunctions.cs index c05b8f7..b85bb72 100755 --- a/HellionChat/GameFunctions/GameFunctions.cs +++ b/HellionChat/GameFunctions/GameFunctions.cs @@ -245,7 +245,8 @@ internal unsafe class GameFunctions : IDisposable vf0(agent, &result, &value, 0, 0); } - private readonly nint PlaceholderNamePtr = Marshal.AllocHGlobal(128); + private const int PlaceholderBufferSize = 128; + private readonly nint PlaceholderNamePtr = Marshal.AllocHGlobal(PlaceholderBufferSize); private readonly string Placeholder = $"<{Guid.NewGuid():N}>"; private string? ReplacementName; @@ -261,6 +262,17 @@ internal unsafe class GameFunctions : IDisposable if (ReplacementName == null || placeholder != Placeholder) return ResolveTextCommandPlaceholderHook.Original(a1, placeholderText, a3, a4); + // The fixed buffer is 128 bytes; UTF-8 + null-terminator must fit. + // FFXIV player names plus an @World suffix should never approach this + // limit, but a malformed ReplacementName must not overflow the buffer. + var byteCount = System.Text.Encoding.UTF8.GetByteCount(ReplacementName); + if (byteCount >= PlaceholderBufferSize) + { + Plugin.Log.Warning($"Replacement name too long for placeholder buffer ({byteCount} bytes >= {PlaceholderBufferSize}); falling back to original."); + ReplacementName = null; + return ResolveTextCommandPlaceholderHook.Original(a1, placeholderText, a3, a4); + } + MemoryHelper.WriteString(PlaceholderNamePtr, ReplacementName); ReplacementName = null; diff --git a/HellionChat/GameFunctions/Types/TellTarget.cs b/HellionChat/GameFunctions/Types/TellTarget.cs index b6151c2..554dbd2 100755 --- a/HellionChat/GameFunctions/Types/TellTarget.cs +++ b/HellionChat/GameFunctions/Types/TellTarget.cs @@ -20,7 +20,7 @@ public class TellTarget } public bool IsSet() - => Name.Length > 0 && World > 0; + => !string.IsNullOrEmpty(Name) && World > 0; public string ToWorldString() => Sheets.WorldSheet.TryGetRow(World, out var worldRow) ? worldRow.Name.ToString() : string.Empty; diff --git a/HellionChat/Ipc/ExtraChat.cs b/HellionChat/Ipc/ExtraChat.cs index b04521e..c51ac54 100644 --- a/HellionChat/Ipc/ExtraChat.cs +++ b/HellionChat/Ipc/ExtraChat.cs @@ -20,10 +20,14 @@ public sealed class ExtraChat : IDisposable internal (string, uint)? ChannelOverride { get; set; } - private Dictionary ChannelCommandColoursInternal { get; set; } = new(); + // Volatile reference: IPC callbacks (OnChannelCommandColours/OnChannelNames) fire on a + // Dalamud-dispatcher thread while the ImGui thread reads the IReadOnlyDictionary projections. + // Reference assignment is atomic on x64, but the JIT (especially Mono on Wine/Linux) needs + // the volatile barrier to guarantee visibility across threads. See AUDIT-2026-05-05 [SEC-01]. + private volatile Dictionary ChannelCommandColoursInternal = new(); internal IReadOnlyDictionary ChannelCommandColours => ChannelCommandColoursInternal; - private Dictionary ChannelNamesInternal { get; set; } = new(); + private volatile Dictionary ChannelNamesInternal = new(); internal IReadOnlyDictionary ChannelNames => ChannelNamesInternal; internal ExtraChat() @@ -40,9 +44,10 @@ public sealed class ExtraChat : IDisposable ChannelCommandColoursInternal = ChannelCommandColoursGate.InvokeFunc(null!); ChannelNamesInternal = ChannelNamesGate.InvokeFunc(null!); } - catch (Exception) + catch (Exception ex) { - // no-op + // ExtraChat is optional; missing IPC peer is normal when the plugin isn't loaded. + Plugin.Log.Verbose(ex, "ExtraChat IPC initial state query failed (peer not loaded?)"); } } diff --git a/HellionChat/MessageManager.cs b/HellionChat/MessageManager.cs index d4b1fe4..a7b60e5 100644 --- a/HellionChat/MessageManager.cs +++ b/HellionChat/MessageManager.cs @@ -93,6 +93,10 @@ internal class MessageManager : IAsyncDisposable Plugin.Log.Debug("Sleeping because PendingMessageThread thread still alive"); } + // CancellationTokenSource owns an unmanaged WaitHandle; dispose after the + // worker thread has drained, otherwise it leaks across plugin reloads. + PendingThreadCancellationToken.Dispose(); + Store.Dispose(); } diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index 62052c9..992600a 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -529,10 +529,15 @@ public sealed class Plugin : IDalamudPlugin 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.FilterAllTabsAsync(); + MessageManager.FilterAllTabs(); }).Wait(); } else diff --git a/HellionChat/Ui/ChatInputBar.cs b/HellionChat/Ui/ChatInputBar.cs index b2319ea..f1280a9 100644 --- a/HellionChat/Ui/ChatInputBar.cs +++ b/HellionChat/Ui/ChatInputBar.cs @@ -104,7 +104,7 @@ public sealed class ChatInputBar // window's logic but operates on _state.HistoryCursor and the shared // InputHistoryService. Index semantics match v0.5.x InputBacklog: // 0 = oldest, Count-1 = newest. - private unsafe int CompactCallback(scoped ref ImGuiInputTextCallbackData data) + private int CompactCallback(scoped ref ImGuiInputTextCallbackData data) { if (data.EventFlag != ImGuiInputTextFlags.CallbackHistory) return 0; diff --git a/HellionChat/Ui/ChatLogWindow.cs b/HellionChat/Ui/ChatLogWindow.cs index ebeb8dc..cd7c8db 100644 --- a/HellionChat/Ui/ChatLogWindow.cs +++ b/HellionChat/Ui/ChatLogWindow.cs @@ -34,6 +34,9 @@ public sealed class ChatLogWindow : Window internal Plugin Plugin { get; } + private readonly CommandWrapper _clearHellionCommand; + private readonly CommandWrapper _hellionCommand; + internal bool ScreenshotMode; private string Salt { get; } @@ -110,8 +113,14 @@ public sealed class ChatLogWindow : Window SetUpTextCommandChannels(); SetUpAllCommands(); - Plugin.Commands.Register("/clearhellion", "Clear the Hellion Chat log").Execute += ClearLog; - Plugin.Commands.Register("/hellion").Execute += ToggleChat; + // Cache the registered wrapper instances so Dispose can detach the same + // event objects the constructor attached to, without going through + // Register() again (which would re-create the wrapper if the command + // happened to be missing from the dictionary). + _clearHellionCommand = Plugin.Commands.Register("/clearhellion", "Clear the Hellion Chat log"); + _hellionCommand = Plugin.Commands.Register("/hellion"); + _clearHellionCommand.Execute += ClearLog; + _hellionCommand.Execute += ToggleChat; Plugin.ClientState.Login += Login; Plugin.ClientState.Logout += Logout; @@ -126,8 +135,8 @@ public sealed class ChatLogWindow : Window Plugin.AddonLifecycle.UnregisterListener(AddonEvent.PostUpdate, "ActionDetail", PayloadHandler.MoveTooltip); Plugin.ClientState.Logout -= Logout; Plugin.ClientState.Login -= Login; - Plugin.Commands.Register("/hellion").Execute -= ToggleChat; - Plugin.Commands.Register("/clearhellion").Execute -= ClearLog; + _hellionCommand.Execute -= ToggleChat; + _clearHellionCommand.Execute -= ClearLog; } private void Logout(int _, int __) @@ -514,13 +523,28 @@ public sealed class ChatLogWindow : Window return FrameTime - lastActivityTime <= 1000 * Plugin.Config.InactivityHideTimeout; } + // Tracks the style instance pushed in PreDraw so PostDraw can pop the same + // one even if the user toggled OverrideStyle / ChosenStyle mid-frame. + // Without this, a config change between PreDraw and PostDraw could either + // leak a Push (no matching Pop) or pop nothing while we still have a frame + // pushed onto the ImGui stack. + private StyleModel? _pushedStyle; + public override void PreDraw() { if (Plugin.Config.KeepInputFocus && Activate) ImGui.SetWindowFocus(WindowName); + _pushedStyle = null; if (Plugin.Config is { OverrideStyle: true, ChosenStyle: not null }) - StyleModel.GetConfiguredStyles()?.FirstOrDefault(style => style.Name == Plugin.Config.ChosenStyle)?.Push(); + { + var style = StyleModel.GetConfiguredStyles()?.FirstOrDefault(s => s.Name == Plugin.Config.ChosenStyle); + if (style != null) + { + style.Push(); + _pushedStyle = style; + } + } } public override void PostDraw() @@ -532,8 +556,11 @@ public sealed class ChatLogWindow : Window if (Plugin.CurrentTab.InputDisabled) Activate = false; - if (Plugin.Config is { OverrideStyle: true, ChosenStyle: not null }) - StyleModel.GetConfiguredStyles()?.FirstOrDefault(style => style.Name == Plugin.Config.ChosenStyle)?.Pop(); + if (_pushedStyle != null) + { + _pushedStyle.Pop(); + _pushedStyle = null; + } } public override void OnClose() @@ -597,10 +624,11 @@ public sealed class ChatLogWindow : Window Plugin.InputPreview.CalculatePreview(); // Hellion Chat v0.6.1 — render the one-time hint banner first so it - // sits above the tab area / sidebar in full window width. Stash the - // height for GetRemainingHeightForMessageLog so the message log - // shrinks accordingly while the banner is visible. - _v061HintBannerHeight = DrawV061HintBannerIfNeeded(); + // sits above the tab area / sidebar in full window width. ImGui's + // GetContentRegionAvail subtracts its height automatically because the + // cursor advances past it before the message log calls + // GetRemainingHeightForMessageLog, so we don't track the height here. + DrawV061HintBannerIfNeeded(); if (Plugin.Config.SidebarTabView) DrawTabSidebar(); @@ -1540,11 +1568,14 @@ public sealed class ChatLogWindow : Window var startY = ImGui.GetCursorPosY(); var bg = new System.Numerics.Vector4(0.16f, 0.20f, 0.28f, 1f); - ImGui.PushStyleColor(ImGuiCol.ChildBg, bg); - ImGui.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 1f); - var dismiss = false; var openSettings = false; + // RAII for the style stack so an early return in this block + // (or a later refactor that introduces one) can never leave the + // ImGui style stack unbalanced. Matches the convention used + // elsewhere in this file. + using (ImRaii.PushColor(ImGuiCol.ChildBg, bg)) + using (ImRaii.PushStyle(ImGuiStyleVar.FrameBorderSize, 1f)) using (var child = ImRaii.Child("##v061-pop-out-header-hint", new System.Numerics.Vector2(0f, 84f), true)) { if (child) @@ -1561,8 +1592,6 @@ public sealed class ChatLogWindow : Window } } - ImGui.PopStyleVar(); - ImGui.PopStyleColor(); ImGui.Spacing(); if (dismiss) @@ -1636,13 +1665,6 @@ public sealed class ChatLogWindow : Window internal readonly List PopOutDocked = []; internal readonly HashSet PopOutWindows = []; - // Hellion Chat v0.6.1 — height the v0.6.1 hint banner consumed in the - // current frame, read by GetRemainingHeightForMessageLog so the message - // log can shrink. Unconditionally reassigned at the top of DrawChatLog - // (before any tab-area render) so the value is always in sync with the - // current frame. Returns 0 once the banner is dismissed. - private float _v061HintBannerHeight; - // v0.6.0 — live enumeration of all active Popout windows so the // KeybindManager can find a focused ChatInputBar to forward tab-cycle // keybinds to. Filter on IsOpen prevents touching closed-but-still- @@ -1745,47 +1767,55 @@ public sealed class ChatLogWindow : Window return; var clipper = new ImGuiListClipperPtr(ImGuiNative.ImGuiListClipper()); - - clipper.Begin(AutoCompleteList.Count); - while (clipper.Step()) + try { - for (var i = clipper.DisplayStart; i < clipper.DisplayEnd; i++) + clipper.Begin(AutoCompleteList.Count); + while (clipper.Step()) { - var entry = AutoCompleteList[i]; - - var highlight = AutoCompleteSelection == i; - var clicked = ImGui.Selectable($"{entry.Text}##{entry.Group}/{entry.Row}", highlight) || selected == i; - if (i < 10) + for (var i = clipper.DisplayStart; i < clipper.DisplayEnd; i++) { - var button = (i + 1) % 10; - var text = string.Format(Language.AutoTranslate_Completion_Key, button); - var size = ImGui.CalcTextSize(text); + var entry = AutoCompleteList[i]; - ImGui.SameLine(ImGui.GetContentRegionAvail().X - size.X); + var highlight = AutoCompleteSelection == i; + var clicked = ImGui.Selectable($"{entry.Text}##{entry.Group}/{entry.Row}", highlight) || selected == i; + if (i < 10) + { + var button = (i + 1) % 10; + var text = string.Format(Language.AutoTranslate_Completion_Key, button); + var size = ImGui.CalcTextSize(text); - using (ImRaii.PushColor(ImGuiCol.Text, ImGui.GetStyle().Colors[(int)ImGuiCol.TextDisabled])) - ImGui.TextUnformatted(text); + ImGui.SameLine(ImGui.GetContentRegionAvail().X - size.X); + + using (ImRaii.PushColor(ImGuiCol.Text, ImGui.GetStyle().Colors[(int)ImGuiCol.TextDisabled])) + ImGui.TextUnformatted(text); + } + + if (!clicked) + continue; + + var before = Chat[..AutoCompleteInfo.StartPos]; + var after = Chat[AutoCompleteInfo.EndPos..]; + var replacement = $""; + Chat = $"{before}{replacement}{after}"; + ImGui.CloseCurrentPopup(); + Activate = true; + ActivatePos = AutoCompleteInfo.StartPos + replacement.Length; } - - if (!clicked) - continue; - - var before = Chat[..AutoCompleteInfo.StartPos]; - var after = Chat[AutoCompleteInfo.EndPos..]; - var replacement = $""; - Chat = $"{before}{replacement}{after}"; - ImGui.CloseCurrentPopup(); - Activate = true; - ActivatePos = AutoCompleteInfo.StartPos + replacement.Length; } + + if (!AutoCompleteShouldScroll) + return; + + AutoCompleteShouldScroll = false; + var selectedPos = clipper.StartPosY + clipper.ItemsHeight * (AutoCompleteSelection * 1f); + ImGui.SetScrollFromPosY(selectedPos - ImGui.GetWindowPos().Y); + } + finally + { + // ImGuiListClipperPtr wraps an unmanaged ImGuiListClipper allocated above. + // Without Destroy() the unmanaged block leaks per autocomplete render. + clipper.Destroy(); } - - if (!AutoCompleteShouldScroll) - return; - - AutoCompleteShouldScroll = false; - var selectedPos = clipper.StartPosY + clipper.ItemsHeight * (AutoCompleteSelection * 1f); - ImGui.SetScrollFromPosY(selectedPos - ImGui.GetWindowPos().Y); } private int AutoCompleteCallback(scoped ref ImGuiInputTextCallbackData data) diff --git a/HellionChat/Ui/CommandHelpWindow.cs b/HellionChat/Ui/CommandHelpWindow.cs index 00e6fb8..d0f1b84 100644 --- a/HellionChat/Ui/CommandHelpWindow.cs +++ b/HellionChat/Ui/CommandHelpWindow.cs @@ -47,8 +47,11 @@ public class CommandHelpWindow : Window { Position = pos; SizeConstraints = new WindowSizeConstraints { - MinimumSize = new Vector2(width, 0), - MaximumSize = LogWindow.LastWindowSize with { X = width } + // Use scaledWidth here so the size constraints stay in the same + // coordinate space as Position above; otherwise the help window + // ends up the wrong width at non-100% DPI. + MinimumSize = new Vector2(scaledWidth, 0), + MaximumSize = LogWindow.LastWindowSize with { X = scaledWidth } }; IsOpen = true; diff --git a/HellionChat/Ui/InputPreview.cs b/HellionChat/Ui/InputPreview.cs index 8e7589b..1a95e4c 100644 --- a/HellionChat/Ui/InputPreview.cs +++ b/HellionChat/Ui/InputPreview.cs @@ -177,7 +177,10 @@ public partial class InputPreview : Window return; NextChunkIsAutoTranslate = true; - var payload = (AutoTranslatePayload) chunk.Link!; + // Malformed chunks could carry an AutoTranslateBegin icon without the matching + // payload; bail out instead of dereferencing a null Link. + if (chunk.Link is not AutoTranslatePayload payload) + return; CursorPosition += $"".Length; return; diff --git a/HellionChat/Ui/Popout.cs b/HellionChat/Ui/Popout.cs index 01f3c5d..a3944ff 100644 --- a/HellionChat/Ui/Popout.cs +++ b/HellionChat/Ui/Popout.cs @@ -65,10 +65,22 @@ internal class Popout : Window return FrameTime - lastActivityTime <= 1000 * Plugin.Config.InactivityHideTimeout; } + // Tracks the style instance pushed in PreDraw so PostDraw pops the same + // one even if config changes mid-frame. See AUDIT-2026-05-05 [CR-UI-5]. + private StyleModel? _pushedStyle; + public override void PreDraw() { + _pushedStyle = null; if (Plugin.Config is { OverrideStyle: true, ChosenStyle: not null }) - StyleModel.GetConfiguredStyles()?.FirstOrDefault(style => style.Name == Plugin.Config.ChosenStyle)?.Push(); + { + var style = StyleModel.GetConfiguredStyles()?.FirstOrDefault(s => s.Name == Plugin.Config.ChosenStyle); + if (style != null) + { + style.Push(); + _pushedStyle = style; + } + } Flags = ImGuiWindowFlags.None; if (!Plugin.Config.ShowPopOutTitleBar) @@ -201,8 +213,11 @@ internal class Popout : Window if (Idx >= 0 && Idx < ChatLogWindow.PopOutDocked.Count) ChatLogWindow.PopOutDocked[Idx] = ImGui.IsWindowDocked(); - if (Plugin.Config is { OverrideStyle: true, ChosenStyle: not null }) - StyleModel.GetConfiguredStyles()?.FirstOrDefault(style => style.Name == Plugin.Config.ChosenStyle)?.Pop(); + if (_pushedStyle != null) + { + _pushedStyle.Pop(); + _pushedStyle = null; + } } public override void OnClose() diff --git a/HellionChat/Ui/SeStringDebugger.cs b/HellionChat/Ui/SeStringDebugger.cs index 36d2d1d..465d016 100644 --- a/HellionChat/Ui/SeStringDebugger.cs +++ b/HellionChat/Ui/SeStringDebugger.cs @@ -222,7 +222,16 @@ public class SeStringDebugger : Window default: var payloadData = payload.Encode(); - var initialByte = payloadData.First(); + if (payloadData.Length == 0) + { + RenderMetadataDictionary("Empty Payload", new Dictionary + { + { "Type", payload.GetType().Name }, + }); + break; + } + + var initialByte = payloadData[0]; if (initialByte != 0x02) { RenderMetadataDictionary("Text Payload", new Dictionary diff --git a/HellionChat/Ui/SettingsTabs/Chat.cs b/HellionChat/Ui/SettingsTabs/Chat.cs index be246b0..bb43339 100644 --- a/HellionChat/Ui/SettingsTabs/Chat.cs +++ b/HellionChat/Ui/SettingsTabs/Chat.cs @@ -21,6 +21,10 @@ internal sealed class Chat : ISettingsTab public string Name => HellionStrings.Settings_Tab_Chat + "###tabs-chat"; private SearchSelector.SelectorPopupOptions WordPopupOptions; + // Snapshot of EmoteCache.State for which we last built WordPopupOptions. + // Without this, an empty FilteredSheet (e.g., the user blocked every emote) + // would trigger a refill every frame the settings tab is open. + private EmoteCache.LoadingState? WordPopupOptionsBuiltFor; internal Chat(Plugin plugin, Configuration mutable) { @@ -28,6 +32,7 @@ internal sealed class Chat : ISettingsTab Mutable = mutable; WordPopupOptions = RefillSheet(); + WordPopupOptionsBuiltFor = EmoteCache.State; } private SearchSelector.SelectorPopupOptions RefillSheet() @@ -160,9 +165,12 @@ internal sealed class Chat : ISettingsTab ImGui.TextUnformatted(Language.Options_Emote_BlockedEmotes); ImGui.Spacing(); - if (EmoteCache.State is EmoteCache.LoadingState.Done && WordPopupOptions.FilteredSheet.Length == 0) + if (EmoteCache.State is EmoteCache.LoadingState.Done + && WordPopupOptions.FilteredSheet.Length == 0 + && WordPopupOptionsBuiltFor != EmoteCache.LoadingState.Done) { WordPopupOptions = RefillSheet(); + WordPopupOptionsBuiltFor = EmoteCache.LoadingState.Done; } var buttonWidth = ImGui.GetContentRegionAvail().X / 3; diff --git a/HellionChat/Ui/SettingsTabs/Database.cs b/HellionChat/Ui/SettingsTabs/Database.cs index 0295456..fa93818 100755 --- a/HellionChat/Ui/SettingsTabs/Database.cs +++ b/HellionChat/Ui/SettingsTabs/Database.cs @@ -81,9 +81,11 @@ internal sealed class Database : ISettingsTab { try { + // Delete both legacy files in one click — the previous if/else + // left the second file behind when both happened to exist. if (old.Exists) old.Delete(); - else + if (migratedOld.Exists) migratedOld.Delete(); WrapperUtil.AddNotification(Language.Options_Database_Old_Delete_Success, NotificationType.Success); } diff --git a/HellionChat/Ui/SettingsTabs/Privacy.cs b/HellionChat/Ui/SettingsTabs/Privacy.cs index 4a0f2c0..071155e 100644 --- a/HellionChat/Ui/SettingsTabs/Privacy.cs +++ b/HellionChat/Ui/SettingsTabs/Privacy.cs @@ -615,7 +615,7 @@ internal sealed class Privacy : ISettingsTab CleanupRunning = true; var allowed = Plugin.Config.PrivacyPersistChannels.Select(t => (int)(ushort)t).ToList(); - new Thread(() => + var thread = new Thread(() => { try { @@ -625,10 +625,14 @@ internal sealed class Privacy : ISettingsTab // Bound the wait so a hung framework tick can't deadlock // the background cleanup worker. See the matching comment in // the retention path above for rationale. + // Note: FilterAllTabs() is called synchronously instead of + // FilterAllTabsAsync() — the async variant fires-and-forgets + // a Task.Run, so the .Wait() would return before the filter + // pass actually finishes. See AUDIT-2026-05-05 [QUAL-02]. if (!Plugin.Framework.Run(() => { Plugin.MessageManager.ClearAllTabs(); - Plugin.MessageManager.FilterAllTabsAsync(); + Plugin.MessageManager.FilterAllTabs(); }).Wait(TimeSpan.FromSeconds(5))) { Plugin.Log.Warning("Privacy cleanup: framework refresh timed out after 5s."); @@ -646,6 +650,9 @@ internal sealed class Privacy : ISettingsTab CleanupRunning = false; CleanupCounts = null; } - }).Start(); + }); + // Background thread so a still-running cleanup doesn't hold up FFXIV exit. + thread.IsBackground = true; + thread.Start(); } } diff --git a/HellionChat/Util/AutoTranslate.cs b/HellionChat/Util/AutoTranslate.cs index d3532d1..0728cb6 100644 --- a/HellionChat/Util/AutoTranslate.cs +++ b/HellionChat/Util/AutoTranslate.cs @@ -1,6 +1,5 @@ using System.Diagnostics; using System.Globalization; -using System.Runtime.InteropServices; using System.Text; using Dalamud.Game; using Dalamud.Utility; @@ -233,9 +232,6 @@ internal static class AutoTranslate .ToList(); } - [DllImport("msvcrt.dll", CallingConvention = CallingConvention.Cdecl)] - private static extern int memcmp(byte[] b1, byte[] b2, nuint count); - internal static void ReplaceWithPayload(ref byte[] bytes) { var search = " entries.Length) + break; // cycle guard + iconId = entry.Redirect; + continue; + } + return !entry.IsEmpty; } @@ -146,12 +158,17 @@ public readonly unsafe ref struct GfdFileView internal static class IconUtil { private static byte[]? GfdFile; - public static unsafe GfdFileView GfdFileView + public static GfdFileView GfdFileView { get { - GfdFile ??= Plugin.DataManager.GetFile("common/font/gfdata.gfd")!.Data; - return new GfdFileView(new ReadOnlySpan(Unsafe.AsPointer(ref GfdFile[0]), GfdFile.Length)); + if (GfdFile is null) + { + var file = Plugin.DataManager.GetFile("common/font/gfdata.gfd") + ?? throw new FileNotFoundException("Failed to load common/font/gfdata.gfd from the game data."); + GfdFile = file.Data; + } + return new GfdFileView(GfdFile); } } diff --git a/HellionChat/Util/Lender.cs b/HellionChat/Util/Lender.cs index 29132d7..2d49bcd 100755 --- a/HellionChat/Util/Lender.cs +++ b/HellionChat/Util/Lender.cs @@ -8,7 +8,7 @@ internal class Lender internal Lender(Func ctor) { - Ctor = ctor; + Ctor = ctor ?? throw new ArgumentNullException(nameof(ctor)); } internal void ResetCounter() diff --git a/HellionChat/Util/MemoryUtil.cs b/HellionChat/Util/MemoryUtil.cs index b65bab9..417496d 100644 --- a/HellionChat/Util/MemoryUtil.cs +++ b/HellionChat/Util/MemoryUtil.cs @@ -4,8 +4,21 @@ namespace HellionChat.Util; public static class MemoryUtil { + // Diagnostic helper. Pointer dereferences here would crash on a null/garbage + // address and a huge length would log megabytes of raw bytes; both are easy + // to trigger from a debugger and pollute the log with potentially sensitive + // game-state. Validate the inputs before reading. + private const int MaxDumpLength = 4096; + public static unsafe void PrintMemoryArea(nint address, int length) { + if (address == nint.Zero) + throw new ArgumentException("Memory address cannot be zero.", nameof(address)); + if (length <= 0) + throw new ArgumentOutOfRangeException(nameof(length), length, "Length must be positive."); + if (length > MaxDumpLength) + throw new ArgumentOutOfRangeException(nameof(length), length, $"Length exceeds the {MaxDumpLength}-byte safety cap."); + var ptr = (byte*)address; var str = new StringBuilder("\n"); for(var i = 0; i < length; i++) diff --git a/HellionChat/Util/Payloads.cs b/HellionChat/Util/Payloads.cs index 62223a9..b225121 100755 --- a/HellionChat/Util/Payloads.cs +++ b/HellionChat/Util/Payloads.cs @@ -66,6 +66,8 @@ internal class UriPayload(Uri uri) : Payload public static UriPayload ResolveUri(string rawUri) { ArgumentNullException.ThrowIfNull(rawUri); + if (string.IsNullOrWhiteSpace(rawUri)) + throw new UriFormatException("URI cannot be empty or whitespace."); // Check for an expected scheme '://', if not add 'https://' if (ExpectedSchemes.Any(scheme => rawUri.StartsWith($"{scheme}://"))) diff --git a/HellionChat/Util/StringUtil.cs b/HellionChat/Util/StringUtil.cs index 6072be4..e812e13 100755 --- a/HellionChat/Util/StringUtil.cs +++ b/HellionChat/Util/StringUtil.cs @@ -23,6 +23,8 @@ internal static class StringUtil var bytes = Math.Abs(byteCount); var place = Convert.ToInt32(Math.Floor(Math.Log(bytes, 1024))); var num = Math.Round(bytes / Math.Pow(1024, place), 1); - return (Math.Sign(byteCount) * num).ToString("N0") + suf[place]; + // "0.#" keeps the rounded fractional digit (1.5 GB stays "1.5GB"); "N0" + // would truncate it back to integer. + return (Math.Sign(byteCount) * num).ToString("0.#") + suf[place]; } } diff --git a/PRIVACY.md b/PRIVACY.md index a000250..7df5354 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -12,7 +12,7 @@ because no data ever leaves your machine on the maintainer's infrastructure. Independently of that, the plugin is built so that you can act on your own data the way the GDPR expects. -Last reviewed: 2026-05-03 (HellionChat v0.5.4). +Last reviewed: 2026-05-05 (HellionChat v1.0.3). --- @@ -103,8 +103,17 @@ on your behalf. reaches BetterTTV (unavoidable for any HTTPS request); the request itself contains no identifying user data, no character name, no message text. Only the emote ID being looked up is in the URL path. -- **When it triggers:** Only when an incoming message contains an - emote token that is on the BetterTTV emote list. +- **When it triggers:** + - The emote *list* (global emotes plus the top-1500 community emotes + over fifteen API pages) is fetched from `api.betterttv.net` once + per session at plugin startup, provided the **Show emotes** option + is on. This first list-fetch happens before any chat message has + arrived; BetterTTV's edge therefore sees your IP as soon as the + plugin loads, not only after an emote is mentioned. + - The individual emote *images* on `cdn.betterttv.net` are fetched + on demand, only when an incoming chat message contains a token + matching one of the cached IDs. These are cached locally + (`emoteCache/`) and reused across sessions. - **Cached:** Yes, in `emoteCache/`. A given emote is downloaded once per machine and reused. - **How to opt out:** Turn off the **Show emotes** option in diff --git a/docs/THIRD_PARTY_NOTICES.md b/docs/THIRD_PARTY_NOTICES.md index 825be25..400eb3a 100644 --- a/docs/THIRD_PARTY_NOTICES.md +++ b/docs/THIRD_PARTY_NOTICES.md @@ -4,21 +4,22 @@ HellionChat ships and depends on a number of third-party components. This document lists them, their licences and which of them touch the network. It is the inventory referenced by `PRIVACY.md`. -Last reviewed: 2026-05-03 (HellionChat v0.5.4). +Last reviewed: 2026-05-05 (HellionChat v1.0.3). --- ## Direct NuGet dependencies -Pinned in `HellionChat/HellionChat.csproj`. Versions reflect the v1.0.0 build. +Pinned in `HellionChat/HellionChat.csproj`. Versions reflect the v1.0.3 build. | Package | Version | Licence | Network | Purpose | | --- | --- | --- | --- | --- | | [MessagePack](https://github.com/MessagePack-CSharp/MessagePack-CSharp) | 3.1.4 | MIT | no | Binary serialisation for the SQLite message store. | | [Microsoft.Data.Sqlite](https://learn.microsoft.com/dotnet/standard/data/sqlite/) | 10.0.7 | MIT | no | Local SQLite access for the message database. | | [morelinq](https://github.com/morelinq/MoreLINQ) | 4.4.0 | Apache-2.0 | no | LINQ helper extensions. | -| [Pidgin](https://github.com/benjamin-hodgson/Pidgin) | 3.3.0 | MIT | no | Parser combinator library used for chat-input parsing. | +| [Pidgin](https://github.com/benjamin-hodgson/Pidgin) | 3.5.1 | MIT | no | Parser combinator library used for chat-input parsing. CIString Unicode fix relevant for non-ASCII channel/tab names. | | [SixLabors.ImageSharp](https://github.com/SixLabors/ImageSharp) | 3.1.12 | [Six Labors Split License 1.0](https://github.com/SixLabors/ImageSharp/blob/main/LICENSE) (OSI-approved; free for open-source / non-commercial use, commercial licence required for closed-source commercial use) | no | Image decoding for cached emotes. | +| [SQLitePCLRaw.lib.e_sqlite3](https://github.com/ericsink/SQLitePCL.raw) | 3.50.3 | MIT | no | Native SQLite binary, explicitly pinned to override the transitive default for CVE-2025-6965 (memory corruption from aggregate-term overflow) and CVE-2025-7709. | Six Labors note: HellionChat is an EUPL-1.2-licensed open-source project distributed at no cost. Use of ImageSharp 3.x under the