vdi-cloud/terminal/native-cursor/README.md

6.8 KiB
Raw Blame History

Linux 原生独立光标(X11 / 单座席 / 2K)

用于当前盒子 .188 的 Ubuntu .72 和麒麟 .71。主机光标图像、热点、动画帧和透明隐藏状态通过认证 SSH 同步;位置由盒子的 X11 原生指针立即显示。没有改系统光标主题,也没有用固定本地箭头替代原生形状。点击、拖窗内容和视频编码延迟仍存在。

本实现绕过两个不同 Sunshine 版本的大型重编译,使用按精确 SHA256 固定的私有副本和捕获适配器。这是本项目的受限接入方式,不是上游 Sunshine/Moonlight 已支持的协议能力。后续要支持其他捕获后端、相对鼠标或多个并发客户端,应扩展双方正式控制协议。

生命周期

  1. vdi-moonlight-rkmpp 调用 client.py。没有对应主机 JSON 配置,或 VDI_NATIVE_CURSOR=0,直接 exec 原客户端。Windows 没有该配置。启用时先核验客户端 SHA256,再获取远端租约;旧客户端或未复核的新构建会在修改主机服务前被拒绝。
  2. 客户端使用各主机独立密钥、固定 known_hosts、BatchMode 和严格主机校验打开 SSH。服务器 authorized_keys 使用 restrict,from="192.168.1.188",command="/usr/bin/python3 .../host.py stream",不提供任意 shell。
  3. host.py stream 获取独占 flock,检查版本、随机租约、单屏尺寸与光标边界;启动用户级 guard。guard 校验私有 Sunshine 和适配器 SHA256,停止原服务,在 /run/user/UID/systemd/user/ 创建临时 drop-in,再以适配器启动私有副本。
  4. capture-no-cursor.c 只在该 Sunshine 进程的 XFixesGetCursorImage 捕获结果中清零像素,不修改 X server 的实际光标、全局主题或系统库。adapter 的 dlsym receipt 和 /proc/PID/maps 都确认后才返回 READY,随后才允许新视频连接。因此所有新视频帧从一开始都不含光标,不在旧视频仍显示光标时直接切换。
  5. SSH 消息包含版本、128位随机租约、递增序号、尺寸、热点、RGBA 像素、源分辨率和输入确认。最多128×128像素;坏包、错误尺寸、重复序号或超时结束当前串流。客户端/bridge状态等待上限2秒,主机租约4秒;短暂停顿期间保留上次形状,本地指针位置继续移动。源几何变化要求重新连接。
  6. 盒子监督进程写入0700私有目录的原子状态文件。SDL主线程每约4ms检查状态,只在形状/缩放改变时创建和释放光标;X11原生指针移动不等该轮询或视频帧。SDL2 X11后端直接复制Xcursor像素,需要预乘alpha,已单独适配。
  7. 本地输入序号和目标坐标通过SSH回传;服务器实际指针到达该位置后确认。只在最新输入已确认、没有按键按下且本地静止500ms后接受服务器主动warp,避免迟到的位置回拉正在移动的光标。触摸/相对游戏模式不在本接入范围。
  8. 客户端保留原PID(fork监督进程后exec),正常kiosk窗口激活仍能识别。退出、父进程被kill或SSH异常时结束租约。guard先停止无光标生产进程,再删除临时drop-in并启动原服务;原视频连接不会被新普通生产进程接续。
  9. guard本身被kill时,systemd ExecStopPost仍执行恢复。重启后 /run 中的drop-in自然不存在;原已启用服务仍正常启动。没有启用全局LD_PRELOAD或永久无光标模式。

构建和安装

  • 客户端补丁:../moonlight/patches/0005-vdi-native-linux-cursor.patch;由 tools/build-rkmpp-client.sh 在0001~0004之后应用。头文件源码保留在本目录,修改时必须同步补丁中的新增头文件。
  • tools/deploy_kiosk.sh 部署 client.py 为 ~/vdi-terminal/bin/vdi-native-cursor。配置/密钥不会由普通kiosk部署脚本生成。
  • 两个客体的私有目录:~/vdi-terminal/native-cursor-v1/,包含 host.py、capture-no-cursor.c/.so、无额外file capability的原版Sunshine副本,以及 manifest.json。原系统二进制/能力保持原值。
  • .so在麒麟glibc2.31/GCC9上编译,两台复用。最小编译不依赖缺失的libc开发包:
gcc -shared -nostdlib -fPIC -O2 -Wall -Wextra -Werror -Wl,-z,defs \
  capture-no-cursor.c /lib/x86_64-linux-gnu/libdl.so.2 \
  /lib/x86_64-linux-gnu/libc.so.6 -o capture-no-cursor.so

麒麟仅对本次 host.py、私有 sunshine 和 .so 做 kysec_set -n exectl -v verified 单文件认证;源码变更后须重新认证。没有关闭KYSEC。Ubuntu原文件具有capabilities,安全加载器会忽略LD_PRELOAD,因此仅租约内使用相同SHA的无capability私有副本;X11捕获无需SYS_ADMIN,启动时其他捕获后端的能力探测告警不代表X11失败。

用户级 vdi-native-cursor-guard.service 的ExecStart为 /usr/bin/python3 <目录>/host.py guard,ExecStopPost为同脚本的 recover;Type=simple,TimeoutStopSec=50。服务按需启动,不enable。manifest.json仅包含两个键 sunshine、capture-no-cursor.so,值为部署文件的SHA256。

盒子 ~/.config/vdi-terminal/native-cursor/<IP>.json:

{"host":"192.168.1.72","user":"test2","key":"/绝对路径/该主机.key","known_hosts":"/绝对路径/该主机.known_hosts","size":[2560,1440],"client_sha256":"5d4330f703d7f8e053bc3795ca42bdaf231c9e9dd8ebb00a612013adbcccee03"}

每台主机单独的ed25519私钥和known_hosts留在盒子配置目录(目录700、私钥600),不进仓库。known_hosts是在工作站用用户提供的SSH凭据成功认证后取得的真实主机公钥,没有在运行时自动接受新主机密钥。JSON和密钥应在已验证0005的客户端部署后才启用。上例SHA为2026-11-05现场构建;重新构建后须先停用profile、完成对应验证,再更新SHA并启用,不能把旧值机械替换为任意新文件的SHA。

停用与回退

退出当前Linux串流,等guard inactive且原vdi-sunshine active。将对应 <IP>.json 改名为 .disabled,下次从同一正常列表连接即使用原视频光标。临时诊断也可设置 VDI_NATIVE_CURSOR=0,但必须在当前租约结束、原服务恢复之后重连。

紧急恢复可在客体执行 systemctl --user stop vdi-native-cursor-guard.service,检查 /run/user/UID/systemd/user/vdi-sunshine.service.d/90-native-cursor.conf 已移除、MainPID路径为原二进制。盒子旧客户端和wrapper备份在 ~/vdi-terminal/backups/native-cursor-20261105/。仅恢复旧二进制前必须先停用两个主机JSON,避免旧客户端连入无光标捕获。

测试结论与边界见云桌面说明,完整测试与失败实验原文见历史归档中的 linux-native-cursor-20261105.md;最新部署 SHA 见终端说明。本地边界单测:python -m unittest discover -s tools/tests -p test_native_cursor.py -v。