Loading
CamelliaV の BLOG
0%
INITIALIZING
DOC_ID // 3f3ca1ONLINE

[临时AI稿][2026.10.8] 截图时按 Esc 为什么会漏进底下的窗口:KWin XWayland 按键复制的一处绕行

2026-10-8
技术分享
READ 6 MIN/COUNT 2041
#开发#实用
CamelliaV の BLOG
🤖
本文由 deepseek-v4.1-flash 撰写。
💡
KDE Plasma Wayland 下,用 XWayland 跑的截图工具在框选时按 Esc 取消、按 Enter 保存,这两个键会同时送到底下正处于焦点的原生 Wayland 窗口——Esc 顺手关掉了别人的对话框,Enter 触发了别人的默认按钮。根因在 KWin 的 XWayland 按键复制过滤器:它把「特殊键」复制给 XWayland 之后仍然放行同一个事件。这里记录绕行的做法:选区出现时清掉 Wayland 侧的活动窗口,把 X 输入焦点设回选区,选区消失后再还原。附机制取证、被排除的方案,和一套不打扰桌面的验证协议。

症状

框选截图时按 Esc 取消,会发生两件事:
  • 截图工具自己的选区关掉了,这是预期的;
  • 底下原来那个窗口也收到了同一个 Esc。如果它是全屏播放器,会退出全屏;如果它正弹着对话框,对话框会关掉。
Enter 同理:保存截图的同时,把别人的默认按钮也按了。
这两个都不需要额外配置,装好就在。除了 Esc 和 Enter,方向键与功能键(F1–F12)走同一条路。

机制:同一个事件被投递了两次

KWin 6.7.5 的 src/xwayland/xwayland.cpp 里有一个 XwaylandInputFilter::keyboardKey()。它的默认行为(对应 XwaylandEavesdrops=AllKeysWithModifier)是:
  1. 如果这个键带修饰键,或者它是一个「特殊键」(Qt key ≥ 0x01000000,覆盖 Esc、Enter、方向键、功能键),就把这个键复制给 Xwayland 连接;
  1. 复制完之后固定 return false,于是同一个事件继续在输入链上往下走,投给当前聚焦的原生 Wayland 表面。
所以这两次投递不是偶发故障,而是这个过滤器的既定行为。唯一不复制的情况是:当前聚焦的 surface 本来就属于这个 XWayland 客户端。
而截图工具恰恰拿不到焦点。它的主进程把自己钉死在 xcb 平台上——即使外部强制 QT_QPA_PLATFORM=wayland,它仍然会加载 libqxcb;而框选用到的选区窗口是 override-redirect 的,override-redirect 意味着不参与窗口管理,KWin 不会把它设为键盘焦点。于是它只能靠上面那条「复制给 XWayland」的路拿到 Esc,而这条路同时也会把键交给底下真正有焦点的窗口。
顺带排掉一个直觉方案:X11 的 XGrabKeyboard 在这套组合里没有用。KWin 对 zwp_xwayland_keyboard_grab_v1 只是记账,hasGrab() 唯一的消费者是「全局快捷键是否被抑制」这一个判断,它不会把键盘焦点搬走。

被排除的方案

方案
结果
把 XwaylandEavesdrops 设成 None
泄漏确实停了,但选区自己也再收不到 Esc/Enter,截图没法用键盘取消或保存。按下症状,换来残废。
让截图工具改用原生 Wayland
程序自己强制 xcb,这不是一个可配置项。
给截图工具挂一个 XGrabKeyboard 垫片
如上,KWin 只把它当成全局快捷键抑制,不搬焦点。
只在选区出现时清掉 KWin 的活动窗口
方向对,但单独做不够:KWin 清掉自己焦点的同时会连带丢掉 X 焦点,选区立刻 focus out,Esc 反而进不去截图工具。必须在清完之后把 X 输入焦点设回选区。
注册一个全局快捷键把 Esc 吃掉
全局快捷键在更早的过滤层就 return true 消费掉了,选区同样拿不到,还得自己把键转发回去。多一层脆弱。

落地:一个用户级守护进程

真正生效的组合是:选区出现时,让 Wayland 侧没有焦点窗口,同时把 X 焦点设回选区。
  • 清掉活动窗口之后,即使过滤器仍然把键复制给 XWayland,Wayland 侧也已经没有接收者了;
  • 把 X 输入焦点设回选区,截图工具自己仍能正常处理 Esc/Enter。
实现是一个两百多行的 Python 脚本加一个 systemd 用户单元:
  • 脚本在 X root 窗口上挂 SubstructureNotifyMask,监听子窗口的映射、卸载与焦点事件;
  • 靠窗口名识别选区(Snipper - Snipaste)。新窗口出现时先补挂监听,避免抢跑;
  • KWin 侧没有可用的返回值通道。脚本把 KWin 脚本写进缓存目录,经 qdbus6 org.kde.KWin /Scripting 的 loadScript / Script<id>.run / unloadScript 执行,KWin 脚本内部用 print 把结果打进 journald;
  • 选区消失后,按先前记下的 internalId 还原活动窗口;
  • 选区仍然映射却收到 FocusOut 时,重新设置一次 X 输入焦点(限频 0.1 秒,连续突发上限 8 次),防止别的东西再把焦点抢走;
  • 服务挂在 graphical-session.target 上,随图形会话起停。

实现中的坑

坑
处理
先清焦点、后设 X 焦点,中间那一小段里按下的 Esc 会丢
顺序反过来:先把 X 输入焦点设回选区,再去读 journald 里的结果。代码注释里写明了原因。
KWin 脚本没法把值返回给调用方
用 journald 当通道。每次调用生成一个随机 token,打印成一条带 token 的标记行,读取时按 token 匹配,避免读到上一轮残留的行。
queryWindowInfo 这个 D-Bus 查询会挂住不返回
完全不用它,所有信息都走 journald。
还原时要把记下的 id 拼进 JS 源码
先校验形态(花括号包裹、长度上限),不合法就拒绝还原,不把任意字符串拼进脚本。
调试用的 KWin 脚本残留
每次加载前先按插件名卸载同名脚本;探针脚本用完即卸。

验证协议:不弹前景窗口

在别人正在使用的桌面上做焦点实验,最容易犯的错是自己先抢了焦点。做法是造一个「静默接收器」:
  1. 起一个离屏的 kitty 窗口,用一个不常见的窗口类名,在里面跑一个把 stdin 设为 cbreak、逐字节打印十六进制的原始按键记录器。它既能被 KWin 脚本精确聚焦,又不占屏幕。
  1. 用 KWin 脚本把焦点压到它身上并打印确认;注入按键之前再确认一次当前活动窗口——桌面外壳会周期性地抢焦点,盲注入只会得到假结论。
  1. 用 ydotool 注入一次 Esc。
  1. 触发截图,让选区出现。
  1. 判定:泄漏的情况下,接收器日志里会出现 0x1b,同时截图工具自己的日志里出现退出记录——同一个键被两个消费者各自处理了一次。修好之后,接收器日志应保持为空,而截图工具日志里仍然有那次退出。
  1. 收尾:杀掉接收器、卸载 KWin 脚本、检查有没有残留进程。
这里的关键是两处日志同时看。只看「截图工具退出了」会得出错误结论(修复前后它都会退出);只看「接收器没收到」也不够,还得确认截图工具确实仍被键盘控制。

开关

要重新打开就把 disable 换成 enable。

边界

这个守护进程只管「选区出现期间的特殊键复制」。它不解决:
  • 在截图上打字。Wayland 下普通字符键本来就不会进到选区,那是另一条路径,本文的绕行没有覆盖它;
  • 截图工具识别窗口的能力。它把自己钉在 xcb 上,因此看不到原生 Wayland 窗口的层级信息,这不在本文范围内。

附:取证线索

  • KWin 侧:src/xwayland/xwayland.cpp 的 XwaylandInputFilter::keyboardKey();src/wayland/xwaylandkeyboardgrab_v1.cpp 与「全局快捷键是否被抑制」那处判断说明 grab 只是记账;输入过滤器顺序表在 src/inputfilter.h 的 InputFilterOrder。
  • 桌面侧:截图工具自己的事件日志,以及 journalctl --user -u plasma-kwin_wayland.service 里 KWin 脚本的 print 输出。
NAVIGATION // Related Articles
Loading...
© 2024-2026 CamelliaV