Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b6bd0b459a |
@@ -10,8 +10,14 @@ using HellionChat.Util;
|
||||
|
||||
namespace HellionChat;
|
||||
|
||||
// Auto-Tell-Tabs: spawns session-only tabs per /tell partner.
|
||||
// Subscribes to MessageManager.MessageProcessed and ClientState.Logout.
|
||||
// Hellion Chat — Auto-Tell-Tabs.
|
||||
//
|
||||
// Spawns a session-only tab per /tell partner so a club greeter can track
|
||||
// multiple parallel conversations without losing context. Subscribes to
|
||||
// MessageManager.MessageProcessed for live tells and to ClientState.Logout
|
||||
// for the cleanup pass; everything else hangs off these two entry points.
|
||||
//
|
||||
// See spec: Hellion Chat Auto-Tell-Tabs Spec (Obsidian vault).
|
||||
internal sealed class AutoTellTabsService : IDisposable
|
||||
{
|
||||
private readonly Plugin _plugin;
|
||||
@@ -81,7 +87,10 @@ internal sealed class AutoTellTabsService : IDisposable
|
||||
var partner = ExtractTellPartner(message);
|
||||
if (partner == null)
|
||||
{
|
||||
// Diagnostics: helps detect regressions (FFXIV payload changes, new edge cases)
|
||||
// Real message without a player payload — e.g. GM tells, which
|
||||
// we deliberately skip. The diagnostics make future regressions
|
||||
// (FFXIV changing tell payload shape, new edge cases) findable
|
||||
// without having to crank up debug logging at the source.
|
||||
Plugin.Log.Warning(
|
||||
$"[AutoTellTabs] Could not extract tell partner. type={message.Code.Type}, "
|
||||
+ $"senderChunks={message.Sender.Count}, contentChunks={message.Content.Count}, "
|
||||
@@ -96,7 +105,9 @@ internal sealed class AutoTellTabsService : IDisposable
|
||||
var existing = FindTempTab(partner.Value.Name, partner.Value.World);
|
||||
if (existing != null)
|
||||
{
|
||||
// Already routed via MessageManager pipeline
|
||||
// Tab already exists; Tab.Matches has already routed this
|
||||
// message via the MessageManager pipeline (see Task 2 sender
|
||||
// filter).
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -113,7 +124,10 @@ internal sealed class AutoTellTabsService : IDisposable
|
||||
{
|
||||
if (message.Code.Type == ChatType.TellIncoming)
|
||||
{
|
||||
// Sender is the partner; check chunks first, then raw SeString as fallback
|
||||
// Incoming tell: the sender is the conversation partner. The
|
||||
// PlayerPayload normally rides on a chunk's Link slot, but for
|
||||
// some tell types FFXIV only puts it in the raw SeString —
|
||||
// fall back to that before giving up.
|
||||
var fromSender =
|
||||
ChunkUtil.TryGetPlayerPayload(message.Sender)
|
||||
?? ChunkUtil.TryGetPlayerPayload(message.SenderSource);
|
||||
@@ -124,7 +138,10 @@ internal sealed class AutoTellTabsService : IDisposable
|
||||
return null;
|
||||
}
|
||||
|
||||
// Outgoing tell: check content first, then channels's TellTarget as fallback
|
||||
// Outgoing tell: the local player is the sender, the partner shows
|
||||
// up either as a payload in the content (for tells typed via the
|
||||
// Chat 2 input bar) or as the channel's tracked tell target (set by
|
||||
// the SetContextTellTarget game hook). Same SeString fallback.
|
||||
var fromContent =
|
||||
ChunkUtil.TryGetPlayerPayload(message.Content)
|
||||
?? ChunkUtil.TryGetPlayerPayload(message.ContentSource)
|
||||
@@ -158,7 +175,10 @@ internal sealed class AutoTellTabsService : IDisposable
|
||||
|
||||
private void DropOldestTempTab()
|
||||
{
|
||||
// Prioritize greeted tabs for drop; within each bucket, drop by oldest LastActivity
|
||||
// Greeted tabs are dropped before un-greeted ones (the user said
|
||||
// "I'm done with that conversation"), and within each bucket we
|
||||
// pick the oldest LastActivity. This protects active conversations
|
||||
// and unfinished greetings while still freeing up a slot.
|
||||
var victim = Plugin
|
||||
.Config.Tabs.Select((tab, idx) => (Tab: tab, Index: idx))
|
||||
.Where(t => t.Tab.IsTempTab)
|
||||
@@ -171,7 +191,12 @@ internal sealed class AutoTellTabsService : IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up pop-out window if tab is popped out
|
||||
// v0.6.1 — if the victim is currently popped out, tear down the
|
||||
// matching Popout window first. Otherwise the window stays in
|
||||
// PopOutWindows + WindowSystem and renders empty / re-spawns on the
|
||||
// next AddPopOutsToDraw tick. Latent since pop-outs were introduced;
|
||||
// becomes visible with AutoTellTabsOpenAsPopout where dropping a
|
||||
// popped tab is now a routine code path.
|
||||
if (victim.Tab.PopOut)
|
||||
{
|
||||
var popout = _plugin.ChatLogWindow.ActivePopouts.FirstOrDefault(p =>
|
||||
@@ -185,7 +210,8 @@ internal sealed class AutoTellTabsService : IDisposable
|
||||
|
||||
Plugin.Config.Tabs.RemoveAt(victim.Index);
|
||||
|
||||
// Re-anchor active tab to avoid silent switch when tab is dropped
|
||||
// Re-anchor the active tab so the user does not silently end up on
|
||||
// a different conversation when their tab gets dropped or shifted.
|
||||
if (victim.Index <= _plugin.LastTab)
|
||||
{
|
||||
_plugin.WantedTab = 0;
|
||||
@@ -196,12 +222,22 @@ internal sealed class AutoTellTabsService : IDisposable
|
||||
{
|
||||
var tab = BuildTempTab(partner.Name, partner.World);
|
||||
|
||||
// Preload history: chronological order with current message already persisted
|
||||
// Preload first so the tab opens with chronological history above
|
||||
// the current message — and so a slow DB query never causes a
|
||||
// visible "empty tab, then history pops in" effect on screen.
|
||||
// The current message is already persisted in the store by the
|
||||
// time MessageProcessed fires (see MessageManager.cs: UpsertMessage
|
||||
// runs before the event), so we have to exclude it explicitly to
|
||||
// avoid the separator landing below the live tell.
|
||||
PreloadHistory(tab, partner.Name, partner.World, currentMessage.Id);
|
||||
|
||||
tab.AddMessage(currentMessage, unread: true);
|
||||
|
||||
// Open as pop-out if configured (set before Tabs.Add for next render-tick)
|
||||
// Hellion Chat v0.6.1 — opt-in: open new /tell tabs directly as a
|
||||
// pop-out window. Set BEFORE Tabs.Add so the next render-tick's
|
||||
// AddPopOutsToDraw() sees PopOut=true and spawns the Popout window
|
||||
// alongside the tab going into the list. No SaveConfig() because
|
||||
// auto-tell tabs are IsTempTab (session-only, never persisted).
|
||||
if (Plugin.Config.AutoTellTabsOpenAsPopout)
|
||||
{
|
||||
tab.PopOut = true;
|
||||
@@ -236,7 +272,9 @@ internal sealed class AutoTellTabsService : IDisposable
|
||||
{
|
||||
return $"{playerName}@{worldRow.Name}";
|
||||
}
|
||||
// Fallback if world lookup misses (rare; only for unseen worlds)
|
||||
// World sheet lookup miss is rare (only for FFXIV worlds Dalamud has
|
||||
// not yet seen). Fall back to the raw RowId so the user still has a
|
||||
// unique, readable label.
|
||||
return $"{playerName}@World{worldRowId}";
|
||||
}
|
||||
|
||||
@@ -250,7 +288,9 @@ internal sealed class AutoTellTabsService : IDisposable
|
||||
|
||||
try
|
||||
{
|
||||
// Pull one extra row: current message is already in store and would eat a preload slot
|
||||
// Pull one extra row because the live tell that triggered this
|
||||
// spawn is already in the store and would otherwise eat one of
|
||||
// the user's preload-budget slots.
|
||||
var history = _store.GetTellHistoryWithSender(
|
||||
_messageManager.CurrentContentId,
|
||||
senderName,
|
||||
@@ -265,17 +305,23 @@ internal sealed class AutoTellTabsService : IDisposable
|
||||
|
||||
if (historicMessages.Count == 0)
|
||||
{
|
||||
// No prior tells; leave tab empty to avoid orphaned "history loaded" marker
|
||||
// No prior tells with this player — leave the tab to start
|
||||
// empty so the user does not see a "history loaded" marker
|
||||
// sitting alone above the very first message.
|
||||
return;
|
||||
}
|
||||
|
||||
// History is oldest-first; add in order for chronological display
|
||||
// The history list is already oldest-first, so a plain AddPrune
|
||||
// loop produces the chronological order the user expects to see
|
||||
// when the tab opens.
|
||||
foreach (var message in historicMessages)
|
||||
{
|
||||
tab.Messages.AddPrune(message, MessageManager.MessageDisplayLimit);
|
||||
}
|
||||
|
||||
// Separator between history and live tell (sorts after history but before current)
|
||||
// Visible separator between the loaded history and the live
|
||||
// tell that triggered this spawn. Goes in last so it sorts
|
||||
// after the historical messages but before the current one.
|
||||
tab.Messages.AddPrune(
|
||||
MakeSystemMarker(HellionStrings.AutoTellTabs_HistorySeparator),
|
||||
MessageManager.MessageDisplayLimit
|
||||
@@ -283,7 +329,9 @@ internal sealed class AutoTellTabsService : IDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Non-fatal: tab still spawns with visible error notice instead of silent history loss
|
||||
// Non-fatal: the tab still spawns, but the user gets a visible
|
||||
// notice instead of silently missing history. The error logs
|
||||
// once with full stack trace for diagnosis.
|
||||
Plugin.Log.Error(ex, "[AutoTellTabs] History preload failed");
|
||||
tab.Messages.AddPrune(
|
||||
MakeSystemMarker(HellionStrings.AutoTellTabs_HistoryLoadError),
|
||||
@@ -324,7 +372,9 @@ internal sealed class AutoTellTabsService : IDisposable
|
||||
|
||||
lock (_tempTabsLock)
|
||||
{
|
||||
// Guard against frame-race: sidebar might render a tab already removed by LRU or logout
|
||||
// Frame-race guard (E5): the sidebar might still render a tab
|
||||
// that has already been removed by LRU drop or logout cleanup.
|
||||
// Silently skip the toggle so we don't mutate stale state.
|
||||
if (!Plugin.Config.Tabs.Contains(tab))
|
||||
{
|
||||
return;
|
||||
@@ -338,12 +388,18 @@ internal sealed class AutoTellTabsService : IDisposable
|
||||
{
|
||||
lock (_tempTabsLock)
|
||||
{
|
||||
// Snapshot active tab index before mutating list
|
||||
// Snapshot whether the active tab is about to be removed, BEFORE
|
||||
// we mutate the list — index lookups would lie to us afterwards.
|
||||
var lastIndex = _plugin.LastTab;
|
||||
var lastIndexValid = lastIndex >= 0 && lastIndex < Plugin.Config.Tabs.Count;
|
||||
var currentWasTempTab = lastIndexValid && Plugin.Config.Tabs[lastIndex].IsTempTab;
|
||||
|
||||
// Clean up pop-out windows before removing temp tabs
|
||||
// v0.6.1 — symmetric to DropOldestTempTab cleanup: tear down any
|
||||
// popped-out temp tab windows before removing the tabs themselves,
|
||||
// otherwise PopOutWindows + WindowSystem keep ghost entries until
|
||||
// the next plugin reload. Especially relevant once Auto-Pop-Out is
|
||||
// enabled — every logout would otherwise leak as many ghosts as
|
||||
// there were active /tell pop-outs.
|
||||
var poppedTempTabIds = Plugin
|
||||
.Config.Tabs.Where(t => t.IsTempTab && t.PopOut)
|
||||
.Select(t => t.Identifier)
|
||||
@@ -363,7 +419,9 @@ internal sealed class AutoTellTabsService : IDisposable
|
||||
|
||||
Plugin.Config.Tabs.RemoveAll(t => t.IsTempTab);
|
||||
|
||||
// Force switch to tab 0 if active tab was temp or index is now out of range
|
||||
// Force a switch to tab 0 if the active tab was a temp tab OR
|
||||
// if drops before the active index pushed LastTab out of range.
|
||||
// Otherwise the user keeps their current persistent tab.
|
||||
var stillValid = lastIndex >= 0 && lastIndex < Plugin.Config.Tabs.Count;
|
||||
if (currentWasTempTab || !stillValid)
|
||||
{
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
// HellionChat/Branding/BrandingLinks.cs
|
||||
namespace HellionChat.Branding;
|
||||
|
||||
// Centralised — a future invite/URL rotation only touches this file.
|
||||
// Centralised so a future invite rotation only touches one file. The same
|
||||
// link is currently hard-coded in repo.json, README.md, SUPPORT.md,
|
||||
// CONTRIBUTORS.md and HellionChat.yaml — those will be migrated to consume
|
||||
// this constant in a separate housekeeping sweep
|
||||
internal static class BrandingLinks
|
||||
{
|
||||
public const string HellionForgeDiscordInvite = "https://discord.gg/X9V7Kcv5gR";
|
||||
public const string HellionForgeGitea = "https://gitea.hellion-forge.cloud/Hellion-Forge";
|
||||
public const string HellionChatRepo =
|
||||
"https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat";
|
||||
public const string HellionForgeWebsite = "https://hellion-forge.cloud";
|
||||
public const string HellionMediaWebsite = "https://hellion-media.de/de";
|
||||
}
|
||||
|
||||
@@ -34,7 +34,9 @@ public abstract class Chunk
|
||||
_ => null,
|
||||
};
|
||||
|
||||
// Returns basic text for hashing (content for TextChunk, icon name for IconChunk)
|
||||
/// <summary>
|
||||
/// Get some basic text for use in generating hashes.
|
||||
/// </summary>
|
||||
internal string StringValue()
|
||||
{
|
||||
return this switch
|
||||
@@ -106,6 +108,9 @@ public class TextChunk : Chunk
|
||||
Content = content ?? "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new TextChunk with identical styling to this one.
|
||||
/// </summary>
|
||||
public TextChunk NewWithStyle(ChunkSource source, Payload? link, string content)
|
||||
{
|
||||
return new TextChunk(source, link, content)
|
||||
@@ -117,6 +122,9 @@ public class TextChunk : Chunk
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new TextChunk with identical styling to this one.
|
||||
/// </summary>
|
||||
public TextChunk NewWithStyle(Chunk chunk, string content)
|
||||
{
|
||||
return new TextChunk(chunk, content)
|
||||
|
||||
+154
-26
@@ -38,26 +38,33 @@ public class Configuration : IPluginConfiguration
|
||||
|
||||
public int Version { get; set; } = LatestVersion;
|
||||
|
||||
// Slug-based; ThemeRegistry resolves the object at runtime.
|
||||
// v1.1.0 — Theme-Engine. Slug-basiert; ThemeRegistry liefert das Objekt.
|
||||
public string Theme = "hellion-arctic";
|
||||
|
||||
// Global window opacity, applied across all themes.
|
||||
// v1.1.0 — Globale Window-Opacity, theme-übergreifend. Migration aus
|
||||
// HellionThemeWindowOpacity beim Bump v13 → v14.
|
||||
public float WindowOpacity = 0.85f;
|
||||
|
||||
// Reserved for future UI toggles; pre-declared to avoid a migration later.
|
||||
// v1.1.0 — Felder für künftige UI-Toggles (v1.2.0 / v1.3.0). Werden
|
||||
// vorab angelegt, damit später keine Migration nötig ist.
|
||||
public bool ReduceMotion;
|
||||
|
||||
// v1.2.1: default flipped false → true. Compact single-line layout is
|
||||
// more readable than the card-rows layout introduced in v1.2.0.
|
||||
// v1.2.1 — Default geflippt von false → true. Card-Rows-Layout aus
|
||||
// v1.2.0 wurde als zu dicht empfunden; Single-Line `[HH:mm] Sender:
|
||||
// Text` ist besser lesbar und platzsparender. Bestand-User mit aktiv
|
||||
// false werden durch die v15→v16-Migration auf den neuen Default
|
||||
// gehoben (Heuristik: wer in v1.2.0 false hatte, hatte den damals
|
||||
// neu eingeführten Default — kaum jemand hat aktiv abgeschaltet).
|
||||
public bool UseCompactDensity = true;
|
||||
|
||||
// Privacy by Default master switch. Set false to restore upstream behaviour.
|
||||
// Hellion Chat — Privacy filter (DSGVO Art. 25 Privacy by Default).
|
||||
// Master-switch defaults to true; set false to restore upstream behavior.
|
||||
public bool PrivacyFilterEnabled = true;
|
||||
|
||||
// Empty set means the migration has not run yet — see Plugin.cs v6→v7.
|
||||
public HashSet<ChatType> PrivacyPersistChannels = [];
|
||||
|
||||
// Failsafe for ChatTypes added by future FFXIV patches.
|
||||
// Failsafe for ChatTypes added by future FFXIV patches we don't know about.
|
||||
public bool PrivacyPersistUnknownChannels;
|
||||
|
||||
public bool IsAllowedForStorage(ChatType type)
|
||||
@@ -69,23 +76,79 @@ public class Configuration : IPluginConfiguration
|
||||
return PrivacyPersistUnknownChannels;
|
||||
}
|
||||
|
||||
// Retention master switch defaults to false — plugin will not delete
|
||||
// history until the user explicitly opts in.
|
||||
// Hellion Chat — Message retention (GDPR data minimization, time axis).
|
||||
// Master switch defaults to false; the plugin will not delete history
|
||||
// until the user explicitly opts in.
|
||||
public bool RetentionEnabled;
|
||||
public int RetentionDefaultDays = 30;
|
||||
public Dictionary<ChatType, int> RetentionPerChannelDays = [];
|
||||
public DateTimeOffset RetentionLastRunAt = DateTimeOffset.MinValue;
|
||||
|
||||
// Hellion Chat first-run wizard — opens once on a fresh install. Existing
|
||||
// ChatTwo users skip it because the v6→v7 migration sets the flag.
|
||||
public bool FirstRunCompleted;
|
||||
|
||||
// Use the bundled Exo 2 font (OFL-1.1) for the regular plugin font
|
||||
// instead of whatever GlobalFontV2.FontId points at. Default ON so a
|
||||
// fresh install gets the Hellion typography out-of-the-box; flip OFF
|
||||
// to fall back to the user's chosen system or Dalamud font.
|
||||
public bool UseHellionFont = true;
|
||||
|
||||
// Cycle 1 of the plugin-integration roadmap. When Honorific is installed
|
||||
// and reports a custom title, render it in the chat header above the
|
||||
// message log. Auto-hides regardless when Honorific is missing or the
|
||||
// active title is original/empty, so leaving this on is safe even for
|
||||
// users who don't run Honorific.
|
||||
public bool ShowHonorificTitleInHeader = true;
|
||||
|
||||
// Hellion Chat — Auto-Tell-Tabs. When enabled, an incoming or outgoing
|
||||
// /tell spawns a session-only tab dedicated to that conversation
|
||||
// partner. See spec: Hellion Chat Auto-Tell-Tabs Spec (Obsidian).
|
||||
public bool EnableAutoTellTabs = true;
|
||||
|
||||
// Hard cap on simultaneously open auto tell tabs. Range enforced by the
|
||||
// settings slider (1–50). LRU drop favors greeted tabs first.
|
||||
public int AutoTellTabsLimit = 15;
|
||||
|
||||
// When true the sidebar shows only a thin separator before the temp
|
||||
// tabs; when false a section header "Active Tells (n)" is rendered.
|
||||
public bool AutoTellTabsCompactDisplay;
|
||||
|
||||
// Number of prior tells to preload from the message store when an
|
||||
// auto tell tab is spawned. Range 0–100; 0 disables preload.
|
||||
public int AutoTellTabsHistoryPreload = 20;
|
||||
|
||||
// Show the greeter "marked-as-greeted" toggle button next to each
|
||||
// temp tab and dim the tab name when set. Off by default because the
|
||||
// workflow is specific to club-greeter use cases — most users just
|
||||
// want the auto tabs themselves without the extra UI affordance.
|
||||
public bool AutoTellTabsShowGreetedToggle;
|
||||
|
||||
// Hellion Chat — One-Time-Hint-Banner that introduces the v0.6.0 pop-out
|
||||
// input feature. Set to true once the user dismisses the banner from a
|
||||
// pop-out window; never reset after that.
|
||||
public bool SeenPopOutInputHint;
|
||||
|
||||
// Hellion Chat — v0.6.0 master switch for the pop-out input bar.
|
||||
// Global on purpose: per-tab makes no sense for Auto-Tell-Tabs which
|
||||
// are session-only and would force the user to re-enable it for every
|
||||
// new conversation. Default flipped to ON in v0.6.1 (was OFF in v0.6.0)
|
||||
// because tester feedback called the manual toggle "umständlich, wirkt
|
||||
// unfertig". v11 → v12 migration applies the same flip to existing users.
|
||||
public bool PopOutInputEnabled = true;
|
||||
|
||||
// Hellion Chat — v0.6.1 One-Time-Hint-Banner that introduces the
|
||||
// chat-header pop-out toolbar button and reminds about the pop-out
|
||||
// input default flip. Set to true once the user dismisses the banner
|
||||
// from the main chat window; never reset after that.
|
||||
public bool SeenPopOutHeaderHint;
|
||||
|
||||
// Hellion Chat — v0.6.1 opt-in: when true, AutoTellTabsService.SpawnTempTab
|
||||
// sets tab.PopOut = true on every new auto-tell tab so the conversation
|
||||
// pops out as its own window directly. Closing the pop-out returns the
|
||||
// tab to the sidebar via the standard Popout.OnClose() flow. Default OFF
|
||||
// because the existing sidebar workflow is what most users (especially
|
||||
// club greeters tracking many parallel tells) expect by default.
|
||||
public bool AutoTellTabsOpenAsPopout;
|
||||
|
||||
public int GetRetentionDays(ChatType type)
|
||||
@@ -104,7 +167,10 @@ public class Configuration : IPluginConfiguration
|
||||
public bool HideInLoadingScreens;
|
||||
public bool HideInBattle;
|
||||
|
||||
// v1.2.1: default flipped false → true for consistency with other hide defaults.
|
||||
// v1.2.1 — Default geflippt false → true. Hellion-UI im NG+-Menü
|
||||
// versteckt zu halten ist konsistent mit den anderen Hide-Defaults
|
||||
// (Cutscenes, Logged-out, UI-Hidden) — UI-out-of-the-way bei Story-
|
||||
// Sequenzen.
|
||||
public bool HideInNewGamePlusMenu = true;
|
||||
public bool HideWhenInactive;
|
||||
public int InactivityHideTimeout = 10;
|
||||
@@ -120,8 +186,18 @@ public class Configuration : IPluginConfiguration
|
||||
public bool NativeItemTooltips = true;
|
||||
public bool PrettierTimestamps = true;
|
||||
public bool MoreCompactPretty;
|
||||
|
||||
// v1.2.1 — Default geflippt false → true. Wiederholte Zeitstempel
|
||||
// innerhalb derselben Minute lesen sich als Rauschen; ein einziger
|
||||
// Timestamp pro Minute reicht aus um die Konversation zu verorten.
|
||||
public bool HideSameTimestamps = true;
|
||||
public bool ShowNoviceNetwork;
|
||||
|
||||
// Hellion Chat — vertical sidebar tab layout reads better than the
|
||||
// horizontal tab strip in the company of Auto-Tell-Tabs (a club
|
||||
// greeter typically tracks 5–15 simultaneous conversations). Bestand
|
||||
// users keep their saved value untouched — only fresh installs pick
|
||||
// up the new default.
|
||||
public bool SidebarTabView = true;
|
||||
public bool PrintChangelog = true;
|
||||
public bool OnlyPreviewIf;
|
||||
@@ -142,10 +218,22 @@ public class Configuration : IPluginConfiguration
|
||||
public bool CollapseKeepUniqueLinks;
|
||||
public bool PlaySounds = true;
|
||||
public bool KeepInputFocus = true;
|
||||
|
||||
// v1.2.1 — Default gesenkt 5000 → 2500. 5000 ist auf Mid-Range-
|
||||
// Hardware bei langen Sessions spürbar langsamer (Card-Layout
|
||||
// re-Layout pro Frame), 2500 deckt eine typische Stunde Chat ab
|
||||
// und bleibt smooth. User die mehr brauchen können bis 10000 hoch.
|
||||
public int MaxLinesToRender = 2_500; // 1-10000
|
||||
|
||||
// Default ON to match a German / European 24h locale. The
|
||||
// ChatLogWindow.cs format-flip in v0.5.1 honours this strictly via
|
||||
// CultureInfo.InvariantCulture so the result is consistent across
|
||||
// host locales.
|
||||
public bool Use24HourClock = true;
|
||||
|
||||
public bool ShowEmotes = true;
|
||||
public HashSet<string> BlockedEmotes = [];
|
||||
|
||||
public bool FontsEnabled = true;
|
||||
public ExtraGlyphRanges ExtraGlyphRanges = 0;
|
||||
public float FontSizeV2 = 12.75f;
|
||||
@@ -170,6 +258,12 @@ public class Configuration : IPluginConfiguration
|
||||
|
||||
public float TooltipOffset;
|
||||
|
||||
// v1.2.1 — Default-Chat-Farben sind das Hellion-Brand-Preset. Der
|
||||
// First-Run-Wizard bietet keine Theme-/Preset-Wahl an, daher kriegen
|
||||
// neue User die Hellion-Brand-Farben out-of-the-box (Cyan-Familie für
|
||||
// Standard/Tell, Ember/Warning für laute Channels). Bestand-User mit
|
||||
// leerem ChatColours-Dict werden durch die v15→v16-Migration auf das
|
||||
// Preset gehoben; User die bereits Custom-Farben haben, bleiben.
|
||||
public Dictionary<ChatType, uint> ChatColours = BuildDefaultChatColours();
|
||||
|
||||
private static Dictionary<ChatType, uint> BuildDefaultChatColours()
|
||||
@@ -239,7 +333,9 @@ public class Configuration : IPluginConfiguration
|
||||
MaxLinesToRender = other.MaxLinesToRender;
|
||||
Use24HourClock = other.Use24HourClock;
|
||||
ShowEmotes = other.ShowEmotes;
|
||||
// Deep-copy so settings window edits don't leak into live config before Save.
|
||||
// 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;
|
||||
@@ -253,11 +349,22 @@ public class Configuration : IPluginConfiguration
|
||||
ChatColours = other.ChatColours.ToDictionary(entry => entry.Key, entry => entry.Value);
|
||||
ColorSelectedInputChannelButton = other.ColorSelectedInputChannelButton;
|
||||
|
||||
// Keep live temp tabs alive across UpdateFrom — a settings save must
|
||||
// not destroy open tell conversations. For persistent tabs, capture
|
||||
// the live MessageList and LastSendUnread by Identifier before the
|
||||
// replace and restore them onto the freshly cloned tabs; new tabs
|
||||
// get an empty MessageList, deleted tabs lose their history (intended).
|
||||
// Hellion Chat — Auto-Tell-Tabs are session-only and therefore
|
||||
// never present in a disk-loaded copy. Keep the live temp tabs of
|
||||
// *this* configuration alive across an UpdateFrom so a settings
|
||||
// save (or sidebar-mode toggle) does not silently destroy the
|
||||
// user's open tell conversations.
|
||||
//
|
||||
// For persistent tabs we go through Tab.Clone() which intentionally
|
||||
// does NOT copy the NonSerialized Messages list (avoids shared
|
||||
// mutable state on disk-load). On a settings save that means the
|
||||
// chat history for every persistent tab would be wiped — bug
|
||||
// reported by Flo 2026-05-05. We work around it by capturing the
|
||||
// live MessageList (and LastSendUnread counter) by Identifier
|
||||
// before the replace, then restoring it onto the freshly cloned
|
||||
// tabs whose Identifier survives Tab.Clone(). New tabs added in
|
||||
// settings get a fresh empty MessageList; deleted tabs lose their
|
||||
// history (intended).
|
||||
var liveTempTabs = Tabs.Where(t => t.IsTempTab).ToList();
|
||||
var livePersistentSession = Tabs.Where(t => !t.IsTempTab)
|
||||
.ToDictionary(t => t.Identifier, t => (t.Messages, t.LastSendUnread));
|
||||
@@ -349,7 +456,9 @@ public class Tab
|
||||
{
|
||||
public string Name = Language.Tab_DefaultName;
|
||||
|
||||
// Optional FontAwesome glyph name; null falls back to TabIconMapping default.
|
||||
// v1.2.0 — optionaler FontAwesome-Glyph-Name. Null bedeutet:
|
||||
// Default-Mapping aus TabIconMapping greift (basiert auf Tab-Name).
|
||||
// User können hier per Settings → Tabs einen eigenen Glyph setzen.
|
||||
public string? Icon = null;
|
||||
|
||||
[Obsolete("Removed in favor of SelectedChannels")]
|
||||
@@ -401,12 +510,15 @@ public class Tab
|
||||
[NonSerialized]
|
||||
public Guid Identifier = Guid.NewGuid();
|
||||
|
||||
// Session-only greeted flag for club-greeter workflows.
|
||||
// Hellion Chat — Auto-Tell-Tabs greeted flag. Toggled manually from the
|
||||
// sidebar to mark a tell partner as already greeted in the current
|
||||
// session. NonSerialized because the temp tab itself is session-only.
|
||||
[NonSerialized]
|
||||
public bool IsGreeted;
|
||||
|
||||
// Separate validation keys per cache so TellTarget changes don't
|
||||
// cause GetTint and GetIcon to strand each other with stale entries.
|
||||
// v1.4.2 — TabTintCache uses separate validation keys per cache so a
|
||||
// TellTarget change picked up by GetTint can't strand GetIcon (or vice
|
||||
// versa) with a stale entry that looks fresh on the shared key.
|
||||
[NonSerialized]
|
||||
internal string? _cachedTintTellName;
|
||||
|
||||
@@ -428,12 +540,17 @@ public class Tab
|
||||
public bool Matches(Message message)
|
||||
{
|
||||
if (!message.Matches(SelectedChannels, ExtraChatAll, ExtraChatChannels))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Temp tabs are bound to a single conversation partner — other tells
|
||||
// matching the channel filter must not land here.
|
||||
// Auto-tell temp tabs are bound to a single conversation partner;
|
||||
// every other tell that matches the channel filter must NOT land
|
||||
// here, otherwise all temp tabs would mirror "Tell Exclusive".
|
||||
if (IsTempTab && TellTarget?.IsSet() == true)
|
||||
{
|
||||
return ChunkUtil.MatchesSender(message, TellTarget.Name, TellTarget.World);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -493,7 +610,10 @@ public class Tab
|
||||
};
|
||||
}
|
||||
|
||||
/// Ordered message list with duplicate ID tracking, sorting and mutex protection.
|
||||
/// <summary>
|
||||
/// MessageList provides an ordered list of messages with duplicate ID
|
||||
/// tracking, sorting and mutex protection.
|
||||
/// </summary>
|
||||
public class MessageList
|
||||
{
|
||||
private readonly SemaphoreSlim LockSlim = new(1, 1);
|
||||
@@ -581,7 +701,10 @@ public class Tab
|
||||
}
|
||||
}
|
||||
|
||||
/// Current message count. Lock-per-read is acceptable for 1×/sec status bar polling.
|
||||
/// <summary>
|
||||
/// Aktuelle Anzahl der gespeicherten Messages. Lock-acquire pro Read
|
||||
/// ist OK für 1×/sec Status-Bar-Polling (v1.2.0).
|
||||
/// </summary>
|
||||
public int Count
|
||||
{
|
||||
get
|
||||
@@ -598,7 +721,9 @@ public class Tab
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an array copy of the message list for usage outside of main thread.
|
||||
/// <summary>
|
||||
/// Returns an array copy of the message list for usage outside of main thread
|
||||
/// </summary>
|
||||
public async Task<Message[]> GetCopy(int millisecondsTimeout = -1)
|
||||
{
|
||||
await LockSlim.WaitAsync(millisecondsTimeout);
|
||||
@@ -612,7 +737,10 @@ public class Tab
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a read-only list while holding a reader lock. Use with a using statement.
|
||||
/// <summary>
|
||||
/// GetReadOnly returns a read-only list of messages while holding a
|
||||
/// reader lock. The list should be used with a using statement.
|
||||
/// </summary>
|
||||
public RLockedMessageList GetReadOnly(int millisecondsTimeout = -1)
|
||||
{
|
||||
LockSlim.Wait(millisecondsTimeout);
|
||||
|
||||
+29
-12
@@ -79,7 +79,7 @@ public static class EmoteCache
|
||||
Done,
|
||||
}
|
||||
|
||||
// All fields below are uninitialised while State != Done.
|
||||
// All of this data is uninitalized while State is not `LoadingState.Done`
|
||||
public static LoadingState State = LoadingState.Unloaded;
|
||||
|
||||
private static readonly Dictionary<string, Emote> Cache = new();
|
||||
@@ -87,11 +87,15 @@ public static class EmoteCache
|
||||
|
||||
public static string[] SortedCodeArray = [];
|
||||
|
||||
// Cancelled on Dispose to stop in-flight downloads; replaced on re-enable.
|
||||
// Plugin-scoped cancellation source for in-flight emote loads. Dispose
|
||||
// cancels every running download/texture-create so the workers don't
|
||||
// touch a torn-down TextureProvider on plugin reload. Replaced with a
|
||||
// fresh source on the next LoadData() call so a re-enable still works.
|
||||
private static CancellationTokenSource Cts = new();
|
||||
internal static CancellationToken Token => Cts.Token;
|
||||
|
||||
// Tracks in-flight loads so Dispose can drain them before teardown.
|
||||
// Drain target for in-flight loads on Dispose; without this an orphan
|
||||
// continuation could still write to a torn-down Texture/Frames field.
|
||||
private static readonly ConcurrentBag<Task> PendingLoads = new();
|
||||
|
||||
internal static void TrackLoad(Task loadTask, string emoteCode)
|
||||
@@ -113,7 +117,8 @@ public static class EmoteCache
|
||||
if (State is not LoadingState.Unloaded)
|
||||
return;
|
||||
|
||||
// Reset CTS if Dispose was called and the plugin is being re-enabled.
|
||||
// Refresh the CTS in case Dispose was called and we're being re-enabled
|
||||
// in the same process (Dalamud /xlplugins toggle).
|
||||
if (Cts.IsCancellationRequested)
|
||||
Cts = new CancellationTokenSource();
|
||||
|
||||
@@ -135,8 +140,11 @@ public static class EmoteCache
|
||||
var topList = await top.Content.ReadAsStringAsync(ct);
|
||||
|
||||
var jsonList = JsonSerializer.Deserialize<List<Top100>>(topList)!;
|
||||
// BetterTTV occasionally returns entries with a null Code;
|
||||
// skip them so a single bad row doesn't break the whole cache.
|
||||
// BetterTTV occasionally returns entries with a null Code; the
|
||||
// upstream code passed those straight into Dictionary.TryAdd
|
||||
// and tripped ArgumentNullException, killing the whole emote
|
||||
// load. Skip them defensively so a single bad row no longer
|
||||
// breaks the cache for everyone else.
|
||||
foreach (var emote in jsonList)
|
||||
if (
|
||||
!string.IsNullOrEmpty(emote.Emote.Code)
|
||||
@@ -152,11 +160,16 @@ public static class EmoteCache
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Plugin disposed mid-load; State stays on Loading so re-enable can retry.
|
||||
// Plugin disposed while the cache was loading; leave State on
|
||||
// Loading so a subsequent re-enable can re-issue LoadData with
|
||||
// a fresh CTS (handled above).
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Reset to Unloaded so a later trigger can retry without a plugin reload.
|
||||
// Reset to Unloaded so a later trigger (e.g. the user reopening
|
||||
// the Emotes tab after the network recovers) can retry. Without
|
||||
// this the State stays on Loading and the early-out at the top
|
||||
// of LoadData blocks every further attempt until plugin reload.
|
||||
State = LoadingState.Unloaded;
|
||||
Plugin.Log.Error(ex, "BetterTTV cache wasn't initialized");
|
||||
}
|
||||
@@ -235,8 +248,11 @@ public static class EmoteCache
|
||||
|
||||
internal async Task<byte[]> LoadAsync(Emote emote, CancellationToken ct)
|
||||
{
|
||||
// Path-traversal guard: resolve and verify the candidate path stays
|
||||
// inside the cache directory before reading or writing.
|
||||
// BetterTTV-supplied Id and ImageType are interpolated straight
|
||||
// into the filename. HTTPS protects the wire, but a compromised
|
||||
// upstream could still hand us "../foo" and write into the
|
||||
// pluginConfigs root (or worse). Resolve the candidate path and
|
||||
// refuse anything that escapes the cache directory.
|
||||
var dir = Path.GetFullPath(
|
||||
Path.Join(Plugin.Interface.ConfigDirectory.FullName, "EmoteCacheV1")
|
||||
);
|
||||
@@ -381,7 +397,7 @@ public static class EmoteCache
|
||||
|
||||
var delay = frame.Metadata.GetGifMetadata().FrameDelay / 100f;
|
||||
|
||||
// Match browser behaviour: anything under 20ms rounds up to 100ms.
|
||||
// Follows the same pattern as browsers, anything under 0.02s delay will be rounded up to 0.1s
|
||||
if (delay < 0.02f)
|
||||
delay = 0.1f;
|
||||
|
||||
@@ -400,7 +416,8 @@ public static class EmoteCache
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Plugin disposed mid-load; release any partial frames.
|
||||
// Plugin disposed mid-load; partial frames are released by
|
||||
// InnerDispose on the next dispose pass.
|
||||
foreach (var f in Frames)
|
||||
f.Texture.Dispose();
|
||||
Frames = [];
|
||||
|
||||
@@ -41,7 +41,12 @@ public class FontManager
|
||||
90f,
|
||||
];
|
||||
|
||||
// Hellion font bytes (Exo 2, OFL-1.1); lazily loaded from manifest resources
|
||||
/// <summary>
|
||||
/// Backing bytes for the bundled Hellion font (Exo 2, OFL-1.1). Lazily
|
||||
/// extracted from the assembly's manifest resources on first use; the
|
||||
/// load happens inside the font atlas build callback so we keep the
|
||||
/// allocation off the plugin constructor's hot path.
|
||||
/// </summary>
|
||||
private static byte[]? HellionFontBytes;
|
||||
|
||||
private static byte[] GetHellionFontBytes()
|
||||
@@ -65,9 +70,11 @@ public class FontManager
|
||||
ushort[] BuildRange(IReadOnlyList<ushort>? chars, params nint[] ranges)
|
||||
{
|
||||
var builder = new ImFontGlyphRangesBuilderPtr(ImGuiNative.ImFontGlyphRangesBuilder());
|
||||
// text
|
||||
foreach (var range in ranges)
|
||||
builder.AddRanges((ushort*)range);
|
||||
|
||||
// chars
|
||||
if (chars != null)
|
||||
{
|
||||
for (var i = 0; i < chars.Count; i += 2)
|
||||
@@ -109,7 +116,13 @@ public class FontManager
|
||||
JpRange = BuildRange(GlyphRangesJapanese.GlyphRanges);
|
||||
}
|
||||
|
||||
// CPU-bound build offloaded to Task.Run; runs parallel with theme init
|
||||
/// <summary>
|
||||
/// Async wrapper around <see cref="BuildFonts"/> for the Phase-1 LoadAsync
|
||||
/// path. The font-atlas build is CPU-bound, so we offload via Task.Run and
|
||||
/// honour the cancellation token at the scheduling boundary; this lets the
|
||||
/// font build run in parallel with the theme init without blocking the
|
||||
/// loader. Settings-driven manual rebuilds keep using the sync entry point.
|
||||
/// </summary>
|
||||
public async Task BuildFontsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
@@ -141,7 +154,12 @@ public class FontManager
|
||||
RegularFont = Plugin.Interface.UiBuilder.FontAtlas.NewDelegateFontHandle(e =>
|
||||
e.OnPreBuild(tk =>
|
||||
{
|
||||
// v1.2.0: UseHellionFont controls font size selection
|
||||
// v1.2.0 — Bei aktiver Hellion-Schrift (Exo 2 ist Variable-Font)
|
||||
// wird die User-Schriftgröße aus FontSizeV2 als SizePt angewendet.
|
||||
// Der Bestand-Pfad nutzt weiter GlobalFontV2.SizePt aus dem
|
||||
// Custom-Font-Stack. Ohne diese Verzweigung war FontSizeV2 bei
|
||||
// UseHellionFont=true wirkungslos, was 4K-User mit größerer
|
||||
// Skalierung blockierte (Settings → Erscheinungsbild → Schriftarten).
|
||||
var basePt = Plugin.Config.UseHellionFont
|
||||
? Plugin.Config.FontSizeV2
|
||||
: Plugin.Config.GlobalFontV2.SizePt;
|
||||
@@ -200,7 +218,13 @@ public class FontManager
|
||||
}
|
||||
}
|
||||
|
||||
// Add font with fallback to NotoSansCjkRegular if unavailable
|
||||
/// <summary>
|
||||
/// Try to add a user-configured font to the build toolkit, falling back to
|
||||
/// the bundled NotoSansCjkRegular asset if the configured font isn't
|
||||
/// available on the system. Without this guard a stale SystemFontId
|
||||
/// pointing at a font the user uninstalled or that never existed on
|
||||
/// Linux (e.g. "Crimson Text") tears down the entire font atlas build.
|
||||
/// </summary>
|
||||
private static ImFontPtr AddFontWithFallback(
|
||||
IFontAtlasBuildToolkitPreBuild tk,
|
||||
IFontId fontId,
|
||||
|
||||
@@ -1,21 +1,36 @@
|
||||
<Project Sdk="Dalamud.NET.Sdk/15.0.0">
|
||||
<PropertyGroup>
|
||||
<!-- Independent versioning; see yaml changelog for upstream Chat 2 base -->
|
||||
<!-- Hellion Chat versioning runs separately from upstream Chat 2.
|
||||
0.1.0 is our bootstrap release; the underlying Chat 2 base is
|
||||
called out in the yaml changelog so users can see what it
|
||||
derives from. -->
|
||||
<Version>1.4.3</Version>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<!-- Use lock file to pin exact versions -->
|
||||
<!-- Honor packages.lock.json on restore so floating version ranges
|
||||
don't silently drift between machines or CI runs. -->
|
||||
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
|
||||
<!-- v1.0.0+: standalone fork, no upstream cherry-pick compatibility -->
|
||||
<!-- v1.0.0 standalone cut — both AssemblyName and RootNamespace
|
||||
are HellionChat. The plugin no longer maintains source-level
|
||||
cherry-pick compatibility with upstream Infiziert90/ChatTwo;
|
||||
upstream changes are integrated manually if at all. -->
|
||||
<AssemblyName>HellionChat</AssemblyName>
|
||||
<RootNamespace>HellionChat</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Closed ranges prevent surprise major bumps during lock file regeneration -->
|
||||
<!-- Closed ranges on packages with breaking-change history block a
|
||||
surprise major bump when the lock file is regenerated. The
|
||||
lock file pins the exact version per build; the upper bound
|
||||
keeps the unlock path from drifting across major lines. -->
|
||||
<PackageReference Include="MessagePack" Version="[3.1.4, 4.0.0)" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.7" />
|
||||
<!-- SQLitePCLRaw override for CVE-2025-6965, CVE-2025-7709 (SQLite >= 3.50.3) -->
|
||||
<!-- Override the transitively-referenced native SQLite build to one
|
||||
that ships SQLite >= 3.50.3 (CVE-2025-6965 memory corruption,
|
||||
CVE-2025-7709 fixed in 3.50.x). Microsoft.Data.Sqlite 10.0.7
|
||||
pulls SQLitePCLRaw 2.1.11 which carries the older lib; pinning
|
||||
the lib package directly forces the newer native binary
|
||||
without a major bump on the managed wrapper. -->
|
||||
<PackageReference Include="SQLitePCLRaw.lib.e_sqlite3" Version="3.50.3" />
|
||||
<PackageReference Include="morelinq" Version="4.4.0" />
|
||||
<PackageReference Include="Pidgin" Version="[3.5.1, 4.0.0)" />
|
||||
@@ -23,7 +38,9 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Test assembly needs access to internal helpers (not redistributed) -->
|
||||
<!-- Pure-function test suites in HellionChat.Tests need access to
|
||||
the internal helper classes (StringUtil, UriPayload, Tokenizer
|
||||
etc.). Test assembly does not get redistributed. -->
|
||||
<InternalsVisibleTo Include="HellionChat.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -42,7 +59,15 @@
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Embedded resources: Hellion font (Exo 2, OFL-1.1) + manifest resource -->
|
||||
<!-- HellionChat — Hellion-specific resource bundle (HellionStrings.resx
|
||||
+ HellionStrings.<lang>.resx) is picked up automatically by the SDK
|
||||
default include. Designer.cs is hand-maintained, no auto-gen needed. -->
|
||||
|
||||
<!-- Bundled Hellion font (Exo 2, OFL-1.1). Embedded as a manifest
|
||||
resource with a fixed LogicalName so FontManager can pull the
|
||||
bytes back at runtime via AddFontFromMemory. The OFL license
|
||||
text travels with it inside the assembly to satisfy the
|
||||
"license must be distributed with the font" clause. -->
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Resources\HellionFont.ttf">
|
||||
<LogicalName>HellionFont.ttf</LogicalName>
|
||||
@@ -55,7 +80,14 @@
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Plugin icon: copy images/* to output for Dalamud discovery -->
|
||||
<!-- Plugin icon. Copy images/* into the build output so Dalamud
|
||||
finds the icon next to the DLL, and let the SDK default
|
||||
DalamudPackager pipeline include the same path in the
|
||||
release ZIP. Earlier we shipped a custom DalamudPackager
|
||||
targets override that explicitly set HandleImages and
|
||||
ImagesPath; that override conflicted with the SDK 15
|
||||
default and the resulting manifest carried no IconUrl.
|
||||
Removed in v0.5.2. -->
|
||||
<ItemGroup>
|
||||
<None Include="images\**">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
|
||||
@@ -31,6 +31,26 @@ description: |-
|
||||
- Independent plugin state — own config file and database directory,
|
||||
so Hellion Chat does not share state with upstream Chat 2
|
||||
|
||||
v1.3.0 First plugin integration cycle. Honorific custom titles
|
||||
are shown in the chat header above the message log, with auto-detect
|
||||
and silent fallback when Honorific is not installed.
|
||||
|
||||
v1.4.0 — Critical Lifecycle Fixes. Plugin reload and shutdown
|
||||
are cleaner: SQLite no longer leans on GC pressure to release
|
||||
its file, worker threads are explicitly background, deferred
|
||||
config saves no longer get lost mid-disable, and pre-v13 config
|
||||
backups carry the user's custom theme opacity into the v14 schema
|
||||
instead of falling back to the default.
|
||||
|
||||
v1.4.1 — Theme Engine Performance plus a tenth built-in.
|
||||
HellionStyle.PushGlobal reads pre-computed ABGR values from a
|
||||
per-theme cache instead of converting RGBA per slot per frame
|
||||
(~13 % render-time recovery in typical scenes). Custom-theme
|
||||
hot-reload survives transient file locks (editor mid-save
|
||||
keeps the last-known-good snapshot). Synthwave Sunset joins
|
||||
as the tenth built-in theme — Hot Magenta + Cyan on midnight
|
||||
violet, 80s neon-grid vibes.
|
||||
|
||||
v1.4.2 — ChatLog Frame-Hot-Path. Three per-frame allocation
|
||||
patterns gone from the chat-log render path: card-mode borders
|
||||
hoist invariants out of the per-message loop, auto-tell tab
|
||||
@@ -164,6 +184,38 @@ changelog: |-
|
||||
|
||||
Based on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).
|
||||
|
||||
**Hellion Chat 1.4.0 — Critical Lifecycle Fixes**
|
||||
|
||||
First sub-patch of the v1.4.x Polish Sweep series. Seven
|
||||
known lifecycle and race bugs eliminated before any
|
||||
performance refactor sits on top.
|
||||
|
||||
- MessageStore disposal no longer triggers GC.Collect
|
||||
globally; Pooling=false on the SQLite connection means
|
||||
there's nothing left to clean up by hand
|
||||
- PendingMessage and RetentionSweep worker threads are
|
||||
explicitly marked IsBackground=true so the plugin domain
|
||||
can unload during XIVLauncher reload without waiting
|
||||
for them
|
||||
- EmoteCache image and gif loaders moved from async-void
|
||||
to async Task with a shared task tracker, draining
|
||||
on Dispose so an in-flight load can no longer write
|
||||
to a disposed EmoteImages entry
|
||||
- DisposeAsync 10s timeout now warns loudly instead of
|
||||
silently leaving the worker behind
|
||||
- Plugin.Dispose flushes any pending DeferredSaveFrames
|
||||
before tearing services down, so settings changes
|
||||
made in the last few frames before disable are no
|
||||
longer lost
|
||||
- The v13→v14 config migration now reads the pre-v13
|
||||
backup and carries HellionThemeWindowOpacity into the
|
||||
new WindowOpacity field instead of falling back to
|
||||
the default 0.85
|
||||
|
||||
Modding & support: join Hellion Forge — https://discord.gg/X9V7Kcv5gR
|
||||
|
||||
Based on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).
|
||||
|
||||
---
|
||||
|
||||
Earlier history: https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases
|
||||
|
||||
@@ -2,8 +2,14 @@ using System.Collections.Generic;
|
||||
|
||||
namespace HellionChat;
|
||||
|
||||
// Shared input history for all ChatInputBars (main and pop-out windows).
|
||||
// Push deduplicates: existing entries are moved to the end when re-added.
|
||||
// Hellion Chat — v0.6.0 shared input history. Replaces the embedded
|
||||
// ChatLogWindow.InputBacklog so that pop-out windows with their own
|
||||
// ChatInputBar can navigate the same Up/Down history as the main window.
|
||||
// Index semantics are kept identical to the v0.5.x InputBacklog:
|
||||
// index 0 = oldest entry
|
||||
// index Count - 1 = newest entry
|
||||
// Push performs move-to-newest deduplication: existing entries are
|
||||
// removed before the new one is appended at the end.
|
||||
public static class InputHistoryService
|
||||
{
|
||||
private const int MaxSize = 30;
|
||||
@@ -20,7 +26,8 @@ public static class InputHistoryService
|
||||
|
||||
var trimmed = entry.Trim();
|
||||
|
||||
// Move-to-newest: remove existing entry before adding at the end
|
||||
// Move-to-newest: existing entries are removed before the append
|
||||
// so the same line typed twice does not occupy two history slots.
|
||||
for (var i = 0; i < _entries.Count; i++)
|
||||
{
|
||||
if (_entries[i] == trimmed)
|
||||
|
||||
@@ -6,17 +6,25 @@ using Newtonsoft.Json;
|
||||
|
||||
namespace HellionChat.Integrations;
|
||||
|
||||
// Newtonsoft.Json is used here for IPC compatibility with Honorific, which
|
||||
// serialises TitleData with it. It's a transitive Dalamud dependency — no
|
||||
// new NuGet entry needed. The rest of HellionChat uses System.Text.Json.
|
||||
// We pull Newtonsoft.Json into this single file for IPC compatibility:
|
||||
// Honorific serialises its TitleData with Newtonsoft (see
|
||||
// Honorific-master/IpcProvider.cs:9 and CustomTitle.cs:12). Using the
|
||||
// same library guarantees identical handling of System.Numerics.Vector3?
|
||||
// and the enum fields we ignore. Newtonsoft is a transitive dependency
|
||||
// via Dalamud, so no new NuGet entry is needed. The rest of HellionChat
|
||||
// keeps using System.Text.Json.
|
||||
internal sealed class HonorificService : IDisposable
|
||||
{
|
||||
private const string IpcNamespace = "Honorific";
|
||||
|
||||
// Major version of the Honorific IPC contract we're built against.
|
||||
// Major version of the Honorific IPC contract HellionChat is built against.
|
||||
// Used both by the runtime compatibility check and by the settings tab when
|
||||
// it tells the user which major version we expected, so the literal lives
|
||||
// in exactly one place.
|
||||
internal const uint ExpectedApiMajor = 3;
|
||||
|
||||
// IPC gates — kept as fields so Dispose can unsubscribe the same instances.
|
||||
// IPC gates we subscribe to. Keep them as fields so Dispose can
|
||||
// unsubscribe the same instances we subscribed in the constructor.
|
||||
private readonly ICallGateSubscriber<(uint, uint)> _apiVersion;
|
||||
private readonly ICallGateSubscriber<string> _getLocalCharacterTitle;
|
||||
private readonly ICallGateSubscriber<string, object> _localCharacterTitleChanged;
|
||||
@@ -40,11 +48,23 @@ internal sealed class HonorificService : IDisposable
|
||||
_framework = framework;
|
||||
_log = log;
|
||||
|
||||
// Gate objects are cached per-name by Dalamud and safe to register
|
||||
// before Honorific loads — they just won't fire until it does.
|
||||
// Initial pull is scheduled on the framework thread because plugin
|
||||
// constructors run on the loader thread, and Honorific's IPC handlers
|
||||
// read ObjectTable.LocalPlayer which throws off the framework thread.
|
||||
// Dalamud caches gate objects per-name for the lifetime of the
|
||||
// plugin interface, so we can register subscribers even when
|
||||
// Honorific isn't loaded yet — the gate just won't fire. Calling
|
||||
// InvokeFunc before Honorific is up will throw, which is why the
|
||||
// initial pull below is wrapped in try-catch.
|
||||
//
|
||||
// Thread-context: plugin constructors run on Dalamud's plugin-loader
|
||||
// thread, NOT the framework thread. Honorific's IPC handlers read
|
||||
// ObjectTable.LocalPlayer (Honorific IpcProvider.cs:61), which throws
|
||||
// "Not on main thread!" outside the framework thread. If Honorific is
|
||||
// already loaded when HellionChat starts, a synchronous InvokeFunc
|
||||
// here would surface that exception, the broad catch below would
|
||||
// mark IsAvailable=false, and OnTitleChanged's `if (!IsAvailable)`
|
||||
// gate would block every subsequent title update. We therefore
|
||||
// schedule the initial pull onto the framework thread via
|
||||
// IFramework.RunOnFrameworkThread so the IPC call sees the right
|
||||
// thread context.
|
||||
_apiVersion = pluginInterface.GetIpcSubscriber<(uint, uint)>($"{IpcNamespace}.ApiVersion");
|
||||
_getLocalCharacterTitle = pluginInterface.GetIpcSubscriber<string>(
|
||||
$"{IpcNamespace}.GetLocalCharacterTitle"
|
||||
@@ -64,8 +84,11 @@ internal sealed class HonorificService : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Wrap each unsubscribe — a missing gate must not block the others.
|
||||
// Leaking a subscription keeps this service alive across plugin reloads.
|
||||
// Honorific may already be gone by the time we dispose. Wrap each
|
||||
// unsubscribe so a missing gate doesn't prevent the others from
|
||||
// unsubscribing — leaking even one subscription leaves a callback
|
||||
// alive that captures `this`, which keeps the whole service alive
|
||||
// and breaks plugin reload.
|
||||
TryUnsubscribe(() => _localCharacterTitleChanged.Unsubscribe(OnTitleChanged));
|
||||
TryUnsubscribe(() => _ready.Unsubscribe(OnReady));
|
||||
TryUnsubscribe(() => _disposing.Unsubscribe(OnDisposing));
|
||||
@@ -96,21 +119,34 @@ internal sealed class HonorificService : IDisposable
|
||||
|
||||
IsAvailable = true;
|
||||
_versionWarningLogged = false;
|
||||
// Pull the current title once at startup; from here on we rely
|
||||
// on LocalCharacterTitleChanged events.
|
||||
var json = _getLocalCharacterTitle.InvokeFunc();
|
||||
CurrentTitle = ParseTitleJson(json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Honorific not installed or not yet initialised — Ready will retry.
|
||||
// Honorific isn't installed or hasn't initialised yet. The Ready
|
||||
// event will give us a second chance later. Log at Debug so
|
||||
// users without Honorific don't see noise on every reload.
|
||||
_log.Debug(ex, "Honorific not available at HellionChat startup; awaiting Ready.");
|
||||
IsAvailable = false;
|
||||
CurrentTitle = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Honorific fires LocalCharacterTitleChanged through its nameplate hook
|
||||
// (Honorific-master/Plugin.cs:665), which means we get title updates on
|
||||
// character switches automatically as soon as the new character is
|
||||
// rendered. While the user is in the character-select menu, HellionChat's
|
||||
// window is hidden by default via HideWhenNotLoggedIn (Configuration.cs:152),
|
||||
// so the stale-title window between logout and login isn't user-visible.
|
||||
private void OnTitleChanged(string json)
|
||||
{
|
||||
// Skip updates on version mismatch; subscription stays live for reload.
|
||||
// Don't update cached state when we've already decided we can't trust
|
||||
// Honorific (e.g. version mismatch). Subscription stays live in case a
|
||||
// compatible Honorific reloads, in which case Ready triggers TryInitialPull
|
||||
// and sets IsAvailable back to true.
|
||||
if (!IsAvailable)
|
||||
return;
|
||||
CurrentTitle = ParseTitleJson(json);
|
||||
@@ -118,16 +154,28 @@ internal sealed class HonorificService : IDisposable
|
||||
|
||||
private void OnReady()
|
||||
{
|
||||
// Schedule on framework thread — NotifyReady can dispatch from any thread.
|
||||
// Honorific loaded after HellionChat; redo the version check and
|
||||
// initial pull. Idempotent on purpose — Honorific can fire Ready
|
||||
// more than once across reloads.
|
||||
//
|
||||
// Honorific's NotifyReady may dispatch from any thread, and
|
||||
// TryInitialPull eventually calls IPC handlers that read
|
||||
// ObjectTable.LocalPlayer — same "Not on main thread!" hazard as
|
||||
// the constructor path. Schedule onto the framework thread.
|
||||
_framework.RunOnFrameworkThread(TryInitialPull);
|
||||
}
|
||||
|
||||
private void OnDisposing()
|
||||
{
|
||||
// Honorific unloading — clear cached state so the header hides next frame.
|
||||
// Subscriptions stay registered in case Honorific reloads.
|
||||
// CurrentTitle is already nulled by OnTitleChanged before this fires,
|
||||
// re-clearing here is belt-and-braces.
|
||||
// Honorific is unloading. Drop our cached state so the header
|
||||
// hides on the next frame; subscriptions stay registered because
|
||||
// the gates may come back later (Honorific reload).
|
||||
//
|
||||
// Race-note: Honorific's NotifyDisposing calls ChangedLocalCharacterTitle(null)
|
||||
// BEFORE SendMessage on the Disposing gate (IpcProvider.cs:109-111),
|
||||
// so OnTitleChanged is expected to fire first and already null out
|
||||
// CurrentTitle. We re-clear here as belt-and-braces; should the
|
||||
// ordering ever flip, ShouldRenderSlot would still gate on IsAvailable.
|
||||
CurrentTitle = null;
|
||||
IsAvailable = false;
|
||||
DetectedApiVersion = null;
|
||||
@@ -145,15 +193,28 @@ internal sealed class HonorificService : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// Threading: IPC events and ImGui both run on the framework thread, so
|
||||
// OnTitleChanged and the render path never race — no volatile/Interlocked
|
||||
// needed as long as Dalamud's framework-thread delivery contract holds.
|
||||
// Threading note: Dalamud fires IPC events on the framework thread and
|
||||
// ImGui renders on the framework thread, so OnTitleChanged and the
|
||||
// render path that reads CurrentTitle never race — OnTitleChanged is
|
||||
// safe to keep direct (no RunOnFrameworkThread wrap needed) because
|
||||
// LocalCharacterTitleChanged delivery is framework-thread by Dalamud
|
||||
// contract. If a future change moves either side onto a worker thread,
|
||||
// switch to volatile/Interlocked for the CurrentTitle field.
|
||||
//
|
||||
// Constructor and OnReady are exceptions: they run outside that contract
|
||||
// (plugin-loader thread and Honorific's NotifyReady respectively), so both
|
||||
// use RunOnFrameworkThread to safely reach ObjectTable.LocalPlayer.
|
||||
// The constructor's initial pull and OnReady, on the other hand, are
|
||||
// explicitly scheduled via IFramework.RunOnFrameworkThread because
|
||||
// they run outside that contract: the constructor executes on the
|
||||
// plugin-loader thread, and Honorific's NotifyReady can dispatch from
|
||||
// any thread. Both call paths eventually invoke IPC handlers that read
|
||||
// ObjectTable.LocalPlayer, which throws "Not on main thread!" off the
|
||||
// framework thread — see the constructor comment block for context.
|
||||
//
|
||||
// Divergence from ChatTwo/Ipc/ExtraChat.cs: that file uses `volatile`
|
||||
// on its state fields out of caution. We don't, because the framework-
|
||||
// thread delivery is the documented Dalamud contract. If the two files
|
||||
// ever need to share a threading audit, this is the place to revisit.
|
||||
|
||||
// --- Pure-logic helpers; tested via HellionChat.Tests/Integrations. ---
|
||||
// --- Pure-logic helpers below; tested via HellionChat.Tests/Integrations. ---
|
||||
|
||||
internal static HonorificTitleData? ParseTitleJson(string json)
|
||||
{
|
||||
|
||||
@@ -2,9 +2,13 @@ using System.Numerics;
|
||||
|
||||
namespace HellionChat.Integrations;
|
||||
|
||||
// Local DTO mirroring Honorific's TitleData — no hard reference to Honorific.dll
|
||||
// so HellionChat loads cleanly when Honorific is absent.
|
||||
// Glow/gradient fields omitted; Cycle 1 renders primary Color only.
|
||||
// Local DTO mirroring Honorific's TitleData shape. We replicate the structure
|
||||
// instead of referencing Honorific.dll because a hard build-time dependency
|
||||
// would couple the two assemblies and break HellionChat at load time when
|
||||
// Honorific is missing. Glow, Color3, GradientColourSet and GradientAnimationStyle
|
||||
// are intentionally omitted — Cycle 1 renders text in the primary Color only;
|
||||
// the "Honorific Full Fidelity" backlog item adds them later as a pure
|
||||
// extension that won't break this DTO's existing consumers.
|
||||
internal sealed record HonorificTitleData(
|
||||
string? Title,
|
||||
bool IsPrefix,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
namespace HellionChat.Integrations;
|
||||
|
||||
// Third-party plugin URLs — separate from BrandingLinks (Hellion-owned URLs).
|
||||
// External URLs for the third-party plugins HellionChat integrates with.
|
||||
// Kept separate from BrandingLinks (which is for Hellion-owned URLs) so
|
||||
// future cycles can extend this file with maintainer attribution links
|
||||
// for Moodles, NotificationMaster, ExtraChat, etc. without polluting the
|
||||
// brand-links class.
|
||||
internal static class IntegrationLinks
|
||||
{
|
||||
public const string HonorificRepo = "https://github.com/Caraxi/Honorific";
|
||||
|
||||
@@ -27,7 +27,16 @@ internal class MessageManager : IAsyncDisposable
|
||||
private Dictionary<ChatType, NameFormatting> Formats { get; } = [];
|
||||
private ulong LastContentId { get; set; }
|
||||
|
||||
// PendingSync (main thread) → PendingAsync (worker thread); LinkedList for O(1) Last access
|
||||
// Messages go into the PendingSync queue first, which will be consumed one
|
||||
// at a time in the main thread. This is to delay the async processing until
|
||||
// after we've received the content ID from the ContentIdResolver hook.
|
||||
//
|
||||
// After that, the message is enqueued in the PendingAsync queue, which will
|
||||
// be consumed in a separate thread and perform more processing (emotes,
|
||||
// URLs) as well as inserting the message into the database.
|
||||
// LinkedList instead of Queue: ContentIdResolver hits PendingSync.Last
|
||||
// every hook call. Queue<T>.Last() is the LINQ extension and walks the
|
||||
// whole queue (O(n)); LinkedList<T>.Last is an O(1) node reference.
|
||||
private LinkedList<PendingMessage> PendingSync { get; } = [];
|
||||
private ConcurrentQueue<PendingMessage> PendingAsync { get; } = [];
|
||||
private readonly Thread PendingMessageThread;
|
||||
@@ -44,8 +53,11 @@ internal class MessageManager : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-Tell-Tabs hook: fires after a message is processed and stored, allowing
|
||||
// AutoTellTabsService to spawn or refresh temp tabs without coupling.
|
||||
// Hellion Chat — Auto-Tell-Tabs hook. Fires after a fully processed
|
||||
// message has been routed to all matching persistent tabs and stored
|
||||
// in the database. The AutoTellTabsService subscribes to spawn or
|
||||
// refresh temp tabs without having to wedge itself into ProcessMessage
|
||||
// directly.
|
||||
public event Action<Message>? MessageProcessed;
|
||||
|
||||
internal unsafe MessageManager(Plugin plugin)
|
||||
@@ -54,6 +66,8 @@ internal class MessageManager : IAsyncDisposable
|
||||
|
||||
Store = new MessageStore(DatabasePath());
|
||||
|
||||
// IsBackground so a stuck worker never blocks plugin unload.
|
||||
// Cooperative cancel via PendingThreadCancellationToken first, background flag is the safety net.
|
||||
PendingMessageThread = new Thread(() =>
|
||||
ProcessPendingMessages(PendingThreadCancellationToken.Token)
|
||||
)
|
||||
@@ -93,9 +107,12 @@ internal class MessageManager : IAsyncDisposable
|
||||
if (PendingMessageThread.IsAlive)
|
||||
Plugin.Log.Warning(
|
||||
"PendingMessageThread did not observe cancellation within 10s. "
|
||||
+ "Worker remains on background thread; next plugin reload releases it."
|
||||
+ "Worker remains on a background thread; next plugin reload releases it. "
|
||||
+ "If this recurs, file a bug with /xllog after the previous reload."
|
||||
);
|
||||
|
||||
// CTS owns an unmanaged WaitHandle; dispose even if the worker is
|
||||
// alive — it checks IsCancellationRequested via the linked token.
|
||||
PendingThreadCancellationToken.Dispose();
|
||||
|
||||
Store.Dispose();
|
||||
@@ -149,7 +166,12 @@ internal class MessageManager : IAsyncDisposable
|
||||
|
||||
internal void ClearAllTabs()
|
||||
{
|
||||
// TempTabs are session-only (not persisted); exclude them to preserve Tell history
|
||||
// Hellion Chat — TempTabs haben keine DB-Persistenz (session-only,
|
||||
// direkt vom AutoTellTabsService befüllt). Ein Clear+Refilter würde
|
||||
// sie leer hinterlassen weil FilterAllTabs nichts aus der DB
|
||||
// findet — Tells sind oft durch Privacy-Filter blockiert oder
|
||||
// schlicht session-flüchtig. TempTabs vom Clear-Pfad ausschließen
|
||||
// damit Settings-Save den Tell-Verlauf nicht zerstört.
|
||||
foreach (var tab in Plugin.Config.Tabs.Where(t => !t.IsTempTab))
|
||||
tab.Clear();
|
||||
}
|
||||
@@ -162,7 +184,12 @@ internal class MessageManager : IAsyncDisposable
|
||||
|
||||
using var messages = Store.GetMostRecentMessages(CurrentContentId, since);
|
||||
|
||||
// TempTabs are excluded; they maintain live state from AutoTellTabsService
|
||||
// We store the pending messages to be added to the chat log in a
|
||||
// temporary list, and apply them all at once after filtering.
|
||||
// TempTabs werden ausgeschlossen — sie bleiben live-state aus dem
|
||||
// AutoTellTabsService, ein DB-Refilter würde sie nur partial
|
||||
// wiederherstellen falls Tells in DB liegen, oder leer lassen wenn
|
||||
// Privacy-Filter sie blockiert hat.
|
||||
var pendingTabs = Plugin
|
||||
.Config.Tabs.Where(t => !t.IsTempTab)
|
||||
.Select(tab => (tab, new List<Message>()))
|
||||
@@ -171,7 +198,7 @@ internal class MessageManager : IAsyncDisposable
|
||||
foreach (var (_, pendingMessages) in pendingTabs.Where(ptab => ptab.Item1.Matches(message)))
|
||||
pendingMessages.Add(message);
|
||||
|
||||
// Apply messages to chat log all at once.
|
||||
// Apply the messages to the chat log in one go.
|
||||
foreach (var (tab, pendingMessages) in pendingTabs)
|
||||
tab.Messages.AddSortPrune(pendingMessages, MessageDisplayLimit);
|
||||
|
||||
@@ -180,7 +207,8 @@ internal class MessageManager : IAsyncDisposable
|
||||
|
||||
WrapperUtil.AddNotification(Language.LoadMessages_Error, NotificationType.Error);
|
||||
|
||||
// Mark failed messages as deleted to prevent retry attempts
|
||||
// Mark the failed messages as deleted so we don't try to load them
|
||||
// again.
|
||||
var failedIds = messages.FailedMessageIds();
|
||||
Plugin.Log.Info($"Marking {failedIds.Count} messages as deleted due to parse failures");
|
||||
foreach (var msgId in messages.FailedMessageIds())
|
||||
@@ -228,10 +256,16 @@ internal class MessageManager : IAsyncDisposable
|
||||
// Update colour codes.
|
||||
GlobalParametersCache.Refresh();
|
||||
|
||||
// Delay to next tick to get content ID from ContentIdResolver hook
|
||||
// We delay messages to be handed off to the async processing thread
|
||||
// in the next tick, otherwise we can't get the content ID from the hook
|
||||
// below.
|
||||
PendingSync.AddLast(pendingMessage);
|
||||
}
|
||||
|
||||
// This hook is called immediately after receiving a message with the
|
||||
// message's content ID. If multiple messages are received in the same tick,
|
||||
// this will be called for each message immediately after ChatMessage is
|
||||
// called for each message.
|
||||
private unsafe void ContentIdResolver(
|
||||
RaptureLogModule* agent,
|
||||
ulong contentId,
|
||||
@@ -374,7 +408,7 @@ internal class MessageManager : IAsyncDisposable
|
||||
var after = formats
|
||||
.GetRange(firstStringParam + 1, secondStringParam - firstStringParam)
|
||||
.Where(payload => payload.Type == ReadOnlySePayloadType.Text)
|
||||
.Select(text => Encoding.UTF8.GetString(text.Body.Span));
|
||||
.Select(text => Encoding.UTF8.GetString(text.Body.Span)); // Can't use `ToString()` as it defaults to macro
|
||||
|
||||
var nameFormatting = NameFormatting.Of(string.Join("", before), string.Join("", after));
|
||||
Formats[type] = nameFormatting;
|
||||
|
||||
+166
-53
@@ -90,8 +90,10 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
||||
|
||||
public readonly WindowSystem WindowSystem = new(PluginName);
|
||||
|
||||
// Phase-2 services are constructed in LoadAsync; null! shape is kept
|
||||
// consistent across all properties for clarity.
|
||||
// v1.4.3: properties moved from { get; } to { get; private set; } = null!;
|
||||
// because LoadAsync now owns construction of the Phase-2 services.
|
||||
// Phase-1 services use the same shape for consistency, even though
|
||||
// they're still allocated in the ctor.
|
||||
public SettingsWindow SettingsWindow { get; private set; } = null!;
|
||||
public ChatLogWindow ChatLogWindow { get; private set; } = null!;
|
||||
public DbViewer DbViewer { get; private set; } = null!;
|
||||
@@ -113,20 +115,27 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
||||
internal Ui.StatusBar StatusBar { get; private set; } = null!;
|
||||
internal Integrations.HonorificService HonorificService { get; private set; } = null!;
|
||||
|
||||
// Idempotency guard — Dalamud may fire DisposeAsync twice in a reload race.
|
||||
// (B3) Lightless idempotency guard — Dalamud may fire DisposeAsync twice
|
||||
// in a reload race; second call short-circuits.
|
||||
private int _disposeStarted;
|
||||
|
||||
internal int DeferredSaveFrames = -1;
|
||||
|
||||
// Serialises retention sweeps so a manual trigger and the 24h auto-sweep
|
||||
// can't run in parallel. Volatile because the ImGui thread reads it outside
|
||||
// the lock to gate the manual button.
|
||||
// Serialises retention sweeps. The 24h auto-sweep on plugin load and
|
||||
// the manual button in the Privacy tab both run on background threads;
|
||||
// without this gate, hitting the manual button moments after a fresh
|
||||
// plugin start would launch two sweeps in parallel and the second one
|
||||
// would just re-do work the first one already finished. The lock guards
|
||||
// the flag — the flag check itself bails before we touch the database.
|
||||
// Volatile because the ImGui thread reads the flag outside the lock to
|
||||
// gate the manual button; without it the JIT may cache the value in a
|
||||
// register and miss the background-thread update.
|
||||
internal readonly object RetentionSweepLock = new();
|
||||
internal volatile bool RetentionSweepRunning;
|
||||
|
||||
internal DateTime GameStarted { get; }
|
||||
|
||||
// Tab management lives here rather than in ChatLogWindow for access reasons.
|
||||
// Tab management needs to happen outside the chatlog window class for access reasons
|
||||
internal int LastTab { get; set; }
|
||||
internal int? WantedTab { get; set; }
|
||||
internal Tab CurrentTab
|
||||
@@ -140,22 +149,31 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
||||
|
||||
public Plugin()
|
||||
{
|
||||
// Phase-1 ctor: bootstrap-essentials only (conflict gate, config load,
|
||||
// language + ImGui init). All service/window allocation lives in LoadAsync.
|
||||
// Phase-1 ctor stays minimal: bootstrap-essentials only (conflict
|
||||
// gate, config load, language + ImGui init, WindowSystem skeleton).
|
||||
// Schema migrations and every service / window allocation moved to
|
||||
// LoadAsync so the sync ctor returns fast. On failure here nothing
|
||||
// is initialized yet, so just throw — there is nothing to clean up.
|
||||
|
||||
// Block load if upstream Chat 2 is active — prevents IPC collisions
|
||||
// and double-replacement of the in-game chat window.
|
||||
// Refuse to start if upstream Chat 2 is loaded — prevents IPC
|
||||
// channel collisions and double-replacement of the in-game chat
|
||||
// window. Throwing here makes Dalamud abort the load cleanly with
|
||||
// our localized message instead of crashing FFXIV mid-frame.
|
||||
ChatTwoConflictDetector.ThrowIfChatTwoIsLoaded(Interface);
|
||||
|
||||
GameStarted = Process.GetCurrentProcess().StartTime.ToUniversalTime();
|
||||
|
||||
// Migrate config + database from upstream ChatTwo on first start.
|
||||
// Hellion Chat: take over config + database from upstream ChatTwo
|
||||
// before Dalamud loads our plugin config. Idempotent: only acts on
|
||||
// the first start where the legacy paths exist and ours don't.
|
||||
MigrateFromChatTwoLayout();
|
||||
|
||||
Config = Interface.GetPluginConfig() as Configuration ?? new Configuration();
|
||||
|
||||
// Schema gate: v1.4.3 requires config v16. Users on older schemas
|
||||
// must install v1.4.2 first to run the migration chain.
|
||||
// Schema-gate: v1.4.3 only supports config schema v16. Older configs
|
||||
// went through their migrations in v1.2.1 (v15→v16) and earlier; users
|
||||
// who skipped past those releases need to install v1.4.2 first to run
|
||||
// the migration chain, then upgrade to v1.4.3.
|
||||
if (Config.Version < 16)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
@@ -164,13 +182,19 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
||||
);
|
||||
}
|
||||
|
||||
// Drop session-only Auto-Tell-Tabs that a previous crash may have persisted.
|
||||
// Hellion Chat — Auto-Tell-Tabs Defense-in-Depth. SaveConfig
|
||||
// already strips temp tabs before persistence, but a previous
|
||||
// crash or external write could have left them in the JSON.
|
||||
// Drop them on load to guarantee the session-only invariant.
|
||||
Config.Tabs.RemoveAll(t => t.IsTempTab);
|
||||
|
||||
LanguageChanged(Interface.UiLanguage);
|
||||
ImGuiUtil.Initialize(this);
|
||||
|
||||
DeferredSaveFrames = -1;
|
||||
|
||||
// WindowSystem skeleton is initialised by the readonly field above —
|
||||
// no AddWindow yet; window construction lives in LoadAsync.
|
||||
}
|
||||
|
||||
public async Task LoadAsync(CancellationToken cancellationToken)
|
||||
@@ -179,8 +203,14 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
||||
|
||||
try
|
||||
{
|
||||
// Default tab layout on fresh install. Tells are handled by
|
||||
// Auto-Tell-Tabs; Novice Network has no preset tab by design.
|
||||
// Hellion v1.0.0 default tab layout. Five thematically separated
|
||||
// tabs: General catches the immediate-surroundings public chat
|
||||
// (Say/Yell/Shout) only; System absorbs the rest of the technical
|
||||
// and gameplay-event noise; FreeCompany, Group and Linkshell each
|
||||
// own their respective channel set. Tells are not in a static
|
||||
// tab anymore — Auto-Tell-Tabs spawns dedicated per-conversation
|
||||
// tabs on demand. Novice-Network gets no preset tab; users who
|
||||
// want it can add HellionBeginner from Settings → Tabs.
|
||||
if (Config.Tabs.Count == 0)
|
||||
{
|
||||
Config.Tabs.Add(TabsUtil.VanillaGeneral);
|
||||
@@ -192,12 +222,19 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// BuildFonts registers handles with Dalamud's FontAtlas; the atlas
|
||||
// rebuilds async a few frames later (visible "font-pop" on first load).
|
||||
// Sync allocation + handle registration. BuildFonts() registers
|
||||
// IFontHandles with Dalamud's UiBuilder.FontAtlas — registration
|
||||
// itself is non-blocking (handles stored, lambdas queued). Dalamud
|
||||
// rebuilds the atlas on its own pipeline a few frames later; first
|
||||
// frames render with the default font until the rebuild lands and
|
||||
// ImGui switches to Hellion-Exo2 / NotoSans (visible "font-pop").
|
||||
// Mirrors ChatTwo Plugin.cs:152.
|
||||
FontManager = new FontManager();
|
||||
FontManager.BuildFonts();
|
||||
|
||||
// ThemeRegistry must be wired before the first Draw tick.
|
||||
// Theme init stays sync on the LoadAsync continuation — cheap,
|
||||
// and Active is read every Draw frame, so the registry must be
|
||||
// wired before the first hook fires.
|
||||
var customThemesDir = Path.Combine(Interface.ConfigDirectory.FullName, "themes");
|
||||
Directory.CreateDirectory(customThemesDir);
|
||||
SeedExampleThemeIfEmpty(customThemesDir);
|
||||
@@ -206,9 +243,11 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Service allocations — order encodes dependencies.
|
||||
// HonorificService registers IPC subscribers early to catch
|
||||
// Ready/Disposing events from the first frame.
|
||||
// Service allocations: order encodes dependencies. Commands is
|
||||
// alloc-only here; Initialise() runs after windows exist so the
|
||||
// slash-commands can toggle their visibility. HonorificService
|
||||
// registers IPC subscribers up-front so Ready/Disposing events
|
||||
// are caught from the very first frame.
|
||||
FileDialogManager = new FileDialogManager();
|
||||
Commands = new Commands();
|
||||
Functions = new GameFunctions.GameFunctions(this);
|
||||
@@ -219,6 +258,9 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
||||
StatusBar = new Ui.StatusBar();
|
||||
MessageManager = new MessageManager(this);
|
||||
|
||||
// Auto-Tell-Tabs subscribes to MessageManager.MessageProcessed for
|
||||
// live tells and to ClientState.Logout for cleanup; needs the live
|
||||
// store handed in at construction.
|
||||
AutoTellTabsService = new AutoTellTabsService(
|
||||
this,
|
||||
MessageManager,
|
||||
@@ -226,6 +268,7 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
||||
);
|
||||
AutoTellTabsService.Initialize();
|
||||
|
||||
// SelfTest steps poll Active per frame and need the registry wired.
|
||||
SelfTestRegistry.RegisterTestSteps([new SelfTests.ThemeSwitchSelfTestStep(this)]);
|
||||
|
||||
ChatLogWindow = new ChatLogWindow(this);
|
||||
@@ -246,19 +289,22 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
||||
WindowSystem.AddWindow(DebuggerWindow);
|
||||
WindowSystem.AddWindow(FirstRunWizard);
|
||||
|
||||
// Open the wizard on a fresh install. Existing ChatTwo users have
|
||||
// FirstRunCompleted set to true by the v6→v7 migration above.
|
||||
if (!Config.FirstRunCompleted)
|
||||
FirstRunWizard.IsOpen = true;
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// let all the other components register, then initialize commands
|
||||
Commands.Initialise();
|
||||
|
||||
// Daily retention sweep — fire-and-forget, skips when disabled
|
||||
// or already ran within the past 24 hours.
|
||||
// Daily retention sweep, fire-and-forget. Skips itself when
|
||||
// disabled or when it already ran within the past 24 hours.
|
||||
RunRetentionSweepIfDue();
|
||||
|
||||
if (Config.ShowEmotes)
|
||||
_ = EmoteCache.LoadData();
|
||||
_ = EmoteCache.LoadData(); // Fire-and-forget, exceptions caught inside
|
||||
|
||||
if (Interface.Reason is not PluginLoadReason.Boot)
|
||||
MessageManager.FilterAllTabsAsync();
|
||||
@@ -267,22 +313,33 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
||||
Interface.UiBuilder.DisableGposeUiHide = true;
|
||||
|
||||
#if !DEBUG
|
||||
// Fire-and-forget — first auto-translate use may have a sub-second
|
||||
// hitch if the cache hasn't filled yet, but avoids blocking load.
|
||||
// Fire-and-forget on a worker thread. The first auto-translate use of
|
||||
// a session may have a sub-second hitch if the cache hasn't filled yet,
|
||||
// but that's preferable to making every user wait ~300 ms during
|
||||
// plugin load for a cache they may never touch. ChatTwo (upstream)
|
||||
// does this sync; we trade load-time for first-use latency.
|
||||
_ = Task.Run(AutoTranslate.PreloadCache, cancellationToken);
|
||||
#endif
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Hooks last — all services and windows must be live before
|
||||
// the first Draw / FrameworkUpdate tick fires.
|
||||
// (B1) Hooks last: every service and window must be live before
|
||||
// Dalamud fires our first Draw / FrameworkUpdate tick. Anything
|
||||
// earlier risks rendering against null FontManager / ThemeRegistry.
|
||||
Framework.Update += FrameworkUpdate;
|
||||
Interface.UiBuilder.Draw += Draw;
|
||||
Interface.LanguageChanged += LanguageChanged;
|
||||
// Hellion Chat — surface a "main UI" entry point so Dalamud's
|
||||
// plugin list shows the Open-Plugin button. Settings is the
|
||||
// most useful landing place; OpenConfigUi is already wired to
|
||||
// the same toggle inside SettingsWindow.
|
||||
Interface.UiBuilder.OpenMainUi += OpenMainUi;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Mirror the v1.4.0 load-failure recovery: hand off to DisposeAsync
|
||||
// so partially-built services are torn down. Swallow the cleanup
|
||||
// exception so the original load failure stays the visible cause.
|
||||
try
|
||||
{
|
||||
await DisposeAsync().ConfigureAwait(false);
|
||||
@@ -294,22 +351,28 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
||||
}
|
||||
}
|
||||
|
||||
// Suppressing this warning because DisposeAsync may run after a partial
|
||||
// LoadAsync, so some properties may not be initialized.
|
||||
[SuppressMessage("ReSharper", "ConditionalAccessQualifierIsNonNullableAccordingToAPIContract")]
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
// Idempotency guard — second call short-circuits on reload race.
|
||||
// (B3) Idempotency guard — Dalamud may reload-race us; second
|
||||
// call short-circuits so we don't double-dispose services.
|
||||
if (Interlocked.Exchange(ref _disposeStarted, 1) != 0)
|
||||
return;
|
||||
|
||||
Exception? failure = null;
|
||||
|
||||
// Unsubscribe hooks first — mirrors the hooks-last subscribe order in LoadAsync.
|
||||
// Hooks unsubscribe FIRST so no Draw / FrameworkUpdate / LanguageChanged
|
||||
// tick can fire while we're tearing services down. Mirrors the
|
||||
// hooks-last subscribe order in LoadAsync.
|
||||
failure = CaptureFailure(failure, () => Interface.UiBuilder.OpenMainUi -= OpenMainUi);
|
||||
failure = CaptureFailure(failure, () => Interface.LanguageChanged -= LanguageChanged);
|
||||
failure = CaptureFailure(failure, () => Interface.UiBuilder.Draw -= Draw);
|
||||
failure = CaptureFailure(failure, () => Framework.Update -= FrameworkUpdate);
|
||||
|
||||
// Flush a pending DeferredSave — FrameworkUpdate won't fire it anymore.
|
||||
// v1.4.0 F5.3 — flush a pending DeferredSave before service teardown,
|
||||
// since FrameworkUpdate just got unsubscribed and won't fire it.
|
||||
failure = CaptureFailure(
|
||||
failure,
|
||||
() =>
|
||||
@@ -322,10 +385,13 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
||||
}
|
||||
);
|
||||
|
||||
// Unsubscribe AutoTellTabs before MessageManager goes away.
|
||||
// Auto-Tell-Tabs unsubscribes from MessageProcessed before MessageManager
|
||||
// goes away. Pure-memory cleanup, no framework-thread requirement.
|
||||
failure = CaptureFailure(failure, () => AutoTellTabsService?.Dispose());
|
||||
|
||||
// MessageManager has its own async dispose path (DB flush, thread shutdown).
|
||||
// v1.4.0 F6.2 — MessageManager has its own async dispose path
|
||||
// (DB flush, pending-message thread shutdown). Run it before the
|
||||
// framework-block so the worker threads are quiesced first.
|
||||
if (MessageManager is not null)
|
||||
{
|
||||
failure = await CaptureFailureAsync(
|
||||
@@ -335,24 +401,36 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Game-function / IPC / window cleanup must run on the framework thread.
|
||||
// (B4) Game-Function / IPC / UI-Window cleanup MUST run on the
|
||||
// framework thread. WindowSystem mutations and IPC subscriber
|
||||
// disposes touch Dalamud state that's only safe from the framework.
|
||||
// Worker-thread DisposeAsync would race the next Draw tick.
|
||||
// Per-line CaptureFailure so a single throw can't strand the lines
|
||||
// behind it; mirrors Lightless DisposeFrameworkBoundServicesAsync.
|
||||
try
|
||||
{
|
||||
await Framework
|
||||
.RunOnFrameworkThread(() =>
|
||||
{
|
||||
// Game-Functions first — other services may still query
|
||||
// chat-interactable state during their Dispose.
|
||||
failure = CaptureFailure(
|
||||
failure,
|
||||
() => GameFunctions.GameFunctions.SetChatInteractable(true)
|
||||
);
|
||||
|
||||
// IPC subscribers before windows — prevents a final IPC event
|
||||
// from reaching a half-torn ChatLogWindow.
|
||||
// IPC subscribers — dispose before windows so any final
|
||||
// event firing from the IPC source can't reach a half-torn
|
||||
// ChatLogWindow.
|
||||
failure = CaptureFailure(failure, () => HonorificService?.Dispose());
|
||||
failure = CaptureFailure(failure, () => TypingIpc?.Dispose());
|
||||
failure = CaptureFailure(failure, () => ExtraChat?.Dispose());
|
||||
failure = CaptureFailure(failure, () => Ipc?.Dispose());
|
||||
|
||||
// Windows — RemoveAllWindows first, then per-window Dispose.
|
||||
// Order matches the pre-v1.4.3 Dispose body byte-for-byte.
|
||||
// CommandHelpWindow and FirstRunWizard don't implement
|
||||
// IDisposable; their resources are reclaimed via WindowSystem.
|
||||
failure = CaptureFailure(failure, () => WindowSystem?.RemoveAllWindows());
|
||||
failure = CaptureFailure(failure, () => ChatLogWindow?.Dispose());
|
||||
failure = CaptureFailure(failure, () => DbViewer?.Dispose());
|
||||
@@ -368,7 +446,8 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
||||
failure ??= ex;
|
||||
}
|
||||
|
||||
// Pure-memory cleanups — no Framework / UI / IPC touch.
|
||||
// Pure-memory cleanups — no Framework / UI / IPC touch, so they
|
||||
// run on whatever thread DisposeAsync resumes on.
|
||||
failure = CaptureFailure(failure, () => Functions?.Dispose());
|
||||
failure = CaptureFailure(failure, () => Commands?.Dispose());
|
||||
failure = CaptureFailure(failure, () => EmoteCache.Dispose());
|
||||
@@ -377,8 +456,9 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
||||
ExceptionDispatchInfo.Capture(failure).Throw();
|
||||
}
|
||||
|
||||
// Run cleanup actions individually so a single failure doesn't strand
|
||||
// the remaining teardown steps.
|
||||
// Lightless-pattern capture helpers: run cleanup, remember the FIRST
|
||||
// exception, keep going. Without these one mid-teardown failure would
|
||||
// skip every cleanup behind it and leave services half-torn.
|
||||
private static Exception? CaptureFailure(Exception? failure, Action action)
|
||||
{
|
||||
try
|
||||
@@ -419,6 +499,9 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
||||
var ourConfigFile = Path.Combine(pluginConfigsDir, "HellionChat.json");
|
||||
var ourConfigDir = Interface.ConfigDirectory.FullName;
|
||||
|
||||
// Track whether anything legitimately blocked us. The most common
|
||||
// cause is upstream Chat 2 still being loaded — its SQLite handle
|
||||
// keeps chat-sqlite.db locked and File.Move throws IOException.
|
||||
var lockedBlocker = false;
|
||||
|
||||
try
|
||||
@@ -440,6 +523,13 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
||||
lockedBlocker = true;
|
||||
}
|
||||
|
||||
// The plugin's ConfigDirectory may already exist on first load
|
||||
// (Dalamud creates it), so check at the file level instead of
|
||||
// skipping when the directory is present. Move every legacy
|
||||
// entry whose target name is not occupied yet, then remove the
|
||||
// source dir if it ends up empty. Each move is wrapped on its
|
||||
// own so a single locked file (the SQLite db while ChatTwo still
|
||||
// runs) does not abandon the rest of the migration.
|
||||
if (!Directory.Exists(legacyConfigDir))
|
||||
return;
|
||||
|
||||
@@ -447,8 +537,6 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
||||
{
|
||||
Directory.CreateDirectory(ourConfigDir);
|
||||
|
||||
// Move each file individually so a single locked file (e.g. the
|
||||
// SQLite db while ChatTwo is still loaded) doesn't abort the rest.
|
||||
foreach (var file in Directory.EnumerateFiles(legacyConfigDir))
|
||||
{
|
||||
var target = Path.Combine(ourConfigDir, Path.GetFileName(file));
|
||||
@@ -502,6 +590,9 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
||||
|
||||
if (lockedBlocker)
|
||||
{
|
||||
// Surface the most common cause to the user as a notification
|
||||
// so they don't think Hellion Chat lost their history when in
|
||||
// fact upstream Chat 2 was still holding the database file.
|
||||
Notification.AddNotification(
|
||||
new Dalamud.Interface.ImGuiNotification.Notification
|
||||
{
|
||||
@@ -519,6 +610,10 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
||||
|
||||
private void OpenMainUi()
|
||||
{
|
||||
// Settings is the most useful landing surface — same target as the
|
||||
// Configure button. SettingsWindow.Toggle is internal and already
|
||||
// wired to OpenConfigUi, so re-using IsOpen keeps both entry points
|
||||
// behaviourally identical.
|
||||
SettingsWindow.IsOpen = !SettingsWindow.IsOpen;
|
||||
}
|
||||
|
||||
@@ -529,7 +624,8 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
||||
if (DateTimeOffset.UtcNow - Config.RetentionLastRunAt < TimeSpan.FromHours(24))
|
||||
return;
|
||||
|
||||
// Snapshot the policy so the user can edit settings while the sweep runs.
|
||||
// Snapshot the policy so the user can edit settings while we run.
|
||||
// Spec defaults form the baseline; explicit user overrides win.
|
||||
var policy = new Dictionary<int, int>();
|
||||
foreach (var (type, days) in Privacy.PrivacyDefaults.DefaultRetentionDays)
|
||||
policy[(int)(ushort)type] = days;
|
||||
@@ -537,10 +633,16 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
||||
policy[(int)(ushort)type] = days;
|
||||
var defaultDays = Config.RetentionDefaultDays;
|
||||
|
||||
// IsBackground = true so a stuck sweep never blocks plugin unload.
|
||||
// IsBackground = true for the same reason as PendingMessageThread:
|
||||
// a stuck sweep must never block plugin unload. RunRetentionSweepIfDue
|
||||
// guards the run-frequency, and the sweep itself uses the framework's
|
||||
// cooperative cancellation pattern. The background flag is the safety
|
||||
// net if the sweep ever takes longer than expected.
|
||||
new Thread(() =>
|
||||
{
|
||||
// Bail early if a manual sweep is already in flight.
|
||||
// Bail out cheaply if a manual sweep is already in flight; the
|
||||
// lock around the actual work would queue us up otherwise and
|
||||
// we would just re-do whatever the manual run already did.
|
||||
lock (RetentionSweepLock)
|
||||
{
|
||||
if (RetentionSweepRunning)
|
||||
@@ -557,8 +659,11 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
||||
if (deleted > 0)
|
||||
{
|
||||
Log.Information($"Retention sweep deleted {deleted} expired messages.");
|
||||
// Run clear+refilter on the framework thread — FilterAllTabsAsync
|
||||
// is fire-and-forget and would race the next sweep cycle.
|
||||
// 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(() =>
|
||||
{
|
||||
@@ -589,7 +694,9 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
||||
|
||||
private void Draw()
|
||||
{
|
||||
// Theme engine is always active; Classic is a theme, not a disabled state.
|
||||
// Theme-Engine ist ab v14 immer aktiv; Klassik ist jetzt ein eigenes
|
||||
// Theme statt einem deaktivierten Hellion-Theme. Active wird einmal
|
||||
// pro Frame aus der Registry gelesen.
|
||||
using IDisposable _style = HellionStyle.PushGlobal(
|
||||
ThemeRegistry.Active,
|
||||
Config.WindowOpacity
|
||||
@@ -604,7 +711,9 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide all plugin windows while the New Game+ menu is open.
|
||||
// v1.0.2 — global skip while the New Game+ menu (QuestRedo addon) is
|
||||
// open. Hides every plugin window in one shot (chat log, pop-outs,
|
||||
// settings, db viewer, etc.), matching the LoadingScreens pattern.
|
||||
if (
|
||||
Config.HideInNewGamePlusMenu
|
||||
&& GameFunctions.GameFunctions.IsAddonInteractable(
|
||||
@@ -633,7 +742,10 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
||||
|
||||
internal void SaveConfig()
|
||||
{
|
||||
// Strip session-only Auto-Tell-Tabs before serialization; restore after.
|
||||
// Hellion Chat — Auto-Tell-Tabs are session-only. Strip them out
|
||||
// before serialization so a crash mid-session can never persist
|
||||
// them. We snapshot the full tab list first and restore it after
|
||||
// the save, preserving the user's order and open conversations.
|
||||
var snapshot = Config.Tabs.ToList();
|
||||
Config.Tabs.RemoveAll(t => t.IsTempTab);
|
||||
|
||||
@@ -682,8 +794,9 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
||||
Condition[ConditionFlag.OccupiedInCutSceneEvent]
|
||||
|| Condition[ConditionFlag.WatchingCutscene78];
|
||||
|
||||
// Seeds example-theme.json into the themes dir on first run.
|
||||
// Skipped if any custom JSON already exists.
|
||||
// v1.1.0 — wenn der themes/-Ordner leer ist, schreiben wir die embedded
|
||||
// example-theme.json als Vorlage rein. Bestehende User-Customs werden
|
||||
// nicht angefasst (existing JSONs lassen den Block überspringen).
|
||||
private static void SeedExampleThemeIfEmpty(string dir)
|
||||
{
|
||||
if (Directory.EnumerateFiles(dir, "*.json").Any())
|
||||
|
||||
@@ -4,8 +4,11 @@ using HellionChat.Util;
|
||||
|
||||
namespace HellionChat.Resources;
|
||||
|
||||
// Built-in colour presets applied via Settings UI → ChatColours.
|
||||
// Battle-channel types are intentionally excluded to preserve combat-log tuning.
|
||||
// Hellion Chat — v0.6.0 built-in colour presets for the ChatColours
|
||||
// settings section. Read-only static data; users apply a preset via the
|
||||
// settings UI which overwrites Configuration.ChatColours immediately.
|
||||
// Battle-channel types are intentionally NOT covered by the stylistic
|
||||
// presets so that combat-log tuning the user has done stays intact.
|
||||
public sealed record ChatColourPreset(
|
||||
string DisplayName,
|
||||
string LocalizationKey,
|
||||
@@ -66,7 +69,9 @@ public static class ChatColourPresets
|
||||
};
|
||||
}
|
||||
|
||||
// Mirrors ChatTypeExt.DefaultColor; channels without a default are skipped.
|
||||
// The Default preset spiegelt 1:1 die Werte aus ChatTypeExt.DefaultColor.
|
||||
// Channels ohne Default-Wert (return null) werden ausgelassen — wer sie
|
||||
// anwenden will, behält seine aktuelle Farbe.
|
||||
private static IReadOnlyDictionary<ChatType, uint> BuildDefault()
|
||||
{
|
||||
var dict = new Dictionary<ChatType, uint>();
|
||||
@@ -178,22 +183,33 @@ public static class ChatColourPresets
|
||||
};
|
||||
}
|
||||
|
||||
// Hellion brand preset — Arctic Cyan + Ember Orange palette.
|
||||
// Cyan family for Standard/Tell, Ember/Warning for loud channels,
|
||||
// Status colours for Linkshells, darker variants for CrossLinkshells.
|
||||
// Hellion brand preset — Arctic Cyan + Ember Orange palette aus
|
||||
// /mnt/ssd-fast/Projekte/hellion-media/hellion-media-website/BRANDING.md
|
||||
// (Schema-Stand 2026-04-16). Channels sind über das ganze Brand-Spektrum
|
||||
// verteilt damit jede Zeile auf einen Glance unterscheidbar ist:
|
||||
// Cyan-Familie für Standard/Tell, Ember + Warning für laute Channels,
|
||||
// Status-Farben (Success, Danger) für Linkshells. CrossLinkshells
|
||||
// nutzen die dunkleren/sattersten Varianten derselben Hue-Familien.
|
||||
private static IReadOnlyDictionary<ChatType, uint> BuildHellion()
|
||||
{
|
||||
return new Dictionary<ChatType, uint>
|
||||
{
|
||||
// Standard / Tell — Cyan-Familie (Brand-Primary)
|
||||
[ChatType.Say] = ColourUtil.ComponentsToRgba(77, 217, 232), // Cyan-light #4DD9E8
|
||||
[ChatType.TellIncoming] = ColourUtil.ComponentsToRgba(0, 190, 210), // Brand Cyan #00BED2
|
||||
[ChatType.TellOutgoing] = ColourUtil.ComponentsToRgba(0, 151, 167), // Cyan-dark #0097A7
|
||||
|
||||
// Laute Channels — Ember/Warning
|
||||
[ChatType.Yell] = ColourUtil.ComponentsToRgba(240, 173, 78), // Warning #F0AD4E
|
||||
[ChatType.Shout] = ColourUtil.ComponentsToRgba(249, 115, 22), // Brand Ember #F97316
|
||||
|
||||
// Gruppen-Channels — Success/Ember-dark/Cyan
|
||||
[ChatType.Party] = ColourUtil.ComponentsToRgba(92, 184, 92), // Success #5CB85C
|
||||
[ChatType.Alliance] = ColourUtil.ComponentsToRgba(232, 93, 4), // Ember-dark #E85D04
|
||||
[ChatType.FreeCompany] = ColourUtil.ComponentsToRgba(0, 190, 210), // Brand Cyan
|
||||
[ChatType.NoviceNetwork] = ColourUtil.ComponentsToRgba(77, 217, 232), // Cyan-light
|
||||
|
||||
// Linkshells 1-8 — über das ganze Brand-Spektrum verteilt
|
||||
[ChatType.Linkshell1] = ColourUtil.ComponentsToRgba(251, 146, 60), // Ember-light #FB923C
|
||||
[ChatType.Linkshell2] = ColourUtil.ComponentsToRgba(240, 173, 78), // Warning
|
||||
[ChatType.Linkshell3] = ColourUtil.ComponentsToRgba(92, 184, 92), // Success
|
||||
@@ -202,6 +218,8 @@ public static class ChatColourPresets
|
||||
[ChatType.Linkshell6] = ColourUtil.ComponentsToRgba(0, 151, 167), // Cyan-dark
|
||||
[ChatType.Linkshell7] = ColourUtil.ComponentsToRgba(249, 115, 22), // Brand Ember
|
||||
[ChatType.Linkshell8] = ColourUtil.ComponentsToRgba(217, 83, 79), // Danger #D9534F
|
||||
|
||||
// CrossWorld-Linkshells 1-8 — dunklere/sattersere Varianten
|
||||
[ChatType.CrossLinkshell1] = ColourUtil.ComponentsToRgba(232, 93, 4), // Ember-dark
|
||||
[ChatType.CrossLinkshell2] = ColourUtil.ComponentsToRgba(200, 140, 50), // Warning-dark
|
||||
[ChatType.CrossLinkshell3] = ColourUtil.ComponentsToRgba(60, 140, 60), // Success-dark
|
||||
@@ -213,20 +231,31 @@ public static class ChatColourPresets
|
||||
};
|
||||
}
|
||||
|
||||
// Night Blue — cool nautical theme, deep navy without purple.
|
||||
// Bonus preset — Night Blue, KAZAMA-Stimmungs-Theme aus
|
||||
// /mnt/HDD-Data1/Obsidian/Vault/Systeme/KAZAMA/Theming/Night Blue + Indigo Violet Themes.md
|
||||
// Klassisch, kühl, technisch — Marineblau-Tiefe ohne Lila-Anteil.
|
||||
// Bewusst NICHT als Brand-Preset markiert (Vault-Boundary): die KAZAMA-Themes
|
||||
// sind persönliche Stimmungs-Themes, nicht Teil des Hellion-Brand-Systems.
|
||||
private static IReadOnlyDictionary<ChatType, uint> BuildNightBlue()
|
||||
{
|
||||
return new Dictionary<ChatType, uint>
|
||||
{
|
||||
// Standard / Tell — Royal Blue Akzent-Familie
|
||||
[ChatType.Say] = ColourUtil.ComponentsToRgba(230, 237, 247), // text-primary
|
||||
[ChatType.TellIncoming] = ColourUtil.ComponentsToRgba(106, 176, 255), // akzent-hot
|
||||
[ChatType.TellOutgoing] = ColourUtil.ComponentsToRgba(74, 144, 226), // akzent-primary
|
||||
|
||||
// Laute Channels — Warning/Danger Status-Töne
|
||||
[ChatType.Yell] = ColourUtil.ComponentsToRgba(255, 184, 74), // warning
|
||||
[ChatType.Shout] = ColourUtil.ComponentsToRgba(255, 92, 122), // danger
|
||||
|
||||
// Gruppen — Success/Akzent-Variations
|
||||
[ChatType.Party] = ColourUtil.ComponentsToRgba(61, 220, 151), // success
|
||||
[ChatType.Alliance] = ColourUtil.ComponentsToRgba(255, 144, 100), // warm-orange-light
|
||||
[ChatType.FreeCompany] = ColourUtil.ComponentsToRgba(74, 144, 226), // akzent-primary
|
||||
[ChatType.NoviceNetwork] = ColourUtil.ComponentsToRgba(140, 160, 191), // text-dim
|
||||
|
||||
// Linkshells 1-8 — über Spektrum verteilt
|
||||
[ChatType.Linkshell1] = ColourUtil.ComponentsToRgba(255, 184, 74),
|
||||
[ChatType.Linkshell2] = ColourUtil.ComponentsToRgba(255, 144, 100),
|
||||
[ChatType.Linkshell3] = ColourUtil.ComponentsToRgba(255, 220, 130),
|
||||
@@ -235,6 +264,8 @@ public static class ChatColourPresets
|
||||
[ChatType.Linkshell6] = ColourUtil.ComponentsToRgba(100, 200, 220),
|
||||
[ChatType.Linkshell7] = ColourUtil.ComponentsToRgba(106, 176, 255),
|
||||
[ChatType.Linkshell8] = ColourUtil.ComponentsToRgba(140, 160, 191),
|
||||
|
||||
// CrossWorld-Linkshells — gedämpfte Variants
|
||||
[ChatType.CrossLinkshell1] = ColourUtil.ComponentsToRgba(200, 130, 50),
|
||||
[ChatType.CrossLinkshell2] = ColourUtil.ComponentsToRgba(220, 110, 80),
|
||||
[ChatType.CrossLinkshell3] = ColourUtil.ComponentsToRgba(200, 180, 60),
|
||||
@@ -246,20 +277,30 @@ public static class ChatColourPresets
|
||||
};
|
||||
}
|
||||
|
||||
// Indigo Violet — warm-mystic theme, deep indigo with violet accent.
|
||||
// Bonus preset — Indigo Violet, KAZAMA-Stimmungs-Theme aus demselben
|
||||
// Vault-Doc. Warm-mystisch, "Galaxy/Glitter/Nordlicht" — tiefes Indigo
|
||||
// mit kräftigem Violet-Akzent. Persönlicher Favorit (siehe Vault).
|
||||
// Auch nicht als Brand-Preset (siehe NightBlue-Note oben).
|
||||
private static IReadOnlyDictionary<ChatType, uint> BuildIndigoViolet()
|
||||
{
|
||||
return new Dictionary<ChatType, uint>
|
||||
{
|
||||
[ChatType.Say] = ColourUtil.ComponentsToRgba(240, 230, 255), // text-primary
|
||||
// Standard / Tell — Royal Violet Akzent-Familie
|
||||
[ChatType.Say] = ColourUtil.ComponentsToRgba(240, 230, 255), // text-primary (light lavender)
|
||||
[ChatType.TellIncoming] = ColourUtil.ComponentsToRgba(176, 124, 255), // akzent-hot
|
||||
[ChatType.TellOutgoing] = ColourUtil.ComponentsToRgba(139, 77, 222), // akzent-primary
|
||||
|
||||
// Laute Channels — geteilt mit Night Blue (Status-Farben)
|
||||
[ChatType.Yell] = ColourUtil.ComponentsToRgba(255, 184, 74),
|
||||
[ChatType.Shout] = ColourUtil.ComponentsToRgba(255, 92, 122),
|
||||
|
||||
// Gruppen
|
||||
[ChatType.Party] = ColourUtil.ComponentsToRgba(61, 220, 151),
|
||||
[ChatType.Alliance] = ColourUtil.ComponentsToRgba(255, 144, 100),
|
||||
[ChatType.FreeCompany] = ColourUtil.ComponentsToRgba(139, 77, 222), // akzent-primary
|
||||
[ChatType.NoviceNetwork] = ColourUtil.ComponentsToRgba(168, 144, 208), // text-dim
|
||||
|
||||
// Linkshells 1-8
|
||||
[ChatType.Linkshell1] = ColourUtil.ComponentsToRgba(255, 184, 74),
|
||||
[ChatType.Linkshell2] = ColourUtil.ComponentsToRgba(255, 144, 100),
|
||||
[ChatType.Linkshell3] = ColourUtil.ComponentsToRgba(255, 220, 130),
|
||||
@@ -268,6 +309,8 @@ public static class ChatColourPresets
|
||||
[ChatType.Linkshell6] = ColourUtil.ComponentsToRgba(139, 77, 222),
|
||||
[ChatType.Linkshell7] = ColourUtil.ComponentsToRgba(130, 90, 200),
|
||||
[ChatType.Linkshell8] = ColourUtil.ComponentsToRgba(168, 144, 208),
|
||||
|
||||
// CrossWorld-Linkshells
|
||||
[ChatType.CrossLinkshell1] = ColourUtil.ComponentsToRgba(200, 130, 50),
|
||||
[ChatType.CrossLinkshell2] = ColourUtil.ComponentsToRgba(220, 110, 80),
|
||||
[ChatType.CrossLinkshell3] = ColourUtil.ComponentsToRgba(200, 180, 60),
|
||||
|
||||
@@ -4,9 +4,13 @@ using HellionChat.Themes;
|
||||
|
||||
namespace HellionChat.SelfTests;
|
||||
|
||||
// Validates the runtime theme-switch contract: polls ThemeRegistry.Active
|
||||
// per frame until the slug moves away and back, then sanity-checks that
|
||||
// the ABGR cache was recomputed on switch.
|
||||
// Validates the runtime theme-switch contract from the user side. The
|
||||
// caller toggles the active theme via Settings -> Theme & Layout, the
|
||||
// step polls ThemeRegistry.Active per frame and only passes once the
|
||||
// slug has moved away from the initial value and back. The ABGR cache
|
||||
// is sanity-checked on every frame: a freshly switched theme must carry
|
||||
// a populated cache, otherwise Switch() forgot the recompute and the UI
|
||||
// would still draw, just with all-transparent slots.
|
||||
internal sealed class ThemeSwitchSelfTestStep : ISelfTestStep
|
||||
{
|
||||
private readonly Plugin plugin;
|
||||
@@ -69,8 +73,9 @@ internal sealed class ThemeSwitchSelfTestStep : ISelfTestStep
|
||||
this.switchedAway = false;
|
||||
}
|
||||
|
||||
// Any non-zero slot confirms the cache was recomputed — no reference
|
||||
// comparison since custom themes can share slot values with built-ins.
|
||||
// Any non-zero slot proves the cache was actually recomputed for the
|
||||
// current theme. We don't compare against a reference, because custom
|
||||
// themes can legitimately share slot values with a built-in.
|
||||
private static bool HasPopulatedCache(Theme theme)
|
||||
{
|
||||
var cache = theme.AbgrCache;
|
||||
|
||||
@@ -10,7 +10,7 @@ internal static class SynthwaveSunset
|
||||
new(
|
||||
Slug: Slug,
|
||||
Name: "Synthwave Sunset",
|
||||
Author: "Zoe Moon",
|
||||
Author: "Hellion Forge",
|
||||
Description: "Hot Magenta + Cyan on midnight violet. 80s neon-grid vibes for late-night raids.",
|
||||
Colors: new ThemeColors(
|
||||
PrimaryDark: ColourUtil.HexToRgba("#C71585"),
|
||||
|
||||
@@ -2,6 +2,8 @@ using HellionChat.Code;
|
||||
|
||||
namespace HellionChat.Themes;
|
||||
|
||||
// Optional per-theme chat colours applied to Configuration.ChatColours on user request.
|
||||
// Themes without this leave channel colours untouched.
|
||||
// Optional pro Theme. Wenn ein Theme ChatColors mitliefert, kann der
|
||||
// User sie per Klick im Themes-Tab auf Configuration.ChatColours anwenden.
|
||||
// Ein Theme ohne ChatColors (z.B. chat2-classic) lässt die User-Channel-
|
||||
// Farben unverändert.
|
||||
public sealed record ThemeChatColors(IReadOnlyDictionary<ChatType, uint> Channels);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace HellionChat.Themes;
|
||||
|
||||
// Colour values as 0xRRGGBBAA — RgbaToAbgr handles the byte-swap for ImGui.
|
||||
// Color-Werte als 0xRRGGBBAA, RgbaToAbgr handled den Byte-Swap zu ImGui.
|
||||
public sealed record ThemeColors(
|
||||
uint PrimaryDark,
|
||||
uint Primary,
|
||||
|
||||
@@ -66,8 +66,10 @@ internal static class ThemeJsonLoader
|
||||
var dict = new Dictionary<HellionChat.Code.ChatType, uint>();
|
||||
foreach (var prop in el.EnumerateObject())
|
||||
{
|
||||
// Property name is the ChatType name (e.g. "Say", "Tell"), value is hex like theme colours.
|
||||
// Unknown channel names are silently skipped for forward-compat with future SE channels.
|
||||
// Property-Name ist der ChatType-Name als String (z.B. "Say", "Tell"),
|
||||
// Value ist Hex wie bei den Theme-Colors. Unbekannte Channel-Names
|
||||
// werden still übersprungen — Forward-Compat falls SE neue Channels
|
||||
// einführt.
|
||||
if (
|
||||
!Enum.TryParse<HellionChat.Code.ChatType>(
|
||||
prop.Name,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace HellionChat.Themes;
|
||||
|
||||
// Layout values mirror the ImGuiStyleVar slots pushed by HellionStyle.
|
||||
// Layout-Werte spiegeln die ImGuiStyleVar-Slots, die HellionStyle pusht.
|
||||
public sealed record ThemeLayout(
|
||||
float WindowRounding,
|
||||
float ChildRounding,
|
||||
|
||||
@@ -29,7 +29,7 @@ public sealed class ThemeRegistry
|
||||
{ SynthwaveSunset.Slug, SynthwaveSunset.Build() },
|
||||
};
|
||||
|
||||
// Centralised so Build() factories stay free of cache plumbing.
|
||||
// Centralised so the ten .Build() factories stay free of cache plumbing.
|
||||
foreach (var theme in _builtIns.Values)
|
||||
theme.RecomputeAbgrCache();
|
||||
|
||||
@@ -58,13 +58,14 @@ public sealed class ThemeRegistry
|
||||
public void Switch(string slug)
|
||||
{
|
||||
var theme = Get(slug);
|
||||
// Defensive — ensures any future theme source always gets a populated cache.
|
||||
// Defensive — idempotent and cheap, so any future theme source
|
||||
// that forgets the cache fill still ends up with a populated one.
|
||||
theme.RecomputeAbgrCache();
|
||||
_active = theme;
|
||||
}
|
||||
|
||||
// 0x80070020 = SHARING_VIOLATION, 0x80070021 = LOCK_VIOLATION.
|
||||
// Other IO failures are permanent — theme is dropped instead of retried.
|
||||
// 0x80070020 = SHARING_VIOLATION, 0x80070021 = LOCK_VIOLATION. Other
|
||||
// IO failures are permanent and get the theme dropped instead of retried.
|
||||
internal static bool IsRecoverableFileLock(Exception? ex)
|
||||
{
|
||||
if (ex is not IOException io)
|
||||
@@ -73,8 +74,9 @@ public sealed class ThemeRegistry
|
||||
return code == 0x80070020u || code == 0x80070021u;
|
||||
}
|
||||
|
||||
// Custom themes are loaded lazily, cached by LastWriteTime.
|
||||
// A changed JSON is reloaded on the next lookup.
|
||||
// Custom-Themes werden lazy aus dem Verzeichnis geladen, Cache mit
|
||||
// LastWriteTime-Token. Eine geänderte JSON wird beim nächsten Lookup
|
||||
// neu eingelesen.
|
||||
private Theme? LoadCustomBySlug(string slug)
|
||||
{
|
||||
if (_customThemesDir is null)
|
||||
@@ -113,7 +115,8 @@ public sealed class ThemeRegistry
|
||||
}
|
||||
catch (Exception ex) when (IsRecoverableFileLock(ex))
|
||||
{
|
||||
// Editor mid-save: keep last known good, retry on next refresh.
|
||||
// Editor mid-save: keep the cached snapshot, leave the stamp
|
||||
// alone so the next refresh retries automatically.
|
||||
Plugin.Log.Debug(
|
||||
$"Custom theme {Path.GetFileName(path)} is locked, keeping last known good"
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
namespace HellionChat.Themes;
|
||||
|
||||
// Optional per-theme; reserved as an extension point for future theme slots.
|
||||
// Optional pro Theme. v1.1.0 nutzt das nicht aktiv; ist als Erweiterungspunkt
|
||||
// für zukünftige Theme-Slots vorbereitet.
|
||||
public sealed record ThemeTypography(
|
||||
float? OverrideGlobalFontSizePt = null,
|
||||
float? OverrideSymbolsFontSizePt = null
|
||||
|
||||
Reference in New Issue
Block a user