Tauri 点击穿透与鼠标事件转发
前言
对于需要窗口透明的应用,通常要求透明的区域点击穿透,非透明的区域则可以使用鼠标进行交互。
Tauri 和 Electron 则为开发着提供了忽略鼠标事件的方法。当鼠标消息无法发送给目标进程,那么就会得到点击穿透的效果。
但无法选择忽略的鼠标消息类型,因此开发者无法再通过鼠标事件去启停忽略鼠标消息。意味着只能启用,不能停用。无法实现更复杂的交互。
而 Electron 在此基础上增加了鼠标消息转发。拦截全局鼠标消息,然后将鼠标的移动位置转发给目标进程。开发者则可以通过鼠标移动产生的事件(mousemove/mouseenter/mouseleave),启停忽略鼠标消息。
本文将借鉴 Electron 中 Windows 系统的实现,并将其移植到 Tauri 中。
代码
完整的代码示例:aweikalee/tauri-click-through-demo。
主要参考的 Electron 代码:electron/native_window_views_win.cc 中的 SetForwardMouseMessages 方法。
移植
前期准备
先安装依赖:
1 | cargo add windows -F Win32_Foundation -F Win32_UI_WindowsAndMessaging -F Win32_Graphics_Gdi |
Windows 中, 使用 Win32 API 管理进程,需要引入依赖 windows.rs 或 winapi。前者微软官方维护,后者社区维护。Tauri 使用 windows.rs,故本文也将使用 windows.rs。
once_cell 等用到了再说。
接着我们需要创建一个函数代替 Tauri 原先的 setIgnoreCursorEvents。并暴露给前端。
1 | // 将会用到的依赖,后文将不再提及 |
忽略鼠标消息
Tauri 已经实现了这一步,可以直接调用 set_ignore_cursor_events:
1 |
|
set_ignore_cursor_events 的实现大致如下,不想了解的可以跳到下一节。
下面的代码是 Electron 的移植版。
1 | fn set_ignore_cursor_events(window: tauri::Window, label: &str, ignore: bool, forward: bool) { |
添加和移除样式是通过位运算进行的,WS_EX_TRANSPARENT 与 WS_EX_LAYERED 的值分别是 32、524288。将其转换位二进制应该就很好理解了:32=100000、524288=10000000000000000000。若还不理解请补一补位运算。
通常 WS_EX_TRANSPARENT 或 WS_EX_LAYERED 都是整数常量,但在 windows.rs 中它们是元组:WS_EX_TRANSPARENT=WINDOW_EX_STYLE(32)。使用时需要频繁转换类型。当然也可以选择取出整数进行使用,比如:WS_EX_TRANSPARENT.0 。
WS_EX_TRANSPARENT 样式会使鼠标事件透明(即忽略所有鼠标事件),WS_EX_LAYERED 样式会使窗口视觉上透明。
设置鼠标消息钩子
当前进程无法接受任何鼠标消息,需要通过全局鼠标钩子获取鼠标位置,然后将鼠标位置转发给目标进程。
声明全局变量
1 | static mut MOUSE_HOOK_: Option<HHOOK> = None; |
需要用到两个全局变量:
MOUSE_HOOK_储存全局鼠标钩子的句柄,用于卸载钩子。FORWARDING_WINDOWS_储存需要转发鼠标消息的进程句柄。
FORWARDING_WINDOWS_ 是 HashSet,因大小不确定,不能直接创建为全局变量。故引入 once_cell,通过 once_cell::Lazy 进行创建。
管理进程句柄
1 | unsafe fn set_forward_mouse_messages(hwnd: HWND, forward: bool) { |
set_forward_mouse_messages 中存在大量不安全操作,所以函数前直接添加了 unsafe 的声明。
Electron 中 FORWARDING_WINDOWS_ 储存的是 NativeWindowViews 的实例。此处实现取而代之的是直接储存进程句柄。
由于 HWND 并未实现 Hash 方法,无法确定唯一性,故直接从元组里取出原始值进行操作。
修正句柄

由于 Tauri 进程内部比 Electron 多了两层进程,将消息发送给最外层的主进程,浏览器进程不会收到消息。
于是用了点脏方法,在主进程上获取第一次子进程句柄,再获取子进程句柄的第一个子进程句柄。
1 | unsafe fn set_forward_mouse_messages(hwnd: HWND, forward: bool) { |
GetWindow(hwnd, GW_CHILD) 获取的是第一个子进程的句柄。
设置与卸载钩子
1 | unsafe fn set_forward_mouse_messages(hwnd: HWND, forward: bool) { |
全局钩子只需设置一次,卸载时则需要等所有进程都被卸载后再卸载全局钩子。
WH_MOUSE_LL 表示低级鼠标钩子,或者说是全局鼠标钩子。mousemove_forward 为处理函数。因挂载的是全局钩子,故 SetWindowsHookExW 后两个参数用不到。
SetWindowsHookExA 与 SetWindowsHookExW
SetWindowsHookEx 存在两个版本的函数:SetWindowsHookExA 与 SetWindowsHookExW。A 代表 _ANSI_,W 代表 _UNICODE_。通常会有以下这段宏,根据编译环境,将 SetWindowsHookEx 定义为 SetWindowsHookExA 或 SetWindowsHookExW:
1 |
由于 Tauri 中使用的都为 UNICODE 版本,故直接使用 SetWindowsHookExW。Win32 中很多函数都存在这样的两个版本,之后将都会使用 UNICODE 版本。上文出现过的 GetWindowLongW 和 SetWindowLongW 同理。
处理鼠标消息
接着实现 mousemove_forward 函数。将捕获到的 WM_MOUSEMOVE 事件,转换坐标后转发给目标进程。
1 | unsafe extern "system" fn mousemove_forward( |
n_code为确定如何处理消息的代码。微软文档中说:_如果 nCode 小于零,则挂钩过程必须将消息传递给 CallNextHookEx 函数,而无需进一步处理,并且应返回 CallNextHookEx 返回的值_。不过我实际获取到的n_code的值只有0。w_param为鼠标消息标识,用于区分消息类型。我们只对WM_MOUSEMOVE消息进行处理。l_param是指向MSLLHOOKSTRUCT结构体的指针的值。MSLLHOOKSTRUCT上有我们需要的鼠标坐标信息。
获取坐标信息
l_param 是个整数,内存指针的值,需要通过强转类型转为原始指针使用:
1 | let p = l_param.0 as *const MSLLHOOKSTRUCT; // 强转为 MSLLHOOKSTRUCT 的原始指针 |
获取窗口坐标信息
1 | let mut client_rect = RECT { |
GetClientRect可以获得进程窗口的位置信息。left/top始终为0,right/bottom等同于窗口宽高。
转换坐标
1 | let mut p = p.clone(); |
先前获得到鼠标坐标是相对于屏幕的,后面需要用到相对于目标窗口的坐标,使用 ScreenToClient 函数进行转换。
转发消息
1 | if PtInRect(&client_rect, p).as_bool() { |
PtInRect函数用于判断坐标点是否位于预期范围内。鼠标落在窗口之外时,则不必转发消息。w表示虚拟键是否已按下,如鼠标左键/右键等。l为鼠标坐标。l是一个32位整数,后16位储存x坐标,前16位储存y坐标。在 C++ 中可以使用MAKELPARAM宏来创建,但 Rust 中并没有这个宏,需要自行实现,如下。
1 | macro_rules! MAKELPARAM { |
最后使用 PostMessageW 将消息转发给目标进程。
调用下一个钩子
1 | CallNextHookEx(None, n_code, w_param, l_param) |
调用 CallNextHookEx 会调用下一个钩子,如果没有下一个钩子,则使用系统默认处理。此处若不调用(即为拦截),你的鼠标将不能工作。
一些问题
至此功能已实现,但实际跑起来还有点小问题。
拖动窗口干扰

当拖动 Webview2 创建的窗口,经过设置了转发鼠标消息的区域时,被拖动的窗口会在两个位置之间反复横跳。一个是鼠标当前位置,另一个是转发给进程的鼠标消息的坐标相对于屏幕的位置。
复现的方式:打开调试工具,拖动调试工具并经过主窗口。主窗口的位置不在屏幕 0, 0 的位置。
Electron 中也存在相同的问题,只是因为一些其他机制,使得这个问题不那么明显。
异步改同步
了解到 PostMessage 是异步的,我就尝试着将它换成了同步版的 SendMessage,有效地解决了问题。
使产生的干扰在在鼠标真实移动前产生,再被真实的移动覆盖,在重新渲染时使得鼠标能在正确的位置。
该方法虽然并没有解决干扰的问题,但避免了窗口反复横跳的现象。
非必要不使用
非必要不使用 set_ignore_cursor_events,在特定场景主动停用,减少副作用的影响。Electron 中有类似的实现,但并非为了解决该问题而实现的。
场景一:窗口被完全覆盖。当窗口被完全覆盖时,没有点击穿透的必要可以停用。待窗口没被完全覆盖时再恢复。
场景二:无边透明窗口,使用 mousemove 事件控制启停时。切换到其他窗口/失焦时,可以停用。待鼠标再次经过窗口触发 mousemove 事件,再选择性启用。
Hover 闪烁

鼠标消息转发给 WebView 后,移到具有 Hover 样式的 DOM 上,会出现闪烁,样式在普通状态与 Hover 状态之间高频切换。每次转发消息时都会顺带产生 mouseenter 与 mouseleave 事件。
Electron 中是通过 SetWindowSubclass(设置窗口子类),拦截 WM_MOUSELEAVE 事件来解决该问题。但在特定条件下还是能复现:
1 | win.setIgnoreMouseEvents(true, { forward: true }) |
在 loadFile 前调用 setIgnoreMouseEvents,依旧存在闪烁问题。不过一般也不会这么用,所以问题不大。
但是 Tauri 的进程结构不同,获取到的事件也不一样,根本不存在 WM_MOUSELEAVE 事件。没法借鉴该解决方案。
病急乱投医,我摸索出了一个看起来不那么正确的方法:
1 | if PtInRect(&client_rect, p).as_bool() { |
我也很难解释原因,虽然违背了这个参数本身的意义,但确实有效,还没有副作用。