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
|
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
|
Original ChatTwo authors and copyright holders of the upstream
|
||||||
plugin this fork is built on. Their work covers the message store,
|
plugin this fork is built on. Their work covers the message store,
|
||||||
the channel filtering, the sidebar tab system, the FFXIV chat
|
the channel filtering, the sidebar tab system, the FFXIV chat
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ public class ConfigKeyBind
|
|||||||
[Serializable]
|
[Serializable]
|
||||||
public class Configuration : IPluginConfiguration
|
public class Configuration : IPluginConfiguration
|
||||||
{
|
{
|
||||||
private const int LatestVersion = 12;
|
private const int LatestVersion = 13;
|
||||||
|
|
||||||
public int Version { get; set; } = LatestVersion;
|
public int Version { get; set; } = LatestVersion;
|
||||||
|
|
||||||
@@ -279,7 +279,10 @@ public class Configuration : IPluginConfiguration
|
|||||||
MaxLinesToRender = other.MaxLinesToRender;
|
MaxLinesToRender = other.MaxLinesToRender;
|
||||||
Use24HourClock = other.Use24HourClock;
|
Use24HourClock = other.Use24HourClock;
|
||||||
ShowEmotes = other.ShowEmotes;
|
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;
|
FontsEnabled = other.FontsEnabled;
|
||||||
ItalicEnabled = other.ItalicEnabled;
|
ItalicEnabled = other.ItalicEnabled;
|
||||||
ExtraGlyphRanges = other.ExtraGlyphRanges;
|
ExtraGlyphRanges = other.ExtraGlyphRanges;
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ internal sealed unsafe class Chat : IDisposable
|
|||||||
{
|
{
|
||||||
playerName = SeString.Parse(agent->TellPlayerName).TextValue;
|
playerName = SeString.Parse(agent->TellPlayerName).TextValue;
|
||||||
worldId = agent->TellWorldId;
|
worldId = agent->TellWorldId;
|
||||||
Plugin.Log.Debug($"Detected tell target '{playerName}'@{worldId}");
|
Plugin.Log.Debug($"Detected tell target '[redacted]'@{worldId}");
|
||||||
}
|
}
|
||||||
|
|
||||||
Plugin.CurrentTab.CurrentChannel = new UsedChannel
|
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);
|
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:
|
default:
|
||||||
return channel;
|
return channel;
|
||||||
|
|||||||
@@ -245,7 +245,8 @@ internal unsafe class GameFunctions : IDisposable
|
|||||||
vf0(agent, &result, &value, 0, 0);
|
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 readonly string Placeholder = $"<{Guid.NewGuid():N}>";
|
||||||
private string? ReplacementName;
|
private string? ReplacementName;
|
||||||
|
|
||||||
@@ -261,6 +262,17 @@ internal unsafe class GameFunctions : IDisposable
|
|||||||
if (ReplacementName == null || placeholder != Placeholder)
|
if (ReplacementName == null || placeholder != Placeholder)
|
||||||
return ResolveTextCommandPlaceholderHook.Original(a1, placeholderText, a3, a4);
|
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);
|
MemoryHelper.WriteString(PlaceholderNamePtr, ReplacementName);
|
||||||
ReplacementName = null;
|
ReplacementName = null;
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ public class TellTarget
|
|||||||
}
|
}
|
||||||
|
|
||||||
public bool IsSet()
|
public bool IsSet()
|
||||||
=> Name.Length > 0 && World > 0;
|
=> !string.IsNullOrEmpty(Name) && World > 0;
|
||||||
|
|
||||||
public string ToWorldString()
|
public string ToWorldString()
|
||||||
=> Sheets.WorldSheet.TryGetRow(World, out var worldRow) ? worldRow.Name.ToString() : string.Empty;
|
=> 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; }
|
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;
|
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 IReadOnlyDictionary<Guid, string> ChannelNames => ChannelNamesInternal;
|
||||||
|
|
||||||
internal ExtraChat()
|
internal ExtraChat()
|
||||||
@@ -40,9 +44,10 @@ public sealed class ExtraChat : IDisposable
|
|||||||
ChannelCommandColoursInternal = ChannelCommandColoursGate.InvokeFunc(null!);
|
ChannelCommandColoursInternal = ChannelCommandColoursGate.InvokeFunc(null!);
|
||||||
ChannelNamesInternal = ChannelNamesGate.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");
|
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();
|
Store.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -529,10 +529,15 @@ public sealed class Plugin : IDalamudPlugin
|
|||||||
if (deleted > 0)
|
if (deleted > 0)
|
||||||
{
|
{
|
||||||
Log.Information($"Retention sweep deleted {deleted} expired messages.");
|
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(() =>
|
Framework.Run(() =>
|
||||||
{
|
{
|
||||||
MessageManager.ClearAllTabs();
|
MessageManager.ClearAllTabs();
|
||||||
MessageManager.FilterAllTabsAsync();
|
MessageManager.FilterAllTabs();
|
||||||
}).Wait();
|
}).Wait();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ public sealed class ChatInputBar
|
|||||||
// window's logic but operates on _state.HistoryCursor and the shared
|
// window's logic but operates on _state.HistoryCursor and the shared
|
||||||
// InputHistoryService. Index semantics match v0.5.x InputBacklog:
|
// InputHistoryService. Index semantics match v0.5.x InputBacklog:
|
||||||
// 0 = oldest, Count-1 = newest.
|
// 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)
|
if (data.EventFlag != ImGuiInputTextFlags.CallbackHistory)
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ public sealed class ChatLogWindow : Window
|
|||||||
|
|
||||||
internal Plugin Plugin { get; }
|
internal Plugin Plugin { get; }
|
||||||
|
|
||||||
|
private readonly CommandWrapper _clearHellionCommand;
|
||||||
|
private readonly CommandWrapper _hellionCommand;
|
||||||
|
|
||||||
internal bool ScreenshotMode;
|
internal bool ScreenshotMode;
|
||||||
private string Salt { get; }
|
private string Salt { get; }
|
||||||
|
|
||||||
@@ -110,8 +113,14 @@ public sealed class ChatLogWindow : Window
|
|||||||
SetUpTextCommandChannels();
|
SetUpTextCommandChannels();
|
||||||
SetUpAllCommands();
|
SetUpAllCommands();
|
||||||
|
|
||||||
Plugin.Commands.Register("/clearhellion", "Clear the Hellion Chat log").Execute += ClearLog;
|
// Cache the registered wrapper instances so Dispose can detach the same
|
||||||
Plugin.Commands.Register("/hellion").Execute += ToggleChat;
|
// 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.Login += Login;
|
||||||
Plugin.ClientState.Logout += Logout;
|
Plugin.ClientState.Logout += Logout;
|
||||||
@@ -126,8 +135,8 @@ public sealed class ChatLogWindow : Window
|
|||||||
Plugin.AddonLifecycle.UnregisterListener(AddonEvent.PostUpdate, "ActionDetail", PayloadHandler.MoveTooltip);
|
Plugin.AddonLifecycle.UnregisterListener(AddonEvent.PostUpdate, "ActionDetail", PayloadHandler.MoveTooltip);
|
||||||
Plugin.ClientState.Logout -= Logout;
|
Plugin.ClientState.Logout -= Logout;
|
||||||
Plugin.ClientState.Login -= Login;
|
Plugin.ClientState.Login -= Login;
|
||||||
Plugin.Commands.Register("/hellion").Execute -= ToggleChat;
|
_hellionCommand.Execute -= ToggleChat;
|
||||||
Plugin.Commands.Register("/clearhellion").Execute -= ClearLog;
|
_clearHellionCommand.Execute -= ClearLog;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Logout(int _, int __)
|
private void Logout(int _, int __)
|
||||||
@@ -514,13 +523,28 @@ public sealed class ChatLogWindow : Window
|
|||||||
return FrameTime - lastActivityTime <= 1000 * Plugin.Config.InactivityHideTimeout;
|
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()
|
public override void PreDraw()
|
||||||
{
|
{
|
||||||
if (Plugin.Config.KeepInputFocus && Activate)
|
if (Plugin.Config.KeepInputFocus && Activate)
|
||||||
ImGui.SetWindowFocus(WindowName);
|
ImGui.SetWindowFocus(WindowName);
|
||||||
|
|
||||||
|
_pushedStyle = null;
|
||||||
if (Plugin.Config is { OverrideStyle: true, ChosenStyle: not 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()
|
public override void PostDraw()
|
||||||
@@ -532,8 +556,11 @@ public sealed class ChatLogWindow : Window
|
|||||||
if (Plugin.CurrentTab.InputDisabled)
|
if (Plugin.CurrentTab.InputDisabled)
|
||||||
Activate = false;
|
Activate = false;
|
||||||
|
|
||||||
if (Plugin.Config is { OverrideStyle: true, ChosenStyle: not null })
|
if (_pushedStyle != null)
|
||||||
StyleModel.GetConfiguredStyles()?.FirstOrDefault(style => style.Name == Plugin.Config.ChosenStyle)?.Pop();
|
{
|
||||||
|
_pushedStyle.Pop();
|
||||||
|
_pushedStyle = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void OnClose()
|
public override void OnClose()
|
||||||
@@ -597,10 +624,11 @@ public sealed class ChatLogWindow : Window
|
|||||||
Plugin.InputPreview.CalculatePreview();
|
Plugin.InputPreview.CalculatePreview();
|
||||||
|
|
||||||
// Hellion Chat v0.6.1 — render the one-time hint banner first so it
|
// 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
|
// sits above the tab area / sidebar in full window width. ImGui's
|
||||||
// height for GetRemainingHeightForMessageLog so the message log
|
// GetContentRegionAvail subtracts its height automatically because the
|
||||||
// shrinks accordingly while the banner is visible.
|
// cursor advances past it before the message log calls
|
||||||
_v061HintBannerHeight = DrawV061HintBannerIfNeeded();
|
// GetRemainingHeightForMessageLog, so we don't track the height here.
|
||||||
|
DrawV061HintBannerIfNeeded();
|
||||||
|
|
||||||
if (Plugin.Config.SidebarTabView)
|
if (Plugin.Config.SidebarTabView)
|
||||||
DrawTabSidebar();
|
DrawTabSidebar();
|
||||||
@@ -1540,11 +1568,14 @@ public sealed class ChatLogWindow : Window
|
|||||||
var startY = ImGui.GetCursorPosY();
|
var startY = ImGui.GetCursorPosY();
|
||||||
|
|
||||||
var bg = new System.Numerics.Vector4(0.16f, 0.20f, 0.28f, 1f);
|
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 dismiss = false;
|
||||||
var openSettings = 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))
|
using (var child = ImRaii.Child("##v061-pop-out-header-hint", new System.Numerics.Vector2(0f, 84f), true))
|
||||||
{
|
{
|
||||||
if (child)
|
if (child)
|
||||||
@@ -1561,8 +1592,6 @@ public sealed class ChatLogWindow : Window
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui.PopStyleVar();
|
|
||||||
ImGui.PopStyleColor();
|
|
||||||
ImGui.Spacing();
|
ImGui.Spacing();
|
||||||
|
|
||||||
if (dismiss)
|
if (dismiss)
|
||||||
@@ -1636,13 +1665,6 @@ public sealed class ChatLogWindow : Window
|
|||||||
internal readonly List<bool> PopOutDocked = [];
|
internal readonly List<bool> PopOutDocked = [];
|
||||||
internal readonly HashSet<Guid> PopOutWindows = [];
|
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
|
// v0.6.0 — live enumeration of all active Popout windows so the
|
||||||
// KeybindManager can find a focused ChatInputBar to forward tab-cycle
|
// KeybindManager can find a focused ChatInputBar to forward tab-cycle
|
||||||
// keybinds to. Filter on IsOpen prevents touching closed-but-still-
|
// keybinds to. Filter on IsOpen prevents touching closed-but-still-
|
||||||
@@ -1745,47 +1767,55 @@ public sealed class ChatLogWindow : Window
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
var clipper = new ImGuiListClipperPtr(ImGuiNative.ImGuiListClipper());
|
var clipper = new ImGuiListClipperPtr(ImGuiNative.ImGuiListClipper());
|
||||||
|
try
|
||||||
clipper.Begin(AutoCompleteList.Count);
|
|
||||||
while (clipper.Step())
|
|
||||||
{
|
{
|
||||||
for (var i = clipper.DisplayStart; i < clipper.DisplayEnd; i++)
|
clipper.Begin(AutoCompleteList.Count);
|
||||||
|
while (clipper.Step())
|
||||||
{
|
{
|
||||||
var entry = AutoCompleteList[i];
|
for (var i = clipper.DisplayStart; i < clipper.DisplayEnd; i++)
|
||||||
|
|
||||||
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 entry = AutoCompleteList[i];
|
||||||
var text = string.Format(Language.AutoTranslate_Completion_Key, button);
|
|
||||||
var size = ImGui.CalcTextSize(text);
|
|
||||||
|
|
||||||
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.SameLine(ImGui.GetContentRegionAvail().X - size.X);
|
||||||
ImGui.TextUnformatted(text);
|
|
||||||
|
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 = $"<at:{entry.Group},{entry.Row}>";
|
||||||
|
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 = $"<at:{entry.Group},{entry.Row}>";
|
|
||||||
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)
|
private int AutoCompleteCallback(scoped ref ImGuiInputTextCallbackData data)
|
||||||
|
|||||||
@@ -47,8 +47,11 @@ public class CommandHelpWindow : Window {
|
|||||||
Position = pos;
|
Position = pos;
|
||||||
SizeConstraints = new WindowSizeConstraints
|
SizeConstraints = new WindowSizeConstraints
|
||||||
{
|
{
|
||||||
MinimumSize = new Vector2(width, 0),
|
// Use scaledWidth here so the size constraints stay in the same
|
||||||
MaximumSize = LogWindow.LastWindowSize with { X = width }
|
// 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;
|
IsOpen = true;
|
||||||
|
|||||||
@@ -177,7 +177,10 @@ public partial class InputPreview : Window
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
NextChunkIsAutoTranslate = true;
|
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;
|
CursorPosition += $"<at:{payload.Group},{payload.Key}>".Length;
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -65,10 +65,22 @@ internal class Popout : Window
|
|||||||
return FrameTime - lastActivityTime <= 1000 * Plugin.Config.InactivityHideTimeout;
|
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()
|
public override void PreDraw()
|
||||||
{
|
{
|
||||||
|
_pushedStyle = null;
|
||||||
if (Plugin.Config is { OverrideStyle: true, ChosenStyle: not 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;
|
Flags = ImGuiWindowFlags.None;
|
||||||
if (!Plugin.Config.ShowPopOutTitleBar)
|
if (!Plugin.Config.ShowPopOutTitleBar)
|
||||||
@@ -201,8 +213,11 @@ internal class Popout : Window
|
|||||||
if (Idx >= 0 && Idx < ChatLogWindow.PopOutDocked.Count)
|
if (Idx >= 0 && Idx < ChatLogWindow.PopOutDocked.Count)
|
||||||
ChatLogWindow.PopOutDocked[Idx] = ImGui.IsWindowDocked();
|
ChatLogWindow.PopOutDocked[Idx] = ImGui.IsWindowDocked();
|
||||||
|
|
||||||
if (Plugin.Config is { OverrideStyle: true, ChosenStyle: not null })
|
if (_pushedStyle != null)
|
||||||
StyleModel.GetConfiguredStyles()?.FirstOrDefault(style => style.Name == Plugin.Config.ChosenStyle)?.Pop();
|
{
|
||||||
|
_pushedStyle.Pop();
|
||||||
|
_pushedStyle = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void OnClose()
|
public override void OnClose()
|
||||||
|
|||||||
@@ -222,7 +222,16 @@ public class SeStringDebugger : Window
|
|||||||
default:
|
default:
|
||||||
var payloadData = payload.Encode();
|
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)
|
if (initialByte != 0x02)
|
||||||
{
|
{
|
||||||
RenderMetadataDictionary("Text Payload", new Dictionary<string, string?>
|
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";
|
public string Name => HellionStrings.Settings_Tab_Chat + "###tabs-chat";
|
||||||
|
|
||||||
private SearchSelector.SelectorPopupOptions WordPopupOptions;
|
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)
|
internal Chat(Plugin plugin, Configuration mutable)
|
||||||
{
|
{
|
||||||
@@ -28,6 +32,7 @@ internal sealed class Chat : ISettingsTab
|
|||||||
Mutable = mutable;
|
Mutable = mutable;
|
||||||
|
|
||||||
WordPopupOptions = RefillSheet();
|
WordPopupOptions = RefillSheet();
|
||||||
|
WordPopupOptionsBuiltFor = EmoteCache.State;
|
||||||
}
|
}
|
||||||
|
|
||||||
private SearchSelector.SelectorPopupOptions RefillSheet()
|
private SearchSelector.SelectorPopupOptions RefillSheet()
|
||||||
@@ -160,9 +165,12 @@ internal sealed class Chat : ISettingsTab
|
|||||||
ImGui.TextUnformatted(Language.Options_Emote_BlockedEmotes);
|
ImGui.TextUnformatted(Language.Options_Emote_BlockedEmotes);
|
||||||
ImGui.Spacing();
|
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();
|
WordPopupOptions = RefillSheet();
|
||||||
|
WordPopupOptionsBuiltFor = EmoteCache.LoadingState.Done;
|
||||||
}
|
}
|
||||||
|
|
||||||
var buttonWidth = ImGui.GetContentRegionAvail().X / 3;
|
var buttonWidth = ImGui.GetContentRegionAvail().X / 3;
|
||||||
|
|||||||
@@ -81,9 +81,11 @@ internal sealed class Database : ISettingsTab
|
|||||||
{
|
{
|
||||||
try
|
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)
|
if (old.Exists)
|
||||||
old.Delete();
|
old.Delete();
|
||||||
else
|
if (migratedOld.Exists)
|
||||||
migratedOld.Delete();
|
migratedOld.Delete();
|
||||||
WrapperUtil.AddNotification(Language.Options_Database_Old_Delete_Success, NotificationType.Success);
|
WrapperUtil.AddNotification(Language.Options_Database_Old_Delete_Success, NotificationType.Success);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -615,7 +615,7 @@ internal sealed class Privacy : ISettingsTab
|
|||||||
CleanupRunning = true;
|
CleanupRunning = true;
|
||||||
var allowed = Plugin.Config.PrivacyPersistChannels.Select(t => (int)(ushort)t).ToList();
|
var allowed = Plugin.Config.PrivacyPersistChannels.Select(t => (int)(ushort)t).ToList();
|
||||||
|
|
||||||
new Thread(() =>
|
var thread = new Thread(() =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -625,10 +625,14 @@ internal sealed class Privacy : ISettingsTab
|
|||||||
// Bound the wait so a hung framework tick can't deadlock
|
// Bound the wait so a hung framework tick can't deadlock
|
||||||
// the background cleanup worker. See the matching comment in
|
// the background cleanup worker. See the matching comment in
|
||||||
// the retention path above for rationale.
|
// 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(() =>
|
if (!Plugin.Framework.Run(() =>
|
||||||
{
|
{
|
||||||
Plugin.MessageManager.ClearAllTabs();
|
Plugin.MessageManager.ClearAllTabs();
|
||||||
Plugin.MessageManager.FilterAllTabsAsync();
|
Plugin.MessageManager.FilterAllTabs();
|
||||||
}).Wait(TimeSpan.FromSeconds(5)))
|
}).Wait(TimeSpan.FromSeconds(5)))
|
||||||
{
|
{
|
||||||
Plugin.Log.Warning("Privacy cleanup: framework refresh timed out after 5s.");
|
Plugin.Log.Warning("Privacy cleanup: framework refresh timed out after 5s.");
|
||||||
@@ -646,6 +650,9 @@ internal sealed class Privacy : ISettingsTab
|
|||||||
CleanupRunning = false;
|
CleanupRunning = false;
|
||||||
CleanupCounts = null;
|
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.Diagnostics;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using Dalamud.Game;
|
using Dalamud.Game;
|
||||||
using Dalamud.Utility;
|
using Dalamud.Utility;
|
||||||
@@ -233,9 +232,6 @@ internal static class AutoTranslate
|
|||||||
.ToList();
|
.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)
|
internal static void ReplaceWithPayload(ref byte[] bytes)
|
||||||
{
|
{
|
||||||
var search = "<at:"u8.ToArray();
|
var search = "<at:"u8.ToArray();
|
||||||
@@ -279,7 +275,10 @@ internal static class AutoTranslate
|
|||||||
start = -1;
|
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;
|
start = i;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ public class ColorPayload
|
|||||||
return payload;
|
return payload;
|
||||||
case 0xE9:
|
case 0xE9:
|
||||||
var param = stream.ReadByte();
|
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);
|
var globalValue = (uint) GlobalParametersCache.GetValue(param - 2);
|
||||||
payload.Enabled = true;
|
payload.Enabled = true;
|
||||||
payload.UnshiftedColor = globalValue;
|
payload.UnshiftedColor = globalValue;
|
||||||
|
|||||||
@@ -49,9 +49,21 @@ public readonly unsafe ref struct GfdFileView
|
|||||||
var entries = Entries;
|
var entries = Entries;
|
||||||
if (DirectLookup)
|
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)];
|
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;
|
return !entry.IsEmpty;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,12 +158,17 @@ public readonly unsafe ref struct GfdFileView
|
|||||||
internal static class IconUtil
|
internal static class IconUtil
|
||||||
{
|
{
|
||||||
private static byte[]? GfdFile;
|
private static byte[]? GfdFile;
|
||||||
public static unsafe GfdFileView GfdFileView
|
public static GfdFileView GfdFileView
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
GfdFile ??= Plugin.DataManager.GetFile("common/font/gfdata.gfd")!.Data;
|
if (GfdFile is null)
|
||||||
return new GfdFileView(new ReadOnlySpan<byte>(Unsafe.AsPointer(ref GfdFile[0]), GfdFile.Length));
|
{
|
||||||
|
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)
|
internal Lender(Func<T> ctor)
|
||||||
{
|
{
|
||||||
Ctor = ctor;
|
Ctor = ctor ?? throw new ArgumentNullException(nameof(ctor));
|
||||||
}
|
}
|
||||||
|
|
||||||
internal void ResetCounter()
|
internal void ResetCounter()
|
||||||
|
|||||||
@@ -4,8 +4,21 @@ namespace HellionChat.Util;
|
|||||||
|
|
||||||
public static class MemoryUtil
|
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)
|
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 ptr = (byte*)address;
|
||||||
var str = new StringBuilder("\n");
|
var str = new StringBuilder("\n");
|
||||||
for(var i = 0; i < length; i++)
|
for(var i = 0; i < length; i++)
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ internal class UriPayload(Uri uri) : Payload
|
|||||||
public static UriPayload ResolveUri(string rawUri)
|
public static UriPayload ResolveUri(string rawUri)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(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://'
|
// Check for an expected scheme '://', if not add 'https://'
|
||||||
if (ExpectedSchemes.Any(scheme => rawUri.StartsWith($"{scheme}://")))
|
if (ExpectedSchemes.Any(scheme => rawUri.StartsWith($"{scheme}://")))
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ internal static class StringUtil
|
|||||||
var bytes = Math.Abs(byteCount);
|
var bytes = Math.Abs(byteCount);
|
||||||
var place = Convert.ToInt32(Math.Floor(Math.Log(bytes, 1024)));
|
var place = Convert.ToInt32(Math.Floor(Math.Log(bytes, 1024)));
|
||||||
var num = Math.Round(bytes / Math.Pow(1024, place), 1);
|
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
|
infrastructure. Independently of that, the plugin is built so that
|
||||||
you can act on your own data the way the GDPR expects.
|
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
|
reaches BetterTTV (unavoidable for any HTTPS request); the request
|
||||||
itself contains no identifying user data, no character name, no
|
itself contains no identifying user data, no character name, no
|
||||||
message text. Only the emote ID being looked up is in the URL path.
|
message text. Only the emote ID being looked up is in the URL path.
|
||||||
- **When it triggers:** Only when an incoming message contains an
|
- **When it triggers:**
|
||||||
emote token that is on the BetterTTV emote list.
|
- 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
|
- **Cached:** Yes, in `emoteCache/`. A given emote is downloaded once
|
||||||
per machine and reused.
|
per machine and reused.
|
||||||
- **How to opt out:** Turn off the **Show emotes** option in
|
- **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
|
This document lists them, their licences and which of them touch the
|
||||||
network. It is the inventory referenced by `PRIVACY.md`.
|
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
|
## 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 |
|
| 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. |
|
| [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. |
|
| [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. |
|
| [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. |
|
| [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
|
Six Labors note: HellionChat is an EUPL-1.2-licensed open-source
|
||||||
project distributed at no cost. Use of ImageSharp 3.x under the
|
project distributed at no cost. Use of ImageSharp 3.x under the
|
||||||
|
|||||||
Reference in New Issue
Block a user