diff --git a/HellionChat/Integrations/HonorificStatus.cs b/HellionChat/Integrations/HonorificStatus.cs new file mode 100644 index 0000000..b50d250 --- /dev/null +++ b/HellionChat/Integrations/HonorificStatus.cs @@ -0,0 +1,29 @@ +namespace HellionChat.Integrations; + +internal enum HonorificStatusKind +{ + NotInstalled, + Incompatible, + Detected, +} + +internal static class HonorificStatus +{ + // Mirrors the 1.5.6 three-state discriminator (1d3b429:About.cs:171/183/196): + // it keys on IsAvailable + the *nullability* of DetectedApiVersion, never a + // recomputed major check. IsAvailable already encodes the compatibility + // result HonorificService set during the initial pull. Null-safe: an + // (isAvailable=true, detectedApiVersion=null) state a test seam can produce + // resolves to NotInstalled rather than dereferencing null. + internal static HonorificStatusKind Resolve( + bool isAvailable, + (uint Major, uint Minor)? detectedApiVersion + ) + { + if (isAvailable && detectedApiVersion is not null) + return HonorificStatusKind.Detected; + if (detectedApiVersion is not null) + return HonorificStatusKind.Incompatible; + return HonorificStatusKind.NotInstalled; + } +} diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index b585a2c..3c0a7f1 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -196,7 +196,10 @@ internal static class PluginHostFactory )); services.AddSingleton(sp => new Ui.Components.Settings.Tabs.AboutTab( sp.GetRequiredService(), - sp.GetRequiredService>() + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService() )); services.AddSingleton(sp => new Ui.Components.StatusBar( sp.GetRequiredService(), diff --git a/HellionChat/Ui/Components/Settings/Tabs/AboutTab.cs b/HellionChat/Ui/Components/Settings/Tabs/AboutTab.cs index 2506695..647429a 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/AboutTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/AboutTab.cs @@ -1,30 +1,52 @@ -using System.Diagnostics; using System.Reflection; using Dalamud.Bindings.ImGui; using Dalamud.Interface; using HellionChat.Branding; -using Microsoft.Extensions.Logging; +using HellionChat.Integrations; +using HellionChat.Resources; +using HellionChat.Themes; +using HellionChat.Util; namespace HellionChat.Ui.Components.Settings.Tabs; internal sealed class AboutTab { private readonly FontManager _fonts; - private readonly ILogger _logger; + private readonly Plugin _plugin; + private readonly HonorificService _honorific; + private readonly ThemeRegistry _themes; + private readonly IPlatformUtil _platformUtil; - public AboutTab(FontManager fonts, ILogger logger) + // SelfTest observable — the status key the real render path resolved. + internal string? LastHonorificStatusKey { get; private set; } + + public AboutTab( + FontManager fonts, + Plugin plugin, + HonorificService honorific, + ThemeRegistry themes, + IPlatformUtil platformUtil + ) { _fonts = fonts; - _logger = logger; + _plugin = plugin; + _honorific = honorific; + _themes = themes; + _platformUtil = platformUtil; } public void Draw() { + // Reset the SelfTest observable each frame so a stale value from a prior + // real render can never let the integrations-status SelfTest pass falsely. + LastHonorificStatusKey = null; DrawPluginInfo(); DrawSectionHeader("Brand"); DrawBrand(); DrawSectionHeader("Links"); DrawLinks(); + DrawSectionHeader("Integrations"); + DrawIntegrations(); DrawSectionHeader("Credits"); DrawCredits(); DrawSectionHeader("License"); @@ -74,24 +96,152 @@ internal sealed class AboutTab DrawLinkButton("Custom repo manifest", BrandingLinks.HellionChatCustomRepoManifest); } - // URLs in v1.7.0 are exclusively hardcoded BrandingLinks.* constants — - // Process.Start with UseShellExecute=true is safe under that constraint. - // If a future cycle ever feeds user-supplied URLs here, add an https/http - // allow-list filter via Uri.TryCreate before Process.Start; without it - // UseShellExecute would happily launch file:// or shell-protocol handlers. + private void DrawIntegrations() + { + ImGui.TextWrapped(HellionStrings.Settings_Integrations_Intro); + ImGui.Spacing(); + + ImGui.TextUnformatted(HellionStrings.Settings_Integrations_Honorific_SectionHeader); + DrawHonorificStatus(); + DrawToggle( + HellionStrings.Settings_Integrations_Honorific_Toggle, + () => Plugin.Config.ShowHonorificTitleInHeader, + v => Plugin.Config.ShowHonorificTitleInHeader = v + ); + ImGui.TextDisabled(HellionStrings.Settings_Integrations_Honorific_ToggleHint); + DrawLinkButton( + HellionStrings.Settings_Integrations_Honorific_LinkRepo, + IntegrationLinks.HonorificRepo + ); + DrawLinkButton( + HellionStrings.Settings_Integrations_Honorific_LinkAuthor, + IntegrationLinks.HonorificAuthor + ); + + DrawComingSoon(); + DrawGotAnIdea(); + } + + private void DrawHonorificStatus() + { + var kind = HonorificStatus.Resolve(_honorific.IsAvailable, _honorific.DetectedApiVersion); + LastHonorificStatusKey = kind.ToString(); + var colors = _themes.Active.Colors; + + // Null-safety via the `is { } v` pattern, never `.Value` raw (spec SEC-2): + // the version is bound only on the arms that have it; the impossible + // Detected/Incompatible-without-version state falls through to default. + switch (kind) + { + case HonorificStatusKind.Detected when _honorific.DetectedApiVersion is { } v: + DrawStatusGlyph('●', colors.StatusSuccess); + ImGui.SameLine(); + ImGui.TextUnformatted( + string.Format( + HellionStrings.Settings_Integrations_Honorific_Status_Detected, + v.Major, + v.Minor + ) + ); + break; + case HonorificStatusKind.Incompatible when _honorific.DetectedApiVersion is { } iv: + DrawStatusGlyph('⚠', colors.StatusWarning); + ImGui.SameLine(); + ImGui.TextUnformatted( + string.Format( + HellionStrings.Settings_Integrations_Honorific_Status_Incompatible, + HonorificService.ExpectedApiMajor, + iv.Major, + iv.Minor + ) + ); + break; + default: + DrawStatusGlyph('○', colors.TextMuted); + ImGui.SameLine(); + ImGui.TextUnformatted( + HellionStrings.Settings_Integrations_Honorific_Status_NotInstalled + ); + break; + } + } + + private static void DrawStatusGlyph(char glyph, uint rgba) + { + ImGui.PushStyleColor(ImGuiCol.Text, ColourUtil.RgbaToAbgr(rgba)); + ImGui.TextUnformatted(glyph.ToString()); + ImGui.PopStyleColor(); + } + + private void DrawComingSoon() + { + ImGui.Spacing(); + ImGui.TextUnformatted(HellionStrings.Settings_Integrations_ComingSoon_SectionHeader); + ImGui.TextDisabled(HellionStrings.Settings_Integrations_ComingSoon_Intro); + DrawComingSoonItem( + HellionStrings.Settings_Integrations_ComingSoon_ContextMenu_Title, + HellionStrings.Settings_Integrations_ComingSoon_ContextMenu_Description + ); + DrawComingSoonItem( + HellionStrings.Settings_Integrations_ComingSoon_Notifications_Title, + HellionStrings.Settings_Integrations_ComingSoon_Notifications_Description + ); + DrawComingSoonItem( + HellionStrings.Settings_Integrations_ComingSoon_RPStatus_Title, + HellionStrings.Settings_Integrations_ComingSoon_RPStatus_Description + ); + DrawComingSoonItem( + HellionStrings.Settings_Integrations_ComingSoon_ExtraChat_Title, + HellionStrings.Settings_Integrations_ComingSoon_ExtraChat_Description + ); + DrawComingSoonItem( + HellionStrings.Settings_Integrations_ComingSoon_QuickDM_Title, + HellionStrings.Settings_Integrations_ComingSoon_QuickDM_Description + ); + } + + private void DrawComingSoonItem(string title, string description) + { + using (_fonts.FontAwesome.Push()) + { + ImGui.TextDisabled(FontAwesomeIcon.Hourglass.ToIconString()); + } + ImGui.SameLine(); + ImGui.TextUnformatted(title); + ImGui.TextDisabled(description); + } + + private void DrawGotAnIdea() + { + ImGui.Spacing(); + ImGui.TextUnformatted(HellionStrings.Settings_Integrations_GotAnIdea_SectionHeader); + ImGui.TextWrapped(HellionStrings.Settings_Integrations_GotAnIdea_Body); + if (ImGui.Button(HellionStrings.Settings_Integrations_GotAnIdea_LinkLabel)) + { + _platformUtil.OpenLink(BrandingLinks.HellionForgeDiscordInvite); + } + } + + private void DrawToggle(string label, Func get, Action set) + { + var current = get(); + if (ImGui.Checkbox(label, ref current)) + { + set(current); + _plugin.SaveConfig(); + } + } + + // URLs are exclusively hardcoded BrandingLinks/IntegrationLinks constants, + // validated to http/https at module-init. OpenLink centralises the browser + // open on an off-draw thread (it internally uses the same ShellExecute, so + // this is a consistency cleanup, not a security change). The standalone Copy + // button stays as the clipboard path. private void DrawLinkButton(string label, string url) { if (ImGui.Button(label)) { - try - { - Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Could not open {Url}, copying to clipboard instead", url); - ImGui.SetClipboardText(url); - } + _platformUtil.OpenLink(url); } ImGui.SameLine(); if (ImGui.SmallButton($"Copy##{url}"))