Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3152312890 | |||
| 4000bbd199 |
@@ -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