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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<string>(other.BlockedEmotes);
|
||||
FontsEnabled = other.FontsEnabled;
|
||||
ItalicEnabled = other.ItalicEnabled;
|
||||
ExtraGlyphRanges = other.ExtraGlyphRanges;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -20,10 +20,14 @@ public sealed class ExtraChat : IDisposable
|
||||
|
||||
internal (string, uint)? ChannelOverride { get; set; }
|
||||
|
||||
private Dictionary<string, uint> 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<string, uint> ChannelCommandColoursInternal = new();
|
||||
internal IReadOnlyDictionary<string, uint> ChannelCommandColours => ChannelCommandColoursInternal;
|
||||
|
||||
private Dictionary<Guid, string> ChannelNamesInternal { get; set; } = new();
|
||||
private volatile Dictionary<Guid, string> ChannelNamesInternal = new();
|
||||
internal IReadOnlyDictionary<Guid, string> 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?)");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<bool> PopOutDocked = [];
|
||||
internal readonly HashSet<Guid> 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,7 +1767,8 @@ public sealed class ChatLogWindow : Window
|
||||
return;
|
||||
|
||||
var clipper = new ImGuiListClipperPtr(ImGuiNative.ImGuiListClipper());
|
||||
|
||||
try
|
||||
{
|
||||
clipper.Begin(AutoCompleteList.Count);
|
||||
while (clipper.Step())
|
||||
{
|
||||
@@ -1787,6 +1810,13 @@ public sealed class ChatLogWindow : Window
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
private int AutoCompleteCallback(scoped ref ImGuiInputTextCallbackData data)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 += $"<at:{payload.Group},{payload.Key}>".Length;
|
||||
|
||||
return;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<string, string?>
|
||||
{
|
||||
{ "Type", payload.GetType().Name },
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
var initialByte = payloadData[0];
|
||||
if (initialByte != 0x02)
|
||||
{
|
||||
RenderMetadataDictionary("Text Payload", new Dictionary<string, string?>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = "<at:"u8.ToArray();
|
||||
@@ -279,7 +275,10 @@ internal static class AutoTranslate
|
||||
start = -1;
|
||||
}
|
||||
|
||||
if (i + search.Length < bytes.Length && memcmp(bytes[i..], search, (nuint) search.Length) == 0)
|
||||
// Pure managed comparison via Span avoids the msvcrt.dll P/Invoke,
|
||||
// which is fragile under Wine and triggered an extra managed-to-
|
||||
// unmanaged copy per check.
|
||||
if (i + search.Length < bytes.Length && bytes.AsSpan(i, search.Length).SequenceEqual(search))
|
||||
start = i;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ public class ColorPayload
|
||||
return payload;
|
||||
case 0xE9:
|
||||
var param = stream.ReadByte();
|
||||
if (param == -1)
|
||||
throw new ArgumentException("Encountered premature end of input (unexpected EOF).", nameof(stream));
|
||||
var globalValue = (uint) GlobalParametersCache.GetValue(param - 2);
|
||||
payload.Enabled = true;
|
||||
payload.UnshiftedColor = globalValue;
|
||||
|
||||
@@ -49,9 +49,21 @@ public readonly unsafe ref struct GfdFileView
|
||||
var entries = Entries;
|
||||
if (DirectLookup)
|
||||
{
|
||||
if (iconId <= entries.Length)
|
||||
// Resolve redirects on the direct-lookup path too — the binary-search
|
||||
// path follows them, and skipping them here was inconsistent for
|
||||
// contiguous ID sets.
|
||||
var visited = 0;
|
||||
while (iconId <= entries.Length)
|
||||
{
|
||||
entry = entries[(int)(iconId - 1)];
|
||||
if (followRedirect && entry.Redirect != 0 && entry.Redirect != iconId)
|
||||
{
|
||||
if (++visited > 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<byte>(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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ internal class Lender<T>
|
||||
|
||||
internal Lender(Func<T> ctor)
|
||||
{
|
||||
Ctor = ctor;
|
||||
Ctor = ctor ?? throw new ArgumentNullException(nameof(ctor));
|
||||
}
|
||||
|
||||
internal void ResetCounter()
|
||||
|
||||
@@ -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++)
|
||||
|
||||
@@ -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}://")))
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
+12
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user