441 lines
15 KiB
Rust
441 lines
15 KiB
Rust
//! 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<Arc<AtomicBool>> = OnceLock::new();
|
||
/// 任务栏覆盖监视线程停止标志
|
||
static TOPMOST_STOP: OnceLock<Arc<AtomicBool>> = OnceLock::new();
|
||
/// 监视线程句柄(用于停止时 join,避免 sleep 猜测式等待 + 线程泄漏)
|
||
static DRAG_HANDLE: Mutex<Option<JoinHandle<()>>> = Mutex::new(None);
|
||
static TOPMOST_HANDLE: Mutex<Option<JoinHandle<()>>> = Mutex::new(None);
|
||
|
||
fn drag_stop() -> &'static Arc<AtomicBool> {
|
||
DRAG_STOP.get_or_init(|| Arc::new(AtomicBool::new(true)))
|
||
}
|
||
|
||
fn topmost_stop() -> &'static Arc<AtomicBool> {
|
||
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<Hwnd> {
|
||
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<POINT> {
|
||
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<RECT> {
|
||
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<String> {
|
||
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<Instant> = 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(())
|
||
}
|