host: route graceful closes of helper windows to the app window (Windows)

tao's thread event target and the single-instance message window are visible
1 px unowned top-level windows. taskkill /PID (WM_CLOSE, no /F) closes only the
first such window in z-order; a minimized app window sinks below them, so the
close destroyed a helper and the app kept running. route_helper_closes
subclasses those helpers and forwards WM_CLOSE to the app window.
This commit is contained in:
CRTkafa 2026-09-29 00:27:10 +07:00
parent e07eee0041
commit 0d222a45ac
2 changed files with 72 additions and 1 deletions

View file

@ -185,6 +185,77 @@ pub fn system_menu<R: Runtime>(webview: &Webview<R>, x: f64, y: f64) -> Result<(
Ok(())
}
/// Windows: a graceful close sent to one of the process's helper windows closes the app window
/// instead. Call once from `setup`, on the main thread, after the main window is built. No-op
/// elsewhere.
///
/// tao's thread event target and the single-instance plugin's message window are visible (1 px,
/// layered, transparent) unowned top-level windows. `taskkill /PID` without `/F` posts one
/// `WM_CLOSE` to the first such window in z-order; a minimized window sinks to the bottom, so the
/// close hit a helper, `DefWindowProc` destroyed it (a second instance could no longer find us,
/// or the event loop lost its target) and the app kept running.
pub fn route_helper_closes<R: Runtime>(window: &tauri::Window<R>) {
#[cfg(windows)]
helper_close::install(window);
#[cfg(not(windows))]
let _ = window;
}
#[cfg(windows)]
mod helper_close {
use windows::core::BOOL;
use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, WPARAM};
use windows::Win32::System::Threading::GetCurrentThreadId;
use windows::Win32::UI::Shell::{DefSubclassProc, RemoveWindowSubclass, SetWindowSubclass};
use windows::Win32::UI::WindowsAndMessaging::{
EnumThreadWindows, GetWindow, IsWindow, IsWindowVisible, PostMessageW, GW_OWNER, WM_CLOSE, WM_NCDESTROY,
};
const SUBCLASS_ID: usize = 0x7762_636c; // "wbcl"
pub(super) fn install<R: tauri::Runtime>(window: &tauri::Window<R>) {
let Ok(main) = window.hwnd() else { return };
// SAFETY: enumerates and subclasses windows of the calling (event loop) thread only;
// `main` is passed by value through the LPARAM / ref data.
unsafe {
let _ = EnumThreadWindows(GetCurrentThreadId(), Some(visit), LPARAM(main.0 as isize));
}
}
unsafe extern "system" fn visit(hwnd: HWND, main: LPARAM) -> BOOL {
let main = HWND(main.0 as *mut _);
// Visible, unowned, not the app window: what `taskkill` may pick. IME windows are hidden.
if hwnd != main && IsWindowVisible(hwnd).as_bool() && GetWindow(hwnd, GW_OWNER).is_err() {
let _ = SetWindowSubclass(hwnd, Some(forward), SUBCLASS_ID, main.0 as usize);
}
BOOL(1)
}
unsafe extern "system" fn forward(
hwnd: HWND,
msg: u32,
wparam: WPARAM,
lparam: LPARAM,
id: usize,
main: usize,
) -> LRESULT {
match msg {
WM_CLOSE => {
let main = HWND(main as *mut _);
if IsWindow(Some(main)).as_bool() {
let _ = PostMessageW(Some(main), WM_CLOSE, WPARAM(0), LPARAM(0));
return LRESULT(0);
}
}
WM_NCDESTROY => {
let _ = RemoveWindowSubclass(hwnd, Some(forward), id);
}
_ => {}
}
DefSubclassProc(hwnd, msg, wparam, lparam)
}
}
/// Places a child webview (tab, popover) at a rectangle of its window, in logical px.
///
/// Windows/macOS: the native child bounds. Linux: tauri 2 packs every child webview into the