cc1c05add0
Adds three embedded WAV files as additional notification sound choices (ids 17-19) alongside the existing 16 game sounds. Playback via NAudio WaveOutEvent/WinMM, which works correctly on Wine/Linux.
1060 lines
42 KiB
C#
Executable File
1060 lines
42 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!;
|
|
|
|
public readonly WindowSystem WindowSystem = new(PluginName);
|
|
|
|
// Phase-2 services are constructed in LoadAsync; null! shape is kept
|
|
// consistent across all properties for clarity.
|
|
public SettingsWindow SettingsWindow { get; private set; } = null!;
|
|
public ChatLogWindow ChatLogWindow { get; private set; } = null!;
|
|
public DbViewer DbViewer { get; private set; } = null!;
|
|
public InputPreview InputPreview { get; private set; } = null!;
|
|
public CommandHelpWindow CommandHelpWindow { get; private set; } = null!;
|
|
public SeStringDebugger SeStringDebugger { get; private set; } = null!;
|
|
public FirstRunWizard FirstRunWizard { get; private set; } = null!;
|
|
public 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!;
|
|
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 FontManager FontManager { get; private set; } = null!;
|
|
internal Themes.ThemeRegistry ThemeRegistry { get; private set; } = null!;
|
|
internal Ui.StatusBar StatusBar { get; private set; } = null!;
|
|
internal Integrations.HonorificService HonorificService { get; private set; } = null!;
|
|
internal Integrations.CustomAudioPlayer CustomAudioPlayer { 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? _hellionViewCmd;
|
|
private CommandWrapper? _hellionDebuggerCmd;
|
|
#if DEBUG
|
|
private CommandWrapper? _hellionSeStringCmd;
|
|
#endif
|
|
|
|
// Idempotency guard — Dalamud may fire DisposeAsync twice in a reload race.
|
|
private int _disposeStarted;
|
|
|
|
// Set in the first DisposeAsync statement so async callbacks scheduled
|
|
// via Framework.RunOnTick (v1.4.8 B3 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;
|
|
|
|
internal int DeferredSaveFrames = -1;
|
|
|
|
// 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 retention sweeps so a manual trigger and the 24h auto-sweep
|
|
// can't run in parallel. Volatile because the ImGui thread reads it outside
|
|
// the lock to gate the manual button.
|
|
internal readonly object RetentionSweepLock = new();
|
|
internal volatile bool RetentionSweepRunning;
|
|
|
|
internal DateTime GameStarted { get; }
|
|
|
|
// Tab management lives here rather than in ChatLogWindow for access reasons.
|
|
internal int LastTab { get; set; }
|
|
internal int? WantedTab { get; set; }
|
|
internal Tab CurrentTab
|
|
{
|
|
get
|
|
{
|
|
var i = LastTab;
|
|
return i > -1 && i < Config.Tabs.Count ? Config.Tabs[i] : 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 ?? new Configuration();
|
|
|
|
// 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. v18 adds the
|
|
// per-tab EnableNotificationSound + NotificationSoundId fields and the
|
|
// top-level NotifyFailedTell flag, all additive with defaults, so
|
|
// v16/v17 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."
|
|
);
|
|
}
|
|
Config.Version = 18;
|
|
|
|
// Unpinned TempTabs are session-only and dropped on every load. Pinned
|
|
// TempTabs survive reload — Jin's tester feedback (v1.4.7).
|
|
Config.Tabs.RemoveAll(TabLifecycleHelpers.ShouldStripOnLoad);
|
|
|
|
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);
|
|
|
|
DeferredSaveFrames = -1;
|
|
|
|
// 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);
|
|
_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>();
|
|
StatusBar = _host.Services.GetRequiredService<Ui.StatusBar>();
|
|
MessageManager = _host.Services.GetRequiredService<MessageManager>();
|
|
AutoTellTabsService = _host.Services.GetRequiredService<AutoTellTabsService>();
|
|
|
|
ChatLogWindow = _host.Services.GetRequiredService<ChatLogWindow>();
|
|
SettingsWindow = _host.Services.GetRequiredService<SettingsWindow>();
|
|
DbViewer = _host.Services.GetRequiredService<DbViewer>();
|
|
InputPreview = _host.Services.GetRequiredService<InputPreview>();
|
|
CommandHelpWindow = _host.Services.GetRequiredService<CommandHelpWindow>();
|
|
SeStringDebugger = _host.Services.GetRequiredService<SeStringDebugger>();
|
|
DebuggerWindow = _host.Services.GetRequiredService<DebuggerWindow>();
|
|
FirstRunWizard = _host.Services.GetRequiredService<FirstRunWizard>();
|
|
}
|
|
|
|
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.ThemeSwitchSelfTestStep(this),
|
|
new SelfTests.ThemeCrossfadeSelfTestStep(this),
|
|
new SelfTests.FontManagerCtorSmokeStep(this),
|
|
new SelfTests.FontPushSmokeStep(this),
|
|
new SelfTests.WizardStateSmokeStep(this),
|
|
new SelfTests.QuickPickerSelfTestStep(this),
|
|
new SelfTests.FoxBannerTextureSmokeStep(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: Plugin.Notification (Z.74) 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
|
|
// (B3 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();
|
|
}
|
|
);
|
|
|
|
// Flush a pending DeferredSave — FrameworkUpdate won't fire it anymore.
|
|
failure = CaptureFailure(
|
|
failure,
|
|
() =>
|
|
{
|
|
if (DeferredSaveFrames >= 0)
|
|
{
|
|
SaveConfig();
|
|
DeferredSaveFrames = -1;
|
|
}
|
|
}
|
|
);
|
|
|
|
// 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;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
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()
|
|
{
|
|
// ChatLogWindow.cs:128 already registers /hellion (ToggleChat). The
|
|
// description-arg here keeps the Dalamud help list populated.
|
|
_hellionSettingsCmd = Commands.Register(
|
|
"/hellion",
|
|
"Perform various actions with Hellion Chat."
|
|
);
|
|
_hellionSettingsCmd.Execute += OnHellionSettingsCommand;
|
|
|
|
_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 (_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)
|
|
{
|
|
// /hellion with args is intentionally a no-op (matches pre-v1.4.9
|
|
// Settings.cs:76-80 behaviour).
|
|
if (string.IsNullOrWhiteSpace(arguments))
|
|
SettingsWindow.Toggle();
|
|
}
|
|
|
|
private void OnOpenConfigUi() => SettingsWindow.Toggle();
|
|
|
|
private void OnOpenMainUi() => SettingsWindow.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;
|
|
|
|
// Snapshot the policy so the user can edit settings while the sweep runs.
|
|
var policy = new Dictionary<int, int>();
|
|
foreach (var (type, days) in Privacy.PrivacyDefaults.DefaultRetentionDays)
|
|
policy[(int)(ushort)type] = days;
|
|
foreach (var (type, days) in Config.RetentionPerChannelDays)
|
|
policy[(int)(ushort)type] = days;
|
|
var defaultDays = Config.RetentionDefaultDays;
|
|
|
|
// IsBackground = true so a stuck sweep never blocks plugin unload.
|
|
new Thread(() =>
|
|
{
|
|
// Bail early if a manual sweep is already in flight.
|
|
lock (RetentionSweepLock)
|
|
{
|
|
if (RetentionSweepRunning)
|
|
return;
|
|
RetentionSweepRunning = true;
|
|
}
|
|
|
|
try
|
|
{
|
|
var deleted = MessageManager.Store.DeleteByRetentionPolicy(policy, defaultDays);
|
|
Config.RetentionLastRunAt = DateTimeOffset.UtcNow;
|
|
SaveConfig();
|
|
|
|
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 framework
|
|
// finishes the current frame. Tabs-list mutation must
|
|
// stay on the framework thread because Plugin.Config.Tabs
|
|
// (Configuration.cs:222) is not lock-protected and
|
|
// AutoTellTabsService can mutate it from background paths.
|
|
// 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.");
|
|
}
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Log.Error(e, "Retention sweep failed");
|
|
}
|
|
finally
|
|
{
|
|
lock (RetentionSweepLock)
|
|
RetentionSweepRunning = false;
|
|
}
|
|
})
|
|
{
|
|
IsBackground = true,
|
|
}.Start();
|
|
}
|
|
|
|
private void Draw()
|
|
{
|
|
// v1.4.8 B2: 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();
|
|
|
|
// Theme engine is always active; Classic is a theme, not a disabled state.
|
|
using IDisposable _style = HellionStyle.PushGlobal(
|
|
ThemeRegistry.Active,
|
|
ThemeRegistry,
|
|
Config.WindowOpacity
|
|
);
|
|
|
|
ChatLogWindow.BeginFrame();
|
|
|
|
if (Config.HideInLoadingScreens && Condition[ConditionFlag.BetweenAreas])
|
|
{
|
|
ChatLogWindow.FinalizeFrame();
|
|
TypingIpc.Update();
|
|
return;
|
|
}
|
|
|
|
// Hide all plugin windows while the New Game+ menu is open.
|
|
if (
|
|
Config.HideInNewGamePlusMenu
|
|
&& GameFunctions.GameFunctions.IsAddonInteractable(
|
|
GameFunctions.GameFunctions.NewGamePlusAddonName
|
|
)
|
|
)
|
|
{
|
|
ChatLogWindow.FinalizeFrame();
|
|
TypingIpc.Update();
|
|
return;
|
|
}
|
|
|
|
ChatLogWindow.HideStateCheck();
|
|
|
|
Interface.UiBuilder.DisableUserUiHide = !Config.HideWhenUiHidden;
|
|
ChatLogWindow.DefaultText = ImGui.GetStyle().Colors[(int)ImGuiCol.Text];
|
|
|
|
// 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.
|
|
// v1.5.3 fix: also push RegularFont when the bundled Inter Light is
|
|
// selected. Without this, UseHellionFont=true silently fell back to
|
|
// the FFXIV Axis font because FontsAndColours forces FontsEnabled
|
|
// off in that branch, and the bundled font never made it into draw.
|
|
var useRegularFont = Config.FontsEnabled || Config.UseHellionFont;
|
|
using ((useRegularFont ? FontManager.RegularFont! : FontManager.Axis).Push())
|
|
WindowSystem.Draw();
|
|
|
|
ChatLogWindow.FinalizeFrame();
|
|
TypingIpc.Update();
|
|
|
|
FileDialogManager.Draw();
|
|
}
|
|
|
|
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.
|
|
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 (DeferredSaveFrames >= 0 && DeferredSaveFrames-- == 0)
|
|
SaveConfig();
|
|
|
|
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();
|
|
}
|
|
}
|
|
}
|