功能:接入Linux原生光标并完善设备接入工具

This commit is contained in:
songyiming committed 2026-11-05 19:18:54 +08:00
1 parent a14f028402
commit a47968496e
92 files changed
+3090 -22

No files matched your search

+50
View File
@@ -0,0 +1,50 @@
# 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开发包:
```sh
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`:
```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,避免旧客户端连入无光标捕获。
完整测试、已知限制及失败实验见 `../../docs/linux-native-cursor-20261105.md`。本地边界单测:`python -m unittest discover -s tools/tests -p test_native_cursor.py -v`。
@@ -0,0 +1,54 @@
/* Linux/glibc X11 capture adapter. Only preload into a leased Sunshine process.
* Sunshine resolves XFixesGetCursorImage with dlsym(), so a plain symbol
* interposer is insufficient. No system library or desktop theme is replaced.
* ABI matches XFixesCursorImage from the Xorg Xfixes.h public header.
*/
#define _GNU_SOURCE
#include <stddef.h>
extern void *dlvsym(void *, const char *, const char *);
extern char *getenv(const char *);
extern int strcmp(const char *, const char *);
extern void abort(void);
extern int open(const char *, int, ...);
extern long write(int, const void *, unsigned long);
extern int close(int);
typedef struct {
short x, y;
unsigned short width, height, xhot, yhot;
unsigned long serial;
unsigned long *pixels;
unsigned long atom;
const char *name;
} CursorImage;
typedef void *(*Lookup)(void *, const char *);
typedef CursorImage *(*GetImage)(void *);
static GetImage real_image;
static CursorImage *capture_image(void *display) {
GetImage fn = __atomic_load_n(&real_image, __ATOMIC_ACQUIRE);
if (!fn) abort();
CursorImage *image = fn(display);
if (image && image->pixels) {
unsigned long n = (unsigned long)image->width * image->height;
if (n > 1048576) abort();
for (unsigned long i = 0; i < n; ++i) image->pixels[i] = 0;
}
return image;
}
void *dlsym(void *handle, const char *name) {
Lookup lookup = (Lookup)dlvsym((void *)-1L, "dlsym", "GLIBC_2.2.5");
if (!lookup) abort();
void *result = lookup(handle, name);
if (result && getenv("VDI_CURSOR_LEASE") &&
strcmp(name, "XFixesGetCursorImage") == 0) {
__atomic_store_n(&real_image, (GetImage)result, __ATOMIC_RELEASE);
const char *receipt = getenv("VDI_CURSOR_RECEIPT");
if (receipt) {
int fd = open(receipt, 0101 | 01000 | 02000000, 0600);
if (fd >= 0) { write(fd, "xfixes-v1\n", 10); close(fd); }
}
return (void *)capture_image;
}
return result;
}
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env python3
"""Box supervisor. Exec preserves the PID expected by kiosk window activation.
Metadata travels over pinned-host-key SSH with a per-host forced-command key.
The private state directory is not a public socket/port. Native cursor resources
are created and freed by Moonlight's SDL main thread, never by this process.
"""
import base64
import hashlib
import json
import os
from pathlib import Path
import select
import shutil
import signal
import struct
import subprocess
import sys
import tempfile
import time
import uuid
HEADER = struct.Struct('<8sQQQIIIIiiII')
MAX_LINE = 100000
STATE_TIMEOUT = 2.0
def verify_client(path, expected):
if (not isinstance(expected, str) or len(expected) != 64 or
hashlib.sha256(Path(path).read_bytes()).hexdigest() != expected):
raise ValueError('native cursor client build changed; verify build before enabling profile')
def validate(state, lease, size, previous):
if type(state.get('version')) is not int or state.get('version') != 1 or state.get('lease') != lease or state.get('type') != 'cursor':
raise ValueError('cursor protocol/session mismatch')
fields = ('seq', 'ack', 'w', 'h', 'hx', 'hy', 'x', 'y', 'sw', 'sh')
if any(type(state.get(k)) is not int for k in fields):
raise ValueError('noninteger field')
if not previous < state['seq'] < 2**63:
raise ValueError('stale sequence')
if not 0 <= state['ack'] < 2**63:
raise ValueError('invalid input acknowledgement')
w, h = state['w'], state['h']
if not (0 < w <= 128 and 0 < h <= 128 and 0 <= state['hx'] < w and 0 <= state['hy'] < h):
raise ValueError('cursor bounds')
if [state['sw'], state['sh']] != size:
raise ValueError('display changed')
if not (0 <= state['x'] < size[0] and 0 <= state['y'] < size[1]):
raise ValueError('cursor position outside single display')
pixels = base64.b64decode(state['rgba'], validate=True)
if len(pixels) != w * h * 4:
raise ValueError('pixel length')
return HEADER.pack(b'VDICUR1\0', int(lease[:16], 16), state['seq'],
time.monotonic_ns(), w, h, state['hx'], state['hy'],
state['x'], state['y'], *size) + pixels + struct.pack('<Q', state['ack'])
def write_state(directory, payload):
tmp = directory / 'state.tmp'
with tmp.open('wb') as f:
f.write(payload)
tmp.replace(directory / 'state')
def supervise(profile, size, lease, directory, ready_fd, parent):
ssh = None
ready = False
try:
cmd = ['ssh', '-T', '-i', profile['key'], '-o', 'IdentitiesOnly=yes',
'-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes',
'-o', 'UserKnownHostsFile=' + profile['known_hosts'],
'-o', 'ConnectTimeout=5', '-o', 'ServerAliveInterval=1',
'-o', 'ServerAliveCountMax=2', profile['user'] + '@' + profile['host']]
ssh = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
ssh.stdin.write((json.dumps(dict(version=1, lease=lease, size=size)) + '\n').encode())
ssh.stdin.flush()
started = last = last_ping = time.monotonic()
seq, buffer, acknowledged = 0, b'', False
last_input = 0
while True:
# A kiosk SIGKILL of the exec'd client must still tear down the lease.
if os.getppid() != parent:
break
now = time.monotonic()
if now - last_ping >= .25:
ssh.stdin.write(b'ping\n')
ssh.stdin.flush()
last_ping = now
input_path = directory / 'state.input'
if ready and input_path.exists():
data = input_path.read_bytes()
if len(data) != 16:
raise ValueError('invalid local input record')
input_seq, px, py = struct.unpack('<Qii', data)
if input_seq > last_input:
if not (input_seq < 2**63 and 0 <= px < size[0] and 0 <= py < size[1]):
raise ValueError('local input bounds')
ssh.stdin.write((json.dumps(dict(input=input_seq, x=px, y=py))+'\n').encode())
ssh.stdin.flush()
last_input = input_seq
if (ready and now - last > STATE_TIMEOUT) or (not ready and now - started > 50):
raise RuntimeError('native cursor heartbeat timed out (%.3fs)' % (now - last))
if select.select([ssh.stdout], [], [], .025)[0]:
chunk = os.read(ssh.stdout.fileno(), 65536)
if not chunk:
raise RuntimeError('native cursor SSH ended')
buffer += chunk
if len(buffer) > MAX_LINE * 2:
raise ValueError('cursor receive buffer limit')
while b'\n' in buffer:
line, buffer = buffer.split(b'\n', 1)
if len(line) > MAX_LINE:
raise ValueError('cursor packet limit')
state = json.loads(line)
if not acknowledged:
if state != dict(type='ready', version=1, lease=lease):
raise ValueError('cursor handshake rejected')
acknowledged = True
continue
payload = validate(state, lease, size, seq)
seq = state['seq']
write_state(directory, payload)
last = time.monotonic()
if not ready:
os.write(ready_fd, b'ready\n')
os.close(ready_fd)
ready = True
print('VDI native cursor: authenticated cursor-free session ready',
file=sys.stderr, flush=True)
return 0
except Exception as error:
print('VDI native cursor: ' + str(error), file=sys.stderr, flush=True)
if ready and os.getppid() == parent:
os.kill(parent, signal.SIGTERM)
return 78
finally:
if ssh:
try:
ssh.stdin.write(b'stop\n')
ssh.stdin.flush()
ssh.stdin.close()
ssh.wait(timeout=12)
except (BrokenPipeError, subprocess.TimeoutExpired, OSError):
ssh.terminate()
try:
ssh.wait(timeout=3)
except subprocess.TimeoutExpired:
ssh.kill()
shutil.rmtree(directory)
def launch(argv):
client, args = argv[0], argv[1:]
if not (len(args) >= 2 and args[0] == 'stream'):
os.execv(client, argv)
host = args[1]
if any(c not in '0123456789.' for c in host):
os.execv(client, argv)
config = Path.home() / '.config/vdi-terminal/native-cursor' / (host + '.json')
if os.environ.get('VDI_NATIVE_CURSOR') == '0' or not config.exists():
os.execv(client, argv)
profile = json.loads(config.read_text())
# An old or separately rebuilt client must never enter cursor-free video
# without the SDL receiver. Verify BEFORE acquiring any remote lease.
verify_client(client, profile.get('client_sha256'))
if profile['host'] != host or '--absolute-mouse' not in args:
raise ValueError('native cursor needs matching absolute desktop mode')
resolution = args[args.index('--resolution') + 1]
size = list(map(int, resolution.split('x')))
if size != profile['size']:
raise ValueError('native cursor needs configured single-screen size')
lease = uuid.uuid4().hex
directory = Path(tempfile.mkdtemp(prefix='vdi-native-cursor-',
dir=os.environ.get('XDG_RUNTIME_DIR', '/tmp')))
rfd, wfd = os.pipe()
parent = os.getpid()
worker = os.fork()
if worker == 0:
os.close(rfd)
os._exit(supervise(profile, size, lease, directory, wfd, parent))
os.close(wfd)
if not select.select([rfd], [], [], 55)[0] or os.read(rfd, 32) != b'ready\n':
# No normal-mode launch while a cursor-free producer might still live.
raise RuntimeError('native cursor preflight failed; reconnect after recovery')
os.close(rfd)
os.environ['VDI_NATIVE_CURSOR_STATE'] = str(directory / 'state')
os.environ['VDI_NATIVE_CURSOR_EPOCH'] = str(int(lease[:16], 16))
os.execv(client, argv)
if __name__ == '__main__':
launch(sys.argv[1:])
+299
View File
@@ -0,0 +1,299 @@
#!/usr/bin/env python3
"""Authenticated single-seat Linux cursor lease; run by a restricted SSH key.
The guard owns the Sunshine lifecycle. Cursor-free capture starts BEFORE the
new video connection; ending the lease stops that capture process before
restoring normal capture. There is deliberately no mid-stream mode switch.
"""
import base64
import ctypes as C
import fcntl
import hashlib
import json
import os
from pathlib import Path
import select
import signal
import subprocess
import sys
import time
ROOT = Path(__file__).resolve().parent
RUN = Path('/run/user/%d/vdi-native-cursor' % os.getuid())
DROP = RUN.parent / 'systemd/user/vdi-sunshine.service.d/90-native-cursor.conf'
SERVICE = 'vdi-sunshine.service'
GUARD = 'vdi-native-cursor-guard.service'
TTL = 4.0
def systemctl(*args):
return subprocess.run(['systemctl', '--user'] + list(args), check=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
timeout=20).stdout.decode().strip()
def recover():
# Stop the cursor-free producer FIRST. A new normal producer cannot resume
# an old Moonlight connection, so lost SSH never creates a double cursor.
if DROP.exists():
systemctl('stop', SERVICE)
DROP.unlink()
systemctl('daemon-reload')
systemctl('start', SERVICE)
for name in ('lease', 'ready', 'receipt'):
(RUN / name).unlink(missing_ok=True)
def guard():
lease = (RUN / 'lease').read_text().strip()
if len(lease) != 32 or any(c not in '0123456789abcdef' for c in lease):
raise ValueError('invalid lease')
manifest = json.loads((ROOT / 'manifest.json').read_text())
for name, expected in manifest.items():
if name not in ('sunshine', 'capture-no-cursor.so') or hashlib.sha256(
(ROOT / name).read_bytes()).hexdigest() != expected:
raise RuntimeError('pinned capture build changed')
kms_config = ROOT / 'kms.json'
scanout = None
def checked_scanout(timeout=3):
deadline = time.monotonic() + timeout
last_error = None
while True:
try:
return scanout.check()
except RuntimeError as exc:
last_error = exc
if time.monotonic() >= deadline:
raise last_error
time.sleep(.1)
if kms_config.exists():
from kms import VirtioScanout
scanout = VirtioScanout(json.loads(kms_config.read_text()))
# Wake an idle output before inspecting its active scanout. This does
# not unlock the session or disable the user's screen lock.
subprocess.run(['/usr/bin/xset', 'dpms', 'force', 'on'], check=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=5)
if hashlib.sha256(Path('/usr/bin/sunshine').read_bytes()).hexdigest() != manifest['sunshine']:
raise RuntimeError('pinned system capture build changed')
systemctl('stop', SERVICE)
DROP.parent.mkdir(parents=True, exist_ok=True)
if scanout:
# File capabilities remain on the original, verified system binary.
# Command-line override is lease-local; recover restores X11 capture.
DROP.write_text('[Service]\nExecStart=\nExecStart=/usr/bin/sunshine %s capture=kms\n' %
(Path.home() / '.config/sunshine/sunshine.conf'))
else:
DROP.write_text('[Service]\nEnvironment="LD_PRELOAD=%s" "VDI_CURSOR_LEASE=%s" '
'"VDI_CURSOR_RECEIPT=%s"\n' %
(ROOT / 'capture-no-cursor.so', lease, RUN / 'receipt') +
'ExecStart=\nExecStart=%s %s\n' %
(ROOT / 'sunshine', Path.home() / '.config/sunshine/sunshine.conf'))
systemctl('daemon-reload')
# The normal X11 producer can leave the DRM plane idle while it is being
# stopped. Install the recoverable drop-in first, then validate the
# stopped display before launching KMS. ExecStopPost restores X11 if this
# fail-closed check rejects the current output.
if scanout:
subprocess.run(['/usr/bin/xset', 'dpms', 'force', 'on'], check=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=5)
checked_scanout()
systemctl('start', SERVICE)
# dlsym receipt proves this exact process resolved the adapter entrypoint.
deadline = time.monotonic() + 12
while not scanout and not (RUN / 'receipt').exists():
if time.monotonic() > deadline:
raise RuntimeError('capture adapter not resolved')
time.sleep(.05)
pid = int(systemctl('show', SERVICE, '-p', 'MainPID', '--value'))
if scanout:
checked_scanout()
if b'capture=kms\0' not in Path('/proc/%d/cmdline' % pid).read_bytes():
raise RuntimeError('KMS capture override not active')
elif str(ROOT / 'capture-no-cursor.so') not in Path('/proc/%d/maps' % pid).read_text():
raise RuntimeError('capture adapter not mapped')
(RUN / 'ready').write_text(lease)
while (RUN / 'lease').exists():
if time.time() - (RUN / 'lease').stat().st_mtime > TTL:
break
if int(systemctl('show', SERVICE, '-p', 'MainPID', '--value')) != pid:
break # Never accept an encoder restart inside the same lease.
if scanout:
checked_scanout(1)
time.sleep(.15)
class Cursor(C.Structure):
_fields_ = [('x', C.c_short), ('y', C.c_short), ('w', C.c_ushort),
('h', C.c_ushort), ('hx', C.c_ushort), ('hy', C.c_ushort),
('serial', C.c_ulong), ('pixels', C.POINTER(C.c_ulong)),
('atom', C.c_ulong), ('name', C.c_char_p)]
class XCursor:
def __init__(self):
# Absolute original library path; never inherit the capture adapter.
self.x = C.CDLL('libX11.so.6')
self.f = C.CDLL('/lib/x86_64-linux-gnu/libXfixes.so.3')
self.x.XOpenDisplay.argtypes = [C.c_char_p]
self.x.XOpenDisplay.restype = C.c_void_p
self.x.XDefaultScreen.argtypes = [C.c_void_p]
self.x.XDefaultRootWindow.argtypes = [C.c_void_p]
self.x.XDefaultRootWindow.restype = C.c_ulong
self.x.XGetGeometry.argtypes = [C.c_void_p, C.c_ulong, C.POINTER(C.c_ulong),
C.POINTER(C.c_int), C.POINTER(C.c_int), C.POINTER(C.c_uint),
C.POINTER(C.c_uint), C.POINTER(C.c_uint), C.POINTER(C.c_uint)]
for name in ('XDisplayWidth', 'XDisplayHeight'):
getattr(self.x, name).argtypes = [C.c_void_p, C.c_int]
self.x.XFree.argtypes = [C.c_void_p]
self.f.XFixesGetCursorImage.argtypes = [C.c_void_p]
self.f.XFixesGetCursorImage.restype = C.POINTER(Cursor)
self.display = self.x.XOpenDisplay(None)
if not self.display:
raise RuntimeError('no authenticated X11 display')
self.screen = self.x.XDefaultScreen(self.display)
self.root = self.x.XDefaultRootWindow(self.display)
self.size = self.dimensions()
def dimensions(self):
root = C.c_ulong()
x, y = C.c_int(), C.c_int()
w, h, border, depth = [C.c_uint() for _ in range(4)]
if not self.x.XGetGeometry(self.display, self.root, C.byref(root),
C.byref(x), C.byref(y), C.byref(w), C.byref(h),
C.byref(border), C.byref(depth)):
raise RuntimeError('root geometry unavailable')
return w.value, h.value
def read(self):
if self.dimensions() != self.size:
raise RuntimeError('display changed; reconnect required')
ptr = self.f.XFixesGetCursorImage(self.display)
if not ptr:
raise RuntimeError('cursor unavailable')
try:
c = ptr.contents
if not (0 <= c.w <= 128 and 0 <= c.h <= 128):
raise ValueError('unsupported cursor bounds')
rgba = bytearray()
for i in range(c.w * c.h):
p = c.pixels[i]
a = (p >> 24) & 255
# XRender pixels are premultiplied; SDL expects straight RGBA.
rgb = [min(255, (((p >> s) & 255) * 255 + a // 2) // a)
if a else 0 for s in (16, 8, 0)]
rgba.extend(rgb + [a])
w, h, hx, hy = c.w, c.h, c.hx, c.hy
if not any(rgba[3::4]):
# GTK's blank cursor can be 1x1 with a hotspot of (1,1).
# Canonicalize invisible pixels; never clip a visible hotspot.
w, h, hx, hy, rgba = 1, 1, 0, 0, bytearray(4)
elif not (hx < w and hy < h):
raise ValueError('visible cursor hotspot outside image')
return dict(w=w, h=h, hx=hx, hy=hy, x=c.x, y=c.y,
sw=self.size[0], sh=self.size[1], shape=int(c.serial),
rgba=base64.b64encode(rgba).decode())
finally:
self.x.XFree(ptr)
def stream():
RUN.mkdir(mode=0o700, parents=True, exist_ok=True)
with (RUN / 'lock').open('w') as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
# Version/capabilities come through the authenticated SSH stdin.
if not select.select([sys.stdin], [], [], 5)[0]:
raise RuntimeError('missing hello')
hello = json.loads(sys.stdin.buffer.readline(2048))
lease = hello.get('lease', '')
if hello.get('version') != 1 or len(lease) != 32 or any(
c not in '0123456789abcdef' for c in lease):
raise ValueError('bad hello')
x = XCursor()
if list(x.size) != hello.get('size'):
raise ValueError('source size differs')
# Fail before touching the service if the native shape is unsupported.
x.read()
systemctl('stop', GUARD)
(RUN / 'lease').write_text(lease)
systemctl('start', GUARD)
deadline = time.monotonic() + 40
try:
while not (RUN / 'ready').exists():
if time.monotonic() > deadline:
raise RuntimeError('guard not ready')
os.utime(RUN / 'lease', None)
time.sleep(.05)
if (RUN / 'ready').read_text() != lease:
raise RuntimeError('stale guard')
print(json.dumps(dict(type='ready', version=1, lease=lease)), flush=True)
seq = 0
last_shape = None
last_sent = 0
heartbeat = time.monotonic()
acknowledged_input = 0
pending_input = None
while True:
now = time.monotonic()
if select.select([sys.stdin], [], [], 0)[0]:
line = sys.stdin.buffer.readline(2048)
if not line or line.strip() == b'stop':
break
if line.strip() != b'ping':
request = json.loads(line)
if (set(request) != {'input', 'x', 'y'} or
any(type(request[k]) is not int for k in request) or
not acknowledged_input < request['input'] < 2**63 or
not 0 <= request['x'] < x.size[0] or
not 0 <= request['y'] < x.size[1]):
raise ValueError('bad input acknowledgement request')
pending_input = request
heartbeat = now
if now - heartbeat > TTL:
raise RuntimeError('client lease expired')
os.utime(RUN / 'lease', None)
if not (RUN / 'ready').exists():
raise RuntimeError('capture guard ended')
read_started = time.monotonic()
state = x.read()
read_seconds = time.monotonic() - read_started
if read_seconds > .1:
print('VDI native cursor: X11 read blocked %.3fs' % read_seconds,
file=sys.stderr, flush=True)
if pending_input and (abs(state['x'] - pending_input['x']) <= 2 and
abs(state['y'] - pending_input['y']) <= 2):
acknowledged_input = pending_input['input']
pending_input = None
state['ack'] = acknowledged_input
identity = (state['shape'], state['rgba'])
if identity != last_shape or now - last_sent >= .05:
seq += 1
state.update(type='cursor', version=1, lease=lease, seq=seq)
print(json.dumps(state, separators=(',', ':')), flush=True)
last_shape, last_sent = identity, now
time.sleep(.008)
finally:
(RUN / 'lease').unlink(missing_ok=True)
systemctl('stop', GUARD)
def main():
os.environ['DISPLAY'] = ':0'
os.environ['XDG_RUNTIME_DIR'] = '/run/user/%d' % os.getuid()
os.environ['DBUS_SESSION_BUS_ADDRESS'] = 'unix:path=%s/bus' % os.environ['XDG_RUNTIME_DIR']
gdm = Path(os.environ['XDG_RUNTIME_DIR']) / 'gdm/Xauthority'
os.environ['XAUTHORITY'] = str(gdm if gdm.exists() else Path.home() / '.Xauthority')
for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
signal.signal(sig, lambda *_: sys.exit(0))
mode = sys.argv[1] if len(sys.argv) > 1 else 'stream'
if mode == 'recover':
recover()
elif mode == 'guard':
guard()
elif mode == 'stream':
stream()
else:
raise ValueError('unknown mode')
if __name__ == '__main__':
main()
+124
View File
@@ -0,0 +1,124 @@
"""Fail-closed guard for the current virtio GPU's cursor-free KMS scanout.
Only primary planes visible with Sunshine's exact DRM client capabilities are
accepted. Virtio cursor hotspot planes are hidden from atomic clients that do
not request DRM_CLIENT_CAP_CURSOR_PLANE_HOTSPOT. This is not a cursor filter.
"""
import ctypes as C
import os
import re
class PlaneResources(C.Structure):
_fields_ = [('count', C.c_uint32), ('planes', C.POINTER(C.c_uint32))]
class Plane(C.Structure):
_fields_ = [('count_formats', C.c_uint32), ('formats', C.POINTER(C.c_uint32)),
('plane_id', C.c_uint32), ('crtc_id', C.c_uint32),
('fb_id', C.c_uint32), ('crtc_x', C.c_uint32),
('crtc_y', C.c_uint32), ('x', C.c_uint32), ('y', C.c_uint32),
('possible_crtcs', C.c_uint32), ('gamma_size', C.c_uint32)]
class Properties(C.Structure):
_fields_ = [('count', C.c_uint32), ('props', C.POINTER(C.c_uint32)),
('values', C.POINTER(C.c_uint64))]
class PropertyPrefix(C.Structure):
_fields_ = [('id', C.c_uint32), ('flags', C.c_uint32), ('name', C.c_char * 32)]
class Framebuffer(C.Structure):
_fields_ = [('id', C.c_uint32), ('width', C.c_uint32), ('height', C.c_uint32),
('format', C.c_uint32), ('modifier', C.c_uint64),
('flags', C.c_uint32), ('handles', C.c_uint32 * 4),
('pitches', C.c_uint32 * 4), ('offsets', C.c_uint32 * 4)]
class VersionPrefix(C.Structure):
_fields_ = [('major', C.c_int), ('minor', C.c_int), ('patch', C.c_int),
('name_len', C.c_int), ('name', C.c_char_p)]
class VirtioScanout:
def __init__(self, config):
if set(config) != {'backend', 'card', 'size'} or config['backend'] != 'kms-virtio-no-cursor-plane':
raise ValueError('unsupported KMS cursor configuration')
self.card = config['card']
if not isinstance(self.card, str) or not re.fullmatch('/dev/dri/card[0-9]+', self.card):
raise ValueError('invalid DRM card')
self.size = config['size']
if self.size != [2560, 1440]:
raise ValueError('unverified KMS geometry')
self.drm = C.CDLL('libdrm.so.2')
self.drm.drmSetClientCap.argtypes = [C.c_int, C.c_uint64, C.c_uint64]
for name, args, result in [
('drmModeGetPlaneResources', [C.c_int], C.POINTER(PlaneResources)),
('drmModeGetPlane', [C.c_int,C.c_uint32], C.POINTER(Plane)),
('drmModeObjectGetProperties', [C.c_int,C.c_uint32,C.c_uint32], C.POINTER(Properties)),
('drmModeGetProperty', [C.c_int,C.c_uint32], C.POINTER(PropertyPrefix)),
('drmModeGetFB2', [C.c_int,C.c_uint32], C.POINTER(Framebuffer)),
('drmGetVersion', [C.c_int], C.POINTER(VersionPrefix)),
]:
fn = getattr(self.drm, name); fn.argtypes = args; fn.restype = result
for name in ['drmModeFreePlaneResources','drmModeFreePlane',
'drmModeFreeObjectProperties','drmModeFreeProperty','drmModeFreeFB2',
'drmFreeVersion']:
fn = getattr(self.drm, name); fn.argtypes = [C.c_void_p]; fn.restype = None
def check(self):
fd = os.open(self.card, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW)
try:
version = self.drm.drmGetVersion(fd)
if not version: raise RuntimeError('DRM driver unavailable')
try:
if version.contents.name != b'virtio_gpu':
raise RuntimeError('KMS driver changed')
finally: self.drm.drmFreeVersion(version)
if self.drm.drmSetClientCap(fd, 2, 1):
raise RuntimeError('cannot enumerate universal planes')
# Match the pinned Sunshine KMS backend. Do not request capability
# 6 (cursor-plane hotspots), which would expose the virtio cursor.
if self.drm.drmSetClientCap(fd, 3, 1):
raise RuntimeError('atomic KMS is required')
resources = self.drm.drmModeGetPlaneResources(fd)
if not resources: raise RuntimeError('DRM plane list unavailable')
try:
# This tested virtio device exposes exactly one primary plane.
if resources.contents.count != 1:
raise RuntimeError('unverified DRM plane topology')
plane = self.drm.drmModeGetPlane(fd, resources.contents.planes[0])
if not plane: raise RuntimeError('DRM plane unavailable')
try:
p = plane.contents
props = self.drm.drmModeObjectGetProperties(fd, p.plane_id, 0xeeeeeeee)
if not props: raise RuntimeError('DRM plane properties unavailable')
kind = None
crtc_w = None
crtc_h = None
try:
if props.contents.count > 128: raise RuntimeError('invalid property count')
for i in range(props.contents.count):
prop = self.drm.drmModeGetProperty(fd, props.contents.props[i])
if not prop: raise RuntimeError('DRM property unavailable')
try:
name = prop.contents.name
value = props.contents.values[i]
if name == b'type': kind = value
elif name == b'CRTC_W': crtc_w = value
elif name == b'CRTC_H': crtc_h = value
finally: self.drm.drmModeFreeProperty(prop)
finally: self.drm.drmModeFreeObjectProperties(props)
# Atomic virtio clients expose CRTC geometry while the
# legacy plane fields remain zero. Sunshine uses the same
# atomic client capability pair and validates the actual
# framebuffer when it captures it.
if kind != 1 or [crtc_w, crtc_h] != self.size:
raise RuntimeError('scanout is not an active primary-only plane')
return {'driver': 'virtio_gpu', 'plane': p.plane_id, 'type': kind,
'crtc': int(p.crtc_id), 'size': self.size, 'cursor_planes': 0}
finally: self.drm.drmModeFreePlane(plane)
finally: self.drm.drmModeFreePlaneResources(resources)
finally: os.close(fd)
@@ -0,0 +1 @@
{"backend":"kms-virtio-no-cursor-plane","card":"/dev/dri/card1","size":[2560,1440]}
+158
View File
@@ -0,0 +1,158 @@
#pragma once
#include <QFile>
#include <QImage>
#include <QSaveFile>
#include <QtEndian>
#include <time.h>
#include "input/input.h"
#include "streamutils.h"
// Linux X11 single-seat native cursor. Called only by the SDL main thread.
// The producer starts cursor-free before this session's first captured frame.
class VdiNativeCursor {
public:
VdiNativeCursor(SDL_Window* window, SdlInputHandler* input) :
m_Window(window), m_Input(input), m_Path(qgetenv("VDI_NATIVE_CURSOR_STATE")),
m_Epoch(qgetenv("VDI_NATIVE_CURSOR_EPOCH").toULongLong()) {}
bool enabled() const { return !m_Path.isEmpty(); }
void observe(const SDL_Event& event) {
if (event.type == SDL_MOUSEMOTION || event.type == SDL_MOUSEBUTTONDOWN ||
event.type == SDL_MOUSEBUTTONUP) {
m_LastInput = nowNs();
++m_InputSequence;
}
}
bool tick() {
if (!enabled()) return true;
if (QByteArray(SDL_GetCurrentVideoDriver()) != "x11") return fail("requires X11 cursor backend");
const quint64 pollTime = nowNs();
if (pollTime - m_LastPoll < 4000000) return true;
m_LastPoll = pollTime;
QFile f(QString::fromUtf8(m_Path));
if (!f.open(QIODevice::ReadOnly) || f.size() < 76 || f.size() > 65608)
return fail("missing or oversized state");
const QByteArray packet = f.readAll();
// The producer may atomically replace state after pollTime. Compare
// against a timestamp taken AFTER the read, never before opening it.
const quint64 now = nowNs();
const auto* p = reinterpret_cast<const uchar*>(packet.constData());
auto u32 = [p](int offset) { return qFromLittleEndian<quint32>(p + offset); };
auto u64 = [p](int offset) { return qFromLittleEndian<quint64>(p + offset); };
if (packet.left(8) != QByteArray("VDICUR1\0", 8) || u64(8) != m_Epoch ||
u64(16) < m_Sequence || u64(24) > now || now - u64(24) > 2000000000)
return fail("stale or mismatched lease");
const int w = u32(32), h = u32(36), hx = u32(40), hy = u32(44);
const int x = qint32(u32(48)), y = qint32(u32(52));
const int sw = u32(56), sh = u32(60);
if (w < 1 || w > 128 || h < 1 || h > 128 || hx < 0 || hx >= w ||
hy < 0 || hy >= h || sw != 2560 || sh != 1440 ||
x < 0 || x >= sw || y < 0 || y >= sh || packet.size() != 72 + w*h*4 ||
!m_Input->isNativeCursorAbsoluteMode())
return fail("unsupported shape, display or input mode");
m_Sequence = u64(16);
int ww, wh;
SDL_GetWindowSize(m_Window, &ww, &wh);
SDL_Rect src = {0, 0, sw, sh}, dst = {0, 0, ww, wh};
StreamUtils::scaleSourceToDestinationSurface(&src, &dst);
if (dst.w <= 0 || dst.h <= 0) return fail("empty viewport");
const int cw = qMax(1, qRound(double(w) * dst.w / sw));
const int ch = qMax(1, qRound(double(h) * dst.h / sh));
if (cw > 256 || ch > 256) return fail("oversized scaled cursor");
const quint64 acknowledged = u64(64 + w*h*4);
const QByteArray pixels = packet.mid(64, w*h*4);
if (pixels != m_Pixels || w != m_Width || h != m_Height || hx != m_HotX ||
hy != m_HotY || dst.w != m_ViewWidth || dst.h != m_ViewHeight) {
QImage image(reinterpret_cast<const uchar*>(pixels.constData()), w, h,
w * 4, QImage::Format_RGBA8888);
image = image.scaled(cw, ch, Qt::IgnoreAspectRatio, Qt::FastTransformation);
// SDL2's X11 Xcursor backend copies pixels without premultiplying.
// XRender requires premultiplied channels, including antialiased edges.
image = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied);
SDL_Surface* surface = SDL_CreateRGBSurfaceWithFormatFrom(
image.bits(), cw, ch, 32, image.bytesPerLine(), SDL_PIXELFORMAT_RGBA32);
if (!surface) return fail("SDL surface failed");
SDL_Cursor* next = SDL_CreateColorCursor(surface,
qMin(cw - 1, qRound(double(hx) * dst.w / sw)),
qMin(ch - 1, qRound(double(hy) * dst.h / sh)));
SDL_FreeSurface(surface);
if (!next) return fail("SDL cursor failed");
SDL_SetCursor(next);
if (m_Cursor) SDL_FreeCursor(m_Cursor);
m_Cursor = next;
m_Pixels = pixels;
m_Width = w; m_Height = h; m_HotX = hx; m_HotY = hy;
m_ViewWidth = dst.w; m_ViewHeight = dst.h;
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION,
"VDI native shape: %dx%d hotspot=%d,%d sequence=%llu",
w, h, hx, hy, (unsigned long long)m_Sequence);
}
int mx, my;
const auto buttons = SDL_GetMouseState(&mx, &my);
const bool inside = mx >= dst.x && my >= dst.y && mx < dst.x + dst.w && my < dst.y + dst.h;
const bool focused = SDL_GetKeyboardFocus() == m_Window;
if (m_PublishedInput != m_InputSequence) {
QByteArray message(16, '\0');
qToLittleEndian<quint64>(m_InputSequence, reinterpret_cast<uchar*>(message.data()));
qToLittleEndian<qint32>(qBound(0, (mx-dst.x)*sw/dst.w, sw-1),
reinterpret_cast<uchar*>(message.data()+8));
qToLittleEndian<qint32>(qBound(0, (my-dst.y)*sh/dst.h, sh-1),
reinterpret_cast<uchar*>(message.data()+12));
QSaveFile outgoing(QString::fromUtf8(m_Path)+".input");
if (!outgoing.open(QIODevice::WriteOnly) || outgoing.write(message) != 16 || !outgoing.commit())
return fail("input acknowledgement file failed");
m_PublishedInput = m_InputSequence;
}
m_Input->enableNativeCursorVisibility();
SDL_SetCursor(focused && inside ? m_Cursor : SDL_GetDefaultCursor());
// Initial alignment, and host-initiated warps only after local input has
// settled. Never pull the pointer back using delayed motion echoes.
if (focused && inside && !buttons && acknowledged == m_InputSequence &&
now - m_LastInput > 500000000) {
const int tx = dst.x + qRound(double(x) * dst.w / sw);
const int ty = dst.y + qRound(double(y) * dst.h / sh);
if (qAbs(tx - mx) > 2 || qAbs(ty - my) > 2) {
// SDL2's X11 in-window warp is a no-op with pointer barriers
// (used by fullscreen absolute mode). Global warp honors this
// in-viewport target while actually moving the native pointer.
int wx, wy;
SDL_GetWindowPosition(m_Window, &wx, &wy);
if (SDL_WarpMouseGlobal(wx+tx, wy+ty) < 0)
return fail("host cursor warp failed");
m_LastInput = now;
}
}
return true;
}
void shutdown() {
if (m_Cursor) {
SDL_SetCursor(SDL_GetDefaultCursor());
SDL_FreeCursor(m_Cursor);
m_Cursor = nullptr;
}
}
private:
static quint64 nowNs() {
struct timespec t;
clock_gettime(CLOCK_MONOTONIC, &t);
return quint64(t.tv_sec)*1000000000 + t.tv_nsec;
}
bool fail(const char* reason) {
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "VDI native cursor ended: %s", reason);
shutdown();
return false;
}
SDL_Window* m_Window;
SdlInputHandler* m_Input;
QByteArray m_Path, m_Pixels;
quint64 m_Epoch, m_Sequence = 0, m_LastPoll = 0, m_LastInput = nowNs();
quint64 m_InputSequence = 0, m_PublishedInput = 0;
SDL_Cursor* m_Cursor = nullptr;
int m_Width = 0, m_Height = 0, m_HotX = 0, m_HotY = 0;
int m_ViewWidth = 0, m_ViewHeight = 0;
};