From 413163276943eef316cda7283e48597f7b7cbad8 Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Thu, 22 Oct 2026 18:40:23 -0300 Subject: [PATCH] Close NeoAstra apps through unowned top-level frames only. Posting WM_CLOSE to every HWND of a NeoAstra process destroys the dispatcher, caption, and WebView after the frame close is canceled, which leaves a window that cannot exit. When any NeoAstra.Window is present, close only the unowned top-level frames. Other GUI apps keep the generic close. --- src/ndx/ProcessStop.cs | 77 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 66 insertions(+), 11 deletions(-) diff --git a/src/ndx/ProcessStop.cs b/src/ndx/ProcessStop.cs index 9a415d1..1ffe404 100644 --- a/src/ndx/ProcessStop.cs +++ b/src/ndx/ProcessStop.cs @@ -1,12 +1,15 @@ using System.Diagnostics; using System.Runtime.InteropServices; using System.Runtime.Versioning; +using System.Text; namespace ndx; /// /// Gracefully stops a child we started: SIGINT / Ctrl+C, then Windows /// WM_CLOSE if the tool is a GUI, then after a timeout. +/// A NeoAstra app is closed through its unowned top-level NeoAstra.Window frames only. +/// Any other GUI keeps the generic close. /// In-process GenerateConsoleCtrlEvent is enough — the parent survives when /// cancels the Ctrl+C. No stopr helper. /// @@ -15,6 +18,8 @@ public static class ProcessStop const uint CtrlCEvent = 0; const uint WmClose = 0x0010; const uint GaRoot = 2; + const uint GwOwner = 4; + const string NeoAstraWindowClass = "NeoAstra.Window"; /// /// Asks to exit. Does not wait. @@ -82,6 +87,15 @@ static bool TryCloseGui(Process process) if (process.HasExited) return true; + var windows = CollectWindows((uint)process.Id); + if (TrySelectNeoAstraFrames(windows, out var frames)) + { + foreach (var hWnd in frames) + PostMessage(hWnd, WmClose, nint.Zero, nint.Zero); + + return true; + } + var posted = false; if (process.MainWindowHandle != nint.Zero && process.CloseMainWindow()) { @@ -91,29 +105,64 @@ static bool TryCloseGui(Process process) return true; } - var state = new GuiCloseState((uint)process.Id); + if (process.MainWindowHandle != nint.Zero) + windows.Add(process.MainWindowHandle); + + foreach (var hWnd in windows) + { + if (PostMessage(hWnd, WmClose, nint.Zero, nint.Zero)) + posted = true; + } + + return posted; + } + + [SupportedOSPlatform("windows")] + static HashSet CollectWindows(uint pid) + { + var state = new GuiCloseState(pid); var handle = GCHandle.Alloc(state); try { - if (process.MainWindowHandle != nint.Zero) - state.Windows.Add(process.MainWindowHandle); - var statePtr = GCHandle.ToIntPtr(handle); EnumWindows(EnumWindowsByPid, statePtr); EnumWindows(EnumHostedParent, statePtr); - - foreach (var hWnd in state.Windows) - { - if (PostMessage(hWnd, WmClose, nint.Zero, nint.Zero)) - posted = true; - } + return state.Windows; } finally { handle.Free(); } + } - return posted; + // NeoAstra turns WM_CLOSE on an unowned top-level NeoAstra.Window into a cancelable user close. + // An owned window of that class is a dialog. The same message on its dispatcher, caption, or + // WebView child destroys that window instead. Filtering applies only once such a frame is + // present; every other GUI is closed as before. + [SupportedOSPlatform("windows")] + static bool TrySelectNeoAstraFrames(HashSet windows, out List frames) + { + frames = []; + var sawNeoAstraWindow = false; + foreach (var hWnd in windows) + { + if (!HasClass(hWnd, NeoAstraWindowClass)) + continue; + + sawNeoAstraWindow = true; + if (GetAncestor(hWnd, GaRoot) == hWnd && GetWindow(hWnd, GwOwner) == nint.Zero) + frames.Add(hWnd); + } + + return sawNeoAstraWindow; + } + + [SupportedOSPlatform("windows")] + static bool HasClass(nint hWnd, string className) + { + var name = new StringBuilder(256); + return GetClassName(hWnd, name, name.Capacity) > 0 + && string.Equals(name.ToString(), className, StringComparison.Ordinal); } [SupportedOSPlatform("windows")] @@ -173,6 +222,12 @@ sealed class GuiCloseState(uint targetPid) [DllImport("user32.dll")] private static extern nint GetAncestor(nint hwnd, uint gaFlags); + + [DllImport("user32.dll")] + private static extern nint GetWindow(nint hWnd, uint uCmd); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetClassName(nint hWnd, StringBuilder lpClassName, int nMaxCount); #endif }