Updated .editorconfig to set indent_style=space and indent_size=4 for C# files. Reformat all .cs files to apply the new indentation settings. No code logic changes, just whitespace reformatting. also updated some comments in files in shorter and Precise way. No logic changes, just comment rewording for clarity and conciseness.
This commit is contained in:
@@ -10,14 +10,8 @@ using HellionChat.Util;
|
|||||||
|
|
||||||
namespace HellionChat;
|
namespace HellionChat;
|
||||||
|
|
||||||
// Hellion Chat — Auto-Tell-Tabs.
|
// Auto-Tell-Tabs: spawns session-only tabs per /tell partner.
|
||||||
//
|
// 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;
|
||||||
@@ -87,10 +81,7 @@ internal sealed class AutoTellTabsService : IDisposable
|
|||||||
var partner = ExtractTellPartner(message);
|
var partner = ExtractTellPartner(message);
|
||||||
if (partner == null)
|
if (partner == null)
|
||||||
{
|
{
|
||||||
// Real message without a player payload — e.g. GM tells, which
|
// Diagnostics: helps detect regressions (FFXIV payload changes, new edge cases)
|
||||||
// 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}, "
|
||||||
@@ -105,9 +96,7 @@ 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)
|
||||||
{
|
{
|
||||||
// Tab already exists; Tab.Matches has already routed this
|
// Already routed via MessageManager pipeline
|
||||||
// message via the MessageManager pipeline (see Task 2 sender
|
|
||||||
// filter).
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,10 +113,7 @@ internal sealed class AutoTellTabsService : IDisposable
|
|||||||
{
|
{
|
||||||
if (message.Code.Type == ChatType.TellIncoming)
|
if (message.Code.Type == ChatType.TellIncoming)
|
||||||
{
|
{
|
||||||
// Incoming tell: the sender is the conversation partner. The
|
// Sender is the partner; check chunks first, then raw SeString as fallback
|
||||||
// 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);
|
||||||
@@ -138,10 +124,7 @@ internal sealed class AutoTellTabsService : IDisposable
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Outgoing tell: the local player is the sender, the partner shows
|
// Outgoing tell: check content first, then channels's TellTarget as fallback
|
||||||
// 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)
|
||||||
@@ -175,10 +158,7 @@ internal sealed class AutoTellTabsService : IDisposable
|
|||||||
|
|
||||||
private void DropOldestTempTab()
|
private void DropOldestTempTab()
|
||||||
{
|
{
|
||||||
// Greeted tabs are dropped before un-greeted ones (the user said
|
// Prioritize greeted tabs for drop; within each bucket, drop by oldest LastActivity
|
||||||
// "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)
|
||||||
@@ -191,12 +171,7 @@ internal sealed class AutoTellTabsService : IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// v0.6.1 — if the victim is currently popped out, tear down the
|
// Clean up pop-out window if tab is popped out
|
||||||
// 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 =>
|
||||||
@@ -210,8 +185,7 @@ internal sealed class AutoTellTabsService : IDisposable
|
|||||||
|
|
||||||
Plugin.Config.Tabs.RemoveAt(victim.Index);
|
Plugin.Config.Tabs.RemoveAt(victim.Index);
|
||||||
|
|
||||||
// Re-anchor the active tab so the user does not silently end up on
|
// Re-anchor active tab to avoid silent switch when tab is dropped
|
||||||
// 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;
|
||||||
@@ -222,22 +196,12 @@ internal sealed class AutoTellTabsService : IDisposable
|
|||||||
{
|
{
|
||||||
var tab = BuildTempTab(partner.Name, partner.World);
|
var tab = BuildTempTab(partner.Name, partner.World);
|
||||||
|
|
||||||
// Preload first so the tab opens with chronological history above
|
// Preload history: chronological order with current message already persisted
|
||||||
// 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);
|
||||||
|
|
||||||
// Hellion Chat v0.6.1 — opt-in: open new /tell tabs directly as a
|
// Open as pop-out if configured (set before Tabs.Add for next render-tick)
|
||||||
// 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;
|
||||||
@@ -272,9 +236,7 @@ internal sealed class AutoTellTabsService : IDisposable
|
|||||||
{
|
{
|
||||||
return $"{playerName}@{worldRow.Name}";
|
return $"{playerName}@{worldRow.Name}";
|
||||||
}
|
}
|
||||||
// World sheet lookup miss is rare (only for FFXIV worlds Dalamud has
|
// Fallback if world lookup misses (rare; only for unseen worlds)
|
||||||
// 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}";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,9 +250,7 @@ internal sealed class AutoTellTabsService : IDisposable
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Pull one extra row because the live tell that triggered this
|
// Pull one extra row: current message is already in store and would eat a preload slot
|
||||||
// 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,
|
||||||
@@ -305,23 +265,17 @@ internal sealed class AutoTellTabsService : IDisposable
|
|||||||
|
|
||||||
if (historicMessages.Count == 0)
|
if (historicMessages.Count == 0)
|
||||||
{
|
{
|
||||||
// No prior tells with this player — leave the tab to start
|
// No prior tells; leave tab empty to avoid orphaned "history loaded" marker
|
||||||
// empty so the user does not see a "history loaded" marker
|
|
||||||
// sitting alone above the very first message.
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The history list is already oldest-first, so a plain AddPrune
|
// History is oldest-first; add in order for chronological display
|
||||||
// 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Visible separator between the loaded history and the live
|
// Separator between history and live tell (sorts after history but before current)
|
||||||
// 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
|
||||||
@@ -329,9 +283,7 @@ internal sealed class AutoTellTabsService : IDisposable
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Non-fatal: the tab still spawns, but the user gets a visible
|
// Non-fatal: tab still spawns with visible error notice instead of silent history loss
|
||||||
// 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),
|
||||||
@@ -372,9 +324,7 @@ internal sealed class AutoTellTabsService : IDisposable
|
|||||||
|
|
||||||
lock (_tempTabsLock)
|
lock (_tempTabsLock)
|
||||||
{
|
{
|
||||||
// Frame-race guard (E5): the sidebar might still render a tab
|
// Guard against frame-race: sidebar might render a tab already removed by LRU or logout
|
||||||
// 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;
|
||||||
@@ -388,18 +338,12 @@ internal sealed class AutoTellTabsService : IDisposable
|
|||||||
{
|
{
|
||||||
lock (_tempTabsLock)
|
lock (_tempTabsLock)
|
||||||
{
|
{
|
||||||
// Snapshot whether the active tab is about to be removed, BEFORE
|
// Snapshot active tab index before mutating list
|
||||||
// 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;
|
||||||
|
|
||||||
// v0.6.1 — symmetric to DropOldestTempTab cleanup: tear down any
|
// Clean up pop-out windows before removing temp tabs
|
||||||
// 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)
|
||||||
@@ -419,9 +363,7 @@ internal sealed class AutoTellTabsService : IDisposable
|
|||||||
|
|
||||||
Plugin.Config.Tabs.RemoveAll(t => t.IsTempTab);
|
Plugin.Config.Tabs.RemoveAll(t => t.IsTempTab);
|
||||||
|
|
||||||
// Force a switch to tab 0 if the active tab was a temp tab OR
|
// Force switch to tab 0 if active tab was temp or index is now out of range
|
||||||
// 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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
// HellionChat/Branding/BrandingLinks.cs
|
|
||||||
namespace HellionChat.Branding;
|
namespace HellionChat.Branding;
|
||||||
|
|
||||||
// Centralised so a future invite rotation only touches one file. The same
|
// Centralised — a future invite/URL rotation only touches this file.
|
||||||
// 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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,9 +34,7 @@ public abstract class Chunk
|
|||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
// Returns basic text for hashing (content for TextChunk, icon name for IconChunk)
|
||||||
/// Get some basic text for use in generating hashes.
|
|
||||||
/// </summary>
|
|
||||||
internal string StringValue()
|
internal string StringValue()
|
||||||
{
|
{
|
||||||
return this switch
|
return this switch
|
||||||
@@ -108,9 +106,6 @@ 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)
|
||||||
@@ -122,9 +117,6 @@ 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)
|
||||||
|
|||||||
+26
-154
@@ -38,33 +38,26 @@ public class Configuration : IPluginConfiguration
|
|||||||
|
|
||||||
public int Version { get; set; } = LatestVersion;
|
public int Version { get; set; } = LatestVersion;
|
||||||
|
|
||||||
// v1.1.0 — Theme-Engine. Slug-basiert; ThemeRegistry liefert das Objekt.
|
// Slug-based; ThemeRegistry resolves the object at runtime.
|
||||||
public string Theme = "hellion-arctic";
|
public string Theme = "hellion-arctic";
|
||||||
|
|
||||||
// v1.1.0 — Globale Window-Opacity, theme-übergreifend. Migration aus
|
// Global window opacity, applied across all themes.
|
||||||
// HellionThemeWindowOpacity beim Bump v13 → v14.
|
|
||||||
public float WindowOpacity = 0.85f;
|
public float WindowOpacity = 0.85f;
|
||||||
|
|
||||||
// v1.1.0 — Felder für künftige UI-Toggles (v1.2.0 / v1.3.0). Werden
|
// Reserved for future UI toggles; pre-declared to avoid a migration later.
|
||||||
// vorab angelegt, damit später keine Migration nötig ist.
|
|
||||||
public bool ReduceMotion;
|
public bool ReduceMotion;
|
||||||
|
|
||||||
// v1.2.1 — Default geflippt von false → true. Card-Rows-Layout aus
|
// v1.2.1: default flipped false → true. Compact single-line layout is
|
||||||
// v1.2.0 wurde als zu dicht empfunden; Single-Line `[HH:mm] Sender:
|
// more readable than the card-rows layout introduced in v1.2.0.
|
||||||
// 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;
|
||||||
|
|
||||||
// Hellion Chat — Privacy filter (DSGVO Art. 25 Privacy by Default).
|
// Privacy by Default master switch. Set false to restore upstream behaviour.
|
||||||
// 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 we don't know about.
|
// Failsafe for ChatTypes added by future FFXIV patches.
|
||||||
public bool PrivacyPersistUnknownChannels;
|
public bool PrivacyPersistUnknownChannels;
|
||||||
|
|
||||||
public bool IsAllowedForStorage(ChatType type)
|
public bool IsAllowedForStorage(ChatType type)
|
||||||
@@ -76,79 +69,23 @@ public class Configuration : IPluginConfiguration
|
|||||||
return PrivacyPersistUnknownChannels;
|
return PrivacyPersistUnknownChannels;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hellion Chat — Message retention (GDPR data minimization, time axis).
|
// Retention master switch defaults to false — plugin will not delete
|
||||||
// Master switch defaults to false; the plugin will not delete history
|
// history until the user explicitly opts in.
|
||||||
// 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 (1–50). 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 0–100; 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)
|
||||||
@@ -167,10 +104,7 @@ public class Configuration : IPluginConfiguration
|
|||||||
public bool HideInLoadingScreens;
|
public bool HideInLoadingScreens;
|
||||||
public bool HideInBattle;
|
public bool HideInBattle;
|
||||||
|
|
||||||
// v1.2.1 — Default geflippt false → true. Hellion-UI im NG+-Menü
|
// v1.2.1: default flipped false → true for consistency with other hide defaults.
|
||||||
// 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;
|
||||||
@@ -186,18 +120,8 @@ 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 5–15 simultaneous conversations). Bestand
|
|
||||||
// users keep their saved value untouched — only fresh installs pick
|
|
||||||
// up the new default.
|
|
||||||
public bool SidebarTabView = true;
|
public bool SidebarTabView = true;
|
||||||
public bool PrintChangelog = true;
|
public bool PrintChangelog = true;
|
||||||
public bool OnlyPreviewIf;
|
public bool OnlyPreviewIf;
|
||||||
@@ -218,22 +142,10 @@ 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;
|
||||||
@@ -258,12 +170,6 @@ 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()
|
||||||
@@ -333,9 +239,7 @@ public class Configuration : IPluginConfiguration
|
|||||||
MaxLinesToRender = other.MaxLinesToRender;
|
MaxLinesToRender = other.MaxLinesToRender;
|
||||||
Use24HourClock = other.Use24HourClock;
|
Use24HourClock = other.Use24HourClock;
|
||||||
ShowEmotes = other.ShowEmotes;
|
ShowEmotes = other.ShowEmotes;
|
||||||
// Deep-copy the set so the live and mutable Configuration instances don't share state
|
// Deep-copy so settings window edits don't leak into live config before Save.
|
||||||
// — 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;
|
||||||
@@ -349,22 +253,11 @@ 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;
|
||||||
|
|
||||||
// Hellion Chat — Auto-Tell-Tabs are session-only and therefore
|
// Keep live temp tabs alive across UpdateFrom — a settings save must
|
||||||
// never present in a disk-loaded copy. Keep the live temp tabs of
|
// not destroy open tell conversations. For persistent tabs, capture
|
||||||
// *this* configuration alive across an UpdateFrom so a settings
|
// the live MessageList and LastSendUnread by Identifier before the
|
||||||
// save (or sidebar-mode toggle) does not silently destroy the
|
// replace and restore them onto the freshly cloned tabs; new tabs
|
||||||
// user's open tell conversations.
|
// get an empty MessageList, deleted tabs lose their history (intended).
|
||||||
//
|
|
||||||
// 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));
|
||||||
@@ -456,9 +349,7 @@ public class Tab
|
|||||||
{
|
{
|
||||||
public string Name = Language.Tab_DefaultName;
|
public string Name = Language.Tab_DefaultName;
|
||||||
|
|
||||||
// v1.2.0 — optionaler FontAwesome-Glyph-Name. Null bedeutet:
|
// Optional FontAwesome glyph name; null falls back to TabIconMapping default.
|
||||||
// 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")]
|
||||||
@@ -510,15 +401,12 @@ public class Tab
|
|||||||
[NonSerialized]
|
[NonSerialized]
|
||||||
public Guid Identifier = Guid.NewGuid();
|
public Guid Identifier = Guid.NewGuid();
|
||||||
|
|
||||||
// Hellion Chat — Auto-Tell-Tabs greeted flag. Toggled manually from the
|
// Session-only greeted flag for club-greeter workflows.
|
||||||
// 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;
|
||||||
|
|
||||||
// v1.4.2 — TabTintCache uses separate validation keys per cache so a
|
// Separate validation keys per cache so TellTarget changes don't
|
||||||
// TellTarget change picked up by GetTint can't strand GetIcon (or vice
|
// cause GetTint and GetIcon to strand each other with stale entries.
|
||||||
// versa) with a stale entry that looks fresh on the shared key.
|
|
||||||
[NonSerialized]
|
[NonSerialized]
|
||||||
internal string? _cachedTintTellName;
|
internal string? _cachedTintTellName;
|
||||||
|
|
||||||
@@ -540,17 +428,12 @@ 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;
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-tell temp tabs are bound to a single conversation partner;
|
// Temp tabs are bound to a single conversation partner — other tells
|
||||||
// every other tell that matches the channel filter must NOT land
|
// matching the channel filter must not land here.
|
||||||
// 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;
|
||||||
}
|
}
|
||||||
@@ -610,10 +493,7 @@ public class Tab
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// Ordered message list with duplicate ID tracking, sorting and mutex protection.
|
||||||
/// 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);
|
||||||
@@ -701,10 +581,7 @@ public class Tab
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// Current message count. Lock-per-read is acceptable for 1×/sec status bar polling.
|
||||||
/// 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
|
||||||
@@ -721,9 +598,7 @@ public class Tab
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// Returns an array copy of the message list for usage outside of main thread.
|
||||||
/// 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);
|
||||||
@@ -737,10 +612,7 @@ public class Tab
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// Returns a read-only list while holding a reader lock. Use with a using statement.
|
||||||
/// 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);
|
||||||
|
|||||||
+12
-29
@@ -79,7 +79,7 @@ public static class EmoteCache
|
|||||||
Done,
|
Done,
|
||||||
}
|
}
|
||||||
|
|
||||||
// All of this data is uninitalized while State is not `LoadingState.Done`
|
// All fields below are uninitialised while State != 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,15 +87,11 @@ public static class EmoteCache
|
|||||||
|
|
||||||
public static string[] SortedCodeArray = [];
|
public static string[] SortedCodeArray = [];
|
||||||
|
|
||||||
// Plugin-scoped cancellation source for in-flight emote loads. Dispose
|
// Cancelled on Dispose to stop in-flight downloads; replaced on re-enable.
|
||||||
// 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;
|
||||||
|
|
||||||
// Drain target for in-flight loads on Dispose; without this an orphan
|
// Tracks in-flight loads so Dispose can drain them before teardown.
|
||||||
// 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)
|
||||||
@@ -117,8 +113,7 @@ public static class EmoteCache
|
|||||||
if (State is not LoadingState.Unloaded)
|
if (State is not LoadingState.Unloaded)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Refresh the CTS in case Dispose was called and we're being re-enabled
|
// Reset CTS if Dispose was called and the plugin is being re-enabled.
|
||||||
// in the same process (Dalamud /xlplugins toggle).
|
|
||||||
if (Cts.IsCancellationRequested)
|
if (Cts.IsCancellationRequested)
|
||||||
Cts = new CancellationTokenSource();
|
Cts = new CancellationTokenSource();
|
||||||
|
|
||||||
@@ -140,11 +135,8 @@ 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; the
|
// BetterTTV occasionally returns entries with a null Code;
|
||||||
// upstream code passed those straight into Dictionary.TryAdd
|
// skip them so a single bad row doesn't break the whole cache.
|
||||||
// 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)
|
||||||
@@ -160,16 +152,11 @@ public static class EmoteCache
|
|||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
// Plugin disposed while the cache was loading; leave State on
|
// Plugin disposed mid-load; State stays on Loading so re-enable can retry.
|
||||||
// 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 (e.g. the user reopening
|
// Reset to Unloaded so a later trigger can retry without a plugin reload.
|
||||||
// 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");
|
||||||
}
|
}
|
||||||
@@ -248,11 +235,8 @@ public static class EmoteCache
|
|||||||
|
|
||||||
internal async Task<byte[]> LoadAsync(Emote emote, CancellationToken ct)
|
internal async Task<byte[]> LoadAsync(Emote emote, CancellationToken ct)
|
||||||
{
|
{
|
||||||
// BetterTTV-supplied Id and ImageType are interpolated straight
|
// Path-traversal guard: resolve and verify the candidate path stays
|
||||||
// into the filename. HTTPS protects the wire, but a compromised
|
// inside the cache directory before reading or writing.
|
||||||
// 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")
|
||||||
);
|
);
|
||||||
@@ -397,7 +381,7 @@ public static class EmoteCache
|
|||||||
|
|
||||||
var delay = frame.Metadata.GetGifMetadata().FrameDelay / 100f;
|
var delay = frame.Metadata.GetGifMetadata().FrameDelay / 100f;
|
||||||
|
|
||||||
// Follows the same pattern as browsers, anything under 0.02s delay will be rounded up to 0.1s
|
// Match browser behaviour: anything under 20ms rounds up to 100ms.
|
||||||
if (delay < 0.02f)
|
if (delay < 0.02f)
|
||||||
delay = 0.1f;
|
delay = 0.1f;
|
||||||
|
|
||||||
@@ -416,8 +400,7 @@ public static class EmoteCache
|
|||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
// Plugin disposed mid-load; partial frames are released by
|
// Plugin disposed mid-load; release any partial frames.
|
||||||
// InnerDispose on the next dispose pass.
|
|
||||||
foreach (var f in Frames)
|
foreach (var f in Frames)
|
||||||
f.Texture.Dispose();
|
f.Texture.Dispose();
|
||||||
Frames = [];
|
Frames = [];
|
||||||
|
|||||||
@@ -41,12 +41,7 @@ public class FontManager
|
|||||||
90f,
|
90f,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// <summary>
|
// Hellion font bytes (Exo 2, OFL-1.1); lazily loaded from manifest resources
|
||||||
/// 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()
|
||||||
@@ -70,11 +65,9 @@ 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)
|
||||||
@@ -116,13 +109,7 @@ public class FontManager
|
|||||||
JpRange = BuildRange(GlyphRangesJapanese.GlyphRanges);
|
JpRange = BuildRange(GlyphRangesJapanese.GlyphRanges);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
// CPU-bound build offloaded to Task.Run; runs parallel with theme init
|
||||||
/// 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();
|
||||||
@@ -154,12 +141,7 @@ 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 — Bei aktiver Hellion-Schrift (Exo 2 ist Variable-Font)
|
// v1.2.0: UseHellionFont controls font size selection
|
||||||
// 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;
|
||||||
@@ -218,13 +200,7 @@ public class FontManager
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
// Add font with fallback to NotoSansCjkRegular if unavailable
|
||||||
/// 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,
|
||||||
|
|||||||
@@ -1,36 +1,21 @@
|
|||||||
<Project Sdk="Dalamud.NET.Sdk/15.0.0">
|
<Project Sdk="Dalamud.NET.Sdk/15.0.0">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<!-- Hellion Chat versioning runs separately from upstream Chat 2.
|
<!-- Independent versioning; see yaml changelog for upstream Chat 2 base -->
|
||||||
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>
|
||||||
<!-- Honor packages.lock.json on restore so floating version ranges
|
<!-- Use lock file to pin exact versions -->
|
||||||
don't silently drift between machines or CI runs. -->
|
|
||||||
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
|
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
|
||||||
<!-- v1.0.0 standalone cut — both AssemblyName and RootNamespace
|
<!-- v1.0.0+: standalone fork, no upstream cherry-pick compatibility -->
|
||||||
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 on packages with breaking-change history block a
|
<!-- Closed ranges prevent surprise major bumps during lock file regeneration -->
|
||||||
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" />
|
||||||
<!-- Override the transitively-referenced native SQLite build to one
|
<!-- SQLitePCLRaw override for CVE-2025-6965, CVE-2025-7709 (SQLite >= 3.50.3) -->
|
||||||
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)" />
|
||||||
@@ -38,9 +23,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- Pure-function test suites in HellionChat.Tests need access to
|
<!-- Test assembly needs access to internal helpers (not redistributed) -->
|
||||||
the internal helper classes (StringUtil, UriPayload, Tokenizer
|
|
||||||
etc.). Test assembly does not get redistributed. -->
|
|
||||||
<InternalsVisibleTo Include="HellionChat.Tests" />
|
<InternalsVisibleTo Include="HellionChat.Tests" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
@@ -59,15 +42,7 @@
|
|||||||
</EmbeddedResource>
|
</EmbeddedResource>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- HellionChat — Hellion-specific resource bundle (HellionStrings.resx
|
<!-- Embedded resources: Hellion font (Exo 2, OFL-1.1) + manifest resource -->
|
||||||
+ 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>
|
||||||
@@ -80,14 +55,7 @@
|
|||||||
</EmbeddedResource>
|
</EmbeddedResource>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- Plugin icon. Copy images/* into the build output so Dalamud
|
<!-- Plugin icon: copy images/* to output for Dalamud discovery -->
|
||||||
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>
|
||||||
|
|||||||
@@ -31,26 +31,6 @@ 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
|
||||||
@@ -184,38 +164,6 @@ 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
|
||||||
|
|||||||
@@ -2,14 +2,8 @@ using System.Collections.Generic;
|
|||||||
|
|
||||||
namespace HellionChat;
|
namespace HellionChat;
|
||||||
|
|
||||||
// Hellion Chat — v0.6.0 shared input history. Replaces the embedded
|
// Shared input history for all ChatInputBars (main and pop-out windows).
|
||||||
// ChatLogWindow.InputBacklog so that pop-out windows with their own
|
// Push deduplicates: existing entries are moved to the end when re-added.
|
||||||
// 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;
|
||||||
@@ -26,8 +20,7 @@ public static class InputHistoryService
|
|||||||
|
|
||||||
var trimmed = entry.Trim();
|
var trimmed = entry.Trim();
|
||||||
|
|
||||||
// Move-to-newest: existing entries are removed before the append
|
// Move-to-newest: remove existing entry before adding at the end
|
||||||
// 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)
|
||||||
|
|||||||
@@ -6,25 +6,17 @@ using Newtonsoft.Json;
|
|||||||
|
|
||||||
namespace HellionChat.Integrations;
|
namespace HellionChat.Integrations;
|
||||||
|
|
||||||
// We pull Newtonsoft.Json into this single file for IPC compatibility:
|
// Newtonsoft.Json is used here for IPC compatibility with Honorific, which
|
||||||
// Honorific serialises its TitleData with Newtonsoft (see
|
// serialises TitleData with it. It's a transitive Dalamud dependency — no
|
||||||
// Honorific-master/IpcProvider.cs:9 and CustomTitle.cs:12). Using the
|
// new NuGet entry needed. The rest of HellionChat uses System.Text.Json.
|
||||||
// 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 HellionChat is built against.
|
// Major version of the Honorific IPC contract we're 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 we subscribe to. Keep them as fields so Dispose can
|
// IPC gates — kept as fields so Dispose can unsubscribe the same instances.
|
||||||
// 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;
|
||||||
@@ -48,23 +40,11 @@ internal sealed class HonorificService : IDisposable
|
|||||||
_framework = framework;
|
_framework = framework;
|
||||||
_log = log;
|
_log = log;
|
||||||
|
|
||||||
// Dalamud caches gate objects per-name for the lifetime of the
|
// Gate objects are cached per-name by Dalamud and safe to register
|
||||||
// plugin interface, so we can register subscribers even when
|
// before Honorific loads — they just won't fire until it does.
|
||||||
// Honorific isn't loaded yet — the gate just won't fire. Calling
|
// Initial pull is scheduled on the framework thread because plugin
|
||||||
// InvokeFunc before Honorific is up will throw, which is why the
|
// constructors run on the loader thread, and Honorific's IPC handlers
|
||||||
// initial pull below is wrapped in try-catch.
|
// read ObjectTable.LocalPlayer which throws off the framework thread.
|
||||||
//
|
|
||||||
// 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"
|
||||||
@@ -84,11 +64,8 @@ internal sealed class HonorificService : IDisposable
|
|||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
// Honorific may already be gone by the time we dispose. Wrap each
|
// Wrap each unsubscribe — a missing gate must not block the others.
|
||||||
// unsubscribe so a missing gate doesn't prevent the others from
|
// Leaking a subscription keeps this service alive across plugin reloads.
|
||||||
// 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));
|
||||||
@@ -119,34 +96,21 @@ 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 isn't installed or hasn't initialised yet. The Ready
|
// Honorific not installed or not yet initialised — Ready will retry.
|
||||||
// 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)
|
||||||
{
|
{
|
||||||
// Don't update cached state when we've already decided we can't trust
|
// Skip updates on version mismatch; subscription stays live for reload.
|
||||||
// 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);
|
||||||
@@ -154,28 +118,16 @@ internal sealed class HonorificService : IDisposable
|
|||||||
|
|
||||||
private void OnReady()
|
private void OnReady()
|
||||||
{
|
{
|
||||||
// Honorific loaded after HellionChat; redo the version check and
|
// Schedule on framework thread — NotifyReady can dispatch from any thread.
|
||||||
// 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 is unloading. Drop our cached state so the header
|
// Honorific unloading — clear cached state so the header hides next frame.
|
||||||
// hides on the next frame; subscriptions stay registered because
|
// Subscriptions stay registered in case Honorific reloads.
|
||||||
// the gates may come back later (Honorific reload).
|
// CurrentTitle is already nulled by OnTitleChanged before this fires,
|
||||||
//
|
// 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;
|
||||||
@@ -193,28 +145,15 @@ internal sealed class HonorificService : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Threading note: Dalamud fires IPC events on the framework thread and
|
// Threading: IPC events and ImGui both run on the framework thread, so
|
||||||
// ImGui renders on the framework thread, so OnTitleChanged and the
|
// OnTitleChanged and the render path never race — no volatile/Interlocked
|
||||||
// render path that reads CurrentTitle never race — OnTitleChanged is
|
// needed as long as Dalamud's framework-thread delivery contract holds.
|
||||||
// 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.
|
|
||||||
//
|
//
|
||||||
// The constructor's initial pull and OnReady, on the other hand, are
|
// Constructor and OnReady are exceptions: they run outside that contract
|
||||||
// explicitly scheduled via IFramework.RunOnFrameworkThread because
|
// (plugin-loader thread and Honorific's NotifyReady respectively), so both
|
||||||
// they run outside that contract: the constructor executes on the
|
// use RunOnFrameworkThread to safely reach ObjectTable.LocalPlayer.
|
||||||
// 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 below; tested via HellionChat.Tests/Integrations. ---
|
// --- Pure-logic helpers; tested via HellionChat.Tests/Integrations. ---
|
||||||
|
|
||||||
internal static HonorificTitleData? ParseTitleJson(string json)
|
internal static HonorificTitleData? ParseTitleJson(string json)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,13 +2,9 @@ using System.Numerics;
|
|||||||
|
|
||||||
namespace HellionChat.Integrations;
|
namespace HellionChat.Integrations;
|
||||||
|
|
||||||
// Local DTO mirroring Honorific's TitleData shape. We replicate the structure
|
// Local DTO mirroring Honorific's TitleData — no hard reference to Honorific.dll
|
||||||
// instead of referencing Honorific.dll because a hard build-time dependency
|
// so HellionChat loads cleanly when Honorific is absent.
|
||||||
// would couple the two assemblies and break HellionChat at load time when
|
// Glow/gradient fields omitted; Cycle 1 renders primary Color only.
|
||||||
// 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,
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
namespace HellionChat.Integrations;
|
namespace HellionChat.Integrations;
|
||||||
|
|
||||||
// External URLs for the third-party plugins HellionChat integrates with.
|
// Third-party plugin URLs — separate from BrandingLinks (Hellion-owned URLs).
|
||||||
// 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";
|
||||||
|
|||||||
@@ -27,16 +27,7 @@ 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; }
|
||||||
|
|
||||||
// Messages go into the PendingSync queue first, which will be consumed one
|
// PendingSync (main thread) → PendingAsync (worker thread); LinkedList for O(1) Last access
|
||||||
// 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;
|
||||||
@@ -53,11 +44,8 @@ internal class MessageManager : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hellion Chat — Auto-Tell-Tabs hook. Fires after a fully processed
|
// Auto-Tell-Tabs hook: fires after a message is processed and stored, allowing
|
||||||
// message has been routed to all matching persistent tabs and stored
|
// AutoTellTabsService to spawn or refresh temp tabs without coupling.
|
||||||
// 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)
|
||||||
@@ -66,8 +54,6 @@ 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)
|
||||||
)
|
)
|
||||||
@@ -107,12 +93,9 @@ 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 a background thread; next plugin reload releases it. "
|
+ "Worker remains on 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();
|
||||||
@@ -166,12 +149,7 @@ internal class MessageManager : IAsyncDisposable
|
|||||||
|
|
||||||
internal void ClearAllTabs()
|
internal void ClearAllTabs()
|
||||||
{
|
{
|
||||||
// Hellion Chat — TempTabs haben keine DB-Persistenz (session-only,
|
// TempTabs are session-only (not persisted); exclude them to preserve Tell history
|
||||||
// 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();
|
||||||
}
|
}
|
||||||
@@ -184,12 +162,7 @@ internal class MessageManager : IAsyncDisposable
|
|||||||
|
|
||||||
using var messages = Store.GetMostRecentMessages(CurrentContentId, since);
|
using var messages = Store.GetMostRecentMessages(CurrentContentId, since);
|
||||||
|
|
||||||
// We store the pending messages to be added to the chat log in a
|
// TempTabs are excluded; they maintain live state from AutoTellTabsService
|
||||||
// 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>()))
|
||||||
@@ -198,7 +171,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 the messages to the chat log in one go.
|
// Apply messages to chat log all at once.
|
||||||
foreach (var (tab, pendingMessages) in pendingTabs)
|
foreach (var (tab, pendingMessages) in pendingTabs)
|
||||||
tab.Messages.AddSortPrune(pendingMessages, MessageDisplayLimit);
|
tab.Messages.AddSortPrune(pendingMessages, MessageDisplayLimit);
|
||||||
|
|
||||||
@@ -207,8 +180,7 @@ internal class MessageManager : IAsyncDisposable
|
|||||||
|
|
||||||
WrapperUtil.AddNotification(Language.LoadMessages_Error, NotificationType.Error);
|
WrapperUtil.AddNotification(Language.LoadMessages_Error, NotificationType.Error);
|
||||||
|
|
||||||
// Mark the failed messages as deleted so we don't try to load them
|
// Mark failed messages as deleted to prevent retry attempts
|
||||||
// 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())
|
||||||
@@ -256,16 +228,10 @@ internal class MessageManager : IAsyncDisposable
|
|||||||
// Update colour codes.
|
// Update colour codes.
|
||||||
GlobalParametersCache.Refresh();
|
GlobalParametersCache.Refresh();
|
||||||
|
|
||||||
// We delay messages to be handed off to the async processing thread
|
// Delay to next tick to get content ID from ContentIdResolver hook
|
||||||
// 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,
|
||||||
@@ -408,7 +374,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)); // Can't use `ToString()` as it defaults to macro
|
.Select(text => Encoding.UTF8.GetString(text.Body.Span));
|
||||||
|
|
||||||
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;
|
||||||
|
|||||||
+53
-166
@@ -90,10 +90,8 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
|||||||
|
|
||||||
public readonly WindowSystem WindowSystem = new(PluginName);
|
public readonly WindowSystem WindowSystem = new(PluginName);
|
||||||
|
|
||||||
// v1.4.3: properties moved from { get; } to { get; private set; } = null!;
|
// Phase-2 services are constructed in LoadAsync; null! shape is kept
|
||||||
// because LoadAsync now owns construction of the Phase-2 services.
|
// consistent across all properties for clarity.
|
||||||
// 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!;
|
||||||
@@ -115,27 +113,20 @@ 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!;
|
||||||
|
|
||||||
// (B3) Lightless idempotency guard — Dalamud may fire DisposeAsync twice
|
// Idempotency guard — Dalamud may fire DisposeAsync twice in a reload race.
|
||||||
// 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. The 24h auto-sweep on plugin load and
|
// Serialises retention sweeps so a manual trigger and the 24h auto-sweep
|
||||||
// the manual button in the Privacy tab both run on background threads;
|
// can't run in parallel. Volatile because the ImGui thread reads it outside
|
||||||
// without this gate, hitting the manual button moments after a fresh
|
// the lock to gate the manual button.
|
||||||
// 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 needs to happen outside the chatlog window class for access reasons
|
// Tab management lives here rather than in ChatLogWindow 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
|
||||||
@@ -149,31 +140,22 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
|||||||
|
|
||||||
public Plugin()
|
public Plugin()
|
||||||
{
|
{
|
||||||
// Phase-1 ctor stays minimal: bootstrap-essentials only (conflict
|
// Phase-1 ctor: bootstrap-essentials only (conflict gate, config load,
|
||||||
// gate, config load, language + ImGui init, WindowSystem skeleton).
|
// language + ImGui init). All service/window allocation lives in LoadAsync.
|
||||||
// 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.
|
|
||||||
|
|
||||||
// Refuse to start if upstream Chat 2 is loaded — prevents IPC
|
// Block load if upstream Chat 2 is active — prevents IPC collisions
|
||||||
// channel collisions and double-replacement of the in-game chat
|
// and double-replacement of the in-game chat window.
|
||||||
// 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();
|
||||||
|
|
||||||
// Hellion Chat: take over config + database from upstream ChatTwo
|
// Migrate config + database from upstream ChatTwo on first start.
|
||||||
// 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 only supports config schema v16. Older configs
|
// Schema gate: v1.4.3 requires config v16. Users on older schemas
|
||||||
// went through their migrations in v1.2.1 (v15→v16) and earlier; users
|
// must install v1.4.2 first to run the migration chain.
|
||||||
// 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(
|
||||||
@@ -182,19 +164,13 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hellion Chat — Auto-Tell-Tabs Defense-in-Depth. SaveConfig
|
// Drop session-only Auto-Tell-Tabs that a previous crash may have persisted.
|
||||||
// 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)
|
||||||
@@ -203,14 +179,8 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Hellion v1.0.0 default tab layout. Five thematically separated
|
// Default tab layout on fresh install. Tells are handled by
|
||||||
// tabs: General catches the immediate-surroundings public chat
|
// Auto-Tell-Tabs; Novice Network has no preset tab by design.
|
||||||
// (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);
|
||||||
@@ -222,19 +192,12 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
|||||||
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
// Sync allocation + handle registration. BuildFonts() registers
|
// BuildFonts registers handles with Dalamud's FontAtlas; the atlas
|
||||||
// IFontHandles with Dalamud's UiBuilder.FontAtlas — registration
|
// rebuilds async a few frames later (visible "font-pop" on first load).
|
||||||
// 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();
|
||||||
|
|
||||||
// Theme init stays sync on the LoadAsync continuation — cheap,
|
// ThemeRegistry must be wired before the first Draw tick.
|
||||||
// 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);
|
||||||
@@ -243,11 +206,9 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
|||||||
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
// Service allocations: order encodes dependencies. Commands is
|
// Service allocations — order encodes dependencies.
|
||||||
// alloc-only here; Initialise() runs after windows exist so the
|
// HonorificService registers IPC subscribers early to catch
|
||||||
// slash-commands can toggle their visibility. HonorificService
|
// Ready/Disposing events from the first frame.
|
||||||
// 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);
|
||||||
@@ -258,9 +219,6 @@ 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,
|
||||||
@@ -268,7 +226,6 @@ 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);
|
||||||
@@ -289,22 +246,19 @@ 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 itself when
|
// Daily retention sweep — fire-and-forget, skips when disabled
|
||||||
// disabled or when it already ran within the past 24 hours.
|
// or already ran within the past 24 hours.
|
||||||
RunRetentionSweepIfDue();
|
RunRetentionSweepIfDue();
|
||||||
|
|
||||||
if (Config.ShowEmotes)
|
if (Config.ShowEmotes)
|
||||||
_ = EmoteCache.LoadData(); // Fire-and-forget, exceptions caught inside
|
_ = EmoteCache.LoadData();
|
||||||
|
|
||||||
if (Interface.Reason is not PluginLoadReason.Boot)
|
if (Interface.Reason is not PluginLoadReason.Boot)
|
||||||
MessageManager.FilterAllTabsAsync();
|
MessageManager.FilterAllTabsAsync();
|
||||||
@@ -313,33 +267,22 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
|||||||
Interface.UiBuilder.DisableGposeUiHide = true;
|
Interface.UiBuilder.DisableGposeUiHide = true;
|
||||||
|
|
||||||
#if !DEBUG
|
#if !DEBUG
|
||||||
// Fire-and-forget on a worker thread. The first auto-translate use of
|
// Fire-and-forget — first auto-translate use may have a sub-second
|
||||||
// a session may have a sub-second hitch if the cache hasn't filled yet,
|
// hitch if the cache hasn't filled yet, but avoids blocking load.
|
||||||
// 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();
|
||||||
|
|
||||||
// (B1) Hooks last: every service and window must be live before
|
// Hooks last — all services and windows must be live before
|
||||||
// Dalamud fires our first Draw / FrameworkUpdate tick. Anything
|
// the first Draw / FrameworkUpdate tick fires.
|
||||||
// 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);
|
||||||
@@ -351,28 +294,22 @@ 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()
|
||||||
{
|
{
|
||||||
// (B3) Idempotency guard — Dalamud may reload-race us; second
|
// Idempotency guard — second call short-circuits on reload race.
|
||||||
// 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;
|
||||||
|
|
||||||
// Hooks unsubscribe FIRST so no Draw / FrameworkUpdate / LanguageChanged
|
// Unsubscribe hooks first — mirrors the hooks-last subscribe order in LoadAsync.
|
||||||
// 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);
|
||||||
|
|
||||||
// v1.4.0 F5.3 — flush a pending DeferredSave before service teardown,
|
// Flush a pending DeferredSave — FrameworkUpdate won't fire it anymore.
|
||||||
// since FrameworkUpdate just got unsubscribed and won't fire it.
|
|
||||||
failure = CaptureFailure(
|
failure = CaptureFailure(
|
||||||
failure,
|
failure,
|
||||||
() =>
|
() =>
|
||||||
@@ -385,13 +322,10 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Auto-Tell-Tabs unsubscribes from MessageProcessed before MessageManager
|
// Unsubscribe AutoTellTabs before MessageManager goes away.
|
||||||
// goes away. Pure-memory cleanup, no framework-thread requirement.
|
|
||||||
failure = CaptureFailure(failure, () => AutoTellTabsService?.Dispose());
|
failure = CaptureFailure(failure, () => AutoTellTabsService?.Dispose());
|
||||||
|
|
||||||
// v1.4.0 F6.2 — MessageManager has its own async dispose path
|
// MessageManager has its own async dispose path (DB flush, thread shutdown).
|
||||||
// (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(
|
||||||
@@ -401,36 +335,24 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
|||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// (B4) Game-Function / IPC / UI-Window cleanup MUST run on the
|
// Game-function / IPC / window cleanup must run on the framework thread.
|
||||||
// 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 — dispose before windows so any final
|
// IPC subscribers before windows — prevents a final IPC event
|
||||||
// event firing from the IPC source can't reach a half-torn
|
// from reaching a half-torn ChatLogWindow.
|
||||||
// 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());
|
||||||
@@ -446,8 +368,7 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
|||||||
failure ??= ex;
|
failure ??= ex;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pure-memory cleanups — no Framework / UI / IPC touch, so they
|
// Pure-memory cleanups — no Framework / UI / IPC touch.
|
||||||
// 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());
|
||||||
@@ -456,9 +377,8 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
|||||||
ExceptionDispatchInfo.Capture(failure).Throw();
|
ExceptionDispatchInfo.Capture(failure).Throw();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lightless-pattern capture helpers: run cleanup, remember the FIRST
|
// Run cleanup actions individually so a single failure doesn't strand
|
||||||
// exception, keep going. Without these one mid-teardown failure would
|
// the remaining teardown steps.
|
||||||
// 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
|
||||||
@@ -499,9 +419,6 @@ 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
|
||||||
@@ -523,13 +440,6 @@ 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;
|
||||||
|
|
||||||
@@ -537,6 +447,8 @@ 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));
|
||||||
@@ -590,9 +502,6 @@ 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
|
||||||
{
|
{
|
||||||
@@ -610,10 +519,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -624,8 +529,7 @@ 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 we run.
|
// Snapshot the policy so the user can edit settings while the sweep runs.
|
||||||
// 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;
|
||||||
@@ -633,16 +537,10 @@ 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 for the same reason as PendingMessageThread:
|
// IsBackground = true so a stuck sweep never blocks plugin unload.
|
||||||
// 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 out cheaply if a manual sweep is already in flight; the
|
// Bail early if a manual sweep is already in flight.
|
||||||
// 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)
|
||||||
@@ -659,11 +557,8 @@ 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 the clear+refilter synchronously on the framework thread.
|
// Run clear+refilter on the framework thread — FilterAllTabsAsync
|
||||||
// Earlier this called FilterAllTabsAsync(), which is fire-and-forget
|
// is fire-and-forget and would race the next sweep cycle.
|
||||||
// — 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(() =>
|
||||||
{
|
{
|
||||||
@@ -694,9 +589,7 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
|||||||
|
|
||||||
private void Draw()
|
private void Draw()
|
||||||
{
|
{
|
||||||
// Theme-Engine ist ab v14 immer aktiv; Klassik ist jetzt ein eigenes
|
// Theme engine is always active; Classic is a theme, not a disabled state.
|
||||||
// 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
|
||||||
@@ -711,9 +604,7 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// v1.0.2 — global skip while the New Game+ menu (QuestRedo addon) is
|
// Hide all plugin windows while the New Game+ menu is open.
|
||||||
// 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(
|
||||||
@@ -742,10 +633,7 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
|||||||
|
|
||||||
internal void SaveConfig()
|
internal void SaveConfig()
|
||||||
{
|
{
|
||||||
// Hellion Chat — Auto-Tell-Tabs are session-only. Strip them out
|
// Strip session-only Auto-Tell-Tabs before serialization; restore after.
|
||||||
// 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);
|
||||||
|
|
||||||
@@ -794,9 +682,8 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
|||||||
Condition[ConditionFlag.OccupiedInCutSceneEvent]
|
Condition[ConditionFlag.OccupiedInCutSceneEvent]
|
||||||
|| Condition[ConditionFlag.WatchingCutscene78];
|
|| Condition[ConditionFlag.WatchingCutscene78];
|
||||||
|
|
||||||
// v1.1.0 — wenn der themes/-Ordner leer ist, schreiben wir die embedded
|
// Seeds example-theme.json into the themes dir on first run.
|
||||||
// example-theme.json als Vorlage rein. Bestehende User-Customs werden
|
// Skipped if any custom JSON already exists.
|
||||||
// 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())
|
||||||
|
|||||||
@@ -4,11 +4,8 @@ using HellionChat.Util;
|
|||||||
|
|
||||||
namespace HellionChat.Resources;
|
namespace HellionChat.Resources;
|
||||||
|
|
||||||
// Hellion Chat — v0.6.0 built-in colour presets for the ChatColours
|
// Built-in colour presets applied via Settings UI → ChatColours.
|
||||||
// settings section. Read-only static data; users apply a preset via the
|
// Battle-channel types are intentionally excluded to preserve combat-log tuning.
|
||||||
// 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,
|
||||||
@@ -69,9 +66,7 @@ public static class ChatColourPresets
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// The Default preset spiegelt 1:1 die Werte aus ChatTypeExt.DefaultColor.
|
// Mirrors ChatTypeExt.DefaultColor; channels without a default are skipped.
|
||||||
// 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>();
|
||||||
@@ -183,33 +178,22 @@ public static class ChatColourPresets
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hellion brand preset — Arctic Cyan + Ember Orange palette aus
|
// Hellion brand preset — Arctic Cyan + Ember Orange palette.
|
||||||
// /mnt/ssd-fast/Projekte/hellion-media/hellion-media-website/BRANDING.md
|
// Cyan family for Standard/Tell, Ember/Warning for loud channels,
|
||||||
// (Schema-Stand 2026-04-16). Channels sind über das ganze Brand-Spektrum
|
// Status colours for Linkshells, darker variants for CrossLinkshells.
|
||||||
// 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
|
||||||
@@ -218,8 +202,6 @@ 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
|
||||||
@@ -231,31 +213,20 @@ public static class ChatColourPresets
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bonus preset — Night Blue, KAZAMA-Stimmungs-Theme aus
|
// Night Blue — cool nautical theme, deep navy without purple.
|
||||||
// /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),
|
||||||
@@ -264,8 +235,6 @@ 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),
|
||||||
@@ -277,30 +246,20 @@ public static class ChatColourPresets
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bonus preset — Indigo Violet, KAZAMA-Stimmungs-Theme aus demselben
|
// Indigo Violet — warm-mystic theme, deep indigo with violet accent.
|
||||||
// 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>
|
||||||
{
|
{
|
||||||
// Standard / Tell — Royal Violet Akzent-Familie
|
[ChatType.Say] = ColourUtil.ComponentsToRgba(240, 230, 255), // text-primary
|
||||||
[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),
|
||||||
@@ -309,8 +268,6 @@ 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,13 +4,9 @@ using HellionChat.Themes;
|
|||||||
|
|
||||||
namespace HellionChat.SelfTests;
|
namespace HellionChat.SelfTests;
|
||||||
|
|
||||||
// Validates the runtime theme-switch contract from the user side. The
|
// Validates the runtime theme-switch contract: polls ThemeRegistry.Active
|
||||||
// caller toggles the active theme via Settings -> Theme & Layout, the
|
// per frame until the slug moves away and back, then sanity-checks that
|
||||||
// step polls ThemeRegistry.Active per frame and only passes once the
|
// the ABGR cache was recomputed on switch.
|
||||||
// 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;
|
||||||
@@ -73,9 +69,8 @@ internal sealed class ThemeSwitchSelfTestStep : ISelfTestStep
|
|||||||
this.switchedAway = false;
|
this.switchedAway = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Any non-zero slot proves the cache was actually recomputed for the
|
// Any non-zero slot confirms the cache was recomputed — no reference
|
||||||
// current theme. We don't compare against a reference, because custom
|
// comparison since custom themes can share slot values with built-ins.
|
||||||
// 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: "Hellion Forge",
|
Author: "Zoe Moon",
|
||||||
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"),
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ using HellionChat.Code;
|
|||||||
|
|
||||||
namespace HellionChat.Themes;
|
namespace HellionChat.Themes;
|
||||||
|
|
||||||
// Optional pro Theme. Wenn ein Theme ChatColors mitliefert, kann der
|
// Optional per-theme chat colours applied to Configuration.ChatColours on user request.
|
||||||
// User sie per Klick im Themes-Tab auf Configuration.ChatColours anwenden.
|
// Themes without this leave channel colours untouched.
|
||||||
// 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,6 +1,6 @@
|
|||||||
namespace HellionChat.Themes;
|
namespace HellionChat.Themes;
|
||||||
|
|
||||||
// Color-Werte als 0xRRGGBBAA, RgbaToAbgr handled den Byte-Swap zu ImGui.
|
// Colour values as 0xRRGGBBAA — RgbaToAbgr handles the byte-swap for ImGui.
|
||||||
public sealed record ThemeColors(
|
public sealed record ThemeColors(
|
||||||
uint PrimaryDark,
|
uint PrimaryDark,
|
||||||
uint Primary,
|
uint Primary,
|
||||||
|
|||||||
@@ -66,10 +66,8 @@ 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 ist der ChatType-Name als String (z.B. "Say", "Tell"),
|
// Property name is the ChatType name (e.g. "Say", "Tell"), value is hex like theme colours.
|
||||||
// Value ist Hex wie bei den Theme-Colors. Unbekannte Channel-Names
|
// Unknown channel names are silently skipped for forward-compat with future SE channels.
|
||||||
// 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,6 +1,6 @@
|
|||||||
namespace HellionChat.Themes;
|
namespace HellionChat.Themes;
|
||||||
|
|
||||||
// Layout-Werte spiegeln die ImGuiStyleVar-Slots, die HellionStyle pusht.
|
// Layout values mirror the ImGuiStyleVar slots pushed by HellionStyle.
|
||||||
public sealed record ThemeLayout(
|
public sealed record ThemeLayout(
|
||||||
float WindowRounding,
|
float WindowRounding,
|
||||||
float ChildRounding,
|
float ChildRounding,
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ public sealed class ThemeRegistry
|
|||||||
{ SynthwaveSunset.Slug, SynthwaveSunset.Build() },
|
{ SynthwaveSunset.Slug, SynthwaveSunset.Build() },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Centralised so the ten .Build() factories stay free of cache plumbing.
|
// Centralised so Build() factories stay free of cache plumbing.
|
||||||
foreach (var theme in _builtIns.Values)
|
foreach (var theme in _builtIns.Values)
|
||||||
theme.RecomputeAbgrCache();
|
theme.RecomputeAbgrCache();
|
||||||
|
|
||||||
@@ -58,14 +58,13 @@ public sealed class ThemeRegistry
|
|||||||
public void Switch(string slug)
|
public void Switch(string slug)
|
||||||
{
|
{
|
||||||
var theme = Get(slug);
|
var theme = Get(slug);
|
||||||
// Defensive — idempotent and cheap, so any future theme source
|
// Defensive — ensures any future theme source always gets a populated cache.
|
||||||
// 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. Other
|
// 0x80070020 = SHARING_VIOLATION, 0x80070021 = LOCK_VIOLATION.
|
||||||
// IO failures are permanent and get the theme dropped instead of retried.
|
// Other IO failures are permanent — theme is 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)
|
||||||
@@ -74,9 +73,8 @@ public sealed class ThemeRegistry
|
|||||||
return code == 0x80070020u || code == 0x80070021u;
|
return code == 0x80070020u || code == 0x80070021u;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom-Themes werden lazy aus dem Verzeichnis geladen, Cache mit
|
// Custom themes are loaded lazily, cached by LastWriteTime.
|
||||||
// LastWriteTime-Token. Eine geänderte JSON wird beim nächsten Lookup
|
// A changed JSON is reloaded on the next lookup.
|
||||||
// neu eingelesen.
|
|
||||||
private Theme? LoadCustomBySlug(string slug)
|
private Theme? LoadCustomBySlug(string slug)
|
||||||
{
|
{
|
||||||
if (_customThemesDir is null)
|
if (_customThemesDir is null)
|
||||||
@@ -115,8 +113,7 @@ public sealed class ThemeRegistry
|
|||||||
}
|
}
|
||||||
catch (Exception ex) when (IsRecoverableFileLock(ex))
|
catch (Exception ex) when (IsRecoverableFileLock(ex))
|
||||||
{
|
{
|
||||||
// Editor mid-save: keep the cached snapshot, leave the stamp
|
// Editor mid-save: keep last known good, retry on next refresh.
|
||||||
// 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"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
namespace HellionChat.Themes;
|
namespace HellionChat.Themes;
|
||||||
|
|
||||||
// Optional pro Theme. v1.1.0 nutzt das nicht aktiv; ist als Erweiterungspunkt
|
// Optional per-theme; reserved as an extension point for future theme slots.
|
||||||
// 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
|
||||||
|
|||||||
Reference in New Issue
Block a user