Files
HellionChat/HellionChat/AutoTellTabsService.cs
T
JonKazama-Hellion 8fea9113b9 fix(privacy): the screenshot guard was reading a field that gets wiped on purpose
This morning's fix hung on TellTarget, and TellTarget is routing state that the
codebase clears deliberately. StripTellBindingOnPromote sets IsTempTab false,
empties TellTarget, and keeps the name -- so a promoted tell tab is called
"Player@World" permanently while carrying neither marker, and falls through both
possible checks. That state survives restarts. A pinned tab whose binding did not
survive a save is the same hole with a different cause; the auto-tell service
logs that case as expected and repairs around it.

The flag is set where the name is built from a partner and is not cleared by
promotion. Renaming clears it, because at that point the user typed it.

Config v26 carries it backwards for tabs that already exist: anything still
holding a tell binding or the temp flag got its name from a partner. Tabs
promoted before this version cannot be recovered -- nothing in the stored data
says where their name came from -- and renaming one has the same effect anyway.

Two more things the header was giving away. Its icon for an auto-tell tab is
derived from the partner and stable across sessions, which is three bits of
linkable information on a picture meant to be shareable; the message path
re-salts its name hashes on every load precisely to avoid that, so screenshot
mode now falls back to a plain envelope. And a world name that is not ASCII --
the CN and KR clients have those, and we ship translations for both -- was being
drawn in the meta face, which carries ASCII and a middle dot. It would have come
out as question marks, the same defect the split was built to prevent.

Plus two that are not privacy: the header had no FontsReady gate, alone among
the drawing components, so its band height and baseline offset were wrong in
exactly the frames this cycle made more common. And a long tab name ran past the
band and got cut mid-glyph at the window edge; it fits now, the way the honorific
header already did it.
2026-08-19 12:00:16 +02:00

649 lines
24 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using Dalamud.Game.Text;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Interface.ImGuiNotification;
using HellionChat.Code;
using HellionChat.GameFunctions.Types;
using HellionChat.Resources;
using HellionChat.Util;
using Microsoft.Extensions.Logging;
namespace HellionChat;
// Auto-Tell-Tabs: spawns session-only tabs per /tell partner.
// Subscribes to MessageManager.MessageProcessed and ClientState.Logout.
internal sealed class AutoTellTabsService : IDisposable
{
private readonly Plugin _plugin;
private readonly MessageManager _messageManager;
private readonly MessageStore _store;
private readonly ILogger<AutoTellTabsService> _logger;
// Tabs-list structure lock now lives on Plugin (neutral owner) so the
// MessageManager refilter can share it. See Plugin.TabsListLock / B3.
private object TabsListLock => _plugin.TabsListLock;
// Bumped whenever something wipes unpinned temp tabs wholesale (logout).
// HandleTell reads it before releasing the lock and re-checks after, so a
// tab built in between is discarded instead of outliving the wipe.
private int _tabGeneration;
// Hard cap on pinned TempTabs so the sidebar doesn't inflate over years
// of usage. Separate pool from AutoTellTabsLimit (15) — pinned tabs live
// in their own bucket. A configurable cap is a vault-backlog anchor for
// a later cycle if tester feedback demands it.
internal const int MaxPinnedTempTabs = 5;
private bool _initialized;
// Set when Initialize ran before a character was available; cleared once the
// history has actually been loaded.
private bool _rehydratePending;
internal AutoTellTabsService(
Plugin plugin,
MessageManager messageManager,
MessageStore store,
ILogger<AutoTellTabsService> logger
)
{
_plugin = plugin;
_messageManager = messageManager;
_store = store;
_logger = logger;
}
// Derived from the tab list on read. Pin/Unpin/Promote/Logout simply
// mutate IsPinned or remove tabs — the count adapts automatically.
// Replaces the F2.1 Interlocked counter because the new pin-state
// transitions are cold-path and don't need lock-free reads.
internal int ActiveTempTabCount =>
Plugin.Config.Tabs.Count(TabLifecycleHelpers.IsInUnpinnedPool);
internal int PinnedTempTabCount => Plugin.Config.Tabs.Count(TabLifecycleHelpers.IsInPinnedPool);
internal void Initialize()
{
if (_initialized)
{
return;
}
// Pinned tabs come out of the JSON with TellTarget set but
// CurrentChannel reset (NonSerialized). Without re-seeding, the chat
// input has no tell-target on the active pinned tab, and the
// game-side channel hook only repaints CurrentChannel once the user
// triggers a /tell or channel switch.
RehydratePinnedTabs();
_messageManager.MessageProcessed += HandleTell;
Plugin.ClientState.Login += OnLogin;
Plugin.ClientState.Logout += OnLogout;
_initialized = true;
}
// Deferred when the plugin starts before a character is logged in, which is
// the normal case: the game loads plugins at boot. CurrentContentId is 0
// until then, so the history query would look up tells for character zero,
// find none, and leave every pinned tab blank for the whole session.
//
// Only visible to someone who actually pins a tell tab AND starts the game
// with the plugin already installed. Reloading the plugin in a running
// session -- what a developer does all day -- hides it completely.
private void RehydratePinnedTabs()
{
if (_messageManager.CurrentContentId == 0)
{
_logger.LogDebug("[Pin] Rehydrate deferred: no character yet, waiting for login");
_rehydratePending = true;
return;
}
_rehydratePending = false;
var pinned = Plugin.Config.Tabs.Count(TabLifecycleHelpers.IsInPinnedPool);
_logger.LogDebug($"[Pin] Rehydrate scan: {pinned} pinned tab(s) found");
foreach (var tab in Plugin.Config.Tabs)
{
if (!TabLifecycleHelpers.IsInPinnedPool(tab))
continue;
if (tab.TellTarget is null || !tab.TellTarget.IsSet())
{
_logger.LogWarning(
$"[Pin] Pinned tab '{tab.Name}' has no usable TellTarget "
+ $"(Name={tab.TellTarget?.Name ?? "<null>"} World={tab.TellTarget?.World ?? 0}). "
+ "Chat input on this tab will be empty until the partner sends a tell or you /tell manually."
);
continue;
}
tab.Channel ??= InputChannel.Tell;
tab.CurrentChannel.Channel = InputChannel.Tell;
tab.CurrentChannel.TellTarget = tab.TellTarget.Clone();
// MessageList is NonSerialized so pinned tabs come back empty.
// Preload the same history window the spawn path uses so the user
// sees the recent conversation, not a blank tab.
PreloadHistory(tab, tab.TellTarget.Name, tab.TellTarget.World, Guid.Empty);
_logger.LogDebug(
$"[Pin] Rehydrated '{tab.Name}' -> Tell target {tab.TellTarget.Name}@{tab.TellTarget.World}"
);
}
}
public void Dispose()
{
if (!_initialized)
{
return;
}
Plugin.ClientState.Login -= OnLogin;
Plugin.ClientState.Logout -= OnLogout;
_messageManager.MessageProcessed -= HandleTell;
_initialized = false;
}
internal void HandleTell(Message message)
{
if (!Plugin.Config.EnableAutoTellTabs)
{
return;
}
if (
message.Code.Type != ChatType.TellIncoming
&& message.Code.Type != ChatType.TellOutgoing
)
{
return;
}
var partner = ExtractTellPartner(message);
if (partner == null)
{
// Diagnostics: helps detect regressions (FFXIV payload changes, new edge cases)
_logger.LogWarning(
$"[AutoTellTabs] Could not extract tell partner. type={message.Code.Type}, "
+ $"senderChunks={message.Sender.Count}, contentChunks={message.Content.Count}, "
+ $"senderSourcePayloads={message.SenderSource?.Payloads?.Count ?? 0}, "
+ $"contentSourcePayloads={message.ContentSource?.Payloads?.Count ?? 0}"
);
return;
}
// Three steps, because building the tab pulls history out of the store and
// that must not happen under TabsListLock (B3 rule; the query sorts the whole
// receiver history). Step 1 and 3 are locked, step 2 is not.
int generation;
lock (TabsListLock)
{
var existing = FindTempTab(partner.Value.Name, partner.Value.World);
if (existing != null)
{
// Already routed via MessageManager pipeline — no AddMessage here,
// HandleTell runs after the delivery loop. Repair the tell-target if
// the fallback hit a pinned tab whose TellTarget didn't survive a
// previous round-trip — keeps FindTempTab fast on the next message.
if (
existing.IsPinned
&& (existing.TellTarget is null || !existing.TellTarget.IsSet())
)
{
existing.TellTarget = new TellTarget(
partner.Value.Name,
partner.Value.World,
0,
TellReason.Direct
);
_plugin.SaveConfig();
}
return;
}
generation = _tabGeneration;
}
var tab = BuildTempTabWithHistory(partner.Value, message);
lock (TabsListLock)
{
// A logout in between wiped the unpinned pool; committing now would
// resurrect a tab for a character we already left.
if (generation != _tabGeneration)
return;
// Someone else (self-test, UI) may have created the tab while we built
// ours. Hand the message to theirs and drop what we built — unlike the
// early return above, this tab appeared after the delivery loop ran.
var raced = FindTempTab(partner.Value.Name, partner.Value.World);
if (raced != null)
{
raced.AddMessage(message, unread: true);
return;
}
CommitTempTab(tab);
}
}
private (string Name, uint World)? ExtractTellPartner(Message message)
{
if (message.Code.Type == ChatType.TellIncoming)
{
// Sender is the partner; check chunks first, then raw SeString as fallback
var fromSender =
ChunkUtil.TryGetPlayerPayload(message.Sender)
?? ChunkUtil.TryGetPlayerPayload(message.SenderSource);
if (fromSender != null)
{
return (fromSender.PlayerName, fromSender.World.RowId);
}
return null;
}
// Outgoing tell: check content first, then channels's TellTarget as fallback
var fromContent =
ChunkUtil.TryGetPlayerPayload(message.Content)
?? ChunkUtil.TryGetPlayerPayload(message.ContentSource)
?? ChunkUtil.TryGetPlayerPayload(message.Sender)
?? ChunkUtil.TryGetPlayerPayload(message.SenderSource);
if (fromContent != null)
{
return (fromContent.PlayerName, fromContent.World.RowId);
}
var current =
_plugin.CurrentTab.CurrentChannel.TellTarget
?? _plugin.CurrentTab.CurrentChannel.TempTellTarget;
if (current != null && current.IsSet())
{
return (current.Name, current.World);
}
return null;
}
internal static Tab? FindTempTab(string name, uint world)
{
var byTarget = Plugin.Config.Tabs.FirstOrDefault(t =>
t.IsTempTab
&& t.TellTarget != null
&& string.Equals(t.TellTarget.Name, name, StringComparison.OrdinalIgnoreCase)
&& t.TellTarget.World == world
);
if (byTarget != null)
return byTarget;
// Fallback: match by tab name. Pinned tabs are named via
// FormatTabName(player, world) at spawn time, so the name is a
// stable secondary key when TellTarget didn't survive a save/load
// (older configs from a renamed pin, malformed migrations, etc.).
var expectedName = FormatTabName(name, world);
return Plugin.Config.Tabs.FirstOrDefault(t =>
t.IsTempTab && string.Equals(t.Name, expectedName, StringComparison.OrdinalIgnoreCase)
);
}
// Lock-protected lookup for the framework-thread caller (TellRouterService).
// Config.Tabs is mutated under the shared Plugin.TabsListLock on the worker thread,
// so a framework-tick reader must take the same lock to avoid enumerating the list
// mid-mutation.
internal Tab? FindTempTabSafe(string name, uint world)
{
lock (TabsListLock)
return FindTempTab(name, world);
}
internal void DropOldestTempTab()
{
// B3: lock the list-structure ops so the (currently caller-less) Unpin path
// can't race the worker; re-entrant when HandleTell already holds the lock.
lock (TabsListLock)
{
// Pinned tabs live in their own bucket (MaxPinnedTempTabs) and are
// never drop candidates. They leave the bucket only via Unpin or
// PromoteToPermanent.
var victim = Plugin
.Config.Tabs.Select((tab, idx) => (Tab: tab, Index: idx))
.Where(t => TabLifecycleHelpers.IsInUnpinnedPool(t.Tab))
.OrderByDescending(t => t.Tab.IsGreeted)
.ThenBy(t => t.Tab.LastActivity)
.FirstOrDefault();
if (victim.Tab == null)
{
return;
}
var dropped = victim.Tab;
// By reference, not by index: the index came from a Select() earlier in
// this block and would point at the wrong tab if anything shifted the list.
Plugin.Config.Tabs.Remove(dropped);
// Re-anchor the UI selection if it pointed at the dropped tab, and close any
// pop-out window the dropped tab owned. Both run on the PendingMessage worker
// thread and touch window state the Draw path reads (OnTabActivated re-seed +
// the pool's Unbind), so marshal onto the framework thread to serialize with
// Draw (reference_dalamud_framework_thread). TryClose is idempotent: a tab that
// was never popped is a silent no-op.
Plugin.Framework.RunOnFrameworkThread(() =>
{
_plugin.ChannelPopoutPool.TryClose(dropped.Identifier);
_plugin.MainWindow?.ResetActiveTabIfRemoved(dropped);
});
}
}
// Runs WITHOUT TabsListLock: PreloadHistory hits the store, which used to hold
// the lock across a query that sorted the whole receiver history. The tab is not
// public until CommitTempTab adds it, so building it unlocked is safe.
private Tab BuildTempTabWithHistory((string Name, uint World) partner, Message currentMessage)
{
var tab = BuildTempTab(partner.Name, partner.World);
// Preload history: chronological order with current message already persisted
PreloadHistory(tab, partner.Name, partner.World, currentMessage.Id);
tab.AddMessage(currentMessage, unread: true);
// Flag the tab as a pop-out if configured; the marshalled TryOpen below reads
// that flag to open the real window.
if (Plugin.Config.AutoTellTabsOpenAsPopout)
{
tab.PopOut = true;
}
return tab;
}
// Caller MUST hold TabsListLock.
private void CommitTempTab(Tab tab)
{
if (ActiveTempTabCount >= Plugin.Config.AutoTellTabsLimit)
{
DropOldestTempTab();
}
Plugin.Config.Tabs.Add(tab);
// Actually open the pop-out window for the flagged tab — without this the
// flag was dead (a PopOut tab with no window). CommitTempTab runs on the
// PendingMessage worker thread under Plugin.TabsListLock; TryOpen does
// OnTabActivated + Bind (window state Draw reads), so marshal onto the
// framework thread. If the pool is full, drop the flag so it never claims a
// window it didn't get (flag/window parity).
if (tab.PopOut)
{
Plugin.Framework.RunOnFrameworkThread(() =>
{
if (!_plugin.ChannelPopoutPool.TryOpen(tab))
tab.PopOut = false;
});
}
}
private static Tab BuildTempTab(string playerName, uint worldRowId)
{
return new Tab
{
Name = FormatTabName(playerName, worldRowId),
NameCameFromPartner = true,
IsTempTab = true,
AllSenderMessages = true,
TellTarget = new TellTarget(playerName, worldRowId, 0, TellReason.Direct),
Channel = InputChannel.Tell,
DisplayTimestamp = true,
UnreadMode = UnreadMode.Unseen,
HideWhenInactive = false,
SelectedChannels = new Dictionary<ChatType, (ChatSource, ChatSource)>
{
[ChatType.TellIncoming] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.TellOutgoing] = (ChatSourceExt.All, ChatSourceExt.All),
},
};
}
private static string FormatTabName(string playerName, uint worldRowId)
{
if (Sheets.WorldSheet.TryGetRow(worldRowId, out var worldRow))
{
return $"{playerName}@{worldRow.Name}";
}
// Fallback if world lookup misses (rare; only for unseen worlds)
return $"{playerName}@World{worldRowId}";
}
private void PreloadHistory(Tab tab, string senderName, uint senderWorld, Guid currentMessageId)
{
var preloadCount = Plugin.Config.AutoTellTabsHistoryPreload;
if (preloadCount <= 0)
{
return;
}
try
{
// Pull one extra row: current message is already in store and would eat a preload slot
var history = _store.GetTellHistoryWithSender(
_messageManager.CurrentContentId,
senderName,
senderWorld,
preloadCount + 1
);
var historicMessages = history
.Where(m => m.Id != currentMessageId)
.Take(preloadCount)
.ToList();
if (historicMessages.Count == 0)
{
// No prior tells; leave tab empty to avoid orphaned "history loaded" marker
return;
}
// History is oldest-first; add in order for chronological display
foreach (var message in historicMessages)
{
tab.Messages.AddPrune(message, MessageManager.MessageDisplayLimit);
}
// Separator between history and live tell (sorts after history but before current)
tab.Messages.AddPrune(
MakeSystemMarker(HellionStrings.AutoTellTabs_HistorySeparator),
MessageManager.MessageDisplayLimit
);
}
catch (Exception ex)
{
// Non-fatal: tab still spawns with visible error notice instead of silent history loss
_logger.LogError(ex, "[AutoTellTabs] History preload failed");
tab.Messages.AddPrune(
MakeSystemMarker(HellionStrings.AutoTellTabs_HistoryLoadError),
MessageManager.MessageDisplayLimit
);
}
}
private static Message MakeSystemMarker(string text)
{
var seString = new SeStringBuilder().AddText(text).Build();
var chunks = ChunkUtil.ToChunks(seString, ChunkSource.Content, ChatType.System).ToList();
var code = new ChatCode((XivChatType)ChatType.System, 0, 0);
return Message.FakeMessage(chunks, code);
}
internal void MarkGreeted(Tab tab)
{
SetGreeted(tab, true);
}
internal void UnmarkGreeted(Tab tab)
{
SetGreeted(tab, false);
}
internal bool IsGreeted(Tab tab)
{
return tab.IsGreeted;
}
private void SetGreeted(Tab tab, bool greeted)
{
if (tab == null)
{
return;
}
lock (TabsListLock)
{
// Guard against frame-race: sidebar might render a tab already removed by LRU or logout
if (!Plugin.Config.Tabs.Contains(tab))
{
return;
}
tab.IsGreeted = greeted;
}
}
// Fires on the login that follows a boot-time start, and on every character
// switch after one. Guarded by the pending flag so a switch does not append
// a second copy of the history to tabs that already have it.
private void OnLogin()
{
if (!_rehydratePending)
return;
RehydratePinnedTabs();
}
private void OnLogout(int type, int code)
{
lock (TabsListLock)
{
// Pinned TempTabs must survive char-switch — that's the whole point
// of pinning. Only unpinned ones get stripped.
var active = _plugin.MainWindow?.ActiveTab;
var poppedTempTabIds = Plugin
.Config.Tabs.Where(t =>
TabLifecycleHelpers.IsInUnpinnedPool(t)
&& _plugin.ChannelPopoutPool.IsOpen(t.Identifier)
)
.Select(t => t.Identifier)
.ToList();
// Close any pop-out window an unpinned temp tab owns before the tabs leave
// the list. Filtering on the live pool (not the PopOut flag) also catches
// manually right-clicked pop-outs, which never set the flag.
foreach (var id in poppedTempTabIds)
_plugin.ChannelPopoutPool.TryClose(id);
Plugin.Config.Tabs.RemoveAll(TabLifecycleHelpers.IsInUnpinnedPool);
// HandleTell builds a tab outside the lock; bumping here lets it detect
// that the world moved on and drop what it built. Read and compared under
// the same lock, so no volatile needed.
_tabGeneration++;
// Re-anchor the UI selection if the active tab was one of the stripped
// unpinned temp tabs (reference predicate, not an index). Logout is a
// framework-thread event, so this is already serialized with Draw — no
// marshalling needed here, unlike the worker-thread eviction path.
if (active is { } a && TabLifecycleHelpers.IsInUnpinnedPool(a))
{
_plugin.MainWindow?.ResetActiveTabIfRemoved(a);
}
}
}
internal bool TryPin(Tab tab)
{
if (!tab.IsTempTab || tab.IsPinned)
{
_logger.LogDebug(
$"[Pin] TryPin skipped: IsTempTab={tab.IsTempTab} IsPinned={tab.IsPinned}"
);
return false;
}
// Count and flag under one lock so the cap can't be raced. SaveConfig stays
// OUTSIDE -- holding TabsListLock across a save would put an fsync on the
// click path, which is what B6 just removed elsewhere.
lock (TabsListLock)
{
if (PinnedTempTabCount >= MaxPinnedTempTabs)
{
WrapperUtil.AddNotification(
string.Format(HellionStrings.PinTab_LimitReached, MaxPinnedTempTabs),
NotificationType.Warning
);
return false;
}
tab.IsPinned = true;
}
_logger.LogDebug(
$"[Pin] Pinned tab '{tab.Name}' target={tab.TellTarget?.Name}@{tab.TellTarget?.World}"
);
_plugin.SaveConfig();
return true;
}
internal void Unpin(Tab tab)
{
if (!tab.IsPinned)
{
return;
}
// If the unpinned pool is already full, dropping the oldest before
// flipping the flag avoids counting the just-unpinned tab as a drop
// candidate. Under lock, since DropOldestTempTab mutates the list.
// SaveConfig stays outside, see TryPin.
lock (TabsListLock)
{
if (ActiveTempTabCount >= Plugin.Config.AutoTellTabsLimit)
{
DropOldestTempTab();
}
tab.IsPinned = false;
}
_logger.LogDebug("[Pin] Unpinned tab '{TabName}'", tab.Name);
_plugin.SaveConfig();
}
internal void PromoteToPermanent(Tab tab)
{
if (!tab.IsTempTab)
{
return;
}
// Drops the temp/pin flags, the persisted tell target AND the runtime
// channel's tell state. The runtime-channel clear is the CORR-1 guard —
// see StripTellBindingOnPromote; clearing Tab.TellTarget alone would leave
// CurrentChannel.Channel == Tell + a stale target and route a typed line
// silently as /tell to the old partner.
// Flips IsTempTab/IsPinned, which decide pool membership and whether a save
// strips the tab. Under lock so a concurrent save sees one or the other, never
// half. SaveConfig stays outside, see TryPin.
lock (TabsListLock)
TabLifecycleHelpers.StripTellBindingOnPromote(tab);
_logger.LogDebug($"[Pin] Promoted tab '{tab.Name}' to permanent (tell-binding dropped)");
_plugin.SaveConfig();
}
}