桌宠穿透不是一行 SetWindowLong

0 阅读17分钟

Windows 桌宠最难的不是 Live2D,也不是模型对话,是窗口本身——透明、鼠标穿透、位置记忆,三件没什么人写的脏活。角色渲染与对话链路的难处,网上已经写了很多;窗口这一层写得少,因为它不性感,全是边界【从业者判断——选题观察】。

这是《栖伴造记》第四篇;上一篇(86)里透明穿透只给了一句、注明留给本篇——现在兑现:把那句 40ms,展开成它应得的整篇。

本篇引用的窗口代码共六处:pet.rs、placement.rs、tray.rs、lib.rs 窗口段、Pet.tsx、useSceneDrag.ts【仓库直核】。

时点钉死:2026-11-04,origin/main @ 060012e,与事实底稿时间线核对一致【仓库直核;事实底稿·时间线】。经 git diff 核对,这六处在 v0.2.5 与 v0.3.1(tag dacc873)之间零差异(lib.rs 另有 3 行非窗口差异:一处模块声明、两处命令注册,不涉窗口段)【仓库直核】——本文工程数字都出自这段稳定的窗口层。

dacc873 之后 21 秒的 060012e 是合并提交:把 dacc873 的发布元数据折进 main,main 侧另带进一处 CSS 类名重命名——与 dacc873 相比只差这处类名,不碰窗口文件,零差异结论不变【仓库直核】。

这两个发布版之间 capabilities/pet.json 只加了一行 allow-live2d-pick-folder,穿透与位置相关权限未动【仓库直核】。

时间线还有一层:首次桌宠交付 2026-09-14(首提交 2026-09-13 的次日),分支 feat/desktop-pet-window;位置记忆同日另一分支 feat/pet-position-memory;场景拖动 2026-09-20【桌宠交付;位置记忆;场景拖动】。三件脏活从第一周就在做,不是后来补的。

一堆开关只是入场券

pet 窗口在 tauri.conf.json 里的完整配置,逐字段抄给你【仓库直核,配置节选】:

{
  "label": "pet",
  "title": "栖栖 · 桌面伴侣",
  "url": "index.html",
  "width": 320, "height": 440,
  "transparent": true,
  "backgroundColor": "#00000000",
  "decorations": false,
  "shadow": false,
  "resizable": false,
  "alwaysOnTop": true,
  "skipTaskbar": true,
  "visible": false,
  "focus": false
}

要点直接说:

  • 透明无边框是四个开关的组合:transparent、backgroundColor 全零、decorations:false、shadow:false。漏一个,你得到的是「一个带白底的怪框」或「带系统阴影的悬浮块」【仓库直核】
  • visible:false + focus:false:窗口启动即隐藏、不抢焦点,由前端就绪命令显示角色(pet_action Ready → show)【工程边界 §桌面呈现;仓库直核】
  • alwaysOnTop + skipTaskbar:置顶、不进任务栏;320×440 逻辑像素,不可 resize【仓库直核】
  • main 窗口(1180×820)同样 visible:false 预加载,气泡或托盘显式打开【仓库直核】

这节最值钱的反而是条验证细节:Tauri(Tao)在 Windows 上保留部分 WS_CAPTION 样式位,所以交付验证里的无边框检查写的是「使用实际客户区与窗口边界相等,不依赖样式位推断」【桌宠交付·原生检查段】。连「验证无边框」都不能信样式位——查窗口就得量边界。

透明不等于穿透:40ms 轮询的矩形命中

透明只决定你看得见桌面,穿透才决定鼠标归谁。工程边界文档一段写完整个机制,逐字:

「透明背景不等于鼠标穿透。前端上报角色、把手和气泡的可见矩形,Rust每40ms将系统光标的物理坐标按窗口位置与缩放换算为逻辑坐标,在矩形外设置原生忽略鼠标事件。按住左键时保留当前命中模式,支持原生拖动。此实现有轮询延迟,按矩形而非SVG像素命中;角色轮廓内部的透明小块仍可能接收点击。安静陪伴让整个窗口穿透并禁止聚焦,托盘恢复互动。」【工程边界】

拆开是三层。

前端上报(Pet.tsx:111-130)。命中元素以 data-pet-hit 标记,现有四处:场景背景(pet-scene)、气泡 section(pet-dialog)、角色按钮(pet-character)、错误重开按钮(pet-error-reopen)【仓库直核】。

采集节奏:ResizeObserver 观察 shell 容器加全部命中元素,回调里再套 requestAnimationFrame 合帧【仓库直核】。

拿到回调后,getClientRects().length > 0 过滤 display:none,getBoundingClientRect 取矩形,invoke('set_pet_regions') 上报【仓库直核】。

气泡开关、安静/隐藏、外观切换、Live2D 失败回退,都会重建 observer 重报;阅读模式的气泡布局变化同样有 ResizeObserver 跟上【工程边界 §模型配置与渲染增量】。

Rust 校验(pet.rs HitRegion + set_pet_regions)【仓库直核】:

  • 上限 16 个矩形
  • 字段须 finite、x/y ≥ 0、w/h > 0、x+w ≤ 1024 且 y+h ≤ 1024(逻辑像素)
  • window.label() 不是 "pet" 直接拒绝——main 窗口不许上报命中区域
  • 越界超量返回错误「角色交互区域无效」

轮询线程(pet.rs start_hit_testing):

  • std::thread 40ms 周期;窗口不可见时 continue,隐藏期间跳过命中查询【仓库直核】
  • 正常模式:cursor_position()(系统光标物理坐标)− outer_position()(窗口原点),先相减、再逐轴除以 scale_factor(),换算成窗口内逻辑坐标;命中任一矩形则可交互,否则原生忽略【仓库直核】
  • fail-safe 方向:cursor/origin/scale 任一查询失败,默认忽略——宁可全穿透,不可反向锁死光标【仓库直核】
  • set_ignore_cursor_events 只在状态翻转或 force_hit_test 置位时调用,不是每 40ms 打一次原生 API【仓库直核】

为什么是轮询不是事件?穿透的目的就是让鼠标消息流过窗口——窗口一旦穿透,它自己收不到任何鼠标消息,没有事件可监听。要区分「在她身上」还是「在她旁边的空白处」,只能主动去问全局光标;窗口消息驱动的路,在被穿透的那一刻就断了【从业者判断——机制推理】。

把三条路摆在一起看,取舍更清楚:

方案粒度窗口自己收得到鼠标消息吗前提与代价
整窗开关(SetWindowLong 加 WS_EX_TRANSPARENT)整只窗口一刀切穿透后收不到Win32 一行就够,但角色会动、气泡会开关就废
矩形轮询(栖伴现状)最多 16 个矩形,动态上报穿透区收不到,靠 40ms 主动问全局光标前端上报加 Rust 换算,两界协作;延迟与矩形近似已披露
逐像素 alpha 命中轮廓级同样收不到WebView 合成内容不在你手里逐像素提交,无现成简单路径

【仓库直核;从业者判断——方案对比】矩形近似不是没想到更精,是更精的那条路在 WebView 上没有现成入口;WS_EX_LAYERED 加 UpdateLayeredWindow 是自绘时代的方案,搬不过来【从业者判断】。

两个细节见脏度。一是拖动中的模式锁定:mouse_down() 在 Windows 下用 GetAsyncKeyState(VK_LBUTTON) 读左键物理状态,按住时保持当前命中模式【仓库直核】。注释原话:

"Read button state only, so a native drag keeps its existing hit-test mode until release."

不加这一手,轮询在拖动中翻转穿透标志,窗口会从光标下被「抽走」【仓库直核】。

二是安静模式与原生标志的同步,注释原话 "Quiet can change the native flag independently; reapply on a restore transition."——安静状态可能独立改掉原生标志,恢复过渡时必须重刷【仓库直核】。

边界不藏,两句原文:「鼠标穿透是40ms轮询的矩形命中,快速跨边界点击可能有短暂延迟,不是像素级命中」【桌宠交付·未验证与下一步】;「角色轮廓内部的透明小块仍可能接收点击」【工程边界】。轮询矩形命中不是妥协的遮羞布,是被写进文档的工程决策——这句是本篇立场。

安静陪伴是穿透的完全体:set_ignore_cursor_events(true) 加 set_focusable(false),整窗穿透且禁止聚焦;README 口径是「安静陪伴」保留角色并让鼠标完全穿透【仓库直核;README】。

托盘恢复按序走:quiet=false、force_hit_test、set_focusable(true)、recover_position、show【仓库直核】。恢复路径里藏着一句很体贴的注释:

"No set_focus: tray restoration must not repeatedly steal the working application's focus."

托盘恢复不调 set_focus,不偷你正在用的应用的焦点【仓库直核】。

位置记忆:400ms 防抖,250ms 落盘

placement.rs 第一行注释把这一层的边界钉死,逐字:"Application-specific window preferences; never part of the task domain or frontend IPC."——窗口偏好永不进任务域与前端 IPC【仓库直核】。

这条注释的分量在域的划分:待办、聊天、记忆都走任务域和 IPC,唯独窗口偏好被挡在外面——一个坐标不值得让前端知道,更不值得混进业务库跟着业务 schema 迁移【从业者判断——架构解读】。

存储:专属库 desktop-settings.db(与待办、聊天分开),pet_position 表 id=1 单行存 x/y 物理坐标;「该数据属于桌面宿主,不向前端开放路径或SQL」【仓库直核;位置记忆】。纪律三条:

  • user_version 迁移:0→1 建 pet_position 表,1→2 加 onboarding 表,version > 2 拒绝打开,不做破坏性降级;测试 refuses_future_preferences_without_overwriting 验证原文件 user_version 保持不动【仓库直核】
  • 打不开或异常时回退内存库,注释里写 an honest "may reappear" instead of a broken panel——指南面板可能重现,好过一个坏掉的面板【仓库直核】
  • 文档口径:「偏好数据库异常或来自新版本时保留原文件、记录诊断并使用默认位置,不阻止角色和已有待办功能启动」【位置记忆】

写盘是两级节奏:WindowEvent::Moved → placement::moved(),展开聊天的宽屏位置除外——那不算桌宠位置;与已保存值相同则清 pending、不动盘;后台线程每 250ms 检查一次,距变更 ≥ 400ms 才落盘(防抖窗口)【仓库直核】。

写失败怎么办?保留 pending 下轮重试,注释原话:"Keep pending on failure; retry on the next tick instead of claiming it was saved."——宁可下轮再试,不谎报已保存【仓库直核】。

ExitRequested 与 Exit 两处 force flush 补存;进程被强杀时,最后不足 400ms 的移动可能丢,文档如实写【位置记忆】。README 用户侧口径:「移动停止后约一秒内自动保存位置」【README】。

这一节最脏的是 busy_timeout。open() 里现在是 busy_timeout(5s),注释带着真实案例,逐字【仓库直核】:

"Exit of the previous instance (placement flush, journal cleanup) can still hold the file when the next one starts; 250ms lost that race (real case 2026-10-07: guide_status read failure on quick restart)."

翻译过来:上个实例退出时的补存与 journal 清理可能还握着文件;250ms 的超时输过一次真实竞态——2026-10-07,快速重启时 guide_status 读取失败——当天提交 c18a238 把它加到 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 溢出,正确处理负坐标多屏(副屏在主屏左侧时原点为负)。三条断言把边界钉住【仓库直核】:

// pet.rs tests 用例节选(非可执行片段,仅示边界)
clamp_axis(-2200, -1920, 1920, 320) == -1920 // 负坐标副屏
clamp_axis(2000, 0, 1920, 320) == 1600        // 越界右移
clamp_axis(100, 0, 200, 320) == 0             // 窗口比工作区宽

拔掉显示器时:保存的物理坐标指向已不存在的屏幕,中心点找不到所属显示器,落主屏夹回【仓库直核】。承诺也只承诺到这里:「旧坐标按现有工作区夹回可见范围,不承诺更换屏幕后保持相同相对位置」【位置记忆】。位置记忆的最后一关不是记住,是屏幕变了之后自己回来。

托盘是生命线

透明窗、skipTaskbar、点关闭只隐藏——用户找回她的全部出口,只剩托盘。菜单六项【仓库直核】:显示栖栖 / 恢复互动、安静陪伴(鼠标穿透)、隐藏栖栖、找回角色(移回主屏)、打开任务面板、退出栖伴。

六项其实是前三节每件脏活的人工逃生门:安静陪伴对应穿透的完全体,找回角色对应夹回的 reset=true,显示栖栖对应 show——自动化路径失灵时,每条都有手动兜底【仓库直核;从业者判断——归纳】。

tooltip 是「栖伴 · 左键恢复角色,右键打开菜单」,左键 Click(Up) 直接 restore;CloseRequested → prevent_close + hide,完全退出走托盘【仓库直核】。

托盘装不上怎么办?lib.rs 注释逐字:

"Fail before showing a hidden-window-only UI if the recovery tray cannot be installed."

托盘装不上,宁可启动失败:一个只有隐藏窗口的 UI 会把用户锁在门外【仓库直核】。

已知未验照报:「系统托盘安装已随原生启动成功;菜单点击、托盘溢出区、Explorer重启后的恢复还需实机回归」【桌宠交付·未验证与下一步】。

6 像素是磨出来的,不是规定的

拖动发生在前端,分界也划在前端:pointerdown 只认 [data-pet-drag] 元素 + 主键 + isPrimary,setPointerCapture 锁定【仓库直核】。

移动累计 < 6 CSS 像素不进拖动(仍是点击),≥ 6px 则 moved=true、preventDefault、suppressClick【仓库直核】。

超阈值后有个容易漏掉的交接:先 end() 释放 web 指针捕获,再 petAction('drag') 进 Rust 的 start_dragging()【仓库直核】。注释原话:

"Release web capture before Windows takes over its window-move loop."

Windows 的窗口移动循环接管前,必须先放掉 web 捕获。suppressClick 在 click 捕获阶段拦截 event.detail > 0 的真实点击,拖完不误触打开气泡【仓库直核】。

浏览器预览没有原生拖动:自己算 offset 并 clamp 在窗口内(-344/-464 边界),预览的拖动是模拟——所以 README 明示「npm run dev 只在浏览器模拟角色交互,不能提供原生透明窗口、系统托盘或跨应用鼠标穿透」【仓库直核;README】。

阈值是被真实使用磨出来的。2026-09-14 首版是「原生拖动把手」【桌宠交付】;2026-09-20 改成角色/背景整片可拖、底部变圆形舞台、去掉「拖动这里」按钮,用户可见变化写明「移动达到6个CSS像素才开始拖动,拖动结束不触发聊天点击」【场景拖动】。

置顶不是常量

alwaysOnTop:true 是开机默认,不是终身状态。

展开阅读时窗口变身:保存原位置(compact_position)→ 放大 760×720 逻辑像素(按工作区 clamp)→ set_always_on_top(false),展开聊天撤置顶;任一步失败全回滚——恢复原尺寸、原位置、重新置顶【仓库直核】。

收起反向走一遍:320×440、原位置、recover_position、重新置顶、placement::moved 保存【仓库直核】。

置顶是随场景翻转的状态,不是常量——全屏聊天还压在所有窗口上面就是骚扰。这个状态机细节,不少桌宠产品做错【从业者判断——产品观察】。

反方:三条最硬的反驳

反方一:「Electron/WPF/Qt 早有透明窗口方案,Win32 一行 SetWindowLong 加 WS_EX_TRANSPARENT 就穿透,你这是不会用才搞轮询。」

WS_EX_TRANSPARENT 是整窗开关;桌宠要的是动态区域命中——角色会动、气泡会开关、聊天会展开,命中区域随状态重建【仓库直核】。更根本的是穿透状态下窗口收不到鼠标消息,事件驱动天然不可用,只能轮询全局光标(见上文机制推理)【从业者判断】。

矩形近似与 40ms 延迟是被文档披露的折衷,不是掩盖【工程边界】。同时承认:如果产品只要「整只宠物一个矩形」或纯静态形状,更简单的方案存在,那些场景不需要这套【从业者判断】。

反方二:「40ms 轮询常驻线程,浪费 CPU。」

摊账:每秒 25 次坐标查询加少量原生调用;窗口隐藏时整段跳过;按住左键时不重算区域【仓库直核】。事件驱动的替代是全局鼠标钩子 WH_MOUSE_LL——系统把事件同步送进你的进程,你的卡顿会拖慢全局光标,进程间代价与稳定性风险都更高【从业者判断——权衡】。

一句话:轮询的失败半径在自己进程里,钩子的失败半径是全系统的光标。也不假装轮询零成本:「常驻两个WebView的CPU、内存与功耗尚未量化」【工程边界】——这笔账没算,就明说没算。

至于为什么是 40ms 而不是 16 或 100——文档没写取值依据,我不替它编一个:这是未做手感量化的实现常量【仓库直核】。

反方三:「位置记不住是小事,存注册表/ini 就行。」

「存个坐标」的代码五行;能用的位置记忆是一组边界处理——400ms 防抖、失败保留 pending、版本拒绝降级、busy_timeout 竞态修补,每条对应一次真实失败模式【仓库直核】。

快速重启读库失败是 2026-10-07 的真实案例【仓库直核】。五行代码记的是坐标;这一组代码保的是「重启之后,她还在你放她的地方」。

诚实账:没做到的和没验够的

【进展】的官方口径分两行,逐字:已实现「透明悬浮、角色/背景拖动、托盘、位置/外观恢复、SVG 与本地 Live2D 导入」;仍待完成「正式角色资产与许可、性能/多屏矩阵、干净设备和独立体验」。

未验证清单汇总,一条不减:

  • 多显示器、混合缩放、真实显示器拔插、全屏应用兼容性、前台焦点行为需实测【桌宠交付】
  • 「尚未覆盖真实混合DPI与拔插显示器」【工程边界】
  • 托盘溢出区、Explorer 重启后恢复需实机回归【桌宠交付】
  • 「常驻两个WebView的CPU、内存与功耗尚未量化」【工程边界】

注意这份清单压在哪儿:多屏与混合 DPI 直接压在穿透换算的 scale_factor 和夹回逻辑上——正是本文这三件脏活的下一层,不是别处的债【从业者判断——推理】。

桌宠作为品类很老,Shimeji、VPet 都是前辈;栖伴的差异不在「会做桌宠」,在产品化的桌面 AI 伴侣,加已实现边界的如实披露【从业者判断——品类观察】。脏活的价值不在聪明,在把每条已知边界如实写进文档——这篇的全部判断力,押在这一句上。

现在就能做的事

不用装栖伴,拿你手上任何一个悬浮窗或桌宠,三关各十秒:

  1. 看任务栏和 Alt+Tab:它在不在里面?在,就是 skipTaskbar 没开。
  2. 在她的透明空白处(轮廓外)点一下底下的应用:点不动,说明穿透是整窗开关,不是区域命中。
  3. 拖动结束的瞬间看有没有误触:弹出了聊天或面板,就是没有拖动阈值加 click 拦截。

第一关测任务栏纪律,第二关测命中粒度,第三关测拖动分界【从业者判断——自检设计】。想看栖伴的实现:GitHub 搜 Thneoly/qiban-companion。

下一篇,第 88 篇《她会开口,也能被你打断:一条云端语音链怎么接进桌面聊天》——窗口稳了,让她开口。