diff --git a/src/native_interop.rs b/src/native_interop.rs index c745d08..c35ec77 100644 --- a/src/native_interop.rs +++ b/src/native_interop.rs @@ -1,5 +1,8 @@ use windows::core::PCWSTR; use windows::Win32::Foundation::{BOOL, HWND, LPARAM, RECT}; +use windows::Win32::Graphics::Gdi::{ + GetMonitorInfoW, MonitorFromWindow, MONITORINFOEXW, MONITOR_DEFAULTTONEAREST, +}; use windows::Win32::UI::Accessibility::{SetWinEventHook, UnhookWinEvent, HWINEVENTHOOK}; use windows::Win32::UI::Shell::{SHAppBarMessage, ABM_GETTASKBARPOS, APPBARDATA}; use windows::Win32::UI::WindowsAndMessaging::*; @@ -24,10 +27,33 @@ pub const WM_APP: u32 = 0x8000; pub const WM_APP_USAGE_UPDATED: u32 = WM_APP + 1; pub const WM_APP_TRAY: u32 = WM_APP + 3; -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] pub struct TaskbarWindow { pub hwnd: HWND, pub rect: RECT, + /// Stable monitor identity (e.g. "\\\\.\\DISPLAY1"). Used to keep the widget + /// anchored to the same physical monitor across topology changes, where the + /// geometric ordering (and therefore the index) of taskbars can shift. + pub device: String, +} + +/// Stable identifier of the monitor a window currently lives on. +pub fn monitor_device_name(hwnd: HWND) -> String { + unsafe { + let monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST); + let mut info = MONITORINFOEXW::default(); + info.monitorInfo.cbSize = std::mem::size_of::() as u32; + if GetMonitorInfoW(monitor, &mut info as *mut _ as *mut _).as_bool() { + let len = info + .szDevice + .iter() + .position(|&c| c == 0) + .unwrap_or(info.szDevice.len()); + String::from_utf16_lossy(&info.szDevice[..len]) + } else { + String::new() + } + } } pub fn find_taskbars() -> Vec { @@ -39,7 +65,8 @@ pub fn find_taskbars() -> Vec { let class_name = String::from_utf16_lossy(&class_name[..len as usize]); if class_name == "Shell_TrayWnd" || class_name == "Shell_SecondaryTrayWnd" { if let Some(rect) = get_taskbar_rect(hwnd).or_else(|| get_window_rect_safe(hwnd)) { - taskbars.push(TaskbarWindow { hwnd, rect }); + let device = monitor_device_name(hwnd); + taskbars.push(TaskbarWindow { hwnd, rect, device }); } } } @@ -169,6 +196,10 @@ pub fn get_window_thread_id(hwnd: HWND) -> u32 { unsafe { GetWindowThreadProcessId(hwnd, None) } } +pub fn window_exists(hwnd: HWND) -> bool { + unsafe { IsWindow(hwnd).as_bool() } +} + /// Unhook a WinEvent hook pub fn unhook_win_event(hook: HWINEVENTHOOK) { unsafe { diff --git a/src/window.rs b/src/window.rs index f6d261e..82ca80c 100644 --- a/src/window.rs +++ b/src/window.rs @@ -84,6 +84,7 @@ struct AppState { last_update_check_unix: Option, taskbar_index: usize, + taskbar_device: Option, tray_offset: i32, dragging: bool, drag_start_mouse_x: i32, @@ -140,6 +141,8 @@ const TRAY_ICON_UPDATE_REPOSITION_SUPPRESS_MS: u64 = 750; /// recreates the taskbar and wipes our tray-icon registration). const TASKBAR_WATCH_INTERVAL_SECS: u64 = 2; +const TASKBAR_MISSING_TICKS: u32 = 2; + static SUPPRESS_TRAY_REPOSITION_UNTIL: Mutex> = Mutex::new(None); /// Current system DPI (96 = 100% scaling, 144 = 150%, 192 = 200%, etc.) @@ -232,21 +235,35 @@ fn relaunch_self() { /// dedicated thread (independent of the dead message loop) polls the taskbar /// handle and, when it changes, relaunches the widget as a fresh process. fn spawn_taskbar_watchdog() { - std::thread::spawn(move || loop { - std::thread::sleep(Duration::from_secs(TASKBAR_WATCH_INTERVAL_SECS)); - let stored = { - let state = lock_state(); - state.as_ref().and_then(|s| s.taskbar_hwnd) - }; - // Only relevant once we have embedded into a taskbar at least once. - let Some(old) = stored else { - continue; - }; - let taskbars = native_interop::find_taskbars(); - if !taskbars.is_empty() && !taskbars.iter().any(|taskbar| taskbar.hwnd == old) { + std::thread::spawn(move || { + let mut missing_streak = 0u32; + loop { + std::thread::sleep(Duration::from_secs(TASKBAR_WATCH_INTERVAL_SECS)); + let stored = { + let state = lock_state(); + state.as_ref().and_then(|s| s.taskbar_hwnd) + }; + let Some(old) = stored else { + missing_streak = 0; + continue; + }; + if native_interop::window_exists(old) { + missing_streak = 0; + continue; + } + let taskbars = native_interop::find_taskbars(); + if taskbars.is_empty() || taskbars.iter().any(|taskbar| taskbar.hwnd == old) { + missing_streak = 0; + continue; + } + missing_streak += 1; + if missing_streak < TASKBAR_MISSING_TICKS { + continue; + } + missing_streak = 0; let new = taskbars[0].hwnd; diagnose::log(format!( - "watchdog: taskbar changed old={:?} new={:?} -> relaunching", + "watchdog: taskbar destroyed old={:?} new={:?} -> relaunching", old.0, new.0 )); relaunch_self(); @@ -302,6 +319,8 @@ struct SettingsFile { tray_offset: i32, #[serde(default)] taskbar_index: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + taskbar_device: Option, #[serde(default = "default_poll_interval")] poll_interval_ms: u32, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -323,6 +342,7 @@ impl Default for SettingsFile { Self { tray_offset: 0, taskbar_index: 0, + taskbar_device: None, poll_interval_ms: default_poll_interval(), language: None, last_update_check_unix: None, @@ -382,6 +402,7 @@ fn save_state_settings() { save_settings(&SettingsFile { tray_offset: s.tray_offset, taskbar_index: s.taskbar_index, + taskbar_device: s.taskbar_device.clone(), poll_interval_ms: s.poll_interval_ms, language: s .language_override @@ -494,17 +515,28 @@ fn toggle_widget_visibility(hwnd: HWND) { } } -fn attach_to_taskbar(hwnd: HWND, requested_index: usize) -> bool { +fn attach_to_taskbar( + hwnd: HWND, + requested_index: usize, + requested_device: Option<&str>, +) -> bool { let taskbars = native_interop::find_taskbars(); if taskbars.is_empty() { diagnose::log("taskbar not found; using fallback popup window"); return false; } - let index = requested_index.min(taskbars.len().saturating_sub(1)); - let taskbar = taskbars[index]; + // Prefer the taskbar on the same physical monitor we were last anchored to. + // The geometric index is unstable across monitor connect/disconnect/reorder, + // so matching by device name keeps the widget on the user's chosen monitor. + let index = requested_device + .filter(|d| !d.is_empty()) + .and_then(|device| taskbars.iter().position(|t| t.device == device)) + .unwrap_or_else(|| requested_index.min(taskbars.len().saturating_sub(1))); + let taskbar = taskbars[index].clone(); diagnose::log(format!( - "taskbar selected index={index} count={} hwnd={:?} rect=({}, {}, {}, {})", + "taskbar selected index={index} device={} count={} hwnd={:?} rect=({}, {}, {}, {})", + taskbar.device, taskbars.len(), taskbar.hwnd, taskbar.rect.left, @@ -546,6 +578,7 @@ fn attach_to_taskbar(hwnd: HWND, requested_index: usize) -> bool { s.tray_notify_hwnd = tray_notify; s.win_event_hook = hook; s.taskbar_index = index; + s.taskbar_device = Some(taskbar.device.clone()); s.embedded = true; } true @@ -1321,6 +1354,7 @@ pub fn run() { update_status: UpdateStatus::Idle, last_update_check_unix: settings.last_update_check_unix, taskbar_index: settings.taskbar_index, + taskbar_device: settings.taskbar_device.clone(), tray_offset: settings.tray_offset, dragging: false, drag_start_mouse_x: 0, @@ -1331,7 +1365,7 @@ pub fn run() { } // Try to embed in taskbar - if attach_to_taskbar(hwnd, settings.taskbar_index) { + if attach_to_taskbar(hwnd, settings.taskbar_index, settings.taskbar_device.as_deref()) { embedded = true; } @@ -2487,7 +2521,7 @@ unsafe extern "system" fn wnd_proc( s.tray_offset = new_offset; } } - if attach_to_taskbar(hwnd, target_index) { + if attach_to_taskbar(hwnd, target_index, Some(&target_taskbar.device)) { position_at_taskbar(); render_layered(); }