diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index b980c88..f6a4e2c 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -386,6 +386,7 @@ public sealed class Plugin : IAsyncDalamudPlugin new SelfTests.MainWindowFocusOpacityStep(this), new SelfTests.MainWindowFlagsStep(this), new SelfTests.SenderNameReformatStep(this), + new SelfTests.DisclosureArmStep(this), ]); // Re-surface the wizard for existing users when a major UX diff --git a/HellionChat/SelfTests/DisclosureArmStep.cs b/HellionChat/SelfTests/DisclosureArmStep.cs new file mode 100644 index 0000000..6e2fb9c --- /dev/null +++ b/HellionChat/SelfTests/DisclosureArmStep.cs @@ -0,0 +1,101 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Game.Text; +using Dalamud.Plugin.SelfTest; +using HellionChat._Helpers; + +namespace HellionChat.SelfTests; + +// B2-3: proves the plugin-disclosure arm-and-hold wires the (otherwise verwaist) +// scanner into the REAL send entry InputBar.TrySend. Drives TrySend via the +// arm-test-hook with a PUA glyph in the buffer and NotifyPluginDisclosure on: +// the first send must ARM and HOLD (no send), so PendingMessage stays the probe +// string and the armed flag is set. Arm-case ONLY (seiteneffektfrei): a real +// send fires ChatBox.SendMessageUnsafe (a real in-game chat line), so the +// second-Enter-sends + ASCII-passthrough legs are in-game smoke only, never +// headless. Does NOT call PluginDisclosureScanner.ContainsPrivateUseGlyph in +// isolation (the false-green trap — it has no other production caller). +internal sealed class DisclosureArmStep : ISelfTestStep +{ + private readonly Plugin plugin; + + public DisclosureArmStep(Plugin plugin) + { + this.plugin = plugin; + } + + public string Name => "Hellion Chat - plugin disclosure arm"; + + public SelfTestStepResult RunStep() + { + var input = this.plugin.InputBar; + if (input is null) + { + ImGui.Text("Plugin.InputBar is null"); + return SelfTestStepResult.Fail; + } + + // The SymbolPicker inserts exactly these FFXIV Private-Use-Area glyphs; + // HighQuality is inside PluginDisclosureScanner's PUA range by + // construction (the scanner range IS the SeIconChar range). + var probe = $"test {SeIconChar.HighQuality.ToIconString()} msg"; + + var savedPending = input.PendingMessage; + var savedNotify = Plugin.Config.NotifyPluginDisclosure; + try + { + Plugin.Config.NotifyPluginDisclosure = true; + input.TestResetDisclosureForSelfTest(); + input.TestSetPendingMessageForSelfTest(probe); + + // Precondition guard: refuse to drive the real TrySend unless the + // toggle is on AND the scanner sees the probe glyph. If the scanner + // regressed, this bails with Fail WITHOUT ever calling TrySend, so a + // broken scanner can never leak a real chat line. (The remaining + // risk — TrySend not calling the scanner at all — is the wiring this + // step exists to catch and is covered by the documented residual-leak + // note + the mandatory mid-cycle smoke; see the Step 4.8 warning box.) + if ( + !Plugin.Config.NotifyPluginDisclosure + || !PluginDisclosureScanner.ContainsPrivateUseGlyph(input.PendingMessage) + ) + { + ImGui.Text( + "Disclosure precondition not met (toggle off or probe glyph not in the scanner's PUA range) — refusing to drive TrySend to avoid an unintended real send" + ); + return SelfTestStepResult.Fail; + } + + // First send with a PUA glyph + toggle on must ARM, not send. Pass a + // null Tab — the arm branch returns before any channel/send use. + var armed = input.TestTryArmDisclosureForSelfTest(null); + + if (!armed) + { + ImGui.Text( + "First send did not arm disclosure for a PUA-glyph buffer (scanner not wired into TrySend?)" + ); + return SelfTestStepResult.Fail; + } + + // Buffer must be HELD: TrySend clears _pendingMessage to empty only on + // a real send, so an unchanged probe proves nothing was transmitted. + if (input.PendingMessage != probe) + { + ImGui.Text( + $"Buffer not held on arm: PendingMessage = '{input.PendingMessage}', expected the unchanged probe (a cleared buffer means it actually sent)" + ); + return SelfTestStepResult.Fail; + } + + return SelfTestStepResult.Pass; + } + finally + { + input.TestResetDisclosureForSelfTest(); + input.TestSetPendingMessageForSelfTest(savedPending); + Plugin.Config.NotifyPluginDisclosure = savedNotify; + } + } + + public void CleanUp() { } +} diff --git a/HellionChat/Ui/Components/InputBar.cs b/HellionChat/Ui/Components/InputBar.cs index 61334ca..db55ae5 100644 --- a/HellionChat/Ui/Components/InputBar.cs +++ b/HellionChat/Ui/Components/InputBar.cs @@ -2,8 +2,10 @@ using System.Numerics; using System.Text; using Dalamud.Bindings.ImGui; using Dalamud.Interface; +using Dalamud.Interface.Colors; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; +using HellionChat._Helpers; using HellionChat.Code; using HellionChat.GameFunctions; using HellionChat.Resources; @@ -42,6 +44,12 @@ internal sealed class InputBar private bool _wasInputTextHovered; private bool? _isFocusedOverride; // Test-only; null = honour per-frame Draw() value. + // UI-11 plugin-disclosure arm-and-hold: holds the buffer that armed the + // disclosure warning. null = not armed. Compared by value so an edit + // re-arms and a resend on the identical buffer goes through. 1.5.6 parity + // (ChatInputBar 1d3b429:27). + private string? _disclosureArmedBuffer; + // Auto-translate popup state — lives here because the popup lifecycle is // tightly coupled to the input callback and the pending message buffer. private const string AutoCompleteId = "##hellion-at-complete"; @@ -158,6 +166,21 @@ internal sealed class InputBar ImGui.SameLine(); DrawQuickButtons(); + // UI-11: yellow inline warning while a plugin-only-glyph message is + // armed-and-held (buffer unchanged since it armed). Renders on its own + // line below the input row. 1.5.6 parity (ChatInputBar 1d3b429:93-103). + if ( + Plugin.Config.NotifyPluginDisclosure + && _disclosureArmedBuffer is not null + && _pendingMessage == _disclosureArmedBuffer + ) + { + ImGui.TextColored( + ImGuiColors.DalamudYellow, + HellionStrings.ChatInput_PluginDisclosure_Warning + ); + } + // SymbolPicker popup is rendered last so it can splice its fragment // straight into the pending buffer. var inserted = _symbolPicker.DrawAndConsume(); @@ -334,6 +357,31 @@ internal sealed class InputBar if (string.IsNullOrEmpty(text)) return; + // UI-11: plugin-disclosure arm-and-hold. Arm + scan on the RAW + // _pendingMessage (NOT the trimmed `text`) so the Draw warning gate + // (_pendingMessage == _disclosureArmedBuffer) matches byte-for-byte even + // when the buffer has leading/trailing whitespace. 1.5.6 armed/held/ + // warned on the raw buffer and only trimmed at SendChatBox; storing the + // trimmed value here would silently kill the warning for a padded buffer + // (the Draw gate compares the untrimmed _pendingMessage). Runs BEFORE the + // channel prefix + AutoTranslate.ReplaceWithPayload (the resolved + // macro carries its own non-ASCII bytes and would false-positive; + // whitespace is never a PUA codepoint, so scanning the raw buffer is + // equivalent for detection). First Enter on a buffer with a plugin-only + // PUA glyph arms + HOLDS (returns without sending, buffer kept); a second + // Enter on the same unchanged buffer sends; editing re-checks. 1.5.6 + // parity (ChatInputBar.SubmitCompact 1d3b429:108-118). + if ( + Plugin.Config.NotifyPluginDisclosure + && _disclosureArmedBuffer != _pendingMessage + && PluginDisclosureScanner.ContainsPrivateUseGlyph(_pendingMessage) + ) + { + _disclosureArmedBuffer = _pendingMessage; + return; + } + _disclosureArmedBuffer = null; + // Slash commands route through verbatim — the game's chat parser // handles /tell, /fc, /hellion etc. on its own. Other text gets // the active channel's prefix so the line lands on the channel @@ -417,6 +465,21 @@ internal sealed class InputBar // override and let Draw()'s ImGui.IsItemFocused() result take over again. internal void TestSetFocusedForSelfTest(bool? value) => _isFocusedOverride = value; + // Test-only hook; do not call from production code. Drives the REAL TrySend + // arm path: with NotifyPluginDisclosure on and a PUA glyph in the buffer the + // first call arms and HOLDS (no send). Returns whether the buffer is armed. + // The caller asserts PendingMessage is unchanged (held) so a regressed wiring + // that fell through to ChatBox.SendMessageUnsafe is caught. + internal bool TestTryArmDisclosureForSelfTest(Tab? activeTab) + { + TrySend(activeTab); + return _disclosureArmedBuffer is not null; + } + + // Test-only hook; do not call from production code. Clears the armed buffer + // so a SelfTest leaves no residual arm state. + internal void TestResetDisclosureForSelfTest() => _disclosureArmedBuffer = null; + private void DrawAutoCompletePopup() { if (_autoCompleteInfo == null) diff --git a/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs b/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs index e777039..96e8e01 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs @@ -56,6 +56,16 @@ internal sealed class ChatTab { DrawCommandHelpSideCombo(); } + + if (ImGui.CollapsingHeader("Plugin disclosure")) + { + DrawToggle( + HellionStrings.Settings_Chat_NotifyPluginDisclosure_Name, + () => Plugin.Config.NotifyPluginDisclosure, + v => Plugin.Config.NotifyPluginDisclosure = v + ); + ImGuiUtil.HelpMarker(HellionStrings.Settings_Chat_NotifyPluginDisclosure_Description); + } } private void DrawPrivacyPersistChannels()