Compare commits

...

4 Commits

103 changed files with 2948 additions and 75 deletions

View File

@ -2,6 +2,32 @@
完整对话总结在:`D:\DAIMA\vdi-cloud\PROJECT_CONTEXT.md`
## 2026-11-04 麒麟鼠标掉帧继续排查(最新)
- 保留原版Sunshine SHA256 1c3099bf924db3aee759b3d7c05e012569ef8650c400d1479144398795decc71、单线程、暂停合成器、真实2K30/30Mbps。未改PVE或其他桌面。
- wrapper支持~/.config/vdi-terminal/host-color-range/<host>的0/1协商;仅.71设0,消除软件full-range额外转换。同场景2K45请求:原full21.42FPS,limited28.81FPS;不代表真实45或物理延迟与Windows一致。
- 两线程25.67、BGRA27.17、full-chroma25.61、bilinear30.79均未保留。精确SHA校验后仅创建过独立实验副本,现已删除三副本及服务drop-in、恢复原程序,无额外capability。
- wrapper已部署,语法/三主机隔离/显式环境覆盖/非法配置拒绝检查通过。用户明确允许退出Windows切回麒麟后,已完成正常kiosk重连:PID135145/父kiosk92843,真实2K30/30Mbps,env颜色0,RKMPP/EGL和服务端MPEG日志确认;最终保留麒麟正常桌面连接。
- 完整同参数2K30对照各24次合成F9回显,无超时:full23.35FPS、P50 150.99/P95 186.38ms;limited27.25FPS、P50 143.57/P95 173.03ms。分布重叠且网络有差异,不能称显著低延迟或Windows等效;排除物理USB及面板扫描。临时GTK和测试串流均已停。
- 文档docs/kylin-mouse-20261104.md,证据docs/evidence/kylin-mouse-20261104/,两端备份~/vdi-terminal/backups/kylin-mouse-20261104/。
## 2026-11-04 麒麟真实2K已落地(最新)
- 用户明确授权关闭并启动VM101;已正常关机、只改vga=std,memory=64并启动,PCI BAR确认64MB;配置diff仅vga,未动其他VM或宿主。
- 麒麟Virtual-1和视频真实2560x1440;自定义241.50MHz/59.95Hz模式,Sunshine用户服务启动前自动创建/添加/切换。删模式后启动服务验证通过,未第二次整机重启验收新配置。
- 盒子只改麒麟对象为2560x1440/30FPS,沿用2K自动30Mbps;Windows、Ubuntu对象不变,源码/客户端二进制未改。
- 最终X11/libx264 ultrafast/zerolatency原单线程;Sunshine服务启动暂停UKUI合成、停止恢复。有效2K局部运动:开合成18.89FPS、两线程18.85、暂停合成22.97(采用),队列0.12ms。未测物理鼠标到屏幕延迟,不能称稳定30/45FPS。
- 有效测试48/48跨屏点击误差<=1px,F8收到;正常kiosk连接/退出/重连2K验证,最终保持麒麟桌面。临时GTK/测试串流已停,未关闭长期锁屏。
- 早期2K样本有锁屏污染,相关ultrafast/damage非valid文件排除性能结论。KMS探测失败恢复X11,最终入口及实体二进制getcap为空。
- 备份两端~/vdi-terminal/backups/kylin-quality-20261104/;详情docs/kylin-quality-20261104.md,证据docs/evidence/kylin-quality-20261104/。此前接入文档的麒麟1080p为历史状态。
## 2026-11-04 Ubuntu / 麒麟已接入(最新)
- 盒子192.168.1.188正常列表已可连接Ubuntu .72/test2、麒麟 .71/test1,两台安装并配置Sunshine,原Moonlight客户端保留。
- Ubuntu24.04.4使用Sunshine2026.914.233613;麒麟V10 SP1/glibc2.31使用官方v0.21.0 Ubuntu20.04构建,/opt解包部署并单文件KYSEC认证,未关闭全局策略。
- 用户明确授权Ubuntu test2自动登录+Xorg;GDM/AccountsService原配置备份/var/backups/vdi-linux-20261104-100722/;重启GDM后已生效,未整机重启验收。麒麟沿用已有图形会话,锁屏仅临时解锁。
- 用户服务vdi-sunshine active/enabled,XDG自启导入会话环境;X11/libx264、Virtual-1 1920x1080、30FPS配置;固定虚拟音频vdi_audio,audio_sink填sink名而非.monitor,音质未验收。
- 真实盒子RKMPP/EGL入口两台画面、F8及鼠标左键已验证;最后Ubuntu/麒麟/Windows依次连接回归通过并退回列表。Linux短测约29FPS,未验收2K/长期性能/重启恢复。
- Windows原对象2560x1440/45FPS保留,未改源码默认地址。盒子配置备份~/vdi-terminal/backups/linux-desktops-20261104-101507/desktops.json。
- 两台管理随机凭据只在各自~/.config/sunshine/vdi-admin.json(600),不要复制进仓库。临时输入/Xvfb测试已停。详见docs/linux-desktop-onboarding-20261104.md及evidence/linux-desktops-20261104。
## 2026-11-03 U 盘真实设备透传 POC
- 用户明确要求 Windows 枚举真实 USB。已开发 Rust terminal/usb-monitor(标准库),usbredir+受限SSH隧道+PVE QMP usb-redir;盒子不调用PVE API。
- Kingston 0951:1666 / USB序列号408D5C1653D4E44179789251,物理3-1(USB2/480M);Windows正常枚举USB Mass Storage / Kingston USBSTOR,KINGSTON F:、FAT32、Removable、Healthy。
@ -116,7 +142,7 @@
- 当前图形:LightDM + Xorg;正式文档要求 Weston + Wayland + Qt,当前只是 POC
- Proxmox:9.2.2 节点 `starhe`。VM 100 win10-test `192.168.1.70`(Sunshine/Moonlight 云桌面);
VM 101 kylin-test `192.168.1.71`(test1)、VM 102 ubuntu-test `192.168.1.72`(test2),
密码均 `yx66668888`,SSH 可登、图形会话在跑,**尚未装 Sunshine**,暂不能串流
SSH 可登,**均已安装 Sunshine 并验证盒子正常串流**(2026-11-04,见顶部最新记录);登录凭据由管理员保管
- `kiosk-shell`(Qt/C++):**开机直进桌面列表**(2026-10-30 起,设备 SN 即用户自动登录,
登录页代码保留待平台正式认证接入)→ 选择桌面 → Moonlight 会话;**RDP 代码已于 2026-10-29
全部删除**(git 历史可查),Moonlight 是唯一会话通道,无 freerdp 构建依赖

View File

@ -86,7 +86,7 @@ VPN 属于网络层能力(盒子上的 VPN 客户端建立隧道后),地
- `vsync`、`frame-pacing`、性能叠图等低延迟参数可配置;
- 独立 Moonlight 启动脚本保留作调试用途,但不再作为产品开机入口。
当前联调环境中,Sunshine 服务端仍使用 `libx264` 软件编码。1080p 实际帧率约 13~22 FPS,鼠标体感延迟主要来自服务端软编码和客户端帧队列/VSync。后续需要完成 GPU 直通并启用 NVENC。
2026-11-04 现场已接通 Windows、Ubuntu、麒麟三种桌面的正常选择与连接流程。Ubuntu 当前采用 1080p / 30 FPS 配置,麒麟已升级真实 2K / 30 FPS 配置与30Mbps;两者使用 `libx264` 软件编码,盒子使用 RKMPP 硬解。麒麟后续采用逐主机limited视频范围协商,同场景动态短测由约21提高至29FPS,仍未达到Windows水平,长期性能及物理输入延迟尚未验收。配置、备份与证据见 [Linux 桌面接入记录](docs/linux-desktop-onboarding-20261104.md)、[麒麟2K调优记录](docs/kylin-quality-20261104.md) 和 [鼠标掉帧继续排查](docs/kylin-mouse-20261104.md)。
### 3. 状态机

View File

@ -0,0 +1,44 @@
import gi,time,json,math
gi.require_version('Gtk','3.0')
from gi.repository import Gtk,Gdk,GLib
started=time.monotonic(); draws=0; ticks=0
win=Gtk.Window(title='VDI Kylin quality benchmark')
win.connect('destroy',Gtk.main_quit)
area=Gtk.DrawingArea();win.add(area)
area.set_events(Gdk.EventMask.BUTTON_PRESS_MASK|Gdk.EventMask.KEY_PRESS_MASK)
area.set_can_focus(True)
def event(w,e):
print(json.dumps({'event':e.type.value_nick,'keyval':getattr(e,'keyval',None),'button':getattr(e,'button',None),'x':getattr(e,'x',None),'y':getattr(e,'y',None),'width':area.get_allocated_width(),'height':area.get_allocated_height(),'t':time.monotonic()}),flush=True)
return False
win.connect('key-press-event',event);area.connect('button-press-event',event)
def draw(w,cr):
global draws
draws+=1; width=w.get_allocated_width();height=w.get_allocated_height()
cr.set_source_rgb(.96,.97,.99);cr.paint()
cr.select_font_face('sans');cr.set_font_size(30);cr.set_source_rgb(.08,.12,.18)
cr.move_to(60,65);cr.show_text('VDI Kylin - resolution / text / motion / input verification')
cr.set_font_size(22);cr.move_to(60,105);cr.show_text(f'Native desktop: {width} x {height} Temporary test window')
for row,size in enumerate([12,14,16,18,20,24]):
cr.set_font_size(size);cr.move_to(60,155+row*45);cr.set_source_rgb(.1,.1,.1)
cr.show_text(f'{size}px Cloud desktop 0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz')
for i in range(600):
cr.set_source_rgb(0,0,0) if i%2 else cr.set_source_rgb(1,1,1)
cr.rectangle(60+i,445,1,80);cr.fill()
for i,color in enumerate([(1,0,0),(0,.65,0),(0,.2,1),(.6,0,.6)]):
cr.set_source_rgb(*color);cr.set_font_size(18);cr.move_to(60,565+i*28);cr.show_text('Color text 0123456789 - desktop quality test')
elapsed=time.monotonic()-started
for i in range(5):
x=70+((elapsed*220+i*173)%(max(200,width-400)))
cr.set_source_rgb(.1+i*.14,.35,.8-i*.1);cr.rectangle(x,height-280+i*35,120,28);cr.fill()
cr.set_source_rgb(.1,.15,.2);cr.set_font_size(16);cr.move_to(60,height-30)
cr.show_text(f'Source draw rate: {draws/max(.1,elapsed):.2f} FPS; elapsed {elapsed:.1f}s. Close after testing.')
return False
def tick():
global ticks
ticks+=1;area.queue_draw_area(0,max(0,area.get_allocated_height()-300),area.get_allocated_width(),300);return True
def report():
elapsed=time.monotonic()-started
print(json.dumps({'event':'source-stats','seconds':elapsed,'draws':draws,'ticks':ticks,'draw_fps':draws/elapsed}),flush=True);return True
area.connect('draw',draw);win.fullscreen();win.show_all();area.grab_focus()
GLib.timeout_add(16,tick);GLib.timeout_add_seconds(5,report)
print('READY',flush=True);Gtk.main()

View File

@ -0,0 +1,3 @@
49316 00:00:10 31.4
49316 00:00:35 62.7

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

View File

@ -0,0 +1,3 @@
48825 00:00:10 29.7
48825 00:00:35 62.4

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

View File

@ -0,0 +1,3 @@
48246 00:01:04 66.2
48246 00:01:29 74.2

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

View File

@ -0,0 +1,107 @@
{
"static-text": {
"mean_abs_rgb_difference": [
1.1931075392269421,
0.3489188672024493,
0.5770053578262534
],
"full_rgb_extrema": [
[
0,
255
],
[
0,
255
],
[
0,
255
]
],
"limited_rgb_extrema": [
[
0,
255
],
[
0,
255
],
[
0,
255
]
]
},
"background": {
"mean_abs_rgb_difference": [
1.25989,
0.3125,
0.5
],
"full_rgb_extrema": [
[
245,
246
],
[
247,
248
],
[
254,
255
]
],
"limited_rgb_extrema": [
[
244,
245
],
[
248,
249
],
[
253,
254
]
]
},
"stripes": {
"mean_abs_rgb_difference": [
0.8629375,
0.40147916666666666,
0.9251041666666666
],
"full_rgb_extrema": [
[
0,
255
],
[
0,
255
],
[
0,
255
]
],
"limited_rgb_extrema": [
[
0,
255
],
[
0,
255
],
[
0,
255
]
]
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,3 @@
49732 00:01:03 3.0
49732 00:01:15 3.5

View File

@ -0,0 +1 @@
{"method": "synthetic F9 injection to decoded video marker in X11 root; includes xdotool startup and polling, excludes physical USB and panel scanout", "samples_ms": [155.707363, 160.165062, 161.74993, 151.641546, 160.943381, 149.877411, 186.379217, 189.858565, 143.675298, 136.966855, 181.810169, 145.660997, 157.279633, 168.322978, 135.966703, 141.853499, 139.798088, 147.296667, 174.832486, 173.398907, 138.442264, 138.570433, 135.947427, 150.334109], "timeouts": [], "p50_ms": 150.9878275, "p95_ms": 186.379217}

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

View File

@ -0,0 +1,11 @@
{
"pid": 135145,
"ppid": 92843,
"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 --keep-awake --no-vsync --no-frame-pacing --audio-on-host",
"color_range_override": "0",
"normal_kiosk_entry": true,
"hardware_decode_ready": true,
"egl_renderer": true,
"server_range": "MPEG",
"temporary_benchmark_stopped": true
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@ -0,0 +1,3 @@
49732 00:01:58 5.5
49732 00:02:09 6.0

View File

@ -0,0 +1 @@
{"method": "synthetic F9 injection to decoded video marker in X11 root; includes xdotool startup and polling, excludes physical USB and panel scanout", "samples_ms": [150.01172, 140.54207, 146.622806, 140.651155, 144.384812, 147.6731, 147.065551, 138.139572, 143.82567, 141.994567, 141.890732, 146.535279, 140.382497, 136.103407, 176.574946, 173.033478, 135.687185, 141.140538, 168.252132, 152.002315, 168.699252, 141.007818, 135.567293, 143.323084], "timeouts": [], "p50_ms": 143.574377, "p95_ms": 173.033478}

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 MiB

View File

@ -0,0 +1,21 @@
[2026:11:04:18:47:42]: Info:
[2026:11:04:18:47:42]: Info: // Ignore any errors mentioned above, they are not relevant. //
[2026:11:04:18:47:42]: Info:
[2026:11:04:18:47:42]: Info: Found H.264 encoder: libx264 [software]
Source ID 249 was not found when attempting to remove it
Source ID 248 was not found when attempting to remove it
[2026:11:04:18:47:42]: Info: CLIENT CONNECTED
[2026:11:04:18:47:42]: Info: Detecting monitors
[2026:11:04:18:47:42]: Info: Detected monitor 0: Virtual-1, connected: true
[2026:11:04:18:47:42]: Info: Screencasting with X11
[2026:11:04:18:47:42]: Info: Configuring selected monitor (0) to stream
[2026:11:04:18:47:42]: Info: Streaming display: Virtual-1 with res 2560x1440 offset by 0x0
[2026:11:04:18:47:42]: Info: SDR color coding [Rec. 601]
[2026:11:04:18:47:42]: Info: Color depth: 8-bit
[2026:11:04:18:47:42]: Info: Color range: [MPEG]
[2026:11:04:18:47:42]: Info: [libx264 @ 0x7f5d64028940] using cpu capabilities: MMX2 SSE2Fast SSSE3 SSE4.2 AVX FMA3 BMI2 AVX2
[2026:11:04:18:47:42]: Info: [libx264 @ 0x7f5d64028940] profile Constrained Baseline, level 5.0, 4:2:0, 8-bit
[2026:11:04:18:47:43]: Info: Found default monitor by name: vdi_audio.monitor
MainPID=0
ActiveState=inactive
桌面

View File

@ -0,0 +1,71 @@
{
"final-full-2k30": {
"method": "synthetic F9 injection to decoded video marker in X11 root; includes xdotool startup and polling, excludes physical USB and panel scanout",
"samples_ms": [
155.707363,
160.165062,
161.74993,
151.641546,
160.943381,
149.877411,
186.379217,
189.858565,
143.675298,
136.966855,
181.810169,
145.660997,
157.279633,
168.322978,
135.966703,
141.853499,
139.798088,
147.296667,
174.832486,
173.398907,
138.442264,
138.570433,
135.947427,
150.334109
],
"timeouts": [],
"p50_ms": 150.9878275,
"p95_ms": 186.379217,
"render_fps": 23.35,
"decode_ms": 9.54
},
"final-limited-2k30": {
"method": "synthetic F9 injection to decoded video marker in X11 root; includes xdotool startup and polling, excludes physical USB and panel scanout",
"samples_ms": [
150.01172,
140.54207,
146.622806,
140.651155,
144.384812,
147.6731,
147.065551,
138.139572,
143.82567,
141.994567,
141.890732,
146.535279,
140.382497,
136.103407,
176.574946,
173.033478,
135.687185,
141.140538,
168.252132,
152.002315,
168.699252,
141.007818,
135.567293,
143.323084
],
"timeouts": [],
"p50_ms": 143.574377,
"p95_ms": 173.033478,
"render_fps": 27.25,
"decode_ms": 6.96
},
"interpretation": "Short sequential synthetic input-to-decoded-video proxy, not physical end-to-end latency. Similar ~140-150ms distributions and varying network; no demonstrated large latency improvement or Windows equivalence."
}

View File

@ -0,0 +1,3 @@
45143 00:19:31 99.0
45143 00:19:51 99.2

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

View File

@ -0,0 +1 @@
{"method": "synthetic F9 injection to decoded video marker in X11 root; includes xdotool startup and polling, excludes physical USB and panel scanout", "samples_ms": [183.940229, 161.718547, 160.71209, 138.491578, 141.028294, 141.934718, 154.77252, 163.688624, 125.041352, 179.385106, 179.373316, 178.694809, 185.66681, 177.159603, 189.758573, 151.004259, 149.251196, 165.416771, 157.880119, 159.972861, 179.910412, 189.615124, 185.924098, 140.941751], "timeouts": [], "p50_ms": 162.7035855, "p95_ms": 189.615124}

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

View File

@ -0,0 +1,32 @@
import ctypes,ctypes.util,subprocess,os,time,json,statistics
os.environ.update(DISPLAY=':0',XAUTHORITY='/home/armbian/.Xauthority')
x=ctypes.CDLL(ctypes.util.find_library('X11'));x.XOpenDisplay.argtypes=[ctypes.c_char_p];x.XOpenDisplay.restype=ctypes.c_void_p
x.XDefaultRootWindow.argtypes=[ctypes.c_void_p];x.XDefaultRootWindow.restype=ctypes.c_ulong
x.XGetImage.argtypes=[ctypes.c_void_p,ctypes.c_ulong,ctypes.c_int,ctypes.c_int,ctypes.c_uint,ctypes.c_uint,ctypes.c_ulong,ctypes.c_int];x.XGetImage.restype=ctypes.c_void_p
x.XGetPixel.argtypes=[ctypes.c_void_p,ctypes.c_int,ctypes.c_int];x.XGetPixel.restype=ctypes.c_ulong
x.XDestroyImage.argtypes=[ctypes.c_void_p];x.XCloseDisplay.argtypes=[ctypes.c_void_p]
d=x.XOpenDisplay(b':0');assert d;root=x.XDefaultRootWindow(d)
prefix='/home/armbian/vdi-build/rkmpp-experiment/build/moonlight/app/moonlight stream 192.168.1.71 '
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
ids=subprocess.check_output(['xdotool','search','--onlyvisible','--pid',items[0][0],'--class','Moonlight'],text=True).split();assert ids
subprocess.run(['xdotool','windowactivate','--sync',ids[-1]],check=True)
def pixel():
im=x.XGetImage(d,root,2250,130,1,1,0xffffffff,2);assert im
v=x.XGetPixel(im,0,0);x.XDestroyImage(im)
return v&0xffffff
samples=[];errors=[]
for i in range(24):
v=pixel();assert v<0x202020 or v>0xe0e0e0,hex(v)
start=time.monotonic_ns()
subprocess.run(['xdotool','key','--clearmodifiers','--delay','0','F9'],check=True,stdout=subprocess.DEVNULL)
while time.monotonic_ns()-start<2000000000:
cur=pixel()
if (v<0x202020 and cur>0xe0e0e0) or (v>0xe0e0e0 and cur<0x202020):
samples.append((time.monotonic_ns()-start)/1e6);break
time.sleep(.002)
else:errors.append(i)
time.sleep(.12)
x.XCloseDisplay(d)
vals=sorted(samples)
print(json.dumps({'method':'synthetic F9 injection to decoded video marker in X11 root; includes xdotool startup and polling, excludes physical USB and panel scanout','samples_ms':samples,'timeouts':errors,'p50_ms':statistics.median(vals) if vals else None,'p95_ms':vals[min(len(vals)-1,int(len(vals)*.95))] if vals else None}))

View File

@ -0,0 +1,48 @@
import gi,time,json,math
gi.require_version('Gtk','3.0')
from gi.repository import Gtk,Gdk,GLib
started=time.monotonic(); draws=0; ticks=0; marker=False
win=Gtk.Window(title='VDI Kylin quality benchmark')
win.connect('destroy',Gtk.main_quit)
area=Gtk.DrawingArea();win.add(area)
area.set_events(Gdk.EventMask.BUTTON_PRESS_MASK|Gdk.EventMask.KEY_PRESS_MASK)
area.set_can_focus(True)
def event(w,e):
global marker
if e.type==Gdk.EventType.KEY_PRESS and getattr(e,'keyval',None)==Gdk.KEY_F9:
marker=not marker; area.queue_draw_area(2180,80,220,140)
print(json.dumps({'event':e.type.value_nick,'keyval':getattr(e,'keyval',None),'button':getattr(e,'button',None),'x':getattr(e,'x',None),'y':getattr(e,'y',None),'width':area.get_allocated_width(),'height':area.get_allocated_height(),'t':time.monotonic()}),flush=True)
return False
win.connect('key-press-event',event);area.connect('button-press-event',event)
def draw(w,cr):
global draws
draws+=1; width=w.get_allocated_width();height=w.get_allocated_height()
cr.set_source_rgb(.96,.97,.99);cr.paint()
cr.set_source_rgb(*( (1,1,1) if marker else (0,0,0)));cr.rectangle(2180,80,220,140);cr.fill()
cr.select_font_face('sans');cr.set_font_size(30);cr.set_source_rgb(.08,.12,.18)
cr.move_to(60,65);cr.show_text('VDI Kylin - resolution / text / motion / input verification')
cr.set_font_size(22);cr.move_to(60,105);cr.show_text(f'Native desktop: {width} x {height} Temporary test window')
for row,size in enumerate([12,14,16,18,20,24]):
cr.set_font_size(size);cr.move_to(60,155+row*45);cr.set_source_rgb(.1,.1,.1)
cr.show_text(f'{size}px Cloud desktop 0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz')
for i in range(600):
cr.set_source_rgb(0,0,0) if i%2 else cr.set_source_rgb(1,1,1)
cr.rectangle(60+i,445,1,80);cr.fill()
for i,color in enumerate([(1,0,0),(0,.65,0),(0,.2,1),(.6,0,.6)]):
cr.set_source_rgb(*color);cr.set_font_size(18);cr.move_to(60,565+i*28);cr.show_text('Color text 0123456789 - desktop quality test')
elapsed=time.monotonic()-started
for i in range(5):
x=70+((elapsed*220+i*173)%(max(200,width-400)))
cr.set_source_rgb(.1+i*.14,.35,.8-i*.1);cr.rectangle(x,height-280+i*35,120,28);cr.fill()
cr.set_source_rgb(.1,.15,.2);cr.set_font_size(16);cr.move_to(60,height-30)
cr.show_text(f'Source draw rate: {draws/max(.1,elapsed):.2f} FPS; elapsed {elapsed:.1f}s. Close after testing.')
return False
def tick():
global ticks
ticks+=1;area.queue_draw_area(0,max(0,area.get_allocated_height()-300),area.get_allocated_width(),300);return True
def report():
elapsed=time.monotonic()-started
print(json.dumps({'event':'source-stats','seconds':elapsed,'draws':draws,'ticks':ticks,'draw_fps':draws/elapsed}),flush=True);return True
area.connect('draw',draw);win.fullscreen();win.show_all();area.grab_focus()
GLib.timeout_add(16,tick);GLib.timeout_add_seconds(5,report)
print('READY',flush=True);Gtk.main()

View File

@ -0,0 +1,3 @@
45143 00:20:22 97.2
45143 00:20:43 97.4

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

View File

@ -0,0 +1,3 @@
47776 00:00:09 44.8
47776 00:00:28 70.9

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

View File

@ -0,0 +1,19 @@
MainPID=49732
ActiveState=active
sunshine_name = VDI-Kylin
capture = x11
encoder = software
sw_preset = ultrafast
sw_tune = zerolatency
upnp = disabled
origin_web_ui_allowed = pc
hevc_mode = 1
audio_sink = vdi_audio
Screen 0: minimum 320 x 200, current 2560 x 1440, maximum 8192 x 8192
Virtual-1 connected primary 2560x1440+0+0 (normal left inverted right x axis y axis) 325mm x 203mm
1280x800 74.99 +
3840x2160 60.00 50.00 59.94
1920x1440 60.00
2560x1080 50.00
1856x1392 60.00
(<false>,)

View File

@ -0,0 +1 @@
{"removed_unused_trials": ["sunshine-v0.21.0-vdi-bgra", "sunshine-v0.21.0-vdi-bgra-fullchroma", "sunshine-v0.21.0-vdi-bgra-bilinear"], "original_sha256": "1c3099bf924db3aee759b3d7c05e012569ef8650c400d1479144398795decc71"}

View File

@ -0,0 +1,10 @@
{
"source": "software CPU clock perf_event_open 10ms period, 4 seconds; top80 addresses only",
"build_sha256": "1c3099bf924db3aee759b3d7c05e012569ef8650c400d1479144398795decc71",
"top80_groups_approx": {
"static_ffmpeg_conversion_approx": 140,
"other": 36,
"static_x264_approx": 34
},
"warning": "Approximate stripped ELF ranges; not exact function timing. Scalar color-range loop disassembled at 0x8e8490 and RGB chroma loop at 0x8fb330."
}

View File

@ -0,0 +1,59 @@
{
"trials": [
{
"label": "fullrange-2k45",
"render_fps": 21.42,
"decode_ms": 7.48,
"queue_ms": 0.15,
"render_ms": 2.07,
"network_drop_pct": 0.0,
"jitter_drop_pct": 1.05
},
{
"label": "limitedrange-2k45",
"render_fps": 28.81,
"decode_ms": 6.9,
"queue_ms": 0.23,
"render_ms": 2.02,
"network_drop_pct": 0.0,
"jitter_drop_pct": 0.81
},
{
"label": "limitedrange-thread2-2k45",
"render_fps": 25.67,
"decode_ms": 7.09,
"queue_ms": 0.12,
"render_ms": 2.01,
"network_drop_pct": 0.0,
"jitter_drop_pct": 0.99
},
{
"label": "bgra-limited-2k45-valid",
"render_fps": 27.17,
"decode_ms": 6.85,
"queue_ms": 0.11,
"render_ms": 2.01,
"network_drop_pct": 0.0,
"jitter_drop_pct": 0.78
},
{
"label": "bgra-fullchroma-limited-2k45",
"render_fps": 25.61,
"decode_ms": 10.38,
"queue_ms": 0.13,
"render_ms": 2.03,
"network_drop_pct": 0.0,
"jitter_drop_pct": 1.72
},
{
"label": "bgra-bilinear-limited-2k45",
"render_fps": 30.79,
"decode_ms": 7.44,
"queue_ms": 0.13,
"render_ms": 1.93,
"network_drop_pct": 0.0,
"jitter_drop_pct": 1.12
}
],
"note": "2K45 request, 30Mbps H264, same partial-motion 60fps source. Short sequential tests, network variation. Only original binary + limited range retained; all binary experiments and 2 threads reverted. Inputecho baseline incomplete after stream deadline and subsequent Windows connection; exclude from paired latency claims."
}

View File

@ -0,0 +1 @@
{"192.168.1.71": "0", "192.168.1.70": "unset", "192.168.1.72": "unset", "explicit override": "1", "invalid rejected": 78}

View File

@ -0,0 +1,3 @@
169194 00:06:35 9.7
169194 00:06:59 10.3

Binary file not shown.

After

Width:  |  Height:  |  Size: 684 KiB

View File

@ -0,0 +1,3 @@
179730 00:00:09 8.7
179730 00:00:34 26.2

Binary file not shown.

After

Width:  |  Height:  |  Size: 686 KiB

View File

@ -0,0 +1,3 @@
180224 00:00:11 46.2
180224 00:00:36 74.1

Binary file not shown.

After

Width:  |  Height:  |  Size: 685 KiB

View File

@ -0,0 +1,3 @@
180438 00:00:09 42.7
180438 00:00:35 74.5

Binary file not shown.

After

Width:  |  Height:  |  Size: 771 KiB

View File

@ -0,0 +1,3 @@
180841 00:00:10 42.4
180841 00:00:37 74.1

Binary file not shown.

After

Width:  |  Height:  |  Size: 688 KiB

View File

@ -0,0 +1,23 @@
Screen 0: minimum 320 x 200, current 2560 x 1440, maximum 8192 x 8192
Virtual-1 connected primary 2560x1440+0+0 (normal left inverted right x axis y axis) 325mm x 203mm
1280x800 74.99 +
3840x2160 60.00 50.00 59.94
1920x1440 60.00
2560x1080 50.00
1856x1392 60.00
1792x1344 60.00
2048x1152 60.00
1920x1200 59.88
1920x1080 60.00 50.00
1600x1200 60.00
1680x1050 59.95
1400x1050 59.98
1280x1024 60.02
1440x900 59.89
1280x960 60.00
1360x768 60.02
1280x768 59.87
1024x768 60.00
800x600 60.32
640x480 60.00 59.94
2560x1440_60.00 59.95*

View File

@ -0,0 +1,7 @@
{
"F8_received": 2,
"alternating_clicks_correct": 48,
"native_width": 2560,
"native_height": 1440,
"coordinate_error_bound_px": 1
}

View File

@ -0,0 +1,3 @@
16379 00:01:07 47.7
16379 00:01:35 57.3

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@ -0,0 +1,3 @@
25194 00:00:09 30.2
25194 00:00:34 58.6

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

View File

@ -0,0 +1,3 @@
42266 00:00:59 35.0
42266 00:01:21 43.1

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

View File

@ -0,0 +1,3 @@
42266 00:00:09 43.6
42266 00:00:30 73.2

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

View File

@ -0,0 +1,3 @@
25194 00:01:01 0.8
25194 00:01:20 1.1

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

View File

@ -0,0 +1,3 @@
16379 00:00:12 46.0
16379 00:00:37 71.3

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@ -0,0 +1,44 @@
import gi,time,json,math
gi.require_version('Gtk','3.0')
from gi.repository import Gtk,Gdk,GLib
started=time.monotonic(); draws=0; ticks=0
win=Gtk.Window(title='VDI Kylin quality benchmark')
win.connect('destroy',Gtk.main_quit)
area=Gtk.DrawingArea();win.add(area)
area.set_events(Gdk.EventMask.BUTTON_PRESS_MASK|Gdk.EventMask.KEY_PRESS_MASK)
area.set_can_focus(True)
def event(w,e):
print(json.dumps({'event':e.type.value_nick,'keyval':getattr(e,'keyval',None),'button':getattr(e,'button',None),'x':getattr(e,'x',None),'y':getattr(e,'y',None),'width':area.get_allocated_width(),'height':area.get_allocated_height(),'t':time.monotonic()}),flush=True)
return False
win.connect('key-press-event',event);area.connect('button-press-event',event)
def draw(w,cr):
global draws
draws+=1; width=w.get_allocated_width();height=w.get_allocated_height()
cr.set_source_rgb(.96,.97,.99);cr.paint()
cr.select_font_face('sans');cr.set_font_size(30);cr.set_source_rgb(.08,.12,.18)
cr.move_to(60,65);cr.show_text('VDI Kylin - resolution / text / motion / input verification')
cr.set_font_size(22);cr.move_to(60,105);cr.show_text(f'Native desktop: {width} x {height} Temporary test window')
for row,size in enumerate([12,14,16,18,20,24]):
cr.set_font_size(size);cr.move_to(60,155+row*45);cr.set_source_rgb(.1,.1,.1)
cr.show_text(f'{size}px Cloud desktop 0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz')
for i in range(600):
cr.set_source_rgb(0,0,0) if i%2 else cr.set_source_rgb(1,1,1)
cr.rectangle(60+i,445,1,80);cr.fill()
for i,color in enumerate([(1,0,0),(0,.65,0),(0,.2,1),(.6,0,.6)]):
cr.set_source_rgb(*color);cr.set_font_size(18);cr.move_to(60,565+i*28);cr.show_text('Color text 0123456789 - desktop quality test')
elapsed=time.monotonic()-started
for i in range(5):
x=70+((elapsed*220+i*173)%(max(200,width-400)))
cr.set_source_rgb(.1+i*.14,.35,.8-i*.1);cr.rectangle(x,height-280+i*35,120,28);cr.fill()
cr.set_source_rgb(.1,.15,.2);cr.set_font_size(16);cr.move_to(60,height-30)
cr.show_text(f'Source draw rate: {draws/max(.1,elapsed):.2f} FPS; elapsed {elapsed:.1f}s. Close after testing.')
return False
def tick():
global ticks
ticks+=1;area.queue_draw_area(0,max(0,area.get_allocated_height()-300),area.get_allocated_width(),300);return True
def report():
elapsed=time.monotonic()-started
print(json.dumps({'event':'source-stats','seconds':elapsed,'draws':draws,'ticks':ticks,'draw_fps':draws/elapsed}),flush=True);return True
area.connect('draw',draw);win.fullscreen();win.show_all();area.grab_focus()
GLib.timeout_add(16,tick);GLib.timeout_add_seconds(5,report)
print('READY',flush=True);Gtk.main()

View File

@ -0,0 +1,44 @@
import gi,time,json,math
gi.require_version('Gtk','3.0')
from gi.repository import Gtk,Gdk,GLib
started=time.monotonic(); draws=0; ticks=0
win=Gtk.Window(title='VDI Kylin quality benchmark')
win.connect('destroy',Gtk.main_quit)
area=Gtk.DrawingArea();win.add(area)
area.set_events(Gdk.EventMask.BUTTON_PRESS_MASK|Gdk.EventMask.KEY_PRESS_MASK)
area.set_can_focus(True)
def event(w,e):
print(json.dumps({'event':e.type.value_nick,'keyval':getattr(e,'keyval',None),'button':getattr(e,'button',None),'x':getattr(e,'x',None),'y':getattr(e,'y',None),'width':area.get_allocated_width(),'height':area.get_allocated_height(),'t':time.monotonic()}),flush=True)
return False
win.connect('key-press-event',event);area.connect('button-press-event',event)
def draw(w,cr):
global draws
draws+=1; width=w.get_allocated_width();height=w.get_allocated_height()
cr.set_source_rgb(.96,.97,.99);cr.paint()
cr.select_font_face('sans');cr.set_font_size(30);cr.set_source_rgb(.08,.12,.18)
cr.move_to(60,65);cr.show_text('VDI Kylin - resolution / text / motion / input verification')
cr.set_font_size(22);cr.move_to(60,105);cr.show_text(f'Native desktop: {width} x {height} Temporary test window')
for row,size in enumerate([12,14,16,18,20,24]):
cr.set_font_size(size);cr.move_to(60,155+row*45);cr.set_source_rgb(.1,.1,.1)
cr.show_text(f'{size}px Cloud desktop 0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz')
for i in range(600):
cr.set_source_rgb(0,0,0) if i%2 else cr.set_source_rgb(1,1,1)
cr.rectangle(60+i,445,1,80);cr.fill()
for i,color in enumerate([(1,0,0),(0,.65,0),(0,.2,1),(.6,0,.6)]):
cr.set_source_rgb(*color);cr.set_font_size(18);cr.move_to(60,565+i*28);cr.show_text('Color text 0123456789 - desktop quality test')
elapsed=time.monotonic()-started
for i in range(5):
x=70+((elapsed*220+i*173)%(max(200,width-400)))
cr.set_source_rgb(.1+i*.14,.35,.8-i*.1);cr.rectangle(x,height-280+i*35,120,28);cr.fill()
cr.set_source_rgb(.1,.15,.2);cr.set_font_size(16);cr.move_to(60,height-30)
cr.show_text(f'Source draw rate: {draws/max(.1,elapsed):.2f} FPS; elapsed {elapsed:.1f}s. Close after testing.')
return False
def tick():
global ticks
ticks+=1;area.queue_draw();return True
def report():
elapsed=time.monotonic()-started
print(json.dumps({'event':'source-stats','seconds':elapsed,'draws':draws,'ticks':ticks,'draw_fps':draws/elapsed}),flush=True);return True
area.connect('draw',draw);win.fullscreen();win.show_all();area.grab_focus()
GLib.timeout_add(16,tick);GLib.timeout_add_seconds(5,report)
print('READY',flush=True);Gtk.main()

View File

@ -0,0 +1 @@
{"updated": {"app": "Desktop", "endpoint": "192.168.1.71", "fps": 30, "height": 1440, "id": "desk-kylin-01", "name": "麒麟", "os": "银河麒麟桌面", "width": 2560}, "other_desktop_objects_unchanged": true}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 MiB

View File

@ -0,0 +1,27 @@
active
enabled
inactive
Screen 0: minimum 320 x 200, current 2560 x 1440, maximum 8192 x 8192
Virtual-1 connected primary 2560x1440+0+0 (normal left inverted right x axis y axis) 325mm x 203mm
1280x800 74.99 +
3840x2160 60.00 50.00 59.94
1920x1440 60.00
(<false>,)
[2026:11:04:17:36:00]: Info: [libx264 @ 0x7fa2e81c4300] kb/s:3306.24
[2026:11:04:17:36:00]: Info: // Testing for available encoders, this may generate errors. You can safely ignore those errors. //
[2026:11:04:17:36:00]: Info:
[2026:11:04:17:36:00]: Info: // Ignore any errors mentioned above, they are not relevant. //
[2026:11:04:17:36:00]: Info:
[2026:11:04:17:36:00]: Info: Found H.264 encoder: libx264 [software]
[2026:11:04:17:36:00]: Info: CLIENT CONNECTED
[2026:11:04:17:36:00]: Info: Detecting monitors
[2026:11:04:17:36:00]: Info: Detected monitor 0: Virtual-1, connected: true
[2026:11:04:17:36:00]: Info: Screencasting with X11
[2026:11:04:17:36:00]: Info: Configuring selected monitor (0) to stream
[2026:11:04:17:36:00]: Info: Streaming display: Virtual-1 with res 2560x1440 offset by 0x0
[2026:11:04:17:36:00]: Info: SDR color coding [Rec. 601]
[2026:11:04:17:36:00]: Info: Color depth: 8-bit
[2026:11:04:17:36:00]: Info: Color range: [JPEG]
[2026:11:04:17:36:00]: Info: [libx264 @ 0x7fa2fc00e6c0] using cpu capabilities: MMX2 SSE2Fast SSSE3 SSE4.2 AVX FMA3 BMI2 AVX2
[2026:11:04:17:36:00]: Info: [libx264 @ 0x7fa2fc00e6c0] profile Constrained Baseline, level 5.0, 4:2:0, 8-bit
[2026:11:04:17:36:00]: Info: Found default monitor by name: vdi_audio.monitor

View File

@ -0,0 +1,20 @@
[
{
"attempt": 1,
"normal_kiosk": true,
"width": 2560,
"height": 1440,
"configured_fps": 30,
"bitrate_kbps": 30000,
"hardware_decode_ready": true
},
{
"attempt": 2,
"normal_kiosk": true,
"width": 2560,
"height": 1440,
"configured_fps": 30,
"bitrate_kbps": 30000,
"hardware_decode_ready": true
}
]

View File

@ -0,0 +1,9 @@
sunshine_name = VDI-Kylin
capture = x11
encoder = software
sw_preset = ultrafast
sw_tune = zerolatency
upnp = disabled
origin_web_ui_allowed = pc
hevc_mode = 1
audio_sink = vdi_audio

View File

@ -0,0 +1,20 @@
[Unit]
Description=VDI Linux desktop Sunshine host
PartOf=graphical-session.target
After=graphical-session.target
[Service]
Environment=DISPLAY=:0 XAUTHORITY=/home/test1/.Xauthority
ExecStartPre=/bin/sh -c '/usr/bin/xrandr --query | grep -q 2560x1440_60.00 || /usr/bin/xrandr --newmode 2560x1440_60.00 241.50 2560 2608 2640 2720 1440 1443 1448 1481 +hsync -vsync'
ExecStartPre=/usr/bin/xrandr --addmode Virtual-1 2560x1440_60.00
ExecStartPre=/usr/bin/xrandr --output Virtual-1 --mode 2560x1440_60.00
ExecStartPre=/bin/sh -c 'pactl list short sinks | grep -qw vdi_audio || pactl load-module module-null-sink sink_name=vdi_audio sink_properties=device.description=VDI-Audio'
ExecStartPre=/usr/bin/pactl set-default-sink vdi_audio
ExecStart=/opt/vdi-sunshine-0.21.0/usr/bin/sunshine /home/test1/.config/sunshine/sunshine.conf
ExecStartPost=-/usr/bin/gdbus call --session --dest org.ukui.KWin --object-path /Compositor --method org.ukui.kwin.Compositing.suspend
ExecStopPost=-/usr/bin/gdbus call --session --dest org.ukui.KWin --object-path /Compositor --method org.ukui.kwin.Compositing.resume
Restart=on-failure
RestartSec=5
[Install]
WantedBy=graphical-session.target

View File

@ -0,0 +1,9 @@
sunshine_name = VDI-Kylin
capture = x11
encoder = software
sw_preset = ultrafast
sw_tune = zerolatency
upnp = disabled
origin_web_ui_allowed = pc
hevc_mode = 1
audio_sink = vdi_audio

View File

@ -0,0 +1,104 @@
[
{
"trial": "1080p30-baseline",
"valid_for_performance": true,
"Rendering frame rate": "28.63 FPS",
"Average decoding time": "4.95 ms",
"Average frame queue delay": "0.17 ms",
"Average rendering time (including monitor V-sync latency)": "1.69 ms",
"Average network latency": "24 ms (variance: 27 ms)"
},
{
"trial": "1080p45-compositor",
"valid_for_performance": true,
"Rendering frame rate": "29.56 FPS",
"Average decoding time": "5.10 ms",
"Average frame queue delay": "0.25 ms",
"Average rendering time (including monitor V-sync latency)": "1.68 ms",
"Average network latency": "11 ms (variance: 7 ms)"
},
{
"trial": "1080p45-no-compositor-retry",
"valid_for_performance": true,
"Rendering frame rate": "31.86 FPS",
"Average decoding time": "5.98 ms",
"Average frame queue delay": "0.16 ms",
"Average rendering time (including monitor V-sync latency)": "1.64 ms",
"Average network latency": "4 ms (variance: 4 ms)"
},
{
"trial": "1080p45-superfast-threads2",
"valid_for_performance": true,
"Rendering frame rate": "27.84 FPS",
"Average decoding time": "7.18 ms",
"Average frame queue delay": "0.18 ms",
"Average rendering time (including monitor V-sync latency)": "1.65 ms",
"Average network latency": "5 ms (variance: 2 ms)"
},
{
"trial": "1080p45-ultrafast-threads2",
"valid_for_performance": true,
"Rendering frame rate": "32.44 FPS",
"Average decoding time": "4.82 ms",
"Average frame queue delay": "0.23 ms",
"Average rendering time (including monitor V-sync latency)": "1.60 ms",
"Average network latency": "2 ms (variance: 1 ms)"
},
{
"trial": "2k30-damage-thread1",
"valid_for_performance": false,
"excluded_reason": "lock screen or interrupted/unverified scene",
"Rendering frame rate": "21.81 FPS",
"Average decoding time": "7.74 ms",
"Average frame queue delay": "0.21 ms",
"Average rendering time (including monitor V-sync latency)": "1.95 ms",
"Average network latency": "8 ms (variance: 1 ms)"
},
{
"trial": "2k30-damage-thread2",
"valid_for_performance": false,
"excluded_reason": "lock screen or interrupted/unverified scene",
"Rendering frame rate": "19.38 FPS",
"Average decoding time": "8.69 ms",
"Average frame queue delay": "0.21 ms",
"Average rendering time (including monitor V-sync latency)": "2.01 ms",
"Average network latency": "4 ms (variance: 1 ms)"
},
{
"trial": "2k30-desktop-no-compositor-valid",
"valid_for_performance": true,
"Rendering frame rate": "22.97 FPS",
"Average decoding time": "9.04 ms",
"Average frame queue delay": "0.12 ms",
"Average rendering time (including monitor V-sync latency)": "2.11 ms",
"Average network latency": "9 ms (variance: 6 ms)"
},
{
"trial": "2k30-desktop-thread1-valid",
"valid_for_performance": true,
"Rendering frame rate": "18.89 FPS",
"Average decoding time": "14.13 ms",
"Average frame queue delay": "0.09 ms",
"Average rendering time (including monitor V-sync latency)": "2.12 ms",
"Average network latency": "10 ms (variance: 8 ms)"
},
{
"trial": "2k30-desktop-thread2-valid",
"valid_for_performance": true,
"Rendering frame rate": "18.85 FPS",
"Average decoding time": "8.96 ms",
"Average frame queue delay": "0.29 ms",
"Average rendering time (including monitor V-sync latency)": "2.12 ms",
"Average network latency": "5 ms (variance: 1 ms)"
},
{
"trial": "2k30-ultrafast-thread1",
"valid_for_performance": false,
"excluded_reason": "lock screen or interrupted/unverified scene",
"Rendering frame rate": "14.12 FPS",
"Average decoding time": "12.11 ms",
"Average frame queue delay": "0.12 ms",
"Average rendering time (including monitor V-sync latency)": "2.10 ms",
"Average network latency": "4 ms (variance: 2 ms)"
}
]

View File

@ -0,0 +1,10 @@
{
"vmid": 101,
"name": "kylin-test",
"before_vga": null,
"apply_vga": "std,memory=64",
"requires": "graceful shutdown to stopped, then start",
"rollback_vga": "delete explicit vga field (restore default)",
"other_fields": "preserve",
"reboot_other_machines": false
}

View File

@ -0,0 +1,23 @@
{
"ostype": "l26",
"cores": 2,
"scsi0": "local-lvm:vm-101-disk-0,discard=on,iothread=1,size=50G",
"scsihw": "virtio-scsi-single",
"onboot": 1,
"sockets": 1,
"digest": "5e371a9964bee0ea2acf3265409281aae89c9ffc",
"memory": "4096",
"boot": "order=scsi0;ide2;net0",
"net0": "virtio=BC:24:11:25:29:40,bridge=vmbr0,firewall=1",
"vmgenid": "cc8ff198-c228-4ac6-8c37-918d4978b79b",
"balloon": 0,
"name": "kylin-test",
"cpu": "host",
"numa": 0,
"bios": "seabios",
"ide2": "none,media=cdrom",
"agent": "1",
"smbios1": "uuid=8ca270ad-7f1a-4da0-97f2-99a94eef9400",
"meta": "creation-qemu=11.0.3,ctime=1793090566",
"machine": "q35"
}

View File

@ -0,0 +1,6 @@
{
"vga": {
"before": null,
"after": "std,memory=64"
}
}

View File

@ -0,0 +1,22 @@
[
{
"phase": "precheck",
"status": "running",
"vga": null,
"digest": "5e371a9964bee0ea2acf3265409281aae89c9ffc"
},
{
"phase": "graceful_shutdown_requested"
},
{
"phase": "stopped"
},
{
"phase": "vram_configured",
"vga": "std,memory=64"
},
{
"phase": "started",
"vga": "std,memory=64"
}
]

View File

@ -0,0 +1,44 @@
use_type: 1
use_tt: 0
size: 4096
0x0000000000000000-0x000000000000012c: 300: free
0x000000000000012c-0x0000000000000915: 2025: used
0x0000000000000915-0x0000000000001000: 1771: free
total: 4096, used 2025 free 2071
framebuffer[39]:
allocated by = Xorg
refcount=2
format=XR24 little-endian (0x34325258)
modifier=0x0
size=1920x1080
layers:
size[0]=1920x1080
pitch[0]=7680
offset[0]=0
obj[0]:
name=0
refcount=2
start=001007d4
size=8294400
imported=no
placement=(none)
bus.offset=fc12c000
framebuffer[36]:
allocated by = [fbcon]
refcount=1
format=XR24 little-endian (0x34325258)
modifier=0x0
size=1280x800
layers:
size[0]=1280x800
pitch[0]=5120
offset[0]=0
obj[0]:
name=0
refcount=3
start=00100000
size=4096000
imported=no
placement=(none)
name size handles refcount
Y

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@ -0,0 +1,20 @@
[
{
"desktop": "Ubuntu",
"endpoint": "192.168.1.72",
"hardware_decode_ready": true,
"still_running": true
},
{
"desktop": "Kylin",
"endpoint": "192.168.1.71",
"hardware_decode_ready": true,
"still_running": true
},
{
"desktop": "Windows",
"endpoint": "192.168.1.70",
"hardware_decode_ready": true,
"still_running": true
}
]

View File

@ -0,0 +1,37 @@
Server String: /run/user/1000/pulse/native
Library Protocol Version: 33
Server Protocol Version: 33
Is Local: yes
Client Index: 60
Tile Size: 65472
User Name: test1
Host Name: test1
Server Name: pulseaudio
Server Version: 13.99.1
Default Sample Specification: s16le 2ch 44100Hz
Default Channel Map: front-left,front-right
Default Sink: vdi_audio
Default Source: vdi_audio.monitor
active
enabled
0 auto_null module-null-sink.c s16le 2ch 44100Hz SUSPENDED
4 vdi_audio module-null-sink.c s16le 2ch 44100Hz SUSPENDED
0 auto_null.monitor module-null-sink.c s16le 2ch 44100Hz SUSPENDED
4 vdi_audio.monitor module-null-sink.c s16le 2ch 44100Hz SUSPENDED
3 1000 test1 seat0
56 1000 test1
[Desktop Entry]
Type=Application
Name=VDI Sunshine host
Exec=/bin/sh -c "systemctl --user import-environment DISPLAY XAUTHORITY XDG_SESSION_TYPE; systemctl --user start vdi-sunshine.service"
X-GNOME-Autostart-enabled=true
KERNEL=="uinput", SUBSYSTEM=="misc", OPTIONS+="static_node=uinput", GROUP="input", MODE="0660", TAG+="uaccess"
# file: /dev/uinput
# owner: root
# group: input
user::rw-
user:test1:rw-
group::---
mask::rw-
other::---

View File

@ -0,0 +1,2 @@
{"event": "key", "keyval": 65477}
{"event": "click", "button": 1, "x": 320.34991455078125, "y": 150.28619384765625}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

View File

@ -0,0 +1,9 @@
sunshine_name = VDI-Kylin
capture = x11
encoder = software
sw_preset = ultrafast
sw_tune = zerolatency
upnp = disabled
origin_web_ui_allowed = pc
hevc_mode = 1
audio_sink = vdi_audio

View File

@ -0,0 +1,16 @@
[Unit]
Description=VDI Linux desktop Sunshine host
PartOf=graphical-session.target
After=graphical-session.target
[Service]
Environment=DISPLAY=:0 XAUTHORITY=/home/test1/.Xauthority
ExecStartPre=/usr/bin/xrandr --output Virtual-1 --mode 1920x1080
ExecStartPre=/bin/sh -c 'pactl list short sinks | grep -qw vdi_audio || pactl load-module module-null-sink sink_name=vdi_audio sink_properties=device.description=VDI-Audio'
ExecStartPre=/usr/bin/pactl set-default-sink vdi_audio
ExecStart=/opt/vdi-sunshine-0.21.0/usr/bin/sunshine /home/test1/.config/sunshine/sunshine.conf
Restart=on-failure
RestartSec=5
[Install]
WantedBy=graphical-session.target

View File

@ -0,0 +1,34 @@
Server String: /run/user/1000/pulse/native
Library Protocol Version: 35
Server Protocol Version: 35
Is Local: yes
Client Index: 212
Tile Size: 65472
User Name: test2
Host Name: test2-Standard-PC-Q35-ICH9-2009
Server Name: PulseAudio (on PipeWire 1.0.5)
Server Version: 15.0.0
Default Sample Specification: float32le 2ch 48000Hz
Default Channel Map: front-left,front-right
Default Sink: vdi_audio
Default Source: vdi_audio.monitor
active
enabled
152 vdi_audio PipeWire float32le 2ch 48000Hz SUSPENDED
152 vdi_audio.monitor PipeWire float32le 2ch 48000Hz SUSPENDED
181 1000 test2 seat0 tty2 active no -
204 1000 test2 - - active no -
[Desktop Entry]
Type=Application
Name=VDI Sunshine host
Exec=/bin/sh -c "systemctl --user import-environment DISPLAY XAUTHORITY XDG_SESSION_TYPE; systemctl --user start vdi-sunshine.service"
X-GNOME-Autostart-enabled=true
# file: /dev/uinput
# owner: root
# group: input
user::rw-
user:test2:rw-
group::rw-
mask::rw-
other::---

View File

@ -0,0 +1,8 @@
{"event": "key", "keyval": 65477}
{"event": "click", "button": 1, "x": 286.970703125, "y": 111.9871826171875}
{"event": "key", "keyval": 65507}
{"event": "key", "keyval": 65513}
{"event": "key", "keyval": 65505}
{"event": "key", "keyval": 65507}
{"event": "key", "keyval": 65513}
{"event": "key", "keyval": 65505}

View File

@ -0,0 +1,47 @@
# GDM configuration storage
#
# See /usr/share/gdm/gdm.schemas for a list of available options.
[daemon]
WaylandEnable=false
AutomaticLoginEnable=true
AutomaticLogin=test2
DefaultSession=ubuntu-xorg.desktop
# Uncomment the line below to force the login screen to use Xorg
#WaylandEnable=false
# Enabling automatic login
# AutomaticLoginEnable = true
# AutomaticLogin = user1
# Enabling timed login
# TimedLoginEnable = true
# TimedLogin = user1
# TimedLoginDelay = 10
[security]
[xdmcp]
[chooser]
[debug]
# Uncomment the line below to turn on debugging
# More verbose logs
# Additionally lets the X server dump core if it crashes
#Enable=true
[User]
Session = ubuntu-xorg
Icon = /home/test2/.face
SystemAccount = false
XSession = ubuntu-xorg
[InputSource0]
xkb = cn
[InputSource1]
ibus = libpinyin
Backup directory: /var/backups/vdi-linux-20261104-100722/
Backed up files: custom.conf, test2

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

@ -0,0 +1,9 @@
sunshine_name = VDI-Ubuntu
capture = x11
encoder = software
sw_preset = ultrafast
sw_tune = zerolatency
upnp = disabled
origin_web_ui_allowed = pc
hevc_mode = 1
audio_sink = vdi_audio

View File

@ -0,0 +1,15 @@
[Unit]
Description=VDI Linux desktop Sunshine host
PartOf=graphical-session.target
After=graphical-session.target
[Service]
ExecStartPre=/usr/bin/xrandr --output Virtual-1 --mode 1920x1080
ExecStartPre=/bin/sh -c 'pactl list short sinks | grep -qw vdi_audio || pactl load-module module-null-sink sink_name=vdi_audio sink_properties=device.description=VDI-Audio'
ExecStartPre=/usr/bin/pactl set-default-sink vdi_audio
ExecStart=/usr/bin/sunshine /home/test2/.config/sunshine/sunshine.conf
Restart=on-failure
RestartSec=5
[Install]
WantedBy=graphical-session.target

View File

@ -0,0 +1,52 @@
# 麒麟鼠标掉帧继续排查(2026-11-04)
本轮保留的改动是**仅麒麟主机协商 limited 视频范围**,避免旧版 Sunshine 的额外全范围转换。真实桌面和视频仍为2560×1440、H.264、30Mbps,正常卡片仍请求30FPS。同场景45FPS请求的对照中,实际渲染由21.42提高至28.81FPS(约35%);尚未达到Windows约43~44FPS的既有水平,也未证明物理鼠标到屏幕延迟与Windows相同。
## 现状与改动
- Sunshine恢复官方v0.21.0原文件,SHA256为`1c3099bf924db3aee759b3d7c05e012569ef8650c400d1479144398795decc71`。保持X11、ultrafast、zerolatency、原单线程、暂停UKUI合成器,无额外capability。
- 盒子`vdi-moonlight-rkmpp`支持逐主机色彩范围文件:`${XDG_CONFIG_HOME:-$HOME/.config}/vdi-terminal/host-color-range/<host>`,内容只允许`0`(limited)或`1`(full)。仅配置`192.168.1.71`为`0`。显式`COLOR_RANGE_OVERRIDE`环境变量优先,无配置的主机沿用Moonlight默认行为。
- 服务端实际日志从`Color range: [JPEG]`变为`[MPEG]`;客户端解码后仍扩展成正常屏幕RGB,不是将黑白截成灰色。未改客户端二进制、分辨率、码率、Windows/Ubuntu卡片或PVE配置。
- wrapper已部署,语法检查、麒麟/Windows/Ubuntu隔离、显式环境覆盖和非法配置拒绝检查通过,见[配置验证](evidence/kylin-mouse-20261104/wrapper-profile-check.json)。用户明确允许切回麒麟后,已从正常kiosk列表连接,确认父进程为kiosk、2560×1440/30FPS/30Mbps、`COLOR_RANGE_OVERRIDE=0`,实际2K RKMPP/EGL解码和服务端`[MPEG]`日志通过。最终保留麒麟桌面连接,见[正常入口](evidence/kylin-mouse-20261104/final-kiosk-connection.json)及[最终桌面](evidence/kylin-mouse-20261104/final-normal-desktop.png)。
备份:盒子`~/vdi-terminal/backups/kylin-mouse-20261104/`有原wrapper和desktops.json;麒麟同目录有原sunshine.conf。回退删除麒麟色彩范围文件,下次连接恢复原协商;必要时恢复wrapper备份。没有修改其他主机的配置文件。
## 测试证据
局部运动GTK源约60FPS,所有以下有效轮次均核对真实未锁屏测试窗口,并发送F8和24次交替点击。每轮请求2560×1440/45FPS/30Mbps,短时顺序测试;网络延迟和抖动有波动,不作为长期保证。
| 服务端 / 协商 | 实际渲染FPS | 解码ms | 队列ms | 采用情况 |
| --- | ---: | ---: | ---: | --- |
| 原程序 / full |21.42|7.48|0.15|对照|
| 原程序 / limited |28.81|6.90|0.23|保留limited协商|
| 原程序 / limited / 两线程 |25.67|7.09|0.12|已撤销|
| BGRA兼容副本 / limited |27.17|6.85|0.11|已撤销|
| BGRA + full-chroma副本 / limited |25.61|10.38|0.13|已撤销|
| BGRA + bilinear副本 / limited |30.79|7.44|0.13|收益不足以保留二进制维护和色度采样变化,已撤销|
数据及日志索引见[测试汇总](evidence/kylin-mouse-20261104/trial-summary.json)。这轮没有把请求45FPS写成真实达到45FPS。原full与limited静态区域的RGB平均绝对差约0.3~1.3/255,黑白条纹仍包含0和255,小字号与彩色文字已视觉核对;这不是逐像素无损承诺。见[色彩核对](evidence/kylin-mouse-20261104/color-comparison.json)、[limited实际截图](evidence/kylin-mouse-20261104/limitedrange-2k45.png)。
原始进程线程观察显示Sunshine有一个线程接近占满单核,而盒子呈现队列通常约0.1~0.2ms。通过Linux软件CPU-clock采样,旧版静态FFmpeg的逐像素色彩转换出现在热点中;ELF已剥离符号,地址区间只能粗略分类,不能当作精确函数耗时。记录见[采样说明](evidence/kylin-mouse-20261104/sampling-hotspots.json)。
## 未采用的兼容实验
仅对精确SHA256匹配的原文件创建三个独立副本,未覆盖原文件。根据v0.21.0源码与反汇编,两个`sws_getContext`调用的BGR0立即数由121改为BGRA的28;后续分别测试附加`SWS_FULL_CHR_H_INP`或改为`SWS_BILINEAR`。只对独立副本做单文件KYSEC认证。实验服务drop-in已删除,三个副本确认没有进程使用后删除,原版SHA和活动服务已读回确认,见[清理记录](evidence/kylin-mouse-20261104/restored-original.json)、[恢复状态](evidence/kylin-mouse-20261104/restored-host-state.txt)。未保留任何实验二进制作为正常入口。
依据:[Sunshine v0.21.0 video.cpp](https://github.com/LizardByte/Sunshine/blob/v0.21.0/src/video.cpp)、[FFmpeg n6.0转换初始化](https://github.com/FFmpeg/FFmpeg/blob/n6.0/libswscale/x86/swscale.c)、[FFmpeg色彩范围转换](https://github.com/FFmpeg/FFmpeg/blob/n6.0/libswscale/swscale.c)。
## 回显延迟边界及后续
增加了F9翻转画面黑白标记的探针:盒子在发出合成按键前计时,轮询实际解码视频所在X11根窗口,看到标记翻转后结束。该方法包含工具启动、串流往返和轮询,排除物理USB输入和显示面板扫描。
用户授权切回麒麟后,使用同一个未锁屏的动态源、原版服务端、相同2560×1440/30FPS/30Mbps完成顺序对照,每组24次,均无超时:
| 配置 | 实际渲染FPS | 解码ms | 合成输入回显P50 ms | P95 ms |
| --- | ---: | ---: | ---: | ---: |
| full原协商 |23.35|9.54|150.99|186.38|
| limited保留配置 |27.25|6.96|143.57|173.03|
见[完整对照数据](evidence/kylin-mouse-20261104/final-response-comparison.json)。视频更新约提高17%;回显分布重叠、网络延迟也不同,24个样本不足以证明显著或大幅的延迟下降,更不能宣称达到Windows几乎无延迟。该探针也不能替代物理输入到屏幕的测量。
此前full/30FPS的一组24次P50约163ms、P95约190ms,后续超出临时串流期限,盒子随后已连接Windows,完整收尾未完成;保留原始JSON和部分日志用于审计,不混入上述完整对照。临时GTK和临时串流服务已停止,最终使用正常kiosk串流。
麒麟2vCPU的软件捕获/颜色转换/编码链路仍然限制视频更新。若目标是Windows同等的视频响应,需要进一步实现并验证转换并行化/硬件编码;若目标是指针本身不等待视频,则需要实现自动形状同步、显隐、热点和可靠交接的独立光标通道。现有受控原型尚不满足这一要求,不能用普通本地箭头叠加冒充完成。

View File

@ -0,0 +1,65 @@
# 麒麟真实 2K 与响应优化(2026-11-04)
后续鼠标掉帧排查已保留逐主机limited协商,同场景原程序对照约21→29FPS,尚未达到Windows水平,见[继续排查记录](kylin-mouse-20261104.md)。下文为本轮之前的真实2K落地记录。
麒麟已从 1080p 升级为真实 **2560×1440** 桌面与视频,盒子麒麟卡片配置为 **30 FPS、30Mbps、H.264**。分辨率与码率已与 Windows 对齐;实际帧率尚未达到 Windows 的水平。保留暂停 UKUI 桌面合成器的优化,局部运动短测由约 19 FPS 提高至约 23 FPS。物理鼠标到屏幕的端到端延迟未测量。
## 最终部署
| 项目 | 生效配置 |
| --- | --- |
| PVE VM 101 kylin-test | `vga=std,memory=64`,其他字段保持原值 |
| 麒麟 Virtual-1 | 2560×1440,约59.95Hz,自定义模式 `2560x1440_60.00` |
| Sunshine | v0.21.0,X11 捕获,libx264 ultrafast / zerolatency,原单线程策略 |
| 盒子麒麟对象 | `desk-kylin-01`,192.168.1.71 / Desktop,2560×1440 / 30 FPS |
| 客户端 | 原 RKMPP 硬解、DRM PRIME、EGL/GLES、latest-frame 队列、无VSync/无frame-pacing |
| 视频码率 | 30Mbps,沿用现有产品对2K的规则;Windows同为30Mbps |
用户明确授权后,已正常关闭 VM 101 至 stopped,修改显存并重新启动。配置差异检查确认只有 `vga` 变化;未重启 PVE、Windows 或 Ubuntu。麒麟内部 PCI BAR 已识别为64MB。见 [执行记录](evidence/kylin-quality-20261104/vm101-execution.json)、[配置差异](evidence/kylin-quality-20261104/vm101-config-change.json)。
服务 `~/.config/systemd/user/vdi-sunshine.service` 增加启动前创建、添加和切换2K模式的步骤,时序为:
```text
241.50 2560 2608 2640 2720 1440 1443 1448 1481 +hsync -vsync
```
已通过“切回1080p、删除自定义模式、启动服务”的实测,确认能重新创建并进入2K。新服务配置尚未通过第二次整机重启验收。麒麟 test1 图形会话在本次开机后存在;未改变其登录或锁屏策略。
服务启动后经 `org.ukui.KWin /Compositor` 暂停桌面合成,停止服务时恢复合成;读回最终 `active=false`。这针对当前UKUI/Xorg现场环境,调用失败不阻止 Sunshine 本体启动。原 `kwinrc` 未修改。见 [最终服务](evidence/kylin-quality-20261104/final-sunshine.service)、[最终配置](evidence/kylin-quality-20261104/final-sunshine.conf)。
## 实际验证
临时 GTK 窗口展示小字号文字、单像素条纹、彩色文字与局部运动块;有效轮次的源绘制约59FPS。只在确认 Moonlight 窗口焦点后注入输入。测试后窗口已关闭。
| 有效2K动态桌面轮次 | 实际渲染 FPS | 解码 ms | 帧队列 ms | 呈现 ms |
| --- | ---: | ---: | ---: | ---: |
| X11 / 单线程 / 合成开启 | 18.89 | 14.13 | 0.09 | 2.12 |
| X11 / 两线程 / 合成开启 | 18.85 | 8.96 | 0.29 | 2.12 |
| X11 / 单线程 / 合成暂停(采用) | 22.97 | 9.04 | 0.12 | 2.11 |
这是约20秒保持加连接与输入阶段的短测,网络延迟和抖动并不完全相同,不能视为长期性能保证。客户端帧队列很短,未证明物理输入延迟下降;2K实际23FPS也不能宣传为稳定30或45FPS。
- 实际桌面与视频尺寸均为2560×1440,已核对盒子截屏,文字不再由1080p放大。见 [2K动态图](evidence/kylin-quality-20261104/2k30-desktop-no-compositor-valid.png)。
- 当前有效测试进程记录中,48次跨屏交替点击全部落在预期坐标(误差不超过1px),收到F8。见 [输入结果](evidence/kylin-quality-20261104/2k-input-result.json) 和 [原始事件](evidence/kylin-quality-20261104/2k-valid-source-and-input.log)。
- 从正常 kiosk 麒麟卡片连接、退出回列表、再次连接,确认2K参数与RKMPP/EGL路径;最终保持正常麒麟桌面连接供用户体验。见 [重连结果](evidence/kylin-quality-20261104/final-kiosk-check.json)、[正常入口日志](evidence/kylin-quality-20261104/final-kiosk-client.log)、[最终桌面](evidence/kylin-quality-20261104/final-desktop.png)。
- 只修改盒子的麒麟对象,Windows、Ubuntu对象经结构比较保持原值;没有修改产品源码、客户端二进制和全局渲染配置。
部分早期2K轮次在长时间暂停后采到了锁屏画面,已经从有效性能结论中排除:`2k30-ultrafast-thread1`、`2k30-damage-thread1`、`2k30-damage-thread2`。这些原始文件保留用于审计。当前有效轮次均为文件名含 `desktop-...-valid` 的三轮,截图与输入记录已核对。测试源码也改为只重绘运动区域,不能直接将新旧不同负载的数字相减作为提升幅度。
曾探测KMS捕获,但此虚拟显卡上未初始化成功,已恢复X11;最终 Sunshine 实体二进制与入口的 `getcap` 均为空,未保留该探测使用的额外 capability。两线程、较慢压缩预设未带来适合本机的收益,均未保留。
## 原因、边界与回退
原默认 Standard VGA 只有16MB。Linux5.15 DRM VRAM helper限制单帧缓冲不超显存一半,2560×1440×4字节的单帧约14.06MiB,双缓冲约28.13MiB,因此原2K切换失败;增加到64MB后解除限制。参考 [内核实现](https://github.com/torvalds/linux/blob/v5.15/drivers/gpu/drm/drm_gem_vram_helper.c)。
麒麟目前只有2个虚拟CPU且使用软件编码。这次完成了真实2K、码率对齐、合成开销优化和基础输入/重连验证;45FPS、长时间高动态负载、物理输入延迟、音质、生产多用户隔离仍未验收。
备份位置:
- 麒麟 `/home/test1/vdi-terminal/backups/kylin-quality-20261104/`:原 sunshine.conf、vdi-sunshine.service、kwinrc、xrandr-before.txt。
- 盒子 `/home/armbian/vdi-terminal/backups/kylin-quality-20261104/desktops.json`。
- PVE [原配置快照](evidence/kylin-quality-20261104/vm101-config-before.json),原 `vga` 字段未设置。
回退先正常退出串流;恢复盒子备份(若其他桌面后续有修改,只恢复麒麟对象);在麒麟恢复原 Sunshine 配置与服务,`systemctl --user daemon-reload` 后重启服务。当前服务停止会恢复合成;原服务将显示器设回1080p。64MB显存可保留;如需完全恢复原PVE设置,删除显式 `vga` 字段并在维护窗口正常关机再启动。
编码参数含义参考 [Sunshine v0.21.0 官方说明](https://docs.lizardbyte.dev/projects/sunshine/v0.21.0/about/advanced_usage.html#min-threads)。

View File

@ -0,0 +1,94 @@
# Ubuntu / 麒麟云桌面接入记录(2026-11-04)
> 后续更新:麒麟已升级真实2560×1440/30FPS配置与30Mbps,动态短测约23FPS,见[麒麟2K调优记录](kylin-quality-20261104.md)。以下1080p参数属于初次接入记录。
两台 Linux 已接入现场盒子的正常桌面选择流程。实际验证了连接、画面、键盘、鼠标、退出和再次连接;最终回到桌面列表。Windows 也完成了原配置连接回归。
## 现场配置
| 桌面 | 地址 / 用户 | 系统与服务端 | 串流配置 |
| --- | --- | --- | --- |
| Ubuntu | 192.168.1.72 / test2 | Ubuntu 24.04.4;Sunshine 2026.914.233613 | Desktop,1920×1080,30 FPS,H.264 软件编码 |
| 麒麟 | 192.168.1.71 / test1 | 银河麒麟 V10 SP1;Sunshine v0.21.0 Ubuntu20.04 构建 | Desktop,1920×1080,30 FPS,H.264 软件编码 |
| Windows | 192.168.1.70 | 原 Sunshine 服务端 | 原 Desktop,2560×1440,45 FPS 配置 |
盒子为 `armbian@192.168.1.188`。原 Linux 桌面上的 Moonlight 是客户端,用于连接其他主机;本次补齐 Linux 的 Sunshine 服务端,让盒子 Moonlight 可以连接它们。未移除原 Moonlight。
盒子使用原 RKMPP 私有客户端与 EGL/GLES 呈现路径;本次没有修改产品源码,也没有把现场 IP 写入源码默认值。
## Linux 服务
两台均使用登录用户的 systemd 服务与 XDG 自启动:
- `~/.config/systemd/user/vdi-sunshine.service`:已 enabled,现场为 active。
- `~/.config/autostart/vdi-sunshine.desktop`:图形登录后导入 DISPLAY、XAUTHORITY 等会话环境并启动服务。
- `~/.config/sunshine/sunshine.conf`:X11 捕获、software 编码、ultrafast、zerolatency、禁用 UPnP、Web 管理来源限制为本机。
- `~/.config/sunshine/apps.json`:发布 `Desktop`。
- `~/.config/sunshine/vdi-admin.json`:随机生成的各自主机管理凭据,权限 600;未复制进仓库。
服务启动前把 `Virtual-1` 设置为 1920×1080,创建或复用 `vdi_audio` 虚拟音频 sink,并设为默认。Sunshine 配置使用 `audio_sink = vdi_audio`,采集端自动选中 `vdi_audio.monitor`。两台最后一轮串流均未出现采音初始化失败;Ubuntu 日志明确显示 Opus 初始化,盒子日志显示双声道音频流启动。实际扬声器听感尚未验收。
### Ubuntu 登录配置
用户明确授权 test2 自动登录并使用 Xorg,修改前已备份:
- `/etc/gdm3/custom.conf`:`WaylandEnable=false`、`AutomaticLoginEnable=true`、`AutomaticLogin=test2`、`DefaultSession=ubuntu-xorg.desktop`。
- `/var/lib/AccountsService/users/test2`:`Session` / `XSession` 为 `ubuntu-xorg`。
- 备份目录 `/var/backups/vdi-linux-20261104-100722/`,文件 `custom.conf`、`test2`。
重启 GDM 后已进入 test2 的 Xorg 桌面,`DISPLAY=:0`、`XAUTHORITY=/run/user/1000/gdm/Xauthority`。未重启虚拟机;整机重启后的自动恢复仍待验证。另补装 `pulseaudio-utils`,未执行系统整体升级。
### 麒麟兼容部署
现场 glibc 2.31,选择可兼容的官方 v0.21.0 Ubuntu20.04 构建。麒麟安全策略拒绝未签名 DEB 后,采用解包部署:
- root 拥有的 `/opt/vdi-sunshine-0.21.0/`。
- `/usr/share/sunshine` 指向其资源目录。
- 对具体 Sunshine 二进制及当次临时安装脚本做 `kysec_set -n exectl -v verified` 单文件认证;未全局关闭 KYSEC。
- `/etc/udev/rules.d/85-vdi-sunshine.rules` 配置 uinput,现场 ACL 已赋予 test1 读写权限。
麒麟沿用已有 test1 图形会话;当时锁屏已临时解锁,未关闭长期锁屏,也未配置新的自动登录。后续若回到登录界面或锁屏,需要按原策略登录或解锁。
## 盒子配置与配对
只更新 `~/.config/vdi-terminal/desktops.json` 中 Ubuntu / 麒麟对象,Windows 对象保留原值。原配置备份:
`/home/armbian/vdi-terminal/backups/linux-desktops-20261104-101507/desktops.json`
两台均完成盒子原 Moonlight 配对;Sunshine 服务重启后原配对仍有效。只在无串流时重启 kiosk 刷新列表。正常客户端路径:
`/home/armbian/vdi-build/rkmpp-experiment/build/moonlight/app/moonlight`
退出串流回列表使用 `Ctrl+Alt+Shift+Q`。
## 验证结果与证据
证据目录:[linux-desktops-20261104](evidence/linux-desktops-20261104/)。
| 项目 | 结果 | 证据 |
| --- | --- | --- |
| 三张卡可连接 | 通过 | [列表截图](evidence/linux-desktops-20261104/desktop-list.png) |
| Ubuntu 实际画面与输入 | F8(65477)及鼠标左键事件到达虚拟机临时 GTK 窗口 | [截图](evidence/linux-desktops-20261104/ubuntu-stream.png)、[事件](evidence/linux-desktops-20261104/ubuntu-input.jsonl) |
| 麒麟实际画面与输入 | F8(65477)及鼠标左键事件到达虚拟机临时 GTK 窗口 | [截图](evidence/linux-desktops-20261104/kylin-stream.png)、[事件](evidence/linux-desktops-20261104/kylin-input.jsonl) |
| 最后 Ubuntu / 麒麟 / Windows 依次连接再返回列表 | 三项均硬解就绪且保持运行,最后正常退出 | [结果 JSON](evidence/linux-desktops-20261104/final-stream-check.json) |
| Linux 实际视频尺寸与渲染路径 | h264_rkmpp、drm_prime、EGL/GLES、1920×1080 | [Ubuntu 日志](evidence/linux-desktops-20261104/ubuntu-client-final.log)、[麒麟日志](evidence/linux-desktops-20261104/kylin-client-final.log) |
| Windows 原配置回归 | 2560×1440 硬解就绪,正常退出 | [客户端日志](evidence/linux-desktops-20261104/windows-client-final.log) |
最后约十秒有效视频样本中 Ubuntu 渲染 29.59 FPS、麒麟 29.35 FPS,仅用于确认短时链路可运行,不代表长时间性能验收。Windows 这轮短测渲染 35.76 FPS,不据此重新定义原 45 FPS 配置的性能结论。
`*-sunshine.service`、`*-sunshine.conf` 是最终配置快照,`*-sunshine-final.log` 是最后串流服务端日志,`*-final-state.txt` 记录服务、音频和 uinput 状态,`ubuntu-login-config.txt` 记录登录配置。截图是盒子 2560×1440 输出,Linux 视频本身为 1920×1080。
Ubuntu 日志仍有 CAP_SYS_ADMIN 探测失败及显示信息回退提示,但实际选中了 X11 并成功捕获 1080p;本次没有为消除这些提示授予额外高权限。麒麟退出时有 GLib Source ID 警告,服务仍为 active 且可再次连接。
最初 Xvfb + 软件解码独立探测曾出现控制断开,同期原 Windows 会话也断开,原因未确定;后续以上正常硬解入口回归均通过。安装 Xvfb 时 apt 同时升级了盒子的 `libpciaccess0`、`xserver-common`、`xserver-xorg-core`;未重启 Xorg。临时 Xvfb 与输入测试服务已停止。
当前验收覆盖基础画面与输入及短时重连;Linux 2K、GPU 编码、长期稳定性、重启恢复、锁屏策略、音质与生产多用户隔离尚未验收。
## 回退与后续维护
1. 盒子恢复上述备份的 `desktops.json`,在无活动会话时刷新 kiosk,可撤回本轮卡片配置。
2. 在对应 Linux 图形用户下执行 `systemctl --user disable --now vdi-sunshine.service`,将 `~/.config/autostart/vdi-sunshine.desktop` 移出自启动目录,可停止本次 Sunshine 自动启动。保留 `~/.config/sunshine/` 可保留配对与配置。
3. Ubuntu 如需恢复原登录方式,以 sudo 将备份 `custom.conf` 放回 `/etc/gdm3/custom.conf`,备份 `test2` 放回 `/var/lib/AccountsService/users/test2`,保留原属主和权限;在维护窗口重启 GDM(会终止图形会话)。
4. 麒麟如需彻底卸载,先停服务,再核对 `/usr/share/sunshine` 链接目标和 uinput 规则是否仍被其他应用使用,最后移除本次 `/opt/vdi-sunshine-0.21.0` 部署;不要直接覆盖发行版同名文件或全局修改 KYSEC。
排查服务可先运行 `systemctl --user status vdi-sunshine.service` 和 `journalctl --user -u vdi-sunshine.service`。麒麟版本较旧是当前兼容方案,后续升级需要重新评估系统库兼容性与安全维护。

View File

@ -0,0 +1,86 @@
# 任意 U 盘透传实现(2026-11-04)
在已验证的真实 USB 透传链路上,将盒子端策略从固定 VID/PID/序列号扩展为
“任意纯 USB Mass Storage 设备”。键盘、鼠标、Hub 和显示设备仍由盒子本地使用。
## 行为
- 一个盒子、一个 VM 同时只导出一个存储设备;多个 U 盘同时插入时按盒子扫描顺序使用,
当前设备拔出后再接入下一个。
- 设备必须是纯存储设备,所有 USB interface class 都必须为 `08`;键鼠、Hub 和复合设备拒绝。
- 盒子检查分区是否本地挂载、是否有 holder 或作为 swap;发现本地占用时等待,不强制卸载。
- QEMU 使用 `0x08:-1:-1:-1:1` 过滤器,允许任意 USB Mass Storage,仍拒绝其他 USB 类别。
- PVE QMP watcher 在 VM/PVE 重启后重新建立运行时 `usb-redir` 设备。
## 部署入口
```sh
python tools/deploy-usb-device-poc.py \
--pve PVE_HOST --box armbian@BOX_HOST --vm 100 --any-storage
```
部署前应在 Windows 中安全弹出当前 U 盘。部署器会安装盒子监控服务、USB 隧道和
`vdi-usb-qmp-100.service`;不会修改 VM 固定配置、GPU、显示或键鼠输入链路。
旧的精确设备策略仍可用:
```sh
python tools/deploy-usb-device-poc.py \
--pve PVE_HOST --box armbian@BOX_HOST --vm 100 \
--vendor 0951 --product 1666 --serial SERIAL
```
## 验收
至少用三只不同品牌或不同 VID/PID 的 U 盘测试:插入后 Windows 出现真实
USB Mass Storage 和盘符,读写大文件后校验 SHA-256;再测试安全弹出、拔插、
两个 U 盘同时插入,以及 VM/PVE 重启后的自动恢复。当前 Rust 单元测试 10/10、
Python QMP 测试 4/4 通过。已现场验证另一只 SanDisk 0781:5591 在 Windows
识别为 USBSTOR,F: NTFS 数据分区和 G: FAT32 UQITONG_ESP 分区均 Healthy。
多盘吞吐和 VM/PVE 重启后的恢复仍待验收;QMP watcher 已实现,但尚未现场安装。
## 插入体验优化
2026-11-04 已将当前 Windows 用户 test 的 StorageOnArrival 从 MSOpenFolder
改为 MSPromptEachTime,并保留自动播放开启。双分区装机盘仍显示两个卷,
不会再按“打开文件夹”这个默认操作自动打开两个窗口;提示数量和通知外观由 Windows 决定。
脚本 `tools/set-usb-experience.ps1` 可重复 Apply,并支持 Restore;仅修改三项
自动播放首选项,使用首次运行保存的原值回退。Windows 已执行 Apply 并回读成功。
备份路径:`C:\ProgramData\VDI\usb-experience-20261104\preferences.clixml`。
盒子监控程序使用已处理的 USB udev 事件即时唤醒,替代原来的每 2 秒轮询;
500ms 兜底扫描负责启动时已有设备和事件监听不可用的情况。活动会话直接核对
已导出的 sysfs 设备身份,不再因另一个 USB 设备扫描失败而误断开。
快速插盘时须等 `bNumInterfaces` 声明的所有接口出现后才做存储类型判断,
防止接口尚未枚举完整的存储/HID 复合设备被误接入。
日志包含 `usb_time_ms`、`local_check_ms`、`since_latest_usb_event_ms`;最后一项
仅为最近事件至导出调用的时间,不代表从物理插入至 Windows 完全可用的总时间。
盒子已离线测试并编译、部署(更新时无 U 盘连接),服务 active,udevadm 子进程正常。
旧二进制备份在 `/home/armbian/vdi-terminal/backups/usb-events-20261104-110747/`。
未重启 VM、Moonlight、Sunshine、Explorer,未修改启动分区和系统原生安全删除行为。
随后实测事件至导出调用为 5~8ms(仅盒子内部时间)。
继续排查发现发行版 usbredirect 0.13.0 的连接回调无条件订阅 G_IO_OUT,
现场导出进程曾占约 96% 的一个 CPU 核心。官方 0.15.0 的 update_watch() 按
usbredirhost_has_data_to_write() 动态监听;已从官方发布包编译私有版本:
- 源码:https://www.spice-space.org/download/usbredir/usbredir-0.15.0.tar.xz
- 源码 SHA256:`6dc2a380277688a068191245dac2ab7063a552999d8ac3ad8e841c10ff050961`
- 私有程序:`/usr/local/libexec/vdi-usbredirect`
- 二进制 SHA256:`58b58b9d9ab017d3221e44f83c0794ae2977ef9e020fcb8df959eef997ae8e4a`
- 构建脚本:`tools/build-usbredirect-private.sh`;头文件仅下载并解包,没有安装或升级系统 GLib/libusb。
- 盒子配置:`/etc/vdi-usb-monitor/device.env` 新增
`VDI_USBREDIRECT_BINARY=/usr/local/libexec/vdi-usbredirect`。
- 切换前备份:`/home/armbian/vdi-terminal/backups/usb-exporter-20261104-112031/`。
运行时 CPU 实测降至约 1%。官方 filter 29 项、serializer 1 项测试全部通过;
Rust 10 项测试通过。Windows 自动播放设置已回读验证,PowerShell 语法检查通过。
用户第一次复测仍反馈菜单慢,后续保持连接并再测后明确反馈“现在已经很快了”。
已确认不再自动打开文件夹;右下角提示和弹出菜单速度通过本次用户现场验收,
但没有测量从物理插入至菜单可用的完整耗时,也不代表长时间稳定性验收完成。
回退时先在 Windows 安全弹出并物理拔下 U 盘,再停止盒子 USB 服务,
从上述备份恢复二进制和 device.env,启动服务即可。Windows 自动播放可用
`C:\ProgramData\VDI\usb-experience-20261104\set-usb-experience.ps1 -Action Restore` 回退。

View File

@ -17,4 +17,22 @@ export HEVC_DECODER_HINT=hevc_rkmpp
# Only affects unpaced rendering in the patched client. Leave the last presented
# frame and decoder references intact; replace only waiting decoded frames.
export VDI_LATEST_FRAME="${VDI_LATEST_FRAME:-1}"
# A host can negotiate limited range to avoid Sunshine's software full-range
# conversion. The decoder still expands the negotiated range for display.
# Keep this per host: hardware-encoded desktops need no override.
if [ "${1:-}" = stream ] && [ -z "${COLOR_RANGE_OVERRIDE+x}" ]; then
case "${2:-}" in
''|*[!a-zA-Z0-9._-]*) ;;
*)
range_file="${XDG_CONFIG_HOME:-$HOME/.config}/vdi-terminal/host-color-range/$2"
if [ -f "$range_file" ]; then
color_range=$(cat "$range_file")
case "$color_range" in
0|1) export COLOR_RANGE_OVERRIDE="$color_range" ;;
*) echo 'Invalid host color range (expected 0 or 1)' >&2; exit 78 ;;
esac
fi
;;
esac
fi
exec "$client" "$@"

View File

@ -1,12 +1,13 @@
# USB 设备透传 POC
# USB 设备透传
将盒子上指定的 U 盘经 usbredir + SSH + QEMU 接入 Windows,枚举真实 VID/PID/序列号和
USBSTOR,使用 Windows 自带驱动。这是 Moonlight 显示会话之外的设备通道,不是网络文件盘。
当前只支持明确登记的一个纯 USB 存储设备、一个盒子和一个 VM;盒子不调用 Proxmox API。
将盒子上的纯 USB 存储设备经 usbredir + SSH + QEMU 接入 Windows,枚举真实
VID/PID/序列号和 USBSTOR,使用 Windows 自带驱动。这是 Moonlight 显示会话之外的
设备通道,不是网络文件盘。默认策略允许任意一个纯 USB 存储设备;一个盒子和一个
VM 同时只导出一个设备,键盘、鼠标、Hub 和显示设备仍由盒子本地使用。
- `src/main.rs`:Rust 标准库实现设备扫描、精确匹配、本地挂载/占用保护、导出及拔插恢复。
- `etc/vdi-usb-monitor.service`:盒子服务,读取 root 管理的单设备白名单。
- `../../tools/deploy-usb-device-poc.py`:管理员部署和受限 SSH 隧道。
- `src/main.rs`:Rust 标准库实现设备扫描、存储类筛选、本地挂载/占用保护、导出及拔插恢复。
- `etc/vdi-usb-monitor.service`:盒子服务,默认使用 `--any-storage`。
- `../../tools/deploy-usb-device-poc.py`:管理员部署、受限 SSH 隧道和 QMP 自动恢复服务。
- `../../tools/usb-device-qmp.py`:在 PVE 节点操作独立 QMP 热插拔端点。
- `../../tools/test-usb-device-poc.ps1`:Windows USB 身份和文件回读校验。
@ -17,12 +18,29 @@ cargo test --offline
cargo build --release --offline
target/release/vdi-usb-monitor list
sudo target/release/vdi-usb-monitor serve \
--vendor 0951 --product 1666 --serial YOUR_USB_SERIAL --listen 127.0.0.1:40001
--any-storage --listen 127.0.0.1:40001
```
`list` 输出 JSON。`serve` 必须指定非空序列号,只能监听 IPv4 回环地址;
拒绝键鼠、Hub、包含非存储接口的复合设备以及本地尚在使用的磁盘。
`list` 输出 JSON。`serve --any-storage` 只接受纯 USB Mass Storage 类设备,
只能监听 IPv4 回环地址;拒绝键鼠、Hub、包含非存储接口的复合设备以及本地尚在使用
的磁盘。仍可使用 `--vendor/--product/--serial` 保留旧的精确设备策略。
本地已挂载时不会强制卸载,应先关闭本地文件操作并正常卸载。
插拔检测使用 `udevadm monitor --udev --property --subsystem-match=usb` 事件,
空闲时每 500ms 扫描兜底,活动设备每秒核对当前实例;其他设备的 sysfs 扫描错误
不会再断开正在导出的 U 盘。日志记录 Unix 毫秒时间、占用检查耗时和最近 USB 事件
至启动导出的间隔;后者不包含物理枚举、网络连接或 Windows 挂载时间。
Windows 端 `tools/set-usb-experience.ps1` 支持 Apply/Restore,为当前登录用户将
存储设备自动播放设置为“每次询问”,原设置保存在 ProgramData。不会改 U 盘分区、
隐藏启动分区、关闭通知、重启 Explorer 或更改其他任务栏图标。
发行版 usbredirect 0.13 的持续 G_IO_OUT 监听可能造成 CPU 空转。盒子当前使用
官方 0.15 的私有构建,保留 `/usr/bin/usbredirect`。可使用
`tools/build-usbredirect-private.sh` 构建后,将程序安装到
`/usr/local/libexec/vdi-usbredirect`,再在 root 管理的 device.env 中配置
`VDI_USBREDIRECT_BINARY=/usr/local/libexec/vdi-usbredirect`。
未配置时监控程序仍使用 `/usr/bin/usbredirect`;更新运行程序前须安全弹出并拔下 U 盘。
完整现场、部署、限制和回退见
[`docs/usb-device-poc-20261103.md`](../../docs/usb-device-poc-20261103.md)。

View File

@ -1,11 +1,11 @@
[Unit]
Description=VDI approved USB storage redirection (device POC)
Description=VDI USB Mass Storage redirection
After=network.target systemd-udevd.service
[Service]
Type=simple
EnvironmentFile=/etc/vdi-usb-monitor/device.env
ExecStart=/usr/local/libexec/vdi-usb-monitor serve --vendor ${VDI_USB_VENDOR} --product ${VDI_USB_PRODUCT} --serial ${VDI_USB_SERIAL} --listen 127.0.0.1:40001
ExecStart=/usr/local/libexec/vdi-usb-monitor serve --any-storage --listen 127.0.0.1:40001
Restart=on-failure
RestartSec=3
KillMode=control-group

View File

@ -1,6 +1,63 @@
//! Device-level USB POC. Never calls the hypervisor API or exports HID devices.
use std::{collections::BTreeSet, env, fs, net::SocketAddr, path::{Path, PathBuf},
process::{self, Command}, thread, time::Duration};
//! Device-level USB redirection. Never calls the hypervisor API or exports HID devices.
use std::{collections::BTreeSet, env, fs, io::{BufRead, BufReader}, net::SocketAddr,
path::{Path, PathBuf}, process::{self, Child, Command, Stdio},
sync::mpsc::{self, Receiver}, thread, time::{Duration, Instant, SystemTime, UNIX_EPOCH}};
// 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.
struct UsbEvents {
child: Option<Child>,
receiver: Receiver<Instant>,
last_event: Option<Instant>,
}
impl UsbEvents {
fn start() -> Self {
let (sender, receiver) = mpsc::sync_channel(64);
let child = Command::new("/usr/bin/stdbuf")
.args(["-oL", "/usr/bin/udevadm", "monitor", "--udev", "--property", "--subsystem-match=usb"])
.stdout(Stdio::piped()).stderr(Stdio::null()).spawn();
let child = match child {
Ok(mut child) => {
let stdout = child.stdout.take().unwrap();
thread::spawn(move || {
for line in BufReader::new(stdout).lines() {
match line {
Ok(line) if line.starts_with("ACTION=") => { let _ = sender.try_send(Instant::now()); }
Ok(_) => {},
Err(_) => break,
}
}
});
Some(child)
}
Err(err) => { eprintln!("USB event monitor unavailable: {err}; using fallback scan"); None }
};
Self { child, receiver, last_event: None }
}
fn wait(&mut self, timeout: Duration) {
match self.receiver.recv_timeout(timeout) {
Ok(event) => {
self.last_event = Some(event);
while let Ok(event) = self.receiver.try_recv() { self.last_event = Some(event); }
}
Err(mpsc::RecvTimeoutError::Disconnected) => thread::sleep(timeout),
Err(mpsc::RecvTimeoutError::Timeout) => {},
}
}
}
impl Drop for UsbEvents {
fn drop(&mut self) {
if let Some(child) = self.child.as_mut() { let _ = child.kill(); let _ = child.wait(); }
}
}
fn log_event(message: &str) {
let ms = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis();
eprintln!("usb_time_ms={ms} {message}");
}
#[derive(Debug, Clone)]
struct Device {
@ -38,6 +95,10 @@ fn scan(root: &Path) -> Result<Vec<Device>, String> {
let mut classes: Vec<_> = entries.iter().filter(|e|
e.file_name().to_string_lossy().starts_with(&prefix))
.map(|e| read(&e.path(), "bInterfaceClass")).collect();
// Early hotplug events can arrive before every interface is visible.
// Do not mistake the first storage interface of a composite for a pure disk.
let expected = read(&p, "bNumInterfaces").parse::<usize>().unwrap_or(0);
if expected == 0 || classes.len() != expected { continue; }
classes.sort();
devices.push(Device {
path: p.canonicalize().map_err(|e| e.to_string())?,
@ -98,38 +159,95 @@ fn hex_id(s: &str) -> bool {
}
#[derive(Debug)]
struct Policy { vendor: String, product: String, serial: String, listen: SocketAddr }
enum MatchPolicy {
AnyStorage,
Exact { vendor: String, product: String, serial: String },
}
#[derive(Debug)]
struct Policy { matcher: MatchPolicy, listen: SocketAddr }
fn parse_policy(args: &[String]) -> Result<Policy, String> {
if args.len() != 8 { return Err("serve requires --vendor XXXX --product XXXX --serial SERIAL --listen 127.0.0.1:PORT".into()); }
let mut values = std::collections::BTreeMap::new();
for pair in args.chunks(2) {
if !["--vendor", "--product", "--serial", "--listen"].contains(&pair[0].as_str())
|| values.insert(pair[0].as_str(), pair[1].as_str()).is_some() {
return Err("unknown or duplicate policy option".into());
let mut any_storage = false;
let mut vendor = None;
let mut product = None;
let mut serial = None;
let mut listen = None;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--any-storage" => {
if any_storage { return Err("duplicate --any-storage".into()); }
any_storage = true;
i += 1;
}
"--vendor" | "--product" | "--serial" | "--listen" => {
if i + 1 >= args.len() { return Err("missing value for policy option".into()); }
let value = args[i + 1].clone();
let target = match args[i].as_str() {
"--vendor" => &mut vendor,
"--product" => &mut product,
"--serial" => &mut serial,
_ => &mut listen,
};
if target.replace(value).is_some() { return Err("duplicate policy option".into()); }
i += 2;
}
_ => return Err("unknown policy option".into()),
}
}
let vendor = values["--vendor"].to_ascii_lowercase();
let product = values["--product"].to_ascii_lowercase();
let serial = values["--serial"].to_string();
let listen: SocketAddr = values["--listen"].parse().map_err(|_| "invalid listen address")?;
if !hex_id(&vendor) || !hex_id(&product) || serial.is_empty()
|| serial.chars().any(char::is_control) || !listen.ip().is_loopback()
|| !listen.is_ipv4() || listen.port() == 0 {
return Err("exact VID/PID/serial and an IPv4 loopback listener are required".into());
let listen: SocketAddr = listen.ok_or("--listen is required")?.parse()
.map_err(|_| "invalid listen address")?;
if !listen.ip().is_loopback() || !listen.is_ipv4() || listen.port() == 0 {
return Err("an IPv4 loopback listener with a nonzero port is required".into());
}
let matcher = if any_storage {
if vendor.is_some() || product.is_some() || serial.is_some() {
return Err("--any-storage cannot be combined with an exact device identity".into());
}
MatchPolicy::AnyStorage
} else {
let vendor = vendor.ok_or("exact mode requires --vendor")?.to_ascii_lowercase();
let product = product.ok_or("exact mode requires --product")?.to_ascii_lowercase();
let serial = serial.ok_or("exact mode requires --serial")?;
if !hex_id(&vendor) || !hex_id(&product) || serial.is_empty()
|| serial.chars().any(char::is_control) {
return Err("exact mode requires valid VID/PID and a nonempty serial".into());
}
MatchPolicy::Exact { vendor, product, serial }
};
Ok(Policy { matcher, listen })
}
fn matches_policy(device: &Device, p: &Policy) -> bool {
match &p.matcher {
MatchPolicy::AnyStorage => true,
MatchPolicy::Exact { vendor, product, serial } =>
device.vendor == vendor.as_str() && device.product == product.as_str()
&& device.serial == serial.as_str(),
}
Ok(Policy { vendor, product, serial, listen })
}
fn select<'a>(devices: &'a [Device], p: &Policy) -> Result<&'a Device, String> {
let found: Vec<_> = devices.iter().filter(|d|
d.vendor == p.vendor && d.product == p.product && d.serial == p.serial).collect();
if found.len() != 1 { return Err(format!("waiting for exactly one approved device; found {}", found.len())); }
let d = found[0];
if !storage_only(&d.device_class, &d.classes) || d.bus == 0 || d.address == 0 {
return Err("not a pure USB mass-storage device; refusing export".into());
matches_policy(d, p) && storage_only(&d.device_class, &d.classes)
&& d.bus != 0 && d.address != 0).collect();
match &p.matcher {
MatchPolicy::Exact { .. } if found.len() != 1 =>
Err(format!("waiting for exactly one approved device; found {}", found.len())),
_ => found.into_iter().next().ok_or_else(||
"waiting for a pure USB mass-storage device".into()),
}
Ok(d)
}
fn still_present(device: &Device) -> bool {
// An unrelated USB device can vanish while a full sysfs scan is running.
// Inspect only the exported instance so that does not disconnect this disk.
read(&device.path, "busnum").parse::<u16>().ok() == Some(device.bus)
&& read(&device.path, "devnum").parse::<u16>().ok() == Some(device.address)
&& read(&device.path, "idVendor") == device.vendor
&& read(&device.path, "idProduct") == device.product
&& read(&device.path, "serial") == device.serial
}
fn run() -> Result<(), String> {
@ -144,40 +262,47 @@ fn run() -> Result<(), String> {
return Ok(());
}
if args.first().map(String::as_str) != Some("serve") {
return Err("usage: vdi-usb-monitor list | serve --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".into());
}
let policy = parse_policy(&args[1..])?;
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 mut events = UsbEvents::start();
let mut last = String::new();
loop {
let result: Result<(), String> = scan(Path::new("/sys/bus/usb/devices")).and_then(|devices| {
let device = select(&devices, &policy)?;
let checked_at = Instant::now();
check_local_use(device)?;
// Bus/address selects one physical instance, never an arbitrary VID/PID match.
let selector = format!("{}-{}", device.bus, device.address);
eprintln!("exporting approved storage {} at {}", device.bus_id, policy.listen);
let mut child = Command::new("/usr/bin/usbredirect")
log_event(&format!("exporting storage {} {:}:{:} at {}; local_check_ms={} since_latest_usb_event_ms={}",
device.bus_id, device.vendor, device.product, policy.listen,
checked_at.elapsed().as_millis(),
events.last_event.map(|time| time.elapsed().as_millis().to_string()).unwrap_or_else(|| "startup".into())));
let mut child = Command::new(&redirect_binary)
.args(["--device", &selector, "--as", &policy.listen.to_string(), "--keepalive"])
.spawn().map_err(|e| e.to_string())?;
loop {
if let Some(status) = child.try_wait().map_err(|e| e.to_string())? {
return Err(format!("export session ended: {status}; waiting before retry"));
}
thread::sleep(Duration::from_secs(1));
let present = scan(Path::new("/sys/bus/usb/devices")).ok().map(|current| {
select(&current, &policy).map(|d| d.bus == device.bus
&& d.address == device.address && d.bus_id == device.bus_id).unwrap_or(false)
}).unwrap_or(false);
if !present {
events.wait(Duration::from_secs(1));
if !still_present(device) {
let _ = child.kill();
let _ = child.wait();
log_event(&format!("storage {} disconnected", device.bus_id));
return Err("approved USB device disconnected; waiting for reinsertion".into());
}
}
});
if let Err(message) = result {
if message != last { eprintln!("{message}"); last = message; }
if message != last { log_event(&message); last = message; }
}
thread::sleep(Duration::from_secs(2));
events.wait(Duration::from_millis(500));
}
}
@ -206,6 +331,14 @@ mod tests {
assert!(parse_policy(&policy(address)).is_err());
}
}
#[test]
fn any_storage_policy_accepts_loopback_listener() {
let p = parse_policy(&["--any-storage".into(), "--listen".into(), "127.0.0.1:40001".into()]).unwrap();
assert!(matches!(p.matcher, MatchPolicy::AnyStorage));
assert!(parse_policy(&["--any-storage".into(), "--vendor".into(), "0951".into(),
"--listen".into(), "127.0.0.1:40001".into()]).is_err());
}
#[test]
fn mounted_partition_blocks_parent_disk_export() {
let numbers = BTreeSet::from(["8:0".into(), "8:1".into()]);
@ -226,6 +359,67 @@ mod tests {
assert!(select(&[d.clone()], &p).is_ok());
assert!(select(&[d.clone(), d], &p).is_err());
}
#[test]
fn any_storage_selects_storage_and_rejects_hid() {
let p = parse_policy(&["--any-storage".into(), "--listen".into(), "127.0.0.1:40001".into()]).unwrap();
let storage = Device {
path: PathBuf::from("/sys/test"), bus_id: "3-1".into(),
vendor: "1234".into(), product: "5678".into(), serial: "".into(),
name: "storage".into(), bus: 3, address: 5,
classes: vec!["08".into()], device_class: "00".into(),
};
let hid = Device { classes: vec!["03".into()], ..storage.clone() };
assert_eq!(select(&[hid, storage.clone()], &p).unwrap().bus_id, "3-1");
}
#[test]
fn usb_events_wake_and_coalesce_without_waiting_for_the_scan_timer() {
let (sender, receiver) = mpsc::sync_channel(8);
let first = Instant::now();
let latest = Instant::now();
sender.send(first).unwrap();
sender.send(latest).unwrap();
let mut events = UsbEvents { child: None, receiver, last_event: None };
events.wait(Duration::from_secs(30));
assert_eq!(events.last_event, Some(latest));
}
#[test]
fn active_presence_check_rejects_a_replacement_on_the_same_port() {
let root = env::temp_dir().join(format!("vdi-usb-presence-{}-{}", process::id(),
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos()));
fs::create_dir(&root).unwrap();
for (name, value) in [("busnum", "3"), ("devnum", "5"), ("idVendor", "1234"),
("idProduct", "5678"), ("serial", "TEST")] {
fs::write(root.join(name), value).unwrap();
}
let device = Device { path: root.clone(), bus_id: "3-1".into(), vendor: "1234".into(),
product: "5678".into(), serial: "TEST".into(), name: "test".into(),
bus: 3, address: 5, classes: vec!["08".into()], device_class: "00".into() };
assert!(still_present(&device));
fs::write(root.join("devnum"), "6").unwrap();
assert!(!still_present(&device));
fs::remove_dir_all(root).unwrap();
}
#[test]
fn incomplete_composite_cannot_be_exported_as_storage() {
let root = env::temp_dir().join(format!("vdi-usb-enumeration-{}-{}", process::id(),
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos()));
let device = root.join("3-1");
fs::create_dir_all(&device).unwrap();
for (name, value) in [("idVendor", "1234"), ("idProduct", "5678"),
("bDeviceClass", "00"), ("bNumInterfaces", "2")] {
fs::write(device.join(name), value).unwrap();
}
fs::create_dir(root.join("3-1:1.0")).unwrap();
fs::write(root.join("3-1:1.0/bInterfaceClass"), "08").unwrap();
assert!(scan(&root).unwrap().is_empty());
fs::create_dir(root.join("3-1:1.1")).unwrap();
fs::write(root.join("3-1:1.1/bInterfaceClass"), "03").unwrap();
let devices = scan(&root).unwrap();
assert_eq!(devices.len(), 1);
assert!(!storage_only(&devices[0].device_class, &devices[0].classes));
fs::remove_dir_all(root).unwrap();
}
#[test]
fn enumeration_json_escapes_control_characters() {
assert_eq!(json_string("a\n\"\\"), "\"a\\u000a\\\"\\\\\"");

View File

@ -0,0 +1,60 @@
#!/bin/sh
# Run on the Linux box as its build user. Download headers only; do not upgrade
# system GLib/libusb or overwrite the distribution's /usr/bin/usbredirect.
set -eu
build_root="${1:?usage: build-usbredirect-private.sh ABSOLUTE_NEW_BUILD_DIRECTORY}"
case "$build_root" in /*) ;; *) exit 2 ;; esac
mkdir "$build_root"
cd "$build_root"
mkdir headers
python3 - <<'PY'
import hashlib, pathlib, tarfile, urllib.request
url = 'https://www.spice-space.org/download/usbredir/usbredir-0.15.0.tar.xz'
data = urllib.request.urlopen(url, timeout=60).read()
expected = '6dc2a380277688a068191245dac2ab7063a552999d8ac3ad8e841c10ff050961'
if hashlib.sha256(data).hexdigest() != expected:
raise SystemExit('usbredir source checksum mismatch')
archive = pathlib.Path('usbredir-0.15.0.tar.xz')
archive.write_bytes(data)
with tarfile.open(archive) as tar:
base = pathlib.Path('.').resolve()
for item in tar.getmembers():
if not (base / item.name).resolve().is_relative_to(base) or item.issym() or item.islnk():
raise SystemExit('unexpected archive member')
tar.extractall('.')
PY
# apt validates package hashes from the configured repository metadata. These
# two development packages are extracted into this private directory only.
apt-get download libglib2.0-dev libusb-1.0-0-dev
for package in ./*.deb; do dpkg-deb -x "$package" headers; done
arch=$(cc -print-multiarch)
cat >config.h <<'EOF'
#define PACKAGE_STRING "usbredir 0.15.0"
#define PACKAGE_VERSION "0.15.0"
#define VERSION "0.15.0"
#define PACKAGE_BUGREPORT "https://gitlab.freedesktop.org/spice/usbredir/issues"
#define USBREDIR_VISIBLE __attribute__((visibility("default")))
EOF
source_dir="$build_root/usbredir-0.15.0"
# Static usbredir objects; GLib, libusb and libc remain the installed runtime.
cc -O2 -g -fstack-protector-strong -D_FORTIFY_SOURCE=2 -pthread \
-Wno-deprecated-declarations -I. \
-Iheaders/usr/include/glib-2.0 \
-I"headers/usr/lib/$arch/glib-2.0/include" \
-Iheaders/usr/include/gio-unix-2.0 -Iheaders/usr/include/libusb-1.0 \
-I"$source_dir/usbredirhost" -I"$source_dir/usbredirparser" \
"$source_dir/tools/usbredirect.c" "$source_dir/usbredirhost/usbredirhost.c" \
"$source_dir/usbredirparser/usbredirparser.c" "$source_dir/usbredirparser/usbredirfilter.c" \
"/usr/lib/$arch/libgio-2.0.so.0" "/usr/lib/$arch/libgobject-2.0.so.0" \
"/usr/lib/$arch/libglib-2.0.so.0" "/usr/lib/$arch/libusb-1.0.so.0" \
-o usbredirect
./usbredirect --help
for suite in filter serializer; do
cc -O2 -pthread -I. -Iheaders/usr/include/glib-2.0 \
-I"headers/usr/lib/$arch/glib-2.0/include" -I"$source_dir/usbredirparser" \
"$source_dir/tests/$suite.c" "$source_dir/usbredirparser/usbredirparser.c" \
"$source_dir/usbredirparser/usbredirfilter.c" \
"/usr/lib/$arch/libglib-2.0.so.0" -o "test-$suite"
"./test-$suite"
done
sha256sum usbredirect

View File

@ -1,10 +1,12 @@
#!/usr/bin/env python3
"""Install a one-device usbredir POC using a restricted SSH tunnel.
"""Install USB Mass Storage redirection using a restricted SSH tunnel.
Requires paramiko on the admin machine and rustc/cargo/usbredirect on the box.
PVE authentication uses SSH keys or VDI_PVE_SSH_PASSWORD (never stored in files).
This prepares services only. Safely unmount the selected USB volume, start the
box service, then run usb-device-qmp.py attach on the PVE node.
Use --any-storage to accept any pure USB Mass Storage device, or provide an
exact VID/PID/serial identity for the legacy single-device policy. The
deployment installs a QMP watcher so the runtime USB endpoint is restored
after a VM/PVE restart.
"""
import argparse
import json
@ -13,6 +15,7 @@ from pathlib import Path
import re
import shlex
import subprocess
import tempfile
import time
@ -35,10 +38,16 @@ def pve_run(client, command):
def install(args, password=None):
import paramiko
if not re.fullmatch(r"[0-9a-f]{4}", args.vendor) or not re.fullmatch(r"[0-9a-f]{4}", args.product):
raise ValueError("VID/PID must be four lowercase hex digits")
if not re.fullmatch(r"[A-Za-z0-9_.-]+", args.serial) or not args.serial:
raise ValueError("POC installer supports nonempty alphanumeric serials only")
exact = all(getattr(args, name, None) for name in ("vendor", "product", "serial"))
if args.any_storage and exact:
raise ValueError("--any-storage cannot be combined with an exact device identity")
if not args.any_storage and not exact:
raise ValueError("choose --any-storage or provide --vendor, --product and --serial")
if exact:
if not re.fullmatch(r"[0-9a-f]{4}", args.vendor) or not re.fullmatch(r"[0-9a-f]{4}", args.product):
raise ValueError("VID/PID must be four lowercase hex digits")
if not re.fullmatch(r"[A-Za-z0-9_.-]+", args.serial) or not args.serial:
raise ValueError("installer supports nonempty alphanumeric serials only")
if not re.fullmatch(r"[a-z_][a-z0-9_-]*@[A-Za-z0-9.-]+", args.box):
raise ValueError("invalid box SSH destination")
if args.vm < 100:
@ -80,12 +89,33 @@ p.chmod(0o600)
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()
subprocess.run(["scp", "-q", str(repo / "terminal/usb-monitor/etc/vdi-usb-monitor.service"),
f"{args.box}:{stage}/vdi-usb-monitor.service"], check=True)
env = f'VDI_USB_VENDOR={args.vendor}\nVDI_USB_PRODUCT={args.product}\nVDI_USB_SERIAL={args.serial}\n'
rule = (f'# VDI USB device POC: only the approved device\nSUBSYSTEM=="block", '
f'ATTRS{{idVendor}}=="{args.vendor}", ATTRS{{idProduct}}=="{args.product}", '
f'ATTRS{{serial}}=="{args.serial}", ENV{{UDISKS_IGNORE}}="1"\n')
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")
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")
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)
service_tmp = Path(service_path)
service_tmp.write_text(service_text)
try:
subprocess.run(["scp", "-q", str(service_tmp),
f"{args.box}:{stage}/vdi-usb-monitor.service"], check=True)
finally:
service_tmp.unlink(missing_ok=True)
if args.any_storage:
env = 'VDI_USB_MODE=any-storage\n'
rule = ('# VDI USB: ignore USB disks locally; the monitor checks exclusive use\n'
'SUBSYSTEM=="block", ENV{ID_BUS}=="usb", ENV{ID_TYPE}=="disk", '
'ENV{UDISKS_IGNORE}="1"\n')
else:
env = f'VDI_USB_VENDOR={args.vendor}\nVDI_USB_PRODUCT={args.product}\nVDI_USB_SERIAL={args.serial}\n'
rule = (f'# VDI USB device: only the approved device\nSUBSYSTEM=="block", '
f'ATTRS{{idVendor}}=="{args.vendor}", ATTRS{{idProduct}}=="{args.product}", '
f'ATTRS{{serial}}=="{args.serial}", ENV{{UDISKS_IGNORE}}="1"\n')
box_install = '''import pathlib,shutil,subprocess,sys
stage,backup,env,rule=sys.argv[1:]
backup=pathlib.Path(backup)
@ -126,18 +156,36 @@ NoNewPrivileges=yes
PrivateTmp=yes
[Install]
WantedBy=multi-user.target
'''
qmp_args = ("--any-storage" if args.any_storage else
f"--vendor {args.vendor} --product {args.product}")
qmp_unit = f'''[Unit]
Description=VDI USB QMP endpoint for VM {args.vm}
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}
Restart=always
RestartSec=3
NoNewPrivileges=yes
PrivateTmp=yes
[Install]
WantedBy=multi-user.target
'''
sftp = client.open_sftp()
try:
for path, content in [(f"{host_dir}/known_hosts", known),
(f"/etc/systemd/system/vdi-usb-tunnel-{args.vm}.service", unit),
(f"/etc/systemd/system/vdi-usb-qmp-{args.vm}.service", qmp_unit),
("/usr/local/libexec/vdi-usb-device-qmp.py", (repo / "tools/usb-device-qmp.py").read_text())]:
with sftp.open(path, "w") as f:
f.write(content)
sftp.chmod(path, 0o600 if "known_hosts" in path else 0o644)
mode = 0o600 if "known_hosts" in path else (0o755 if path.endswith("usb-device-qmp.py") else 0o644)
sftp.chmod(path, mode)
finally:
sftp.close()
pve_run(client, f"systemctl daemon-reload && systemctl enable --now vdi-usb-tunnel-{args.vm}.service")
pve_run(client, f"systemctl daemon-reload && systemctl enable --now vdi-usb-tunnel-{args.vm}.service vdi-usb-qmp-{args.vm}.service")
print(json.dumps({"box_backup": backup, "pve_directory": host_dir,
"state": "prepared; unmount USB safely before starting export"}, indent=2))
finally:
@ -149,7 +197,9 @@ if __name__ == "__main__":
parser.add_argument("--pve", required=True)
parser.add_argument("--box", required=True)
parser.add_argument("--vm", type=int, required=True)
parser.add_argument("--vendor", required=True)
parser.add_argument("--product", required=True)
parser.add_argument("--serial", required=True)
parser.add_argument("--any-storage", action="store_true",
help="accept any pure USB Mass Storage device")
parser.add_argument("--vendor")
parser.add_argument("--product")
parser.add_argument("--serial")
install(parser.parse_args())

Some files were not shown because too many files have changed in this diff Show More