74bcb91b65
When the user edits their active custom theme JSON in an external editor and saves, the change now propagates to HellionChat within ~1 second without re-selecting the theme in the picker. RefreshActiveIfStale runs from Plugin.Draw on every frame but the actual File.GetLastWriteTimeUtc stat is 1Hz-throttled -- 60fps would otherwise mean 3600 stats/min, more on Wine. Built-in themes short-circuit on the IsBuiltIn check; custom themes without a captured source path (Switch fell to default) short-circuit on the null check. Switch() now captures the source path of custom themes via an out-param on LoadCustomBySlug, which now reverse-looks-up against the existing _customCache (no re-parse, no extra disk IO). Plugin.LoadAsync warms the cache via AllCustom() once before the first Switch so a Config.Theme pointing at a custom slug does not fall through to the built-in default on a cold registry. Switch's lookup order is now built-in-first to match Get(slug), so a user-authored JSON that declares a built-in slug is consistently ignored in both code paths. Pure-helper ThemeStampDiff isolates the stamp-diff rules for the Build-Suite (covers DateTime.MinValue hold-the-line semantics). v1.4.8 B2.
884 lines
34 KiB
C#
Executable File
884 lines
34 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;
|
|
|
|
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!;
|
|
|
|
// 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!;
|
|
|
|
// Idempotency guard — Dalamud may fire DisposeAsync twice in a reload race.
|
|
private int _disposeStarted;
|
|
|
|
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();
|
|
|
|
// Wire platform indirection before LoadAsync allocates anything that
|
|
// needs Util.* — services then read Plugin.PlatformUtil instead of
|
|
// hitting the Dalamud static surface directly.
|
|
PlatformUtil = new DalamudPlatformUtil();
|
|
LogProxy = new DalamudPluginLogProxy(Log);
|
|
|
|
// Schema gate: v1.4.x requires config v16+. Users on older schemas
|
|
// must install v1.4.2 first to run the migration chain. v17 adds
|
|
// Tab.IsPinned (additive, no data migration needed) so v16 configs
|
|
// load cleanly and get their Version stamp bumped after the gate.
|
|
if (Config.Version < 16)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"HellionChat v1.4.7 requires config schema v16, got v{Config.Version}. "
|
|
+ "Please install v1.4.2 first to migrate the configuration, then upgrade to v1.4.7."
|
|
);
|
|
}
|
|
Config.Version = 17;
|
|
|
|
// 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);
|
|
ImGuiUtil.Initialize(this);
|
|
|
|
DeferredSaveFrames = -1;
|
|
}
|
|
|
|
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();
|
|
|
|
// BuildFonts registers handles with Dalamud's FontAtlas; the atlas
|
|
// rebuilds async a few frames later (visible "font-pop" on first load).
|
|
FontManager = new FontManager();
|
|
FontManager.BuildFonts();
|
|
|
|
// ThemeRegistry must be wired before the first Draw tick.
|
|
var customThemesDir = Path.Combine(Interface.ConfigDirectory.FullName, "themes");
|
|
Directory.CreateDirectory(customThemesDir);
|
|
SeedExampleThemeIfEmpty(customThemesDir);
|
|
ThemeRegistry = new Themes.ThemeRegistry(customThemesDir);
|
|
// Warm up the custom-theme cache before the first Switch.
|
|
// LoadCustomBySlug is a reverse-lookup over _customCache; on a
|
|
// cold cache a Config.Theme that points at a custom slug would
|
|
// fall through to the built-in default. AllCustom is a lazy
|
|
// enumerable, so iterate it explicitly to materialise the cache.
|
|
foreach (var _ in ThemeRegistry.AllCustom()) { }
|
|
ThemeRegistry.Switch(Config.Theme);
|
|
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
// Service allocations — order encodes dependencies.
|
|
// HonorificService registers IPC subscribers early to catch
|
|
// Ready/Disposing events from the first frame.
|
|
FileDialogManager = new FileDialogManager();
|
|
Commands = new Commands();
|
|
Functions = new GameFunctions.GameFunctions(this);
|
|
Ipc = new IpcManager();
|
|
TypingIpc = new TypingIpc(this);
|
|
ExtraChat = new ExtraChat();
|
|
HonorificService = new Integrations.HonorificService(Interface, Log, Framework);
|
|
StatusBar = new Ui.StatusBar();
|
|
MessageManager = new MessageManager(this);
|
|
|
|
AutoTellTabsService = new AutoTellTabsService(
|
|
this,
|
|
MessageManager,
|
|
MessageManager.Store
|
|
);
|
|
AutoTellTabsService.Initialize();
|
|
|
|
SelfTestRegistry.RegisterTestSteps([new SelfTests.ThemeSwitchSelfTestStep(this)]);
|
|
|
|
ChatLogWindow = new ChatLogWindow(this);
|
|
SettingsWindow = new SettingsWindow(this);
|
|
DbViewer = new DbViewer(this);
|
|
InputPreview = new InputPreview(ChatLogWindow);
|
|
CommandHelpWindow = new CommandHelpWindow(ChatLogWindow);
|
|
SeStringDebugger = new SeStringDebugger(this);
|
|
DebuggerWindow = new DebuggerWindow(this);
|
|
FirstRunWizard = new FirstRunWizard(this);
|
|
|
|
WindowSystem.AddWindow(ChatLogWindow);
|
|
WindowSystem.AddWindow(SettingsWindow);
|
|
WindowSystem.AddWindow(DbViewer);
|
|
WindowSystem.AddWindow(InputPreview);
|
|
WindowSystem.AddWindow(CommandHelpWindow);
|
|
WindowSystem.AddWindow(SeStringDebugger);
|
|
WindowSystem.AddWindow(DebuggerWindow);
|
|
WindowSystem.AddWindow(FirstRunWizard);
|
|
|
|
if (!Config.FirstRunCompleted)
|
|
FirstRunWizard.IsOpen = true;
|
|
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
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();
|
|
|
|
if (Interface.Reason is not PluginLoadReason.Boot)
|
|
MessageManager.FilterAllTabsAsync();
|
|
|
|
// 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;
|
|
Interface.UiBuilder.OpenMainUi += OpenMainUi;
|
|
}
|
|
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;
|
|
|
|
Exception? failure = null;
|
|
|
|
// Unsubscribe hooks first — mirrors the hooks-last subscribe order in LoadAsync.
|
|
failure = CaptureFailure(failure, () => Interface.UiBuilder.OpenMainUi -= OpenMainUi);
|
|
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;
|
|
}
|
|
}
|
|
);
|
|
|
|
// Unsubscribe AutoTellTabs before MessageManager goes away.
|
|
failure = CaptureFailure(failure, () => AutoTellTabsService?.Dispose());
|
|
|
|
// MessageManager has its own async dispose path (DB flush, thread shutdown).
|
|
if (MessageManager is not null)
|
|
{
|
|
failure = await CaptureFailureAsync(
|
|
failure,
|
|
() => MessageManager.DisposeAsync().AsTask()
|
|
)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
// Game-function / IPC / window cleanup must run on the framework thread.
|
|
try
|
|
{
|
|
await Framework
|
|
.RunOnFrameworkThread(() =>
|
|
{
|
|
failure = CaptureFailure(
|
|
failure,
|
|
() => GameFunctions.GameFunctions.SetChatInteractable(true)
|
|
);
|
|
|
|
// IPC subscribers before windows — prevents a final IPC event
|
|
// from reaching a half-torn ChatLogWindow.
|
|
failure = CaptureFailure(failure, () => HonorificService?.Dispose());
|
|
failure = CaptureFailure(failure, () => TypingIpc?.Dispose());
|
|
failure = CaptureFailure(failure, () => ExtraChat?.Dispose());
|
|
failure = CaptureFailure(failure, () => Ipc?.Dispose());
|
|
|
|
failure = CaptureFailure(failure, () => WindowSystem?.RemoveAllWindows());
|
|
failure = CaptureFailure(failure, () => ChatLogWindow?.Dispose());
|
|
failure = CaptureFailure(failure, () => DbViewer?.Dispose());
|
|
failure = CaptureFailure(failure, () => InputPreview?.Dispose());
|
|
failure = CaptureFailure(failure, () => SettingsWindow?.Dispose());
|
|
failure = CaptureFailure(failure, () => DebuggerWindow?.Dispose());
|
|
failure = CaptureFailure(failure, () => SeStringDebugger?.Dispose());
|
|
})
|
|
.ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
failure ??= ex;
|
|
}
|
|
|
|
// Pure-memory cleanups — no Framework / UI / IPC touch.
|
|
failure = CaptureFailure(failure, () => Functions?.Dispose());
|
|
failure = CaptureFailure(failure, () => Commands?.Dispose());
|
|
failure = CaptureFailure(failure, () => EmoteCache.Dispose());
|
|
// Static input history would otherwise survive the plugin reload.
|
|
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),
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
private void OpenMainUi()
|
|
{
|
|
SettingsWindow.IsOpen = !SettingsWindow.IsOpen;
|
|
}
|
|
|
|
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.");
|
|
// Run clear+refilter on the framework thread — FilterAllTabsAsync
|
|
// is fire-and-forget and would race the next sweep cycle.
|
|
Framework
|
|
.Run(() =>
|
|
{
|
|
MessageManager.ClearAllTabs();
|
|
MessageManager.FilterAllTabs();
|
|
})
|
|
.Wait();
|
|
}
|
|
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,
|
|
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];
|
|
|
|
using ((Config.FontsEnabled ? 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();
|
|
}
|
|
}
|
|
}
|