功能:接入Linux原生光标并完善设备接入工具
This commit is contained in:
1 parent
a14f028402
commit
a47968496e
92 files changed
+3090
-22
No files matched your search
@@ -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()
|
||||
Reference in new issue
Block a user