The first sweep matched a character class that swallowed the digit, so a bare B1 slipped through while B1-2 was caught. Searching the whole A-Z space instead of guessing prefixes turned up 130-odd more: B0 through B6, C2, C3, D1, H2, M6, P7, P8, T2, W2, plus GP-04, KB-01, OD-1, PM-1, PM-3, SEC-01, TR-4, TR-7, UI-11, UI-12, XC-8 and API-3. Kept deliberately: 41 B4 01 is a byte signature, "N0" a format string, #L119-L128 a source anchor, LS4/LS6 are linkshells, and A=FF B=0C G=41 R=C2 explains a colour-channel order. Those look like codes and are not. Also translated the eight German comments left in the theme files and ImGuiUtil. Seven of them described what a palette does to which channel, which is worth reading -- just not in a second language in an otherwise English codebase.
1525 lines
64 KiB
C#
Executable File
1525 lines
64 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."
|
|
);
|
|
}
|
|
|
|
// 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 = 27;
|
|
|
|
// 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.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.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.ConfigMigrationV26Step(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();
|
|
|
|
if (Config.ShowEmotes)
|
|
_ = EmoteCache.LoadData();
|
|
|
|
// 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, () => EmoteCache.Dispose());
|
|
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();
|
|
}
|
|
}
|
|
}
|