Files
Thing/src-tauri/src/osd_window.rs
T

441 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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(())
}