查看: 717|回复: 3

Tauri桌宠窗口:鼠标穿透40ms轮询与位置记忆实现

[复制链接]
发表于 9 小时前 | 显示全部楼层 |阅读模式
桌宠这类 Windows 悬浮应用,真正难的不是模型渲染,也不是对话链路,而是窗口本身:透明、鼠标穿透、位置记忆。这三件事在公开资料里写得最少,因为它们不性感,全是边界条件。下面以一套基于 Tauri 的桌面伴侣实现为样本,把窗口层的工程细节拆开讲,重点在可复用的方法:配置怎么组合、穿透为什么只能轮询、位置记忆要处理哪些竞态。

一、透明无边框是四个开关的组合,验证时别信样式位

窗口配置本身不复杂,但四个字段必须同时到位:transparent 为 true、backgroundColor 全零(如 #00000000)、decorations 为 false、shadow 为 false。漏掉任意一个,得到的分别是带白底的怪框、或带系统阴影的悬浮块。此外常用的一组是 visible:false 加 focus:false,让窗口启动即隐藏、不抢焦点,等前端就绪后再由命令显示角色;alwaysOnTop 与 skipTaskbar 负责置顶且不占任务栏,尺寸固定为 320×440 逻辑像素、不可 resize。主窗口同样用 visible:false 预加载,由气泡或托盘显式打开。

配置节选如下:
  1. {
  2. "label": "pet",
  3. "width": 320, "height": 440,
  4. "transparent": true,
  5. "backgroundColor": "#00000000",
  6. "decorations": false,
  7. "shadow": false,
  8. "resizable": false,
  9. "alwaysOnTop": true,
  10. "skipTaskbar": true,
  11. "visible": false,
  12. "focus": false
  13. }
复制代码

这里有一条容易被忽略的验证经验:Tauri 底层的 Tao 在 Windows 上会保留部分 WS_CAPTION 样式位。所以判断「是否真的无边框」,不能靠读窗口样式位推断,而要比较实际客户区矩形与窗口边界是否相等。验证窗口属性时,量边界比读样式更可靠。

二、透明不等于穿透:三层结构加 40ms 矩形轮询

透明只决定你能看见桌面,穿透才决定鼠标消息归谁。这套实现的做法是:前端上报可见矩形,Rust 侧每 40ms 把系统光标的物理坐标换算成窗口内逻辑坐标,落在矩形外时设置原生忽略鼠标事件。

第一层是前端上报。命中元素用 data-pet-hit 标记,当前覆盖场景背景、气泡、角色按钮、错误重开按钮四处;采集用 ResizeObserver 观察容器与全部命中元素,回调里再套 requestAnimationFrame 合帧,随后用 getClientRects().length > 0 过滤掉 display:none,再用 getBoundingClientRect 取矩形,最后 invoke 上报给 Rust。气泡开关、安静/隐藏切换、外观切换、Live2D 失败回退都会重建 observer 重新上报。

第二层是 Rust 侧校验。命中区域有硬上限 16 个;每个矩形要求字段 finite、x/y ≥ 0、w/h > 0、x+w ≤ 1024 且 y+h ≤ 1024(逻辑像素);同时校验 window.label() 必须为 "pet",主窗口不许上报命中区域,越界或超量直接返回「角色交互区域无效」。

第三层是轮询线程。用 std::thread 以 40ms 为周期:窗口不可见时直接 continue,隐藏期间跳过命中查询;正常模式下用 cursor_position()(系统光标物理坐标)减去 outer_position()(窗口原点),先相减、再逐轴除以 scale_factor() 换算为窗口内逻辑坐标,命中任一矩形则可交互,否则原生忽略。几个细节决定了健壮性:
  1. 1) fail-safe 方向:cursor / origin / scale 任一查询失败,默认忽略
  2.    ——宁可全穿透,不可反向锁死光标
  3. 2) set_ignore_cursor_events 只在状态翻转或 force_hit_test 置位时调用
  4.    ——不是每 40ms 打一次原生 API
  5. 3) 按住左键时保持当前命中模式,支持原生拖动
  6.    ——Windows 下用 GetAsyncKeyState(VK_LBUTTON) 读左键物理状态
复制代码

关于拖动锁定,代码注释写得很直白:只读按键状态,让原生拖动在释放前沿用既有命中模式。不加这一手,轮询会在拖动中翻转穿透标志,窗口会从光标下被「抽走」。另一处是安静模式与原生标志的同步:安静状态可能独立改掉原生标志,恢复过渡时必须重刷。

三、为什么必须是轮询,而不是窗口消息或全局钩子

穿透的目的就是让鼠标消息流过窗口——窗口一旦穿透,它自己收不到任何鼠标消息,也就没有事件可监听。要区分「在角色身上」还是「在旁边的空白处」,只能主动去问全局光标;消息驱动的路在被穿透的那一刻就断了。

把三条路摆在一起看更清楚:整窗开关(SetWindowLong 加 WS_EX_TRANSPARENT)一行代码就能做,但粒度为整窗,角色会动、气泡会开关的场景直接作废;矩形轮询是当前方案,最多 16 个矩形、动态上报,代价是穿透区收不到消息、依赖 40ms 主动查询和矩形近似;逐像素 alpha 命中能做到轮廓级,但 WebView 合成内容不在应用手里,没有现成入口,WS_EX_LAYERED 加 UpdateLayeredWindow 那套自绘时代方案搬不过来。

替代方案是全局鼠标钩子 WH_MOUSE_LL,系统会把事件同步送进你的进程,代价是你的卡顿会拖慢全局光标。一句话概括取舍:轮询的失败半径在自己进程里,钩子的失败半径是全系统的光标。不过也不假装轮询零成本——常驻两个 WebView 的 CPU、内存与功耗尚未量化;40ms 这个值的取值依据文档也没写,不做手感量化就不替它编理由。

安静陪伴是穿透的完全体:set_ignore_cursor_events(true) 配合 set_focusable(false),整窗穿透且禁止聚焦。托盘恢复则按顺序走:quiet=false、force_hit_test、set_focusable(true)、recover_position、show。恢复路径里特意不调 set_focus,避免反复偷走用户正在用的应用的焦点。

四、位置记忆:400ms 防抖、250ms 落盘与一次真实竞态

窗口偏好的定位很明确:它属于桌面宿主,不进任务域,也不进前端 IPC。存储用独立的 desktop-settings.db,与待办、聊天分库,pet_position 表以 id=1 单行保存 x/y 物理坐标,不向前端开放路径或 SQL。

迁移纪律有三条:用 user_version 做版本迁移(0→1 建 pet_position,1→2 加 onboarding),version 大于 2 时拒绝打开、不做破坏性降级;打不开或异常时回退内存库,注释的原话是宁可可能重现,也不给一个坏掉的面板;文档口径为保留原文件、记录诊断、使用默认位置,不阻止角色与已有待办功能启动。

写盘是两级节奏:WindowEvent::Moved 触发记录,与已保存值相同则清 pending 不动盘;后台线程每 250ms 检查一次,距上次变更 ≥ 400ms 才落盘。写失败时保留 pending 下轮重试,注释写的是「保留 pending,下轮重试,而不是谎报已保存」。退出路径在 ExitRequested 与 Exit 两处强制 flush 补存;进程被强杀时,最后不足 400ms 的移动可能丢失,这一点如实写在文档里。

这一节最脏的是 busy_timeout。打开数据库时设了 5 秒超时,注释里带着真实案例:上一个实例退出时的位置补存与 journal 清理可能仍握着文件,250ms 曾输过一次真实竞态——快速重启时读取引导状态失败,随后把超时加到 5 秒。超时参数不是拍出来的,是被一次真实失败教育出来的。

启动恢复的顺序也有讲究:先 set_ignore_cursor_events(true),起点即穿透,等首轮轮询接管;有保存位置则 set_position 再 recover_position;首次启动无保存位置则落主屏右下角,右缘留 20px、下缘留 12px。实测从 (1580,580) 拖到 (1480,480),等待保存后结束进程,重启仍为 (1480,480)。

五、屏幕配置会变:每约 2 秒自愈夹回

位置记住之后,屏幕还会变:拔显示器、改分辨率、改缩放。这里的答案不是监听事件,而是轮询线程每 50 tick(约 2 秒)且鼠标未按下时执行一次 recover_position,周期性自愈。定位依据是窗口中心点落在哪个显示器的工作区,找不到就落主屏;托盘的「找回角色」带 reset=true,强制回主屏右下角。

夹回逻辑用 clamp_axis,先以 i64 中转防 i32 溢出,并正确处理副屏在主屏左侧时的负坐标。三条断言把边界钉住:
  1. clamp_axis(-2200, -1920, 1920, 320) == -1920
  2. clamp_axis(2000, 0, 1920, 320) == 1600
  3. clamp_axis(100, 0, 200, 320) == 0
复制代码

拔掉显示器时,保存的物理坐标指向已不存在的屏幕,中心点找不到所属显示器,于是落主屏再夹回。承诺也只到这一步:旧坐标按现有工作区夹回可见范围,不承诺更换屏幕后保持相同相对位置。

六、托盘是生命线,拖动阈值分界在前端

透明窗加 skipTaskbar,点关闭只隐藏,用户找回角色的全部出口只剩托盘。菜单六项分别是显示栖栖、恢复互动、安静陪伴(鼠标穿透)、隐藏栖栖、找回角色(移回主屏)、打开任务面板、退出栖伴——每一项都对应前面某个自动化路径失灵时的手动兜底。CloseRequested 走 prevent_close 加 hide,完全退出只能走托盘。代码注释的原则是:如果恢复用托盘装不上,宁可启动失败,也不要给出一个只有隐藏窗口的 UI,把用户锁在门外。

拖动分界同样在前端。pointerdown 只认 [data-pet-drag] 元素、主键与 isPrimary,并 setPointerCapture 锁定;移动累计小于 6 CSS 像素不进拖动(仍算点击),达到 6px 才 moved=true、preventDefault、suppressClick。超阈值后有一个容易漏掉的交接:先 end() 释放 web 指针捕获,再调用 Rust 的 start_dragging 进入 Windows 原生窗口移动循环——注释原话是必须在 Windows 接管移动循环之前放掉 web 捕获。suppressClick 在 click 捕获阶段拦截 event.detail > 0 的真实点击,避免拖完误触打开气泡。浏览器预览没有原生拖动,只能自己算 offset 并 clamp 在窗口内,所以 npm run dev 仅能模拟交互,不提供原生透明窗口、系统托盘或跨应用穿透。

置顶也不是常量。展开阅读时先保存原位置,放大到 760×720 逻辑像素(按工作区 clamp),并 set_always_on_top(false);任一步失败全量回滚:恢复原尺寸、原位置、重新置顶。收起时反向走一遍:320×440、原位置、recover_position、重新置顶、保存位置。全屏聊天还压在所有窗口之上就是骚扰,置顶应当随场景翻转。

七、还没验够的部分

如实披露的未验证项包括:多显示器、混合缩放、真实显示器拔插、全屏应用兼容性、前台焦点行为需实测;托盘溢出区与 Explorer 重启后的恢复需实机回归;常驻两个 WebView 的 CPU、内存与功耗尚未量化。注意这份清单的落点:多屏与混合 DPI 直接压在穿透换算的 scale_factor 和夹回逻辑上,正是窗口层下一层要还的债。

如果你手上有一个悬浮窗或桌宠,可以用三关各十秒做自检:看任务栏和 Alt+Tab 是否出现(出现即 skipTaskbar 没生效);在轮廓外的透明空白处点击底下应用,点不动说明穿透是整窗开关而非区域命中;拖动结束瞬间是否误触弹出聊天或面板,弹出即缺少拖动阈值与点击拦截。第一关测任务栏纪律,第二关测命中粒度,第三关测拖动分界。
回复

使用道具 举报

发表于 4 小时前 | 显示全部楼层

Re: Tauri桌宠窗口:鼠标穿透40ms轮询与位置记忆实现

这篇把窗口层那些不性感的边界条件拆得挺清楚,几个点很有共鸣。 透明那四个开关的组合确实是最容易踩的地方,尤其 shadow 漏掉之后那个悬浮块很出戏。你说的「量客户区矩形和窗口边界,别读 WS_CAPTION 样式位」这个验证方法我记下了,之前也吃过样式位和实际渲染不一致的亏。 fail-safe 的方向选择也认同——cursor/origin/scale 任一查询失败就默认忽略,宁可全穿透也别反向锁死光标,这个决策方向是对的,不然用户直接没法操作桌面了。 按住左键时保持当前命中模式那块特别有画面感。轮询在拖动中翻转穿透标志、窗口从光标下被「抽走」,这个 bug 描述得太真实了。用 GetAsyncKeyState 读物理按键状态让原生拖动沿用既有模式,思路简单但确实是必需的一手。 有几个想追问的: 一是拖动锁定用物理按键状态是可行的,但触摸板双指拖、或者某些用 SetCapture 走软件拖动的场景下这套还成立吗?还是说桌宠定位就是只保证鼠标场景? 二是命中矩形的校验里写死了 1024×1024 逻辑像素上限,遇到高 DPI 缩放比较猛或者以后做更大的挂件尺寸,这个常量会不会需要跟着动?现在有留配置口吗? 三是多显示器混 DPI 的情况,outer_position 减去后再除 scale_factor,窗口跨屏时那一下 scale 切换会不会出现一两帧的抖动,进而误判成穿透?
回复 支持 反对

使用道具 举报

发表于 4 小时前 | 显示全部楼层

Re: Tauri桌宠窗口:鼠标穿透40ms轮询与位置记忆实现

这套拆法很对路子,窗口层确实是全靠边界条件撑着,出问题的地方基本都不在渲染和对话上。 几个点我特别认同: 一是"量边界而不是读样式位"。Tao 在 Windows 上留着 WS_CAPTION 这事,很多人第一次遇到都会栽,改配置改半天,最后发现要去比客户区矩形和窗口边界。透明那几个开关也一样,四个少一个就是三种不同的怪样子,而且报错都很含糊,只能靠肉眼和量。 二是把"透明不等于穿透"单独拎出来讲。这两件事经常被混成一件事,实际上一个是视觉,一个是消息路由,后者一旦开了窗口自己就收不到鼠标消息,所以只能反向去问系统光标,这是逻辑上的必然,不是实现偷懒。你把三条路的失败半径摆在一起对比,这个视角比单纯说"轮询简单"有说服力得多——钩子拖慢的是全系统光标,这个代价平时没人愿意写出来。 三是 fail-safe 的方向选得对。光标、原点、缩放任一失败就默认忽略,宁可全穿透也别把用户的鼠标锁死在自己窗口上,这是桌宠这类东西最该有的自保意识。还有拖动时按 VK_LBUTTON 保持命中模式,不刷穿透标志,这个坑我信是真踩过才写得出来,"窗口从光标下被抽走"形容得太准了。 另外 set_ignore_cursor_events 只在状态翻转时调,而不是每 40ms 打一次原生 API,这个细节也值得抄。很多实现就是老老实实每个周期调一次,功能上没毛病,但长跑下来没必要。 有两个地方想请教下:
回复 支持 反对

使用道具 举报

发表于 4 小时前 | 显示全部楼层

Re: Tauri桌宠窗口:鼠标穿透40ms轮询与位置记忆实现

感谢分享,这类窗口层的东西确实很少有人系统写。我印象最深的是“验证是否无边框要量客户区矩形,别读样式位”这一条,之前也被 Tao 保留 WS_CAPTION 坑过,当时一直盯 style 位看,结果窗口实际已经没边框了,白折腾半天。 40ms 轮询和全局钩子的取舍那段也讲得很实在。轮询把失败半径限制在自己进程里,这个角度比单纯说“钩子危险”更有说服力。另外按住左键保持命中模式、防止拖动中翻标志把窗口抽走,这个细节很关键,属于不实际做过桌宠基本想不到的坑。 安静模式恢复那条顺序也值得记:先 quiet=false、再 force_hit_test、再 set_focusable(true)、最后 recover_position 和 show,而且特意不调 set_focus,确实能少偷一次焦点。 位置记忆那部分好像还没展开,400ms 防抖和 250ms 落盘的竞态挺想看的,等楼主续上。整体写得很有工程味,收藏了。
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-11-9 23:50 , Processed in 0.038228 second(s), 29 queries , Gzip On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部