diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 602a74bf0..e2d7fb081 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -1168,6 +1168,59 @@ fn set_main_window_hidden(hide: bool) { ); } +/// Look up the main `WebviewWindow`, optionally waiting briefly on Windows +/// for the Tauri runtime to re-track the window after SW_SHOW. +/// +/// Why this exists (OPENHUMAN-TAURI-3A): on Windows the close button routes +/// through [`set_main_window_hidden`] which uses raw-HWND `SW_HIDE`. CEF +/// treats the hidden host as gone and the Tauri runtime drops its +/// `WebviewWindow` record for `"main"` until the next event-loop tick after +/// SW_SHOW restores visibility. A tray "Show window" callback that runs +/// `set_main_window_hidden(false)` and then immediately calls +/// `app.get_webview_window("main")` can race the re-track step and observe +/// `None` even though the OS window is visible — Sentry sees a +/// `[tray] failed to show main window from menu: main window not found` +/// warn even though, from the user's perspective, the window came back. +/// +/// Bounded retry budget: up to 5 lookups with 10 ms between attempts (≤ 50 ms +/// worst case). The tray menu is closed during this window, so the small +/// blocking delay is invisible. After the budget expires the original +/// error path still triggers, preserving the signal if the runtime never +/// re-tracks (which would indicate a real lifecycle bug, not a race). +/// +/// Non-Windows platforms use a single lookup — the close-to-tray flow that +/// produces the race is Windows-specific (the macOS close button routes +/// through `app.hide()` per PR #2049, and Linux/X11 keeps the +/// `WebviewWindow` record across `WM_DELETE_WINDOW` handling). +fn get_main_webview_window_with_retry( + app: &AppHandle, +) -> Option> { + #[cfg(target_os = "windows")] + { + const ATTEMPTS: usize = 5; + const BACKOFF: std::time::Duration = std::time::Duration::from_millis(10); + for attempt in 0..ATTEMPTS { + if let Some(window) = app.get_webview_window("main") { + if attempt > 0 { + log::debug!( + "[show_main_window] runtime re-tracked main window after {} retries", + attempt + ); + } + return Some(window); + } + if attempt + 1 < ATTEMPTS { + std::thread::sleep(BACKOFF); + } + } + None + } + #[cfg(not(target_os = "windows"))] + { + app.get_webview_window("main") + } +} + fn show_main_window(app: &AppHandle) -> Result<(), String> { // On Windows: surface the OS top-level Chrome_WidgetWin_1 frame BEFORE // any Tauri lookups. After our close handler's SW_HIDE the runtime @@ -1176,7 +1229,10 @@ fn show_main_window(app: &AppHandle) -> Result<(), String> { // and the early `?` below would abort before SW_SHOW fires (#1607). // EnumWindows + SW_SHOW operates directly on the OS HWND that // survived independently, and the runtime re-tracks the window once - // it's visible again. + // it's visible again — but re-tracking lands on the next event-loop + // tick, not synchronously with SW_SHOW. `get_main_webview_window_with_retry` + // bounds the wait to ~50 ms total so the tray callback can pick up the + // re-tracked window without re-emitting OPENHUMAN-TAURI-3A. #[cfg(target_os = "windows")] { set_main_window_hidden(false); @@ -1185,8 +1241,7 @@ fn show_main_window(app: &AppHandle) -> Result<(), String> { let _ = webview.set_focus(); } } - let window = app - .get_webview_window("main") + let window = get_main_webview_window_with_retry(app) .ok_or_else(|| "main window not found".to_string())?; window .show()