本文由 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)是:- 如果这个键带修饰键,或者它是一个「特殊键」(Qt key ≥
0x01000000,覆盖 Esc、Enter、方向键、功能键),就把这个键复制给 Xwayland 连接;
- 复制完之后固定
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 脚本残留 | 每次加载前先按插件名卸载同名脚本;探针脚本用完即卸。 |
验证协议:不弹前景窗口
在别人正在使用的桌面上做焦点实验,最容易犯的错是自己先抢了焦点。做法是造一个「静默接收器」:
- 起一个离屏的 kitty 窗口,用一个不常见的窗口类名,在里面跑一个把 stdin 设为 cbreak、逐字节打印十六进制的原始按键记录器。它既能被 KWin 脚本精确聚焦,又不占屏幕。
- 用 KWin 脚本把焦点压到它身上并打印确认;注入按键之前再确认一次当前活动窗口——桌面外壳会周期性地抢焦点,盲注入只会得到假结论。
- 用
ydotool注入一次 Esc。
- 触发截图,让选区出现。
- 判定:泄漏的情况下,接收器日志里会出现
0x1b,同时截图工具自己的日志里出现退出记录——同一个键被两个消费者各自处理了一次。修好之后,接收器日志应保持为空,而截图工具日志里仍然有那次退出。
- 收尾:杀掉接收器、卸载 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 输出。
