把桌面集成从 X11 搬到 Wayland:一次完整的补全实录

在 Wayland 上跑虚拟机的桌面集成(剪贴板同步、拖放、指针注入、分辨率自适应),常会遇到一种尴尬处境:功能看着有、实际不工作;翻文档,上游明确写着”不支持 Wayland,请用 X11”。

本文记录一次完整的补全过程。结论先放这里:这类问题很少是”缺一段代码”,多数是”架构上刻意绕开了 Wayland”,所以第一步永远不是写代码,而是搞清楚上游到底绕在哪。

一、先确认上游”不支持”是哪一种

“不支持”有三种,处理方式完全不同:

  1. 没实现——接口在,里面是 return;;
  2. 实现了一半——有分支,但只覆盖部分协议;
  3. 刻意禁用——代码里显式把后端切成 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
2
/* 用 opcode 直接驱动,同一份实现跑在两套协议上 */
wl_proxy_marshal_flags(manager, DATA_CONTROL_OPCODE_CREATE_SOURCE, ...);

好处是只有一份逻辑,坏处是可读性下降——所以额外加了一个环境变量用于强制指定走哪条协议,方便在同时提供两个协议的合成器上做交叉验证。

验证方法:不要只看”绑定成功”。开一个独立进程,用 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,否则响应回不到正确的请求路径上。

七、验证怎么才算数

这类协议层改动,”单元测试通过”说明不了什么。有效的验证组合是:

  1. 真机跑在真实合成器上(KDE Wayland)——剪贴板用独立进程读回并逐字节比对;
  2. 嵌套一个合成器做交叉验证——用同时提供新旧两个协议的合成器,分别强制走两条实现,各跑一遍;
  3. 反向验证——故意把优先级反过来,确认它确实会失败(证明”选错了会坏”,而不是”两条路都能走”);
  4. 无 Wayland 构建——--without-<feature> 编译必须通过并落到 stub,避免把新依赖变成硬依赖。

八、还剩下的边界

有些事这个架构下就是做不到,写清楚比假装解决更有价值:

  • 指针位置无法回报(libei sender 单向);
  • 拖放仍依赖 /dev/uinput(需要提权包装器),且仍依赖 XWayland;
  • 平铺式合成器需要手动把工具窗口设为浮动;
  • 某些桌面环境的拖放时序问题,根治需要合成器实现对应协议,应用侧只能缓解。

总结

把桌面集成搬到 Wayland,难点不在”写多少行”,而在先分清上游是没实现、半实现、还是刻意禁用:

  • 刻意禁用的情况下,要补的是一个完整后端,不是补丁;
  • 协议差异优先用适配层收敛,而不是复制实现;
  • portal 只解决授权,真正的设备操作要走 EIS/libei;
  • 混合图形栈里,”后写者赢”的角色注册一定会冲突;
  • 验证必须到”读回的数据逐字节一致”这个粒度。

最后一条经验:文档里写的”不支持”,往往比代码里的实现更诚实——但也要自己验证一遍,因为”不支持”的边界在哪里,文档通常不会写。