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 }