我很想知道你对我博客的看法!方便请到 关于 页面留下评论!

虽然大部分时间有 SSH 就足够方便高效的控制一台 Linux 设备,但是难免会遇到一些需要在某台机器的桌面环境下才能处理的需求。Linux 下面有各种各样的远程桌面实现方案,但是也各有各的问题。

传统基于 X server 的远程桌面方案就不用说了,2025 年连 NVIDIA 都支持 Wayland 了,当然不能要求用户使用远程桌面之前再注销登录进 X11 会话。RustDesk 是个流行的远程桌面方案,但是它不支持 Wayland 下面的远程登录。如果不考虑其它桌面,只考虑我使用的 GNOME 的话,GNOME Remote Desktop 确实号称自己支持远程登录,但是当初我们讨论这个的实现方式的时候,由于 GNOME 的远程桌面接口是每个用户会话独立的,所以必然涉及不同的服务之间的切换,然后他们选择用 RDP 协议的 ServerRedirection 来实现,结果就是只能使用 RDP,而且不是所有的 RDP 客户端都支持 ServerRedirection,比如 macOS 下的 Windows App 就不支持,于是对我来说也几乎不能用。而由于我不用 wlroots,我也不确定 wayvnc 是否支持远程登录。

解决问题的希望出现在 kmsvnc,这个项目使用 DRM 的 API 获取显卡输出的 frame buffer,因此和使用什么桌面完全无关,然后使用 VA-API 将 frame buffer 解码成像素数据交给 VNC 服务器,使用 uinput 处理 VNC 服务器的输入数据。而对于没有连接显示器的设备,可以使用 Linux 的功能强制开启一个显示输出。虽然这个项目还有一些问题,比如在 NVIDIA 显卡上 VA-API 没法解码,但是和作者 @isjerryxiao 聊了一下之后,他觉得应该使用 OpenGL 的 API 来解码 frame buffer,因为显卡的渲染部分总是要能处理自己的格式的。然后还有些其它的问题,比如 kmsvnc 只在启动时获取一次 buffer ID,导致从登录界面切换到用户会话时不会更新显示内容,以及由于 DRM API 需要权限,整个程序都以 root 权限运行,这对一个网络服务来说不是很安全,我把这些作为改进的目标。

为了解决权限问题,我决定将程序分成两部分,高权限的进程仅仅负责从 DRM 抓取 frame buffer 和向 uinput 写入输入事件,低权限的进程负责启动一个 VNC 服务器,将 frame buffer 渲染成像素数据传给客户端和处理客户端的输入事件。DRM 导出和 OpenGL 导入交换的是内核的 DMA-BUF 文件描述符,而 UNIX socket 是可以传送文件描述符的,所以两个进程使用 UNIX socket 通信。为了避免重复造轮子,直接使用了 GLib 的各种工具和事件循环。

抓取 frame buffer 的部分其实没有什么困难,而输入处理的部分值得注意的是正常的坐标输入设备分为相对和绝对两种类型,常见的鼠标和触摸板属于相对设备,而触摸屏属于绝对设备,当然像 VNC 传过来的坐标自然也是绝对坐标,所以这里需要模拟成绝对设备,不然很难处理客户端和服务端初始的光标位置不一致的情况。像 Qemu 这种虚拟机,默认虚拟的指针设备也是触摸屏而非鼠标。唯一一个需要注意的点是你需要给 uinput 的指针设备指定 X 轴和 Y 轴的长度,但是这里不要求你比例和屏幕尺寸一致。因为一般桌面环境会将指针设备的 X 轴和 Y 轴映射到整个虚拟桌面的大小,所以你需要的其实是鼠标指针在整个虚拟桌面中位置的比例,然后直接乘上你设置的长度值。

OpenGL 的部分和普通的程序不一样的地方在于普通的程序一般是跑在窗口系统里的,使用 EGL 建立 OpenGL Context 的时候建立的是基于窗口系统的,但是这个程序是独立在窗口系统外面的,所以使用的是 EGL 的 surfaceless 平台,需要设置 EGL_PLATFORM=surfaceless 进行离屏渲染,然后对于大部分双显卡的系统,默认的 surfaceless 设备并不是实际用于输出的设备,但是只有显卡自己才能解码自己的输出格式,因此还要选择用哪个显卡渲染才行。至于将 DMA-BUF 的文件描述符导入成 OpenGL 纹理的 EGL_LINUX_DMA_BUF_EXT 虽然是个扩展,但是因为 0 拷贝的渲染需求实在是太常见了,一般的显卡应该都实现了,所以不算什么特别过分的要求。

输入事件处理最难的地方在于把客户端的坐标映射成相对整个虚拟桌面的全局坐标,因为虚拟桌面其实是桌面环境的概念,我们没什么特别好的办法拿到整个虚拟桌面的尺寸,和当前选中的显示器在虚拟桌面中的相对位置,只能把这个问题留给用户,让用户手动设置在配置文件里面。

整个进程完全是单线程的,不管是 socket 通信还是 VNC 服务器都跑在 GLib 的事件循环上,既然单线程的性能完全足够,就没有必要搞多线程增加额外的复杂度了。我觉得最妙的地方是我利用 systemd 的 socket 来启动高权限的进程,这样就做到只有当有 VNC 客户端连接进来时,高权限的部分才会运行,而所有 VNC 客户端都退出之后,高权限的部分也会退出。按需启动高权限进程增加了安全性,也减少了不必要的资源占用。

项目地址:GitHub Repo

既然看了喵写的文章,不打算投喂一下再走吗?哼!
微信支付 微信支付
支付宝 支付宝