Reported by Carla: a tell arriving while you are typing pulls the focus away. The interruption is the visible half. The sharp half is that the input buffer belongs to the WINDOW while the send target is read off whatever tab is active at Enter -- so a line typed at one person could leave addressed to whoever just wrote, and in this game losing the keyboard means the next sentence walks the character around. Nothing is revealed now while any chat surface is mid-sentence, in any mode. The tab still appears and still carries its unread mark. The check lives in its own file because the answer has to be identical everywhere: it started inside the reveal plan, and a second pop-out path walked straight past it -- AutoTellTabsService opened windows off its own flag, at tab creation, a tick before the router was ever asked. Those two paths are one now. AutoTellTabsOpenAsPopout and TellAutoOpenMode were two settings for one decision, and the older one won every race, which is why the other looked inert. Config schema 28 carries the old flag forward so nobody's behaviour changes. "Off" went with it: it never stopped the tab from being created -- that is the auto-tell switch -- it only stopped the jump to it, which is what the switch below it does. Also in here, all from the same corner of the code: - Closing a tab was lost in the v2.0.0 rebuild. The trash entry lived in the retired ChatLogWindow menu, and the rebuilt one restored rename, sound, pop-out and pinning but not this. For tell tabs that left no way out at all: IsEditable keeps them out of the settings editor on purpose and points at the context menu, which could not close them either. Pinned tell tabs stay disabled with a tooltip rather than absent. - Re-anchoring the active tab used an unconditional Tabs[0] in three places, and Tabs[0] can be popped out -- so it ran OnTabActivated over a tab live in its own window and stripped its tell binding. With every tab popped, the seed and the re-anchor also fought each other every frame. - PinTab_LimitReached still pointed at "Promote to permanent", removed in May. Spanish said "Desija", which is not a word; Greek left "tell tabs" untranslated; pt-PT broke its own unpin verb. - Pop Out was a hardcoded English literal despite the key existing in all 25 languages since v1.5.6, and the tell-open modes were the last English display names in the plugin. - Segmented setting rows measured 200px flat, which cut German labels in half. They size to their longest label now. - Metrics.Scale still called GlobalScaleSafe. It is an alias for GlobalScale in current Dalamud, and dropping it clears the last compiler warning in the project.
1551 lines
66 KiB
C#
Executable File
1551 lines
66 KiB
C#
Executable File
using System.Diagnostics;
|
|
using System.Diagnostics.CodeAnalysis;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Runtime.ExceptionServices;
|
|
using Dalamud.Bindings.ImGui;
|
|
using Dalamud.Game.ClientState.Conditions;
|
|
using Dalamud.Interface.ImGuiFileDialog;
|
|
using Dalamud.Interface.Windowing;
|
|
using Dalamud.IoC;
|
|
using Dalamud.Plugin;
|
|
using Dalamud.Plugin.Services;
|
|
using HellionChat.Ipc;
|
|
using HellionChat.Resources;
|
|
using HellionChat.Ui;
|
|
using HellionChat.Util;
|
|
using Microsoft.Data.Sqlite;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Hosting;
|
|
|
|
namespace HellionChat;
|
|
|
|
// ReSharper disable once ClassNeverInstantiated.Global
|
|
public sealed class Plugin : IAsyncDalamudPlugin
|
|
{
|
|
public const string PluginName = "Hellion Chat";
|
|
|
|
[PluginService]
|
|
public static IPluginLog Log { get; private set; } = null!;
|
|
|
|
[PluginService]
|
|
public static IDalamudPluginInterface Interface { get; private set; } = null!;
|
|
|
|
[PluginService]
|
|
public static IChatGui ChatGui { get; private set; } = null!;
|
|
|
|
[PluginService]
|
|
public static IClientState ClientState { get; private set; } = null!;
|
|
|
|
[PluginService]
|
|
public static ICommandManager CommandManager { get; private set; } = null!;
|
|
|
|
[PluginService]
|
|
public static ICondition Condition { get; private set; } = null!;
|
|
|
|
[PluginService]
|
|
public static IDataManager DataManager { get; private set; } = null!;
|
|
|
|
[PluginService]
|
|
public static IFramework Framework { get; private set; } = null!;
|
|
|
|
[PluginService]
|
|
public static IGameGui GameGui { get; private set; } = null!;
|
|
|
|
[PluginService]
|
|
public static IKeyState KeyState { get; private set; } = null!;
|
|
|
|
[PluginService]
|
|
public static IObjectTable ObjectTable { get; private set; } = null!;
|
|
|
|
[PluginService]
|
|
public static IPartyList PartyList { get; private set; } = null!;
|
|
|
|
[PluginService]
|
|
public static ITargetManager TargetManager { get; private set; } = null!;
|
|
|
|
[PluginService]
|
|
public static ITextureProvider TextureProvider { get; private set; } = null!;
|
|
|
|
[PluginService]
|
|
public static IGameInteropProvider GameInteropProvider { get; private set; } = null!;
|
|
|
|
[PluginService]
|
|
public static IGameConfig GameConfig { get; private set; } = null!;
|
|
|
|
[PluginService]
|
|
public static INotificationManager Notification { get; private set; } = null!;
|
|
|
|
[PluginService]
|
|
public static IAddonLifecycle AddonLifecycle { get; private set; } = null!;
|
|
|
|
[PluginService]
|
|
public static IPlayerState PlayerState { get; private set; } = null!;
|
|
|
|
[PluginService]
|
|
public static ISeStringEvaluator Evaluator { get; private set; } = null!;
|
|
|
|
[PluginService]
|
|
public static ISelfTestRegistry SelfTestRegistry { get; private set; } = null!;
|
|
|
|
public static Configuration Config = null!;
|
|
public static FileDialogManager FileDialogManager { get; private set; } = null!;
|
|
|
|
// Single static handle to the live Plugin instance. Lets statically-accessed
|
|
// UI helpers (TabContextMenu) reach instance-only members — SaveConfig(),
|
|
// AutoTellTabsService, CustomAudioPlayer — without ctor-injection. A per-member
|
|
// static accessor is impossible: it would collide by name with the instance
|
|
// property (CS0102). Filled in the post-resolve bridge block below.
|
|
internal static Plugin Instance = null!;
|
|
|
|
public readonly WindowSystem WindowSystem = new(PluginName);
|
|
|
|
// Phase-2 services are constructed in LoadAsync; null! shape is kept
|
|
// consistent across all properties for clarity.
|
|
internal Ui.Windows.MainWindow MainWindow { get; private set; } = null!;
|
|
internal Ui.Windows.SettingsWindow SettingsWindow { get; private set; } = null!;
|
|
internal Ui.Windows.ChannelPopoutPool ChannelPopoutPool { get; private set; } = null!;
|
|
public DbViewer DbViewer { get; private set; } = null!;
|
|
internal static InputPreview InputPreview { get; private set; } = null!;
|
|
internal CommandHelpWindow CommandHelpWindow { get; private set; } = null!;
|
|
public SeStringDebugger SeStringDebugger { get; private set; } = null!;
|
|
#if DEBUG
|
|
internal Ui.Windows.WidgetGalleryWindow WidgetGallery { get; private set; } = null!;
|
|
#endif
|
|
internal Ui.Windows.InputBarLabWindow InputBarLab { get; private set; } = null!;
|
|
public FirstRunWizard FirstRunWizard { get; private set; } = null!;
|
|
internal DebuggerWindow DebuggerWindow { get; private set; } = null!;
|
|
|
|
internal Commands Commands { get; private set; } = null!;
|
|
internal GameFunctions.GameFunctions Functions { get; private set; } = null!;
|
|
internal MessageManager MessageManager { get; private set; } = null!;
|
|
|
|
// Reached by the gate-wiring self-test, which has to ask the live tab
|
|
// whether it sees a held gate.
|
|
internal Ui.Components.Settings.Tabs.DataPrivacyTab DataPrivacyTab { get; private set; } =
|
|
null!;
|
|
internal AutoTellTabsService AutoTellTabsService { get; private set; } = null!;
|
|
internal IpcManager Ipc { get; private set; } = null!;
|
|
internal ExtraChat ExtraChat { get; private set; } = null!;
|
|
internal TypingIpc TypingIpc { get; private set; } = null!;
|
|
internal Ui.Components.InputBar InputBar { get; private set; } = null!;
|
|
internal FontManager FontManager { get; private set; } = null!;
|
|
internal Themes.ThemeRegistry ThemeRegistry { get; private set; } = null!;
|
|
internal Integrations.HonorificService HonorificService { get; private set; } = null!;
|
|
internal Integrations.CustomAudioPlayer CustomAudioPlayer { get; private set; } = null!;
|
|
|
|
// Ctor-smoke anchors. Exposed so the Payload/Chunk ctor-smoke steps
|
|
// can drive the real per-frame Lender path (Borrow()) and the eager
|
|
// singletons through the container, never via new(). Mirror of the
|
|
// FontManager property pattern — every SelfTest reaches services this way.
|
|
internal PayloadHandler PayloadHandler { get; private set; } = null!;
|
|
internal Util.Lender<PayloadHandler> PayloadHandlerLender { get; private set; } = null!;
|
|
internal Ui.Components.ChunkRenderer ChunkRenderer { get; private set; } = null!;
|
|
|
|
// Platform indirection over Dalamud.Utility.Util. Wired in Phase-1 ctor so
|
|
// any service allocated in LoadAsync can read Plugin.PlatformUtil.
|
|
internal static IPlatformUtil PlatformUtil { get; private set; } = null!;
|
|
|
|
// Log indirection over Dalamud's IPluginLog. Same rationale as PlatformUtil:
|
|
// call-sites read through LogProxy so MessageStore can be tested in
|
|
// isolation. Wired immediately after Dalamud injects Log.
|
|
internal static IPluginLogProxy LogProxy { get; private set; } = null!;
|
|
|
|
// Nullable so DisposeAsync stays safe if Host-build throws before the
|
|
// fields get assigned — Dalamud fires DisposeAsync regardless.
|
|
private readonly IHost? _host;
|
|
private readonly PluginLifecycle? _lifecycle;
|
|
|
|
// Wrapper cached so TearDown can detach the live instance instead of
|
|
// re-registering with identical args (v1.4.9 ISSUE-1 cleanup).
|
|
private CommandWrapper? _hellionSettingsCmd;
|
|
private CommandWrapper? _clearHellionCmd;
|
|
private CommandWrapper? _hellionViewCmd;
|
|
private CommandWrapper? _hellionDebuggerCmd;
|
|
#if DEBUG
|
|
private CommandWrapper? _hellionSeStringCmd;
|
|
#endif
|
|
|
|
// Idempotency guard — Dalamud may fire DisposeAsync twice in a reload race.
|
|
private int _disposeStarted;
|
|
|
|
// The three hide conditions v1.5.6 evaluated and cf4705e left without a
|
|
// reader. Advanced once per draw, before any window is drawn.
|
|
private Util.ChatHideReason _hideReason = Util.ChatHideReason.None;
|
|
|
|
// Set by the chat-activation keybind, consumed by the next hide evaluation.
|
|
// A cutscene the user dismissed stays dismissed until it ends.
|
|
internal bool ChatActivationRequested;
|
|
|
|
// Set in the first DisposeAsync statement so async callbacks scheduled
|
|
// via Framework.RunOnTick (v1.4.8 retention sweep) can early-bail
|
|
// before they touch state that has already been torn down. Volatile
|
|
// because the tick reads it from a different thread than the writer.
|
|
private volatile bool _isDisposing;
|
|
|
|
// Read by background workers that outlive a teardown -- the export thread
|
|
// finishes its file either way, but a notification for a plugin the user
|
|
// just unloaded belongs to nobody.
|
|
internal bool IsDisposing => _isDisposing;
|
|
|
|
// v1.9.0: last full Draw() wall-time in ms, written once per frame at
|
|
// the end of the UiBuilder.Draw handler. Covers the GlobalStyleScope push
|
|
// and the font push (the first-frame hitch measurement must include atlas/style
|
|
// prologue cost), not just WindowSystem.Draw — measuring the inner call
|
|
// alone would drop the prologue and make the figure non-comparable to the
|
|
// v1.5.6 baseline. Only accumulated here; the disk write happens in
|
|
// PerformanceBaselineStep so the hot path stays allocation-free.
|
|
internal double LastDrawMs;
|
|
|
|
// Cancels the v1.4.8 FTS5 bulk-insert worker on plugin teardown. The
|
|
// worker runs off the framework thread on its own SqliteConnection, so a
|
|
// Dispose mid-rebuild must signal cancellation before MessageManager
|
|
// tears down (the worker logs "rebuild failed" via Log on error paths).
|
|
private CancellationTokenSource? _ftsRebuildCts;
|
|
|
|
// Serialises every long-running database operation against every other one,
|
|
// not just retention sweeps against each other. An export leaves a reader
|
|
// open on the primary connection outside _readLock by design -- the
|
|
// enumerator is consumed lazily -- and a VACUUM meeting that reader hits a
|
|
// connection Microsoft documents as not thread-safe.
|
|
//
|
|
// Replaces the retention-only pair, which solved the same problem for one
|
|
// case. The draw thread reads Current every frame to disable buttons and
|
|
// must never block doing so.
|
|
internal readonly Util.DbOperationGate DbOperations = new();
|
|
|
|
// Neutral owner of the Config.Tabs LIST-structure lock so both the
|
|
// worker-thread mutator (AutoTellTabsService) and the framework-thread
|
|
// refilter (MessageManager) share ONE monitor. Lock order: this outer,
|
|
// MessageList's SemaphoreSlim inner — never the reverse.
|
|
internal readonly object TabsListLock = new();
|
|
|
|
// Guards the serialized config maps that the draw thread mutates while a
|
|
// background save may be serializing them: ChatColours, PrivacyPersistChannels
|
|
// and RetentionPerChannelDays. TabsListLock does not cover these.
|
|
// Ordering: ConfigMapsLock sits INSIDE TabsListLock (that edge is real, via
|
|
// AutoTellTabsService calling SaveConfig under the tabs lock). Never the other
|
|
// way round -- so SaveConfig must never be called while holding ConfigMapsLock.
|
|
internal readonly object ConfigMapsLock = new();
|
|
|
|
internal DateTime GameStarted { get; }
|
|
|
|
// Couples "current tab" to the real UI selection. The chat hooks are
|
|
// installed before MainWindow is Phase-1 resolved, so the null-conditional
|
|
// fallback to Tabs[0] is load-bearing — it keeps the pre-coupling behavior
|
|
// in that early window rather than being merely defensive.
|
|
// Read once into a local: Count and [0] as two separate accesses can be split
|
|
// by a removal on another thread. Only reachable before MainWindow exists.
|
|
internal Tab CurrentTab
|
|
{
|
|
get
|
|
{
|
|
if (MainWindow?.ActiveTab is { } active)
|
|
return active;
|
|
|
|
lock (TabsListLock)
|
|
{
|
|
var tabs = Config.Tabs;
|
|
return tabs.Count > 0 ? tabs[0] : new Tab();
|
|
}
|
|
}
|
|
}
|
|
|
|
public Plugin()
|
|
{
|
|
// Phase-1 ctor: bootstrap-essentials only (conflict gate, config load,
|
|
// language + ImGui init). All service/window allocation lives in LoadAsync.
|
|
|
|
// Block load if upstream Chat 2 is active — prevents IPC collisions
|
|
// and double-replacement of the in-game chat window.
|
|
ChatTwoConflictDetector.ThrowIfChatTwoIsLoaded(Interface);
|
|
|
|
GameStarted = Process.GetCurrentProcess().StartTime.ToUniversalTime();
|
|
|
|
// Migrate config + database from upstream ChatTwo on first start.
|
|
MigrateFromChatTwoLayout();
|
|
|
|
Config = Interface.GetPluginConfig() as Configuration ?? Configuration.CreateFresh();
|
|
|
|
// PlatformUtil and LogProxy are filled from the DI container in
|
|
// Phase-1 below (`_host.Services.GetRequiredService<IPlatformUtil>()`
|
|
// and the LogProxy equivalent). Phase-0 helpers that run before that
|
|
// point (MigrateFromChatTwoLayout, LanguageChanged, ImGuiUtil.Initialize)
|
|
// do not touch either static, so the brief null-window is safe.
|
|
|
|
// Schema gate: v1.4.x+ requires config v16+. Users on older schemas
|
|
// must install v1.4.2 first to run the migration chain. v19 added the
|
|
// top-level CustomSoundVolume, WindowOpacityInactive, WorldSuffixMode
|
|
// and NameFormMode fields; v20 adds MainWindowOpen, SettingsWindowOpen,
|
|
// MaxParallelPopouts, TellAutoOpenMode and SidebarAutoSwitchThresholdPx
|
|
// — all additive with defaults, so v16-v19 configs load cleanly and
|
|
// get their Version stamp bumped after the gate.
|
|
if (Config.Version < 16)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"HellionChat v1.4.10 requires config schema v16, got v{Config.Version}. "
|
|
+ "Please install v1.4.2 first to migrate the configuration, then upgrade to v1.4.10."
|
|
);
|
|
}
|
|
// 2.0.0 does not migrate, it starts over. Five cycles rebuilt the whole
|
|
// window layer, and a config carried through them keeps values chosen
|
|
// against surfaces that no longer exist -- an opacity picked for a
|
|
// window that has been redrawn twice since, tabs laid out for a sidebar
|
|
// that works differently now. Every user of this build is a tester who
|
|
// was told this happens, and it is the only way to be sure everyone
|
|
// sees the same defaults.
|
|
//
|
|
// The file is copied aside first. Rolling back to 1.5.6 is a supported
|
|
// move here and it stays cheap: the old settings are a file copy away,
|
|
// rather than an evening of clicking them back in.
|
|
if (Config.Version < 27)
|
|
{
|
|
BackUpConfigBeforeReset();
|
|
|
|
Config = Configuration.CreateFresh();
|
|
Config.Version = 27;
|
|
|
|
// Saved immediately: a crash between here and the first user-driven
|
|
// save would otherwise run the whole reset again on the next start,
|
|
// and the second run would back up the already-reset file over the
|
|
// real backup.
|
|
SaveConfig();
|
|
|
|
Log.Information(
|
|
"Config reset to defaults for 2.0.0. Previous settings kept as "
|
|
+ "HellionChat.json.pre-2.0.0.bak next to the config file."
|
|
);
|
|
}
|
|
else
|
|
{
|
|
// v23 migration: SidebarTabView was the 1.5.6 sidebar↔top-tabs switch,
|
|
// superseded by MainWindowLayoutMode in the v1.6.0 rewrite. A user who
|
|
// set it false (only effective in 1.5.6) wanted top tabs — carry that
|
|
// intent forward. Runs only for pre-v23 configs; fresh configs load at
|
|
// LatestVersion and skip it. Additive v20/v22 fields keep their
|
|
// initializer defaults as before.
|
|
if (Config.Version < 23 && !Config.SidebarTabView)
|
|
{
|
|
Config.MainWindowLayoutMode = MainWindowLayoutMode.TopTabs;
|
|
}
|
|
|
|
// v24 migration: the privacy filter used to route a known but unticked
|
|
// channel through the unknown-type failsafe, so the channel grid was
|
|
// inert whenever that failsafe was on. Corrected in v1.12.0. A config
|
|
// that never picked a channel was storing everything through that hole,
|
|
// and the corrected rule would store nothing at all -- so the intent is
|
|
// carried forward as a filter that is honestly switched off.
|
|
if (
|
|
Config.Version < 24
|
|
&& Privacy.StorageRule.ShouldDisableFilterOnV24(
|
|
Config.PrivacyFilterEnabled,
|
|
Config.PrivacyPersistUnknownChannels,
|
|
Config.PrivacyPersistChannels.Count
|
|
)
|
|
)
|
|
{
|
|
Config.PrivacyFilterEnabled = false;
|
|
// Log, not LogProxy: this runs in Phase-0 and the proxy is only
|
|
// resolved from the container further down.
|
|
Log.Information(
|
|
"Privacy filter switched off during the v24 migration: it was on with no channels "
|
|
+ "picked, which stored everything through the unknown-channel failsafe. Pick "
|
|
+ "channels in Settings to switch it back on."
|
|
);
|
|
}
|
|
|
|
// v28 migration: two settings could open a tell in its own window,
|
|
// and AutoTellTabsOpenAsPopout won every race because it fired at tab
|
|
// creation while TellAutoOpenMode only got asked a tick later -- by
|
|
// which point the window was already open and the mode read back as
|
|
// "nothing to do". Anyone with the old flag on was, in practice, on
|
|
// Popout, so that is what carries forward. The flag itself is left
|
|
// alone: it is the only record of what was chosen, and clearing it
|
|
// would make a re-run of this step silently change a later decision.
|
|
if (Config.Version < 28)
|
|
{
|
|
if (Config.AutoTellTabsOpenAsPopout)
|
|
{
|
|
Config.TellAutoOpenMode = TellAutoOpenMode.Popout;
|
|
}
|
|
else if (Config.TellAutoOpenMode == TellAutoOpenMode.Off)
|
|
{
|
|
// "Off" never stopped the tab from being created -- that is the
|
|
// auto-tell switch further up -- it only stopped the jump to it,
|
|
// which is exactly what TellAutoOpenSwitchAlways is for. Two
|
|
// controls for one decision, and the one that read like "no tell
|
|
// tabs at all" was the misleading one. Same behaviour, said once.
|
|
Config.TellAutoOpenMode = TellAutoOpenMode.Sidebar;
|
|
Config.TellAutoOpenSwitchAlways = false;
|
|
}
|
|
}
|
|
|
|
// v25 carried no migration step; the bump was documentation.
|
|
//
|
|
// v26 does. NameCameFromPartner is what screenshot mode reads to decide
|
|
// whether a tab name is a person, and a config written before it existed
|
|
// has it false on every tab -- including pinned tell tabs, which survive
|
|
// reloads and are named "Player@World". Anything still carrying a tell
|
|
// binding or the temp flag got its name from a partner, so the flag is
|
|
// set from those two.
|
|
//
|
|
// Tabs promoted before this version are past saving: promotion clears
|
|
// both markers and keeps the name, so nothing in the stored data says
|
|
// where that name came from. Renaming one clears the flag anyway, which
|
|
// is the same outcome the user gets by editing it.
|
|
if (Config.Version < 26)
|
|
{
|
|
var carried = 0;
|
|
foreach (var tab in Config.Tabs)
|
|
{
|
|
if (
|
|
tab.NameCameFromPartner
|
|
|| (!tab.IsTempTab && tab.TellTarget?.IsSet() != true)
|
|
)
|
|
continue;
|
|
|
|
tab.NameCameFromPartner = true;
|
|
carried++;
|
|
}
|
|
|
|
if (carried > 0)
|
|
{
|
|
Log.Information(
|
|
$"Marked {carried} tab(s) as partner-named during the v26 migration, so "
|
|
+ "screenshot mode hides them in the channel header."
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Config.Version = Configuration.LatestVersion;
|
|
|
|
// Unpinned TempTabs are session-only and dropped on every load. Pinned
|
|
// TempTabs survive reload -- tester feedback in v1.4.7.
|
|
Config.Tabs.RemoveAll(TabLifecycleHelpers.ShouldStripOnLoad);
|
|
|
|
// Clear stale Tab.PopOut flags now — the pool binds further down
|
|
// (ChannelPopoutPool resolve below), so at this point no tab can own a
|
|
// slot. A persisted PopOut=true (notably on surviving pinned TempTabs)
|
|
// would otherwise be a flag with no window. Runs after the strip, before
|
|
// any pool TryOpen.
|
|
TabLifecycleHelpers.ResetPopOutOnLoad(Config.Tabs);
|
|
|
|
LanguageChanged(Interface.UiLanguage);
|
|
|
|
// v1.5.3 migration: Settings.Apply auto-activates the matching
|
|
// ExtraGlyphRanges flag on a language CHANGE; a config that already
|
|
// has e.g. Czech selected from a previous version never goes through
|
|
// that path. ORing in the required flag here lets the first atlas
|
|
// build pick it up, so an upgrade from v1.5.2 renders correctly
|
|
// without forcing the user to toggle the language twice.
|
|
var requiredRanges =
|
|
Config.LanguageOverride is LanguageOverride.None
|
|
? LanguageOverrideExt.RequiredGlyphRangesForCulture(Interface.UiLanguage)
|
|
: Config.LanguageOverride.RequiredGlyphRanges();
|
|
if (requiredRanges != 0 && !Config.ExtraGlyphRanges.HasFlag(requiredRanges))
|
|
Config.ExtraGlyphRanges |= requiredRanges;
|
|
|
|
ImGuiUtil.Initialize(this);
|
|
|
|
// Custom themes dir + seed run before the container builds so the
|
|
// ThemeRegistry factory lambda finds the directory ready.
|
|
var customThemesDir = Path.Combine(Interface.ConfigDirectory.FullName, "themes");
|
|
Directory.CreateDirectory(customThemesDir);
|
|
SeedExampleThemeIfEmpty(customThemesDir);
|
|
|
|
// Phase-1: build the host synchronously (the schema gate must clear
|
|
// before services allocate; Lightless' deferred build would invert
|
|
// that order) and pull singletons into the Plugin.X surface.
|
|
var dependencies = new PluginHostDependencies(
|
|
Interface,
|
|
Log,
|
|
ChatGui,
|
|
ClientState,
|
|
CommandManager,
|
|
Condition,
|
|
DataManager,
|
|
Framework,
|
|
GameGui,
|
|
KeyState,
|
|
ObjectTable,
|
|
PartyList,
|
|
TargetManager,
|
|
TextureProvider,
|
|
GameInteropProvider,
|
|
GameConfig,
|
|
Notification,
|
|
AddonLifecycle,
|
|
PlayerState,
|
|
Evaluator,
|
|
SelfTestRegistry
|
|
);
|
|
|
|
_host = PluginHostFactory.Build(this, dependencies);
|
|
|
|
// Bridge the static handle before the instance members below are read.
|
|
Instance = this;
|
|
|
|
_lifecycle = _host.Services.GetRequiredService<PluginLifecycle>();
|
|
_lifecycle.Host = _host;
|
|
|
|
// Plugin.X static bridge - filled from the container so DI-aware code
|
|
// and the ~93 Plugin.X consumer sites read the same instances.
|
|
PlatformUtil = _host.Services.GetRequiredService<IPlatformUtil>();
|
|
LogProxy = _host.Services.GetRequiredService<IPluginLogProxy>();
|
|
FileDialogManager = _host.Services.GetRequiredService<FileDialogManager>();
|
|
|
|
// Resolve order matters: block-B services first so the windows can
|
|
// read Plugin.MessageManager etc. from their own ctors without NREs.
|
|
FontManager = _host.Services.GetRequiredService<FontManager>();
|
|
ThemeRegistry = _host.Services.GetRequiredService<Themes.ThemeRegistry>();
|
|
Commands = _host.Services.GetRequiredService<Commands>();
|
|
Functions = _host.Services.GetRequiredService<GameFunctions.GameFunctions>();
|
|
Ipc = _host.Services.GetRequiredService<IpcManager>();
|
|
TypingIpc = _host.Services.GetRequiredService<TypingIpc>();
|
|
ExtraChat = _host.Services.GetRequiredService<ExtraChat>();
|
|
HonorificService = _host.Services.GetRequiredService<Integrations.HonorificService>();
|
|
CustomAudioPlayer = _host.Services.GetRequiredService<Integrations.CustomAudioPlayer>();
|
|
MessageManager = _host.Services.GetRequiredService<MessageManager>();
|
|
AutoTellTabsService = _host.Services.GetRequiredService<AutoTellTabsService>();
|
|
|
|
InputBar = _host.Services.GetRequiredService<Ui.Components.InputBar>();
|
|
MainWindow = _host.Services.GetRequiredService<Ui.Windows.MainWindow>();
|
|
SettingsWindow = _host.Services.GetRequiredService<Ui.Windows.SettingsWindow>();
|
|
DataPrivacyTab =
|
|
_host.Services.GetRequiredService<Ui.Components.Settings.Tabs.DataPrivacyTab>();
|
|
DbViewer = _host.Services.GetRequiredService<DbViewer>();
|
|
InputPreview = _host.Services.GetRequiredService<InputPreview>();
|
|
CommandHelpWindow = _host.Services.GetRequiredService<CommandHelpWindow>();
|
|
SeStringDebugger = _host.Services.GetRequiredService<SeStringDebugger>();
|
|
#if DEBUG
|
|
WidgetGallery = _host.Services.GetRequiredService<Ui.Windows.WidgetGalleryWindow>();
|
|
#endif
|
|
InputBarLab = _host.Services.GetRequiredService<Ui.Windows.InputBarLabWindow>();
|
|
DebuggerWindow = _host.Services.GetRequiredService<DebuggerWindow>();
|
|
FirstRunWizard = _host.Services.GetRequiredService<FirstRunWizard>();
|
|
ChannelPopoutPool = _host.Services.GetRequiredService<Ui.Windows.ChannelPopoutPool>();
|
|
|
|
// Ctor-smoke anchors. Resolved last, against the fully built
|
|
// container: every MakePayloadHandler dep (MainWindow, InputBar,
|
|
// ChunkRenderer, ...) is resolvable here, and the ChunkRenderer resolve
|
|
// below just reuses the same cached singleton. These are plain
|
|
// post-build container resolves (no new factory-lambda edge) — they add
|
|
// no DI cycle. See feedback_di_factory_callsite_cycles.
|
|
PayloadHandler = _host.Services.GetRequiredService<PayloadHandler>();
|
|
PayloadHandlerLender = _host.Services.GetRequiredService<Util.Lender<PayloadHandler>>();
|
|
ChunkRenderer = _host.Services.GetRequiredService<Ui.Components.ChunkRenderer>();
|
|
}
|
|
|
|
public async Task LoadAsync(CancellationToken cancellationToken)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
try
|
|
{
|
|
// Default tab layout on fresh install. Tells are handled by
|
|
// Auto-Tell-Tabs; Novice Network has no preset tab by design.
|
|
if (Config.Tabs.Count == 0)
|
|
{
|
|
Config.Tabs.Add(TabsUtil.VanillaGeneral);
|
|
Config.Tabs.Add(TabsUtil.HellionSystem);
|
|
Config.Tabs.Add(TabsUtil.HellionEmote);
|
|
Config.Tabs.Add(TabsUtil.HellionFreeCompany);
|
|
Config.Tabs.Add(TabsUtil.HellionParty);
|
|
Config.Tabs.Add(TabsUtil.HellionLinkshell);
|
|
}
|
|
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
// Container drives service init now: Host.StartAsync triggers the
|
|
// remaining IHostedService adapters (ThemeRegistry cache warmup +
|
|
// Switch, IPC eager-resolve, MessageManager FilterAllTabsAsync,
|
|
// AutoTellTabsService.Initialize). FontManager runs its own init
|
|
// inline inside the ctor's SuppressAutoRebuild block on eager
|
|
// resolve. Window registration with WindowSystem runs on the
|
|
// framework thread inside PluginLifecycle.LoadAsync after
|
|
// StartAsync returns.
|
|
if (_lifecycle is not null)
|
|
await _lifecycle.LoadAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
SelfTestRegistry.RegisterTestSteps([
|
|
new SelfTests.ExportRoundTripStep(),
|
|
new SelfTests.ThemeSwitchSelfTestStep(this),
|
|
new SelfTests.ThemeCrossfadeSelfTestStep(this),
|
|
new SelfTests.FontManagerCtorSmokeStep(this),
|
|
new SelfTests.PayloadHandlerCtorSmokeStep(this),
|
|
new SelfTests.ChunkRendererCtorSmokeStep(this),
|
|
new SelfTests.FontPushSmokeStep(this),
|
|
new SelfTests.WizardStateSmokeStep(this),
|
|
new SelfTests.FoxBannerTextureSmokeStep(this),
|
|
new SelfTests.SidebarModeAutoSwitchStep(this),
|
|
new SelfTests.ColorEditorBufferStep(this),
|
|
new SelfTests.ThemePickerCategoryStep(this),
|
|
new SelfTests.QuickPickerSelfTestStep(this),
|
|
new SelfTests.HideRestoreSelfTestStep(this),
|
|
new SelfTests.SettingsWindowOpenStep(this),
|
|
new SelfTests.OnOpenMainUiRoutesMainWindowStep(this),
|
|
new SelfTests.TypingIpcStateStep(this),
|
|
new SelfTests.ConfigMigrationV27Step(this),
|
|
new SelfTests.DbGateWiringStep(this),
|
|
new SelfTests.ChannelPopoutBindStep(this),
|
|
new SelfTests.HoverStateFootprintStep(),
|
|
new SelfTests.HonorificHeaderRenderStep(this),
|
|
new SelfTests.AboutIntegrationsStatusStep(this),
|
|
new SelfTests.TypeScaleStep(this),
|
|
new SelfTests.PerformanceBaselineStep(this),
|
|
new SelfTests.GlobalStyleScopeAllocStep(this),
|
|
new SelfTests.MainWindowFocusOpacityStep(this),
|
|
new SelfTests.MainWindowFlagsStep(this),
|
|
new SelfTests.SenderNameReformatStep(this),
|
|
new SelfTests.DisclosureArmStep(this),
|
|
new SelfTests.TellRoutingBuildStep(this),
|
|
new SelfTests.TellPillTransparencyStep(this),
|
|
new SelfTests.TabRenamePersistStep(this),
|
|
new SelfTests.NotificationSoundSelectStep(),
|
|
new SelfTests.SidebarGreetedGlyphStep(this),
|
|
new SelfTests.SidebarSectionHeaderStep(this),
|
|
new SelfTests.ScrollSnapDecisionStep(this),
|
|
new SelfTests.TellResetOnActivateStep(),
|
|
new SelfTests.CurrentTabCouplingStep(this),
|
|
new SelfTests.SidebarUnreadDotStep(this),
|
|
new SelfTests.SidebarActiveSurfaceStep(this),
|
|
new SelfTests.TopTabUnderlineStep(this),
|
|
new SelfTests.UnreadDecisionStep(),
|
|
new SelfTests.CurrentTabGuidedStep(this),
|
|
new SelfTests.CardClipPlanStep(this),
|
|
]);
|
|
|
|
// Re-surface the wizard for existing users when a major UX
|
|
// rework ships. The constant tracks the most recent version
|
|
// whose wizard should be shown once; bump it in future cycles
|
|
// that reshape the onboarding flow. Saved immediately so a
|
|
// pre-Finish crash doesn't loop the prompt forever.
|
|
const string WizardReshowVersion = "1.5.2";
|
|
if (Config.WizardLastShownVersion != WizardReshowVersion)
|
|
{
|
|
Config.FirstRunCompleted = false;
|
|
Config.WizardLastShownVersion = WizardReshowVersion;
|
|
SaveConfig();
|
|
}
|
|
|
|
if (!Config.FirstRunCompleted)
|
|
FirstRunWizard.IsOpen = true;
|
|
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
// Populate the command dictionary + UiBuilder hooks BEFORE
|
|
// Commands.Initialise() walks the dictionary and registers each
|
|
// entry with Dalamud's CommandManager (Commands.cs:15-28). Adding
|
|
// wrappers after Initialise() would leak them — they'd live in
|
|
// the dictionary but never reach Dalamud.
|
|
SetupCommands();
|
|
Commands.Initialise();
|
|
|
|
// Daily retention sweep — fire-and-forget, skips when disabled
|
|
// or already ran within the past 24 hours.
|
|
RunRetentionSweepIfDue();
|
|
|
|
// FilterAllTabsAsync now runs from MessageManagerInitHostedService
|
|
// during Host.StartAsync (same Reason-not-Boot guard there).
|
|
|
|
// Kick the FTS5 rebuild worker if Migrate4 just added the schema or
|
|
// a previous run was cut short (InitFtsReadyCache leaves _ftsReady
|
|
// false in that case). Runs off the framework thread on its own
|
|
// SqliteConnection so the live UpsertMessage path keeps flowing
|
|
// through the chunked-commit windows.
|
|
_ftsRebuildCts = new CancellationTokenSource();
|
|
if (!MessageManager.Store.IsFtsIndexBuilt)
|
|
{
|
|
var token = _ftsRebuildCts.Token;
|
|
_ = Task.Run(
|
|
async () =>
|
|
{
|
|
// FQN: the Plugin.Notification property shadows the type name.
|
|
Dalamud.Interface.ImGuiNotification.IActiveNotification? notif = null;
|
|
try
|
|
{
|
|
notif = Notification.AddNotification(
|
|
new Dalamud.Interface.ImGuiNotification.Notification
|
|
{
|
|
Title = "Hellion Chat",
|
|
Content = "Indexing chat history for full-text search...",
|
|
Type = Dalamud
|
|
.Interface
|
|
.ImGuiNotification
|
|
.NotificationType
|
|
.Info,
|
|
Minimized = false,
|
|
InitialDuration = TimeSpan.FromMinutes(10),
|
|
}
|
|
);
|
|
|
|
// Progress<T> raises this callback on the captured
|
|
// sync-context (Task.Run worker pool). IActiveNotification
|
|
// is ImGui-backed and mutates the UI, so marshal the
|
|
// mutation onto the framework thread via RunOnTick.
|
|
var progress = new Progress<long>(done =>
|
|
{
|
|
Framework.RunOnTick(() =>
|
|
{
|
|
if (notif is { } n)
|
|
n.Content = $"Indexing chat history: {done:N0} messages...";
|
|
});
|
|
});
|
|
|
|
// Worker-owned connection. Closed+disposed before we
|
|
// flip the readiness flag so the DbViewer never sees
|
|
// IsFtsIndexBuilt=true while the worker connection
|
|
// is still alive.
|
|
SqliteConnection? workerConn = null;
|
|
try
|
|
{
|
|
workerConn = MessageManager.Store.OpenSecondaryConnection();
|
|
var total = await Task.Run(
|
|
() =>
|
|
MessageManager.Store.RebuildFtsIndex(
|
|
workerConn,
|
|
progress,
|
|
token
|
|
),
|
|
token
|
|
)
|
|
.ConfigureAwait(false);
|
|
|
|
workerConn.Close();
|
|
workerConn.Dispose();
|
|
workerConn = null;
|
|
MessageManager.Store.MarkFtsIndexBuilt();
|
|
|
|
if (notif is { } final)
|
|
{
|
|
final.Content = $"Indexed {total:N0} messages.";
|
|
final.Type = Dalamud
|
|
.Interface
|
|
.ImGuiNotification
|
|
.NotificationType
|
|
.Success;
|
|
final.InitialDuration = TimeSpan.FromSeconds(5);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
workerConn?.Dispose();
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
notif?.DismissNow();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Error(ex, "FTS index rebuild failed");
|
|
if (notif is { } err)
|
|
{
|
|
err.Content =
|
|
"Full-text indexing failed -- search will use local filter only.";
|
|
err.Type = Dalamud
|
|
.Interface
|
|
.ImGuiNotification
|
|
.NotificationType
|
|
.Error;
|
|
}
|
|
}
|
|
},
|
|
_ftsRebuildCts.Token
|
|
);
|
|
}
|
|
|
|
Interface.UiBuilder.DisableCutsceneUiHide = true;
|
|
Interface.UiBuilder.DisableGposeUiHide = true;
|
|
|
|
#if !DEBUG
|
|
// Fire-and-forget — first auto-translate use may have a sub-second
|
|
// hitch if the cache hasn't filled yet, but avoids blocking load.
|
|
_ = Task.Run(AutoTranslate.PreloadCache, cancellationToken);
|
|
#endif
|
|
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
// Hooks last — all services and windows must be live before
|
|
// the first Draw / FrameworkUpdate tick fires.
|
|
Framework.Update += FrameworkUpdate;
|
|
Interface.UiBuilder.Draw += Draw;
|
|
Interface.LanguageChanged += LanguageChanged;
|
|
}
|
|
catch
|
|
{
|
|
try
|
|
{
|
|
await DisposeAsync().ConfigureAwait(false);
|
|
}
|
|
catch
|
|
{ /* keep original failure */
|
|
}
|
|
throw;
|
|
}
|
|
}
|
|
|
|
[SuppressMessage("ReSharper", "ConditionalAccessQualifierIsNonNullableAccordingToAPIContract")]
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
// Idempotency guard — second call short-circuits on reload race.
|
|
if (Interlocked.Exchange(ref _disposeStarted, 1) != 0)
|
|
return;
|
|
|
|
// Set before any cleanup so deferred Framework.RunOnTick callbacks
|
|
// (the retention sweep) see the flag and bail out before they touch
|
|
// MessageManager / Log / static fields that the rest of this method
|
|
// is about to tear down.
|
|
_isDisposing = true;
|
|
|
|
Exception? failure = null;
|
|
|
|
// Unsubscribe hooks first — mirrors the hooks-last subscribe order in LoadAsync.
|
|
failure = CaptureFailure(failure, () => Interface.LanguageChanged -= LanguageChanged);
|
|
failure = CaptureFailure(failure, () => Interface.UiBuilder.Draw -= Draw);
|
|
failure = CaptureFailure(failure, () => Framework.Update -= FrameworkUpdate);
|
|
|
|
// Signal the FTS rebuild worker to bail. Runs before MessageManager
|
|
// tears down so the worker's "rebuild failed" log path still finds
|
|
// a live Log static. Worker owns its own SqliteConnection and disposes
|
|
// it itself; we only flip the cancellation flag here.
|
|
failure = CaptureFailure(
|
|
failure,
|
|
() =>
|
|
{
|
|
_ftsRebuildCts?.Cancel();
|
|
_ftsRebuildCts?.Dispose();
|
|
}
|
|
);
|
|
|
|
// Framework-thread cleanup the container does not reach.
|
|
try
|
|
{
|
|
await Framework
|
|
.RunOnFrameworkThread(() =>
|
|
{
|
|
failure = CaptureFailure(failure, TearDownCommands);
|
|
failure = CaptureFailure(
|
|
failure,
|
|
() => GameFunctions.GameFunctions.SetChatInteractable(true)
|
|
);
|
|
failure = CaptureFailure(failure, () => WindowSystem?.RemoveAllWindows());
|
|
})
|
|
.ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
failure ??= ex;
|
|
}
|
|
|
|
// The four long-running workers are background threads with no
|
|
// cancellation path, and one of them may be holding an open reader or
|
|
// sitting inside a VACUUM. Disposing the store under that tears the
|
|
// connection out mid-statement. Five seconds is not a guarantee, but it
|
|
// covers everything short of a VACUUM over a very large file, and it
|
|
// costs nothing when nothing is running.
|
|
var grace = Stopwatch.StartNew();
|
|
while (DbOperations.IsBusy && grace.ElapsedMilliseconds < 5_000)
|
|
await Task.Delay(50).ConfigureAwait(false);
|
|
|
|
if (DbOperations.IsBusy)
|
|
Log.Warning(
|
|
$"Disposing while {DbOperations.Current} still owns the store; it outlasted the 5s grace period."
|
|
);
|
|
|
|
// Container disposes services + windows on the framework thread.
|
|
// MessageManager.DisposeAsync is not idempotent, so we let the
|
|
// container do it once instead of double-disposing.
|
|
if (_lifecycle is not null)
|
|
{
|
|
failure = await CaptureFailureAsync(failure, () => _lifecycle.DisposeAsync().AsTask())
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
// Static-class cleanups the container has no handle on.
|
|
failure = CaptureFailure(failure, InputHistoryService.Reset);
|
|
|
|
if (failure is not null)
|
|
ExceptionDispatchInfo.Capture(failure).Throw();
|
|
}
|
|
|
|
// Run cleanup actions individually so a single failure doesn't strand
|
|
// the remaining teardown steps.
|
|
private static Exception? CaptureFailure(Exception? failure, Action action)
|
|
{
|
|
try
|
|
{
|
|
action();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
failure ??= ex;
|
|
}
|
|
return failure;
|
|
}
|
|
|
|
private static async ValueTask<Exception?> CaptureFailureAsync(
|
|
Exception? failure,
|
|
Func<Task> action
|
|
)
|
|
{
|
|
try
|
|
{
|
|
await action().ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
failure ??= ex;
|
|
}
|
|
return failure;
|
|
}
|
|
|
|
// Copies the config aside before the 2.0.0 reset overwrites it. Best effort
|
|
// by design: a backup that cannot be written must not stop the plugin from
|
|
// starting, and the reset itself is what the user was told would happen.
|
|
//
|
|
// Overwrite=false, so a second run cannot bury the real backup under a copy
|
|
// of the already-reset file. The reset saves immediately for the same
|
|
// reason, but a crash in between is exactly when this matters.
|
|
private static void BackUpConfigBeforeReset()
|
|
{
|
|
try
|
|
{
|
|
var dir = Interface.ConfigDirectory.Parent?.FullName;
|
|
if (dir is null)
|
|
return;
|
|
|
|
var configFile = Path.Combine(dir, "HellionChat.json");
|
|
if (!File.Exists(configFile))
|
|
return;
|
|
|
|
var backup = Path.Combine(dir, "HellionChat.json.pre-2.0.0.bak");
|
|
if (File.Exists(backup))
|
|
return;
|
|
|
|
File.Copy(configFile, backup);
|
|
Log.Information($"HellionChat: config backed up to {backup} before the 2.0.0 reset");
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Log.Warning(e, "HellionChat: could not back up the config before the 2.0.0 reset");
|
|
}
|
|
}
|
|
|
|
private static void MigrateFromChatTwoLayout()
|
|
{
|
|
var pluginConfigsDir = Interface.ConfigDirectory.Parent?.FullName;
|
|
if (pluginConfigsDir is null)
|
|
return;
|
|
|
|
var legacyConfigFile = Path.Combine(pluginConfigsDir, "ChatTwo.json");
|
|
var legacyConfigDir = Path.Combine(pluginConfigsDir, "ChatTwo");
|
|
var ourConfigFile = Path.Combine(pluginConfigsDir, "HellionChat.json");
|
|
var ourConfigDir = Interface.ConfigDirectory.FullName;
|
|
|
|
var lockedBlocker = false;
|
|
|
|
try
|
|
{
|
|
if (!File.Exists(ourConfigFile) && File.Exists(legacyConfigFile))
|
|
{
|
|
File.Move(legacyConfigFile, ourConfigFile);
|
|
Log.Information(
|
|
$"HellionChat: migrated config file {legacyConfigFile} → {ourConfigFile}"
|
|
);
|
|
}
|
|
}
|
|
catch (IOException e)
|
|
{
|
|
Log.Warning(
|
|
e,
|
|
$"HellionChat: config file move blocked, leaving {legacyConfigFile} in place"
|
|
);
|
|
lockedBlocker = true;
|
|
}
|
|
|
|
if (!Directory.Exists(legacyConfigDir))
|
|
return;
|
|
|
|
try
|
|
{
|
|
Directory.CreateDirectory(ourConfigDir);
|
|
|
|
// Move each file individually so a single locked file (e.g. the
|
|
// SQLite db while ChatTwo is still loaded) doesn't abort the rest.
|
|
foreach (var file in Directory.EnumerateFiles(legacyConfigDir))
|
|
{
|
|
var target = Path.Combine(ourConfigDir, Path.GetFileName(file));
|
|
if (File.Exists(target))
|
|
continue;
|
|
try
|
|
{
|
|
File.Move(file, target);
|
|
Log.Information($"HellionChat: migrated file {file} → {target}");
|
|
}
|
|
catch (IOException e)
|
|
{
|
|
Log.Warning(
|
|
e,
|
|
$"HellionChat: file move blocked for {file}, will retry on next load"
|
|
);
|
|
lockedBlocker = true;
|
|
}
|
|
}
|
|
|
|
foreach (var dir in Directory.EnumerateDirectories(legacyConfigDir))
|
|
{
|
|
var target = Path.Combine(ourConfigDir, Path.GetFileName(dir));
|
|
if (Directory.Exists(target))
|
|
continue;
|
|
try
|
|
{
|
|
Directory.Move(dir, target);
|
|
Log.Information($"HellionChat: migrated subdir {dir} → {target}");
|
|
}
|
|
catch (IOException e)
|
|
{
|
|
Log.Warning(
|
|
e,
|
|
$"HellionChat: subdir move blocked for {dir}, will retry on next load"
|
|
);
|
|
lockedBlocker = true;
|
|
}
|
|
}
|
|
|
|
if (!Directory.EnumerateFileSystemEntries(legacyConfigDir).Any())
|
|
{
|
|
Directory.Delete(legacyConfigDir);
|
|
Log.Information($"HellionChat: removed empty legacy dir {legacyConfigDir}");
|
|
}
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Log.Error(e, "HellionChat: layout migration failed, continuing with whatever exists");
|
|
}
|
|
|
|
if (lockedBlocker)
|
|
{
|
|
Notification.AddNotification(
|
|
new Dalamud.Interface.ImGuiNotification.Notification
|
|
{
|
|
Title = "Hellion Chat",
|
|
Content =
|
|
"Could not migrate the Chat 2 database — the file appears to be in use. "
|
|
+ "Disable Chat 2, fully close the game, then start it again. "
|
|
+ "See the README troubleshooting section if the issue persists.",
|
|
Type = Dalamud.Interface.ImGuiNotification.NotificationType.Warning,
|
|
InitialDuration = TimeSpan.FromSeconds(30),
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
// Central slash-command + UiBuilder.OpenConfigUi/OpenMainUi subscribe so
|
|
// the four lazy windows (Settings, DbViewer, SeStringDebugger, Debugger)
|
|
// have working entry points before they're constructed.
|
|
private void SetupCommands()
|
|
{
|
|
_hellionSettingsCmd = Commands.Register(
|
|
"/hellion",
|
|
"Toggle Hellion Chat. /hellion settings opens settings, /hellion wizard reopens the setup wizard, /hellion reset restores the default theme."
|
|
);
|
|
_hellionSettingsCmd.Execute += OnHellionSettingsCommand;
|
|
|
|
_clearHellionCmd = Commands.Register("/clearhellion", "Clear the active Hellion Chat tab.");
|
|
_clearHellionCmd.Execute += OnClearHellionCommand;
|
|
|
|
_hellionViewCmd = Commands.Register(
|
|
"/hellionView",
|
|
"Get access to your message history, with simple filter options.",
|
|
true
|
|
);
|
|
_hellionViewCmd.Execute += OnHellionViewCommand;
|
|
|
|
_hellionDebuggerCmd = Commands.Register("/hellionDebugger", showInHelp: false);
|
|
_hellionDebuggerCmd.Execute += OnHellionDebuggerCommand;
|
|
#if DEBUG
|
|
// SeStringDebugger.cs lives under #if DEBUG too; keep this out of release builds.
|
|
_hellionSeStringCmd = Commands.Register("/hellionSeString", showInHelp: false);
|
|
_hellionSeStringCmd.Execute += OnHellionSeStringCommand;
|
|
#endif
|
|
|
|
// Plugin-Manager "Settings" button. Was in Settings.cs:67 pre-v1.4.9.
|
|
Interface.UiBuilder.OpenConfigUi += OnOpenConfigUi;
|
|
|
|
// Plugin-Manager "Open" button. Was in Plugin.cs LoadAsync pre-v1.4.9
|
|
// (separate OpenMainUi handler that flipped SettingsWindow.IsOpen).
|
|
Interface.UiBuilder.OpenMainUi += OnOpenMainUi;
|
|
}
|
|
|
|
private void TearDownCommands()
|
|
{
|
|
Interface.UiBuilder.OpenMainUi -= OnOpenMainUi;
|
|
Interface.UiBuilder.OpenConfigUi -= OnOpenConfigUi;
|
|
|
|
// Null-tolerant detaches: TearDownCommands can run from the LoadAsync
|
|
// failure path (Plugin.cs CaptureFailure) before SetupCommands finished.
|
|
if (_hellionSettingsCmd is not null)
|
|
{
|
|
_hellionSettingsCmd.Execute -= OnHellionSettingsCommand;
|
|
_hellionSettingsCmd = null;
|
|
}
|
|
|
|
if (_clearHellionCmd is not null)
|
|
{
|
|
_clearHellionCmd.Execute -= OnClearHellionCommand;
|
|
_clearHellionCmd = null;
|
|
}
|
|
|
|
if (_hellionViewCmd is not null)
|
|
{
|
|
_hellionViewCmd.Execute -= OnHellionViewCommand;
|
|
_hellionViewCmd = null;
|
|
}
|
|
|
|
if (_hellionDebuggerCmd is not null)
|
|
{
|
|
_hellionDebuggerCmd.Execute -= OnHellionDebuggerCommand;
|
|
_hellionDebuggerCmd = null;
|
|
}
|
|
#if DEBUG
|
|
if (_hellionSeStringCmd is not null)
|
|
{
|
|
_hellionSeStringCmd.Execute -= OnHellionSeStringCommand;
|
|
_hellionSeStringCmd = null;
|
|
}
|
|
#endif
|
|
}
|
|
|
|
private void OnHellionSettingsCommand(string command, string arguments)
|
|
{
|
|
var arg = arguments.Trim();
|
|
if (string.IsNullOrEmpty(arg))
|
|
{
|
|
MainWindow.Toggle();
|
|
return;
|
|
}
|
|
if (arg.Equals("settings", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
SettingsWindow.Toggle();
|
|
return;
|
|
}
|
|
#if DEBUG
|
|
if (arg.Equals("widgets", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
WidgetGallery.Toggle();
|
|
return;
|
|
}
|
|
#endif
|
|
|
|
if (arg.Equals("lab", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
InputBarLab.Toggle();
|
|
return;
|
|
}
|
|
|
|
// The wizard had no way back into it: the reopen button the resource
|
|
// file still carries in 25 languages lost its call site in the
|
|
// four-step rewrite, and nothing replaced it. Without this the only
|
|
// route is closing the game and editing FirstRunCompleted by hand.
|
|
// Toggle, not IsOpen = true, so the same command closes it again.
|
|
if (arg.Equals("wizard", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
FirstRunWizard.Toggle();
|
|
return;
|
|
}
|
|
#if DEBUG
|
|
#endif
|
|
if (arg.Equals("reset", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
// Documented recovery path -- drops a
|
|
// broken custom theme out of the loader cache without touching
|
|
// the user's JSON on disk.
|
|
ThemeRegistry.SwitchSilent(Themes.ThemeRegistry.DefaultSlug);
|
|
}
|
|
}
|
|
|
|
private void OnClearHellionCommand(string command, string arguments)
|
|
{
|
|
MainWindow.ActiveTab?.Clear();
|
|
}
|
|
|
|
private void OnOpenConfigUi() => SettingsWindow.Toggle();
|
|
|
|
private void OnOpenMainUi() => MainWindow.Toggle();
|
|
|
|
private void OnHellionViewCommand(string _, string __) => DbViewer.Toggle();
|
|
|
|
private void OnHellionDebuggerCommand(string _, string __) => DebuggerWindow.Toggle();
|
|
|
|
#if DEBUG
|
|
private void OnHellionSeStringCommand(string _, string __) => SeStringDebugger.Toggle();
|
|
#endif
|
|
|
|
private void RunRetentionSweepIfDue()
|
|
{
|
|
if (!Config.RetentionEnabled)
|
|
return;
|
|
if (DateTimeOffset.UtcNow - Config.RetentionLastRunAt < TimeSpan.FromHours(24))
|
|
return;
|
|
|
|
StartRetentionSweep(notify: false);
|
|
}
|
|
|
|
// Shared by the daily check above and the manual button in settings.
|
|
//
|
|
// notify: the unattended sweep stays quiet, because a notification for
|
|
// something the user did not ask for at a moment they did not choose is
|
|
// noise. A run they pressed a button for reports back.
|
|
//
|
|
// Returns false when the store is already busy, so the caller can say so
|
|
// instead of leaving the user waiting for a run that never started.
|
|
internal bool StartRetentionSweep(bool notify)
|
|
{
|
|
if (DbOperations.IsBusy)
|
|
return false;
|
|
|
|
// Snapshot the policy so the user can edit settings while the sweep runs.
|
|
//
|
|
// Seeded from the spec defaults only when the global limit is not "keep
|
|
// forever". The slider is labelled "0 = never", and pre-filling 31
|
|
// channels with 365- and 90-day windows made that label a lie: setting
|
|
// it to zero still lost free company, linkshell and party history after
|
|
// ninety days, and the short-circuit in DeleteByRetentionPolicy could
|
|
// never be reached because the map was never empty.
|
|
//
|
|
// Explicit per-channel overrides still apply. Somebody who typed a
|
|
// number for one channel meant that number.
|
|
var policy = new Dictionary<int, int>();
|
|
if (Config.RetentionDefaultDays > 0)
|
|
{
|
|
foreach (var (type, days) in Privacy.PrivacyDefaults.DefaultRetentionDays)
|
|
policy[(int)(ushort)type] = days;
|
|
}
|
|
|
|
// This is the enumerator the wizard's Clear() cuts short. Reading under the
|
|
// same lock the writers take keeps the policy snapshot whole.
|
|
lock (ConfigMapsLock)
|
|
{
|
|
foreach (var (type, days) in Config.RetentionPerChannelDays)
|
|
policy[(int)(ushort)type] = days;
|
|
}
|
|
var defaultDays = Config.RetentionDefaultDays;
|
|
|
|
_retentionSweepRunning = true;
|
|
|
|
// IsBackground = true so a stuck sweep never blocks plugin unload.
|
|
var worker = new Thread(() =>
|
|
{
|
|
// Bails when anything else already owns the store, not only another
|
|
// sweep: a user-triggered export or cleanup counts too.
|
|
try
|
|
{
|
|
if (!DbOperations.TryBegin(Util.DbOperation.RetentionSweep))
|
|
{
|
|
// A run the user pressed a button for has to say something.
|
|
// The pre-check in StartRetentionSweep only covers a gate
|
|
// that was already busy; losing the race here is the same
|
|
// outcome and used to be silent.
|
|
if (notify)
|
|
NotifySweep(
|
|
Resources.HellionStrings.Retention_Error,
|
|
Dalamud.Interface.ImGuiNotification.NotificationType.Warning
|
|
);
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
var deleted = MessageManager.Store.DeleteByRetentionPolicy(policy, defaultDays);
|
|
Config.RetentionLastRunAt = DateTimeOffset.UtcNow;
|
|
SaveConfig();
|
|
|
|
if (notify)
|
|
Util.WrapperUtil.AddNotification(
|
|
string.Format(Resources.HellionStrings.Retention_Success, deleted),
|
|
Dalamud.Interface.ImGuiNotification.NotificationType.Success
|
|
);
|
|
|
|
if (deleted > 0)
|
|
{
|
|
Log.Information($"Retention sweep deleted {deleted} expired messages.");
|
|
// Schedule on the next framework tick to avoid the ~194ms
|
|
// hitch from blocking with .Wait() while the frame finishes.
|
|
// The Config.Tabs enumeration in ClearAllTabs/FilterAllTabs is
|
|
// now guarded by the shared Plugin.TabsListLock, so this
|
|
// tick scheduling is purely hitch-avoidance, not safety.
|
|
// Pattern reference: SimpleTweaks
|
|
// Tweaks/Chat/CaseInsensitiveCommands.cs:45.
|
|
Framework.RunOnTick(() =>
|
|
{
|
|
// The retention thread is IsBackground=true so plugin
|
|
// unload can fire while a scheduled tick is still
|
|
// pending; bail before touching anything torn down.
|
|
if (_isDisposing)
|
|
return;
|
|
try
|
|
{
|
|
MessageManager.ClearAllTabs();
|
|
MessageManager.FilterAllTabs();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Error(ex, "Retention sweep clear+refilter failed");
|
|
}
|
|
});
|
|
}
|
|
else
|
|
{
|
|
Log.Information("Retention sweep ran, nothing expired.");
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
DbOperations.End(Util.DbOperation.RetentionSweep);
|
|
}
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Log.Error(e, "Retention sweep failed");
|
|
if (notify)
|
|
NotifySweep(
|
|
Resources.HellionStrings.Retention_Error,
|
|
Dalamud.Interface.ImGuiNotification.NotificationType.Error
|
|
);
|
|
}
|
|
finally
|
|
{
|
|
_retentionSweepRunning = false;
|
|
}
|
|
})
|
|
{
|
|
IsBackground = true,
|
|
};
|
|
|
|
try
|
|
{
|
|
worker.Start();
|
|
return true;
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
// The thread never ran, so nothing will clear the flag for us.
|
|
_retentionSweepRunning = false;
|
|
Log.Error(e, "Could not start the retention sweep thread");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// The sweep is a background thread that can outlive an unload, same as the
|
|
// settings-tab workers. A notification filed against a plugin that is gone
|
|
// belongs to nobody.
|
|
private void NotifySweep(
|
|
string message,
|
|
Dalamud.Interface.ImGuiNotification.NotificationType type
|
|
)
|
|
{
|
|
if (_isDisposing)
|
|
return;
|
|
|
|
Util.WrapperUtil.AddNotification(message, type);
|
|
}
|
|
|
|
// Read by the settings tab every frame so the manual button can say a run is
|
|
// in progress. The gate itself cannot answer that: it goes busy only once
|
|
// the worker reaches TryBegin, which is after Start returns.
|
|
private volatile bool _retentionSweepRunning;
|
|
|
|
internal bool RetentionSweepRunning => _retentionSweepRunning;
|
|
|
|
private void Draw()
|
|
{
|
|
// v1.9.0: time the whole handler (style + font prologue included).
|
|
// Bail before measuring once teardown has begun — a late Draw tick
|
|
// must not touch ThemeRegistry / FontManager after DisposeAsync.
|
|
if (_isDisposing)
|
|
return;
|
|
|
|
var drawWatch = Stopwatch.StartNew();
|
|
try
|
|
{
|
|
// v1.4.8: pick up external edits of the active custom theme JSON
|
|
// without forcing the user to re-click the picker. The disk-stat is
|
|
// 1Hz-throttled inside RefreshActiveIfStale, so this is essentially
|
|
// free on built-in themes and ~1 stat/second on custom themes.
|
|
ThemeRegistry.RefreshActiveIfStale();
|
|
|
|
using IDisposable _style = Ui.StyleEngine.GlobalStyleScope.Push(
|
|
ThemeRegistry.Active,
|
|
ThemeRegistry,
|
|
Config.WindowOpacity
|
|
);
|
|
|
|
// Advance every held hover value once, before any window draws. Sits
|
|
// above the early returns below so a hidden main window still lets
|
|
// pop-out hovers fade instead of freezing mid-blend.
|
|
Ui.StyleEngine.HoverState.BeginFrame();
|
|
|
|
if (Config.HideInLoadingScreens && Condition[ConditionFlag.BetweenAreas])
|
|
{
|
|
TypingIpc.Update();
|
|
return;
|
|
}
|
|
|
|
// Hide all plugin windows while the New Game+ menu is open.
|
|
if (
|
|
Config.HideInNewGamePlusMenu
|
|
&& GameFunctions.GameFunctions.IsAddonInteractable(
|
|
GameFunctions.GameFunctions.NewGamePlusAddonName
|
|
)
|
|
)
|
|
{
|
|
TypingIpc.Update();
|
|
return;
|
|
}
|
|
|
|
Interface.UiBuilder.DisableUserUiHide = !Config.HideWhenUiHidden;
|
|
|
|
// Stateless, so it needs no machine: there is no gesture that shows
|
|
// the chat while nobody is logged in.
|
|
if (Config.HideWhenNotLoggedIn && !ClientState.IsLoggedIn)
|
|
{
|
|
TypingIpc.Update();
|
|
return;
|
|
}
|
|
|
|
_hideReason = Util.ChatHideState.Next(
|
|
_hideReason,
|
|
new Util.ChatHideState.Inputs(
|
|
Config.HideInBattle,
|
|
InBattle,
|
|
Config.HideDuringCutscenes,
|
|
CutsceneActive || GposeActive,
|
|
ChatActivationRequested
|
|
)
|
|
);
|
|
ChatActivationRequested = false;
|
|
|
|
if (Util.ChatHideState.Hides(_hideReason))
|
|
{
|
|
TypingIpc.Update();
|
|
return;
|
|
}
|
|
|
|
// RegularFont is nullable only because the live rebuild path
|
|
// disposes it before reassigning; both ends of that swap happen on
|
|
// this same draw thread, so it cannot be null here.
|
|
var useRegularFont = Config.FontsEnabled || Config.UseHellionFont;
|
|
using ((useRegularFont ? FontManager.RegularFont! : FontManager.Axis).Push())
|
|
WindowSystem.Draw();
|
|
|
|
TypingIpc.Update();
|
|
|
|
FileDialogManager.Draw();
|
|
}
|
|
finally
|
|
{
|
|
// finally so the early-return frames (loading screen / NG+) record
|
|
// their (cheap) time too instead of freezing on the last full frame.
|
|
drawWatch.Stop();
|
|
LastDrawMs = drawWatch.Elapsed.TotalMilliseconds;
|
|
}
|
|
}
|
|
|
|
internal void SaveConfig()
|
|
{
|
|
// Only unpinned TempTabs are session-only — they move aside before
|
|
// serialization and re-attach after. Pinned TempTabs stay in
|
|
// Config.Tabs across the save so JSON includes them. Cloning only the
|
|
// unpinned subset keeps the allocation proportional to
|
|
// AutoTellTabsLimit (<=15) instead of the full tab list.
|
|
// The strip/restore mutates the tab LIST, so it shares TabsListLock
|
|
// with the worker add/remove and the refilter snapshot. Re-entrant: the
|
|
// one worker caller (HandleTell) already holds it; framework callers take
|
|
// it here. SavePluginConfig runs inside (short, in-memory) — the documented fallback
|
|
// (serialize a copy outside the lock) is a tracked pre-beta to-do.
|
|
lock (TabsListLock)
|
|
{
|
|
var unpinnedTempTabs = Config.Tabs.Where(TabLifecycleHelpers.IsInUnpinnedPool).ToList();
|
|
Config.Tabs.RemoveAll(TabLifecycleHelpers.ShouldStripOnSave);
|
|
|
|
Interface.SavePluginConfig(Config);
|
|
|
|
Config.Tabs.AddRange(unpinnedTempTabs);
|
|
}
|
|
}
|
|
|
|
internal void LanguageChanged(string langCode)
|
|
{
|
|
var info =
|
|
Config.LanguageOverride is LanguageOverride.None
|
|
? new CultureInfo(langCode)
|
|
: new CultureInfo(Config.LanguageOverride.Code());
|
|
|
|
Language.Culture = info;
|
|
HellionStrings.Culture = info;
|
|
}
|
|
|
|
private static readonly string[] ChatAddonNames =
|
|
[
|
|
"ChatLog",
|
|
"ChatLogPanel_0",
|
|
"ChatLogPanel_1",
|
|
"ChatLogPanel_2",
|
|
"ChatLogPanel_3",
|
|
];
|
|
|
|
private void FrameworkUpdate(IFramework framework)
|
|
{
|
|
if (!Config.HideChat)
|
|
return;
|
|
|
|
foreach (var name in ChatAddonNames)
|
|
if (GameFunctions.GameFunctions.IsAddonInteractable(name))
|
|
GameFunctions.GameFunctions.SetAddonInteractable(name, false);
|
|
}
|
|
|
|
public static bool InBattle => Condition[ConditionFlag.InCombat];
|
|
public static bool GposeActive => Condition[ConditionFlag.WatchingCutscene];
|
|
public static bool CutsceneActive =>
|
|
Condition[ConditionFlag.OccupiedInCutSceneEvent]
|
|
|| Condition[ConditionFlag.WatchingCutscene78];
|
|
|
|
// Seeds example-theme.json into the themes dir on first run.
|
|
// Skipped if any custom JSON already exists.
|
|
private static void SeedExampleThemeIfEmpty(string dir)
|
|
{
|
|
if (Directory.EnumerateFiles(dir, "*.json").Any())
|
|
return;
|
|
|
|
var examplePath = Path.Combine(dir, "example-theme.json");
|
|
var resourceStream = typeof(Plugin).Assembly.GetManifestResourceStream(
|
|
"HellionChat.Themes.Builtin.example-theme.json"
|
|
);
|
|
if (resourceStream is null)
|
|
{
|
|
Log.Warning("Themes example template not found in assembly resources; skipping seed.");
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
using var fileStream = File.Create(examplePath);
|
|
resourceStream.CopyTo(fileStream);
|
|
Log.Information($"Seeded example-theme.json into {dir}");
|
|
}
|
|
catch (IOException ex)
|
|
{
|
|
Log.Warning(
|
|
ex,
|
|
"Failed to seed example-theme.json; user can create custom themes manually."
|
|
);
|
|
}
|
|
finally
|
|
{
|
|
resourceStream.Dispose();
|
|
}
|
|
}
|
|
}
|