//! OSD 悬浮窗 Windows 原生窗口管理 //! //! 提供: //! - NoActivate(不获取焦点):设置 WS_EX_NOACTIVATE 扩展样式 //! - 右键长按拖动:轮询 GetAsyncKeyState 检测右键长按,触发拖动事件 //! - 任务栏覆盖检测:轮询 GetForegroundWindow,检测系统 UI 出现时暂时取消置顶 use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; use tauri::{AppHandle, Emitter}; /// 右键拖动监视线程停止标志(全局,仅一个 OSD 悬浮窗) static DRAG_STOP: OnceLock> = OnceLock::new(); /// 任务栏覆盖监视线程停止标志 static TOPMOST_STOP: OnceLock> = OnceLock::new(); /// 监视线程句柄(用于停止时 join,避免 sleep 猜测式等待 + 线程泄漏) static DRAG_HANDLE: Mutex>> = Mutex::new(None); static TOPMOST_HANDLE: Mutex>> = Mutex::new(None); fn drag_stop() -> &'static Arc { DRAG_STOP.get_or_init(|| Arc::new(AtomicBool::new(true))) } fn topmost_stop() -> &'static Arc { TOPMOST_STOP.get_or_init(|| Arc::new(AtomicBool::new(true))) } /// 停止右键拖动监视线程并等待其退出(标志置位后线程最迟一个轮询周期退出) fn stop_drag_thread() { drag_stop().store(true, Ordering::SeqCst); if let Some(h) = DRAG_HANDLE .lock() .unwrap_or_else(|e| e.into_inner()) .take() { let _ = h.join(); } } /// 停止任务栏覆盖监视线程并等待其退出 fn stop_topmost_thread() { topmost_stop().store(true, Ordering::SeqCst); if let Some(h) = TOPMOST_HANDLE .lock() .unwrap_or_else(|e| e.into_inner()) .take() { let _ = h.join(); } } #[cfg(windows)] mod win_api { use tauri::{AppHandle, Manager}; use windows_sys::Win32::Foundation::{POINT, RECT}; use windows_sys::Win32::UI::Input::KeyboardAndMouse::{GetAsyncKeyState, VK_RBUTTON}; use windows_sys::Win32::UI::WindowsAndMessaging::{ GetClassNameW, GetCursorPos, GetForegroundWindow, GetWindowLongPtrW, GetWindowRect, SendMessageW, SetWindowLongPtrW, SetWindowPos, GWL_EXSTYLE, HTCAPTION, HWND_NOTOPMOST, HWND_TOPMOST, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, SWP_SHOWWINDOW, WM_NCLBUTTONDOWN, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, WS_EX_TRANSPARENT, }; /// windows-sys 的 HWND 类型别名(isize) pub type Hwnd = isize; /// 从 Tauri 窗口标签获取原生 HWND pub fn get_hwnd(label: &str, app: &AppHandle) -> Option { let win = app.get_webview_window(label)?; let hwnd = win.hwnd().ok()?; // Tauri 的 hwnd() 返回 windows::Win32::Foundation::HWND(pub *mut c_void) // 转为 isize 用于 windows-sys 调用 Some(hwnd.0 as isize) } /// 设置窗口扩展样式:WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW pub fn apply_no_activate(hwnd: Hwnd) { unsafe { let ex = GetWindowLongPtrW(hwnd, GWL_EXSTYLE); let new_ex = ex | (WS_EX_NOACTIVATE as isize) | (WS_EX_TOOLWINDOW as isize); SetWindowLongPtrW(hwnd, GWL_EXSTYLE, new_ex); } } /// 设置点击穿透(WS_EX_TRANSPARENT) pub fn set_click_through(hwnd: Hwnd, enabled: bool) { unsafe { let ex = GetWindowLongPtrW(hwnd, GWL_EXSTYLE); let new_ex = if enabled { ex | (WS_EX_TRANSPARENT as isize) } else { ex & !(WS_EX_TRANSPARENT as isize) }; SetWindowLongPtrW(hwnd, GWL_EXSTYLE, new_ex); } } /// 获取鼠标光标位置 pub fn get_cursor_pos() -> Option { let mut pt = POINT { x: 0, y: 0 }; unsafe { if GetCursorPos(&mut pt) != 0 { Some(pt) } else { None } } } /// 获取窗口矩形 pub fn get_window_rect(hwnd: Hwnd) -> Option { let mut rect = RECT { left: 0, top: 0, right: 0, bottom: 0, }; unsafe { if GetWindowRect(hwnd, &mut rect) != 0 { Some(rect) } else { None } } } /// 判断点是否在窗口矩形内 pub fn point_in_rect(pt: POINT, rect: RECT) -> bool { pt.x >= rect.left && pt.x <= rect.right && pt.y >= rect.top && pt.y <= rect.bottom } /// 判断右键是否按下 pub fn is_rbutton_down() -> bool { // GetAsyncKeyState 返回 i16,最高位为 1 表示按下 // 转 u16 后与 0x8000 进行按位与,避免 i16 字面量溢出 unsafe { (GetAsyncKeyState(VK_RBUTTON as i32) as u16 & 0x8000) != 0 } } /// 获取前景窗口 pub fn get_foreground_window() -> Hwnd { unsafe { GetForegroundWindow() } } /// 获取窗口类名 pub fn get_class_name(hwnd: Hwnd) -> Option { let mut buf = [0u16; 256]; unsafe { let len = GetClassNameW(hwnd, buf.as_mut_ptr(), buf.len() as i32); if len > 0 { Some(String::from_utf16_lossy(&buf[..len as usize])) } else { None } } } /// 设置窗口置顶状态 pub fn set_topmost(hwnd: Hwnd, topmost: bool) { unsafe { let insert_after = if topmost { HWND_TOPMOST } else { HWND_NOTOPMOST }; SetWindowPos( hwnd, insert_after, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_SHOWWINDOW, ); } } /// 启动原生拖动:发送 WM_NCLBUTTONDOWN(HTCAPTION) 进入标题栏拖动 /// /// 调用前应已关闭 WS_EX_TRANSPARENT(点击穿透),否则窗口无法接收后续鼠标事件。 /// SendMessageW 会阻塞直到拖动结束,应在独立线程调用。 pub fn begin_drag(hwnd: Hwnd) { unsafe { // 获取当前鼠标位置,编码为 LPARAM(低字 x,高字 y) let pt = get_cursor_pos().unwrap_or(POINT { x: 0, y: 0 }); let lparam = ((pt.y as isize) << 16) | (pt.x as isize & 0xFFFF); SendMessageW(hwnd, WM_NCLBUTTONDOWN, HTCAPTION as usize, lparam); } } /// 原子设置窗口位置与尺寸(物理像素) /// /// 一次 SetWindowPos 调用同时更新 x/y/w/h,避免 setSize + setPosition /// 两次调用之间出现"宽度已变、位置未动"的中间帧(视觉闪烁)。 pub fn set_bounds(hwnd: Hwnd, x: i32, y: i32, w: i32, h: i32) { unsafe { SetWindowPos( hwnd, 0, // 不改 Z 序 x, y, w, h, SWP_NOACTIVATE | SWP_NOZORDER, ); } } /// 判断窗口类名是否为系统 UI(任务栏、开始菜单、通知区域等) pub fn is_system_ui_class(class_name: &str) -> bool { matches!( class_name, "Shell_TrayWnd" // 任务栏 | "Shell_SecondaryTrayWnd" // 副任务栏 | "Windows.UI.Core.CoreWindow" // UWP 系统弹窗(开始菜单、通知中心、日历等) | "XamlExplorerHostIslandWindow" // Win11 系统弹窗容器 | "TopLevelWindowForOverflowSink" // Win11 系统托盘溢出 | "TopLevelWindowForHiddenRegion" // Win11 系统隐藏区域 | "Windows.UI.Shell.ShellFlyoutWindow" // Win11 Shell 弹出 ) } } /// 应用 OSD 悬浮窗的原生样式(NoActivate + ToolWindow) #[tauri::command] pub fn osd_apply_overlay_style(label: String, app: AppHandle) -> Result<(), String> { #[cfg(windows)] { let hwnd = win_api::get_hwnd(&label, &app) .ok_or_else(|| format!("窗口 {} 不存在", label))?; win_api::apply_no_activate(hwnd); crate::logger::log_info("osd", &format!("已应用 NoActivate 样式到窗口 {}", label)); } Ok(()) } /// 启动右键长按拖动监视 /// /// 轮询检测右键按下,持续超过 400ms 且鼠标在 OSD 窗口上方时, /// 发出 `osd-start-drag` 事件通知前端临时关闭点击穿透并开始拖动。 /// 右键释放后发出 `osd-end-drag` 事件。 #[tauri::command] pub fn osd_start_drag_watch(label: String, app: AppHandle) -> Result<(), String> { // 停止旧线程,等待其退出后再启动新线程(避免新旧线程并存) stop_drag_thread(); let stop_flag = drag_stop().clone(); stop_flag.store(false, Ordering::SeqCst); let app_handle = app.clone(); let handle = thread::spawn(move || { let mut rbutton_was_down = false; let mut press_start: Option = None; let mut drag_emitted = false; loop { if stop_flag.load(Ordering::SeqCst) { break; } #[cfg(windows)] { let rbutton_down = win_api::is_rbutton_down(); if rbutton_down && !rbutton_was_down { // 右键刚按下 press_start = Some(Instant::now()); drag_emitted = false; } else if rbutton_down && rbutton_was_down { // 右键持续按住,检测长按 if !drag_emitted { if let Some(start) = press_start { if start.elapsed() >= Duration::from_millis(400) { if let Some(hwnd) = win_api::get_hwnd(&label, &app_handle) { if let (Some(pt), Some(rect)) = (win_api::get_cursor_pos(), win_api::get_window_rect(hwnd)) { if win_api::point_in_rect(pt, rect) { let _ = app_handle.emit(crate::constants::events::OSD_START_DRAG, ()); drag_emitted = true; } } } } } } } else if !rbutton_down && rbutton_was_down { // 右键释放 if drag_emitted { let _ = app_handle.emit(crate::constants::events::OSD_END_DRAG, ()); } press_start = None; drag_emitted = false; } rbutton_was_down = rbutton_down; } thread::sleep(Duration::from_millis(30)); } }); if let Ok(mut guard) = DRAG_HANDLE.lock() { *guard = Some(handle); } Ok(()) } /// 启动任务栏覆盖监视 /// /// 轮询检测前景窗口变化,当系统 UI(任务栏弹窗、开始菜单等)变为前景时, /// 发出 `osd-system-ui-active` 事件通知前端暂时取消置顶; /// 系统 UI 关闭后发出 `osd-system-ui-inactive` 事件恢复置顶。 #[tauri::command] pub fn osd_start_topmost_watch(app: AppHandle) -> Result<(), String> { // 停止旧线程,等待其退出后再启动新线程 stop_topmost_thread(); let stop_flag = topmost_stop().clone(); stop_flag.store(false, Ordering::SeqCst); let app_handle = app.clone(); let handle = thread::spawn(move || { let mut last_foreground: isize = 0; let mut system_ui_active = false; loop { if stop_flag.load(Ordering::SeqCst) { break; } #[cfg(windows)] { let fg = win_api::get_foreground_window(); if fg != last_foreground { last_foreground = fg; if let Some(class) = win_api::get_class_name(fg) { if win_api::is_system_ui_class(&class) { if !system_ui_active { system_ui_active = true; let _ = app_handle.emit(crate::constants::events::OSD_SYSTEM_UI_ACTIVE, ()); } } else if system_ui_active { system_ui_active = false; let _ = app_handle.emit(crate::constants::events::OSD_SYSTEM_UI_INACTIVE, ()); } } else if system_ui_active { system_ui_active = false; let _ = app_handle.emit(crate::constants::events::OSD_SYSTEM_UI_INACTIVE, ()); } } } thread::sleep(Duration::from_millis(150)); } }); if let Ok(mut guard) = TOPMOST_HANDLE.lock() { *guard = Some(handle); } Ok(()) } /// 停止所有 OSD 监视线程 #[tauri::command] pub fn osd_stop_watch() { stop_drag_thread(); stop_topmost_thread(); } /// 设置点击穿透(Rust 侧原生 WS_EX_TRANSPARENT,比 JS setIgnoreCursorEvents 更可靠) #[tauri::command] pub fn osd_set_click_through(label: String, enabled: bool, app: AppHandle) -> Result<(), String> { #[cfg(windows)] { let hwnd = win_api::get_hwnd(&label, &app) .ok_or_else(|| format!("窗口 {} 不存在", label))?; win_api::set_click_through(hwnd, enabled); } Ok(()) } /// 设置窗口置顶状态(Rust 侧原生 SetWindowPos) #[tauri::command] pub fn osd_set_topmost(label: String, topmost: bool, app: AppHandle) -> Result<(), String> { #[cfg(windows)] { let hwnd = win_api::get_hwnd(&label, &app) .ok_or_else(|| format!("窗口 {} 不存在", label))?; win_api::set_topmost(hwnd, topmost); } Ok(()) } /// 原子设置窗口位置与尺寸(物理像素) /// /// 一次调用同时更新位置和尺寸,避免 setSize + setPosition 两次 IPC 之间的 /// 中间帧(宽度已变、位置未动 → 视觉闪烁)。前端传入物理像素坐标。 #[tauri::command] pub fn osd_set_bounds( label: String, x: i32, y: i32, w: i32, h: i32, app: AppHandle, ) -> Result<(), String> { #[cfg(windows)] { let hwnd = win_api::get_hwnd(&label, &app) .ok_or_else(|| format!("窗口 {} 不存在", label))?; win_api::set_bounds(hwnd, x, y, w, h); } Ok(()) } /// 启动原生拖动(右键长按触发) /// /// 同步关闭点击穿透(WS_EX_TRANSPARENT),然后在独立线程中调用 /// `SendMessage(WM_NCLBUTTONDOWN, HTCAPTION)` 进入原生标题栏拖动模式。 /// 相比前端 `startDragging()`,此方式在 WS_EX_NOACTIVATE 窗口上更可靠。 #[tauri::command] pub fn osd_begin_drag(label: String, app: AppHandle) -> Result<(), String> { #[cfg(windows)] { let hwnd = win_api::get_hwnd(&label, &app) .ok_or_else(|| format!("窗口 {} 不存在", label))?; // 先同步关闭穿透,确保窗口能接收后续鼠标事件 win_api::set_click_through(hwnd, false); // SendMessageW 会阻塞直到拖动结束,在独立线程中调用避免阻塞 IPC thread::spawn(move || { win_api::begin_drag(hwnd); }); } Ok(()) }