Compare commits
4 Commits
b2de05c6a6
...
6f2ff67300
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f2ff67300 | |||
| e35abf6dc0 | |||
| b1082f8465 | |||
| bb5f554820 |
28
MEMORY.md
@ -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 构建依赖
|
||||
|
||||
@ -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. 状态机
|
||||
|
||||
|
||||
44
docs/evidence/kylin-mouse-20261104/benchmark-source.py
Normal 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()
|
||||
@ -0,0 +1,3 @@
|
||||
49316 00:00:10 31.4
|
||||
|
||||
49316 00:00:35 62.7
|
||||
|
After Width: | Height: | Size: 364 KiB |
@ -0,0 +1,3 @@
|
||||
48825 00:00:10 29.7
|
||||
|
||||
48825 00:00:35 62.4
|
||||
|
After Width: | Height: | Size: 402 KiB |
@ -0,0 +1,3 @@
|
||||
48246 00:01:04 66.2
|
||||
|
||||
48246 00:01:29 74.2
|
||||
BIN
docs/evidence/kylin-mouse-20261104/bgra-limited-2k45-valid.png
Normal file
|
After Width: | Height: | Size: 386 KiB |
107
docs/evidence/kylin-mouse-20261104/color-comparison.json
Normal 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
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
1008
docs/evidence/kylin-mouse-20261104/cpu-sampling-raw.txt
Normal file
@ -0,0 +1,3 @@
|
||||
49732 00:01:03 3.0
|
||||
|
||||
49732 00:01:15 3.5
|
||||
@ -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}
|
||||
BIN
docs/evidence/kylin-mouse-20261104/final-full-2k30.png
Normal file
|
After Width: | Height: | Size: 378 KiB |
@ -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
|
||||
}
|
||||
BIN
docs/evidence/kylin-mouse-20261104/final-kiosk-list.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
@ -0,0 +1,3 @@
|
||||
49732 00:01:58 5.5
|
||||
|
||||
49732 00:02:09 6.0
|
||||
@ -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}
|
||||
BIN
docs/evidence/kylin-mouse-20261104/final-limited-2k30.png
Normal file
|
After Width: | Height: | Size: 386 KiB |
BIN
docs/evidence/kylin-mouse-20261104/final-normal-desktop.png
Normal file
|
After Width: | Height: | Size: 5.1 MiB |
21
docs/evidence/kylin-mouse-20261104/final-normal-host.txt
Normal 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
|
||||
桌面
|
||||
@ -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."
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
45143 00:19:31 99.0
|
||||
|
||||
45143 00:19:51 99.2
|
||||
BIN
docs/evidence/kylin-mouse-20261104/fullrange-2k45.png
Normal file
|
After Width: | Height: | Size: 378 KiB |
@ -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}
|
||||
|
After Width: | Height: | Size: 376 KiB |
32
docs/evidence/kylin-mouse-20261104/inputecho-probe.py
Normal 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}))
|
||||
48
docs/evidence/kylin-mouse-20261104/inputecho-source.py
Normal 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()
|
||||
@ -0,0 +1,3 @@
|
||||
45143 00:20:22 97.2
|
||||
|
||||
45143 00:20:43 97.4
|
||||
BIN
docs/evidence/kylin-mouse-20261104/limitedrange-2k45.png
Normal file
|
After Width: | Height: | Size: 386 KiB |
@ -0,0 +1,3 @@
|
||||
47776 00:00:09 44.8
|
||||
|
||||
47776 00:00:28 70.9
|
||||
BIN
docs/evidence/kylin-mouse-20261104/limitedrange-thread2-2k45.png
Normal file
|
After Width: | Height: | Size: 388 KiB |
19
docs/evidence/kylin-mouse-20261104/restored-host-state.txt
Normal 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>,)
|
||||
@ -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"}
|
||||
10
docs/evidence/kylin-mouse-20261104/sampling-hotspots.json
Normal 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."
|
||||
}
|
||||
59
docs/evidence/kylin-mouse-20261104/trial-summary.json
Normal 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."
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
{"192.168.1.71": "0", "192.168.1.70": "unset", "192.168.1.72": "unset", "explicit override": "1", "invalid rejected": 78}
|
||||
@ -0,0 +1,3 @@
|
||||
169194 00:06:35 9.7
|
||||
|
||||
169194 00:06:59 10.3
|
||||
BIN
docs/evidence/kylin-quality-20261104/1080p30-baseline.png
Normal file
|
After Width: | Height: | Size: 684 KiB |
@ -0,0 +1,3 @@
|
||||
179730 00:00:09 8.7
|
||||
|
||||
179730 00:00:34 26.2
|
||||
BIN
docs/evidence/kylin-quality-20261104/1080p45-compositor.png
Normal file
|
After Width: | Height: | Size: 686 KiB |
@ -0,0 +1,3 @@
|
||||
180224 00:00:11 46.2
|
||||
|
||||
180224 00:00:36 74.1
|
||||
|
After Width: | Height: | Size: 685 KiB |
@ -0,0 +1,3 @@
|
||||
180438 00:00:09 42.7
|
||||
|
||||
180438 00:00:35 74.5
|
||||
|
After Width: | Height: | Size: 771 KiB |
@ -0,0 +1,3 @@
|
||||
180841 00:00:10 42.4
|
||||
|
||||
180841 00:00:37 74.1
|
||||
|
After Width: | Height: | Size: 688 KiB |
23
docs/evidence/kylin-quality-20261104/2k-display-probe.txt
Normal 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*
|
||||
@ -0,0 +1,7 @@
|
||||
{
|
||||
"F8_received": 2,
|
||||
"alternating_clicks_correct": 48,
|
||||
"native_width": 2560,
|
||||
"native_height": 1440,
|
||||
"coordinate_error_bound_px": 1
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
16379 00:01:07 47.7
|
||||
|
||||
16379 00:01:35 57.3
|
||||
BIN
docs/evidence/kylin-quality-20261104/2k30-damage-thread1.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
@ -0,0 +1,3 @@
|
||||
25194 00:00:09 30.2
|
||||
|
||||
25194 00:00:34 58.6
|
||||
BIN
docs/evidence/kylin-quality-20261104/2k30-damage-thread2.png
Normal file
|
After Width: | Height: | Size: 378 KiB |
@ -0,0 +1,3 @@
|
||||
42266 00:00:59 35.0
|
||||
|
||||
42266 00:01:21 43.1
|
||||
|
After Width: | Height: | Size: 376 KiB |
@ -0,0 +1,3 @@
|
||||
42266 00:00:09 43.6
|
||||
|
||||
42266 00:00:30 73.2
|
||||
|
After Width: | Height: | Size: 376 KiB |
@ -0,0 +1,3 @@
|
||||
25194 00:01:01 0.8
|
||||
|
||||
25194 00:01:20 1.1
|
||||
|
After Width: | Height: | Size: 378 KiB |
@ -0,0 +1,3 @@
|
||||
16379 00:00:12 46.0
|
||||
|
||||
16379 00:00:37 71.3
|
||||
BIN
docs/evidence/kylin-quality-20261104/2k30-ultrafast-thread1.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
@ -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()
|
||||
44
docs/evidence/kylin-quality-20261104/benchmark-source.py
Normal 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()
|
||||
@ -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}
|
||||
BIN
docs/evidence/kylin-quality-20261104/final-desktop.png
Normal file
|
After Width: | Height: | Size: 5.1 MiB |
27
docs/evidence/kylin-quality-20261104/final-host-state.txt
Normal 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
|
||||
20
docs/evidence/kylin-quality-20261104/final-kiosk-check.json
Normal 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
|
||||
}
|
||||
]
|
||||
9
docs/evidence/kylin-quality-20261104/final-sunshine.conf
Normal 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
|
||||
20
docs/evidence/kylin-quality-20261104/final-sunshine.service
Normal 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
|
||||
@ -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
|
||||
104
docs/evidence/kylin-quality-20261104/trial-summary.json
Normal 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)"
|
||||
}
|
||||
]
|
||||
10
docs/evidence/kylin-quality-20261104/vm101-change-plan.json
Normal 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
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
{
|
||||
"vga": {
|
||||
"before": null,
|
||||
"after": "std,memory=64"
|
||||
}
|
||||
}
|
||||
22
docs/evidence/kylin-quality-20261104/vm101-execution.json
Normal 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"
|
||||
}
|
||||
]
|
||||
44
docs/evidence/kylin-quality-20261104/vram-diagnostic.txt
Normal 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
|
||||
BIN
docs/evidence/linux-desktops-20261104/desktop-list.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
@ -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
|
||||
}
|
||||
]
|
||||
37
docs/evidence/linux-desktops-20261104/kylin-final-state.txt
Normal 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::---
|
||||
|
||||
2
docs/evidence/linux-desktops-20261104/kylin-input.jsonl
Normal file
@ -0,0 +1,2 @@
|
||||
{"event": "key", "keyval": 65477}
|
||||
{"event": "click", "button": 1, "x": 320.34991455078125, "y": 150.28619384765625}
|
||||
BIN
docs/evidence/linux-desktops-20261104/kylin-stream.png
Normal file
|
After Width: | Height: | Size: 3.6 MiB |
@ -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
|
||||
16
docs/evidence/linux-desktops-20261104/kylin-sunshine.service
Normal 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
|
||||
34
docs/evidence/linux-desktops-20261104/ubuntu-final-state.txt
Normal 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::---
|
||||
|
||||
8
docs/evidence/linux-desktops-20261104/ubuntu-input.jsonl
Normal 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}
|
||||
@ -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
|
||||
BIN
docs/evidence/linux-desktops-20261104/ubuntu-stream.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
@ -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
|
||||
@ -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
|
||||
52
docs/kylin-mouse-20261104.md
Normal 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同等的视频响应,需要进一步实现并验证转换并行化/硬件编码;若目标是指针本身不等待视频,则需要实现自动形状同步、显隐、热点和可靠交接的独立光标通道。现有受控原型尚不满足这一要求,不能用普通本地箭头叠加冒充完成。
|
||||
65
docs/kylin-quality-20261104.md
Normal 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)。
|
||||
94
docs/linux-desktop-onboarding-20261104.md
Normal 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`。麒麟版本较旧是当前兼容方案,后续升级需要重新评估系统库兼容性与安全维护。
|
||||
86
docs/usb-any-storage-20261104.md
Normal 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` 回退。
|
||||
@ -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" "$@"
|
||||
|
||||
@ -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)。
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(¤t, &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\\\"\\\\\"");
|
||||
|
||||
60
tools/build-usbredirect-private.sh
Normal 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
|
||||
@ -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())
|
||||
|
||||