diff --git a/docs/evidence/native-cursor-20261105/capture-isolation.json b/docs/evidence/native-cursor-20261105/capture-isolation.json new file mode 100644 index 0000000..9724154 --- /dev/null +++ b/docs/evidence/native-cursor-20261105/capture-isolation.json @@ -0,0 +1,28 @@ +[ + { + "host": "192.168.1.72", + "normal": { + "width": 24, + "height": 24, + "visible_pixels": 240 + }, + "adapter": { + "width": 24, + "height": 24, + "visible_pixels": 0 + } + }, + { + "host": "192.168.1.71", + "normal": { + "width": 16, + "height": 16, + "visible_pixels": 0 + }, + "adapter": { + "width": 16, + "height": 16, + "visible_pixels": 0 + } + } +] \ No newline at end of file diff --git a/docs/evidence/native-cursor-20261105/click-verification.json b/docs/evidence/native-cursor-20261105/click-verification.json new file mode 100644 index 0000000..4cf2d38 --- /dev/null +++ b/docs/evidence/native-cursor-20261105/click-verification.json @@ -0,0 +1,16 @@ +{ + "ubuntu": { + "measured_clicks": 28, + "matched_source_events": 28, + "max_coordinate_error_pixels": 0.9814453125, + "p50_ms": 188.9504005, + "p95_ms": 468.920931 + }, + "kylin": { + "measured_clicks": 28, + "matched_source_events": 28, + "max_coordinate_error_pixels": 0.74267578125, + "p50_ms": 127.61443249999999, + "p95_ms": 168.630841 + } +} \ No newline at end of file diff --git a/docs/evidence/native-cursor-20261105/client-sha.txt b/docs/evidence/native-cursor-20261105/client-sha.txt new file mode 100644 index 0000000..2ddd920 --- /dev/null +++ b/docs/evidence/native-cursor-20261105/client-sha.txt @@ -0,0 +1 @@ +5d4330f703d7f8e053bc3795ca42bdaf231c9e9dd8ebb00a612013adbcccee03 diff --git a/docs/evidence/native-cursor-20261105/deployment-final.json b/docs/evidence/native-cursor-20261105/deployment-final.json new file mode 100644 index 0000000..fe83043 --- /dev/null +++ b/docs/evidence/native-cursor-20261105/deployment-final.json @@ -0,0 +1,13 @@ +{ + "192.168.1.71": { + "host_py_sha256": "41f6a9a7dcbe3b916864eb3a4bb1b2cbaf669c779256f913fbeef4f53cf09b65", + "client_sha256": "5d4330f703d7f8e053bc3795ca42bdaf231c9e9dd8ebb00a612013adbcccee03", + "status": "600\nActiveState=inactive\n" + }, + "192.168.1.72": { + "host_py_sha256": "41f6a9a7dcbe3b916864eb3a4bb1b2cbaf669c779256f913fbeef4f53cf09b65", + "client_sha256": "5d4330f703d7f8e053bc3795ca42bdaf231c9e9dd8ebb00a612013adbcccee03", + "status": "600\nActiveState=active\n39181 /usr/bin/python3 /home/test2/vdi-terminal/native-cursor-v1/host.py stream\n" + }, + "box_helper_sha256": "44b3e8a0ca591fbde42cf3372d343ab4cb603c089e7805bbccf6d261b2e173ac" +} \ No newline at end of file diff --git a/docs/evidence/native-cursor-20261105/fast-input.py b/docs/evidence/native-cursor-20261105/fast-input.py new file mode 100644 index 0000000..b9f718c --- /dev/null +++ b/docs/evidence/native-cursor-20261105/fast-input.py @@ -0,0 +1,103 @@ +"""Run only with the controlled GTK acceptance window filling the stream.""" +import ctypes as C +import json +import os +from pathlib import Path +import signal +import statistics +import struct +import subprocess +import time + +os.environ.update(DISPLAY=':0', XAUTHORITY='/home/armbian/.Xauthority') +x = C.CDLL('libX11.so.6') +fix = C.CDLL('libXfixes.so.3') +test = C.CDLL('libXtst.so.6') +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)] +x.XOpenDisplay.argtypes = [C.c_char_p] +x.XOpenDisplay.restype = C.c_void_p +x.XDefaultRootWindow.argtypes = [C.c_void_p] +x.XDefaultRootWindow.restype = C.c_ulong +x.XFree.argtypes = [C.c_void_p] +x.XSync.argtypes = [C.c_void_p, C.c_int] +x.XWarpPointer.argtypes = [C.c_void_p, C.c_ulong, C.c_ulong, C.c_int, + C.c_int, C.c_uint, C.c_uint, C.c_int, C.c_int] +x.XGetImage.argtypes = [C.c_void_p, C.c_ulong, C.c_int, C.c_int, + C.c_uint, C.c_uint, C.c_ulong, C.c_int] +x.XGetImage.restype = C.c_void_p +x.XGetPixel.argtypes = [C.c_void_p, C.c_int, C.c_int] +x.XGetPixel.restype = C.c_ulong +x.XDestroyImage.argtypes = [C.c_void_p] +fix.XFixesGetCursorImage.argtypes = [C.c_void_p] +fix.XFixesGetCursorImage.restype = C.POINTER(Cursor) +test.XTestFakeButtonEvent.argtypes = [C.c_void_p, C.c_uint, C.c_int, C.c_ulong] +d = x.XOpenDisplay(None) +assert d +root = x.XDefaultRootWindow(d) + + +def native(): + p = fix.XFixesGetCursorImage(d) + assert p + try: + c = p.contents + return dict(x=c.x, y=c.y, w=c.w, h=c.h, hx=c.hx, hy=c.hy, + pixels=[int(c.pixels[i]) & 0xffffffff for i in range(c.w*c.h)]) + finally: + x.XFree(p) + + +def warp(px, py): + x.XWarpPointer(d, 0, root, 0, 0, 0, 0, px, py) + x.XSync(d, 0) + + +def decoded_sequence(): + im = x.XGetImage(d, root, 40, 1370, 640, 1, 0xffffffff, 2) + assert im + try: + values = [x.XGetPixel(im, i*40+20, 0) & 0xffffff for i in range(16)] + if any(0x202020 <= v <= 0xe0e0e0 for v in values): + return -1 + return sum((1 << i) for i, v in enumerate(values) if v > 0xe0e0e0) + finally: + x.XDestroyImage(im) + + +def flat_video_crop(px, py): + im = x.XGetImage(d, root, px-40, py-40, 100, 100, 0xffffffff, 2) + assert im + try: + pixels = [x.XGetPixel(im, a, b) & 0xffffff for b in range(100) for a in range(100)] + # No baked-in pointer: uniform acceptance-window background at this point. + ref = pixels[0] + bad = sum(any(abs(((v >> s) & 255) - ((ref >> s) & 255)) > 8 + for s in (0, 8, 16)) for v in pixels) + return dict(reference=ref, nonuniform_pixels=bad) + finally: + x.XDestroyImage(im) + + + +start_sequence=decoded_sequence() +results=[] +for i in range(16): + px,py=((300,800) if i%2==0 else (1500,900)) + warp(px,py);time.sleep(.18) + start=time.monotonic_ns();test.XTestFakeButtonEvent(d,1,1,0);x.XSync(d,0) + time.sleep(.04);test.XTestFakeButtonEvent(d,1,0,0);x.XSync(d,0) + target=start_sequence+i+1 + while decoded_sequence()!=target and time.monotonic_ns()-start<2000000000:time.sleep(.002) + seen=decoded_sequence();results.append(dict(seq=target,seen=seen,x=px,y=py));assert seen==target,results[-1] + time.sleep(.08) +# Two quick clicks before waiting for their combined numbered video update. +start=decoded_sequence() +for i in range(2): + test.XTestFakeButtonEvent(d,1,1,0);x.XSync(d,0);time.sleep(.04);test.XTestFakeButtonEvent(d,1,0,0);x.XSync(d,0);time.sleep(.07) +end=time.monotonic()+2 +while decoded_sequence()!=start+2 and time.monotonic() 0xe0e0e0) + finally: + x.XDestroyImage(im) + + +def flat_video_crop(px, py): + im = x.XGetImage(d, root, px-40, py-40, 100, 100, 0xffffffff, 2) + assert im + try: + pixels = [x.XGetPixel(im, a, b) & 0xffffff for b in range(100) for a in range(100)] + # No baked-in pointer: uniform acceptance-window background at this point. + ref = pixels[0] + bad = sum(any(abs(((v >> s) & 255) - ((ref >> s) & 255)) > 8 + for s in (0, 8, 16)) for v in pixels) + return dict(reference=ref, nonuniform_pixels=bad) + finally: + x.XDestroyImage(im) + + + +focus=C.c_ulong();revert=C.c_int() +x.XGetInputFocus.argtypes=[C.c_void_p,C.POINTER(C.c_ulong),C.POINTER(C.c_int)] +x.XSetInputFocus.argtypes=[C.c_void_p,C.c_ulong,C.c_int,C.c_ulong] +x.XGetInputFocus(d,C.byref(focus),C.byref(revert)) +before=native() +try: + x.XSetInputFocus(d,0,1,0);x.XSync(d,0);time.sleep(.25);unfocused=native() +finally: + x.XSetInputFocus(d,focus.value,revert.value,0);x.XSync(d,0) +time.sleep(.3);after=native() +print(json.dumps(dict(original_focus=focus.value,unfocused_shape_changed=before['pixels']!=unfocused['pixels'],native_restored=before['pixels']==after['pixels'],position=[after['x'],after['y']]))) diff --git a/docs/evidence/native-cursor-20261105/idle-final.json b/docs/evidence/native-cursor-20261105/idle-final.json new file mode 100644 index 0000000..5982b82 --- /dev/null +++ b/docs/evidence/native-cursor-20261105/idle-final.json @@ -0,0 +1,10 @@ +{ + "192.168.1.71": { + "output": "MainPID=184769\nExecStart={ path=/opt/vdi-sunshine-0.21.0/usr/bin/sunshine ; argv[]=/opt/vdi-sunshine-0.21.0/usr/bin/sunshine /home/test1/.config/sunshine/sunshine.conf ; ignore_errors=no ; start_time=[Thu 2026-11-05 11:33:00 CST] ; stop_time=[n/a] ; pid=184769 ; code=(null) ; status=0/0 }\nId=vdi-sunshine.service\nActiveState=active\n\nMainPID=0\nExecStart={ path=/usr/bin/python3 ; argv[]=/usr/bin/python3 /home/test1/vdi-terminal/native-cursor-v1/host.py guard ; ignore_errors=no ; start_time=[n/a] ; stop_time=[n/a] ; pid=0 ; code=(null) ; status=0/0 }\nId=vdi-native-cursor-guard.service\nActiveState=inactive\n\nMainPID=0\nId=vdi-native-cursor-source.service\nActiveState=inactive\n", + "stderr": "sha256sum: /usr/bin/sunshine: 没有那个文件或目录\n/usr/bin/sunshine (No such file or directory)\n" + }, + "192.168.1.72": { + "output": "MainPID=32278\nExecStart={ path=/usr/bin/sunshine ; argv[]=/usr/bin/sunshine /home/test2/.config/sunshine/sunshine.conf ; ignore_errors=no ; start_time=[Thu 2026-11-05 11:43:42 CST] ; stop_time=[n/a] ; pid=32278 ; code=(null) ; status=0/0 }\nId=vdi-sunshine.service\nActiveState=active\n\nMainPID=0\nExecStart={ path=/usr/bin/python3 ; argv[]=/usr/bin/python3 /home/test2/vdi-terminal/native-cursor-v1/host.py guard ; ignore_errors=no ; start_time=[n/a] ; stop_time=[n/a] ; pid=0 ; code=(null) ; status=0/0 }\nId=vdi-native-cursor-guard.service\nActiveState=inactive\n\nMainPID=0\nId=vdi-native-cursor-source.service\nActiveState=inactive\n61e1ecbc8db2df786b9760c652aa4b9885dff36c57d4af09616344314cb7c34c /usr/bin/sunshine\n/usr/bin/sunshine cap_sys_admin,cap_sys_nice=p\n", + "stderr": "" + } +} \ No newline at end of file diff --git a/docs/evidence/native-cursor-20261105/kylin-fast-input.json b/docs/evidence/native-cursor-20261105/kylin-fast-input.json new file mode 100644 index 0000000..1bd92b1 --- /dev/null +++ b/docs/evidence/native-cursor-20261105/kylin-fast-input.json @@ -0,0 +1 @@ +{"clicks": [{"seq": 29, "seen": 29, "x": 300, "y": 800}, {"seq": 30, "seen": 30, "x": 1500, "y": 900}, {"seq": 31, "seen": 31, "x": 300, "y": 800}, {"seq": 32, "seen": 32, "x": 1500, "y": 900}, {"seq": 33, "seen": 33, "x": 300, "y": 800}, {"seq": 34, "seen": 34, "x": 1500, "y": 900}, {"seq": 35, "seen": 35, "x": 300, "y": 800}, {"seq": 36, "seen": 36, "x": 1500, "y": 900}, {"seq": 37, "seen": 37, "x": 300, "y": 800}, {"seq": 38, "seen": 38, "x": 1500, "y": 900}, {"seq": 39, "seen": 39, "x": 300, "y": 800}, {"seq": 40, "seen": 40, "x": 1500, "y": 900}, {"seq": 41, "seen": 41, "x": 300, "y": 800}, {"seq": 42, "seen": 42, "x": 1500, "y": 900}, {"seq": 43, "seen": 43, "x": 300, "y": 800}, {"seq": 44, "seen": 44, "x": 1500, "y": 900}], "double_click_start": 44, "double_click_end": 46} diff --git a/docs/evidence/native-cursor-20261105/kylin-final-probe.json b/docs/evidence/native-cursor-20261105/kylin-final-probe.json new file mode 100644 index 0000000..dfdf833 --- /dev/null +++ b/docs/evidence/native-cursor-20261105/kylin-final-probe.json @@ -0,0 +1 @@ +{"host": "192.168.1.71", "pid": 267646, "shapes": [{"name": "default", "source": {"w": 24, "h": 24, "hx": 4, "hy": 0, "x": 182, "y": 600, "sw": 2560, "sh": 1440}, "local": {"x": 182, "y": 600, "w": 24, "h": 24, "hx": 4, "hy": 0}, "geometry_matches": true, "max_pixel_difference": 0, "visible_pixels": 300, "video": {"reference": 15726330, "nonuniform_pixels": 0}}, {"name": "text", "source": {"w": 24, "h": 24, "hx": 11, "hy": 11, "x": 548, "y": 600, "sw": 2560, "sh": 1440}, "local": {"x": 548, "y": 600, "w": 24, "h": 24, "hx": 11, "hy": 11}, "geometry_matches": true, "max_pixel_difference": 0, "visible_pixels": 216, "video": {"reference": 15791866, "nonuniform_pixels": 0}}, {"name": "ew-resize", "source": {"w": 24, "h": 24, "hx": 11, "hy": 11, "x": 914, "y": 600, "sw": 2560, "sh": 1440}, "local": {"x": 914, "y": 600, "w": 24, "h": 24, "hx": 11, "hy": 11}, "geometry_matches": true, "max_pixel_difference": 0, "visible_pixels": 204, "video": {"reference": 15726330, "nonuniform_pixels": 0}}, {"name": "wait", "source": {"w": 24, "h": 24, "hx": 12, "hy": 12, "x": 1280, "y": 600, "sw": 2560, "sh": 1440}, "local": {"x": 1280, "y": 600, "w": 24, "h": 24, "hx": 12, "hy": 12}, "geometry_matches": true, "max_pixel_difference": 0, "visible_pixels": 290, "video": {"reference": 15791866, "nonuniform_pixels": 0}}, {"name": "none", "source": {"w": 1, "h": 1, "hx": 0, "hy": 0, "x": 1645, "y": 600, "sw": 2560, "sh": 1440}, "local": {"x": 1645, "y": 600, "w": 1, "h": 1, "hx": 0, "hy": 0}, "geometry_matches": true, "max_pixel_difference": 0, "visible_pixels": 0, "video": {"reference": 15726074, "nonuniform_pixels": 0}}, {"name": "crosshair", "source": {"w": 24, "h": 24, "hx": 11, "hy": 11, "x": 2011, "y": 600, "sw": 2560, "sh": 1440}, "local": {"x": 2011, "y": 600, "w": 24, "h": 24, "hx": 11, "hy": 11}, "geometry_matches": true, "max_pixel_difference": 0, "visible_pixels": 252, "video": {"reference": 15726074, "nonuniform_pixels": 0}}, {"name": "custom", "source": {"w": 24, "h": 24, "hx": 7, "hy": 9, "x": 2377, "y": 600, "sw": 2560, "sh": 1440}, "local": {"x": 2377, "y": 600, "w": 24, "h": 24, "hx": 7, "hy": 9}, "geometry_matches": true, "max_pixel_difference": 0, "visible_pixels": 576, "video": {"reference": 15726074, "nonuniform_pixels": 0}}], "paused_client": {"position": [350, 700], "elapsed_ms": 249.580297, "same_shape": true, "steps": [{"target": [250, 650], "position": [250, 650], "query_ms": 2.364751}, {"target": [280, 670], "position": [280, 670], "query_ms": 2.234379}, {"target": [300, 690], "position": [300, 690], "query_ms": 2.128216}, {"target": [350, 700], "position": [350, 700], "query_ms": 2.286878}]}, "clicks": [{"seq": 1, "x": 182, "y": 750, "seen": 1, "decoded_ms": 108.930461}, {"seq": 2, "x": 548, "y": 850, "seen": 2, "decoded_ms": 98.691574}, {"seq": 3, "x": 914, "y": 750, "seen": 3, "decoded_ms": 152.320189}, {"seq": 4, "x": 1280, "y": 850, "seen": 4, "decoded_ms": 125.352183}, {"seq": 5, "x": 1645, "y": 750, "seen": 5, "decoded_ms": 135.816524}, {"seq": 6, "x": 2011, "y": 850, "seen": 6, "decoded_ms": 111.517755}, {"seq": 7, "x": 2377, "y": 750, "seen": 7, "decoded_ms": 108.898971}, {"seq": 8, "x": 182, "y": 850, "seen": 8, "decoded_ms": 119.075451}, {"seq": 9, "x": 548, "y": 750, "seen": 9, "decoded_ms": 134.943313}, {"seq": 10, "x": 914, "y": 850, "seen": 10, "decoded_ms": 146.081384}, {"seq": 11, "x": 1280, "y": 750, "seen": 11, "decoded_ms": 117.025403}, {"seq": 12, "x": 1645, "y": 850, "seen": 12, "decoded_ms": 152.719197}, {"seq": 13, "x": 2011, "y": 750, "seen": 13, "decoded_ms": 141.342555}, {"seq": 14, "x": 2377, "y": 850, "seen": 14, "decoded_ms": 130.95113}, {"seq": 15, "x": 182, "y": 750, "seen": 15, "decoded_ms": 143.698269}, {"seq": 16, "x": 548, "y": 850, "seen": 16, "decoded_ms": 114.9634}, {"seq": 17, "x": 914, "y": 750, "seen": 17, "decoded_ms": 159.173858}, {"seq": 18, "x": 1280, "y": 850, "seen": 18, "decoded_ms": 175.329882}, {"seq": 19, "x": 1645, "y": 750, "seen": 19, "decoded_ms": 119.051263}, {"seq": 20, "x": 2011, "y": 850, "seen": 20, "decoded_ms": 124.040341}, {"seq": 21, "x": 2377, "y": 750, "seen": 21, "decoded_ms": 121.588678}, {"seq": 22, "x": 182, "y": 850, "seen": 22, "decoded_ms": 129.876682}, {"seq": 23, "x": 548, "y": 750, "seen": 23, "decoded_ms": 124.467041}, {"seq": 24, "x": 914, "y": 850, "seen": 24, "decoded_ms": 153.052003}, {"seq": 25, "x": 1280, "y": 750, "seen": 25, "decoded_ms": 136.166846}, {"seq": 26, "x": 1645, "y": 850, "seen": 26, "decoded_ms": 168.630841}, {"seq": 27, "x": 2011, "y": 750, "seen": 27, "decoded_ms": 119.541845}, {"seq": 28, "x": 2377, "y": 850, "seen": 28, "decoded_ms": 110.41562}], "p50_ms": 127.61443249999999, "p95_ms": 168.630841, "method": "XTest click-down to unique decoded sequence; excludes USB and scanout"} diff --git a/docs/evidence/native-cursor-20261105/kylin-first.png b/docs/evidence/native-cursor-20261105/kylin-first.png new file mode 100644 index 0000000..c503c42 Binary files /dev/null and b/docs/evidence/native-cursor-20261105/kylin-first.png differ diff --git a/docs/evidence/native-cursor-20261105/kylin-first.xwd.gz b/docs/evidence/native-cursor-20261105/kylin-first.xwd.gz new file mode 100644 index 0000000..1613e48 Binary files /dev/null and b/docs/evidence/native-cursor-20261105/kylin-first.xwd.gz differ diff --git a/docs/evidence/native-cursor-20261105/kylin-focus.json b/docs/evidence/native-cursor-20261105/kylin-focus.json new file mode 100644 index 0000000..5db947b --- /dev/null +++ b/docs/evidence/native-cursor-20261105/kylin-focus.json @@ -0,0 +1 @@ +{"original_focus": 48234511, "unfocused_shape_changed": false, "native_restored": true, "position": [400, 600]} diff --git a/docs/evidence/native-cursor-20261105/kylin-normal-client.json b/docs/evidence/native-cursor-20261105/kylin-normal-client.json new file mode 100644 index 0000000..24b1cf8 --- /dev/null +++ b/docs/evidence/native-cursor-20261105/kylin-normal-client.json @@ -0,0 +1 @@ +{"pid": 297530, "args": "/home/armbian/vdi-build/rkmpp-experiment/build/moonlight/app/moonlight stream 192.168.1.71 Desktop --resolution 2560x1440 --fps 30 --bitrate 30000 --display-mode fullscreen --video-codec H.264 --video-decoder hardware --absolute-mouse --capture-system-keys always --keep-awake --no-vsync --no-frame-pacing --audio-on-host ", "sha256": "5d4330f703d7f8e053bc3795ca42bdaf231c9e9dd8ebb00a612013adbcccee03", "native": true, "shape": [24, 24, 4, 0]} diff --git a/docs/evidence/native-cursor-20261105/kylin-normal.png b/docs/evidence/native-cursor-20261105/kylin-normal.png new file mode 100644 index 0000000..f711129 Binary files /dev/null and b/docs/evidence/native-cursor-20261105/kylin-normal.png differ diff --git a/docs/evidence/native-cursor-20261105/kylin-release-probe.json b/docs/evidence/native-cursor-20261105/kylin-release-probe.json new file mode 100644 index 0000000..fb2271e --- /dev/null +++ b/docs/evidence/native-cursor-20261105/kylin-release-probe.json @@ -0,0 +1 @@ +{"host": "192.168.1.71", "pid": 297530, "shapes": [{"name": "default", "source": {"w": 24, "h": 24, "hx": 4, "hy": 0, "x": 182, "y": 600, "sw": 2560, "sh": 1440}, "local": {"x": 182, "y": 600, "w": 24, "h": 24, "hx": 4, "hy": 0}, "geometry_matches": true, "max_pixel_difference": 0, "visible_pixels": 300, "video": {"reference": 15726330, "nonuniform_pixels": 0}}, {"name": "text", "source": {"w": 24, "h": 24, "hx": 11, "hy": 11, "x": 548, "y": 600, "sw": 2560, "sh": 1440}, "local": {"x": 548, "y": 600, "w": 24, "h": 24, "hx": 11, "hy": 11}, "geometry_matches": true, "max_pixel_difference": 0, "visible_pixels": 216, "video": {"reference": 15791866, "nonuniform_pixels": 0}}, {"name": "ew-resize", "source": {"w": 24, "h": 24, "hx": 11, "hy": 11, "x": 914, "y": 600, "sw": 2560, "sh": 1440}, "local": {"x": 914, "y": 600, "w": 24, "h": 24, "hx": 11, "hy": 11}, "geometry_matches": true, "max_pixel_difference": 0, "visible_pixels": 204, "video": {"reference": 15726330, "nonuniform_pixels": 0}}, {"name": "wait", "source": {"w": 24, "h": 24, "hx": 12, "hy": 12, "x": 1280, "y": 600, "sw": 2560, "sh": 1440}, "local": {"x": 1280, "y": 600, "w": 24, "h": 24, "hx": 12, "hy": 12}, "geometry_matches": true, "max_pixel_difference": 0, "visible_pixels": 290, "video": {"reference": 15791866, "nonuniform_pixels": 0}}, {"name": "none", "source": {"w": 1, "h": 1, "hx": 0, "hy": 0, "x": 1645, "y": 600, "sw": 2560, "sh": 1440}, "local": {"x": 1645, "y": 600, "w": 1, "h": 1, "hx": 0, "hy": 0}, "geometry_matches": true, "max_pixel_difference": 0, "visible_pixels": 0, "video": {"reference": 15726074, "nonuniform_pixels": 0}}, {"name": "crosshair", "source": {"w": 24, "h": 24, "hx": 11, "hy": 11, "x": 2011, "y": 600, "sw": 2560, "sh": 1440}, "local": {"x": 2011, "y": 600, "w": 24, "h": 24, "hx": 11, "hy": 11}, "geometry_matches": true, "max_pixel_difference": 0, "visible_pixels": 252, "video": {"reference": 15726074, "nonuniform_pixels": 0}}, {"name": "custom", "source": {"w": 24, "h": 24, "hx": 7, "hy": 9, "x": 2377, "y": 600, "sw": 2560, "sh": 1440}, "local": {"x": 2377, "y": 600, "w": 24, "h": 24, "hx": 7, "hy": 9}, "geometry_matches": true, "max_pixel_difference": 0, "visible_pixels": 576, "video": {"reference": 15726074, "nonuniform_pixels": 0}}], "paused_client": {"position": [350, 700], "elapsed_ms": 249.614056, "same_shape": true, "steps": [{"target": [250, 650], "position": [250, 650], "query_ms": 2.255936}, {"target": [280, 670], "position": [280, 670], "query_ms": 2.398262}, {"target": [300, 690], "position": [300, 690], "query_ms": 2.188272}, {"target": [350, 700], "position": [350, 700], "query_ms": 2.242227}]}, "clicks": [{"seq": 1, "x": 182, "y": 750, "seen": 1, "decoded_ms": 149.895203}, {"seq": 2, "x": 548, "y": 850, "seen": 2, "decoded_ms": 120.380242}, {"seq": 3, "x": 914, "y": 750, "seen": 3, "decoded_ms": 119.660739}, {"seq": 4, "x": 1280, "y": 850, "seen": 4, "decoded_ms": 147.265671}, {"seq": 5, "x": 1645, "y": 750, "seen": 5, "decoded_ms": 145.663335}, {"seq": 6, "x": 2011, "y": 850, "seen": 6, "decoded_ms": 129.562066}, {"seq": 7, "x": 2377, "y": 750, "seen": 7, "decoded_ms": 134.743571}, {"seq": 8, "x": 182, "y": 850, "seen": 8, "decoded_ms": 140.630877}, {"seq": 9, "x": 548, "y": 750, "seen": 9, "decoded_ms": 130.420116}, {"seq": 10, "x": 914, "y": 850, "seen": 10, "decoded_ms": 107.155271}, {"seq": 11, "x": 1280, "y": 750, "seen": 11, "decoded_ms": 117.124838}, {"seq": 12, "x": 1645, "y": 850, "seen": 12, "decoded_ms": 130.236678}, {"seq": 13, "x": 2011, "y": 750, "seen": 13, "decoded_ms": 111.917383}, {"seq": 14, "x": 2377, "y": 850, "seen": 14, "decoded_ms": 136.811123}, {"seq": 15, "x": 182, "y": 750, "seen": 15, "decoded_ms": 106.746093}, {"seq": 16, "x": 548, "y": 850, "seen": 16, "decoded_ms": 108.595469}, {"seq": 17, "x": 914, "y": 750, "seen": 17, "decoded_ms": 123.440014}, {"seq": 18, "x": 1280, "y": 850, "seen": 18, "decoded_ms": 150.429282}, {"seq": 19, "x": 1645, "y": 750, "seen": 19, "decoded_ms": 117.354684}, {"seq": 20, "x": 2011, "y": 850, "seen": 20, "decoded_ms": 147.688919}, {"seq": 21, "x": 2377, "y": 750, "seen": 21, "decoded_ms": 135.505122}, {"seq": 22, "x": 182, "y": 850, "seen": 22, "decoded_ms": 142.014237}, {"seq": 23, "x": 548, "y": 750, "seen": 23, "decoded_ms": 195.466041}, {"seq": 24, "x": 914, "y": 850, "seen": 24, "decoded_ms": 165.559673}, {"seq": 25, "x": 1280, "y": 750, "seen": 25, "decoded_ms": 142.88512}, {"seq": 26, "x": 1645, "y": 850, "seen": 26, "decoded_ms": 127.731718}, {"seq": 27, "x": 2011, "y": 750, "seen": 27, "decoded_ms": 146.832648}, {"seq": 28, "x": 2377, "y": 850, "seen": 28, "decoded_ms": 133.995845}], "p50_ms": 134.369708, "p95_ms": 165.559673, "method": "XTest click-down to unique decoded sequence; excludes USB and scanout"} diff --git a/docs/evidence/native-cursor-20261105/kylin-resolution-change.json b/docs/evidence/native-cursor-20261105/kylin-resolution-change.json new file mode 100644 index 0000000..4c50b85 --- /dev/null +++ b/docs/evidence/native-cursor-20261105/kylin-resolution-change.json @@ -0,0 +1,5 @@ +{ + "recovered": true, + "seconds": 3.555, + "state": "ExecStart={ path=/opt/vdi-sunshine-0.21.0/usr/bin/sunshine ; argv[]=/opt/vdi-sunshine-0.21.0/usr/bin/sunshine /home/test1/.config/sunshine/sunshine.conf ; ignore_errors=no ; start_time=[Thu 2026-11-05 11:33:00 CST] ; stop_time=[n/a] ; pid=184769 ; code=(null) ; status=0/0 }\nActiveState=active\nScreen 0: minimum 320 x 200, current 2560 x 1440, maximum 8192 x 8192\nVirtual-1 connected primary 2560x1440+0+0 (normal left inverted right x axis y axis) 325mm x 203mm\n 1280x800 74.99 +\n 3840x2160 60.00 50.00 59.94 \n 1920x1440 60.00 \ninactive\n" +} \ No newline at end of file diff --git a/docs/evidence/native-cursor-20261105/kylin-warp.json b/docs/evidence/native-cursor-20261105/kylin-warp.json new file mode 100644 index 0000000..2c62d2d --- /dev/null +++ b/docs/evidence/native-cursor-20261105/kylin-warp.json @@ -0,0 +1,9 @@ +{ + "host": "192.168.1.71", + "target": [ + 400, + 600 + ], + "readback": "X=400\nY=600\nSCREEN=0\nWINDOW=48234511\n", + "passed": true +} \ No newline at end of file diff --git a/docs/evidence/native-cursor-20261105/list.png b/docs/evidence/native-cursor-20261105/list.png new file mode 100644 index 0000000..7066dbe Binary files /dev/null and b/docs/evidence/native-cursor-20261105/list.png differ diff --git a/docs/evidence/native-cursor-20261105/list.xwd.gz b/docs/evidence/native-cursor-20261105/list.xwd.gz new file mode 100644 index 0000000..beccd01 Binary files /dev/null and b/docs/evidence/native-cursor-20261105/list.xwd.gz differ diff --git a/docs/evidence/native-cursor-20261105/normal-regression.json b/docs/evidence/native-cursor-20261105/normal-regression.json new file mode 100644 index 0000000..83c260d --- /dev/null +++ b/docs/evidence/native-cursor-20261105/normal-regression.json @@ -0,0 +1,12 @@ +{ + "192.168.1.71": { + "status": "MainPID=186038\nId=vdi-sunshine.service\nActiveState=active\n\nMainPID=185981\nId=vdi-native-cursor-guard.service\nActiveState=active\n\nMainPID=0\nId=vdi-native-cursor-source.service\nActiveState=inactive\nScreen 0: minimum 320 x 200, current 2560 x 1440, maximum 8192 x 8192\nVirtual-1 connected primary 2560x1440+0+0 (normal left inverted right x axis y axis) 325mm x 203mm\n 1280x800 74.99 +\n 3840x2160 60.00 50.00 59.94 \n 1920x1440 60.00 \n'Adwaita'\n24\n'default'\ntrue\n1c3099bf924db3aee759b3d7c05e012569ef8650c400d1479144398795decc71 /opt/vdi-sunshine-0.21.0/usr/bin/sunshine\n", + "stderr": "", + "profile_client_sha": "1da552e4855d1562fdd8d6f04f1553382db4ccd364c66fe7486b1688cc584f15" + }, + "192.168.1.72": { + "status": "MainPID=32278\nId=vdi-sunshine.service\nActiveState=active\n\nMainPID=0\nId=vdi-native-cursor-guard.service\nActiveState=inactive\n\nMainPID=0\nId=vdi-native-cursor-source.service\nActiveState=inactive\nScreen 0: minimum 320 x 200, current 2560 x 1440, maximum 8192 x 8192\nVirtual-1 connected primary 2560x1440+0+0 (normal left inverted right x axis y axis) 325mm x 203mm\n 1280x800 74.99 +\n 1920x1440 60.00 \n 2560x1080 50.00 \n'Adwaita'\n24\n'default'\nfalse\n61e1ecbc8db2df786b9760c652aa4b9885dff36c57d4af09616344314cb7c34c /usr/bin/sunshine\n/usr/bin/sunshine cap_sys_admin,cap_sys_nice=p\n", + "stderr": "", + "profile_client_sha": "1da552e4855d1562fdd8d6f04f1553382db4ccd364c66fe7486b1688cc584f15" + } +} \ No newline at end of file diff --git a/docs/evidence/native-cursor-20261105/patch-application-final.json b/docs/evidence/native-cursor-20261105/patch-application-final.json new file mode 100644 index 0000000..cdbacd6 --- /dev/null +++ b/docs/evidence/native-cursor-20261105/patch-application-final.json @@ -0,0 +1,14 @@ +{ + "baseline": "de2467e433821664cdd2224aad8c89a625be1ad9", + "patches": [ + "0001-vdi-input-and-latest-frame.patch", + "0003-vdi-input-priority.patch", + "0004-vdi-power-key-policy.patch", + "0005-vdi-native-linux-cursor.patch", + "0002-vdi-absolute-click-barrier.patch" + ], + "native_patch_sha256": "6a5879901ae0d2986271df7441a38d0f1813c0d7fb3f616af5e1212c75eaaa02", + "returncode": 0, + "stdout": "PATCHES_OK\n", + "stderr": "" +} \ No newline at end of file diff --git a/docs/evidence/native-cursor-20261105/patch-application.json b/docs/evidence/native-cursor-20261105/patch-application.json new file mode 100644 index 0000000..11b2ce6 --- /dev/null +++ b/docs/evidence/native-cursor-20261105/patch-application.json @@ -0,0 +1 @@ +{"patches_apply_cleanly": ["0001-vdi-input-and-latest-frame.patch", "0003-vdi-input-priority.patch", "0004-vdi-power-key-policy.patch", "0005-vdi-native-linux-cursor.patch", "0002-vdi-absolute-click-barrier.patch"], "baseline": "de2467e433821664cdd2224aad8c89a625be1ad9"} diff --git a/docs/evidence/native-cursor-20261105/probe.py b/docs/evidence/native-cursor-20261105/probe.py new file mode 100644 index 0000000..2ffda4c --- /dev/null +++ b/docs/evidence/native-cursor-20261105/probe.py @@ -0,0 +1,188 @@ +"""Run only with the controlled GTK acceptance window filling the stream.""" +import ctypes as C +import json +import os +from pathlib import Path +import signal +import statistics +import struct +import subprocess +import time + +os.environ.update(DISPLAY=':0', XAUTHORITY='/home/armbian/.Xauthority') +x = C.CDLL('libX11.so.6') +fix = C.CDLL('libXfixes.so.3') +test = C.CDLL('libXtst.so.6') +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)] +x.XOpenDisplay.argtypes = [C.c_char_p] +x.XOpenDisplay.restype = C.c_void_p +x.XDefaultRootWindow.argtypes = [C.c_void_p] +x.XDefaultRootWindow.restype = C.c_ulong +x.XFree.argtypes = [C.c_void_p] +x.XSync.argtypes = [C.c_void_p, C.c_int] +x.XWarpPointer.argtypes = [C.c_void_p, C.c_ulong, C.c_ulong, C.c_int, + C.c_int, C.c_uint, C.c_uint, C.c_int, C.c_int] +x.XGetImage.argtypes = [C.c_void_p, C.c_ulong, C.c_int, C.c_int, + C.c_uint, C.c_uint, C.c_ulong, C.c_int] +x.XGetImage.restype = C.c_void_p +x.XGetPixel.argtypes = [C.c_void_p, C.c_int, C.c_int] +x.XGetPixel.restype = C.c_ulong +x.XDestroyImage.argtypes = [C.c_void_p] +fix.XFixesGetCursorImage.argtypes = [C.c_void_p] +fix.XFixesGetCursorImage.restype = C.POINTER(Cursor) +test.XTestFakeButtonEvent.argtypes = [C.c_void_p, C.c_uint, C.c_int, C.c_ulong] +d = x.XOpenDisplay(None) +assert d +root = x.XDefaultRootWindow(d) + + +def native(): + p = fix.XFixesGetCursorImage(d) + assert p + try: + c = p.contents + return dict(x=c.x, y=c.y, w=c.w, h=c.h, hx=c.hx, hy=c.hy, + pixels=[int(c.pixels[i]) & 0xffffffff for i in range(c.w*c.h)]) + finally: + x.XFree(p) + + +def warp(px, py): + x.XWarpPointer(d, 0, root, 0, 0, 0, 0, px, py) + x.XSync(d, 0) + + +def decoded_sequence(): + im = x.XGetImage(d, root, 40, 1370, 640, 1, 0xffffffff, 2) + assert im + try: + values = [x.XGetPixel(im, i*40+20, 0) & 0xffffff for i in range(16)] + if any(0x202020 <= v <= 0xe0e0e0 for v in values): + return -1 + return sum((1 << i) for i, v in enumerate(values) if v > 0xe0e0e0) + finally: + x.XDestroyImage(im) + + +def flat_video_crop(px, py): + im = x.XGetImage(d, root, px-40, py-40, 100, 100, 0xffffffff, 2) + assert im + try: + pixels = [x.XGetPixel(im, a, b) & 0xffffff for b in range(100) for a in range(100)] + # No baked-in pointer: uniform acceptance-window background at this point. + ref = pixels[0] + bad = sum(any(abs(((v >> s) & 255) - ((ref >> s) & 255)) > 8 + for s in (0, 8, 16)) for v in pixels) + return dict(reference=ref, nonuniform_pixels=bad) + finally: + x.XDestroyImage(im) + + +host = os.sys.argv[1] +prefix = '/home/armbian/vdi-build/rkmpp-experiment/build/moonlight/app/moonlight stream ' + host + ' ' +items = [l.split(None, 1) for l in subprocess.check_output(['ps','-eo','pid,args'], text=True).splitlines()[1:] + if len(l.split(None, 1)) == 2 and l.split(None, 1)[1].startswith(prefix)] +assert len(items) == 1, items +pid = int(items[0][0]) +focus = subprocess.check_output(['xdotool','getwindowfocus','getwindowpid'], text=True).strip() +assert focus == str(pid), (focus, pid) +env = dict(item.split('=', 1) for item in Path('/proc/%d/environ' % pid).read_bytes().decode().split('\0') if '=' in item) +statepath = Path(env['VDI_NATIVE_CURSOR_STATE']) +initial_sequence = decoded_sequence() +assert 0 <= initial_sequence < 60000, 'test window not ready' +reference = flat_video_crop(180, 600)['reference'] +assert all(abs(((reference >> s) & 255)-v) < 10 for s,v in [(16,240),(8,245),(0,250)]), 'acceptance window not visible' +shapes = [] +for i, name in enumerate(['default','text','ew-resize','wait','none','crosshair','custom']): + px = int((i+.5)*2560/7) + warp(px, 600) + time.sleep(.6) + state = statepath.read_bytes() + header = struct.unpack('<8sQQQIIIIiiII', state[:64]) + local = native() + source = dict(zip(['w','h','hx','hy','x','y','sw','sh'], header[4:])) + # Convert expected straight RGBA to XRender premultiplied ARGB. + expected = [] + for j in range(64, len(state)-8, 4): + r,g,b,a = state[j:j+4] + expected.append((a << 24) | ((r*a+127)//255 << 16) | + ((g*a+127)//255 << 8) | ((b*a+127)//255)) + same_geometry = all(local[k] == source[k] for k in ['w','h','hx','hy']) + # Busy frames animate; these snapshots can legitimately be adjacent frames. + diff = max((abs(((a >> s) & 255)-((b >> s) & 255)) + for a,b in zip(local['pixels'],expected) for s in [0,8,16,24]), default=0) + shapes.append(dict(name=name, source=source, + local={k:v for k,v in local.items() if k != 'pixels'}, + geometry_matches=same_geometry, max_pixel_difference=diff, + visible_pixels=sum(p >> 24 != 0 for p in local['pixels']), + video=flat_video_crop(px, 600))) + +Path('/tmp/native-%s-shapes.json' % host).write_text(json.dumps(shapes)) +samples = [] +for i in range(28): + px = int(((i % 7)+.5)*2560/7) + py = 750 if i % 2 == 0 else 850 + warp(px, py) + time.sleep(.25) + start = time.monotonic_ns() + test.XTestFakeButtonEvent(d, 1, 1, 0) + x.XSync(d, 0) + expected = initial_sequence+i+1 + seen = -1 + released = False + try: + while time.monotonic_ns()-start < 3000000000: + if not released and time.monotonic_ns()-start >= 80000000: + test.XTestFakeButtonEvent(d, 1, 0, 0) + x.XSync(d, 0) + released = True + seen = decoded_sequence() + if seen == expected: + break + if seen > expected: + raise RuntimeError('unexpected duplicate click %d instead of %d' % (seen,expected)) + time.sleep(.002) + finally: + if not released: + time.sleep(max(0, .08-(time.monotonic_ns()-start)/1e9)) + test.XTestFakeButtonEvent(d, 1, 0, 0) + x.XSync(d, 0) + samples.append(dict(seq=expected, x=px, y=py, seen=seen, + decoded_ms=(time.monotonic_ns()-start)/1e6)) + if seen != expected: + Path('/tmp/native-%s-failed-clicks.json' % host).write_text(json.dumps(samples)) + raise AssertionError(samples[-1]) + time.sleep(.1) +times = sorted(s['decoded_ms'] for s in samples) +# A stopped Moonlight cannot capture input, receive video, or draw frames. +# X11's native pointer must still move and keep its shape while it is stopped. +warp(200, 600) +time.sleep(.3) +before = native() +try: + os.kill(pid, signal.SIGSTOP) + start = time.monotonic_ns() + steps = [] + for px,py in [(250,650),(280,670),(300,690),(350,700)]: + step_start = time.monotonic_ns() + warp(px, py) + after = native() + steps.append(dict(target=[px,py],position=[after['x'],after['y']], + query_ms=(time.monotonic_ns()-step_start)/1e6)) + time.sleep(.06) + paused_ms = (time.monotonic_ns()-start)/1e6 +finally: + os.kill(pid, signal.SIGCONT) +pause = dict(position=[after['x'],after['y']], elapsed_ms=paused_ms, + same_shape=before['pixels']==after['pixels'], steps=steps) +assert all(s['target']==s['position'] for s in steps) and pause['same_shape'] +time.sleep(.4) + +print(json.dumps(dict(host=host,pid=pid,shapes=shapes,paused_client=pause, + clicks=samples,p50_ms=statistics.median(times), + p95_ms=times[int(len(times)*.95)], + method='XTest click-down to unique decoded sequence; excludes USB and scanout'))) diff --git a/docs/evidence/native-cursor-20261105/release-click-verification.json b/docs/evidence/native-cursor-20261105/release-click-verification.json new file mode 100644 index 0000000..e99d683 --- /dev/null +++ b/docs/evidence/native-cursor-20261105/release-click-verification.json @@ -0,0 +1,17 @@ +{ + "client_sha256": "5d4330f703d7f8e053bc3795ca42bdaf231c9e9dd8ebb00a612013adbcccee03", + "ubuntu": { + "measured_clicks": 28, + "matched_source_events": 28, + "max_coordinate_error_pixels": 1.0546875, + "p50_ms": 188.3848435, + "p95_ms": 522.420223 + }, + "kylin": { + "measured_clicks": 28, + "matched_source_events": 28, + "max_coordinate_error_pixels": 0.82855224609375, + "p50_ms": 134.369708, + "p95_ms": 165.559673 + } +} \ No newline at end of file diff --git a/docs/evidence/native-cursor-20261105/soak.py b/docs/evidence/native-cursor-20261105/soak.py new file mode 100644 index 0000000..4bd164a --- /dev/null +++ b/docs/evidence/native-cursor-20261105/soak.py @@ -0,0 +1,53 @@ +"""Observe actual deployed state/receiver; keep the test seat awake with motion. + +Run on the box only while a controlled native-cursor session is active. +No clicks, protocol injection, or changes to the state producer are made. +""" +import json +import os +from pathlib import Path +import struct +import subprocess +import sys +import time + +duration = float(sys.argv[1]) +output = Path(sys.argv[2]) +pid = int(subprocess.check_output(['pgrep', '-x', 'moonlight']).strip()) +proc = Path('/proc') / str(pid) +env = dict(x.split('=', 1) for x in (proc / 'environ').read_bytes().decode().split('\0') if '=' in x) +state = Path(env['VDI_NATIVE_CURSOR_STATE']) +start = time.monotonic() +last_motion = 0 +result = dict(pid=pid, requested_seconds=duration, samples=0, + timestamp_after_pre_read_clock=0, timestamp_after_post_read_clock=0, + max_age_ms=0, survived=False) +try: + while time.monotonic() - start < duration: + if not proc.exists() or (proc / 'comm').read_text().strip() != 'moonlight': + raise RuntimeError('receiver exited') + before = time.monotonic_ns() + packet = state.read_bytes() + after = time.monotonic_ns() + stamp = struct.unpack_from(' before + result['timestamp_after_post_read_clock'] += stamp > after + result['max_age_ms'] = max(result['max_age_ms'], (after - stamp) / 1e6) + result['last_sequence'] = struct.unpack_from(' after or after - stamp > 2_000_000_000: + raise RuntimeError('invalid post-read timestamp') + now = time.monotonic() + if now - last_motion > 10: + px = 1800 + (int(now - start) // 10 % 2) * 50 + motion_env = dict(os.environ, DISPLAY=':0', XAUTHORITY='/home/armbian/.Xauthority') + subprocess.run(['xdotool', 'mousemove', str(px), '1100'], env=motion_env, check=True) + last_motion = now + time.sleep(.004) + result['survived'] = True +except Exception as error: + result['error'] = str(error) +finally: + result['elapsed_seconds'] = round(time.monotonic() - start, 3) + output.write_text(json.dumps(result, indent=2) + '\n') + print(json.dumps(result), flush=True) diff --git a/docs/evidence/native-cursor-20261105/source.py b/docs/evidence/native-cursor-20261105/source.py new file mode 100644 index 0000000..8a83ae9 --- /dev/null +++ b/docs/evidence/native-cursor-20261105/source.py @@ -0,0 +1,92 @@ +"""Controlled GTK test window: real native cursors and unique click sequences.""" +import gi +import json +import time +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk, Gdk, GdkPixbuf, GLib + +win = Gtk.Window(title='VDI native cursor acceptance') +area = Gtk.DrawingArea() +win.add(area) +area.set_can_focus(True) +area.set_events(Gdk.EventMask.POINTER_MOTION_MASK | Gdk.EventMask.BUTTON_PRESS_MASK | + Gdk.EventMask.BUTTON_RELEASE_MASK) +display = Gdk.Display.get_default() +names = ['default', 'text', 'ew-resize', 'wait', 'none', 'crosshair', 'custom'] +cursors = {name: Gdk.Cursor.new_from_name(display, name) for name in names[:4] + ['crosshair']} +cursors['none'] = Gdk.Cursor.new_for_display(display, Gdk.CursorType.BLANK_CURSOR) +pix = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, True, 8, 24, 24) +pix.fill(0xff5030ff) +cursors['custom'] = Gdk.Cursor.new_from_pixbuf(display, pix, 7, 9) +current = None +sequence = 0 + + +def report(**kw): + kw['t'] = time.monotonic() + print(json.dumps(kw), flush=True) + + +def motion(widget, event): + global current + index = min(6, int(event.x // (2560/7))) + name = names[index] + if name != current: + area.get_window().set_cursor(cursors[name]) + current = name + report(event='shape', name=name) + return False + + +def button(widget, event): + global sequence + if event.type in (Gdk.EventType.BUTTON_PRESS, Gdk.EventType.DOUBLE_BUTTON_PRESS, + Gdk.EventType.TRIPLE_BUTTON_PRESS): + # GTK emits an extra double/triple notification, not another input. + if event.type == Gdk.EventType.BUTTON_PRESS: + sequence += 1 + area.queue_draw_area(0, 1300, 1000, 140) + report(event=event.type.value_nick, seq=sequence, x=event.x_root, + y=event.y_root, button=event.button) + return False + + +def draw(widget, cr): + cr.set_source_rgb(.94, .96, .98) + cr.paint() + cr.select_font_face('sans') + cr.set_font_size(25) + for i, name in enumerate(names): + cr.set_source_rgb(.1, .15, .2) + cr.move_to(40 + i*2560/7, 100) + cr.show_text(name) + cr.set_source_rgb(.65, .7, .8) + cr.rectangle(i*2560/7, 120, 2, 1100) + cr.fill() + # Unique monotonic sequence, read from the actual decoded video. + for bit in range(16): + v = 1 if sequence & (1 << bit) else 0 + cr.set_source_rgb(v, v, v) + cr.rectangle(40 + bit*40, 1340, 40, 60) + cr.fill() + return False + + +def key(widget, event): + if event.keyval == Gdk.KEY_Escape: + Gtk.main_quit() + return False + + +win.connect('destroy', Gtk.main_quit) +win.connect('key-press-event', key) +area.connect('motion-notify-event', motion) +area.connect('button-press-event', button) +area.connect('button-release-event', button) +area.connect('draw', draw) +win.fullscreen() +win.show_all() +win.present() +area.grab_focus() +report(event='ready') +Gtk.main() diff --git a/docs/evidence/native-cursor-20261105/test1-manifest.json b/docs/evidence/native-cursor-20261105/test1-manifest.json new file mode 100644 index 0000000..67f0815 --- /dev/null +++ b/docs/evidence/native-cursor-20261105/test1-manifest.json @@ -0,0 +1,4 @@ +{ + "sunshine": "1c3099bf924db3aee759b3d7c05e012569ef8650c400d1479144398795decc71", + "capture-no-cursor.so": "cff4b7532aee69f0f205fa42946f45a89c1330486adc6358b90d13d2ccf1a0ea" +} \ No newline at end of file diff --git a/docs/evidence/native-cursor-20261105/test1-preflight-v2.json b/docs/evidence/native-cursor-20261105/test1-preflight-v2.json new file mode 100644 index 0000000..153db01 --- /dev/null +++ b/docs/evidence/native-cursor-20261105/test1-preflight-v2.json @@ -0,0 +1,302 @@ +{ + "host": "192.168.1.71", + "ready": true, + "samples": [ + { + "w": 16, + "h": 16, + "hx": 8, + "hy": 8, + "x": 1814, + "y": 554, + "sw": 2560, + "sh": 1440, + "shape": 166, + "type": "cursor", + "version": 1, + "seq": 1 + }, + { + "w": 16, + "h": 16, + "hx": 8, + "hy": 8, + "x": 1814, + "y": 554, + "sw": 2560, + "sh": 1440, + "shape": 166, + "type": "cursor", + "version": 1, + "seq": 2 + }, + { + "w": 16, + "h": 16, + "hx": 8, + "hy": 8, + "x": 1814, + "y": 554, + "sw": 2560, + "sh": 1440, + "shape": 166, + "type": "cursor", + "version": 1, + "seq": 3 + }, + { + "w": 16, + "h": 16, + "hx": 8, + "hy": 8, + "x": 1814, + "y": 554, + "sw": 2560, + "sh": 1440, + "shape": 166, + "type": "cursor", + "version": 1, + "seq": 4 + }, + { + "w": 16, + "h": 16, + "hx": 8, + "hy": 8, + "x": 1814, + "y": 554, + "sw": 2560, + "sh": 1440, + "shape": 166, + "type": "cursor", + "version": 1, + "seq": 5 + }, + { + "w": 16, + "h": 16, + "hx": 8, + "hy": 8, + "x": 1814, + "y": 554, + "sw": 2560, + "sh": 1440, + "shape": 166, + "type": "cursor", + "version": 1, + "seq": 6 + }, + { + "w": 16, + "h": 16, + "hx": 8, + "hy": 8, + "x": 1814, + "y": 554, + "sw": 2560, + "sh": 1440, + "shape": 166, + "type": "cursor", + "version": 1, + "seq": 7 + }, + { + "w": 16, + "h": 16, + "hx": 8, + "hy": 8, + "x": 1814, + "y": 554, + "sw": 2560, + "sh": 1440, + "shape": 166, + "type": "cursor", + "version": 1, + "seq": 8 + }, + { + "w": 16, + "h": 16, + "hx": 8, + "hy": 8, + "x": 1814, + "y": 554, + "sw": 2560, + "sh": 1440, + "shape": 166, + "type": "cursor", + "version": 1, + "seq": 9 + }, + { + "w": 16, + "h": 16, + "hx": 8, + "hy": 8, + "x": 1814, + "y": 554, + "sw": 2560, + "sh": 1440, + "shape": 166, + "type": "cursor", + "version": 1, + "seq": 10 + }, + { + "w": 16, + "h": 16, + "hx": 8, + "hy": 8, + "x": 1814, + "y": 554, + "sw": 2560, + "sh": 1440, + "shape": 166, + "type": "cursor", + "version": 1, + "seq": 11 + }, + { + "w": 16, + "h": 16, + "hx": 8, + "hy": 8, + "x": 1814, + "y": 554, + "sw": 2560, + "sh": 1440, + "shape": 166, + "type": "cursor", + "version": 1, + "seq": 12 + }, + { + "w": 16, + "h": 16, + "hx": 8, + "hy": 8, + "x": 1814, + "y": 554, + "sw": 2560, + "sh": 1440, + "shape": 166, + "type": "cursor", + "version": 1, + "seq": 13 + }, + { + "w": 16, + "h": 16, + "hx": 8, + "hy": 8, + "x": 1814, + "y": 554, + "sw": 2560, + "sh": 1440, + "shape": 166, + "type": "cursor", + "version": 1, + "seq": 14 + }, + { + "w": 16, + "h": 16, + "hx": 8, + "hy": 8, + "x": 1814, + "y": 554, + "sw": 2560, + "sh": 1440, + "shape": 166, + "type": "cursor", + "version": 1, + "seq": 15 + }, + { + "w": 16, + "h": 16, + "hx": 8, + "hy": 8, + "x": 1814, + "y": 554, + "sw": 2560, + "sh": 1440, + "shape": 166, + "type": "cursor", + "version": 1, + "seq": 16 + }, + { + "w": 16, + "h": 16, + "hx": 8, + "hy": 8, + "x": 1814, + "y": 554, + "sw": 2560, + "sh": 1440, + "shape": 166, + "type": "cursor", + "version": 1, + "seq": 17 + }, + { + "w": 16, + "h": 16, + "hx": 8, + "hy": 8, + "x": 1814, + "y": 554, + "sw": 2560, + "sh": 1440, + "shape": 166, + "type": "cursor", + "version": 1, + "seq": 18 + }, + { + "w": 16, + "h": 16, + "hx": 8, + "hy": 8, + "x": 1814, + "y": 554, + "sw": 2560, + "sh": 1440, + "shape": 166, + "type": "cursor", + "version": 1, + "seq": 19 + }, + { + "w": 16, + "h": 16, + "hx": 8, + "hy": 8, + "x": 1814, + "y": 554, + "sw": 2560, + "sh": 1440, + "shape": 166, + "type": "cursor", + "version": 1, + "seq": 20 + }, + { + "w": 16, + "h": 16, + "hx": 8, + "hy": 8, + "x": 1814, + "y": 554, + "sw": 2560, + "sh": 1440, + "shape": 166, + "type": "cursor", + "version": 1, + "seq": 21 + } + ], + "stderr": "", + "exit": 0 +} \ No newline at end of file diff --git a/docs/evidence/native-cursor-20261105/test1-preflight.json b/docs/evidence/native-cursor-20261105/test1-preflight.json new file mode 100644 index 0000000..eaf7718 --- /dev/null +++ b/docs/evidence/native-cursor-20261105/test1-preflight.json @@ -0,0 +1,7 @@ +{ + "host": "192.168.1.71", + "ready": false, + "samples": [], + "stderr": "RuntimeError: Permission denied by kysec\n", + "exit": 1 +} \ No newline at end of file diff --git a/docs/evidence/native-cursor-20261105/test1-recovery.json b/docs/evidence/native-cursor-20261105/test1-recovery.json new file mode 100644 index 0000000..404029b --- /dev/null +++ b/docs/evidence/native-cursor-20261105/test1-recovery.json @@ -0,0 +1,16 @@ +[ + { + "case": "agent-sigkill", + "host": "192.168.1.71", + "recovered": true, + "recovery_seconds": 3.22, + "state": "ExecStart={ path=/opt/vdi-sunshine-0.21.0/usr/bin/sunshine ; argv[]=/opt/vdi-sunshine-0.21.0/usr/bin/sunshine /home/test1/.config/sunshine/sunshine.conf ; ignore_errors=no ; start_time=[Thu 2026-11-05 11:11:57 CST] ; stop_time=[n/a] ; pid=180535 ; code=(null) ; status=0/0 }\nActiveState=active\n" + }, + { + "case": "guard-sigkill", + "host": "192.168.1.71", + "recovered": true, + "recovery_seconds": 0.893, + "state": "ExecStart={ path=/opt/vdi-sunshine-0.21.0/usr/bin/sunshine ; argv[]=/opt/vdi-sunshine-0.21.0/usr/bin/sunshine /home/test1/.config/sunshine/sunshine.conf ; ignore_errors=no ; start_time=[Thu 2026-11-05 11:12:00 CST] ; stop_time=[n/a] ; pid=180701 ; code=(null) ; status=0/0 }\nActiveState=active\n" + } +] \ No newline at end of file diff --git a/docs/evidence/native-cursor-20261105/test2-manifest.json b/docs/evidence/native-cursor-20261105/test2-manifest.json new file mode 100644 index 0000000..4ba9b00 --- /dev/null +++ b/docs/evidence/native-cursor-20261105/test2-manifest.json @@ -0,0 +1,4 @@ +{ + "sunshine": "61e1ecbc8db2df786b9760c652aa4b9885dff36c57d4af09616344314cb7c34c", + "capture-no-cursor.so": "cff4b7532aee69f0f205fa42946f45a89c1330486adc6358b90d13d2ccf1a0ea" +} \ No newline at end of file diff --git a/docs/evidence/native-cursor-20261105/test2-preflight-v2.json b/docs/evidence/native-cursor-20261105/test2-preflight-v2.json new file mode 100644 index 0000000..8030850 --- /dev/null +++ b/docs/evidence/native-cursor-20261105/test2-preflight-v2.json @@ -0,0 +1,316 @@ +{ + "host": "192.168.1.72", + "ready": true, + "samples": [ + { + "w": 24, + "h": 24, + "hx": 3, + "hy": 1, + "x": 2548, + "y": 1, + "sw": 2560, + "sh": 1440, + "shape": 124, + "type": "cursor", + "version": 1, + "seq": 1 + }, + { + "w": 24, + "h": 24, + "hx": 3, + "hy": 1, + "x": 2548, + "y": 1, + "sw": 2560, + "sh": 1440, + "shape": 124, + "type": "cursor", + "version": 1, + "seq": 2 + }, + { + "w": 24, + "h": 24, + "hx": 3, + "hy": 1, + "x": 2548, + "y": 1, + "sw": 2560, + "sh": 1440, + "shape": 124, + "type": "cursor", + "version": 1, + "seq": 3 + }, + { + "w": 24, + "h": 24, + "hx": 3, + "hy": 1, + "x": 2548, + "y": 1, + "sw": 2560, + "sh": 1440, + "shape": 124, + "type": "cursor", + "version": 1, + "seq": 4 + }, + { + "w": 24, + "h": 24, + "hx": 3, + "hy": 1, + "x": 2548, + "y": 1, + "sw": 2560, + "sh": 1440, + "shape": 124, + "type": "cursor", + "version": 1, + "seq": 5 + }, + { + "w": 24, + "h": 24, + "hx": 3, + "hy": 1, + "x": 2548, + "y": 1, + "sw": 2560, + "sh": 1440, + "shape": 124, + "type": "cursor", + "version": 1, + "seq": 6 + }, + { + "w": 24, + "h": 24, + "hx": 3, + "hy": 1, + "x": 2548, + "y": 1, + "sw": 2560, + "sh": 1440, + "shape": 124, + "type": "cursor", + "version": 1, + "seq": 7 + }, + { + "w": 24, + "h": 24, + "hx": 3, + "hy": 1, + "x": 2548, + "y": 1, + "sw": 2560, + "sh": 1440, + "shape": 124, + "type": "cursor", + "version": 1, + "seq": 8 + }, + { + "w": 24, + "h": 24, + "hx": 3, + "hy": 1, + "x": 2548, + "y": 1, + "sw": 2560, + "sh": 1440, + "shape": 124, + "type": "cursor", + "version": 1, + "seq": 9 + }, + { + "w": 24, + "h": 24, + "hx": 3, + "hy": 1, + "x": 2548, + "y": 1, + "sw": 2560, + "sh": 1440, + "shape": 124, + "type": "cursor", + "version": 1, + "seq": 10 + }, + { + "w": 24, + "h": 24, + "hx": 3, + "hy": 1, + "x": 2548, + "y": 1, + "sw": 2560, + "sh": 1440, + "shape": 124, + "type": "cursor", + "version": 1, + "seq": 11 + }, + { + "w": 24, + "h": 24, + "hx": 3, + "hy": 1, + "x": 2548, + "y": 1, + "sw": 2560, + "sh": 1440, + "shape": 124, + "type": "cursor", + "version": 1, + "seq": 12 + }, + { + "w": 24, + "h": 24, + "hx": 3, + "hy": 1, + "x": 2548, + "y": 1, + "sw": 2560, + "sh": 1440, + "shape": 124, + "type": "cursor", + "version": 1, + "seq": 13 + }, + { + "w": 24, + "h": 24, + "hx": 3, + "hy": 1, + "x": 2548, + "y": 1, + "sw": 2560, + "sh": 1440, + "shape": 124, + "type": "cursor", + "version": 1, + "seq": 14 + }, + { + "w": 24, + "h": 24, + "hx": 3, + "hy": 1, + "x": 2548, + "y": 1, + "sw": 2560, + "sh": 1440, + "shape": 124, + "type": "cursor", + "version": 1, + "seq": 15 + }, + { + "w": 24, + "h": 24, + "hx": 3, + "hy": 1, + "x": 2548, + "y": 1, + "sw": 2560, + "sh": 1440, + "shape": 124, + "type": "cursor", + "version": 1, + "seq": 16 + }, + { + "w": 24, + "h": 24, + "hx": 3, + "hy": 1, + "x": 2548, + "y": 1, + "sw": 2560, + "sh": 1440, + "shape": 124, + "type": "cursor", + "version": 1, + "seq": 17 + }, + { + "w": 24, + "h": 24, + "hx": 3, + "hy": 1, + "x": 2548, + "y": 1, + "sw": 2560, + "sh": 1440, + "shape": 124, + "type": "cursor", + "version": 1, + "seq": 18 + }, + { + "w": 24, + "h": 24, + "hx": 3, + "hy": 1, + "x": 2548, + "y": 1, + "sw": 2560, + "sh": 1440, + "shape": 124, + "type": "cursor", + "version": 1, + "seq": 19 + }, + { + "w": 24, + "h": 24, + "hx": 3, + "hy": 1, + "x": 2548, + "y": 1, + "sw": 2560, + "sh": 1440, + "shape": 124, + "type": "cursor", + "version": 1, + "seq": 20 + }, + { + "w": 24, + "h": 24, + "hx": 3, + "hy": 1, + "x": 2548, + "y": 1, + "sw": 2560, + "sh": 1440, + "shape": 124, + "type": "cursor", + "version": 1, + "seq": 21 + }, + { + "w": 24, + "h": 24, + "hx": 3, + "hy": 1, + "x": 2548, + "y": 1, + "sw": 2560, + "sh": 1440, + "shape": 124, + "type": "cursor", + "version": 1, + "seq": 22 + } + ], + "stderr": "", + "exit": 0 +} \ No newline at end of file diff --git a/docs/evidence/native-cursor-20261105/test2-preflight.json b/docs/evidence/native-cursor-20261105/test2-preflight.json new file mode 100644 index 0000000..e7df695 --- /dev/null +++ b/docs/evidence/native-cursor-20261105/test2-preflight.json @@ -0,0 +1,7 @@ +{ + "host": "192.168.1.72", + "ready": false, + "samples": [], + "stderr": "Traceback (most recent call last):\n File \"/home/test2/vdi-terminal/native-cursor-v1/host.py\", line 211, in \n main()\n File \"/home/test2/vdi-terminal/native-cursor-v1/host.py\", line 205, in main\n stream()\n File \"/home/test2/vdi-terminal/native-cursor-v1/host.py\", line 154, in stream\n raise RuntimeError('guard not ready')\nRuntimeError: guard not ready\n", + "exit": 1 +} \ No newline at end of file diff --git a/docs/evidence/native-cursor-20261105/test2-recovery.json b/docs/evidence/native-cursor-20261105/test2-recovery.json new file mode 100644 index 0000000..5d7f81d --- /dev/null +++ b/docs/evidence/native-cursor-20261105/test2-recovery.json @@ -0,0 +1,16 @@ +[ + { + "case": "agent-sigkill", + "host": "192.168.1.72", + "recovered": true, + "recovery_seconds": 2.724, + "state": "ExecStart={ path=/usr/bin/sunshine ; argv[]=/usr/bin/sunshine /home/test2/.config/sunshine/sunshine.conf ; ignore_errors=no ; start_time=[Thu 2026-11-05 11:11:56 CST] ; stop_time=[n/a] ; pid=24296 ; code=(null) ; status=0/0 }\nActiveState=active\n" + }, + { + "case": "guard-sigkill", + "host": "192.168.1.72", + "recovered": true, + "recovery_seconds": 0.771, + "state": "ExecStart={ path=/usr/bin/sunshine ; argv[]=/usr/bin/sunshine /home/test2/.config/sunshine/sunshine.conf ; ignore_errors=no ; start_time=[Thu 2026-11-05 11:12:14 CST] ; stop_time=[n/a] ; pid=24470 ; code=(null) ; status=0/0 }\nActiveState=active\n" + } +] \ No newline at end of file diff --git a/docs/evidence/native-cursor-20261105/ubuntu-clockfix-soak.json b/docs/evidence/native-cursor-20261105/ubuntu-clockfix-soak.json new file mode 100644 index 0000000..5be820c --- /dev/null +++ b/docs/evidence/native-cursor-20261105/ubuntu-clockfix-soak.json @@ -0,0 +1,11 @@ +{ + "pid": 285520, + "requested_seconds": 360.0, + "samples": 65658, + "timestamp_after_pre_read_clock": 0, + "timestamp_after_post_read_clock": 0, + "max_age_ms": 308.103546, + "survived": true, + "last_sequence": 7956, + "elapsed_seconds": 360.005 +} diff --git a/docs/evidence/native-cursor-20261105/ubuntu-existing-crash-reports.json b/docs/evidence/native-cursor-20261105/ubuntu-existing-crash-reports.json new file mode 100644 index 0000000..ea647e4 --- /dev/null +++ b/docs/evidence/native-cursor-20261105/ubuntu-existing-crash-reports.json @@ -0,0 +1 @@ +[{"file": "_usr_bin_sunshine.1000.crash", "ProblemType": "Crash", "Date": "Thu Nov 5 11:12:07 2026", "ExecutablePath": "/usr/bin/sunshine", "Package": "sunshine 2026.914.233613-1+ubuntu24.04 [origin: unknown]", "Signal": "5"}] diff --git a/docs/evidence/native-cursor-20261105/ubuntu-fast-input.json b/docs/evidence/native-cursor-20261105/ubuntu-fast-input.json new file mode 100644 index 0000000..1bd92b1 --- /dev/null +++ b/docs/evidence/native-cursor-20261105/ubuntu-fast-input.json @@ -0,0 +1 @@ +{"clicks": [{"seq": 29, "seen": 29, "x": 300, "y": 800}, {"seq": 30, "seen": 30, "x": 1500, "y": 900}, {"seq": 31, "seen": 31, "x": 300, "y": 800}, {"seq": 32, "seen": 32, "x": 1500, "y": 900}, {"seq": 33, "seen": 33, "x": 300, "y": 800}, {"seq": 34, "seen": 34, "x": 1500, "y": 900}, {"seq": 35, "seen": 35, "x": 300, "y": 800}, {"seq": 36, "seen": 36, "x": 1500, "y": 900}, {"seq": 37, "seen": 37, "x": 300, "y": 800}, {"seq": 38, "seen": 38, "x": 1500, "y": 900}, {"seq": 39, "seen": 39, "x": 300, "y": 800}, {"seq": 40, "seen": 40, "x": 1500, "y": 900}, {"seq": 41, "seen": 41, "x": 300, "y": 800}, {"seq": 42, "seen": 42, "x": 1500, "y": 900}, {"seq": 43, "seen": 43, "x": 300, "y": 800}, {"seq": 44, "seen": 44, "x": 1500, "y": 900}], "double_click_start": 44, "double_click_end": 46} diff --git a/docs/evidence/native-cursor-20261105/ubuntu-final-probe.json b/docs/evidence/native-cursor-20261105/ubuntu-final-probe.json new file mode 100644 index 0000000..ffa35b8 --- /dev/null +++ b/docs/evidence/native-cursor-20261105/ubuntu-final-probe.json @@ -0,0 +1 @@ +{"host": "192.168.1.72", "pid": 263384, "shapes": [{"name": "default", "source": {"w": 24, "h": 24, "hx": 3, "hy": 1, "x": 181, "y": 599, "sw": 2560, "sh": 1440}, "local": {"x": 182, "y": 600, "w": 24, "h": 24, "hx": 3, "hy": 1}, "geometry_matches": true, "max_pixel_difference": 0, "visible_pixels": 254, "video": {"reference": 15726330, "nonuniform_pixels": 0}}, {"name": "text", "source": {"w": 24, "h": 24, "hx": 11, "hy": 12, "x": 547, "y": 599, "sw": 2560, "sh": 1440}, "local": {"x": 548, "y": 600, "w": 24, "h": 24, "hx": 11, "hy": 12}, "geometry_matches": true, "max_pixel_difference": 0, "visible_pixels": 240, "video": {"reference": 15791866, "nonuniform_pixels": 0}}, {"name": "ew-resize", "source": {"w": 24, "h": 24, "hx": 12, "hy": 12, "x": 913, "y": 599, "sw": 2560, "sh": 1440}, "local": {"x": 914, "y": 600, "w": 24, "h": 24, "hx": 12, "hy": 12}, "geometry_matches": true, "max_pixel_difference": 0, "visible_pixels": 301, "video": {"reference": 15726330, "nonuniform_pixels": 0}}, {"name": "wait", "source": {"w": 24, "h": 24, "hx": 11, "hy": 11, "x": 1280, "y": 599, "sw": 2560, "sh": 1440}, "local": {"x": 1280, "y": 600, "w": 24, "h": 24, "hx": 11, "hy": 11}, "geometry_matches": true, "max_pixel_difference": 0, "visible_pixels": 433, "video": {"reference": 15791866, "nonuniform_pixels": 0}}, {"name": "none", "source": {"w": 1, "h": 1, "hx": 0, "hy": 0, "x": 1645, "y": 599, "sw": 2560, "sh": 1440}, "local": {"x": 1645, "y": 600, "w": 1, "h": 1, "hx": 0, "hy": 0}, "geometry_matches": true, "max_pixel_difference": 0, "visible_pixels": 0, "video": {"reference": 15726074, "nonuniform_pixels": 0}}, {"name": "crosshair", "source": {"w": 24, "h": 24, "hx": 11, "hy": 11, "x": 2011, "y": 599, "sw": 2560, "sh": 1440}, "local": {"x": 2011, "y": 600, "w": 24, "h": 24, "hx": 11, "hy": 11}, "geometry_matches": true, "max_pixel_difference": 0, "visible_pixels": 281, "video": {"reference": 15726074, "nonuniform_pixels": 0}}, {"name": "custom", "source": {"w": 24, "h": 24, "hx": 7, "hy": 9, "x": 2377, "y": 599, "sw": 2560, "sh": 1440}, "local": {"x": 2377, "y": 600, "w": 24, "h": 24, "hx": 7, "hy": 9}, "geometry_matches": true, "max_pixel_difference": 0, "visible_pixels": 576, "video": {"reference": 15726074, "nonuniform_pixels": 0}}], "paused_client": {"position": [250, 650], "elapsed_ms": 2.232537, "same_shape": true}, "clicks": [{"seq": 1, "x": 182, "y": 750, "seen": 1, "decoded_ms": 183.136301}, {"seq": 2, "x": 548, "y": 850, "seen": 2, "decoded_ms": 150.601919}, {"seq": 3, "x": 914, "y": 750, "seen": 3, "decoded_ms": 162.979614}, {"seq": 4, "x": 1280, "y": 850, "seen": 4, "decoded_ms": 562.228218}, {"seq": 5, "x": 1645, "y": 750, "seen": 5, "decoded_ms": 149.661983}, {"seq": 6, "x": 2011, "y": 850, "seen": 6, "decoded_ms": 187.689807}, {"seq": 7, "x": 2377, "y": 750, "seen": 7, "decoded_ms": 186.617722}, {"seq": 8, "x": 182, "y": 850, "seen": 8, "decoded_ms": 167.201514}, {"seq": 9, "x": 548, "y": 750, "seen": 9, "decoded_ms": 143.805796}, {"seq": 10, "x": 914, "y": 850, "seen": 10, "decoded_ms": 145.803285}, {"seq": 11, "x": 1280, "y": 750, "seen": 11, "decoded_ms": 505.147227}, {"seq": 12, "x": 1645, "y": 850, "seen": 12, "decoded_ms": 156.874419}, {"seq": 13, "x": 2011, "y": 750, "seen": 13, "decoded_ms": 164.724633}, {"seq": 14, "x": 2377, "y": 850, "seen": 14, "decoded_ms": 172.592036}, {"seq": 15, "x": 182, "y": 750, "seen": 15, "decoded_ms": 195.775758}, {"seq": 16, "x": 548, "y": 850, "seen": 16, "decoded_ms": 151.145679}, {"seq": 17, "x": 914, "y": 750, "seen": 17, "decoded_ms": 173.941806}, {"seq": 18, "x": 1280, "y": 850, "seen": 18, "decoded_ms": 352.426864}, {"seq": 19, "x": 1645, "y": 750, "seen": 19, "decoded_ms": 156.050885}, {"seq": 20, "x": 2011, "y": 850, "seen": 20, "decoded_ms": 158.266234}, {"seq": 21, "x": 2377, "y": 750, "seen": 21, "decoded_ms": 126.999009}, {"seq": 22, "x": 182, "y": 850, "seen": 22, "decoded_ms": 194.209712}, {"seq": 23, "x": 548, "y": 750, "seen": 23, "decoded_ms": 183.157837}, {"seq": 24, "x": 914, "y": 850, "seen": 24, "decoded_ms": 186.545894}, {"seq": 25, "x": 1280, "y": 750, "seen": 25, "decoded_ms": 506.013562}, {"seq": 26, "x": 1645, "y": 850, "seen": 26, "decoded_ms": 164.698513}, {"seq": 27, "x": 2011, "y": 750, "seen": 27, "decoded_ms": 177.684307}, {"seq": 28, "x": 2377, "y": 850, "seen": 28, "decoded_ms": 174.644202}], "p50_ms": 173.26692100000002, "p95_ms": 506.013562, "method": "XTest click-down to unique decoded sequence; excludes USB and scanout"} diff --git a/docs/evidence/native-cursor-20261105/ubuntu-final-soak.json b/docs/evidence/native-cursor-20261105/ubuntu-final-soak.json new file mode 100644 index 0000000..3377e1b --- /dev/null +++ b/docs/evidence/native-cursor-20261105/ubuntu-final-soak.json @@ -0,0 +1,11 @@ +{ + "pid": 293859, + "requested_seconds": 360.0, + "samples": 65960, + "timestamp_after_pre_read_clock": 1, + "timestamp_after_post_read_clock": 0, + "max_age_ms": 1109.787402, + "survived": true, + "last_sequence": 6538, + "elapsed_seconds": 360.003 +} diff --git a/docs/evidence/native-cursor-20261105/ubuntu-final-stale-state.json b/docs/evidence/native-cursor-20261105/ubuntu-final-stale-state.json new file mode 100644 index 0000000..6bba7c5 --- /dev/null +++ b/docs/evidence/native-cursor-20261105/ubuntu-final-stale-state.json @@ -0,0 +1 @@ +{"pid": 293859, "worker": 293861, "pause_seconds": 2.6, "exited": true, "check_seconds": 2.9042484689998673} diff --git a/docs/evidence/native-cursor-20261105/ubuntu-first.png b/docs/evidence/native-cursor-20261105/ubuntu-first.png new file mode 100644 index 0000000..e535101 Binary files /dev/null and b/docs/evidence/native-cursor-20261105/ubuntu-first.png differ diff --git a/docs/evidence/native-cursor-20261105/ubuntu-first.xwd.gz b/docs/evidence/native-cursor-20261105/ubuntu-first.xwd.gz new file mode 100644 index 0000000..c712675 Binary files /dev/null and b/docs/evidence/native-cursor-20261105/ubuntu-first.xwd.gz differ diff --git a/docs/evidence/native-cursor-20261105/ubuntu-normal-after-fix.png b/docs/evidence/native-cursor-20261105/ubuntu-normal-after-fix.png new file mode 100644 index 0000000..7e8d4e1 Binary files /dev/null and b/docs/evidence/native-cursor-20261105/ubuntu-normal-after-fix.png differ diff --git a/docs/evidence/native-cursor-20261105/ubuntu-normal-client.json b/docs/evidence/native-cursor-20261105/ubuntu-normal-client.json new file mode 100644 index 0000000..e2ac015 --- /dev/null +++ b/docs/evidence/native-cursor-20261105/ubuntu-normal-client.json @@ -0,0 +1 @@ +{"pid": 299860, "ppid": 235482, "args": "/home/armbian/vdi-build/rkmpp-experiment/build/moonlight/app/moonlight stream 192.168.1.72 Desktop --resolution 2560x1440 --fps 30 --bitrate 30000 --display-mode fullscreen --video-codec H.264 --video-decoder hardware --absolute-mouse --capture-system-keys always --keep-awake --no-vsync --no-frame-pacing --audio-on-host ", "sha256": "5d4330f703d7f8e053bc3795ca42bdaf231c9e9dd8ebb00a612013adbcccee03", "native": true, "shape": [24, 24, 3, 1]} diff --git a/docs/evidence/native-cursor-20261105/ubuntu-normal.png b/docs/evidence/native-cursor-20261105/ubuntu-normal.png new file mode 100644 index 0000000..13ab00a Binary files /dev/null and b/docs/evidence/native-cursor-20261105/ubuntu-normal.png differ diff --git a/docs/evidence/native-cursor-20261105/ubuntu-probe-v2.json b/docs/evidence/native-cursor-20261105/ubuntu-probe-v2.json new file mode 100644 index 0000000..e69de29 diff --git a/docs/evidence/native-cursor-20261105/ubuntu-probe-v3.json b/docs/evidence/native-cursor-20261105/ubuntu-probe-v3.json new file mode 100644 index 0000000..b9d55e0 --- /dev/null +++ b/docs/evidence/native-cursor-20261105/ubuntu-probe-v3.json @@ -0,0 +1 @@ +{"host": "192.168.1.72", "pid": 256179, "shapes": [{"name": "default", "source": {"w": 24, "h": 24, "hx": 3, "hy": 1, "x": 181, "y": 599, "sw": 2560, "sh": 1440}, "local": {"x": 182, "y": 600, "w": 24, "h": 24, "hx": 3, "hy": 1}, "geometry_matches": true, "max_pixel_difference": 162, "visible_pixels": 254, "video": {"reference": 15726330, "nonuniform_pixels": 0}}, {"name": "text", "source": {"w": 24, "h": 24, "hx": 11, "hy": 12, "x": 547, "y": 599, "sw": 2560, "sh": 1440}, "local": {"x": 548, "y": 600, "w": 24, "h": 24, "hx": 11, "hy": 12}, "geometry_matches": true, "max_pixel_difference": 101, "visible_pixels": 240, "video": {"reference": 15791866, "nonuniform_pixels": 0}}, {"name": "ew-resize", "source": {"w": 24, "h": 24, "hx": 12, "hy": 12, "x": 913, "y": 599, "sw": 2560, "sh": 1440}, "local": {"x": 914, "y": 600, "w": 24, "h": 24, "hx": 12, "hy": 12}, "geometry_matches": true, "max_pixel_difference": 175, "visible_pixels": 301, "video": {"reference": 15726330, "nonuniform_pixels": 0}}, {"name": "wait", "source": {"w": 24, "h": 24, "hx": 11, "hy": 11, "x": 1280, "y": 599, "sw": 2560, "sh": 1440}, "local": {"x": 1280, "y": 600, "w": 24, "h": 24, "hx": 11, "hy": 11}, "geometry_matches": true, "max_pixel_difference": 129, "visible_pixels": 433, "video": {"reference": 15791866, "nonuniform_pixels": 0}}, {"name": "none", "source": {"w": 1, "h": 1, "hx": 0, "hy": 0, "x": 1645, "y": 599, "sw": 2560, "sh": 1440}, "local": {"x": 1645, "y": 600, "w": 1, "h": 1, "hx": 0, "hy": 0}, "geometry_matches": true, "max_pixel_difference": 0, "visible_pixels": 0, "video": {"reference": 15726074, "nonuniform_pixels": 0}}, {"name": "crosshair", "source": {"w": 24, "h": 24, "hx": 11, "hy": 11, "x": 2011, "y": 599, "sw": 2560, "sh": 1440}, "local": {"x": 2011, "y": 600, "w": 24, "h": 24, "hx": 11, "hy": 11}, "geometry_matches": true, "max_pixel_difference": 78, "visible_pixels": 281, "video": {"reference": 15726074, "nonuniform_pixels": 0}}, {"name": "custom", "source": {"w": 24, "h": 24, "hx": 7, "hy": 9, "x": 2377, "y": 599, "sw": 2560, "sh": 1440}, "local": {"x": 2377, "y": 600, "w": 24, "h": 24, "hx": 7, "hy": 9}, "geometry_matches": true, "max_pixel_difference": 0, "visible_pixels": 576, "video": {"reference": 15726074, "nonuniform_pixels": 0}}], "paused_client": {"position": [250, 650], "elapsed_ms": 2.727055, "same_shape": true}, "clicks": [{"seq": 3, "x": 182, "y": 750, "seen": 3, "decoded_ms": 177.207219}, {"seq": 4, "x": 548, "y": 850, "seen": 4, "decoded_ms": 177.766631}, {"seq": 5, "x": 914, "y": 750, "seen": 5, "decoded_ms": 199.373737}, {"seq": 6, "x": 1280, "y": 850, "seen": 6, "decoded_ms": 182.446665}, {"seq": 7, "x": 1645, "y": 750, "seen": 7, "decoded_ms": 174.159044}, {"seq": 8, "x": 2011, "y": 850, "seen": 8, "decoded_ms": 211.839356}, {"seq": 9, "x": 2377, "y": 750, "seen": 9, "decoded_ms": 172.112732}, {"seq": 10, "x": 182, "y": 850, "seen": 10, "decoded_ms": 218.076291}, {"seq": 11, "x": 548, "y": 750, "seen": 11, "decoded_ms": 158.319669}, {"seq": 12, "x": 914, "y": 850, "seen": 12, "decoded_ms": 190.78387}, {"seq": 13, "x": 1280, "y": 750, "seen": 13, "decoded_ms": 475.373962}, {"seq": 14, "x": 1645, "y": 850, "seen": 14, "decoded_ms": 183.265657}, {"seq": 15, "x": 2011, "y": 750, "seen": 15, "decoded_ms": 190.493082}, {"seq": 16, "x": 2377, "y": 850, "seen": 16, "decoded_ms": 174.658874}, {"seq": 17, "x": 182, "y": 750, "seen": 17, "decoded_ms": 202.290292}, {"seq": 18, "x": 548, "y": 850, "seen": 18, "decoded_ms": 201.035264}, {"seq": 19, "x": 914, "y": 750, "seen": 19, "decoded_ms": 169.113849}, {"seq": 20, "x": 1280, "y": 850, "seen": 20, "decoded_ms": 389.791156}, {"seq": 21, "x": 1645, "y": 750, "seen": 21, "decoded_ms": 145.709303}, {"seq": 22, "x": 2011, "y": 850, "seen": 22, "decoded_ms": 142.136716}, {"seq": 23, "x": 2377, "y": 750, "seen": 23, "decoded_ms": 150.834416}, {"seq": 24, "x": 182, "y": 850, "seen": 24, "decoded_ms": 218.919785}, {"seq": 25, "x": 548, "y": 750, "seen": 25, "decoded_ms": 145.115185}, {"seq": 26, "x": 914, "y": 850, "seen": 26, "decoded_ms": 174.238963}, {"seq": 27, "x": 1280, "y": 750, "seen": 27, "decoded_ms": 476.883703}, {"seq": 28, "x": 1645, "y": 850, "seen": 28, "decoded_ms": 165.254265}, {"seq": 29, "x": 2011, "y": 750, "seen": 29, "decoded_ms": 182.507335}, {"seq": 30, "x": 2377, "y": 850, "seen": 30, "decoded_ms": 168.10207}], "p50_ms": 180.106648, "p95_ms": 475.373962, "method": "XTest click-down to unique decoded sequence; excludes USB and scanout"} diff --git a/docs/evidence/native-cursor-20261105/ubuntu-probe.json b/docs/evidence/native-cursor-20261105/ubuntu-probe.json new file mode 100644 index 0000000..e69de29 diff --git a/docs/evidence/native-cursor-20261105/ubuntu-release-probe.json b/docs/evidence/native-cursor-20261105/ubuntu-release-probe.json new file mode 100644 index 0000000..2e07958 --- /dev/null +++ b/docs/evidence/native-cursor-20261105/ubuntu-release-probe.json @@ -0,0 +1 @@ +{"host": "192.168.1.72", "pid": 293859, "shapes": [{"name": "default", "source": {"w": 24, "h": 24, "hx": 3, "hy": 1, "x": 181, "y": 599, "sw": 2560, "sh": 1440}, "local": {"x": 182, "y": 600, "w": 24, "h": 24, "hx": 3, "hy": 1}, "geometry_matches": true, "max_pixel_difference": 0, "visible_pixels": 254, "video": {"reference": 15726330, "nonuniform_pixels": 0}}, {"name": "text", "source": {"w": 24, "h": 24, "hx": 11, "hy": 12, "x": 547, "y": 599, "sw": 2560, "sh": 1440}, "local": {"x": 548, "y": 600, "w": 24, "h": 24, "hx": 11, "hy": 12}, "geometry_matches": true, "max_pixel_difference": 0, "visible_pixels": 240, "video": {"reference": 15791866, "nonuniform_pixels": 0}}, {"name": "ew-resize", "source": {"w": 24, "h": 24, "hx": 12, "hy": 12, "x": 913, "y": 599, "sw": 2560, "sh": 1440}, "local": {"x": 914, "y": 600, "w": 24, "h": 24, "hx": 12, "hy": 12}, "geometry_matches": true, "max_pixel_difference": 0, "visible_pixels": 301, "video": {"reference": 15726330, "nonuniform_pixels": 0}}, {"name": "wait", "source": {"w": 24, "h": 24, "hx": 11, "hy": 11, "x": 1280, "y": 599, "sw": 2560, "sh": 1440}, "local": {"x": 1280, "y": 600, "w": 24, "h": 24, "hx": 11, "hy": 11}, "geometry_matches": true, "max_pixel_difference": 0, "visible_pixels": 433, "video": {"reference": 15791866, "nonuniform_pixels": 0}}, {"name": "none", "source": {"w": 1, "h": 1, "hx": 0, "hy": 0, "x": 1645, "y": 599, "sw": 2560, "sh": 1440}, "local": {"x": 1645, "y": 600, "w": 1, "h": 1, "hx": 0, "hy": 0}, "geometry_matches": true, "max_pixel_difference": 0, "visible_pixels": 0, "video": {"reference": 15726074, "nonuniform_pixels": 0}}, {"name": "crosshair", "source": {"w": 24, "h": 24, "hx": 11, "hy": 11, "x": 2011, "y": 599, "sw": 2560, "sh": 1440}, "local": {"x": 2011, "y": 600, "w": 24, "h": 24, "hx": 11, "hy": 11}, "geometry_matches": true, "max_pixel_difference": 0, "visible_pixels": 281, "video": {"reference": 15726074, "nonuniform_pixels": 0}}, {"name": "custom", "source": {"w": 24, "h": 24, "hx": 7, "hy": 9, "x": 2377, "y": 599, "sw": 2560, "sh": 1440}, "local": {"x": 2377, "y": 600, "w": 24, "h": 24, "hx": 7, "hy": 9}, "geometry_matches": true, "max_pixel_difference": 0, "visible_pixels": 576, "video": {"reference": 15726074, "nonuniform_pixels": 0}}], "paused_client": {"position": [350, 700], "elapsed_ms": 249.623897, "same_shape": true, "steps": [{"target": [250, 650], "position": [250, 650], "query_ms": 2.255624}, {"target": [280, 670], "position": [280, 670], "query_ms": 2.353619}, {"target": [300, 690], "position": [300, 690], "query_ms": 2.202252}, {"target": [350, 700], "position": [350, 700], "query_ms": 2.288873}]}, "clicks": [{"seq": 1, "x": 182, "y": 750, "seen": 1, "decoded_ms": 186.428878}, {"seq": 2, "x": 548, "y": 850, "seen": 2, "decoded_ms": 181.712898}, {"seq": 3, "x": 914, "y": 750, "seen": 3, "decoded_ms": 207.490698}, {"seq": 4, "x": 1280, "y": 850, "seen": 4, "decoded_ms": 533.830025}, {"seq": 5, "x": 1645, "y": 750, "seen": 5, "decoded_ms": 190.340809}, {"seq": 6, "x": 2011, "y": 850, "seen": 6, "decoded_ms": 204.401567}, {"seq": 7, "x": 2377, "y": 750, "seen": 7, "decoded_ms": 166.595545}, {"seq": 8, "x": 182, "y": 850, "seen": 8, "decoded_ms": 195.011611}, {"seq": 9, "x": 548, "y": 750, "seen": 9, "decoded_ms": 181.070161}, {"seq": 10, "x": 914, "y": 850, "seen": 10, "decoded_ms": 191.989545}, {"seq": 11, "x": 1280, "y": 750, "seen": 11, "decoded_ms": 450.587655}, {"seq": 12, "x": 1645, "y": 850, "seen": 12, "decoded_ms": 166.558247}, {"seq": 13, "x": 2011, "y": 750, "seen": 13, "decoded_ms": 174.993944}, {"seq": 14, "x": 2377, "y": 850, "seen": 14, "decoded_ms": 195.413266}, {"seq": 15, "x": 182, "y": 750, "seen": 15, "decoded_ms": 242.226344}, {"seq": 16, "x": 548, "y": 850, "seen": 16, "decoded_ms": 206.124429}, {"seq": 17, "x": 914, "y": 750, "seen": 17, "decoded_ms": 181.78767}, {"seq": 18, "x": 1280, "y": 850, "seen": 18, "decoded_ms": 463.825494}, {"seq": 19, "x": 1645, "y": 750, "seen": 19, "decoded_ms": 194.360157}, {"seq": 20, "x": 2011, "y": 850, "seen": 20, "decoded_ms": 177.975533}, {"seq": 21, "x": 2377, "y": 750, "seen": 21, "decoded_ms": 229.660945}, {"seq": 22, "x": 182, "y": 850, "seen": 22, "decoded_ms": 159.611781}, {"seq": 23, "x": 548, "y": 750, "seen": 23, "decoded_ms": 149.18704}, {"seq": 24, "x": 914, "y": 850, "seen": 24, "decoded_ms": 179.366438}, {"seq": 25, "x": 1280, "y": 750, "seen": 25, "decoded_ms": 522.420223}, {"seq": 26, "x": 1645, "y": 850, "seen": 26, "decoded_ms": 154.151825}, {"seq": 27, "x": 2011, "y": 750, "seen": 27, "decoded_ms": 154.669796}, {"seq": 28, "x": 2377, "y": 850, "seen": 28, "decoded_ms": 145.580813}], "p50_ms": 188.3848435, "p95_ms": 522.420223, "method": "XTest click-down to unique decoded sequence; excludes USB and scanout"} diff --git a/docs/evidence/native-cursor-20261105/ubuntu-second.png b/docs/evidence/native-cursor-20261105/ubuntu-second.png new file mode 100644 index 0000000..34c9d5e Binary files /dev/null and b/docs/evidence/native-cursor-20261105/ubuntu-second.png differ diff --git a/docs/evidence/native-cursor-20261105/ubuntu-second.xwd.gz b/docs/evidence/native-cursor-20261105/ubuntu-second.xwd.gz new file mode 100644 index 0000000..ea88061 Binary files /dev/null and b/docs/evidence/native-cursor-20261105/ubuntu-second.xwd.gz differ diff --git a/docs/evidence/native-cursor-20261105/ubuntu-short-stall.json b/docs/evidence/native-cursor-20261105/ubuntu-short-stall.json new file mode 100644 index 0000000..a779489 --- /dev/null +++ b/docs/evidence/native-cursor-20261105/ubuntu-short-stall.json @@ -0,0 +1,7 @@ +{ + "agent": 39181, + "client": 293859, + "pause_seconds": 1.1, + "local_pointer": "x:650 y:900 screen:0 window:48234511", + "survived": true +} \ No newline at end of file diff --git a/docs/evidence/native-cursor-20261105/ubuntu-stale-state.json b/docs/evidence/native-cursor-20261105/ubuntu-stale-state.json new file mode 100644 index 0000000..1c6bd8d --- /dev/null +++ b/docs/evidence/native-cursor-20261105/ubuntu-stale-state.json @@ -0,0 +1,5 @@ +{ + "bridge_frozen_seconds": 1.2, + "checked_after_seconds": 3.289, + "remaining_clients": [] +} \ No newline at end of file diff --git a/docs/evidence/native-cursor-20261105/ubuntu-v3-final-probe.json b/docs/evidence/native-cursor-20261105/ubuntu-v3-final-probe.json new file mode 100644 index 0000000..e69de29 diff --git a/docs/evidence/native-cursor-20261105/ubuntu-v4-final-probe.json b/docs/evidence/native-cursor-20261105/ubuntu-v4-final-probe.json new file mode 100644 index 0000000..afb530c --- /dev/null +++ b/docs/evidence/native-cursor-20261105/ubuntu-v4-final-probe.json @@ -0,0 +1 @@ +{"host": "192.168.1.72", "pid": 272099, "shapes": [{"name": "default", "source": {"w": 24, "h": 24, "hx": 3, "hy": 1, "x": 181, "y": 599, "sw": 2560, "sh": 1440}, "local": {"x": 182, "y": 600, "w": 24, "h": 24, "hx": 3, "hy": 1}, "geometry_matches": true, "max_pixel_difference": 0, "visible_pixels": 254, "video": {"reference": 15726330, "nonuniform_pixels": 0}}, {"name": "text", "source": {"w": 24, "h": 24, "hx": 11, "hy": 12, "x": 547, "y": 599, "sw": 2560, "sh": 1440}, "local": {"x": 548, "y": 600, "w": 24, "h": 24, "hx": 11, "hy": 12}, "geometry_matches": true, "max_pixel_difference": 0, "visible_pixels": 240, "video": {"reference": 15791866, "nonuniform_pixels": 0}}, {"name": "ew-resize", "source": {"w": 24, "h": 24, "hx": 12, "hy": 12, "x": 913, "y": 599, "sw": 2560, "sh": 1440}, "local": {"x": 914, "y": 600, "w": 24, "h": 24, "hx": 12, "hy": 12}, "geometry_matches": true, "max_pixel_difference": 0, "visible_pixels": 301, "video": {"reference": 15726330, "nonuniform_pixels": 0}}, {"name": "wait", "source": {"w": 24, "h": 24, "hx": 11, "hy": 11, "x": 1280, "y": 599, "sw": 2560, "sh": 1440}, "local": {"x": 1280, "y": 600, "w": 24, "h": 24, "hx": 11, "hy": 11}, "geometry_matches": true, "max_pixel_difference": 0, "visible_pixels": 433, "video": {"reference": 15791866, "nonuniform_pixels": 0}}, {"name": "none", "source": {"w": 1, "h": 1, "hx": 0, "hy": 0, "x": 1645, "y": 599, "sw": 2560, "sh": 1440}, "local": {"x": 1645, "y": 600, "w": 1, "h": 1, "hx": 0, "hy": 0}, "geometry_matches": true, "max_pixel_difference": 0, "visible_pixels": 0, "video": {"reference": 15726074, "nonuniform_pixels": 0}}, {"name": "crosshair", "source": {"w": 24, "h": 24, "hx": 11, "hy": 11, "x": 2011, "y": 599, "sw": 2560, "sh": 1440}, "local": {"x": 2011, "y": 600, "w": 24, "h": 24, "hx": 11, "hy": 11}, "geometry_matches": true, "max_pixel_difference": 0, "visible_pixels": 281, "video": {"reference": 15726074, "nonuniform_pixels": 0}}, {"name": "custom", "source": {"w": 24, "h": 24, "hx": 7, "hy": 9, "x": 2377, "y": 599, "sw": 2560, "sh": 1440}, "local": {"x": 2377, "y": 600, "w": 24, "h": 24, "hx": 7, "hy": 9}, "geometry_matches": true, "max_pixel_difference": 0, "visible_pixels": 576, "video": {"reference": 15726074, "nonuniform_pixels": 0}}], "paused_client": {"position": [350, 700], "elapsed_ms": 263.611753, "same_shape": true, "steps": [{"target": [250, 650], "position": [250, 650], "query_ms": 16.220678}, {"target": [280, 670], "position": [280, 670], "query_ms": 2.214575}, {"target": [300, 690], "position": [300, 690], "query_ms": 2.215451}, {"target": [350, 700], "position": [350, 700], "query_ms": 2.380822}]}, "clicks": [{"seq": 2, "x": 182, "y": 750, "seen": 2, "decoded_ms": 192.980398}, {"seq": 3, "x": 548, "y": 850, "seen": 3, "decoded_ms": 249.189423}, {"seq": 4, "x": 914, "y": 750, "seen": 4, "decoded_ms": 193.088898}, {"seq": 5, "x": 1280, "y": 850, "seen": 5, "decoded_ms": 374.099277}, {"seq": 6, "x": 1645, "y": 750, "seen": 6, "decoded_ms": 166.521582}, {"seq": 7, "x": 2011, "y": 850, "seen": 7, "decoded_ms": 207.861192}, {"seq": 8, "x": 2377, "y": 750, "seen": 8, "decoded_ms": 246.000709}, {"seq": 9, "x": 182, "y": 850, "seen": 9, "decoded_ms": 181.998527}, {"seq": 10, "x": 548, "y": 750, "seen": 10, "decoded_ms": 181.410831}, {"seq": 11, "x": 914, "y": 850, "seen": 11, "decoded_ms": 221.952754}, {"seq": 12, "x": 1280, "y": 750, "seen": 12, "decoded_ms": 507.168335}, {"seq": 13, "x": 1645, "y": 850, "seen": 13, "decoded_ms": 182.568433}, {"seq": 14, "x": 2011, "y": 750, "seen": 14, "decoded_ms": 164.879833}, {"seq": 15, "x": 2377, "y": 850, "seen": 15, "decoded_ms": 212.375805}, {"seq": 16, "x": 182, "y": 750, "seen": 16, "decoded_ms": 188.116394}, {"seq": 17, "x": 548, "y": 850, "seen": 17, "decoded_ms": 203.54084}, {"seq": 18, "x": 914, "y": 750, "seen": 18, "decoded_ms": 183.044427}, {"seq": 19, "x": 1280, "y": 850, "seen": 19, "decoded_ms": 316.917585}, {"seq": 20, "x": 1645, "y": 750, "seen": 20, "decoded_ms": 161.788239}, {"seq": 21, "x": 2011, "y": 850, "seen": 21, "decoded_ms": 180.879436}, {"seq": 22, "x": 2377, "y": 750, "seen": 22, "decoded_ms": 172.71762}, {"seq": 23, "x": 182, "y": 850, "seen": 23, "decoded_ms": 175.79901}, {"seq": 24, "x": 548, "y": 750, "seen": 24, "decoded_ms": 185.907366}, {"seq": 25, "x": 914, "y": 850, "seen": 25, "decoded_ms": 207.759418}, {"seq": 26, "x": 1280, "y": 750, "seen": 26, "decoded_ms": 468.920931}, {"seq": 27, "x": 1645, "y": 850, "seen": 27, "decoded_ms": 189.784407}, {"seq": 28, "x": 2011, "y": 750, "seen": 28, "decoded_ms": 186.12174}, {"seq": 29, "x": 2377, "y": 850, "seen": 29, "decoded_ms": 182.798273}], "p50_ms": 188.9504005, "p95_ms": 468.920931, "method": "XTest click-down to unique decoded sequence; excludes USB and scanout"} diff --git a/docs/evidence/native-cursor-20261105/ubuntu-warp-v3.json b/docs/evidence/native-cursor-20261105/ubuntu-warp-v3.json new file mode 100644 index 0000000..9608a2e --- /dev/null +++ b/docs/evidence/native-cursor-20261105/ubuntu-warp-v3.json @@ -0,0 +1,9 @@ +{ + "host": "192.168.1.72", + "target": [ + 400, + 600 + ], + "readback": "X=400\nY=600\nSCREEN=0\nWINDOW=48234511\n", + "passed": true +} \ No newline at end of file diff --git a/docs/evidence/native-cursor-20261105/ubuntu-warp.json b/docs/evidence/native-cursor-20261105/ubuntu-warp.json new file mode 100644 index 0000000..9ef234f --- /dev/null +++ b/docs/evidence/native-cursor-20261105/ubuntu-warp.json @@ -0,0 +1,8 @@ +{ + "host": "192.168.1.72", + "target": [ + 400, + 600 + ], + "readback": "X=1500\nY=900\nSCREEN=0\nWINDOW=48234511\n" +} \ No newline at end of file diff --git a/docs/evidence/native-cursor-20261105/windows-final.json b/docs/evidence/native-cursor-20261105/windows-final.json new file mode 100644 index 0000000..7340fbd --- /dev/null +++ b/docs/evidence/native-cursor-20261105/windows-final.json @@ -0,0 +1 @@ +{"pid": 299142, "args": "/home/armbian/vdi-build/rkmpp-experiment/build/moonlight/app/moonlight stream 192.168.1.70 Desktop --resolution 2560x1440 --fps 45 --bitrate 30000 --display-mode fullscreen --video-codec H.264 --video-decoder hardware --absolute-mouse --capture-system-keys always --keep-awake --no-vsync --no-frame-pacing --audio-on-host ", "sha256": "5d4330f703d7f8e053bc3795ca42bdaf231c9e9dd8ebb00a612013adbcccee03", "native": false} diff --git a/docs/evidence/native-cursor-20261105/windows-normal.png b/docs/evidence/native-cursor-20261105/windows-normal.png new file mode 100644 index 0000000..a63bc2c Binary files /dev/null and b/docs/evidence/native-cursor-20261105/windows-normal.png differ diff --git a/docs/independent-cursor-design-20261103.md b/docs/independent-cursor-design-20261103.md index 59f4b36..77ef80b 100644 --- a/docs/independent-cursor-design-20261103.md +++ b/docs/independent-cursor-design-20261103.md @@ -1,5 +1,7 @@ # 独立光标二次开发设计与接入条件 +> 2026-11-05更新:Ubuntu/麒麟已部署单座席X11受限方案,通过认证SSH同步形状,在新连接前启动无光标捕获并在整次会话结束后恢复服务,见[实施记录](linux-native-cursor-20261105.md)。以下是11月3日Windows正式控制协议设计与当时现场状态;该Windows协议仍未实现,不能混同Linux新方案。 + 状态:已通过受控的 Windows 光标导出 + 盒子 X11 本地指针原型验证;尚未实现 Sunshine/Moonlight 的自动独立光标通道,不应标记为生产功能已部署。测试已结束,当前恢复单个 Windows 远程白色光标。实测结果见 [独立光标可行性验证](independent-cursor-feasibility-20261103.md)。 ## 已核实的实际路径 diff --git a/docs/independent-cursor-feasibility-20261103.md b/docs/independent-cursor-feasibility-20261103.md index fb41b36..9ad0c1c 100644 --- a/docs/independent-cursor-feasibility-20261103.md +++ b/docs/independent-cursor-feasibility-20261103.md @@ -1,5 +1,7 @@ # 独立光标原型可行性实测(2026-11-03) +> 2026-11-05更新:Linux两台主机已接入不同于本原型的认证SSH原生光标方案,见[实施记录](linux-native-cursor-20261105.md)。本页保留11月3日Windows受控实验;Windows仍走原视频光标,正式双方控制协议尚未实现。 + ## 结论和当前现场 本地独立移动和 Windows 光标静态形状显示可行,值得继续开发 Sunshine + Moonlight 的正式同步协议。本轮完成了真实盒子和 VM100 的受控原型,不是仅做源码调研。但是自动形状同步、视频帧交接、断线回退尚未成为产品功能,不能默认启用,也不能称鼠标或窗口零延迟。 diff --git a/docs/linux-desktop-onboarding-20261104.md b/docs/linux-desktop-onboarding-20261104.md index e7d881c..59c6de5 100644 --- a/docs/linux-desktop-onboarding-20261104.md +++ b/docs/linux-desktop-onboarding-20261104.md @@ -2,6 +2,8 @@ > 后续更新:麒麟已升级真实2560×1440/30FPS配置与30Mbps,动态短测约23FPS,见[麒麟2K调优记录](kylin-quality-20261104.md)。以下1080p参数属于初次接入记录。 +> 2026-11-05:两套Linux均为真实2K/30FPS请求配置与30Mbps,已启用同步原生形状的独立光标,指针位置不再等待视频;Ubuntu恢复原生Adwaita/default加速,关闭动画保留。详见[独立光标实施记录](linux-native-cursor-20261105.md)。视频动态性能和物理输入延迟仍有边界,较早画质实验见[Ubuntu调优记录](ubuntu-quality-20261105.md)。 + 两台 Linux 已接入现场盒子的正常桌面选择流程。实际验证了连接、画面、键盘、鼠标、退出和再次连接;最终回到桌面列表。Windows 也完成了原配置连接回归。 ## 现场配置 diff --git a/docs/linux-native-cursor-20261105.md b/docs/linux-native-cursor-20261105.md new file mode 100644 index 0000000..63d040a --- /dev/null +++ b/docs/linux-native-cursor-20261105.md @@ -0,0 +1,66 @@ +# Ubuntu / 麒麟原生独立光标实施(2026-11-05) + +本轮按用户要求保留系统原生光标,降低指针移动对视频更新的依赖。两台Linux都使用X11、真实2560×1440、30FPS请求、30Mbps和原有软件编码;Windows仍为原硬编路径。Ubuntu光标主题已从此前的DMZ-White恢复Adwaita/24px,鼠标加速恢复default,关闭动画的性能设置保留。 + +## 调研与取舍 + +当前Moonlight v6.2.0的普通本地光标快捷键不会同步服务端形状,不能用于本任务。[上游使用说明](https://github.com/moonlight-stream/moonlight-docs/wiki/Setup-Guide)和[维护者说明](https://github.com/moonlight-stream/moonlight-qt/issues/1386)与固定版本源码一致。Linux Sunshine X11捕获会通过XFixes读取光标后混入视频;[v0.21.0捕获源码](https://github.com/LizardByte/Sunshine/blob/v0.21.0/src/platform/linux/x11grab.cpp)已实际下载核对。 + +继续调编码参数无法消除指针位置对软件编码、网络、解码的等待。换SPICE/VNC/RDP虽然可采用协议原生光标,但会改变现有桌面入口、音视频和部署路径;本轮没有把更换协议当成已验证优化。两台又有不同Sunshine版本,因此采用**已认证SSH光标通道 + 精确版本固定的进程级捕获适配器 + SDL主线程原生光标**,保留现有Moonlight视频和可靠鼠标输入通道。 + +这与11月3日的正式控制协议设计不同:不做运行中切换,而是在新视频连接前启动无光标捕获,退出时整个捕获会话结束后才恢复普通服务。这样无需用未关联的视频帧ACK或固定延时来猜测旧光标何时消失。SSH使用每台主机独立的受限强制命令密钥和固定主机公钥;没有新增未认证UDP端口。实现、安装、生命周期和回退详见[模块说明](../terminal/native-cursor/README.md)。 + +[SDL光标API](https://wiki.libsdl.org/SDL2/SDL_CreateColorCursor)提供真实彩色形状和热点。[SDL2 X11源码](https://github.com/libsdl-org/SDL/blob/release-2.30.0/src/video/x11/SDL_x11mouse.c)还暴露两个适配点:Xcursor像素直接复制,必须预乘alpha;启用指针边界时窗口内warp可能不执行,因此主动位置校正使用窗口范围内的global warp。没有更换SDL系统库。 + +## 实现及边界 + +- 0005客户端补丁由构建器在现有补丁后应用;Linux主机JSON才启用功能,Windows无该JSON。profile中的客户端SHA256在远端租约前校验,旧备份客户端实测被拒绝且麒麟原服务PID未改变。fork监督进程后exec保留kiosk预期的Moonlight PID,实际测试从正常列表进入。 +- 主机guard核验私有Sunshine副本和adapter SHA256;adapter仅在该进程的XFixes捕获结果中清除光标像素。原系统二进制、主题、原配置保留;租约结束删除/run中的临时服务drop-in并恢复原服务。 +- 形状、热点、动画、透明隐藏、单屏几何、序号和随机租约均有边界检查;原子状态文件位于盒子0700私有目录。SDL资源只在主线程创建/切换/释放。 +- 最新本地输入位置先由服务器实际指针确认;本地静止500ms且没有按钮按下时才接受服务器主动warp,避免拿迟到的输入回显回拉光标。 +- 当前仅支持已配置的单座席X11/2560×1440/绝对鼠标、最多128×128像素形状。短暂停顿保留上次形状,本地位置继续移动;未知形状、源分辨率改变、同步状态超过2秒或SSH故障会结束当前串流,恢复普通服务后可从列表重连。主机租约4秒,不允许无限期保留旧状态。 +- 不宣称通用多座席、相对鼠标游戏、Wayland、任意显示设备或运行中平滑降级已完成。启用模式内阻止切换为相对鼠标或固定本地箭头的快捷键;其他主机保持原逻辑。 +- 原视频保留2K H.264 4:2:0及30Mbps,没有用降分辨率提高指针反馈。独立指针解决位置移动,点击后的页面和拖窗内容继续受视频链路限制。 + +## 联调与证据 + +原始证据位于[evidence/native-cursor-20261105/](evidence/native-cursor-20261105/)。最终SHA为 `5d4330…` 的两系统复测为 `ubuntu-release-probe.json`、`kylin-release-probe.json`,点击与源事件核对见 `release-click-verification.json` 及对应 `*-release-source.log`。较早 `ubuntu-v4-final-probe.json`、`kylin-final-probe.json` 等保留作修正历史,不混同最终二进制证据。 + +| 验证项目 | Ubuntu | 麒麟 | +| --- | --- | --- | +| 原生箭头、文字、横向缩放、忙碌、隐藏、十字、自定义共7种 | 几何/热点一致,采样像素最大差0 | 几何/热点一致,采样像素最大差0 | +| 光标附近100×100均匀视频区域 | 七种均无非均匀像素;隐藏0可见像素 | 七种均无非均匀像素;隐藏0可见像素 | +| 唯一编号点击与源事件核对 | 28/28,最大坐标误差1.055px | 28/28,最大误差0.829px | +| 合成按下至编号出现在解码画面 | P50 188.38ms / P95 522.42ms | P50 134.37ms / P95 165.56ms | +| 稳定会话短按/双击补测 | 16次40ms按住全部收到,双击计数+2 | 16次40ms按住全部收到,双击计数+2 | +| 暂停Moonlight进程时移动本地指针 | 约250ms内4个位置读回正确,形状保留 | 约250ms内4个位置读回正确,形状保留 | +| 服务端主动warp至(400,600) | 本地读回(400,600) | 本地读回(400,600) | +| 主机agent / guard被SIGKILL后恢复原服务 | 2.724s / 0.771s检查恢复 | 3.220s / 0.893s检查恢复 | + +这些点击数字测量的是**页面/视频响应**,不表示本地指针移动延迟;坐标查询耗时也不是物理显示延迟。暂停视频生产者仍可移动本地指针证明了指针位置不再等待视频更新。没有测量物理USB输入、面板扫描或与Windows做相同仪器对照,因此不能宣称物理零延迟或定量等同Windows。 + +Ubuntu冻结盒子bridge后状态超时退出,3.289s检查无残留客户端且原服务恢复;麒麟实际改为1920×1080后自动结束会话,3.555s检查恢复原服务和2560×1440,之后从正常入口重连2K通过。焦点探针仅确认原生形状重新出现;kiosk键盘抓取下没有证据证明XSetInputFocus(None)触发了真正SDL失焦,**完整失焦场景尚未验收**。详见对应 `*-recovery.json`、`ubuntu-stale-state.json`、`kylin-resolution-change.json`、`kylin-focus.json` 和warp记录。 + +4项本地协议/边界/旧客户端拒绝单测通过;固定基线按顺序应用0001~0005(0002在common-c子模块)的检查通过,构建器规范化补丁CRLF后仍严格检查上下文。现场客户端保留原先源码修改并加入0005,本轮未把其他任务的0004重新构建部署;不得仅据构建器清洁应用成功宣称现场已包含0004。 + +最终2秒等待策略的Ubuntu持续测试(`ubuntu-final-soak.json`)完成360.003秒、65,960次实际状态读取,接收器持续存活。观察到1次“包时间戳晚于读前时间、但不晚于读后时间”的真实并发更新,支持修正校验顺序;读后未来时间0次。测试中主动暂停主机代理1.1秒,本地指针移动至(650,900),会话存活并恢复同步(`ubuntu-short-stall.json`);最大状态年龄1109.79ms包含这次故障注入,不是鼠标移动延迟。之后同一会话的七种光标和28点击复测全部匹配;再冻结bridge 2.6秒,2.904秒检查客户端已退出,原Sunshine路径和guard inactive核对通过,见 `ubuntu-final-stale-state.json`、`final-stale-recovery-services.json`。这仍是有界回归,长期稳定性未验收。 + +## 故障与修正 + +1. Ubuntu系统Sunshine带file capabilities,加载器忽略LD_PRELOAD。没有清除原文件能力;只在租约内使用相同SHA256、无额外能力的私有副本,正常退出恢复原文件。适配器映射和dlsym receipt均确认后才返回READY。 +2. 麒麟KYSEC拒绝未认证新脚本;仅认证host.py、私有Sunshine副本和adapter。没有关闭全局安全策略。 +3. GTK blank cursor实测是1×1、热点(1,1)。初版按可见光标边界拒绝并安全结束会话;修正为全透明图像的规范1×1/热点(0,0)。可见图像仍严格拒绝越界热点。 +4. 第一轮原生边缘像素差异大,发现SDL2 X11复制未预乘像素;修正后逐像素复测,不把初版当通过。初版28次点击P50约180ms/P95约475ms,只是探索数据。 +5. Ubuntu两次“强制暂停客户端→恢复→第一点击”探针首击未回显(40ms和80ms各一次),根因未确定。最终将正常输入测量与SIGSTOP实验分开,在稳定会话核对28个唯一编号及源端事件,再单独暂停客户端;稳定短按/双击补测也通过。没有证据把失败归因于GTK/窗口焦点,不得据后续通过抹去失败,或宣称任意高速输入绝不丢失。 +6. 主动warp第一轮未移动,确认是SDL2在pointer barrier存在时忽略窗口内warp;改为有界global warp后再复测。 +7. 最终解锁Ubuntu时弹出历史Sunshine崩溃提示。`/var/crash`中报告为11:12:07、原`/usr/bin/sunshine`、SIGTRAP;同秒服务日志为“10 seconds passed, yet Sunshine's still running: Forcing shutdown”,发生在故障恢复测试期间原服务停止的阶段,随后11:12:14恢复。该停止看门狗问题未作上游修复;不能据后续恢复称所有服务退出都无异常。原报告保留,未向外发送;元数据与日志在证据目录。 +8. 普通Ubuntu桌面连续运行约4分42秒后触发“stale or mismatched lease”退出。复核发现客户端先取当前时间再打开状态文件,可与写入进程原子更新文件竞态,合法新时间戳可能大于先前读数;修正为读取完成后再取校验时间。该轮错误未区分未来时间/实际过期,不能断言该竞态是退出的唯一原因。此前详细形状/点击证据来自修正前构建。 +9. 仅修正读时间顺序的构建先完成6分钟/65,658次状态读取,最大状态年龄308.10ms、无未来时间;但之后会话9分32秒时发生真实heartbeat timeout。故障发生前有持续忙碌动画和桌面应用启动,尚未确定是X11读取、调度还是SSH发送阻塞,不能只凭CPU软件编码推断唯一原因。最终将客户端/bridge等待上限调整为2秒,主机租约为4秒,并增加超过100ms的X11读取耗时诊断;本地指针位置仍立即显示,状态最多保留2秒后结束整次会话。修正版需按新阈值验证短暂停顿容忍和持续失效恢复;不把前一轮6分钟通过当成长时间稳定证明。 + +## 配置、备份与回退 + +现场客户端SHA256 `5d4330f703d7f8e053bc3795ca42bdaf231c9e9dd8ebb00a612013adbcccee03`,两个Linux profile均固定该值。私有副本:Ubuntu SHA256 `61e1ecbc8db2df786b9760c652aa4b9885dff36c57d4af09616344314cb7c34c`;麒麟 `1c3099bf924db3aee759b3d7c05e012569ef8650c400d1479144398795decc71`。adapter `cff4b7532aee69f0f205fa42946f45a89c1330486adc6358b90d13d2ccf1a0ea`。精确manifest在证据目录。未重启VM/盒子、未改PVE显卡或Windows服务端。 + +最终正常入口Windows为2K45/RKMPP/EGL、无原生光标环境;麒麟为2K30、原生形状恢复后正常退出,原服务active且无临时drop-in。盒子随后保留Ubuntu2K30会话,PID299860/父235482、原生Adwaita/24及default加速,实际视频源仍2K。两台GTK源程序和盒子持续探针均停止;活动Ubuntu会话的guard按设计保持active。见 `windows-final.json`、`kylin-normal-client.json`、`ubuntu-normal-client.json`、`final-services.json` 及普通桌面截图。XWD截图不含盒子原生指针,不能根据截图中没有箭头判定无光标。 + +盒子备份 `~/vdi-terminal/backups/native-cursor-20261105/` 有旧客户端和wrapper。回退先退出Linux会话并确认guard inactive/原Sunshine active,再把盒子对应 `~/.config/vdi-terminal/native-cursor/.json` 改名为.disabled;重连即使用原视频光标。若要恢复旧客户端,必须先停用两个JSON。Ubuntu主题原值备份仍在 `~/vdi-terminal/backups/ubuntu-quality-20261105/gsettings.json`;本轮恢复原生主题符合最新用户要求。 diff --git a/docs/usb-any-storage-20261104.md b/docs/usb-any-storage-20261104.md index 9f23d6c..1551d35 100644 --- a/docs/usb-any-storage-20261104.md +++ b/docs/usb-any-storage-20261104.md @@ -1,5 +1,11 @@ # 任意 U 盘透传实现(2026-11-04) +2026-11-05 补充:空的 usb-redir 端点移除已验证,但用户照片显示安全删除菜单还列出 +显卡、VirtIO 网卡/串口和 SATA 控制器。仅移除 USB 端点不能保证托盘图标消失。 +固定 PCI 设备属性修复准备与实际验收范围见 `fixed-pci-tray-20261105.md`。 +该修复随后已在用户授权正常关机再开机后应用:四项 PCI 设备能力从 6 变为 0, +设备状态、GPU、系统盘、网络和 Sunshine 正常;真实 U 盘插拔与托盘外观待现场确认。 + 在已验证的真实 USB 透传链路上,将盒子端策略从固定 VID/PID/序列号扩展为 “任意纯 USB Mass Storage 设备”。键盘、鼠标、Hub 和显示设备仍由盒子本地使用。 @@ -11,6 +17,9 @@ - 盒子检查分区是否本地挂载、是否有 holder 或作为 swap;发现本地占用时等待,不强制卸载。 - QEMU 使用 `0x08:-1:-1:-1:1` 过滤器,允许任意 USB Mass Storage,仍拒绝其他 USB 类别。 - PVE QMP watcher 在 VM/PVE 重启后重新建立运行时 `usb-redir` 设备。 +- 盒子同时提供仅回环的存储就绪状态端口。PVE watcher 只在状态为 `ready` 时创建 + `usb-redir`,连续 3 次确认状态为空后删除端点;因此物理 U 盘拔出后,Windows + 的安全删除硬件图标也会消失。插拔瞬间使用防抖,避免短暂枚举波动造成误移除。 ## 部署入口 diff --git a/terminal/kiosk-shell/CMakeLists.txt b/terminal/kiosk-shell/CMakeLists.txt index 4c974f8..555f238 100644 --- a/terminal/kiosk-shell/CMakeLists.txt +++ b/terminal/kiosk-shell/CMakeLists.txt @@ -40,6 +40,11 @@ target_link_libraries(kiosk-shell PRIVATE Qt5::Widgets ) +if(UNIX AND NOT APPLE) + find_package(X11 REQUIRED) + target_link_libraries(kiosk-shell PRIVATE X11::X11) +endif() + # 看门狗 Monitor:独立进程,监控主进程假死/崩溃 add_executable(vdi-kiosk-monitor src/monitor_main.cpp diff --git a/terminal/kiosk-shell/scripts/vdi-moonlight b/terminal/kiosk-shell/scripts/vdi-moonlight index 9867868..cf2d9de 100644 --- a/terminal/kiosk-shell/scripts/vdi-moonlight +++ b/terminal/kiosk-shell/scripts/vdi-moonlight @@ -183,6 +183,11 @@ build_cmd() { CMD="$CMD --video-codec $CODEC" CMD="$CMD --video-decoder $DECODER" CMD="$CMD --keep-awake" + # The kiosk must keep system shortcuts inside the streamed desktop. Without + # this, combinations such as Win+Shift+S are handled by the box OS and can + # expose its UI over the cloud desktop. Apply this to Windows, Ubuntu and + # Kylin sessions alike. + CMD="$CMD --capture-system-keys always" [ "$ABSOLUTE_MOUSE" = "1" ] && CMD="$CMD --absolute-mouse" [ "$AUDIO" = "1" ] && CMD="$CMD --audio-on-host" || CMD="$CMD --no-audio-on-host" # 按profile选择同步策略;默认保持当前稳定档。 diff --git a/terminal/kiosk-shell/scripts/vdi-moonlight-rkmpp b/terminal/kiosk-shell/scripts/vdi-moonlight-rkmpp index cabf02c..53bc75d 100644 --- a/terminal/kiosk-shell/scripts/vdi-moonlight-rkmpp +++ b/terminal/kiosk-shell/scripts/vdi-moonlight-rkmpp @@ -35,4 +35,7 @@ if [ "${1:-}" = stream ] && [ -z "${COLOR_RANGE_OVERRIDE+x}" ]; then ;; esac fi +if [ -f "$HOME/vdi-terminal/bin/vdi-native-cursor" ]; then + exec /usr/bin/python3 "$HOME/vdi-terminal/bin/vdi-native-cursor" "$client" "$@" +fi exec "$client" "$@" diff --git a/terminal/kiosk-shell/src/main.cpp b/terminal/kiosk-shell/src/main.cpp index b3fccb0..48c5011 100644 --- a/terminal/kiosk-shell/src/main.cpp +++ b/terminal/kiosk-shell/src/main.cpp @@ -16,7 +16,7 @@ int main(int argc, char** argv) { app.setOrganizationName(QStringLiteral("vdi")); auto* window = new vdi::MainWindow(); - window->show(); + window->showFullScreen(); return app.exec(); } diff --git a/terminal/kiosk-shell/src/main_window.cpp b/terminal/kiosk-shell/src/main_window.cpp index a549bd3..b868368 100644 --- a/terminal/kiosk-shell/src/main_window.cpp +++ b/terminal/kiosk-shell/src/main_window.cpp @@ -3,12 +3,25 @@ #include #include #include +#include +#include #include +#include #include #include +#include +#include #include #include #include +#include +#include + +#ifdef Q_OS_UNIX +#include +#undef KeyPress +#undef KeyRelease +#endif #include "desktop_list_page.h" #include "desktop_settings_dialog.h" @@ -46,13 +59,42 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { } MainWindow::~MainWindow() { + qApp->removeEventFilter(this); + setKioskInputGuard(false); +#ifdef Q_OS_UNIX + if (grabDisplay_) { + XCloseDisplay(static_cast(grabDisplay_)); + grabDisplay_ = nullptr; + } +#endif stopSessions(); + delete returnCover_; + returnCover_ = nullptr; } void MainWindow::setupUi() { setWindowTitle(QStringLiteral("VDI 云终端")); + // The login/list pages are the security boundary of the terminal. Keep a + // borderless, top-level surface over the desktop panels while the guest + // stream is not active. X11BypassWindowManagerHint also prevents MATE's + // panel from being raised above the shell after a global shortcut. + Qt::WindowFlags kioskFlags = Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint; +#ifdef Q_OS_UNIX + kioskFlags |= Qt::X11BypassWindowManagerHint; +#endif + setWindowFlags(kioskFlags); setWindowState(Qt::WindowFullScreen); setStyleSheet(QStringLiteral("QMainWindow { background: #0f1420; }")); + qApp->installEventFilter(this); + + Qt::WindowFlags coverFlags = Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint | Qt::Tool; +#ifdef Q_OS_UNIX + coverFlags |= Qt::X11BypassWindowManagerHint; +#endif + returnCover_ = new QWidget(nullptr, coverFlags); + returnCover_->setAttribute(Qt::WA_ShowWithoutActivating); + returnCover_->setStyleSheet(QStringLiteral("QWidget { background: #0f1420; }")); + returnCover_->hide(); stack_ = new QStackedWidget(this); stack_->setStyleSheet(QStringLiteral("background: #0f1420;")); @@ -120,12 +162,18 @@ void MainWindow::setupHotkey() { connect(quitShortcut, &QShortcut::activated, this, [this]() { if (fsm_.isIn(State::kConnecting) || fsm_.isIn(State::kSession) || fsm_.isIn(State::kReconnect)) { - stopSessions(); + // Put the opaque kiosk surface back on top before asking + // Moonlight to terminate. This prevents one compositor frame of + // the box desktop from showing while the stream window closes. + showReturnCover(); fsm_.transitionTo(State::kDesktopList, QStringLiteral("已退出串流")); + presentKioskSurface(); + stopSessions(); if (desktopListPage_) { desktopListPage_->setBusy(false); desktopListPage_->setStatus(QStringLiteral("已退出串流")); } + releaseReturnCoverWhenReady(); } }); } @@ -148,6 +196,21 @@ void MainWindow::onStateChanged(State from, State to, const QString& detail) { const QString stateName = QString::fromLatin1(stateToString(to)); updateStatusBar(detail.isEmpty() ? stateName : QStringLiteral("%1 %2").arg(stateName, detail)); + // Only the Moonlight window may receive system shortcuts. Every other + // state keeps the kiosk keyboard grab, including the connecting overlay. + const bool streamSurface = to == State::kSession || to == State::kReconnect; + if (streamSurface) { + if (isVisible()) { + hide(); + } + setKioskInputGuard(false); + } else { + setKioskInputGuard(true); + if (to != State::kBoot) { + presentKioskSurface(); + } + } + switch (to) { case State::kConfig: showLogin(); @@ -177,6 +240,182 @@ void MainWindow::onStateChanged(State from, State to, const QString& detail) { } } +void MainWindow::showEvent(QShowEvent* event) { + QMainWindow::showEvent(event); + applyKioskSurface(); + if (kioskInputGuard_) { + retryKeyboardGrab(); + } +} + +void MainWindow::applyKioskSurface() { + if (!isVisible()) { + return; + } + raise(); + activateWindow(); + if (QScreen* screen = windowHandle() ? windowHandle()->screen() : QApplication::primaryScreen()) { + // An override-redirect window does not receive the WM fullscreen + // negotiation; explicitly cover the complete output geometry. + setGeometry(screen->geometry()); + } +} + +void MainWindow::presentKioskSurface() { + if (!isVisible()) { + showFullScreen(); + } + // Let Qt map and paint the opaque surface before changing the X11 stack. + // The direct X11 calls are needed because Moonlight uses an override/full- + // screen window and a WM-only raise can otherwise be deferred one frame. + applyKioskSurface(); + QApplication::processEvents(QEventLoop::ExcludeUserInputEvents); +#ifdef Q_OS_UNIX + if (!grabDisplay_) { + grabDisplay_ = XOpenDisplay(nullptr); + } + if (grabDisplay_) { + auto* display = static_cast(grabDisplay_); + const Window window = static_cast(winId()); + XMapRaised(display, window); + XRaiseWindow(display, window); + XSetInputFocus(display, window, RevertToParent, CurrentTime); + XSync(display, False); + } +#endif + applyKioskSurface(); +} + +void MainWindow::showReturnCover() { + if (!returnCover_) { + return; + } + QScreen* screen = QApplication::primaryScreen(); + if (screen) { + returnCover_->setGeometry(screen->geometry()); + } + returnCover_->show(); + returnCover_->raise(); + QApplication::processEvents(QEventLoop::ExcludeUserInputEvents); +#ifdef Q_OS_UNIX + if (!grabDisplay_) { + grabDisplay_ = XOpenDisplay(nullptr); + } + if (grabDisplay_) { + auto* display = static_cast(grabDisplay_); + const Window window = static_cast(returnCover_->winId()); + XMapRaised(display, window); + XRaiseWindow(display, window); + XSync(display, False); + } +#endif +} + +void MainWindow::hideReturnCover() { + if (!returnCover_) { + return; + } + returnCover_->hide(); + QApplication::processEvents(QEventLoop::ExcludeUserInputEvents); +} + +void MainWindow::releaseReturnCoverWhenReady() { + if (!returnCover_ || !returnCover_->isVisible()) { + return; + } + + bool ready = isVisible(); +#ifdef Q_OS_UNIX + if (ready && grabDisplay_) { + XWindowAttributes attributes{}; + ready = XGetWindowAttributes(static_cast(grabDisplay_), + static_cast(winId()), &attributes) != 0 && + attributes.map_state == IsViewable; + } +#endif + if (ready) { + presentKioskSurface(); + hideReturnCover(); + return; + } + + // The X server may need a few event-loop turns to map the Qt surface + // after Moonlight relinquishes its fullscreen window. Keep the opaque + // cover in place until that mapping is observable; never expose the root + // desktop during this interval. + QTimer::singleShot(20, this, [this]() { releaseReturnCoverWhenReady(); }); +} + +void MainWindow::setKioskInputGuard(bool enabled) { + kioskInputGuard_ = enabled; + if (!enabled) { +#ifdef Q_OS_UNIX + if (keyboardGrabActive_ && grabDisplay_) { + auto* display = static_cast(grabDisplay_); + XUngrabKeyboard(display, CurrentTime); + XFlush(display); + } +#endif + keyboardGrabActive_ = false; + return; + } + if (isVisible()) { + retryKeyboardGrab(); + } +} + +void MainWindow::retryKeyboardGrab() { + if (!kioskInputGuard_ || !isVisible() || keyboardGrabActive_) { + return; + } +#ifdef Q_OS_UNIX + if (!grabDisplay_) { + grabDisplay_ = XOpenDisplay(nullptr); + } + if (grabDisplay_) { + auto* display = static_cast(grabDisplay_); + const int result = XGrabKeyboard(display, static_cast(winId()), False, + GrabModeAsync, GrabModeAsync, CurrentTime); + if (result == GrabSuccess) { + XSetInputFocus(display, static_cast(winId()), RevertToParent, CurrentTime); + } + XFlush(display); + keyboardGrabActive_ = result == GrabSuccess; + } +#else + keyboardGrabActive_ = true; +#endif + if (!keyboardGrabActive_ && !keyboardGrabRetryPending_) { + keyboardGrabRetryPending_ = true; + QTimer::singleShot(200, this, [this]() { + keyboardGrabRetryPending_ = false; + retryKeyboardGrab(); + }); + } +} + +bool MainWindow::eventFilter(QObject* watched, QEvent* event) { + Q_UNUSED(watched); + if (kioskInputGuard_ && + (event->type() == QEvent::KeyPress || event->type() == QEvent::KeyRelease || + event->type() == QEvent::ShortcutOverride)) { + auto* keyEvent = static_cast(event); + const Qt::KeyboardModifiers modifiers = keyEvent->modifiers(); + const int key = keyEvent->key(); + const bool metaShortcut = modifiers.testFlag(Qt::MetaModifier) || key == Qt::Key_Meta; + const bool altWindowShortcut = modifiers.testFlag(Qt::AltModifier) && + (key == Qt::Key_Tab || key == Qt::Key_F4); + const bool vtShortcut = modifiers.testFlag(Qt::ControlModifier) && + modifiers.testFlag(Qt::AltModifier) && + (key == Qt::Key_Delete || (key >= Qt::Key_F1 && key <= Qt::Key_F12)); + const bool screenshotKey = key == Qt::Key_Print; + if (metaShortcut || altWindowShortcut || vtShortcut || screenshotKey) { + return true; + } + } + return QMainWindow::eventFilter(watched, event); +} + void MainWindow::updateStatusBar(const QString& text) { statusBar_->showMessage(text); } @@ -443,10 +682,12 @@ void MainWindow::onMenuAction(int action) { } break; case 2: - stopSessions(); + showReturnCover(); if (fsm_.current() != State::kConfig) { fsm_.transitionTo(State::kDesktopList, QStringLiteral("已断开")); } + stopSessions(); + releaseReturnCoverWhenReady(); break; case 3: onQuitRequested(); diff --git a/terminal/kiosk-shell/src/main_window.h b/terminal/kiosk-shell/src/main_window.h index 8eba3a7..7b826ba 100644 --- a/terminal/kiosk-shell/src/main_window.h +++ b/terminal/kiosk-shell/src/main_window.h @@ -19,6 +19,9 @@ class QStackedWidget; class QStatusBar; class QTimer; +class QEvent; +class QShowEvent; +class QWidget; namespace vdi { @@ -33,6 +36,10 @@ public: explicit MainWindow(QWidget* parent = nullptr); ~MainWindow() override; +protected: + void showEvent(QShowEvent* event) override; + bool eventFilter(QObject* watched, QEvent* event) override; + private slots: void onLoginRequested(const QString& username, const QString& password); void onDesktopSelected(const QString& desktopId); @@ -67,6 +74,13 @@ private: void showLogin(); void showDesktopList(); void updateStatusBar(const QString& text); + void setKioskInputGuard(bool enabled); + void applyKioskSurface(); + void presentKioskSurface(); + void showReturnCover(); + void hideReturnCover(); + void releaseReturnCoverWhenReady(); + void retryKeyboardGrab(); StateMachine fsm_; DesktopService desktopService_; @@ -88,6 +102,13 @@ private: quint64 connectionAttempt_ = 0; ConnectionPhase connectionPhase_ = ConnectionPhase::kIdle; TerminalConfig config_; + QWidget* returnCover_ = nullptr; + bool kioskInputGuard_ = true; + bool keyboardGrabActive_ = false; + bool keyboardGrabRetryPending_ = false; +#ifdef Q_OS_UNIX + void* grabDisplay_ = nullptr; +#endif }; } // namespace vdi diff --git a/terminal/kiosk-shell/src/moonlight_session.cpp b/terminal/kiosk-shell/src/moonlight_session.cpp index 791bc41..6860014 100644 --- a/terminal/kiosk-shell/src/moonlight_session.cpp +++ b/terminal/kiosk-shell/src/moonlight_session.cpp @@ -157,6 +157,11 @@ QStringList MoonlightSession::buildStreamArguments(const ConnectionTicket& ticke << QStringLiteral("--video-codec") << options.videoCodec << QStringLiteral("--video-decoder") << options.decoder << QStringLiteral("--absolute-mouse") + // The terminal is a kiosk: system shortcuts must be delivered to the + // streamed desktop instead of being handled by the box OS. This is + // required for Win+Shift+S, Alt+Tab, Super shortcuts, etc. on every + // supported desktop guest (Windows, Ubuntu and Kylin). + << QStringLiteral("--capture-system-keys") << QStringLiteral("always") << QStringLiteral("--keep-awake") << (options.vsync ? QStringLiteral("--vsync") : QStringLiteral("--no-vsync")) << (options.framePacing ? QStringLiteral("--frame-pacing") diff --git a/terminal/kiosk-shell/tests/test_moonlight_session.cpp b/terminal/kiosk-shell/tests/test_moonlight_session.cpp index d387f13..d2dca66 100644 --- a/terminal/kiosk-shell/tests/test_moonlight_session.cpp +++ b/terminal/kiosk-shell/tests/test_moonlight_session.cpp @@ -134,6 +134,9 @@ void MoonlightSessionTest::buildsConsistentStreamArguments() { QVERIFY(args.contains(QStringLiteral("--no-frame-pacing"))); QVERIFY(args.contains(QStringLiteral("--performance-overlay"))); QVERIFY(args.contains(QStringLiteral("--absolute-mouse"))); + const int captureOption = args.indexOf(QStringLiteral("--capture-system-keys")); + QVERIFY(captureOption >= 0); + QCOMPARE(args.value(captureOption + 1), QStringLiteral("always")); } void MoonlightSessionTest::selectsProfileWithoutLosingStats() { diff --git a/terminal/moonlight/README.md b/terminal/moonlight/README.md index 54805e0..d8fbad8 100644 --- a/terminal/moonlight/README.md +++ b/terminal/moonlight/README.md @@ -9,12 +9,14 @@ - `0001-vdi-input-and-latest-frame.patch` 在 Moonlight Qt 根目录应用。鼠标移动只合并队首连续事件,遇到按钮、键盘、窗口、呈现事件或其他鼠标设备立即停止;每次最多额外合并 64 个事件。绝对模式在发送按键前使用该按键事件的坐标。低延迟队列可选地把已等待的解码帧替换成新帧。 - `0002-vdi-absolute-click-barrier.patch` 在 `moonlight-common-c/moonlight-common-c` 中应用。鼠标按钮封住此前的位置批次,后续移动只能进入新批次;发送线程只锁定自己的批次,避免旧包出队时误清除新批次。所有包依旧走原可靠鼠标通道,保留 1ms 合并限流和原坐标边界修正。 - `0003-vdi-input-priority.patch` 在 Qt 根目录应用。可选地让帧就绪后的连续输入先执行,最多 32 条或 2ms,不能跨窗口/焦点/其他事件。`VDI_INPUT_PRIORITY=1` 才启用,默认关闭。另提供 `VDI_INPUT_TIMING=1` 的汇总统计,区分 SDL 输入派发年龄与主线程呈现调用耗时,不代表端到端延迟。 +- `0004-vdi-power-key-policy.patch` 在 Moonlight Qt 根目录应用。客户端丢弃电源/睡眠键,以及 `Ctrl+Alt+Delete`、`Alt+F4`、`Win+L`、`Win+X`,防止云桌面被关机、重启、锁定或打开包含电源操作的系统菜单;按键不会发送到 Windows、Ubuntu 或麒麟客体系统。 +- `0005-vdi-native-linux-cursor.patch` 在 Qt 根目录应用,为已配置的单座席Linux/X11/2K/绝对鼠标会话创建原生SDL光标,形状/热点/显隐经认证SSH同步,位置不等待视频帧;主机以精确SHA固定的捕获适配器在新连接前去除视频光标。状态过期等故障结束整次会话,恢复原服务后重连。Windows无对应profile,继续原逻辑。安装和限制见[模块说明](../native-cursor/README.md)。 -2026-11-03 的现场 RKMPP/EGL 使用独立 PacerRender 线程,诊断会话主线程 render 调用数为 0;因此第三补丁的新调度没有已证明的现场收益,未启用。诊断候选二进制保存为 `moonlight.input-priority-20261103`;默认客户端已恢复到本轮前 SHA256 `35de7b1a4ade583823b23b5f2e012307f6d0a3848c8eb97bd6d4de9660afa00f`。详细计划/初测限制见 `docs/mouse-optimization-plan-20261103.md`,独立光标接入设计见 `docs/independent-cursor-design-20261103.md`。 +2026-11-03 的现场 RKMPP/EGL 使用独立 PacerRender 线程,诊断会话主线程 render 调用数为 0;因此第三补丁的新调度没有已证明的现场收益,未启用。当时默认客户端恢复至 SHA256 `35de7b1a4ade583823b23b5f2e012307f6d0a3848c8eb97bd6d4de9660afa00f`,该版本现保留作备份。2026-11-05加入0005后现场SHA为 `5d4330f703d7f8e053bc3795ca42bdaf231c9e9dd8ebb00a612013adbcccee03`,Linux profile在获取远端租约前严格校验此SHA;独立光标回归见[实施记录](../../docs/linux-native-cursor-20261105.md)。现场构建保留已有修改并加入0005,尚未重建部署其他任务的0004;构建器0001~0005清洁应用通过与现场补丁集合应分开判断。 `VDI_LATEST_FRAME=1` 只在没有 VSync pacing source 的呈现队列生效。最多保留一个待呈现解码帧,丢弃数计入原 `pacerDroppedFrames`。不丢压缩包,不改变 H.264 参考链,也不提前释放正在呈现或延迟释放的上一帧。未设置或设为 `0` 时保留上游帧队列策略;kiosk 私有 wrapper 默认设为 `1`。smooth 档仍使用原 pacing 行为。 -这些补丁不增加本地光标,不隐藏 Windows 光标,不改变控制协议。远程光标仍随视频显示,其延迟仍受服务器捕获/编码、网络、解码和显示影响。 +0001~0004不增加本地光标。0005仅在已配置Linux主机启用原生独立光标,不改变Windows光标或Moonlight原可靠输入通道。Windows远程光标仍随视频显示,其延迟受服务器捕获/编码、网络、解码和显示影响。 ## 构建与测试 @@ -41,4 +43,4 @@ VDI_TEST_SANITIZE=1 ASAN_OPTIONS=detect_leaks=1 bash tools/test-moonlight-latenc GPU修复后实测见`docs/2k60-validation-20261103.md`:当前正常二进制保持不变,VDD60Hz/串流45FPS已配置并从kiosk重连;约43~44FPS渲染。2K60请求受当前盒子呈现路径限制,不能宣称稳定60。`experiments/0004-vdi-egl-exact-size-sampling.patch`为未采用的精确尺寸采样实验,未进入正常构建器;默认仍为原GL_LINEAR取样。该实验候选二进制也未被正常wrapper使用。 -独立光标已完成受控X11辅助程序原型,结果见`docs/independent-cursor-feasibility-20261103.md`。尚无自动服务端形状同步和可靠帧交接,未加入正常构建/启动流程;不能把Ctrl+Alt+Shift+C显示本地箭头和Sunshine全局N切换直接当生产方案。现有客户端保持单个Windows远程白色光标。 +2026-11-03的Windows受控X11辅助程序原型见`docs/independent-cursor-feasibility-20261103.md`;该Windows原型没有自动形状同步或可靠帧交接,未进入正常启动流程。2026-11-05的Linux方案由0005和`terminal/native-cursor/`实现自动形状同步,通过连接前/整次会话后交接避开运行中帧切换;完整失焦和广泛缩放等场景尚未验收。不能把固定本地箭头和Sunshine全局N切换当作该方案。Windows保持单个远程白色光标。 diff --git a/terminal/moonlight/patches/0005-vdi-native-linux-cursor.patch b/terminal/moonlight/patches/0005-vdi-native-linux-cursor.patch new file mode 100644 index 0000000..b7fd997 --- /dev/null +++ b/terminal/moonlight/patches/0005-vdi-native-linux-cursor.patch @@ -0,0 +1,237 @@ +--- a/app/streaming/session.cpp ++++ b/app/streaming/session.cpp +@@ -1,3 +1,4 @@ ++#include "vdi_native_cursor.h" + #include "session.h" + #include "settings/streamingpreferences.h" + #include "streaming/streamutils.h" +@@ -1968,8 +1969,10 @@ + VdiInputTiming vdiInputTiming(qEnvironmentVariableIntValue("VDI_INPUT_TIMING") == 1); + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "VDI input priority: %s", + qEnvironmentVariableIntValue("VDI_INPUT_PRIORITY") == 1 ? "enabled" : "disabled"); ++ VdiNativeCursor vdiNativeCursor(m_Window, m_InputHandler); + SDL_Event event; + for (;;) { ++ if (!vdiNativeCursor.tick()) goto DispatchDeferredCleanup; + if (!vdiInputPriority.takeDeferred(event, SDL_GetWindowID(m_Window))) { + #if SDL_VERSION_ATLEAST(2, 0, 18) && !defined(STEAM_LINK) + // SDL 2.0.18 has a proper wait event implementation that uses platform +@@ -1981,7 +1984,7 @@ + // NB: This behavior was introduced in SDL 2.0.16, but had a few critical + // issues that could cause indefinite timeouts, delayed joystick detection, + // and other problems. +- if (!SDL_WaitEventTimeout(&event, 1000)) { ++ if (!SDL_WaitEventTimeout(&event, vdiNativeCursor.enabled() ? 4 : 1000)) { + presence.runCallbacks(); + continue; + } +@@ -2006,6 +2009,7 @@ + continue; + } + } ++ vdiNativeCursor.observe(event); + vdiInputTiming.observe(event, SDL_GetWindowID(m_Window)); + switch (event.type) { + case SDL_QUIT: +@@ -2322,6 +2326,7 @@ + } + + DispatchDeferredCleanup: ++ vdiNativeCursor.shutdown(); + vdiInputTiming.report(vdiInputPriority.prioritized()); + // Switch back to synchronous logging mode + StreamUtils::exitAsyncLoggingMode(); +--- a/app/streaming/input/input.h ++++ b/app/streaming/input/input.h +@@ -89,6 +89,12 @@ + ~SdlInputHandler(); + + void setWindow(SDL_Window* window); ++ ++ bool isNativeCursorAbsoluteMode() const { return m_AbsoluteMouseMode; } ++ void enableNativeCursorVisibility() { ++ m_MouseCursorCapturedVisibilityState = SDL_ENABLE; ++ SDL_ShowCursor(SDL_ENABLE); ++ } + + void handleKeyEvent(SDL_KeyboardEvent* event); + +--- a/app/streaming/input/keyboard.cpp ++++ b/app/streaming/input/keyboard.cpp +@@ -68,6 +68,7 @@ + break; + + case KeyComboToggleMouseMode: ++ if (SDL_getenv("VDI_NATIVE_CURSOR_STATE")) break; + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, + "Detected mouse mode toggle combo"); + +@@ -82,6 +83,7 @@ + break; + + case KeyComboToggleCursorHide: ++ if (SDL_getenv("VDI_NATIVE_CURSOR_STATE")) break; + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, + "Detected show mouse combo"); + +--- /dev/null ++++ b/app/streaming/vdi_native_cursor.h +@@ -0,0 +1,158 @@ ++#pragma once ++ ++#include ++#include ++#include ++#include ++#include ++#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(packet.constData()); ++ auto u32 = [p](int offset) { return qFromLittleEndian(p + offset); }; ++ auto u64 = [p](int offset) { return qFromLittleEndian(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(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(m_InputSequence, reinterpret_cast(message.data())); ++ qToLittleEndian(qBound(0, (mx-dst.x)*sw/dst.w, sw-1), ++ reinterpret_cast(message.data()+8)); ++ qToLittleEndian(qBound(0, (my-dst.y)*sh/dst.h, sh-1), ++ reinterpret_cast(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; ++}; diff --git a/terminal/native-cursor/README.md b/terminal/native-cursor/README.md new file mode 100644 index 0000000..a7088d7 --- /dev/null +++ b/terminal/native-cursor/README.md @@ -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/.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。将对应 `.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`。 diff --git a/terminal/native-cursor/capture-no-cursor.c b/terminal/native-cursor/capture-no-cursor.c new file mode 100644 index 0000000..07d1a2a --- /dev/null +++ b/terminal/native-cursor/capture-no-cursor.c @@ -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 +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; +} diff --git a/terminal/native-cursor/client.py b/terminal/native-cursor/client.py new file mode 100644 index 0000000..3e7831d --- /dev/null +++ b/terminal/native-cursor/client.py @@ -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('= .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(' 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:]) diff --git a/terminal/native-cursor/host.py b/terminal/native-cursor/host.py new file mode 100644 index 0000000..4ab2e26 --- /dev/null +++ b/terminal/native-cursor/host.py @@ -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() diff --git a/terminal/native-cursor/kms.py b/terminal/native-cursor/kms.py new file mode 100644 index 0000000..65fbfd1 --- /dev/null +++ b/terminal/native-cursor/kms.py @@ -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) diff --git a/terminal/native-cursor/ubuntu-kms.example.json b/terminal/native-cursor/ubuntu-kms.example.json new file mode 100644 index 0000000..f163f03 --- /dev/null +++ b/terminal/native-cursor/ubuntu-kms.example.json @@ -0,0 +1 @@ +{"backend":"kms-virtio-no-cursor-plane","card":"/dev/dri/card1","size":[2560,1440]} diff --git a/terminal/native-cursor/vdi_native_cursor.h b/terminal/native-cursor/vdi_native_cursor.h new file mode 100644 index 0000000..df44b01 --- /dev/null +++ b/terminal/native-cursor/vdi_native_cursor.h @@ -0,0 +1,158 @@ +#pragma once + +#include +#include +#include +#include +#include +#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(packet.constData()); + auto u32 = [p](int offset) { return qFromLittleEndian(p + offset); }; + auto u64 = [p](int offset) { return qFromLittleEndian(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(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(m_InputSequence, reinterpret_cast(message.data())); + qToLittleEndian(qBound(0, (mx-dst.x)*sw/dst.w, sw-1), + reinterpret_cast(message.data()+8)); + qToLittleEndian(qBound(0, (my-dst.y)*sh/dst.h, sh-1), + reinterpret_cast(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; +}; diff --git a/terminal/usb-monitor/README.md b/terminal/usb-monitor/README.md index 7d93f28..bad5806 100644 --- a/terminal/usb-monitor/README.md +++ b/terminal/usb-monitor/README.md @@ -18,10 +18,12 @@ cargo test --offline cargo build --release --offline target/release/vdi-usb-monitor list sudo target/release/vdi-usb-monitor serve \ - --any-storage --listen 127.0.0.1:40001 + --any-storage --listen 127.0.0.1:40001 --status 127.0.0.1:40002 ``` `list` 输出 JSON。`serve --any-storage` 只接受纯 USB Mass Storage 类设备, +`--status` 提供仅监听回环地址的 ready/empty 状态给 PVE watcher;没有可导出的存储设备时, +PVE 会移除 Windows 中的虚拟 USB 端点,避免托盘残留空设备图标。 只能监听 IPv4 回环地址;拒绝键鼠、Hub、包含非存储接口的复合设备以及本地尚在使用 的磁盘。仍可使用 `--vendor/--product/--serial` 保留旧的精确设备策略。 本地已挂载时不会强制卸载,应先关闭本地文件操作并正常卸载。 diff --git a/terminal/usb-monitor/etc/vdi-usb-monitor.service b/terminal/usb-monitor/etc/vdi-usb-monitor.service index 8fe1198..b19e668 100644 --- a/terminal/usb-monitor/etc/vdi-usb-monitor.service +++ b/terminal/usb-monitor/etc/vdi-usb-monitor.service @@ -5,7 +5,7 @@ After=network.target systemd-udevd.service [Service] Type=simple EnvironmentFile=/etc/vdi-usb-monitor/device.env -ExecStart=/usr/local/libexec/vdi-usb-monitor serve --any-storage --listen 127.0.0.1:40001 +ExecStart=/usr/local/libexec/vdi-usb-monitor serve --any-storage --listen 127.0.0.1:40001 --status 127.0.0.1:40002 Restart=on-failure RestartSec=3 KillMode=control-group diff --git a/terminal/usb-monitor/src/main.rs b/terminal/usb-monitor/src/main.rs index ff22e9c..2d7d7f7 100644 --- a/terminal/usb-monitor/src/main.rs +++ b/terminal/usb-monitor/src/main.rs @@ -1,7 +1,8 @@ //! Device-level USB redirection. Never calls the hypervisor API or exports HID devices. -use std::{collections::BTreeSet, env, fs, io::{BufRead, BufReader}, net::SocketAddr, +use std::{collections::BTreeSet, env, fs, io::{BufRead, BufReader, Write}, net::{SocketAddr, TcpListener}, path::{Path, PathBuf}, process::{self, Child, Command, Stdio}, sync::mpsc::{self, Receiver}, thread, time::{Duration, Instant, SystemTime, UNIX_EPOCH}}; +use std::sync::{Arc, atomic::{AtomicBool, Ordering}}; // udev delivers completed USB changes; the timeout also covers startup and a // stopped event monitor. The monitor never runs a command supplied by a device. @@ -167,6 +168,22 @@ enum MatchPolicy { #[derive(Debug)] struct Policy { matcher: MatchPolicy, listen: SocketAddr } +// This loopback-only status endpoint lets the PVE watcher know whether a +// physical storage device is ready before creating the guest usb-redir device. +// The USB data channel remains separate and is never exposed on the LAN. +fn start_status_server(addr: SocketAddr, ready: Arc) -> Result<(), String> { + let listener = TcpListener::bind(addr).map_err(|e| format!("status listener: {e}"))?; + thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { continue }; + let state = if ready.load(Ordering::Acquire) { "ready\n" } else { "empty\n" }; + let _ = stream.write_all(state.as_bytes()); + let _ = stream.shutdown(std::net::Shutdown::Both); + } + }); + Ok(()) +} + fn parse_policy(args: &[String]) -> Result { let mut any_storage = false; let mut vendor = None; @@ -262,14 +279,37 @@ fn run() -> Result<(), String> { return Ok(()); } if args.first().map(String::as_str) != Some("serve") { - return Err("usage: vdi-usb-monitor list | serve (--any-storage | --vendor XXXX --product XXXX --serial SERIAL) --listen 127.0.0.1:PORT".into()); + return Err("usage: vdi-usb-monitor list | serve (--any-storage | --vendor XXXX --product XXXX --serial SERIAL) --listen 127.0.0.1:PORT [--status 127.0.0.1:PORT]".into()); } - let policy = parse_policy(&args[1..])?; + let mut policy_args = Vec::new(); + let mut status_addr = None; + let mut i = 1; + while i < args.len() { + if args[i] == "--status" { + if i + 1 >= args.len() { return Err("--status requires an address".into()); } + if status_addr.is_some() { return Err("duplicate --status".into()); } + let addr: SocketAddr = args[i + 1].parse().map_err(|_| "invalid status address")?; + if !addr.ip().is_loopback() || !addr.is_ipv4() || addr.port() == 0 { + return Err("status must be an IPv4 loopback listener with a nonzero port".into()); + } + status_addr = Some(addr); + i += 2; + } else { + policy_args.push(args[i].clone()); + i += 1; + } + } + let policy = parse_policy(&policy_args)?; let redirect_binary = env::var("VDI_USBREDIRECT_BINARY").unwrap_or_else(|_| "/usr/bin/usbredirect".into()); if !Path::new(&redirect_binary).is_absolute() || !Path::new(&redirect_binary).is_file() { return Err("VDI_USBREDIRECT_BINARY must name an installed absolute executable path".into()); } log_event(&format!("USB exporter: {redirect_binary}")); + let ready = Arc::new(AtomicBool::new(false)); + if let Some(addr) = status_addr { + start_status_server(addr, ready.clone())?; + log_event(&format!("USB status listener: {addr}")); + } let mut events = UsbEvents::start(); let mut last = String::new(); loop { @@ -286,12 +326,15 @@ fn run() -> Result<(), String> { let mut child = Command::new(&redirect_binary) .args(["--device", &selector, "--as", &policy.listen.to_string(), "--keepalive"]) .spawn().map_err(|e| e.to_string())?; + ready.store(true, Ordering::Release); loop { if let Some(status) = child.try_wait().map_err(|e| e.to_string())? { + ready.store(false, Ordering::Release); return Err(format!("export session ended: {status}; waiting before retry")); } events.wait(Duration::from_secs(1)); if !still_present(device) { + ready.store(false, Ordering::Release); let _ = child.kill(); let _ = child.wait(); log_event(&format!("storage {} disconnected", device.bus_id)); @@ -299,6 +342,7 @@ fn run() -> Result<(), String> { } } }); + ready.store(false, Ordering::Release); if let Err(message) = result { if message != last { log_event(&message); last = message; } } diff --git a/tools/build-rkmpp-client.sh b/tools/build-rkmpp-client.sh index f53f39e..e6e2779 100644 --- a/tools/build-rkmpp-client.sh +++ b/tools/build-rkmpp-client.sh @@ -28,16 +28,20 @@ PATCH_DIR="${VDI_MOONLIGHT_PATCH_DIR:-$(cd "$(dirname "$0")/../terminal/moonligh apply_vdi_patch() { local source="$1" patch="$2" test -r "$patch" || { echo "Missing VDI patch: $patch" >&2; exit 1; } - if git -C "$source" apply --reverse --check "$patch" 2>/dev/null; then + # A Windows checkout can give patch files CRLF. Normalize patch transport + # only; source drift still has to pass the ordinary exact-context check. + if sed 's/\r$//' "$patch" | git -C "$source" apply --reverse --check - 2>/dev/null; then return fi - git -C "$source" apply --check "$patch" - git -C "$source" apply "$patch" + sed 's/\r$//' "$patch" | git -C "$source" apply --check - + sed 's/\r$//' "$patch" | git -C "$source" apply - } apply_vdi_patch "$TASK_ROOT/src/moonlight" "$PATCH_DIR/0001-vdi-input-and-latest-frame.patch" apply_vdi_patch "$TASK_ROOT/src/moonlight/moonlight-common-c/moonlight-common-c" \ "$PATCH_DIR/0002-vdi-absolute-click-barrier.patch" apply_vdi_patch "$TASK_ROOT/src/moonlight" "$PATCH_DIR/0003-vdi-input-priority.patch" +apply_vdi_patch "$TASK_ROOT/src/moonlight" "$PATCH_DIR/0004-vdi-power-key-policy.patch" +apply_vdi_patch "$TASK_ROOT/src/moonlight" "$PATCH_DIR/0005-vdi-native-linux-cursor.patch" # Moonlight 6.2.0 uses std::optional in drm.h but relies on a transitive # include from Qt 6. Our Qt 5 build needs the standard header explicitly. diff --git a/tools/deploy-usb-device-poc.py b/tools/deploy-usb-device-poc.py index d0fd41e..eea5828 100644 --- a/tools/deploy-usb-device-poc.py +++ b/tools/deploy-usb-device-poc.py @@ -86,16 +86,18 @@ if line not in text.splitlines():p.write_text(text.rstrip()+'\\n'+line+'\\n') p.chmod(0o600) ''' auth_line = (f'from="{args.pve}",restrict,port-forwarding,permitopen="127.0.0.1:40001",' + f'permitopen="127.0.0.1:40002",' f'command="/bin/false" {pubkey}') box_run(args.box, f"python3 - {shlex.quote(backup)} {shlex.quote(auth_line)}", auth_script) stage = box_run(args.box, "mktemp -d /tmp/vdi-usb-deploy.XXXXXX").strip() service_text = (repo / "terminal/usb-monitor/etc/vdi-usb-monitor.service").read_text() if args.any_storage: service_exec = ("ExecStart=/usr/local/libexec/vdi-usb-monitor serve " - "--any-storage --listen 127.0.0.1:40001") + "--any-storage --listen 127.0.0.1:40001 --status 127.0.0.1:40002") else: service_exec = (f"ExecStart=/usr/local/libexec/vdi-usb-monitor serve --vendor {args.vendor} " - f"--product {args.product} --serial {args.serial} --listen 127.0.0.1:40001") + f"--product {args.product} --serial {args.serial} --listen 127.0.0.1:40001 " + "--status 127.0.0.1:40002") service_text = re.sub(r"^ExecStart=.*$", service_exec, service_text, flags=re.MULTILINE) service_fd, service_path = tempfile.mkstemp(prefix="vdi-usb-monitor-", suffix=".service") os.close(service_fd) @@ -149,7 +151,7 @@ After=network-online.target Wants=network-online.target [Service] Type=simple -ExecStart=/usr/bin/ssh -NT -i {host_dir}/id_ed25519 -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile={host_dir}/known_hosts -o ExitOnForwardFailure=yes -o ServerAliveInterval=10 -o ServerAliveCountMax=3 -L 127.0.0.1:40001:127.0.0.1:40001 {args.box} +ExecStart=/usr/bin/ssh -NT -i {host_dir}/id_ed25519 -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile={host_dir}/known_hosts -o ExitOnForwardFailure=yes -o ServerAliveInterval=10 -o ServerAliveCountMax=3 -L 127.0.0.1:40001:127.0.0.1:40001 -L 127.0.0.1:40002:127.0.0.1:40002 {args.box} Restart=on-failure RestartSec=3 NoNewPrivileges=yes @@ -165,7 +167,7 @@ After=network-online.target vdi-usb-tunnel-{args.vm}.service Wants=network-online.target [Service] Type=simple -ExecStart=/usr/local/libexec/vdi-usb-device-qmp.py watch --vm {args.vm} --port 40001 --bus ehci-2.0 {qmp_args} +ExecStart=/usr/local/libexec/vdi-usb-device-qmp.py watch --vm {args.vm} --port 40001 --status-port 40002 --interval 1 --remove-after 3 --bus ehci-2.0 {qmp_args} Restart=always RestartSec=3 NoNewPrivileges=yes diff --git a/tools/deploy_kiosk.sh b/tools/deploy_kiosk.sh index c5b0ed2..dc7e903 100644 --- a/tools/deploy_kiosk.sh +++ b/tools/deploy_kiosk.sh @@ -23,6 +23,8 @@ scp -q "$SRC/tests/test_display_guard.py" "$BOX":~/vdi-build/repo/kiosk-shell/te scp -q "$SRC/scripts/vdi-display-guard" "$BOX":~/vdi-build/repo/kiosk-shell/scripts/ scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/scripts/vdi-moonlight" "$SRC/scripts/vdi-kiosk-start" "$BOX":~/vdi-terminal/bin/ scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/scripts/vdi-moonlight-rkmpp" "$BOX":~/vdi-terminal/bin/ +scp -q "$REPO/terminal/native-cursor/client.py" "$BOX":~/vdi-terminal/bin/vdi-native-cursor +ssh "$BOX" 'sed -i "s/\r$//" ~/vdi-terminal/bin/vdi-native-cursor' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'sed -i "s/\r$//" ~/vdi-terminal/bin/vdi-moonlight-rkmpp; chmod +x ~/vdi-terminal/bin/vdi-moonlight-rkmpp' scp -q "$SRC/etc/70-vdi-rockchip-video.rules" "$BOX":/tmp/70-vdi-rockchip-video.rules ssh "$BOX" 'sed -i "s/\r$//" /tmp/70-vdi-rockchip-video.rules; sudo -n install -m 0644 /tmp/70-vdi-rockchip-video.rules /etc/udev/rules.d/70-vdi-rockchip-video.rules && sudo -n udevadm control --reload-rules && for device in /dev/mpp_service /dev/rga /dev/dma_heap/*; do test ! -e "$device" || { sudo -n chgrp video "$device" && sudo -n chmod 0660 "$device"; } || exit 1; done' diff --git a/tools/tests/test_native_cursor.py b/tools/tests/test_native_cursor.py new file mode 100644 index 0000000..1bb93a4 --- /dev/null +++ b/tools/tests/test_native_cursor.py @@ -0,0 +1,63 @@ +"""Bounds/replay checks for untrusted cursor metadata before SDL allocation.""" +import base64 +import importlib.util +import hashlib +from pathlib import Path +import unittest +import tempfile + +spec = importlib.util.spec_from_file_location( + 'native_cursor_client', Path(__file__).resolve().parents[2] / + 'terminal/native-cursor/client.py') +client = importlib.util.module_from_spec(spec) +spec.loader.exec_module(client) + + +class CursorProtocolTests(unittest.TestCase): + def state(self): + return dict(type='cursor', version=1, lease='c'*32, seq=2, ack=0, w=2, h=2, + hx=1, hy=1, x=100, y=100, sw=2560, sh=1440, + rgba=base64.b64encode(bytes([255]*16)).decode()) + + def test_valid_and_hidden(self): + for pixels in (bytes([255]*16), bytes(16)): + s = self.state() + s['rgba'] = base64.b64encode(pixels).decode() + packet = client.validate(s, 'c'*32, [2560, 1440], 1) + self.assertEqual(len(packet), 88) + self.assertEqual(packet[64:80], pixels) + + def test_bad_metadata(self): + for key, value in [('version', 2), ('version', True), ('ack', -1), + ('ack', 2**63), ('lease', 'd'*32), ('type', 'ready'), + ('seq', 1), ('seq', 2**63), ('w', 0), ('w', 129), + ('h', -1), ('hx', 2), ('hy', -1), ('x', -1), + ('y', 1440), ('sw', 1920), ('w', True), + ('seq', 2.0), ('rgba', '***'), ('rgba', 'AAAA')]: + with self.subTest(key=key, value=value): + state = self.state() + state[key] = value + with self.assertRaises(ValueError): + client.validate(state, 'c'*32, [2560, 1440], 1) + + def test_missing_field(self): + state = self.state() + del state['h'] + with self.assertRaises(ValueError): + client.validate(state, 'c'*32, [2560, 1440], 1) + + def test_old_client_rejected_before_lease(self): + with tempfile.TemporaryDirectory() as directory: + binary = Path(directory) / 'client' + binary.write_bytes(b'validated client') + expected = hashlib.sha256(binary.read_bytes()).hexdigest() + client.verify_client(binary, expected) + binary.write_bytes(b'old client without cursor receiver') + with self.assertRaises(ValueError): + client.verify_client(binary, expected) + with self.assertRaises(ValueError): + client.verify_client(binary, None) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/tests/test_usb_device_qmp.py b/tools/tests/test_usb_device_qmp.py index 6bfe24d..a4d6ba8 100644 --- a/tools/tests/test_usb_device_qmp.py +++ b/tools/tests/test_usb_device_qmp.py @@ -1,5 +1,7 @@ import importlib.util from pathlib import Path +import socket +import threading import unittest spec = importlib.util.spec_from_file_location("usb_qmp", Path(__file__).parents[1] / "usb-device-qmp.py") @@ -51,6 +53,24 @@ class UsbQmpTests(unittest.TestCase): usb.attach(q, 100, 40001, "ehci-2.0", "0951", "1666") self.assertEqual(q.calls[-1], ("chardev-remove", {"id": "vdi_usb_box_100"})) + def test_status_probe_distinguishes_ready_from_empty(self): + listener = socket.socket() + listener.bind(("127.0.0.1", 0)) + listener.listen() + port = listener.getsockname()[1] + + def serve(): + conn, _ = listener.accept() + conn.sendall(b"ready\n") + conn.close() + listener.close() + + thread = threading.Thread(target=serve) + thread.start() + self.assertTrue(usb.probe_status(port, timeout=1)) + thread.join(timeout=1) + self.assertFalse(usb.probe_status(port, timeout=0.05)) + if __name__ == "__main__": unittest.main() diff --git a/tools/usb-device-qmp.py b/tools/usb-device-qmp.py index a79c6d1..8cb9fc4 100644 --- a/tools/usb-device-qmp.py +++ b/tools/usb-device-qmp.py @@ -116,13 +116,41 @@ def ensure(qmp, vm, port, bus, vendor=None, product=None): attach(qmp, vm, port, bus, vendor, product) -def watch(vm, port, bus, vendor=None, product=None, interval=3): - """Keep the runtime endpoint attached across VM restarts.""" +def probe_status(port, host="127.0.0.1", timeout=0.7): + """Return whether the box has an exportable storage device ready.""" + try: + with socket.create_connection((host, port), timeout=timeout) as stream: + stream.settimeout(timeout) + return stream.recv(32).strip() == b"ready" + except OSError: + return False + + +def watch(vm, port, bus, vendor=None, product=None, interval=3, + status_port=None, remove_after=3): + """Track the box device and keep the guest endpoint in sync. + + With status_port, the QEMU endpoint is present only while the box reports + an exportable physical storage device. A short missing-device debounce + prevents a transient udev/export restart from causing a guest hot-unplug. + Without status_port this retains the legacy always-present behavior. + """ + missing = 0 while True: try: + ready = True if status_port is None else probe_status(status_port) qmp = Qmp(vm) try: - ensure(qmp, vm, port, bus, vendor, product) + channel, device = ids(vm) + present = device in peripherals(qmp) + if ready: + missing = 0 + if not present: + attach(qmp, vm, port, bus, vendor, product) + else: + missing += 1 + if present and missing >= remove_after: + detach(qmp, vm) finally: qmp.close() except (OSError, RuntimeError) as exc: @@ -140,11 +168,21 @@ def main(): parser.add_argument("--product") parser.add_argument("--any-storage", action="store_true", help="Allow any pure USB Mass Storage device") + parser.add_argument("--status-port", type=int, + help="loopback port forwarded to the box readiness endpoint") + parser.add_argument("--interval", type=float, default=3, + help="watch polling interval in seconds") + parser.add_argument("--remove-after", type=int, default=3, + help="consecutive empty probes before guest removal") parser.add_argument("--guest-ejected", action="store_true", help="Confirm the guest has safely ejected the USB device before detach") args = parser.parse_args() if args.vm < 100 or not 1 <= args.port <= 65535: parser.error("invalid VM ID or local tunnel port") + if args.status_port is not None and not 1 <= args.status_port <= 65535: + parser.error("invalid status port") + if args.interval <= 0 or args.remove_after < 1: + parser.error("interval must be positive and remove-after must be at least one") if args.action in ("attach", "watch"): exact = all(re.fullmatch(r"[0-9a-fA-F]{4}", x or "") for x in [args.vendor, args.product]) if args.any_storage and (args.vendor or args.product): @@ -156,7 +194,9 @@ def main(): if args.action == "watch": watch(args.vm, args.port, args.bus, None if args.any_storage else args.vendor.lower(), - None if args.any_storage else args.product.lower()) + None if args.any_storage else args.product.lower(), + interval=args.interval, status_port=args.status_port, + remove_after=args.remove_after) return if args.action == "detach" and not args.guest_ejected: parser.error("safely eject in the guest first, then pass --guest-ejected")