Compare commits

..

1 Commits

Author SHA1 Message Date
renovate-bot b6bd0b459a chore(deps): refresh
Security / scan (pull_request) Successful in 21s
2026-05-10 12:34:14 +00:00
23 changed files with 800 additions and 203 deletions
+79 -21
View File
@@ -10,8 +10,14 @@ using HellionChat.Util;
namespace HellionChat; namespace HellionChat;
// Auto-Tell-Tabs: spawns session-only tabs per /tell partner. // Hellion Chat — Auto-Tell-Tabs.
// Subscribes to MessageManager.MessageProcessed and ClientState.Logout. //
// 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 internal sealed class AutoTellTabsService : IDisposable
{ {
private readonly Plugin _plugin; private readonly Plugin _plugin;
@@ -81,7 +87,10 @@ internal sealed class AutoTellTabsService : IDisposable
var partner = ExtractTellPartner(message); var partner = ExtractTellPartner(message);
if (partner == null) 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( Plugin.Log.Warning(
$"[AutoTellTabs] Could not extract tell partner. type={message.Code.Type}, " $"[AutoTellTabs] Could not extract tell partner. type={message.Code.Type}, "
+ $"senderChunks={message.Sender.Count}, contentChunks={message.Content.Count}, " + $"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); var existing = FindTempTab(partner.Value.Name, partner.Value.World);
if (existing != null) 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; return;
} }
@@ -113,7 +124,10 @@ internal sealed class AutoTellTabsService : IDisposable
{ {
if (message.Code.Type == ChatType.TellIncoming) 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 = var fromSender =
ChunkUtil.TryGetPlayerPayload(message.Sender) ChunkUtil.TryGetPlayerPayload(message.Sender)
?? ChunkUtil.TryGetPlayerPayload(message.SenderSource); ?? ChunkUtil.TryGetPlayerPayload(message.SenderSource);
@@ -124,7 +138,10 @@ internal sealed class AutoTellTabsService : IDisposable
return null; 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 = var fromContent =
ChunkUtil.TryGetPlayerPayload(message.Content) ChunkUtil.TryGetPlayerPayload(message.Content)
?? ChunkUtil.TryGetPlayerPayload(message.ContentSource) ?? ChunkUtil.TryGetPlayerPayload(message.ContentSource)
@@ -158,7 +175,10 @@ internal sealed class AutoTellTabsService : IDisposable
private void DropOldestTempTab() 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 var victim = Plugin
.Config.Tabs.Select((tab, idx) => (Tab: tab, Index: idx)) .Config.Tabs.Select((tab, idx) => (Tab: tab, Index: idx))
.Where(t => t.Tab.IsTempTab) .Where(t => t.Tab.IsTempTab)
@@ -171,7 +191,12 @@ internal sealed class AutoTellTabsService : IDisposable
return; 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) if (victim.Tab.PopOut)
{ {
var popout = _plugin.ChatLogWindow.ActivePopouts.FirstOrDefault(p => var popout = _plugin.ChatLogWindow.ActivePopouts.FirstOrDefault(p =>
@@ -185,7 +210,8 @@ internal sealed class AutoTellTabsService : IDisposable
Plugin.Config.Tabs.RemoveAt(victim.Index); 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) if (victim.Index <= _plugin.LastTab)
{ {
_plugin.WantedTab = 0; _plugin.WantedTab = 0;
@@ -196,12 +222,22 @@ internal sealed class AutoTellTabsService : IDisposable
{ {
var tab = BuildTempTab(partner.Name, partner.World); 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); PreloadHistory(tab, partner.Name, partner.World, currentMessage.Id);
tab.AddMessage(currentMessage, unread: true); 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) if (Plugin.Config.AutoTellTabsOpenAsPopout)
{ {
tab.PopOut = true; tab.PopOut = true;
@@ -236,7 +272,9 @@ internal sealed class AutoTellTabsService : IDisposable
{ {
return $"{playerName}@{worldRow.Name}"; 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}"; return $"{playerName}@World{worldRowId}";
} }
@@ -250,7 +288,9 @@ internal sealed class AutoTellTabsService : IDisposable
try 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( var history = _store.GetTellHistoryWithSender(
_messageManager.CurrentContentId, _messageManager.CurrentContentId,
senderName, senderName,
@@ -265,17 +305,23 @@ internal sealed class AutoTellTabsService : IDisposable
if (historicMessages.Count == 0) 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; 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) foreach (var message in historicMessages)
{ {
tab.Messages.AddPrune(message, MessageManager.MessageDisplayLimit); 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( tab.Messages.AddPrune(
MakeSystemMarker(HellionStrings.AutoTellTabs_HistorySeparator), MakeSystemMarker(HellionStrings.AutoTellTabs_HistorySeparator),
MessageManager.MessageDisplayLimit MessageManager.MessageDisplayLimit
@@ -283,7 +329,9 @@ internal sealed class AutoTellTabsService : IDisposable
} }
catch (Exception ex) 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"); Plugin.Log.Error(ex, "[AutoTellTabs] History preload failed");
tab.Messages.AddPrune( tab.Messages.AddPrune(
MakeSystemMarker(HellionStrings.AutoTellTabs_HistoryLoadError), MakeSystemMarker(HellionStrings.AutoTellTabs_HistoryLoadError),
@@ -324,7 +372,9 @@ internal sealed class AutoTellTabsService : IDisposable
lock (_tempTabsLock) 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)) if (!Plugin.Config.Tabs.Contains(tab))
{ {
return; return;
@@ -338,12 +388,18 @@ internal sealed class AutoTellTabsService : IDisposable
{ {
lock (_tempTabsLock) 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 lastIndex = _plugin.LastTab;
var lastIndexValid = lastIndex >= 0 && lastIndex < Plugin.Config.Tabs.Count; var lastIndexValid = lastIndex >= 0 && lastIndex < Plugin.Config.Tabs.Count;
var currentWasTempTab = lastIndexValid && Plugin.Config.Tabs[lastIndex].IsTempTab; 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 var poppedTempTabIds = Plugin
.Config.Tabs.Where(t => t.IsTempTab && t.PopOut) .Config.Tabs.Where(t => t.IsTempTab && t.PopOut)
.Select(t => t.Identifier) .Select(t => t.Identifier)
@@ -363,7 +419,9 @@ internal sealed class AutoTellTabsService : IDisposable
Plugin.Config.Tabs.RemoveAll(t => t.IsTempTab); 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; var stillValid = lastIndex >= 0 && lastIndex < Plugin.Config.Tabs.Count;
if (currentWasTempTab || !stillValid) if (currentWasTempTab || !stillValid)
{ {
+5 -6
View File
@@ -1,12 +1,11 @@
// HellionChat/Branding/BrandingLinks.cs
namespace HellionChat.Branding; 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 internal static class BrandingLinks
{ {
public const string HellionForgeDiscordInvite = "https://discord.gg/X9V7Kcv5gR"; 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";
} }
+9 -1
View File
@@ -34,7 +34,9 @@ public abstract class Chunk
_ => null, _ => 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() internal string StringValue()
{ {
return this switch return this switch
@@ -106,6 +108,9 @@ public class TextChunk : Chunk
Content = content ?? ""; Content = content ?? "";
} }
/// <summary>
/// Creates a new TextChunk with identical styling to this one.
/// </summary>
public TextChunk NewWithStyle(ChunkSource source, Payload? link, string content) public TextChunk NewWithStyle(ChunkSource source, Payload? link, string content)
{ {
return new TextChunk(source, link, 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) public TextChunk NewWithStyle(Chunk chunk, string content)
{ {
return new TextChunk(chunk, content) return new TextChunk(chunk, content)
+154 -26
View File
@@ -38,26 +38,33 @@ public class Configuration : IPluginConfiguration
public int Version { get; set; } = LatestVersion; 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"; 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; 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; public bool ReduceMotion;
// v1.2.1: default flipped false → true. Compact single-line layout is // v1.2.1 — Default geflippt von false → true. Card-Rows-Layout aus
// more readable than the card-rows layout introduced in v1.2.0. // 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; 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; public bool PrivacyFilterEnabled = true;
// Empty set means the migration has not run yet — see Plugin.cs v6→v7. // Empty set means the migration has not run yet — see Plugin.cs v6→v7.
public HashSet<ChatType> PrivacyPersistChannels = []; 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 PrivacyPersistUnknownChannels;
public bool IsAllowedForStorage(ChatType type) public bool IsAllowedForStorage(ChatType type)
@@ -69,23 +76,79 @@ public class Configuration : IPluginConfiguration
return PrivacyPersistUnknownChannels; return PrivacyPersistUnknownChannels;
} }
// Retention master switch defaults to false — plugin will not delete // Hellion Chat — Message retention (GDPR data minimization, time axis).
// history until the user explicitly opts in. // Master switch defaults to false; the plugin will not delete history
// until the user explicitly opts in.
public bool RetentionEnabled; public bool RetentionEnabled;
public int RetentionDefaultDays = 30; public int RetentionDefaultDays = 30;
public Dictionary<ChatType, int> RetentionPerChannelDays = []; public Dictionary<ChatType, int> RetentionPerChannelDays = [];
public DateTimeOffset RetentionLastRunAt = DateTimeOffset.MinValue; 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; 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; 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; 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; public bool EnableAutoTellTabs = true;
// Hard cap on simultaneously open auto tell tabs. Range enforced by the
// settings slider (150). LRU drop favors greeted tabs first.
public int AutoTellTabsLimit = 15; 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; public bool AutoTellTabsCompactDisplay;
// Number of prior tells to preload from the message store when an
// auto tell tab is spawned. Range 0100; 0 disables preload.
public int AutoTellTabsHistoryPreload = 20; 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; 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; 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; 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; 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 bool AutoTellTabsOpenAsPopout;
public int GetRetentionDays(ChatType type) public int GetRetentionDays(ChatType type)
@@ -104,7 +167,10 @@ public class Configuration : IPluginConfiguration
public bool HideInLoadingScreens; public bool HideInLoadingScreens;
public bool HideInBattle; 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 HideInNewGamePlusMenu = true;
public bool HideWhenInactive; public bool HideWhenInactive;
public int InactivityHideTimeout = 10; public int InactivityHideTimeout = 10;
@@ -120,8 +186,18 @@ public class Configuration : IPluginConfiguration
public bool NativeItemTooltips = true; public bool NativeItemTooltips = true;
public bool PrettierTimestamps = true; public bool PrettierTimestamps = true;
public bool MoreCompactPretty; 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 HideSameTimestamps = true;
public bool ShowNoviceNetwork; 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 515 simultaneous conversations). Bestand
// users keep their saved value untouched — only fresh installs pick
// up the new default.
public bool SidebarTabView = true; public bool SidebarTabView = true;
public bool PrintChangelog = true; public bool PrintChangelog = true;
public bool OnlyPreviewIf; public bool OnlyPreviewIf;
@@ -142,10 +218,22 @@ public class Configuration : IPluginConfiguration
public bool CollapseKeepUniqueLinks; public bool CollapseKeepUniqueLinks;
public bool PlaySounds = true; public bool PlaySounds = true;
public bool KeepInputFocus = 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 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 Use24HourClock = true;
public bool ShowEmotes = true; public bool ShowEmotes = true;
public HashSet<string> BlockedEmotes = []; public HashSet<string> BlockedEmotes = [];
public bool FontsEnabled = true; public bool FontsEnabled = true;
public ExtraGlyphRanges ExtraGlyphRanges = 0; public ExtraGlyphRanges ExtraGlyphRanges = 0;
public float FontSizeV2 = 12.75f; public float FontSizeV2 = 12.75f;
@@ -170,6 +258,12 @@ public class Configuration : IPluginConfiguration
public float TooltipOffset; 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(); public Dictionary<ChatType, uint> ChatColours = BuildDefaultChatColours();
private static Dictionary<ChatType, uint> BuildDefaultChatColours() private static Dictionary<ChatType, uint> BuildDefaultChatColours()
@@ -239,7 +333,9 @@ public class Configuration : IPluginConfiguration
MaxLinesToRender = other.MaxLinesToRender; MaxLinesToRender = other.MaxLinesToRender;
Use24HourClock = other.Use24HourClock; Use24HourClock = other.Use24HourClock;
ShowEmotes = other.ShowEmotes; 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); BlockedEmotes = new HashSet<string>(other.BlockedEmotes);
FontsEnabled = other.FontsEnabled; FontsEnabled = other.FontsEnabled;
ItalicEnabled = other.ItalicEnabled; ItalicEnabled = other.ItalicEnabled;
@@ -253,11 +349,22 @@ public class Configuration : IPluginConfiguration
ChatColours = other.ChatColours.ToDictionary(entry => entry.Key, entry => entry.Value); ChatColours = other.ChatColours.ToDictionary(entry => entry.Key, entry => entry.Value);
ColorSelectedInputChannelButton = other.ColorSelectedInputChannelButton; ColorSelectedInputChannelButton = other.ColorSelectedInputChannelButton;
// Keep live temp tabs alive across UpdateFrom — a settings save must // Hellion Chat — Auto-Tell-Tabs are session-only and therefore
// not destroy open tell conversations. For persistent tabs, capture // never present in a disk-loaded copy. Keep the live temp tabs of
// the live MessageList and LastSendUnread by Identifier before the // *this* configuration alive across an UpdateFrom so a settings
// replace and restore them onto the freshly cloned tabs; new tabs // save (or sidebar-mode toggle) does not silently destroy the
// get an empty MessageList, deleted tabs lose their history (intended). // 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 liveTempTabs = Tabs.Where(t => t.IsTempTab).ToList();
var livePersistentSession = Tabs.Where(t => !t.IsTempTab) var livePersistentSession = Tabs.Where(t => !t.IsTempTab)
.ToDictionary(t => t.Identifier, t => (t.Messages, t.LastSendUnread)); .ToDictionary(t => t.Identifier, t => (t.Messages, t.LastSendUnread));
@@ -349,7 +456,9 @@ public class Tab
{ {
public string Name = Language.Tab_DefaultName; 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; public string? Icon = null;
[Obsolete("Removed in favor of SelectedChannels")] [Obsolete("Removed in favor of SelectedChannels")]
@@ -401,12 +510,15 @@ public class Tab
[NonSerialized] [NonSerialized]
public Guid Identifier = Guid.NewGuid(); 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] [NonSerialized]
public bool IsGreeted; public bool IsGreeted;
// Separate validation keys per cache so TellTarget changes don't // v1.4.2 — TabTintCache uses separate validation keys per cache so a
// cause GetTint and GetIcon to strand each other with stale entries. // 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] [NonSerialized]
internal string? _cachedTintTellName; internal string? _cachedTintTellName;
@@ -428,12 +540,17 @@ public class Tab
public bool Matches(Message message) public bool Matches(Message message)
{ {
if (!message.Matches(SelectedChannels, ExtraChatAll, ExtraChatChannels)) if (!message.Matches(SelectedChannels, ExtraChatAll, ExtraChatChannels))
{
return false; return false;
}
// Temp tabs are bound to a single conversation partner — other tells // Auto-tell temp tabs are bound to a single conversation partner;
// matching the channel filter must not land here. // 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) if (IsTempTab && TellTarget?.IsSet() == true)
{
return ChunkUtil.MatchesSender(message, TellTarget.Name, TellTarget.World); return ChunkUtil.MatchesSender(message, TellTarget.Name, TellTarget.World);
}
return true; 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 public class MessageList
{ {
private readonly SemaphoreSlim LockSlim = new(1, 1); 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 public int Count
{ {
get 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) public async Task<Message[]> GetCopy(int millisecondsTimeout = -1)
{ {
await LockSlim.WaitAsync(millisecondsTimeout); 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) public RLockedMessageList GetReadOnly(int millisecondsTimeout = -1)
{ {
LockSlim.Wait(millisecondsTimeout); LockSlim.Wait(millisecondsTimeout);
+29 -12
View File
@@ -79,7 +79,7 @@ public static class EmoteCache
Done, 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; public static LoadingState State = LoadingState.Unloaded;
private static readonly Dictionary<string, Emote> Cache = new(); private static readonly Dictionary<string, Emote> Cache = new();
@@ -87,11 +87,15 @@ public static class EmoteCache
public static string[] SortedCodeArray = []; 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(); private static CancellationTokenSource Cts = new();
internal static CancellationToken Token => Cts.Token; 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(); private static readonly ConcurrentBag<Task> PendingLoads = new();
internal static void TrackLoad(Task loadTask, string emoteCode) internal static void TrackLoad(Task loadTask, string emoteCode)
@@ -113,7 +117,8 @@ public static class EmoteCache
if (State is not LoadingState.Unloaded) if (State is not LoadingState.Unloaded)
return; 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) if (Cts.IsCancellationRequested)
Cts = new CancellationTokenSource(); Cts = new CancellationTokenSource();
@@ -135,8 +140,11 @@ public static class EmoteCache
var topList = await top.Content.ReadAsStringAsync(ct); var topList = await top.Content.ReadAsStringAsync(ct);
var jsonList = JsonSerializer.Deserialize<List<Top100>>(topList)!; var jsonList = JsonSerializer.Deserialize<List<Top100>>(topList)!;
// BetterTTV occasionally returns entries with a null Code; // BetterTTV occasionally returns entries with a null Code; the
// skip them so a single bad row doesn't break the whole cache. // 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) foreach (var emote in jsonList)
if ( if (
!string.IsNullOrEmpty(emote.Emote.Code) !string.IsNullOrEmpty(emote.Emote.Code)
@@ -152,11 +160,16 @@ public static class EmoteCache
} }
catch (OperationCanceledException) 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) 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; State = LoadingState.Unloaded;
Plugin.Log.Error(ex, "BetterTTV cache wasn't initialized"); 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) internal async Task<byte[]> LoadAsync(Emote emote, CancellationToken ct)
{ {
// Path-traversal guard: resolve and verify the candidate path stays // BetterTTV-supplied Id and ImageType are interpolated straight
// inside the cache directory before reading or writing. // 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( var dir = Path.GetFullPath(
Path.Join(Plugin.Interface.ConfigDirectory.FullName, "EmoteCacheV1") Path.Join(Plugin.Interface.ConfigDirectory.FullName, "EmoteCacheV1")
); );
@@ -381,7 +397,7 @@ public static class EmoteCache
var delay = frame.Metadata.GetGifMetadata().FrameDelay / 100f; 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) if (delay < 0.02f)
delay = 0.1f; delay = 0.1f;
@@ -400,7 +416,8 @@ public static class EmoteCache
} }
catch (OperationCanceledException) 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) foreach (var f in Frames)
f.Texture.Dispose(); f.Texture.Dispose();
Frames = []; Frames = [];
+28 -4
View File
@@ -41,7 +41,12 @@ public class FontManager
90f, 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[]? HellionFontBytes;
private static byte[] GetHellionFontBytes() private static byte[] GetHellionFontBytes()
@@ -65,9 +70,11 @@ public class FontManager
ushort[] BuildRange(IReadOnlyList<ushort>? chars, params nint[] ranges) ushort[] BuildRange(IReadOnlyList<ushort>? chars, params nint[] ranges)
{ {
var builder = new ImFontGlyphRangesBuilderPtr(ImGuiNative.ImFontGlyphRangesBuilder()); var builder = new ImFontGlyphRangesBuilderPtr(ImGuiNative.ImFontGlyphRangesBuilder());
// text
foreach (var range in ranges) foreach (var range in ranges)
builder.AddRanges((ushort*)range); builder.AddRanges((ushort*)range);
// chars
if (chars != null) if (chars != null)
{ {
for (var i = 0; i < chars.Count; i += 2) for (var i = 0; i < chars.Count; i += 2)
@@ -109,7 +116,13 @@ public class FontManager
JpRange = BuildRange(GlyphRangesJapanese.GlyphRanges); 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) public async Task BuildFontsAsync(CancellationToken cancellationToken)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
@@ -141,7 +154,12 @@ public class FontManager
RegularFont = Plugin.Interface.UiBuilder.FontAtlas.NewDelegateFontHandle(e => RegularFont = Plugin.Interface.UiBuilder.FontAtlas.NewDelegateFontHandle(e =>
e.OnPreBuild(tk => 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 var basePt = Plugin.Config.UseHellionFont
? Plugin.Config.FontSizeV2 ? Plugin.Config.FontSizeV2
: Plugin.Config.GlobalFontV2.SizePt; : 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( private static ImFontPtr AddFontWithFallback(
IFontAtlasBuildToolkitPreBuild tk, IFontAtlasBuildToolkitPreBuild tk,
IFontId fontId, IFontId fontId,
+40 -8
View File
@@ -1,21 +1,36 @@
<Project Sdk="Dalamud.NET.Sdk/15.0.0"> <Project Sdk="Dalamud.NET.Sdk/15.0.0">
<PropertyGroup> <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> <Version>1.4.3</Version>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <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> <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> <AssemblyName>HellionChat</AssemblyName>
<RootNamespace>HellionChat</RootNamespace> <RootNamespace>HellionChat</RootNamespace>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <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="MessagePack" Version="[3.1.4, 4.0.0)" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.7" /> <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="SQLitePCLRaw.lib.e_sqlite3" Version="3.50.3" />
<PackageReference Include="morelinq" Version="4.4.0" /> <PackageReference Include="morelinq" Version="4.4.0" />
<PackageReference Include="Pidgin" Version="[3.5.1, 4.0.0)" /> <PackageReference Include="Pidgin" Version="[3.5.1, 4.0.0)" />
@@ -23,7 +38,9 @@
</ItemGroup> </ItemGroup>
<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" /> <InternalsVisibleTo Include="HellionChat.Tests" />
</ItemGroup> </ItemGroup>
@@ -42,7 +59,15 @@
</EmbeddedResource> </EmbeddedResource>
</ItemGroup> </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> <ItemGroup>
<EmbeddedResource Include="Resources\HellionFont.ttf"> <EmbeddedResource Include="Resources\HellionFont.ttf">
<LogicalName>HellionFont.ttf</LogicalName> <LogicalName>HellionFont.ttf</LogicalName>
@@ -55,7 +80,14 @@
</EmbeddedResource> </EmbeddedResource>
</ItemGroup> </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> <ItemGroup>
<None Include="images\**"> <None Include="images\**">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+52
View File
@@ -31,6 +31,26 @@ description: |-
- Independent plugin state — own config file and database directory, - Independent plugin state — own config file and database directory,
so Hellion Chat does not share state with upstream Chat 2 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 v1.4.2 — ChatLog Frame-Hot-Path. Three per-frame allocation
patterns gone from the chat-log render path: card-mode borders patterns gone from the chat-log render path: card-mode borders
hoist invariants out of the per-message loop, auto-tell tab 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). 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 Earlier history: https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases
+10 -3
View File
@@ -2,8 +2,14 @@ using System.Collections.Generic;
namespace HellionChat; namespace HellionChat;
// Shared input history for all ChatInputBars (main and pop-out windows). // Hellion Chat — v0.6.0 shared input history. Replaces the embedded
// Push deduplicates: existing entries are moved to the end when re-added. // 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 public static class InputHistoryService
{ {
private const int MaxSize = 30; private const int MaxSize = 30;
@@ -20,7 +26,8 @@ public static class InputHistoryService
var trimmed = entry.Trim(); 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++) for (var i = 0; i < _entries.Count; i++)
{ {
if (_entries[i] == trimmed) if (_entries[i] == trimmed)
+87 -26
View File
@@ -6,17 +6,25 @@ using Newtonsoft.Json;
namespace HellionChat.Integrations; namespace HellionChat.Integrations;
// Newtonsoft.Json is used here for IPC compatibility with Honorific, which // We pull Newtonsoft.Json into this single file for IPC compatibility:
// serialises TitleData with it. It's a transitive Dalamud dependency — no // Honorific serialises its TitleData with Newtonsoft (see
// new NuGet entry needed. The rest of HellionChat uses System.Text.Json. // 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 internal sealed class HonorificService : IDisposable
{ {
private const string IpcNamespace = "Honorific"; 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; 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<(uint, uint)> _apiVersion;
private readonly ICallGateSubscriber<string> _getLocalCharacterTitle; private readonly ICallGateSubscriber<string> _getLocalCharacterTitle;
private readonly ICallGateSubscriber<string, object> _localCharacterTitleChanged; private readonly ICallGateSubscriber<string, object> _localCharacterTitleChanged;
@@ -40,11 +48,23 @@ internal sealed class HonorificService : IDisposable
_framework = framework; _framework = framework;
_log = log; _log = log;
// Gate objects are cached per-name by Dalamud and safe to register // Dalamud caches gate objects per-name for the lifetime of the
// before Honorific loads — they just won't fire until it does. // plugin interface, so we can register subscribers even when
// Initial pull is scheduled on the framework thread because plugin // Honorific isn't loaded yet — the gate just won't fire. Calling
// constructors run on the loader thread, and Honorific's IPC handlers // InvokeFunc before Honorific is up will throw, which is why the
// read ObjectTable.LocalPlayer which throws off the framework thread. // 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"); _apiVersion = pluginInterface.GetIpcSubscriber<(uint, uint)>($"{IpcNamespace}.ApiVersion");
_getLocalCharacterTitle = pluginInterface.GetIpcSubscriber<string>( _getLocalCharacterTitle = pluginInterface.GetIpcSubscriber<string>(
$"{IpcNamespace}.GetLocalCharacterTitle" $"{IpcNamespace}.GetLocalCharacterTitle"
@@ -64,8 +84,11 @@ internal sealed class HonorificService : IDisposable
public void Dispose() public void Dispose()
{ {
// Wrap each unsubscribe — a missing gate must not block the others. // Honorific may already be gone by the time we dispose. Wrap each
// Leaking a subscription keeps this service alive across plugin reloads. // 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(() => _localCharacterTitleChanged.Unsubscribe(OnTitleChanged));
TryUnsubscribe(() => _ready.Unsubscribe(OnReady)); TryUnsubscribe(() => _ready.Unsubscribe(OnReady));
TryUnsubscribe(() => _disposing.Unsubscribe(OnDisposing)); TryUnsubscribe(() => _disposing.Unsubscribe(OnDisposing));
@@ -96,21 +119,34 @@ internal sealed class HonorificService : IDisposable
IsAvailable = true; IsAvailable = true;
_versionWarningLogged = false; _versionWarningLogged = false;
// Pull the current title once at startup; from here on we rely
// on LocalCharacterTitleChanged events.
var json = _getLocalCharacterTitle.InvokeFunc(); var json = _getLocalCharacterTitle.InvokeFunc();
CurrentTitle = ParseTitleJson(json); CurrentTitle = ParseTitleJson(json);
} }
catch (Exception ex) 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."); _log.Debug(ex, "Honorific not available at HellionChat startup; awaiting Ready.");
IsAvailable = false; IsAvailable = false;
CurrentTitle = null; 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) 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) if (!IsAvailable)
return; return;
CurrentTitle = ParseTitleJson(json); CurrentTitle = ParseTitleJson(json);
@@ -118,16 +154,28 @@ internal sealed class HonorificService : IDisposable
private void OnReady() 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); _framework.RunOnFrameworkThread(TryInitialPull);
} }
private void OnDisposing() private void OnDisposing()
{ {
// Honorific unloading — clear cached state so the header hides next frame. // Honorific is unloading. Drop our cached state so the header
// Subscriptions stay registered in case Honorific reloads. // hides on the next frame; subscriptions stay registered because
// CurrentTitle is already nulled by OnTitleChanged before this fires, // the gates may come back later (Honorific reload).
// re-clearing here is belt-and-braces. //
// 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; CurrentTitle = null;
IsAvailable = false; IsAvailable = false;
DetectedApiVersion = null; DetectedApiVersion = null;
@@ -145,15 +193,28 @@ internal sealed class HonorificService : IDisposable
} }
} }
// Threading: IPC events and ImGui both run on the framework thread, so // Threading note: Dalamud fires IPC events on the framework thread and
// OnTitleChanged and the render path never race — no volatile/Interlocked // ImGui renders on the framework thread, so OnTitleChanged and the
// needed as long as Dalamud's framework-thread delivery contract holds. // 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 // The constructor's initial pull and OnReady, on the other hand, are
// (plugin-loader thread and Honorific's NotifyReady respectively), so both // explicitly scheduled via IFramework.RunOnFrameworkThread because
// use RunOnFrameworkThread to safely reach ObjectTable.LocalPlayer. // 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) internal static HonorificTitleData? ParseTitleJson(string json)
{ {
@@ -2,9 +2,13 @@ using System.Numerics;
namespace HellionChat.Integrations; namespace HellionChat.Integrations;
// Local DTO mirroring Honorific's TitleData — no hard reference to Honorific.dll // Local DTO mirroring Honorific's TitleData shape. We replicate the structure
// so HellionChat loads cleanly when Honorific is absent. // instead of referencing Honorific.dll because a hard build-time dependency
// Glow/gradient fields omitted; Cycle 1 renders primary Color only. // 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( internal sealed record HonorificTitleData(
string? Title, string? Title,
bool IsPrefix, bool IsPrefix,
+5 -1
View File
@@ -1,6 +1,10 @@
namespace HellionChat.Integrations; 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 internal static class IntegrationLinks
{ {
public const string HonorificRepo = "https://github.com/Caraxi/Honorific"; public const string HonorificRepo = "https://github.com/Caraxi/Honorific";
+44 -10
View File
@@ -27,7 +27,16 @@ internal class MessageManager : IAsyncDisposable
private Dictionary<ChatType, NameFormatting> Formats { get; } = []; private Dictionary<ChatType, NameFormatting> Formats { get; } = [];
private ulong LastContentId { get; set; } 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 LinkedList<PendingMessage> PendingSync { get; } = [];
private ConcurrentQueue<PendingMessage> PendingAsync { get; } = []; private ConcurrentQueue<PendingMessage> PendingAsync { get; } = [];
private readonly Thread PendingMessageThread; 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 // Hellion Chat — Auto-Tell-Tabs hook. Fires after a fully processed
// AutoTellTabsService to spawn or refresh temp tabs without coupling. // 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; public event Action<Message>? MessageProcessed;
internal unsafe MessageManager(Plugin plugin) internal unsafe MessageManager(Plugin plugin)
@@ -54,6 +66,8 @@ internal class MessageManager : IAsyncDisposable
Store = new MessageStore(DatabasePath()); 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(() => PendingMessageThread = new Thread(() =>
ProcessPendingMessages(PendingThreadCancellationToken.Token) ProcessPendingMessages(PendingThreadCancellationToken.Token)
) )
@@ -93,9 +107,12 @@ internal class MessageManager : IAsyncDisposable
if (PendingMessageThread.IsAlive) if (PendingMessageThread.IsAlive)
Plugin.Log.Warning( Plugin.Log.Warning(
"PendingMessageThread did not observe cancellation within 10s. " "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(); PendingThreadCancellationToken.Dispose();
Store.Dispose(); Store.Dispose();
@@ -149,7 +166,12 @@ internal class MessageManager : IAsyncDisposable
internal void ClearAllTabs() 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)) foreach (var tab in Plugin.Config.Tabs.Where(t => !t.IsTempTab))
tab.Clear(); tab.Clear();
} }
@@ -162,7 +184,12 @@ internal class MessageManager : IAsyncDisposable
using var messages = Store.GetMostRecentMessages(CurrentContentId, since); 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 var pendingTabs = Plugin
.Config.Tabs.Where(t => !t.IsTempTab) .Config.Tabs.Where(t => !t.IsTempTab)
.Select(tab => (tab, new List<Message>())) .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))) foreach (var (_, pendingMessages) in pendingTabs.Where(ptab => ptab.Item1.Matches(message)))
pendingMessages.Add(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) foreach (var (tab, pendingMessages) in pendingTabs)
tab.Messages.AddSortPrune(pendingMessages, MessageDisplayLimit); tab.Messages.AddSortPrune(pendingMessages, MessageDisplayLimit);
@@ -180,7 +207,8 @@ internal class MessageManager : IAsyncDisposable
WrapperUtil.AddNotification(Language.LoadMessages_Error, NotificationType.Error); 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(); var failedIds = messages.FailedMessageIds();
Plugin.Log.Info($"Marking {failedIds.Count} messages as deleted due to parse failures"); Plugin.Log.Info($"Marking {failedIds.Count} messages as deleted due to parse failures");
foreach (var msgId in messages.FailedMessageIds()) foreach (var msgId in messages.FailedMessageIds())
@@ -228,10 +256,16 @@ internal class MessageManager : IAsyncDisposable
// Update colour codes. // Update colour codes.
GlobalParametersCache.Refresh(); 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); 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( private unsafe void ContentIdResolver(
RaptureLogModule* agent, RaptureLogModule* agent,
ulong contentId, ulong contentId,
@@ -374,7 +408,7 @@ internal class MessageManager : IAsyncDisposable
var after = formats var after = formats
.GetRange(firstStringParam + 1, secondStringParam - firstStringParam) .GetRange(firstStringParam + 1, secondStringParam - firstStringParam)
.Where(payload => payload.Type == ReadOnlySePayloadType.Text) .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)); var nameFormatting = NameFormatting.Of(string.Join("", before), string.Join("", after));
Formats[type] = nameFormatting; Formats[type] = nameFormatting;
+166 -53
View File
@@ -90,8 +90,10 @@ public sealed class Plugin : IAsyncDalamudPlugin
public readonly WindowSystem WindowSystem = new(PluginName); public readonly WindowSystem WindowSystem = new(PluginName);
// Phase-2 services are constructed in LoadAsync; null! shape is kept // v1.4.3: properties moved from { get; } to { get; private set; } = null!;
// consistent across all properties for clarity. // 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 SettingsWindow SettingsWindow { get; private set; } = null!;
public ChatLogWindow ChatLogWindow { get; private set; } = null!; public ChatLogWindow ChatLogWindow { get; private set; } = null!;
public DbViewer DbViewer { 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 Ui.StatusBar StatusBar { get; private set; } = null!;
internal Integrations.HonorificService HonorificService { 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; private int _disposeStarted;
internal int DeferredSaveFrames = -1; internal int DeferredSaveFrames = -1;
// Serialises retention sweeps so a manual trigger and the 24h auto-sweep // Serialises retention sweeps. The 24h auto-sweep on plugin load and
// can't run in parallel. Volatile because the ImGui thread reads it outside // the manual button in the Privacy tab both run on background threads;
// the lock to gate the manual button. // 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 readonly object RetentionSweepLock = new();
internal volatile bool RetentionSweepRunning; internal volatile bool RetentionSweepRunning;
internal DateTime GameStarted { get; } 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 LastTab { get; set; }
internal int? WantedTab { get; set; } internal int? WantedTab { get; set; }
internal Tab CurrentTab internal Tab CurrentTab
@@ -140,22 +149,31 @@ public sealed class Plugin : IAsyncDalamudPlugin
public Plugin() public Plugin()
{ {
// Phase-1 ctor: bootstrap-essentials only (conflict gate, config load, // Phase-1 ctor stays minimal: bootstrap-essentials only (conflict
// language + ImGui init). All service/window allocation lives in LoadAsync. // 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 // Refuse to start if upstream Chat 2 is loaded — prevents IPC
// and double-replacement of the in-game chat window. // 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); ChatTwoConflictDetector.ThrowIfChatTwoIsLoaded(Interface);
GameStarted = Process.GetCurrentProcess().StartTime.ToUniversalTime(); 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(); MigrateFromChatTwoLayout();
Config = Interface.GetPluginConfig() as Configuration ?? new Configuration(); Config = Interface.GetPluginConfig() as Configuration ?? new Configuration();
// Schema gate: v1.4.3 requires config v16. Users on older schemas // Schema-gate: v1.4.3 only supports config schema v16. Older configs
// must install v1.4.2 first to run the migration chain. // 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) if (Config.Version < 16)
{ {
throw new InvalidOperationException( 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); Config.Tabs.RemoveAll(t => t.IsTempTab);
LanguageChanged(Interface.UiLanguage); LanguageChanged(Interface.UiLanguage);
ImGuiUtil.Initialize(this); ImGuiUtil.Initialize(this);
DeferredSaveFrames = -1; 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) public async Task LoadAsync(CancellationToken cancellationToken)
@@ -179,8 +203,14 @@ public sealed class Plugin : IAsyncDalamudPlugin
try try
{ {
// Default tab layout on fresh install. Tells are handled by // Hellion v1.0.0 default tab layout. Five thematically separated
// Auto-Tell-Tabs; Novice Network has no preset tab by design. // 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) if (Config.Tabs.Count == 0)
{ {
Config.Tabs.Add(TabsUtil.VanillaGeneral); Config.Tabs.Add(TabsUtil.VanillaGeneral);
@@ -192,12 +222,19 @@ public sealed class Plugin : IAsyncDalamudPlugin
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
// BuildFonts registers handles with Dalamud's FontAtlas; the atlas // Sync allocation + handle registration. BuildFonts() registers
// rebuilds async a few frames later (visible "font-pop" on first load). // 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 = new FontManager();
FontManager.BuildFonts(); 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"); var customThemesDir = Path.Combine(Interface.ConfigDirectory.FullName, "themes");
Directory.CreateDirectory(customThemesDir); Directory.CreateDirectory(customThemesDir);
SeedExampleThemeIfEmpty(customThemesDir); SeedExampleThemeIfEmpty(customThemesDir);
@@ -206,9 +243,11 @@ public sealed class Plugin : IAsyncDalamudPlugin
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
// Service allocations order encodes dependencies. // Service allocations: order encodes dependencies. Commands is
// HonorificService registers IPC subscribers early to catch // alloc-only here; Initialise() runs after windows exist so the
// Ready/Disposing events from the first frame. // 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(); FileDialogManager = new FileDialogManager();
Commands = new Commands(); Commands = new Commands();
Functions = new GameFunctions.GameFunctions(this); Functions = new GameFunctions.GameFunctions(this);
@@ -219,6 +258,9 @@ public sealed class Plugin : IAsyncDalamudPlugin
StatusBar = new Ui.StatusBar(); StatusBar = new Ui.StatusBar();
MessageManager = new MessageManager(this); 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( AutoTellTabsService = new AutoTellTabsService(
this, this,
MessageManager, MessageManager,
@@ -226,6 +268,7 @@ public sealed class Plugin : IAsyncDalamudPlugin
); );
AutoTellTabsService.Initialize(); AutoTellTabsService.Initialize();
// SelfTest steps poll Active per frame and need the registry wired.
SelfTestRegistry.RegisterTestSteps([new SelfTests.ThemeSwitchSelfTestStep(this)]); SelfTestRegistry.RegisterTestSteps([new SelfTests.ThemeSwitchSelfTestStep(this)]);
ChatLogWindow = new ChatLogWindow(this); ChatLogWindow = new ChatLogWindow(this);
@@ -246,19 +289,22 @@ public sealed class Plugin : IAsyncDalamudPlugin
WindowSystem.AddWindow(DebuggerWindow); WindowSystem.AddWindow(DebuggerWindow);
WindowSystem.AddWindow(FirstRunWizard); 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) if (!Config.FirstRunCompleted)
FirstRunWizard.IsOpen = true; FirstRunWizard.IsOpen = true;
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
// let all the other components register, then initialize commands
Commands.Initialise(); Commands.Initialise();
// Daily retention sweep fire-and-forget, skips when disabled // Daily retention sweep, fire-and-forget. Skips itself when
// or already ran within the past 24 hours. // disabled or when it already ran within the past 24 hours.
RunRetentionSweepIfDue(); RunRetentionSweepIfDue();
if (Config.ShowEmotes) if (Config.ShowEmotes)
_ = EmoteCache.LoadData(); _ = EmoteCache.LoadData(); // Fire-and-forget, exceptions caught inside
if (Interface.Reason is not PluginLoadReason.Boot) if (Interface.Reason is not PluginLoadReason.Boot)
MessageManager.FilterAllTabsAsync(); MessageManager.FilterAllTabsAsync();
@@ -267,22 +313,33 @@ public sealed class Plugin : IAsyncDalamudPlugin
Interface.UiBuilder.DisableGposeUiHide = true; Interface.UiBuilder.DisableGposeUiHide = true;
#if !DEBUG #if !DEBUG
// Fire-and-forget first auto-translate use may have a sub-second // Fire-and-forget on a worker thread. The first auto-translate use of
// hitch if the cache hasn't filled yet, but avoids blocking load. // 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); _ = Task.Run(AutoTranslate.PreloadCache, cancellationToken);
#endif #endif
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
// Hooks last — all services and windows must be live before // (B1) Hooks last: every service and window must be live before
// the first Draw / FrameworkUpdate tick fires. // Dalamud fires our first Draw / FrameworkUpdate tick. Anything
// earlier risks rendering against null FontManager / ThemeRegistry.
Framework.Update += FrameworkUpdate; Framework.Update += FrameworkUpdate;
Interface.UiBuilder.Draw += Draw; Interface.UiBuilder.Draw += Draw;
Interface.LanguageChanged += LanguageChanged; 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; Interface.UiBuilder.OpenMainUi += OpenMainUi;
} }
catch 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 try
{ {
await DisposeAsync().ConfigureAwait(false); 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")] [SuppressMessage("ReSharper", "ConditionalAccessQualifierIsNonNullableAccordingToAPIContract")]
public async ValueTask DisposeAsync() 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) if (Interlocked.Exchange(ref _disposeStarted, 1) != 0)
return; return;
Exception? failure = null; 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.UiBuilder.OpenMainUi -= OpenMainUi);
failure = CaptureFailure(failure, () => Interface.LanguageChanged -= LanguageChanged); failure = CaptureFailure(failure, () => Interface.LanguageChanged -= LanguageChanged);
failure = CaptureFailure(failure, () => Interface.UiBuilder.Draw -= Draw); failure = CaptureFailure(failure, () => Interface.UiBuilder.Draw -= Draw);
failure = CaptureFailure(failure, () => Framework.Update -= FrameworkUpdate); 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 = CaptureFailure(
failure, 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()); 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) if (MessageManager is not null)
{ {
failure = await CaptureFailureAsync( failure = await CaptureFailureAsync(
@@ -335,24 +401,36 @@ public sealed class Plugin : IAsyncDalamudPlugin
.ConfigureAwait(false); .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 try
{ {
await Framework await Framework
.RunOnFrameworkThread(() => .RunOnFrameworkThread(() =>
{ {
// Game-Functions first — other services may still query
// chat-interactable state during their Dispose.
failure = CaptureFailure( failure = CaptureFailure(
failure, failure,
() => GameFunctions.GameFunctions.SetChatInteractable(true) () => GameFunctions.GameFunctions.SetChatInteractable(true)
); );
// IPC subscribers before windows — prevents a final IPC event // IPC subscribers — dispose before windows so any final
// from reaching a half-torn ChatLogWindow. // event firing from the IPC source can't reach a half-torn
// ChatLogWindow.
failure = CaptureFailure(failure, () => HonorificService?.Dispose()); failure = CaptureFailure(failure, () => HonorificService?.Dispose());
failure = CaptureFailure(failure, () => TypingIpc?.Dispose()); failure = CaptureFailure(failure, () => TypingIpc?.Dispose());
failure = CaptureFailure(failure, () => ExtraChat?.Dispose()); failure = CaptureFailure(failure, () => ExtraChat?.Dispose());
failure = CaptureFailure(failure, () => Ipc?.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, () => WindowSystem?.RemoveAllWindows());
failure = CaptureFailure(failure, () => ChatLogWindow?.Dispose()); failure = CaptureFailure(failure, () => ChatLogWindow?.Dispose());
failure = CaptureFailure(failure, () => DbViewer?.Dispose()); failure = CaptureFailure(failure, () => DbViewer?.Dispose());
@@ -368,7 +446,8 @@ public sealed class Plugin : IAsyncDalamudPlugin
failure ??= ex; 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, () => Functions?.Dispose());
failure = CaptureFailure(failure, () => Commands?.Dispose()); failure = CaptureFailure(failure, () => Commands?.Dispose());
failure = CaptureFailure(failure, () => EmoteCache.Dispose()); failure = CaptureFailure(failure, () => EmoteCache.Dispose());
@@ -377,8 +456,9 @@ public sealed class Plugin : IAsyncDalamudPlugin
ExceptionDispatchInfo.Capture(failure).Throw(); ExceptionDispatchInfo.Capture(failure).Throw();
} }
// Run cleanup actions individually so a single failure doesn't strand // Lightless-pattern capture helpers: run cleanup, remember the FIRST
// the remaining teardown steps. // 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) private static Exception? CaptureFailure(Exception? failure, Action action)
{ {
try try
@@ -419,6 +499,9 @@ public sealed class Plugin : IAsyncDalamudPlugin
var ourConfigFile = Path.Combine(pluginConfigsDir, "HellionChat.json"); var ourConfigFile = Path.Combine(pluginConfigsDir, "HellionChat.json");
var ourConfigDir = Interface.ConfigDirectory.FullName; 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; var lockedBlocker = false;
try try
@@ -440,6 +523,13 @@ public sealed class Plugin : IAsyncDalamudPlugin
lockedBlocker = true; 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)) if (!Directory.Exists(legacyConfigDir))
return; return;
@@ -447,8 +537,6 @@ public sealed class Plugin : IAsyncDalamudPlugin
{ {
Directory.CreateDirectory(ourConfigDir); 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)) foreach (var file in Directory.EnumerateFiles(legacyConfigDir))
{ {
var target = Path.Combine(ourConfigDir, Path.GetFileName(file)); var target = Path.Combine(ourConfigDir, Path.GetFileName(file));
@@ -502,6 +590,9 @@ public sealed class Plugin : IAsyncDalamudPlugin
if (lockedBlocker) 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( Notification.AddNotification(
new Dalamud.Interface.ImGuiNotification.Notification new Dalamud.Interface.ImGuiNotification.Notification
{ {
@@ -519,6 +610,10 @@ public sealed class Plugin : IAsyncDalamudPlugin
private void OpenMainUi() 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; SettingsWindow.IsOpen = !SettingsWindow.IsOpen;
} }
@@ -529,7 +624,8 @@ public sealed class Plugin : IAsyncDalamudPlugin
if (DateTimeOffset.UtcNow - Config.RetentionLastRunAt < TimeSpan.FromHours(24)) if (DateTimeOffset.UtcNow - Config.RetentionLastRunAt < TimeSpan.FromHours(24))
return; 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>(); var policy = new Dictionary<int, int>();
foreach (var (type, days) in Privacy.PrivacyDefaults.DefaultRetentionDays) foreach (var (type, days) in Privacy.PrivacyDefaults.DefaultRetentionDays)
policy[(int)(ushort)type] = days; policy[(int)(ushort)type] = days;
@@ -537,10 +633,16 @@ public sealed class Plugin : IAsyncDalamudPlugin
policy[(int)(ushort)type] = days; policy[(int)(ushort)type] = days;
var defaultDays = Config.RetentionDefaultDays; 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(() => 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) lock (RetentionSweepLock)
{ {
if (RetentionSweepRunning) if (RetentionSweepRunning)
@@ -557,8 +659,11 @@ public sealed class Plugin : IAsyncDalamudPlugin
if (deleted > 0) if (deleted > 0)
{ {
Log.Information($"Retention sweep deleted {deleted} expired messages."); Log.Information($"Retention sweep deleted {deleted} expired messages.");
// Run clear+refilter on the framework thread — FilterAllTabsAsync // Run the clear+refilter synchronously on the framework thread.
// is fire-and-forget and would race the next sweep cycle. // 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 Framework
.Run(() => .Run(() =>
{ {
@@ -589,7 +694,9 @@ public sealed class Plugin : IAsyncDalamudPlugin
private void Draw() 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( using IDisposable _style = HellionStyle.PushGlobal(
ThemeRegistry.Active, ThemeRegistry.Active,
Config.WindowOpacity Config.WindowOpacity
@@ -604,7 +711,9 @@ public sealed class Plugin : IAsyncDalamudPlugin
return; 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 ( if (
Config.HideInNewGamePlusMenu Config.HideInNewGamePlusMenu
&& GameFunctions.GameFunctions.IsAddonInteractable( && GameFunctions.GameFunctions.IsAddonInteractable(
@@ -633,7 +742,10 @@ public sealed class Plugin : IAsyncDalamudPlugin
internal void SaveConfig() 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(); var snapshot = Config.Tabs.ToList();
Config.Tabs.RemoveAll(t => t.IsTempTab); Config.Tabs.RemoveAll(t => t.IsTempTab);
@@ -682,8 +794,9 @@ public sealed class Plugin : IAsyncDalamudPlugin
Condition[ConditionFlag.OccupiedInCutSceneEvent] Condition[ConditionFlag.OccupiedInCutSceneEvent]
|| Condition[ConditionFlag.WatchingCutscene78]; || Condition[ConditionFlag.WatchingCutscene78];
// Seeds example-theme.json into the themes dir on first run. // v1.1.0 — wenn der themes/-Ordner leer ist, schreiben wir die embedded
// Skipped if any custom JSON already exists. // example-theme.json als Vorlage rein. Bestehende User-Customs werden
// nicht angefasst (existing JSONs lassen den Block überspringen).
private static void SeedExampleThemeIfEmpty(string dir) private static void SeedExampleThemeIfEmpty(string dir)
{ {
if (Directory.EnumerateFiles(dir, "*.json").Any()) if (Directory.EnumerateFiles(dir, "*.json").Any())
+52 -9
View File
@@ -4,8 +4,11 @@ using HellionChat.Util;
namespace HellionChat.Resources; namespace HellionChat.Resources;
// Built-in colour presets applied via Settings UI → ChatColours. // Hellion Chat — v0.6.0 built-in colour presets for the ChatColours
// Battle-channel types are intentionally excluded to preserve combat-log tuning. // 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( public sealed record ChatColourPreset(
string DisplayName, string DisplayName,
string LocalizationKey, 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() private static IReadOnlyDictionary<ChatType, uint> BuildDefault()
{ {
var dict = new Dictionary<ChatType, uint>(); var dict = new Dictionary<ChatType, uint>();
@@ -178,22 +183,33 @@ public static class ChatColourPresets
}; };
} }
// Hellion brand preset — Arctic Cyan + Ember Orange palette. // Hellion brand preset — Arctic Cyan + Ember Orange palette aus
// Cyan family for Standard/Tell, Ember/Warning for loud channels, // /mnt/ssd-fast/Projekte/hellion-media/hellion-media-website/BRANDING.md
// Status colours for Linkshells, darker variants for CrossLinkshells. // (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() private static IReadOnlyDictionary<ChatType, uint> BuildHellion()
{ {
return new Dictionary<ChatType, uint> return new Dictionary<ChatType, uint>
{ {
// Standard / Tell — Cyan-Familie (Brand-Primary)
[ChatType.Say] = ColourUtil.ComponentsToRgba(77, 217, 232), // Cyan-light #4DD9E8 [ChatType.Say] = ColourUtil.ComponentsToRgba(77, 217, 232), // Cyan-light #4DD9E8
[ChatType.TellIncoming] = ColourUtil.ComponentsToRgba(0, 190, 210), // Brand Cyan #00BED2 [ChatType.TellIncoming] = ColourUtil.ComponentsToRgba(0, 190, 210), // Brand Cyan #00BED2
[ChatType.TellOutgoing] = ColourUtil.ComponentsToRgba(0, 151, 167), // Cyan-dark #0097A7 [ChatType.TellOutgoing] = ColourUtil.ComponentsToRgba(0, 151, 167), // Cyan-dark #0097A7
// Laute Channels — Ember/Warning
[ChatType.Yell] = ColourUtil.ComponentsToRgba(240, 173, 78), // Warning #F0AD4E [ChatType.Yell] = ColourUtil.ComponentsToRgba(240, 173, 78), // Warning #F0AD4E
[ChatType.Shout] = ColourUtil.ComponentsToRgba(249, 115, 22), // Brand Ember #F97316 [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.Party] = ColourUtil.ComponentsToRgba(92, 184, 92), // Success #5CB85C
[ChatType.Alliance] = ColourUtil.ComponentsToRgba(232, 93, 4), // Ember-dark #E85D04 [ChatType.Alliance] = ColourUtil.ComponentsToRgba(232, 93, 4), // Ember-dark #E85D04
[ChatType.FreeCompany] = ColourUtil.ComponentsToRgba(0, 190, 210), // Brand Cyan [ChatType.FreeCompany] = ColourUtil.ComponentsToRgba(0, 190, 210), // Brand Cyan
[ChatType.NoviceNetwork] = ColourUtil.ComponentsToRgba(77, 217, 232), // Cyan-light [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.Linkshell1] = ColourUtil.ComponentsToRgba(251, 146, 60), // Ember-light #FB923C
[ChatType.Linkshell2] = ColourUtil.ComponentsToRgba(240, 173, 78), // Warning [ChatType.Linkshell2] = ColourUtil.ComponentsToRgba(240, 173, 78), // Warning
[ChatType.Linkshell3] = ColourUtil.ComponentsToRgba(92, 184, 92), // Success [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.Linkshell6] = ColourUtil.ComponentsToRgba(0, 151, 167), // Cyan-dark
[ChatType.Linkshell7] = ColourUtil.ComponentsToRgba(249, 115, 22), // Brand Ember [ChatType.Linkshell7] = ColourUtil.ComponentsToRgba(249, 115, 22), // Brand Ember
[ChatType.Linkshell8] = ColourUtil.ComponentsToRgba(217, 83, 79), // Danger #D9534F [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.CrossLinkshell1] = ColourUtil.ComponentsToRgba(232, 93, 4), // Ember-dark
[ChatType.CrossLinkshell2] = ColourUtil.ComponentsToRgba(200, 140, 50), // Warning-dark [ChatType.CrossLinkshell2] = ColourUtil.ComponentsToRgba(200, 140, 50), // Warning-dark
[ChatType.CrossLinkshell3] = ColourUtil.ComponentsToRgba(60, 140, 60), // Success-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() private static IReadOnlyDictionary<ChatType, uint> BuildNightBlue()
{ {
return new Dictionary<ChatType, uint> return new Dictionary<ChatType, uint>
{ {
// Standard / Tell — Royal Blue Akzent-Familie
[ChatType.Say] = ColourUtil.ComponentsToRgba(230, 237, 247), // text-primary [ChatType.Say] = ColourUtil.ComponentsToRgba(230, 237, 247), // text-primary
[ChatType.TellIncoming] = ColourUtil.ComponentsToRgba(106, 176, 255), // akzent-hot [ChatType.TellIncoming] = ColourUtil.ComponentsToRgba(106, 176, 255), // akzent-hot
[ChatType.TellOutgoing] = ColourUtil.ComponentsToRgba(74, 144, 226), // akzent-primary [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.Yell] = ColourUtil.ComponentsToRgba(255, 184, 74), // warning
[ChatType.Shout] = ColourUtil.ComponentsToRgba(255, 92, 122), // danger [ChatType.Shout] = ColourUtil.ComponentsToRgba(255, 92, 122), // danger
// Gruppen — Success/Akzent-Variations
[ChatType.Party] = ColourUtil.ComponentsToRgba(61, 220, 151), // success [ChatType.Party] = ColourUtil.ComponentsToRgba(61, 220, 151), // success
[ChatType.Alliance] = ColourUtil.ComponentsToRgba(255, 144, 100), // warm-orange-light [ChatType.Alliance] = ColourUtil.ComponentsToRgba(255, 144, 100), // warm-orange-light
[ChatType.FreeCompany] = ColourUtil.ComponentsToRgba(74, 144, 226), // akzent-primary [ChatType.FreeCompany] = ColourUtil.ComponentsToRgba(74, 144, 226), // akzent-primary
[ChatType.NoviceNetwork] = ColourUtil.ComponentsToRgba(140, 160, 191), // text-dim [ChatType.NoviceNetwork] = ColourUtil.ComponentsToRgba(140, 160, 191), // text-dim
// Linkshells 1-8 — über Spektrum verteilt
[ChatType.Linkshell1] = ColourUtil.ComponentsToRgba(255, 184, 74), [ChatType.Linkshell1] = ColourUtil.ComponentsToRgba(255, 184, 74),
[ChatType.Linkshell2] = ColourUtil.ComponentsToRgba(255, 144, 100), [ChatType.Linkshell2] = ColourUtil.ComponentsToRgba(255, 144, 100),
[ChatType.Linkshell3] = ColourUtil.ComponentsToRgba(255, 220, 130), [ChatType.Linkshell3] = ColourUtil.ComponentsToRgba(255, 220, 130),
@@ -235,6 +264,8 @@ public static class ChatColourPresets
[ChatType.Linkshell6] = ColourUtil.ComponentsToRgba(100, 200, 220), [ChatType.Linkshell6] = ColourUtil.ComponentsToRgba(100, 200, 220),
[ChatType.Linkshell7] = ColourUtil.ComponentsToRgba(106, 176, 255), [ChatType.Linkshell7] = ColourUtil.ComponentsToRgba(106, 176, 255),
[ChatType.Linkshell8] = ColourUtil.ComponentsToRgba(140, 160, 191), [ChatType.Linkshell8] = ColourUtil.ComponentsToRgba(140, 160, 191),
// CrossWorld-Linkshells — gedämpfte Variants
[ChatType.CrossLinkshell1] = ColourUtil.ComponentsToRgba(200, 130, 50), [ChatType.CrossLinkshell1] = ColourUtil.ComponentsToRgba(200, 130, 50),
[ChatType.CrossLinkshell2] = ColourUtil.ComponentsToRgba(220, 110, 80), [ChatType.CrossLinkshell2] = ColourUtil.ComponentsToRgba(220, 110, 80),
[ChatType.CrossLinkshell3] = ColourUtil.ComponentsToRgba(200, 180, 60), [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() private static IReadOnlyDictionary<ChatType, uint> BuildIndigoViolet()
{ {
return new Dictionary<ChatType, uint> 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.TellIncoming] = ColourUtil.ComponentsToRgba(176, 124, 255), // akzent-hot
[ChatType.TellOutgoing] = ColourUtil.ComponentsToRgba(139, 77, 222), // akzent-primary [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.Yell] = ColourUtil.ComponentsToRgba(255, 184, 74),
[ChatType.Shout] = ColourUtil.ComponentsToRgba(255, 92, 122), [ChatType.Shout] = ColourUtil.ComponentsToRgba(255, 92, 122),
// Gruppen
[ChatType.Party] = ColourUtil.ComponentsToRgba(61, 220, 151), [ChatType.Party] = ColourUtil.ComponentsToRgba(61, 220, 151),
[ChatType.Alliance] = ColourUtil.ComponentsToRgba(255, 144, 100), [ChatType.Alliance] = ColourUtil.ComponentsToRgba(255, 144, 100),
[ChatType.FreeCompany] = ColourUtil.ComponentsToRgba(139, 77, 222), // akzent-primary [ChatType.FreeCompany] = ColourUtil.ComponentsToRgba(139, 77, 222), // akzent-primary
[ChatType.NoviceNetwork] = ColourUtil.ComponentsToRgba(168, 144, 208), // text-dim [ChatType.NoviceNetwork] = ColourUtil.ComponentsToRgba(168, 144, 208), // text-dim
// Linkshells 1-8
[ChatType.Linkshell1] = ColourUtil.ComponentsToRgba(255, 184, 74), [ChatType.Linkshell1] = ColourUtil.ComponentsToRgba(255, 184, 74),
[ChatType.Linkshell2] = ColourUtil.ComponentsToRgba(255, 144, 100), [ChatType.Linkshell2] = ColourUtil.ComponentsToRgba(255, 144, 100),
[ChatType.Linkshell3] = ColourUtil.ComponentsToRgba(255, 220, 130), [ChatType.Linkshell3] = ColourUtil.ComponentsToRgba(255, 220, 130),
@@ -268,6 +309,8 @@ public static class ChatColourPresets
[ChatType.Linkshell6] = ColourUtil.ComponentsToRgba(139, 77, 222), [ChatType.Linkshell6] = ColourUtil.ComponentsToRgba(139, 77, 222),
[ChatType.Linkshell7] = ColourUtil.ComponentsToRgba(130, 90, 200), [ChatType.Linkshell7] = ColourUtil.ComponentsToRgba(130, 90, 200),
[ChatType.Linkshell8] = ColourUtil.ComponentsToRgba(168, 144, 208), [ChatType.Linkshell8] = ColourUtil.ComponentsToRgba(168, 144, 208),
// CrossWorld-Linkshells
[ChatType.CrossLinkshell1] = ColourUtil.ComponentsToRgba(200, 130, 50), [ChatType.CrossLinkshell1] = ColourUtil.ComponentsToRgba(200, 130, 50),
[ChatType.CrossLinkshell2] = ColourUtil.ComponentsToRgba(220, 110, 80), [ChatType.CrossLinkshell2] = ColourUtil.ComponentsToRgba(220, 110, 80),
[ChatType.CrossLinkshell3] = ColourUtil.ComponentsToRgba(200, 180, 60), [ChatType.CrossLinkshell3] = ColourUtil.ComponentsToRgba(200, 180, 60),
@@ -4,9 +4,13 @@ using HellionChat.Themes;
namespace HellionChat.SelfTests; namespace HellionChat.SelfTests;
// Validates the runtime theme-switch contract: polls ThemeRegistry.Active // Validates the runtime theme-switch contract from the user side. The
// per frame until the slug moves away and back, then sanity-checks that // caller toggles the active theme via Settings -> Theme & Layout, the
// the ABGR cache was recomputed on switch. // 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 internal sealed class ThemeSwitchSelfTestStep : ISelfTestStep
{ {
private readonly Plugin plugin; private readonly Plugin plugin;
@@ -69,8 +73,9 @@ internal sealed class ThemeSwitchSelfTestStep : ISelfTestStep
this.switchedAway = false; this.switchedAway = false;
} }
// Any non-zero slot confirms the cache was recomputed — no reference // Any non-zero slot proves the cache was actually recomputed for the
// comparison since custom themes can share slot values with built-ins. // 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) private static bool HasPopulatedCache(Theme theme)
{ {
var cache = theme.AbgrCache; var cache = theme.AbgrCache;
@@ -10,7 +10,7 @@ internal static class SynthwaveSunset
new( new(
Slug: Slug, Slug: Slug,
Name: "Synthwave Sunset", Name: "Synthwave Sunset",
Author: "Zoe Moon", Author: "Hellion Forge",
Description: "Hot Magenta + Cyan on midnight violet. 80s neon-grid vibes for late-night raids.", Description: "Hot Magenta + Cyan on midnight violet. 80s neon-grid vibes for late-night raids.",
Colors: new ThemeColors( Colors: new ThemeColors(
PrimaryDark: ColourUtil.HexToRgba("#C71585"), PrimaryDark: ColourUtil.HexToRgba("#C71585"),
+4 -2
View File
@@ -2,6 +2,8 @@ using HellionChat.Code;
namespace HellionChat.Themes; namespace HellionChat.Themes;
// Optional per-theme chat colours applied to Configuration.ChatColours on user request. // Optional pro Theme. Wenn ein Theme ChatColors mitliefert, kann der
// Themes without this leave channel colours untouched. // 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); public sealed record ThemeChatColors(IReadOnlyDictionary<ChatType, uint> Channels);
+1 -1
View File
@@ -1,6 +1,6 @@
namespace HellionChat.Themes; 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( public sealed record ThemeColors(
uint PrimaryDark, uint PrimaryDark,
uint Primary, uint Primary,
+4 -2
View File
@@ -66,8 +66,10 @@ internal static class ThemeJsonLoader
var dict = new Dictionary<HellionChat.Code.ChatType, uint>(); var dict = new Dictionary<HellionChat.Code.ChatType, uint>();
foreach (var prop in el.EnumerateObject()) foreach (var prop in el.EnumerateObject())
{ {
// Property name is the ChatType name (e.g. "Say", "Tell"), value is hex like theme colours. // Property-Name ist der ChatType-Name als String (z.B. "Say", "Tell"),
// Unknown channel names are silently skipped for forward-compat with future SE channels. // Value ist Hex wie bei den Theme-Colors. Unbekannte Channel-Names
// werden still übersprungen — Forward-Compat falls SE neue Channels
// einführt.
if ( if (
!Enum.TryParse<HellionChat.Code.ChatType>( !Enum.TryParse<HellionChat.Code.ChatType>(
prop.Name, prop.Name,
+1 -1
View File
@@ -1,6 +1,6 @@
namespace HellionChat.Themes; 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( public sealed record ThemeLayout(
float WindowRounding, float WindowRounding,
float ChildRounding, float ChildRounding,
+10 -7
View File
@@ -29,7 +29,7 @@ public sealed class ThemeRegistry
{ SynthwaveSunset.Slug, SynthwaveSunset.Build() }, { 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) foreach (var theme in _builtIns.Values)
theme.RecomputeAbgrCache(); theme.RecomputeAbgrCache();
@@ -58,13 +58,14 @@ public sealed class ThemeRegistry
public void Switch(string slug) public void Switch(string slug)
{ {
var theme = Get(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(); theme.RecomputeAbgrCache();
_active = theme; _active = theme;
} }
// 0x80070020 = SHARING_VIOLATION, 0x80070021 = LOCK_VIOLATION. // 0x80070020 = SHARING_VIOLATION, 0x80070021 = LOCK_VIOLATION. Other
// Other IO failures are permanent theme is dropped instead of retried. // IO failures are permanent and get the theme dropped instead of retried.
internal static bool IsRecoverableFileLock(Exception? ex) internal static bool IsRecoverableFileLock(Exception? ex)
{ {
if (ex is not IOException io) if (ex is not IOException io)
@@ -73,8 +74,9 @@ public sealed class ThemeRegistry
return code == 0x80070020u || code == 0x80070021u; return code == 0x80070020u || code == 0x80070021u;
} }
// Custom themes are loaded lazily, cached by LastWriteTime. // Custom-Themes werden lazy aus dem Verzeichnis geladen, Cache mit
// A changed JSON is reloaded on the next lookup. // LastWriteTime-Token. Eine geänderte JSON wird beim nächsten Lookup
// neu eingelesen.
private Theme? LoadCustomBySlug(string slug) private Theme? LoadCustomBySlug(string slug)
{ {
if (_customThemesDir is null) if (_customThemesDir is null)
@@ -113,7 +115,8 @@ public sealed class ThemeRegistry
} }
catch (Exception ex) when (IsRecoverableFileLock(ex)) 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( Plugin.Log.Debug(
$"Custom theme {Path.GetFileName(path)} is locked, keeping last known good" $"Custom theme {Path.GetFileName(path)} is locked, keeping last known good"
); );
+2 -1
View File
@@ -1,6 +1,7 @@
namespace HellionChat.Themes; 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( public sealed record ThemeTypography(
float? OverrideGlobalFontSizePt = null, float? OverrideGlobalFontSizePt = null,
float? OverrideSymbolsFontSizePt = null float? OverrideSymbolsFontSizePt = null