Skip to content

Route LoadOrderValidator's prompt through Program.Notifier - #1

Merged
TheValiantOne merged 2 commits into
mainfrom
fix/loadordervalidator-messagebox-notifier
Aug 7, 2026
Merged

Route LoadOrderValidator's prompt through Program.Notifier#1
TheValiantOne merged 2 commits into
mainfrom
fix/loadordervalidator-messagebox-notifier

Conversation

@TheValiantOne

@TheValiantOneTheValiantOne commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Summary

  • LoadOrderValidator.PromptToPrioritizeMergedMod called MessageBox.Show directly instead of Program.Notifier.ShowMessage, unlike every other domain call site in the codebase. Its sole caller (ValidateAndFix) is currently only ever invoked from Forms/MainForm.cs, so this was harmless today, but it's a landmine for any future headless (CLI/MCP) load-order validation path — an unmediated WinForms MessageBox.Show with no message pump watching it. This change routes it through Program.Notifier.ShowMessage, matching the pattern used everywhere else (e.g. LoadOrder/CustomLoadOrder.cs's ShowWarningForMalformedFile).
  • IMergeNotifier.ShowMessage gained a trailing optional MessageBoxDefaultButton defaultButton = MessageBoxDefaultButton.Button1 parameter. The original call passed MessageBoxDefaultButton.Button2 (defaulting focus to "No"); dropping it silently would have flipped the Enter-key default from "leave my load order alone" to "rewrite mods.settings" — not behavior-preserving. MainForm.ShowMessage forwards it to the 6-arg MessageBox.Show overload; HeadlessMergeNotifier ignores it (no dialog is ever shown headlessly — see below). Only two types implement IMergeNotifier (MainForm, HeadlessMergeNotifier), both updated; the new parameter is a trailing optional so no existing call site needed changes.
  • The MessageBoxManager.Register()/Cancel = "Ne&ver"/Unregister() wrapping (a SetWindowsHookEx-based hack to relabel the Cancel button "Never") is removed, not preserved, and is a genuine, disclosed regression — not a no-op cleanup. The old hook worked because the old MessageBox.Show call ran directly on the same background thread Register() hooked (no owner window, no marshalling). Program.Notifier.ShowMessageMainForm.ShowMessage marshals the actual MessageBox.Show call onto the UI thread via Invoke whenever called off-thread — which this call always is, since LoadOrderValidator.ValidateAndFix runs inside MainForm's Task.Run. The hook (registered on the calling thread) can no longer see the dialog's window messages once routed through the notifier. Preserving it would require adding custom button-text support to IMergeNotifier, which is out of scope for this fix. User-visible effect: the Cancel button now reads "Cancel" instead of "Never" — clicking it still permanently disables this validation check (unchanged DialogResult semantics), just without a label saying so.
  • ValidateAndFix's Cancel branch is now additionally guarded on Program.Notifier.IsInteractive. HeadlessMergeNotifier's fixed non-destructive default for YesNoCancel is Cancel, which at this specific call site means "Never" → Settings.Set("ValidateCustomLoadOrder", false); Settings.Save(). Without the guard, a future headless caller reaching this code would silently persist a settings change to App.config — exactly the landmine this PR exists to defuse. With the guard, a headless run is a safe no-op instead.

Why

Closes a "safe only by accident" gap flagged for follow-on headless load-order validation work: LoadOrderValidator was the one remaining domain-layer file bypassing the IMergeNotifier abstraction that the rest of the codebase (CustomLoadOrder, FileMerger, AppSettings, Paths, the Tools/* wrappers) already routes through, which is what makes CLI/MCP mode possible for everything else.

Verified

  • dotnet build WitcherScriptMerger.sln succeeds with no new warnings (same 7 pre-existing warnings as main: NU1510, and CA1823 unused-field warnings in unrelated files).
  • dotnet format whitespace WitcherScriptMerger.sln --verify-no-changes passes (exit 0).
  • No GUI automation harness is available in this environment, so this was verified by code inspection rather than an interactive run: confirmed the button set (YesNoCancel), icon (Exclamation), message text, and DialogResult handling in ValidateAndFix are byte-for-byte identical to the original MessageBox.Show call (the "Ne&ver" label change is the one disclosed exception, see above).
  • Confirmed by reading Program.cs that Program.Notifier is reassigned from the default HeadlessMergeNotifier to MainForm immediately after construction, before Application.Run starts the message loop — and confirmed by tracing callers that PromptToPrioritizeMergedMod's only reachable path (MainForm_ShownRefreshMergeInventoryLoadOrderValidator.ValidateAndFix) runs from the Shown event and later user-triggered handlers, never from MainForm's constructor. So this change can't introduce a window where the prompt silently goes to the headless notifier instead of showing a dialog to the interactive user.
  • Grepped the codebase for remaining unmediated MessageBox.Show calls: the only ones left are in Forms/MainForm.cs and Forms/DependencyForm.cs, both GUI-layer code where a direct call is correct (no Program.Notifier indirection needed there).
  • Ran the code-review skill against this branch. It surfaced 7 findings; addressed the 3 in scope (see commit "Address code-review findings..."): simplified enum literals back to unqualified for in-file consistency, strengthened the "Ne&ver" regression comment after review correctly identified it as a real behavior change rather than the inert cleanup an earlier, terser comment could have read as, and documented why HeadlessMergeNotifier accepts but ignores defaultButton. The other 4 findings are out of scope for this unit and are noted below for whoever picks up the relevant follow-on work.

Out of scope, flagged for other units

  • HeadlessMergeNotifier.Write routes MessageBoxIcon.None messages to stdout, which risks corrupting the MCP JSON-RPC stream if reached during an MCP session (pre-existing behavior, not introduced here, but live in a file this PR touches) — belongs to MCP-hardening work.
  • Forms/MainForm.cs's PromptToDeleteForChangedHash (an analogous "permanently disable a check" prompt for ValidateMergeSources) still uses the MessageBoxManager "Ne&ver" relabel and still works there (same-thread, no Program.Notifier involved) — after this PR, the app has one prompt that says "Never" and one that says "Cancel" for structurally similar choices. PromptToDeleteForChangedHash is GUI-layer code outside LoadOrderValidator.cs, so not touched here.

Heads-up for other in-flight units

This PR touches IMergeNotifier.cs, HeadlessMergeNotifier.cs, and MainForm.cs's ShowMessage — the interface a later unit is expected to refactor toward a UI-neutral return type. The added MessageBoxDefaultButton parameter is a trailing optional and can't break any existing caller, but whoever picks up that refactor will hit a textual merge conflict in these three files.

Disclosure

This PR was substantially produced with Claude Code (an AI coding agent), per this repo's AI-assisted-development policy in CONTRIBUTING.md. I've reviewed the diff and can explain any part of it if asked.

Chris Knightand others added 2 commits August 7, 2026 12:48
PromptToPrioritizeMergedMod called MessageBox.Show directly instead of
Program.Notifier.ShowMessage, unlike every other domain call site. Its
sole caller today is invoked only from MainForm.cs, so it was harmless
in practice, but it was a landmine for any future headless (CLI/MCP)
load-order validation path, which would otherwise hit an unmediated
WinForms MessageBox.Show with no message pump watching it.
IMergeNotifier.ShowMessage gained a trailing optional
MessageBoxDefaultButton parameter (default Button1, matching
MessageBox.Show's own default) so the prompt's Button2 (No) default
survives the move - dropping it silently would have flipped the
default action from "leave load order alone" to "rewrite
mods.settings". MainForm.ShowMessage forwards it to the 6-arg
MessageBox.Show overload; HeadlessMergeNotifier ignores it.
The MessageBoxManager relabeling of the Cancel button to "Ne&ver" is
removed rather than preserved: it depends on a SetWindowsHookEx hook
registered on the calling thread, but Program.Notifier.ShowMessage
(MainForm.ShowMessage) marshals the actual MessageBox.Show call onto
the UI thread via Invoke when called off-thread - as this call always
is, via MainForm's Task.Run - so the hook would never see the dialog's
window messages once routed through the notifier. Kept as dead code it
would look functional without being so. The Cancel button now reads
"Cancel" instead of "Never"; the DialogResult value and its handling
in ValidateAndFix are unchanged.
Also guarded ValidateAndFix's Cancel branch on
Program.Notifier.IsInteractive: HeadlessMergeNotifier's fixed
non-destructive default for YesNoCancel is Cancel, which previously
mapped to "Never" here and would have silently persisted
ValidateCustomLoadOrder=false to App.config on any future headless run
that reaches this code path - exactly the landmine this change exists
to defuse.
Verified: Program.Notifier is reassigned to MainForm in Program.cs
before Application.Run, and the only path that reaches
PromptToPrioritizeMergedMod (MainForm_Shown -> RefreshMergeInventory ->
LoadOrderValidator.ValidateAndFix) runs after Shown, never from
MainForm's constructor - so this change doesn't introduce a window
where the prompt silently goes to a HeadlessMergeNotifier instead of
the GUI.
No GUI automation harness is available in this environment; verified
by dotnet build (no new warnings), dotnet format whitespace
--verify-no-changes, and code inspection confirming button set, icon,
message text, and DialogResult handling are unchanged from the
original MessageBox.Show call.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXAuGMLB44T5Zv5o5ZzKah
- LoadOrderValidator.cs: simplify back to unqualified MessageBoxButtons/
MessageBoxIcon/MessageBoxDefaultButton now that the fully-qualified
form (matched literally to the task's example) reads as inconsistent
next to the same file's own unqualified DialogResult usage and the
rest of the codebase's convention wherever `using System.Windows.Forms;`
is already present.
- LoadOrderValidator.cs: expand the comment on the dropped "Ne&ver"
relabel. Review correctly pointed out the old MessageBoxManager hook
genuinely worked before this change (MessageBox.Show ran directly on
the same background thread Register() hooked, no Invoke involved) -
this is a real, disclosed regression in how the Cancel button reads,
not a no-op cleanup, and the comment now says so plainly along with
why it can't be preserved through Program.Notifier without extending
IMergeNotifier with custom button-text support (out of scope here).
- HeadlessMergeNotifier.cs: comment on why defaultButton is accepted
but not consulted when choosing the headless DialogResult, so it
doesn't read as an oversight to a future caller relying on it.
Not addressed here, flagged for other units instead: HeadlessMergeNotifier
.Write already routes MessageBoxIcon.None messages to stdout, which is a
pre-existing MCP stdout-hygiene risk unrelated to this change (belongs
to the MCP-hardening unit); MainForm.cs's PromptToDeleteForChangedHash
has an analogous still-"Ne&ver"-labeled prompt that now reads
inconsistently with this one, but that method is GUI-layer code outside
LoadOrderValidator.cs's scope.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXAuGMLB44T5Zv5o5ZzKah
@TheValiantOne
TheValiantOne merged commit 9ed9777 into mainAug 7, 2026
TheValiantOne pushed a commit that referenced this pull request Aug 10, 2026
…w-up)
A code-review pass surfaced 6 issues; all verified against the real code and fixed:
1. isWsmToolAcquired swallowed every fs.access error, not just ENOENT - a locked file
(AV scan, a running WSM process) would silently look identical to "nothing
installed". Now mirrors toolAcquisition.ts's pathExists exactly: re-throws anything
that isn't ENOENT.
2. notifyConflictsIfChanged committed lastNotifiedSignature before the
sendNotification/dismissNotification call actually succeeded. A failure there would
permanently suppress the real notification for that conflict set, since the next
check would see the same signature and skip. Now wrapped in try/catch, with the
signature only committed on success, and the error swallowed (never thrown) per
onAsync's contract.
3. scanWsmConflicts had no in-flight coalescing, unlike toolAcquisition.ts's
inFlightAcquisitions. Overlapping did-deploy events could run two concurrent WSM
processes and resolve out of order, feeding a stale result to
notifyConflictsIfChanged after a fresher one already landed. Added the same
single-slot coalescing pattern.
4. The post-deploy scan used mcpClient.ts's full 30s-per-request default, but this path
runs inside Vortex's own emitAndAwait('did-deploy', ...) await window - a slow WSM
process would extend Vortex's own reported deployment-completion time. Added a
tighter 15s requestTimeoutMs specific to this call site (mcpClient.ts itself
untouched - this uses its existing public per-call override).
5. checkForConflictsAfterDeploy gated on isWitcher3Active(context.api) - whichever game
is active when the async handler happens to run - rather than the deployed
profile's own game (did-deploy's own profileId argument). A user switching games
between did-deploy firing and this handler's turn coming up could cause a real
Witcher 3 deployment's scan to be silently skipped. Now resolves profileId's own
gameId via selectors.profileById and gates on that instead - time-invariant, so
immune to this race. (Verified against game-witcher3's own validateProfile as
precedent for the general profileId-driven approach, but it is not a verbatim copy:
that function still ultimately keys off the active profile, with an added
same-profile guard - not needed for this extension's narrower job of surfacing
conflicts from a deployment that genuinely happened.)
6. lastNotifiedSignature initialized to undefined instead of '', so the very first
post-deploy check of a session with zero conflicts always called dismissNotification
for an id that was never sent. Now initialized to '', matching
computeConflictSignature([])'s own value.
Also fixes a regression introduced while addressing #1: isWsmToolAcquired's new
non-ENOENT throw must stay inside checkForConflictsAfterDeploy's try/catch, not ahead
of it, or it would reject the onAsync('did-deploy', ...) handler's promise straight
into Vortex's own dispatch.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXAuGMLB44T5Zv5o5ZzKah
@TheValiantOne
TheValiantOne deleted the fix/loadordervalidator-messagebox-notifier branch August 11, 2026 01:37
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@TheValiantOne