把桌面集成从 X11 搬到 Wayland:一次完整的补全实录
在 Wayland 上跑虚拟机的桌面集成(剪贴板同步、拖放、指针注入、分辨率自适应),常会遇到一种尴尬处境:功能看着有、实际不工作;翻文档,上游明确写着”不支持 Wayland,请用 X11”。
本文记录一次完整的补全过程。结论先放这里:这类问题很少是”缺一段代码”,多数是”架构上刻意绕开了 Wayland”,所以第一步永远不是写代码,而是搞清楚上游到底绕在哪。
一、先确认上游”不支持”是哪一种
“不支持”有三种,处理方式完全不同:
- 没实现——接口在,里面是
return;; - 实现了一半——有分支,但只覆盖部分协议;
- 刻意禁用——代码里显式把后端切成 X11。
这套工具属于第三种。搜索整个仓库,wl_display、wl_data_device、libei、xdg-desktop-portal 一个都没有;反而在一处初始化函数里找到一行无条件调用:
1 | gdk_set_allowed_backends("x11"); |
它不是”忘记加 Wayland 支持”,而是主动声明”我只跑在 X11 上”。维护者在 issue 里的回复也很直接:Wayland 尚未支持,请使用 X11。
这个结论决定了后面的所有工作:不是给已有后端打补丁,而是补齐一个从未存在过的后端。先摸清”哪些能力依赖 X11、依赖在哪一层”,比急着写代码重要得多。
二、剪贴板:两个协议,一份实现
剪贴板是第一个硬骨头。社区有现成补丁集,但它只绑定 ext-data-control-v1——这个协议要 wayland-protocols >= 1.39,2024 年才出现。
问题在于:主流的 LTS 发行版还在用旧协议 zwlr_data_control。这意味着补丁集在”很新的发行版”上能用,在”企业还在跑的 LTS”上剪贴板完全失效。
两条路:复制一份 900 行的实现,或者写一层适配。对比两个协议后发现,它们的四层接口、请求/事件 opcode、listener 布局是一一对应的,唯一的差别是”创建对象时用哪个接口指针”。于是写了一层薄适配:
1 | /* 用 opcode 直接驱动,同一份实现跑在两套协议上 */ |
好处是只有一份逻辑,坏处是可读性下降——所以额外加了一个环境变量用于强制指定走哪条协议,方便在同时提供两个协议的合成器上做交叉验证。
验证方法:不要只看”绑定成功”。开一个独立进程,用 wl-paste 把数据读回来,逐字节比对——中文、变音符号、换行都要覆盖。绑定成功但内容错位,是这类协议实现最常见的失败形态。
三、指针:portal 能授权,但注入得换条路
指针注入原本在 Wayland 后端是空实现。用 xdg-desktop-portal 的 RemoteDesktop 界面走了一遍授权流程,看起来很顺:
CreateSession → SelectDevices → Start → ConnectToEIS
然后踩到第一个坑:Start 漏一个参数就静默失败。它的真实签名是
1 | Start(session_handle, parent_window, options) |
少传 parent_window 时,portal 不会报”参数错误”,而是直接拒绝,会话永远不会进入 started 状态。更麻烦的是,同样的 bug 也存在于当时写进正式代码的那一版里——单元测试覆盖不到,只有真机跑起来才暴露。
第二个坑更根本:纯 portal 路线注入不了指针。调用 NotifyPointerMotionAbsolute 报 Invalid position。读 portal 的实现才发现,它在注入前会遍历 screencast streams 校验坐标范围——没有屏幕流,坐标校验永远返回假。
所以正确路径是:portal 只负责”授权”,真正注入必须走 ConnectToEIS 拿到 fd,再交给 libei。
第三个坑是能力边界:libei 的 sender 上下文只能发送事件、不能接收(官方头文件写得很明白)。而 portal 也没有查询指针位置的接口。所以”把客体里的指针位置回报给宿主”这件事,在这个架构下技术上做不到——不是没写,是链路不存在。早先文档里写的”能读回位置”属于想当然,实测后改成了明确说明。
四、分辨率:两个组件抢同一个角色
多显示器循环切换失效,查了很久。
根因是两个组件都会向系统声明自己是”分辨率服务”:一个跑在用户会话(X11 插件),一个跑在系统服务(KMS 后端)。声明机制是”后写者赢”。
在 X11 会话里,用户会话那个启动得早,先占位;在 Wayland 会话里,它启动得晚,却能通过 XWayland 抢走已经属于别人的角色,于是 KMS 那条链路被顶掉,”循环切换”再也没有响应。
修法是加一个会话类型判断(WAYLAND_DISPLAY 存在且 XDG_SESSION_TYPE != x11),让 X11 插件在 Wayland 会话下主动退让,复用代码里已有的”能否使用 KMS”早退分支即可。
这类问题的通用教训:“后写者赢”的注册机制在混合图形栈里一定会出问题,因为两边的启动顺序和可见性不一样。
五、一个 gtk_init 让整个插件卸载
有一条看起来和 Wayland 无关的代码:单实例互斥锁用 gtk_init() 初始化,失败就退出进程。
在 X11 下它只是”检查显示器在不在”;在纯 Wayland 会话里,没有 X display,gtk_init() 直接失败——于是整个桌面事件插件被卸载,不只是锁没拿到。
修法是分支处理:X11 会话保持原行为,非 X11 会话改用文件锁:
1 | $XDG_RUNTIME_DIR/<app>.lock + flock |
文件锁的好处是持有者进程退出后内核自动释放,不需要额外的清理逻辑。
排查手法:反汇编确认分支位置——单靠读源码容易误判”这个函数只用于锁”,实际它同时承担了环境探测。
六、免弹窗授权:注册一个真正的 app_id
portal 的 RemoteDesktop 每次授权都要用户点确认,对无人值守场景是灾难。
三个要点:
- 用
Registry.Register注册一个 app_id,必须能匹配到一个真实存在的.desktop文件,否则注册无效; - 注册后要重启 portal 服务,否则它还在用旧的桌面文件缓存;
- 授权请求带上
persist_mode=2(持久化),并且在第一次授权后保存 restore token。
还有一个细节:SelectDevices 是”后端内部方法”,不会产生 Response 信号;但它的 options 里必须带 handle_token,否则响应回不到正确的请求路径上。
七、验证怎么才算数
这类协议层改动,”单元测试通过”说明不了什么。有效的验证组合是:
- 真机跑在真实合成器上(KDE Wayland)——剪贴板用独立进程读回并逐字节比对;
- 嵌套一个合成器做交叉验证——用同时提供新旧两个协议的合成器,分别强制走两条实现,各跑一遍;
- 反向验证——故意把优先级反过来,确认它确实会失败(证明”选错了会坏”,而不是”两条路都能走”);
- 无 Wayland 构建——
--without-<feature>编译必须通过并落到 stub,避免把新依赖变成硬依赖。
八、还剩下的边界
有些事这个架构下就是做不到,写清楚比假装解决更有价值:
- 指针位置无法回报(libei sender 单向);
- 拖放仍依赖
/dev/uinput(需要提权包装器),且仍依赖 XWayland; - 平铺式合成器需要手动把工具窗口设为浮动;
- 某些桌面环境的拖放时序问题,根治需要合成器实现对应协议,应用侧只能缓解。
总结
把桌面集成搬到 Wayland,难点不在”写多少行”,而在先分清上游是没实现、半实现、还是刻意禁用:
- 刻意禁用的情况下,要补的是一个完整后端,不是补丁;
- 协议差异优先用适配层收敛,而不是复制实现;
- portal 只解决授权,真正的设备操作要走 EIS/libei;
- 混合图形栈里,”后写者赢”的角色注册一定会冲突;
- 验证必须到”读回的数据逐字节一致”这个粒度。
最后一条经验:文档里写的”不支持”,往往比代码里的实现更诚实——但也要自己验证一遍,因为”不支持”的边界在哪里,文档通常不会写。