Compare commits

...

5 Commits

92 changed files with 3145 additions and 13 deletions

View File

@ -2,7 +2,48 @@
完整对话总结在:`D:\DAIMA\vdi-cloud\PROJECT_CONTEXT.md`
## 2026-11-03 午后鼠标进一步优化(最新)
## 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。
- 独立VDI_USB_TEST_20261103-173342目录新增1MiB文件并复制回VM,三份SHA一致;用户安全弹出后真实拔插,17:34:43断开、17:34:53重连,F:恢复且文件SHA一致。原文件未改,测试目录保留。
- 仅固定VID/PID/serial纯存储,拒绝键鼠/复合设备/本地挂载/holder/swap;udev只禁止这只盘本地自动挂载。最初口报-71,换口后成功,不是软件修好了原口。
- 盒子vdi-usb-monitor.service、PVE vdi-usb-tunnel-100.service active/enabled;QMP端点vdi_usb_box_100/vdi_usb_redir_100仅运行时,VM/PVE重启需管理员重新attach。未验收开机恢复/多用户生产能力。
- 盒子备份~/vdi-terminal/backups/usb-device-20261103-172709;PVE密钥/etc/vdi-usb-poc/vm100,回环40001经SSH。未装Windows驱动、开测试签名、重启或改GPU/串流;Moonlight PID46934保留,GTX965M错误0。
- 详见docs/usb-device-poc-20261103.md及evidence/usb-device-20261103;工具deploy-usb-device-poc.py、usb-device-qmp.py、test-usb-device-poc.ps1。
## 2026-11-03 Windows保留单个2K主屏(最新)
- 用户明确授权:不影响2K才能改,否则不动。现场VDD已变DISPLAY4,原主屏2560×1440@60;Basic DISPLAY1另扩展2K64,NV DISPLAY2原本无活动输出。
- 已只停用Basic桌面输出,VDD成为唯一活动主屏,保持2K60;未禁用/卸载任何显卡、未重启。原Moonlight PID46934/2K45继续,截图正常;GPU错误0,Sunshine重建h264_nvenc,编码10%。
- 新工具set-single-vdi-display.ps1、run-single-vdi-display.py支持Query/Apply/Restore,有2K60主屏前置条件、XML布局备份、读回失败恢复、设备身份校验;临时任务已删除。备份C:\ProgramData\VDI\single-display-20261103\before.xml。
- 布局已按当前用户保存,但未重启/重登验收;设备管理器仍有适配器,不能说卸载设备或保证设置界面不列未连接输出。详见docs/single-display-20261103.md及对应evidence。
## 2026-11-03 独立光标受控原型
- 已实测Windows真实箭头/文字/左右缩放/忙碌静态形状导出和盒子X11本地显示,热点正确,隐藏全透明;短暂停Moonlight小于0.5秒,本地指针仍移动,已恢复SIGCONT。
- 最后完整轮32/32跨区点击正确,归一化坐标最大差1px;10项边界测试通过,0.8秒辅助状态租约到期退出/SDL资源归还通过。未测物理输入到屏幕延迟。
- 这是脚本形状回放和受控Sunshine全局N快捷键交接,不是自动形状同步,不是生产独立光标;没有替换正常Moonlight/Sunshine二进制。正式须改双方控制通道、帧边界ACK/租约与SDL生命周期,并适配Weston。
- 首两轮辅助连接退出后XFixes图像不可用,修正为XFixes临时隐藏下成对切Moonlight本地C快捷键,让SDL重新设置其拥有的隐藏指针;第三轮完整通过。静止画面N切换旧箭头留旧帧也已实测,不能靠快捷键直接上线。
- 已停删VDICursorPOC,正常PID31694/2K45/原SHA保留,VDD2K60、GPU错误0/NVENC编码11%、显示守护active;最后本地透明1×1,正常Windows单个白色远程箭头。未重启/改ROM/VFIO/主题。
- 详见docs/independent-cursor-feasibility-20261103.md及对应evidence。新增cursor-poc工具仅实验,不自动启动;verify-cursor-poc-box.py点击仅可用于临时Windows测试窗口。
## 2026-11-03 GPU修复后联调与45帧落地
- 用户确认运维修复结束,授权的鼠标优化任务已恢复。现场复核GTX965M错误码0、vBIOS84.04.2b.00.13、
Sunshine实际h264_nvenc、NVENC会话1;正常重连后编码占用18%。本轮未改ROM/VFIO、未重启任何机器。
- 修正的测试源实测约59.4~59.8FPS;2K30实渲染29.83FPS;2K60接收58.82但只渲染46.11,
客户端等待队列7.54ms,GPU89~95%/800MHz。原长队列渲染仍46且排队36ms;nearest实验42;VSync24,均未采用。
- 已落地Windows VDD2560x1440@60、正常kiosk票据45FPS;短测实渲染44.22/队列1.30ms,
约两分半持续动态会话43.14/队列1.55ms/网络实际丢帧0。不能称稳定2K60或零延迟。
- Windows活动VDD变为DISPLAY3,动态查找;登录脚本tools/set-2k-display.ps1默认60、支持RefreshRate30。
已备份/部署并手动运行原VDISet2KDisplay任务,独立Query确认60Hz;未通过重启验收登录触发。
Windows原脚本/任务XML:C:\ProgramData\VDI\backups\display-refresh-20261103-142920。
- tools/set-terminal-fps.py支持30/45/60、原子写入和备份;当前30票据备份
~/vdi-terminal/backups/fps-20261103-143333-297521/desktops.json。正常流程重新连接PID31694实际fps45。
- 正常二进制仍35de7b...afa00f,RKMPP/DRM PRIME/EGL、最新帧队列、无VSync/无pacing,第三补丁不开。
nearest实验保留terminal/moonlight/experiments/0004-vdi-egl-exact-size-sampling.patch,已撤回正常源码且不进构建器。
- Windows临时基准任务已停止并删除,256次交替点击记录全部位置正确;用户原应用保留,恢复正常Windows串流。
显示守护active,2560x1440输出正常。独立光标尚未实现;稳定2K60需继续验证更低开销呈现路径。
- 详细对照、限制与回退见docs/2k60-validation-20261103.md,证据docs/evidence/2k60-20261103/。
## 2026-11-03 午后鼠标进一步优化(历史,已恢复联调)
- 用户要求制定计划并执行;随后明确运维正在修复GPU Code43。Windows多次被外部qmshutdown/qmstart,
本轮已停止Windows配置/串流测试,不能与运维并发改VM;没有发出VM/宿主/显卡重启指令。
- 新增0003-vdi-input-priority.patch:帧前连续输入最多32条/2ms,保留焦点/窗口/退出等边界;

View File

@ -0,0 +1,55 @@
# GPU修复后刷新率与鼠标画面优化验收(2026-11-03)
## 结论
Windows虚拟屏已调整为2560×1440、60Hz,登录脚本已备份并更新;客户端默认已设为45FPS,并通过正常选择桌面入口重连。当前盒子的Xorg/EGL路径在请求60FPS时实际只能渲染约46FPS,因此不宣称稳定2K60。45FPS短测实际44.22FPS,持续测试43.14FPS;相对30FPS基线29.83FPS提高约45%~48%,排队约1.3~1.6ms。
## GPU复核
本轮实际查询NVIDIA错误码0,驱动582.78,vBIOS 84.04.2b.00.13。动态串流期间Sunshine实际创建h264_nvenc,NVIDIA有1个编码会话,检查时编码引擎约24%。运维报告的重启测试作为其提供的验收记录,本轮未自行重启VM、宿主或GPU,也未修改直通和ROM配置。
## 同一修正测试源对照
修正后的Windows测试窗口实测约59FPS,避免了原WinForms定时器将源限制到约32FPS的问题。所有测试保持2560×1440、H.264、30Mbps、RKMPP硬解;默认客户端为已有两个输入补丁的版本,第三补丁调度未启用。除同步实验外,均关闭VSync与帧步调。
| 档位 | 接收/渲染FPS | 主机处理平均 | 解码平均 | 帧队列平均 | 客户端队列丢弃指标 |
| --- | --- | --- | --- | --- | --- |
| VDD30 / 客户端30 | 29.95 / 29.83 | 7.5ms | 6.81ms | 0.60ms | 0.41% |
| VDD60 / 客户端60、最新帧队列 | 58.82 / 46.11 | 7.5ms | 7.37ms | 7.54ms | 21.61% |
| VDD60 / 客户端60、原队列 | 58.81 / 46.10 | 7.6ms | 7.13ms | 36.13ms | 10.28% |
| VDD60 / 客户端60、精确尺寸nearest实验 | 58.20 / 42.01 | 7.5ms | 6.94ms | 7.66ms | 27.77% |
| VDD60 / 客户端60、VSync开/帧步调关 | 57.88 / 24.39 | 7.5ms | 7.26ms | 8.12ms | 57.80% |
| VDD60 / 客户端45、最新帧队列 | 44.84 / 44.22 | 7.6ms | 8.54ms | 1.30ms | 1.38% |
| VDD60 / 客户端45、持续测试 | 43.98 / 43.14 | 7.6ms | 7.32ms | 1.55ms | 1.91% |
上表“客户端队列丢弃指标”来自上游标成`Frames dropped due to network jitter`的统计,但实际字段是`pacerDroppedFrames`,本项目最新帧队列也计入其中。不能把它全部归因于网络,原队列溢出统计也并不完整;应结合接收/解码/渲染FPS及帧队列时间评估。上述会话真正的网络丢帧字段均为0%,网络延迟约2~7ms;这些是现场顺序测试,不是严格实验室基准。
60FPS会话采样盒子图形核心约89%~95%负载、800MHz;DDR频率已在786MHz最高公布档位,MATE合成器关闭。测试温度约62~65℃,没有通过提高频率/电压或更改CPU/GPU调速器来制造结果。原队列没有提高帧率却增加排队,nearest采样和VSync实验均未显示收益,未部署。
nearest实验保留在`terminal/moonlight/experiments/0004-vdi-egl-exact-size-sampling.patch`供复查,已从盒子正常源码撤回,不进入默认构建流程。正常二进制SHA256仍为`35de7b1a4ade583823b23b5f2e012307f6d0a3848c8eb97bd6d4de9660afa00f`。
## 配置和回退
- Windows实际活动VDD编号已变为DISPLAY3,工具动态查找,未写死设备号。旧DISPLAY2备份不能直接作为新设备的恢复依据,因此探测器增加`--backup-tag`区分验收批次。
- `tools/set-2k-display.ps1`默认60Hz,支持`-RefreshRate 30`回退;已更新原VDISet2KDisplay登录任务使用的脚本,未修改任务动作或用户。
- Windows部署前原脚本和任务XML备份:`C:\ProgramData\VDI\backups\display-refresh-20261103-142920`。部署工具按本次新生成的结果日志和任务退出码确认成功,随后独立Query确认60Hz。本轮没有重启验证登录触发。
- `tools/run-mouse-display-probe.py Apply --hz 30 --backup-tag nvenc-20261103`可临时恢复30Hz;要保持30Hz应恢复原登录脚本,或将登录任务脚本参数设为`-RefreshRate 30`。
- `tools/set-terminal-fps.py --fps 45 --apply`只更改盒子Windows桌面票据FPS,自动备份和原子写入,不更改其他桌面;回退用`--fps 30 --apply`。
部署工具前两次因Windows任务调度器LastRunTime与墙上时钟不同步而误判成功任务为失败,已自动恢复旧脚本;随后改为新结果日志证明执行新鲜度,部署成功。受配置切换影响的`nvenc45-sustained`不纳入持续测试结论,重新执行`nvenc45-sustained-final`。
## 持续测试和现场收尾
`nvenc45-sustained-final`在视频就绪后注入20秒鼠标移动和32次点击,再持续120秒,正常退出。接收43.98/渲染43.14FPS,主机平均7.6ms、解码7.32ms、排队1.55ms、呈现1.55ms,实际网络丢帧0%。这是约两分半钟的动态会话结果,不是长期稳定性保证。
三组Windows点击记录共256次左右交替点击,脚本校验全部符合预期,包含被排除的持续性能试验中的32次;点击正确性可核验,但其帧率数据不纳入持续性能结论。修正测试源在保存的状态中约59.4~59.8FPS,足够进行本轮对照。
已执行`set-terminal-fps.py --fps 45 --apply`,原30帧票据备份于盒子`~/vdi-terminal/backups/fps-20261103-143333-297521/desktops.json`。只改变Windows卡片FPS,其余桌面、分辨率和编码配置保持原值。
已停止并删除VDIInputLatencyBench临时任务;最终Windows查询确认`benchmark_task_present=False`。通过正常kiosk页面点击Windows连接,实际新进程PID31694带`--resolution 2560x1440 --fps 45`,RKMPP/DRM PRIME/EGL路径正常。再次查询错误码0、NVENC会话1、编码占用18%,Sunshine本次实际创建h264_nvenc。显示守护active,正常客户端二进制未换成实验版本。
证据目录:`docs/evidence/2k60-20261103/`,包含逐会话日志、点击记录、最终GPU/编码查询和页面截图。实际拖窗与鼠标的主观体验仍需用户现场确认;本轮自动点击和统计不能代替物理输入到屏幕测量。
## 后续边界
独立光标尚未实现,仍保留单个Windows远程白色光标。下一阶段要进一步达到稳定2K60,需要验证更低开销的显示路径及正式Weston/Wayland架构,或评估终端硬件;当前不能通过提高FPS参数或安装所谓流畅插件绕过呈现瓶颈。独立光标双方协议与单光标交接设计见`docs/independent-cursor-design-20261103.md`。本轮未测量物理输入到屏幕的P50/P95,不能称零延迟。

View File

@ -0,0 +1,199 @@
width=2560 height=1440 paints=25330 elapsed_ms=425839 clicks=96
ms,x,y,target
42057,639,600,L
42058,1920,600,R
42093,639,600,L
42094,1920,600,R
42095,639,600,L
42096,1920,600,R
42097,639,600,L
42098,1920,600,R
42099,639,600,L
42100,1920,600,R
42101,639,600,L
42102,1920,600,R
42103,639,600,L
42104,1920,600,R
42105,639,600,L
42106,1920,600,R
42107,639,600,L
42141,1920,600,R
42142,639,600,L
42143,1920,600,R
42144,639,600,L
42145,1920,600,R
42147,639,600,L
42148,1920,600,R
42148,639,600,L
42149,1920,600,R
42150,639,600,L
42151,1920,600,R
42152,639,600,L
42153,1920,600,R
42154,639,600,L
42154,1920,600,R
112701,639,600,L
112702,1920,600,R
112704,639,600,L
112706,1920,600,R
112707,639,600,L
112709,1920,600,R
112711,639,600,L
112712,1920,600,R
112732,639,600,L
112733,1920,600,R
112735,639,600,L
112736,1920,600,R
112737,639,600,L
112759,1920,600,R
112762,639,600,L
112763,1920,600,R
112765,639,600,L
112766,1920,600,R
112768,639,600,L
112769,1920,600,R
112788,639,600,L
112790,1920,600,R
112791,639,600,L
112792,1920,600,R
112793,639,600,L
112815,1920,600,R
112818,639,600,L
112820,1920,600,R
112822,639,600,L
112823,1920,600,R
112824,639,600,L
112825,1920,600,R
302705,639,600,L
302706,1920,600,R
302707,639,600,L
302710,1920,600,R
302711,639,600,L
302712,1920,600,R
302713,639,600,L
302732,1920,600,R
302733,639,600,L
302734,1920,600,R
302735,639,600,L
302754,1920,600,R
302777,639,600,L
302778,1920,600,R
302779,639,600,L
302780,1920,600,R
302782,639,600,L
302783,1920,600,R
302784,639,600,L
302785,1920,600,R
302786,639,600,L
302805,1920,600,R
302809,639,600,L
302812,1920,600,R
302813,639,600,L
302814,1920,600,R
302815,639,600,L
302817,1920,600,R
302819,639,600,L
302820,1920,600,R
302821,639,600,L
302838,1920,600,R
{"LastTaskResult":267009}

View File

@ -0,0 +1,199 @@
width=2560 height=1440 paints=29513 elapsed_ms=493665 clicks=96
ms,x,y,target
145117,639,600,L
145118,1920,600,R
145122,639,600,L
145123,1920,600,R
145144,639,600,L
145145,1920,600,R
145148,639,600,L
145150,1920,600,R
145151,639,600,L
145153,1920,600,R
145175,639,600,L
145180,1920,600,R
145183,639,600,L
145185,1920,600,R
145186,639,600,L
145188,1920,600,R
145190,639,600,L
145191,1920,600,R
145193,639,600,L
145213,1920,600,R
145215,639,600,L
145217,1920,600,R
145219,639,600,L
145220,1920,600,R
145221,639,600,L
145223,1920,600,R
145225,639,600,L
145245,1920,600,R
145246,639,600,L
145247,1920,600,R
145248,639,600,L
145250,1920,600,R
324992,639,600,L
324993,1920,600,R
324994,639,600,L
324995,1920,600,R
325013,639,600,L
325015,1920,600,R
325016,639,600,L
325017,1920,600,R
325017,639,600,L
325034,1920,600,R
325038,639,600,L
325040,1920,600,R
325042,639,600,L
325044,1920,600,R
325045,639,600,L
325047,1920,600,R
325065,639,600,L
325067,1920,600,R
325069,639,600,L
325070,1920,600,R
325072,639,600,L
325074,1920,600,R
325075,639,600,L
325092,1920,600,R
325095,639,600,L
325097,1920,600,R
325100,639,600,L
325102,1920,600,R
325103,639,600,L
325104,1920,600,R
325121,639,600,L
325123,1920,600,R
434053,639,600,L
434055,1920,600,R
434058,639,600,L
434061,1920,600,R
434063,639,600,L
434063,1920,600,R
434079,639,600,L
434081,1920,600,R
434084,639,600,L
434085,1920,600,R
434086,639,600,L
434088,1920,600,R
434089,639,600,L
434105,1920,600,R
434108,639,600,L
434109,1920,600,R
434110,639,600,L
434112,1920,600,R
434129,639,600,L
434131,1920,600,R
434132,639,600,L
434148,1920,600,R
434152,639,600,L
434155,1920,600,R
434157,639,600,L
434158,1920,600,R
434160,639,600,L
434162,1920,600,R
434163,639,600,L
434164,1920,600,R
434166,639,600,L
434167,1920,600,R
{"LastTaskResult":267009}

View File

@ -0,0 +1,135 @@
width=2560 height=1440 paints=24646 elapsed_ms=412170 clicks=64
ms,x,y,target
145117,639,600,L
145118,1920,600,R
145122,639,600,L
145123,1920,600,R
145144,639,600,L
145145,1920,600,R
145148,639,600,L
145150,1920,600,R
145151,639,600,L
145153,1920,600,R
145175,639,600,L
145180,1920,600,R
145183,639,600,L
145185,1920,600,R
145186,639,600,L
145188,1920,600,R
145190,639,600,L
145191,1920,600,R
145193,639,600,L
145213,1920,600,R
145215,639,600,L
145217,1920,600,R
145219,639,600,L
145220,1920,600,R
145221,639,600,L
145223,1920,600,R
145225,639,600,L
145245,1920,600,R
145246,639,600,L
145247,1920,600,R
145248,639,600,L
145250,1920,600,R
324992,639,600,L
324993,1920,600,R
324994,639,600,L
324995,1920,600,R
325013,639,600,L
325015,1920,600,R
325016,639,600,L
325017,1920,600,R
325017,639,600,L
325034,1920,600,R
325038,639,600,L
325040,1920,600,R
325042,639,600,L
325044,1920,600,R
325045,639,600,L
325047,1920,600,R
325065,639,600,L
325067,1920,600,R
325069,639,600,L
325070,1920,600,R
325072,639,600,L
325074,1920,600,R
325075,639,600,L
325092,1920,600,R
325095,639,600,L
325097,1920,600,R
325100,639,600,L
325102,1920,600,R
325103,639,600,L
325104,1920,600,R
325121,639,600,L
325123,1920,600,R
{"LastTaskResult":267009}

View File

@ -0,0 +1,135 @@
width=2560 height=1440 paints=26163 elapsed_ms=440146 clicks=64
ms,x,y,target
36986,639,600,L
36987,1920,600,R
36989,639,600,L
37005,1920,600,R
37023,639,600,L
37026,1920,600,R
37030,639,600,L
37031,1920,600,R
37033,639,600,L
37034,1920,600,R
37036,639,600,L
37037,1920,600,R
37037,639,600,L
37038,1920,600,R
37056,639,600,L
37058,1920,600,R
37060,639,600,L
37062,1920,600,R
37082,639,600,L
37084,1920,600,R
37085,639,600,L
37086,1920,600,R
37087,639,600,L
37088,1920,600,R
37106,639,600,L
37110,1920,600,R
37112,639,600,L
37113,1920,600,R
37114,639,600,L
37114,1920,600,R
37115,639,600,L
37116,1920,600,R
264310,639,600,L
264311,1920,600,R
264314,639,600,L
264315,1920,600,R
264316,639,600,L
264317,1920,600,R
264334,639,600,L
264337,1920,600,R
264338,639,600,L
264339,1920,600,R
264340,639,600,L
264358,1920,600,R
264361,639,600,L
264362,1920,600,R
264363,639,600,L
264364,1920,600,R
264368,639,600,L
264370,1920,600,R
264388,639,600,L
264392,1920,600,R
264393,639,600,L
264394,1920,600,R
264396,639,600,L
264415,1920,600,R
264418,639,600,L
264420,1920,600,R
264422,639,600,L
264424,1920,600,R
264425,639,600,L
264427,1920,600,R
264428,639,600,L
264429,1920,600,R
{"LastTaskResult":267009}

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

View File

@ -0,0 +1,11 @@
{"Name":"NVIDIA GeForce GTX 965M","ConfigManagerErrorCode":0}
NVIDIA GeForce GTX 965M, 84.04.2b.00.13, 1, 18 %
Capture size : 2560x1440
[2026-11-03 14:34:22.850]: Info: Requested frame rate [45fps]
[2026-11-03 14:34:22.851]: Info: Creating encoder [h264_nvenc]
2026-11-03T14:29:21.7186993+08:00 device=\\.\DISPLAY3 result=0 mode=2560x1440@60
benchmark_task_present=False

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

View File

@ -0,0 +1 @@
{"width": 1, "height": 1, "hotX": 0, "hotY": 0, "x": 320, "y": 1250, "argb": [0]}

View File

@ -0,0 +1 @@
{"width": 32, "height": 32, "hotX": 0, "hotY": 0, "x": 320, "y": 500, "argb": [4278255885, 4278848013, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278255885, 4278387726, 4278848013, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278255885, 4294967295, 4278387726, 4278848013, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278255885, 4294967295, 4294967295, 4278387726, 4278848013, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278255885, 4294967295, 4294967295, 4294967295, 4278387726, 4278848013, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278255885, 4294967295, 4294967295, 4294967295, 4294967295, 4278387726, 4278848013, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278255885, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4278387726, 4278848013, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278255885, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4278387726, 4278848013, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278255885, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294507003, 4278387726, 4278848013, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278255885, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4293980403, 4278387726, 4278848013, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278255885, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4293454058, 4278387726, 4278848013, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278255885, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4292993507, 4278387726, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278255885, 4294967295, 4294967295, 4294967295, 4294243575, 4294967295, 4294967295, 4278387726, 4278387726, 4278387726, 4278387726, 4278387726, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278255885, 4294967295, 4294967295, 4294243575, 4278255630, 4289967292, 4294967295, 4278387726, 4278782222, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278255885, 4294967295, 4294243575, 4278255630, 4278848013, 4278321678, 4294967295, 4292993507, 4278321678, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278255885, 4294243575, 4278255630, 4278848013, 0, 4278453264, 4290953929, 4294967295, 4278453264, 4278782222, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278387726, 4278255630, 4278848013, 0, 0, 4278848013, 4278321678, 4294967295, 4292269784, 4278321678, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278453264, 4292269784, 4294967295, 4278387214, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278979085, 4278321678, 4278519056, 4278913294, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]}

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

View File

@ -0,0 +1,5 @@
{
"clicks": 32,
"correct_regions_and_shapes": 32,
"max_coordinate_quantization_px": 1
}

View File

@ -0,0 +1,3 @@
{"action":"Query","device":"\\\\.\\DISPLAY3","hz":60,"width":2560,"bpp":32,"height":1440}
task_result=0

View File

@ -0,0 +1,10 @@
{"Name":"NVIDIA GeForce GTX 965M","ConfigManagerErrorCode":0}
NVIDIA GeForce GTX 965M, 582.78, 11 %
cursor_probe_task_present=False
[2026-11-03 14:29:49.548]: Info: Found H.264 encoder: h264_nvenc [nvenc]
[2026-11-03 14:29:50.970]: Info: Creating encoder [h264_nvenc]
[2026-11-03 14:34:22.851]: Info: Creating encoder [h264_nvenc]

View File

@ -0,0 +1,2 @@
{'width': 1, 'height': 1, 'hotX': 0, 'hotY': 0, 'x': 100, 'y': 100}
visible_local_pixels 0

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

View File

@ -0,0 +1 @@
{"width": 1, "height": 1, "hotX": 0, "hotY": 0, "x": 320, "y": 1250, "argb": [0]}

View File

@ -0,0 +1 @@
{"width": 32, "height": 32, "hotX": 0, "hotY": 0, "x": 320, "y": 500, "argb": [4278255885, 4278848013, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278255885, 4278387726, 4278848013, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278255885, 4294967295, 4278387726, 4278848013, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278255885, 4294967295, 4294967295, 4278387726, 4278848013, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278255885, 4294967295, 4294967295, 4294967295, 4278387726, 4278848013, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278255885, 4294967295, 4294967295, 4294967295, 4294967295, 4278387726, 4278848013, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278255885, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4278387726, 4278848013, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278255885, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4278387726, 4278848013, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278255885, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294507003, 4278387726, 4278848013, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278255885, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4293980403, 4278387726, 4278848013, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278255885, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4293454058, 4278387726, 4278848013, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278255885, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4292993507, 4278387726, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278255885, 4294967295, 4294967295, 4294967295, 4294243575, 4294967295, 4294967295, 4278387726, 4278387726, 4278387726, 4278387726, 4278387726, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278255885, 4294967295, 4294967295, 4294243575, 4278255630, 4289967292, 4294967295, 4278387726, 4278782222, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278255885, 4294967295, 4294243575, 4278255630, 4278848013, 4278321678, 4294967295, 4292993507, 4278321678, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278255885, 4294243575, 4278255630, 4278848013, 0, 4278453264, 4290953929, 4294967295, 4278453264, 4278782222, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278387726, 4278255630, 4278848013, 0, 0, 4278848013, 4278321678, 4294967295, 4292269784, 4278321678, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278453264, 4292269784, 4294967295, 4278387214, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278979085, 4278321678, 4278519056, 4278913294, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]}

View File

@ -0,0 +1 @@
{"width": 32, "height": 32, "hotX": 8, "hotY": 9, "x": 960, "y": 500, "argb": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4294112499, 4294112499, 4294112499, 0, 4294112499, 4294112499, 4294112499, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4294112499, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4294112499, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4294112499, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4294112499, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4294112499, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4294112499, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4294112499, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4294112499, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4294112499, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4294112499, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4294112499, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4294112499, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4294112499, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4294112499, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4294112499, 4294112499, 4294112499, 0, 4294112499, 4294112499, 4294112499, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]}

View File

@ -0,0 +1 @@
{"version": 1, "shape": "arrow", "visible": true, "expires": 1793689477.871138}

View File

@ -0,0 +1,44 @@
[
{
"shape": "arrow",
"x": 320,
"y": 500,
"visible": true
},
{
"shape": "ibeam",
"x": 960,
"y": 500,
"visible": true
},
{
"shape": "sizewe",
"x": 1600,
"y": 500,
"visible": true
},
{
"shape": "wait",
"x": 2240,
"y": 500,
"visible": true
},
{
"shape": "__hidden",
"x": 320,
"y": 1250,
"visible": false
},
{
"test": "client-paused-local-motion",
"before": [
320,
500
],
"after": [
500,
650
],
"passed": true
}
]

View File

@ -0,0 +1 @@
{"width": 32, "height": 32, "hotX": 11, "hotY": 4, "x": 1600, "y": 500, "argb": [0, 0, 0, 4278848013, 4278387726, 4278387726, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278387726, 4278387726, 4278848013, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278848013, 4278387726, 4294967295, 4278190093, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278190093, 4293980403, 4278387726, 4278848013, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278848013, 4278387726, 4294967295, 4294967295, 4278190093, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278190093, 4294967295, 4293717230, 4278387726, 4278848013, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278848013, 4278387726, 4294967295, 4294967295, 4294967295, 4278387726, 4278387726, 4278387726, 4278387726, 4278387726, 4278387726, 4278387726, 4278387726, 4278387726, 4278387726, 4278387726, 4278387726, 4278387726, 4294967295, 4294967295, 4293454058, 4278387726, 4278848013, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278387726, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4293454058, 4278387726, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278848013, 4278387726, 4294967295, 4294967295, 4294967295, 4278387726, 4278387726, 4278387726, 4278387726, 4278387726, 4278387726, 4278387726, 4278387726, 4278387726, 4278387726, 4278387726, 4278387726, 4278387726, 4294967295, 4294967295, 4293454058, 4278387726, 4278848013, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278848013, 4278387726, 4294967295, 4294967295, 4278190093, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278190093, 4294967295, 4293454058, 4278387726, 4278848013, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278848013, 4278387726, 4294967295, 4278190093, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278190093, 4293454058, 4278387726, 4278848013, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278848013, 4278387726, 4278387726, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278387726, 4278387726, 4278848013, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,74 @@
{"version":1,"shape":"wait","visible":true,"x":2240,"y":741,"utc":"2026-11-03T07:12:30.9503413Z"}
utc,x,y,shape
2026-11-03T07:11:23.6728439Z,320,600,arrow
2026-11-03T07:11:23.9853459Z,959,600,ibeam
2026-11-03T07:11:24.2703418Z,1600,600,sizewe
2026-11-03T07:11:24.5743423Z,2240,600,wait
2026-11-03T07:11:24.8668434Z,320,620,arrow
2026-11-03T07:11:25.1643408Z,959,620,ibeam
2026-11-03T07:11:25.4458407Z,1600,620,sizewe
2026-11-03T07:11:25.7348467Z,2240,620,wait
2026-11-03T07:11:26.0408467Z,320,639,arrow
2026-11-03T07:11:26.3293390Z,959,639,ibeam
2026-11-03T07:11:26.6163416Z,1600,639,sizewe
2026-11-03T07:11:26.9133420Z,2240,639,wait
2026-11-03T07:11:27.1828416Z,320,659,arrow
2026-11-03T07:11:27.4833409Z,959,659,ibeam
2026-11-03T07:11:27.7798422Z,1600,659,sizewe
2026-11-03T07:11:28.0623467Z,2240,659,wait
2026-11-03T07:11:28.3813431Z,320,679,arrow
2026-11-03T07:11:28.6843441Z,959,679,ibeam
2026-11-03T07:11:28.9513522Z,1600,679,sizewe
2026-11-03T07:11:29.2338429Z,2240,679,wait
2026-11-03T07:11:29.5318423Z,320,699,arrow
2026-11-03T07:11:29.8163521Z,959,699,ibeam
2026-11-03T07:11:30.1223411Z,1600,699,sizewe
2026-11-03T07:11:30.4248457Z,2240,699,wait
2026-11-03T07:11:30.6963403Z,320,720,arrow
2026-11-03T07:11:30.9818405Z,959,720,ibeam
2026-11-03T07:11:31.2808460Z,1600,720,sizewe
2026-11-03T07:11:31.5703482Z,2240,720,wait
2026-11-03T07:11:31.8683454Z,320,740,arrow
2026-11-03T07:11:32.1633457Z,959,740,ibeam
2026-11-03T07:11:32.4473379Z,1600,740,sizewe
2026-11-03T07:11:32.7883589Z,2240,740,wait
utc,shape,flags,x,y
2026-11-03T07:11:06.8498407Z,wait,1,2559,0
2026-11-03T07:11:16.6898849Z,arrow,1,320,499
2026-11-03T07:11:19.9603457Z,ibeam,1,959,499
2026-11-03T07:11:20.6268581Z,sizewe,1,1600,499
2026-11-03T07:11:21.2778534Z,wait,1,2240,499
2026-11-03T07:11:21.9288721Z,unknown,0,320,1250
2026-11-03T07:11:22.4868707Z,arrow,1,320,499
2026-11-03T07:11:23.8973466Z,ibeam,1,959,600
2026-11-03T07:11:24.2383472Z,sizewe,1,1600,600
2026-11-03T07:11:24.5638838Z,wait,1,2240,600
2026-11-03T07:11:24.8898404Z,arrow,1,320,620
2026-11-03T07:11:25.1063443Z,ibeam,1,959,620
2026-11-03T07:11:25.4318717Z,sizewe,1,1600,620
2026-11-03T07:11:25.7573475Z,wait,1,2240,620
2026-11-03T07:11:25.9898858Z,arrow,1,320,639
2026-11-03T07:11:26.3153463Z,ibeam,1,959,639
2026-11-03T07:11:26.6408719Z,sizewe,1,1600,639
2026-11-03T07:11:26.8578756Z,wait,1,2240,639
2026-11-03T07:11:27.1958524Z,arrow,1,320,659
2026-11-03T07:11:27.5088735Z,ibeam,1,959,659
2026-11-03T07:11:27.7258762Z,sizewe,1,1600,659
2026-11-03T07:11:28.0758556Z,wait,1,2240,659
2026-11-03T07:11:28.3948537Z,arrow,1,320,679
2026-11-03T07:11:28.6093549Z,ibeam,1,959,679
2026-11-03T07:11:28.9348850Z,sizewe,1,1600,679
2026-11-03T07:11:29.1518438Z,wait,1,2240,679
2026-11-03T07:11:29.4773532Z,arrow,1,320,699
2026-11-03T07:11:29.8318419Z,ibeam,1,959,699
2026-11-03T07:11:30.1438730Z,sizewe,1,1600,699
2026-11-03T07:11:30.3608443Z,wait,1,2240,699
2026-11-03T07:11:30.6863784Z,arrow,1,320,720
2026-11-03T07:11:30.9033588Z,ibeam,1,959,720
2026-11-03T07:11:31.2288704Z,sizewe,1,1600,720
2026-11-03T07:11:31.5543568Z,wait,1,2240,720
2026-11-03T07:11:31.8998470Z,arrow,1,320,740
2026-11-03T07:11:32.1123740Z,ibeam,1,959,740
2026-11-03T07:11:32.4378716Z,sizewe,1,1600,740
2026-11-03T07:11:32.7633726Z,wait,1,2240,740

View File

@ -0,0 +1,74 @@
{"version":1,"shape":"wait","visible":true,"x":2308,"y":758,"utc":"2026-11-03T07:07:02.9393420Z"}
utc,x,y,shape
2026-11-03T07:04:27.5718439Z,320,600,arrow
2026-11-03T07:04:27.8688460Z,959,600,ibeam
2026-11-03T07:04:28.1413412Z,1600,600,sizewe
2026-11-03T07:04:28.4368405Z,2240,600,wait
2026-11-03T07:04:28.7398485Z,320,620,arrow
2026-11-03T07:04:29.0323472Z,959,620,ibeam
2026-11-03T07:04:29.3253400Z,1600,620,sizewe
2026-11-03T07:04:29.6178424Z,2240,620,wait
2026-11-03T07:04:29.9088496Z,320,639,arrow
2026-11-03T07:04:30.2033461Z,959,639,ibeam
2026-11-03T07:04:30.4853411Z,1600,639,sizewe
2026-11-03T07:04:30.7708577Z,2240,639,wait
2026-11-03T07:04:31.0543463Z,320,659,arrow
2026-11-03T07:04:31.3768397Z,959,659,ibeam
2026-11-03T07:04:31.6688468Z,1600,659,sizewe
2026-11-03T07:04:31.9598392Z,2240,659,wait
2026-11-03T07:04:32.2533467Z,320,679,arrow
2026-11-03T07:04:32.5598422Z,959,679,ibeam
2026-11-03T07:04:32.8513466Z,1600,679,sizewe
2026-11-03T07:04:33.1398470Z,2240,679,wait
2026-11-03T07:04:33.4373390Z,320,699,arrow
2026-11-03T07:04:33.7618453Z,959,699,ibeam
2026-11-03T07:04:34.0528411Z,1600,699,sizewe
2026-11-03T07:04:34.3553395Z,2240,699,wait
2026-11-03T07:04:34.6193461Z,320,720,arrow
2026-11-03T07:04:34.9018406Z,959,720,ibeam
2026-11-03T07:04:35.1888416Z,1600,720,sizewe
2026-11-03T07:04:35.4698436Z,2240,720,wait
2026-11-03T07:04:35.7783469Z,320,740,arrow
2026-11-03T07:04:36.0693463Z,959,740,ibeam
2026-11-03T07:04:36.3558408Z,1600,740,sizewe
2026-11-03T07:04:36.6408493Z,2240,740,wait
utc,shape,flags,x,y
2026-11-03T07:04:11.1088554Z,wait,1,2426,267
2026-11-03T07:04:20.5148709Z,arrow,1,320,499
2026-11-03T07:04:23.8008747Z,ibeam,1,959,499
2026-11-03T07:04:24.4518732Z,sizewe,1,1600,499
2026-11-03T07:04:25.1028711Z,wait,1,2240,499
2026-11-03T07:04:25.7693710Z,unknown,0,320,1250
2026-11-03T07:04:26.4203719Z,arrow,1,320,499
2026-11-03T07:04:27.8463737Z,ibeam,1,959,600
2026-11-03T07:04:28.1718717Z,sizewe,1,1600,600
2026-11-03T07:04:28.3888745Z,wait,1,2240,600
2026-11-03T07:04:28.7143746Z,arrow,1,320,620
2026-11-03T07:04:29.0463438Z,ibeam,1,959,620
2026-11-03T07:04:29.2568716Z,sizewe,1,1600,620
2026-11-03T07:04:29.5978717Z,wait,1,2240,620
2026-11-03T07:04:29.9258382Z,arrow,1,320,639
2026-11-03T07:04:30.1403684Z,ibeam,1,959,639
2026-11-03T07:04:30.4658710Z,sizewe,1,1600,639
2026-11-03T07:04:30.7918446Z,wait,1,2240,639
2026-11-03T07:04:31.0083608Z,arrow,1,320,659
2026-11-03T07:04:31.3493702Z,ibeam,1,959,659
2026-11-03T07:04:31.6833398Z,sizewe,1,1600,659
2026-11-03T07:04:31.8918706Z,wait,1,2240,659
2026-11-03T07:04:32.2173716Z,arrow,1,320,679
2026-11-03T07:04:32.5428703Z,ibeam,1,959,679
2026-11-03T07:04:32.8683855Z,sizewe,1,1600,679
2026-11-03T07:04:33.0853744Z,wait,1,2240,679
2026-11-03T07:04:33.4263715Z,arrow,1,320,699
2026-11-03T07:04:33.7518751Z,ibeam,1,959,699
2026-11-03T07:04:34.0773750Z,sizewe,1,1600,699
2026-11-03T07:04:34.2943515Z,wait,1,2240,699
2026-11-03T07:04:34.6348401Z,arrow,1,320,720
2026-11-03T07:04:34.8368725Z,ibeam,1,959,720
2026-11-03T07:04:35.1778728Z,sizewe,1,1600,720
2026-11-03T07:04:35.3933486Z,wait,1,2240,720
2026-11-03T07:04:35.7203735Z,arrow,1,320,740
2026-11-03T07:04:36.0458710Z,ibeam,1,959,740
2026-11-03T07:04:36.3713767Z,sizewe,1,1600,740
2026-11-03T07:04:36.5883715Z,wait,1,2240,740

View File

@ -0,0 +1 @@
{"width": 32, "height": 32, "hotX": 8, "hotY": 9, "x": 960, "y": 500, "argb": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4294112499, 4294112499, 4294112499, 0, 4294112499, 4294112499, 4294112499, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4294112499, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4294112499, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4294112499, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4294112499, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4294112499, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4294112499, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4294112499, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4294112499, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4294112499, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4294112499, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4294112499, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4294112499, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4294112499, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4294112499, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4294112499, 4294112499, 4294112499, 0, 4294112499, 4294112499, 4294112499, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]}

View File

@ -0,0 +1 @@
{"version": 1, "shape": "arrow", "visible": true, "expires": 1793689894.0310185}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

@ -0,0 +1,48 @@
[
{
"shape": "arrow",
"x": 320,
"y": 500,
"visible": true
},
{
"shape": "ibeam",
"x": 960,
"y": 500,
"visible": true
},
{
"shape": "sizewe",
"x": 1600,
"y": 500,
"visible": true
},
{
"shape": "wait",
"x": 2240,
"y": 500,
"visible": true
},
{
"shape": "__hidden",
"x": 320,
"y": 1250,
"visible": false
},
{
"test": "client-paused-local-motion",
"before": [
320,
500
],
"after": [
500,
650
],
"passed": true
},
{
"test": "helper-lease-expiry",
"passed": true
}
]

View File

@ -0,0 +1 @@
{"width": 32, "height": 32, "hotX": 11, "hotY": 4, "x": 1600, "y": 500, "argb": [0, 0, 0, 4278848013, 4278387726, 4278387726, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278387726, 4278387726, 4278848013, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278848013, 4278387726, 4294967295, 4278190093, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278190093, 4293980403, 4278387726, 4278848013, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278848013, 4278387726, 4294967295, 4294967295, 4278190093, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278190093, 4294967295, 4293717230, 4278387726, 4278848013, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278848013, 4278387726, 4294967295, 4294967295, 4294967295, 4278387726, 4278387726, 4278387726, 4278387726, 4278387726, 4278387726, 4278387726, 4278387726, 4278387726, 4278387726, 4278387726, 4278387726, 4278387726, 4294967295, 4294967295, 4293454058, 4278387726, 4278848013, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278387726, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4294967295, 4293454058, 4278387726, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278848013, 4278387726, 4294967295, 4294967295, 4294967295, 4278387726, 4278387726, 4278387726, 4278387726, 4278387726, 4278387726, 4278387726, 4278387726, 4278387726, 4278387726, 4278387726, 4278387726, 4278387726, 4294967295, 4294967295, 4293454058, 4278387726, 4278848013, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278848013, 4278387726, 4294967295, 4294967295, 4278190093, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278190093, 4294967295, 4293454058, 4278387726, 4278848013, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278848013, 4278387726, 4294967295, 4278190093, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278190093, 4293454058, 4278387726, 4278848013, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278848013, 4278387726, 4278387726, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4278387726, 4278387726, 4278848013, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]}

View File

@ -0,0 +1,42 @@
{
"01-remote-cursor": {
"bright_pixels": 77,
"bbox": [
295,
474,
380,
559
],
"pixels_above_background_threshold": 77
},
"02-video-cursor-disabled": {
"bright_pixels": 0,
"bbox": [
296,
474,
381,
559
],
"pixels_above_background_threshold": 0
},
"03-local-mode-video-only": {
"bright_pixels": 0,
"bbox": [
2215,
715,
2300,
800
],
"pixels_above_background_threshold": 0
},
"04-remote-cursor-restored": {
"bright_pixels": 0,
"bbox": [
2215,
716,
2300,
801
],
"pixels_above_background_threshold": 226
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

View File

@ -0,0 +1,19 @@
Stopped scoped encoder-session monitor
[{"Name":"Microsoft Basic Display Driver","ConfigManagerErrorCode":0},{"Name":"Virtual Display Driver","ConfigManagerErrorCode":0},{"Name":"NVIDIA GeForce GTX 965M","ConfigManagerErrorCode":0},{"Name":"Microsoft Basic Display Adapter","ConfigManagerErrorCode":0}]
NVIDIA GeForce GTX 965M, 582.78, 10 %
[2026-11-03 16:57:40.500]: Error: Couldn't get default audio endpoint [0x80070490]
[2026-11-03 16:57:40.500]: Error: Unable to initialize audio capture. The stream will not have audio.
Capture size : 2560x1440
[2026-11-03 16:57:41.165]: Info: Desktop resolution [2560x1440]
[2026-11-03 16:57:41.165]: Info: Display refresh rate [60Hz]
[2026-11-03 16:57:41.166]: Info: Creating encoder [h264_nvenc]
Capture size : 2560x1440
[2026-11-03 17:14:13.305]: Info: Desktop resolution [2560x1440]
[2026-11-03 17:14:13.306]: Info: Display refresh rate [60Hz]
[2026-11-03 17:14:13.308]: Info: Creating encoder [h264_nvenc]
probe_task_present=False
backup_exists=True

View File

@ -0,0 +1,7 @@
[{"width":0,"height":0,"description":"Microsoft Basic Display Adapter","primary":false,"active":false,"y":0,"hz":0,"device":"\\\\.\\DISPLAY1","x":0},{"width":0,"height":0,"description":"NVIDIA GeForce GTX 965M","primary":false,"active":false,"y":0,"hz":0,"device":"\\\\.\\DISPLAY2","x":0},{"width":2560,"height":1440,"description":"Virtual Display Driver","primary":true,"active":true,"y":0,"hz":60,"device":"\\\\.\\DISPLAY4","x":0}]
task_result=0

View File

@ -0,0 +1,12 @@
Screen 0: minimum 320 x 200, current 2560 x 1440, maximum 16384 x 16384
HDMI-1 connected primary 2560x1440+0+0 (normal left inverted right x axis y axis) 597mm x 336mm
1920x1440 75.00
2048x1152 60.00
1920x1080 100.00 60.00 60.00 50.00 59.94
1920x1080i 60.00 50.00 59.94
1600x1200 60.00
1680x1050 59.88
1600x900 60.00
1280x1024 60.02
46934 /home/armbian/vdi-build/rkmpp-experiment/build/moonlight/app/moonlight stream 192.168.1.70 Desktop --resolution 2560x1440 --fps 45 --bitrate 30000 --display-mode fullscreen --video-codec H.264 --video-decoder hardware --absolute-mouse --keep-awake --no-vsync --no-frame-pacing --audio-on-host
active

View File

@ -0,0 +1,7 @@
[{"width":0,"height":0,"description":"Microsoft Basic Display Adapter","primary":false,"active":false,"y":0,"hz":0,"device":"\\\\.\\DISPLAY1","x":0},{"width":0,"height":0,"description":"NVIDIA GeForce GTX 965M","primary":false,"active":false,"y":0,"hz":0,"device":"\\\\.\\DISPLAY2","x":0},{"width":2560,"height":1440,"description":"Virtual Display Driver","primary":true,"active":true,"y":0,"hz":60,"device":"\\\\.\\DISPLAY4","x":0}]
task_result=0

View File

@ -0,0 +1,7 @@
[{"width":2560,"height":1440,"description":"Microsoft Basic Display Adapter","primary":false,"active":true,"y":1440,"hz":64,"device":"\\\\.\\DISPLAY1","x":-1024},{"width":0,"height":0,"description":"NVIDIA GeForce GTX 965M","primary":false,"active":false,"y":0,"hz":0,"device":"\\\\.\\DISPLAY2","x":0},{"width":2560,"height":1440,"description":"Virtual Display Driver","primary":true,"active":true,"y":0,"hz":60,"device":"\\\\.\\DISPLAY4","x":0}]
task_result=0

View File

@ -0,0 +1,28 @@
active
enabled
11月 03 17:27:23 rk3528pro-ddr4 systemd[1]: Started vdi-usb-monitor.service - VDI approved USB storage redirection (device POC).
11月 03 17:27:23 rk3528pro-ddr4 vdi-usb-monitor[56130]: USB filesystem is mounted locally; unmount safely before export
11月 03 17:27:25 rk3528pro-ddr4 vdi-usb-monitor[56130]: exporting approved storage 3-1 at 127.0.0.1:40001
11月 03 17:29:02 rk3528pro-ddr4 vdi-usb-monitor[56130]: export session ended: exit status: 0; waiting before retry
11月 03 17:29:04 rk3528pro-ddr4 vdi-usb-monitor[56130]: exporting approved storage 3-1 at 127.0.0.1:40001
11月 03 17:34:43 rk3528pro-ddr4 vdi-usb-monitor[56130]: approved USB device disconnected; waiting for reinsertion
11月 03 17:34:45 rk3528pro-ddr4 vdi-usb-monitor[56130]: waiting for exactly one approved device; found 0
11月 03 17:34:53 rk3528pro-ddr4 vdi-usb-monitor[56130]: exporting approved storage 3-1 at 127.0.0.1:40001
/: Bus 001.Port 001: Dev 001, Class=root_hub, Driver=xhci-hcd/1p, 480M
|__ Port 001: Dev 047, If 0, Class=Human Interface Device, Driver=usbhid, 1.5M
/: Bus 002.Port 001: Dev 001, Class=root_hub, Driver=xhci-hcd/1p, 5000M
/: Bus 003.Port 001: Dev 001, Class=root_hub, Driver=ehci-platform/1p, 480M
|__ Port 001: Dev 006, If 0, Class=Mass Storage, Driver=usbfs, 480M
/: Bus 004.Port 001: Dev 001, Class=root_hub, Driver=ohci-platform/1p, 12M
NAME TRAN FSTYPE LABEL MOUNTPOINTS
mmcblk1 mmc
├─mmcblk1p1 mmc ext4 armbi_boot /boot
└─mmcblk1p2 mmc ext4 armbi_root /var/log.hdd
/
mmcblk1boot0 mmc
mmcblk1boot1 mmc
zram0 [SWAP]
zram1 /var/log
zram2
active
46934 /home/armbian/vdi-build/rkmpp-experiment/build/moonlight/app/moonlight stream 192.168.1.70 Desktop --resolution 2560x1440 --fps 45 --bitrate 30000 --display-mode fullscreen --video-codec H.264 --video-decoder hardware --absolute-mouse --keep-awake --no-vsync --no-frame-pacing --audio-on-host

View File

@ -0,0 +1,16 @@
{
"vm": 100,
"device_present": true,
"channels": [
{
"frontend-open": true,
"filename": "tcp:127.0.0.1:45882 <-> 127.0.0.1:40001",
"label": "vdi_usb_box_100"
}
],
"usb": " Device 0.1, Port 1, Speed 480 Mb/s, Product QEMU USB Tablet, ID: tablet\r\n Device 1.1, Port 2, Speed 480 Mb/s, Product USB Redirection Device, ID: vdi_usb_redir_100\r\n",
"vm_status": {
"status": "running",
"running": true
}
}

View File

@ -0,0 +1,49 @@
{
"USB": [
{
"Model": "Kingston DataTraveler 3.0 USB Device",
"InterfaceType": "USB",
"PNPDeviceID": "USBSTOR\\DISK\u0026VEN_KINGSTON\u0026PROD_DATATRAVELER_3.0\u0026REV_PMAP\\408D5C1653D4E44179789251\u00260",
"Size": 62051512320,
"Index": 1
}
],
"Volume": [
{
"DriveLetter": "F",
"FileSystemLabel": "KINGSTON",
"FileSystem": "FAT32",
"DriveType": "Removable",
"Size": 61908123648,
"HealthStatus": "Healthy"
}
],
"PnP": [
{
"Status": "OK",
"Class": "DiskDrive",
"FriendlyName": "Kingston DataTraveler 3.0 USB Device",
"InstanceId": "USBSTOR\\DISK\u0026VEN_KINGSTON\u0026PROD_DATATRAVELER_3.0\u0026REV_PMAP\\408D5C1653D4E44179789251\u00260"
},
{
"Status": "OK",
"Class": "USB",
"FriendlyName": "USB Mass Storage Device",
"InstanceId": "USB\\VID_0951\u0026PID_1666\\408D5C1653D4E44179789251"
}
],
"GPU": [
{
"Name": "Virtual Display Driver",
"ConfigManagerErrorCode": 0
},
{
"Name": "NVIDIA GeForce GTX 965M",
"ConfigManagerErrorCode": 0
},
{
"Name": "Microsoft Basic Display Adapter",
"ConfigManagerErrorCode": 0
}
]
}

View File

@ -0,0 +1,21 @@
{
"Result": "PASS",
"Disk": [
{
"Model": "Kingston DataTraveler 3.0 USB Device",
"InterfaceType": "USB",
"PNPDeviceID": "USBSTOR\\DISK\u0026VEN_KINGSTON\u0026PROD_DATATRAVELER_3.0\u0026REV_PMAP\\408D5C1653D4E44179789251\u00260",
"Index": 1
}
],
"Volume": [
{
"DriveLetter": "F",
"DriveType": "Removable",
"FileSystemLabel": "KINGSTON",
"FileSystem": "FAT32",
"HealthStatus": "Healthy"
}
],
"SHA256": "8244F1189EFA02118BF0DC4CB68FC8924FE562AC610B68DEEFBE09EC85454D51"
}

View File

@ -0,0 +1,11 @@
{
"Result": "PASS",
"Model": "Kingston DataTraveler 3.0 USB Device",
"InterfaceType": "USB",
"PNPDeviceID": "USBSTOR\\DISK\u0026VEN_KINGSTON\u0026PROD_DATATRAVELER_3.0\u0026REV_PMAP\\408D5C1653D4E44179789251\u00260",
"DriveLetter": "F",
"TestDirectory": "F:\\VDI_USB_TEST_20261103-173342",
"LocalDirectory": "C:\\Windows\\TEMP\\VDI_USB_TEST_20261103-173342",
"Bytes": 1048576,
"SHA256": "8244F1189EFA02118BF0DC4CB68FC8924FE562AC610B68DEEFBE09EC85454D51"
}

View File

@ -1,6 +1,6 @@
# 独立光标二次开发设计与接入条件
状态:源码核实完成,尚未实现 Sunshine/Moonlight 的独立光标通道,不应标记为已部署。当前保留单个 Windows 远程白色光标。
状态:已通过受控的 Windows 光标导出 + 盒子 X11 本地指针原型验证;尚未实现 Sunshine/Moonlight 的自动独立光标通道,不应标记为生产功能已部署。测试已结束,当前恢复单个 Windows 远程白色光标。实测结果见 [独立光标可行性验证](independent-cursor-feasibility-20261103.md)。
## 已核实的实际路径

View File

@ -0,0 +1,53 @@
# 独立光标原型可行性实测(2026-11-03)
## 结论和当前现场
本地独立移动和 Windows 光标静态形状显示可行,值得继续开发 Sunshine + Moonlight 的正式同步协议。本轮完成了真实盒子和 VM100 的受控原型,不是仅做源码调研。但是自动形状同步、视频帧交接、断线回退尚未成为产品功能,不能默认启用,也不能称鼠标或窗口零延迟。
测试已结束,Windows 临时任务 VDICursorPOC 已停止并删除。正常 Moonlight PID31694、2560×1440/45FPS、RKMPP/EGL 和原二进制 SHA256 `35de7b1a4ade583823b23b5f2e012307f6d0a3848c8eb97bd6d4de9660afa00f` 保留。VDD读回 DISPLAY3、2560×1440@60。最后确认本地指针为全透明1×1,屏幕里只有正常Windows远程白色光标;没有遗留测试辅助进程。GTX965M错误码0,编码占用11%,Sunshine该正常会话创建了h264_nvenc。没有重启机器、修改GPU固件/直通、Windows鼠标主题或正式客户端配置。
## 原型做了什么
Windows已登录用户会话运行临时测试窗口,用 GetCursorInfo 读取实际光标状态;GetIconInfo/DrawIconEx 导出默认箭头、文字、左右缩放、忙碌四种真实Windows形状,均为32×32,热点分别为(0,0)、(8,9)、(11,4)、(16,16)。导出仅复制资源并释放 GetIconInfo 返回的位图,没有替换系统主题。通过黑白背景双次绘制重建alpha,检测到依赖背景反色的像素就拒绝;这四个样本未检测到此类像素,不代表全部自定义光标都支持。
管理工作站通过已有的认证PVE guest-agent通道读取导出文件,再经SSH传给盒子。没有新增网络监听端口,盒子没有直接使用PVE凭据。盒子辅助程序在现有Moonlight的X11窗口上创建本地指针,形状由测试脚本按已知测试区域顺序切换。**这是脚本控制的形状回放;没有将GetCursorInfo状态自动、实时同步到客户端。** Windows另存状态变化记录,供交叉核对。
测试临时使用Sunshine的Ctrl+Alt+Shift+N隐藏视频光标,最后成对恢复。它只是原型手段,没有会话确认/租约,不能作为正式交接协议。正常Moonlight二进制未重编或替换;本轮验证的是底层X11本地指针可用性。正式功能应进入Moonlight的SDL主线程,并适配后续Weston/Wayland,不能把这份X11辅助程序当最终架构。
## 实测结果
| 验证项 | 结果与边界 |
| --- | --- |
| 四种形状及热点 | XFixes读取本地指针,尺寸/热点均与Windows导出一致;忙碌只验证一帧,未实现动画 |
| 光标显隐 | 本地隐藏状态为全透明1×1;Windows记录也捕获到隐藏flags=0 |
| 移动独立于视频 | 仅将当前Moonlight进程短暂停住,小于0.5秒,并保证SIGCONT恢复;本地指针仍从(320,500)移动到(500,650),图像保持一致。这证明依赖关系解除,没有测得物理输入到屏幕毫秒数 |
| 单光标受控稳态 | 原始XWD截图在禁用后不再包含远程指针,XFixes另行确认本地指针存在。XWD不包含本地硬件指针,不能将原始截图无指针误报为本地显示失败;未用合成图冒充屏幕截图 |
| 点击正确性 | 最后完整一轮32次跨四区点击,Windows记录32/32区域及形状正确;绝对坐标归一化差值最大1像素,不宣称零偏差 |
| 辅助状态超时 | 0.8秒测试租约过期,辅助程序退出并通过SDL恢复其拥有的隐藏指针;测试控制器随后恢复远程视频光标。没有证明真实控制断网时服务器能自动回退 |
| 输入数据边界 | 10项测试通过,覆盖尺寸、热点、像素长度/范围、重复形状、XOR拒绝、格式、未知形状、过期/异常租约及文件大小 |
最后完整轮次为 `cursor-poc-evidence-v3`。最初两轮已验证形状、移动和点击,但辅助程序退出后当前XFixes图像暂不可用,不能算完整通过;修正后才通过完整流程。没有把前两轮的失败隐藏成成功。
## 暴露的两个实际问题
1. **静止画面的旧光标会留在旧视频帧里。** 第一轮切换快捷键后立即截图,原位置的远程箭头仍在;之后画面更新才消失。最后一轮加入测试窗口时钟更新并等待新帧,禁用后的光标区域变为空背景,恢复后的区域重新出现Windows忙碌光标。正式实现必须用双方确认的视频帧边界切换,不能只延时几百毫秒后直接显示本地箭头。
2. **X11指针资源有连接所有权。** 辅助程序在自己连接上复制一个隐藏指针,再关闭连接,并不足以可靠恢复SDL原先的资源。原型现在在XFixes临时隐藏保护下,使用Moonlight本地光标快捷键成对切换,让SDL重新设置它自己拥有的隐藏指针,再释放辅助资源。此清理依赖同一前台绝对鼠标会话,不能代替正式客户端内部的资源生命周期管理。代码失焦时明确报错,不声称任意退出路径都能可靠恢复。
## 下一阶段执行顺序
1. 固定现场Sunshine版本对应提交,在独立目录准备Windows可复现构建;当前未准备好该服务端C++构建环境。先验证未经修改的构建,再备份/部署测试实例,避免直接替换正常服务。
2. 在现有已认证、加密的控制通道添加能力协商、形状/热点、显隐、状态序号与显示配置代数。Moonlight将有界状态投递SDL主线程,缓存形状,本地移动直接跟随物理输入;旧服务端保持远程模式。
3. Sunshine提供有明确目标状态、帧边界确认和会话租约的光标交接,代替全局toggle;未捕获到可分离光标或形状不支持时回退。锁屏/UAC、远程主动warp、动画、缩放、多会话也须覆盖。
4. 验收断网/重连/失焦、文字选择、拖窗、右键/双击及100%/125%/150%缩放,再用高速摄像分别测指针移动、点击反馈、窗口内容的P50/P95。通过后再开放可关闭的试用开关。
独立光标可以绕开捕获、编码、网络、解码对“指针位置显示”的等待;窗口本身和点击后的页面仍需视频返回。它不会直接解决当前盒子2K60只实渲染约46FPS的问题,视频呈现优化仍按此前对照结论推进。
## 文件与复核
- `tools/cursor-poc-host.ps1`、`tools/run-cursor-poc-host.py`:临时Windows交互任务的start/status/export/stop。不自动触发、不修改Sunshine。
- `tools/cursor-poc-x11.py`:有界资产/状态解析、静态alpha形状、本地租约、SDL所有权归还;必须显式选择同一个前台Moonlight绝对鼠标窗口。仅用于受控实验。
- `tools/verify-cursor-poc-box.py`:受控演示、32点击、短暂停客户端及恢复;**仅可在上述Windows测试窗口位于前台时运行,不能向用户普通桌面盲目注入点击。** 输出目录拒绝覆盖,复跑须使用新目录名。
- `tools/test-cursor-poc.py`:Linux边界测试,不更改指针。
- `docs/evidence/cursor-poc-20261103/`:Windows资产和状态/点击记录、XFixes形状、原始截图转换、首轮失败证据、最终成功结果、超时日志、GPU/显示/本地指针收尾查询。压缩包保留原始XWD。
实现依据:[Microsoft GetCursorInfo](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getcursorinfo)、[GetIconInfo](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-geticoninfo)、[Desktop Duplication光标元数据](https://learn.microsoft.com/en-us/windows/win32/direct3ddxgi/desktop-dup-api)。正式协议及验收要求继续见 [双方二次开发设计](independent-cursor-design-20261103.md)。

View File

@ -2,6 +2,10 @@
这是用户要求“制定计划并按照计划干”后的本轮记录,与此前已部署的两个输入补丁分开。
后续更新:用户确认运维修复后已恢复联调;当前已落地VDD60Hz/客户端45FPS,约43~44FPS渲染,详见`docs/2k60-validation-20261103.md`。下文保留运维期间的历史记录,不再代表当前GPU故障或联调暂停状态。
最新补充:用户要求尝试独立光标,已完成受控X11本地指针原型,四种真实Windows静态形状/热点、隐藏、暂停客户端时独立移动、32/32区域点击和辅助租约超时清理均通过。没有实现自动形状同步或正式双方交接协议;测试已收尾并恢复单个Windows远程白色光标,2K45及正常二进制保留。详见`docs/independent-cursor-feasibility-20261103.md`,勿将下文历史“未实现”理解为本地原型未做,也勿将原型误报为产品已部署。
## 实际进度
用户确认运维正在处理 GPU Code43。测试中 Windows 多次外部关机/启动;本轮未发出任何 VM/设备/宿主重启指令。已暂停 Windows 配置修改和串流测试,继续完成盒子端开发与文档,尚未达到最终性能验收。

View File

@ -37,3 +37,7 @@
用户确认运维正在处理 GPU Code43,已暂停 Windows 配置修改和串流测试,避免同时操作。盒子端新增第三补丁已编译并通过普通/ASAN测试,但当前独立渲染线程没有主线程呈现调用,未证明新调度收益,因此默认客户端已恢复本轮前版本。60Hz对照尚未完成,独立光标只完成设计,尚未实现。
本轮逐项结果、初测数据限制、候选版本和恢复联调步骤见 `docs/mouse-optimization-execution-20261103.md`。独立光标协议和单光标交接要求见 `docs/independent-cursor-design-20261103.md`。
## 运维修复后的继续执行
用户提供GPU修复验收后已恢复联调,现场复核错误码0和实际NVENC。修正测试源对比表明:当前盒子请求2K60实际仅渲染约46FPS,原队列/VSync/nearest实验均未改善;按实测将默认落为VDD60Hz、Moonlight45FPS,实际约43~44FPS,客户端队列约1.3~1.6ms。已保存配置、验证登录显示脚本、从正常入口重连并清理临时任务。完整数据与回退见`docs/2k60-validation-20261103.md`;稳定2K60与独立光标尚未完成。

View File

@ -0,0 +1,35 @@
# Windows仅保留2K主屏(2026-11-03)
用户条件:只有在不影响当前2K屏幕使用时才调整。已完成并读回:Windows仅一个活动桌面输出,保留Virtual Display Driver主屏2560×1440@60,盒子原Moonlight进程46934继续2K45/RKMPP/EGL,截图正常。GTX965M错误码0,Sunshine布局变化后仍创建h264_nvenc,最终编码占用10%。没有重启Windows/盒子、禁用显卡设备、卸载驱动或修改PVE/GPU直通。
## 调整前后
| 输出 | 原布局 | 现布局 |
| --- | --- | --- |
| DISPLAY4 / Virtual Display Driver | 主屏,2560×1440@60,坐标0,0 | 保留原模式和主屏身份,唯一活动输出 |
| DISPLAY1 / Microsoft Basic Display Adapter | 扩展屏,2560×1440@64,坐标-1024,1440 | 从Windows桌面布局停用 |
| DISPLAY2 / NVIDIA GTX965M直接输出 | 原本未激活 | 保持未激活;GPU本身仍正常用于硬件编码 |
这里的“停用”仅指显示输出不参与Windows扩展桌面。设备管理器仍保留显示适配器,Windows设置也可能列出未连接的输出,不能将此操作说成卸载了多余显卡或移除了所有显示设备条目。
本轮VDD编号已是DISPLAY4,早先DISPLAY3记录不能用于写死设备号。工具按唯一活动VDD动态选择,并在修改前强制核对它已是2560×1440@60主屏,不满足条件不修改。
## 实现与验证
新增`tools/set-single-vdi-display.ps1`,Query只查询;Apply先保存设备身份、模式、主屏、布局为XML,使用ChangeDisplaySettingsEx逐项暂存,最后一次提交停用其他活动输出。申请为当前用户保存布局;不添加持续守护或额外登录任务。读回若不是唯一VDD主屏或模式改变,立即尝试恢复本次原布局。已有备份拒绝覆盖。
新增`tools/run-single-vdi-display.py`,通过已有VM100代理,在当前已登录用户会话运行一次性任务。任务已删除,最终`probe_task_present=False`。初次Query编译因缺少System.Xml引用失败,未执行显示修改;补齐引用后查询、应用、独立再次查询均成功。
当前状态已经验证;未通过重启/重新登录检验持久性,以避免中断用户使用。原2K登录脚本仍动态选择VDD,未改分辨率策略。盒子原配置、显示守护和串流参数未修改。
复核时误使用了会持续运行的`nvidia-smi encodersessions`,读取超时后已按进程及父命令精确停止本次监视子进程,再以一次性查询确认GPU健康;没有终止Sunshine或用户应用。Sunshine日志中的16:57音频端点错误早于本次显示调整,不是本次产生的新故障。
## 回退和证据
Windows原布局备份:`C:\ProgramData\VDI\single-display-20261103\before.xml`。
如需恢复,使用`python tools/run-single-vdi-display.py Restore`。恢复前核对显示设备名称、ID和注册表Key,身份变更时拒绝套用旧布局;本轮没有为演示回退再次打断当前单屏状态。
证据目录`docs/evidence/single-display-20261103/`包含before、apply、独立after-query、显卡/编码健康查询及实际2560×1440桌面截图。
API依据:[Microsoft ChangeDisplaySettingsEx](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-changedisplaysettingsexw)。

View File

@ -0,0 +1,117 @@
# U 盘真实 USB 设备透传(2026-11-03)
用户明确选择 Windows 必须识别真实 USB,并配合插盘、换口和 Windows 安全弹出后实际拔插。
当前为一个盒子、一个登记设备、一个 VM 的 POC,不是多用户生产功能。
## 已验证的现场结果
| 项目 | 结果 |
| --- | --- |
| U 盘 | Kingston DataTraveler 3.0,0951:1666,USB 描述符序列号 408D5C1653D4E44179789251 |
| 盒子 | 192.168.1.188,物理路径 3-1,实际 USB2 / 480 Mb/s 链路 |
| Windows | PVE starhe / VM100 / 192.168.1.70,USB Mass Storage 状态 OK |
| 磁盘 | Kingston DataTraveler 3.0 USB Device,InterfaceType=USB,USBSTOR 实例含原序列号 |
| 卷 | KINGSTON(F:),FAT32,Removable,约 57.7 GiB,Healthy |
| 文件读写 | 1 MiB 随机文件:源文件、USB 文件、复制回 VM 的文件 SHA-256 一致 |
| 实物拔插 | 用户先在 Windows 安全弹出,17:34:43 断开、17:34:53 重新导出,Windows F: 自动恢复 |
| 拔插后数据 | 再次读取测试文件,SHA-256 不变 |
| 独占 | 盒子 usbfs 持有接口;透传时 Linux 无该 U 盘的本地块设备挂载 |
| 原有会话 | Moonlight PID46934 / 2K45 保留,显示守护 active,GTX965M 错误码 0 |
只新增 `F:\VDI_USB_TEST_20261103-173342\from-windows.bin` 和 `readme.txt`。
未格式化、改分区、覆盖或删除原文件。源和回读副本在 VM 的
`C:\Windows\TEMP\VDI_USB_TEST_20261103-173342`。
SHA-256:`8244F1189EFA02118BF0DC4CB68FC8924FE562AC610B68DEEFBE09EC85454D51`。
原始验收记录见 [`evidence/usb-device-20261103/`](evidence/usb-device-20261103/)。
自动检查:盒子 Rust 测试 5/5、管理员 QMP 测试 3/3,通过;Python 编译检查和
`git diff --check` 通过。现场还实测了“本地挂载时拒绝导出”,随后正常卸载才导出。
## 实现
```text
盒子物理 USB -> Rust vdi-usb-monitor -> usbredirect/libusb(回环40001)
-> PVE 发起的 SSH 加密隧道 -> QEMU usb-redir(现有 ehci-2.0)
-> Windows 自带 USB Mass Storage / USBSTOR -> F:
```
usbredir 独立于 SPICE;显示通道仍为 Moonlight/Sunshine。
没有安装 Windows 第三方驱动、开测试签名、替换控制器、重启机器或改 GPU 配置。
盒子不调用 PVE API,也不保存 PVE 管理凭据。
Rust 使用标准库,无第三方 crate;等待设备每 2 秒扫描,活动连接每秒检查身份与地址。
按 VID/PID/serial 匹配唯一设备,并使用当前 bus-address 打开精确实例。
设备消失、匹配多实例、含非存储接口、已本地挂载、存在块设备 holder 或被用作 swap 时拒绝。
udev 规则仅对登记设备设置 UDISKS_IGNORE=1,防止本地自动挂载与 Windows 并发写盘。
SSH 使用独立密钥、已有可信主机密钥、源 IP 和 permitopen 限制,禁止该密钥执行 shell。
USB 监听与隧道两端均为回环地址,未暴露 LAN USB 服务端口。
Rust 为终端模块;Python 是管理员部署/QMP 工具;PowerShell 是 Windows 验收工具。
## 部署和管理
盒子先安装 `rustc cargo usbredirect`,在 `/home/armbian/vdi-build/usb-monitor`
运行 `cargo test --offline`、`cargo build --release --offline`。没有整体升级系统。
管理员机器需要 `paramiko`、可信 SSH 主机密钥,以及 PVE SSH 公钥或
`VDI_PVE_SSH_PASSWORD` 环境变量。部署器不保存密码。
```sh
python tools/deploy-usb-device-poc.py --pve 192.168.1.51 --box armbian@192.168.1.188 \
--vm 100 --vendor 0951 --product 1666 --serial 408D5C1653D4E44179789251
```
确认设备身份、关闭本地文件操作并正常卸载对应分区,然后启动盒子服务:
`sudo systemctl start vdi-usb-monitor.service`。本次分区为 `/dev/sda1`,
使用了 `udisksctl unmount --block-device /dev/sda1`;其他环境不能盲目照抄设备名。
在 PVE root 执行:
```sh
python3 /usr/local/libexec/vdi-usb-device-qmp.py attach --vm 100 --vendor 0951 --product 1666
python3 /usr/local/libexec/vdi-usb-device-qmp.py status --vm 100
```
QMP 只管理 `vdi_usb_box_100` / `vdi_usb_redir_100`,不操作已有 tablet、GPU、网卡。
重复 attach 拒绝覆盖;device_add 失败回收仅本次新建 channel。
QEMU filter 内部分隔符为冒号,与 spice-client 的逗号不同。
| 位置 | 文件 |
| --- | --- |
| 盒子 | `/usr/local/libexec/vdi-usb-monitor` |
| 盒子白名单 | `/etc/vdi-usb-monitor/device.env` |
| 盒子挂载策略 | `/etc/udev/rules.d/99-vdi-usb-device-poc.rules` |
| 盒子服务 | `/etc/systemd/system/vdi-usb-monitor.service` |
| PVE 密钥 | `/etc/vdi-usb-poc/vm100/` |
| PVE 隧道 | `/etc/systemd/system/vdi-usb-tunnel-100.service` |
| 盒子原始备份 | `/home/armbian/vdi-terminal/backups/usb-device-20261103-172709/` |
两端服务已 active/enabled,未通过重启验证开机顺序。重新部署前须在 Windows 安全弹出,
停止盒子导出服务;部署器拒绝覆盖运行中的服务。
## 限制
- 仅登记的这一只 U 盘;其他设备需管理员登记。没有多终端隔离、会话授权/切换或 Qt 状态提示。
- 固定 IP、单端口、单 VM POC。QMP 设备仅存在于当前运行实例,VM/PVE 重启后须重新 attach;
没有修改原 VM args 或 GPU 启动设置。
- 验证了当前 FAT32 U 盘、1 MiB 读写和一次实物热插拔;未测大文件吞吐、NTFS/exFAT、
写入中断网/掉电恢复或长期老化。
- 最初接口报 USB 枚举 -71,换到 3-1 对应口才成功;并非软件修好了原口,也未验证 USB3 速度。
- 使用后正常安全弹出;程序不会重试用户文件写入,不能保证强拔/断网时的数据完整性。
## 回退
1. 关闭 U 盘文件,在 Windows 正常安全弹出。
2. PVE:`python3 /usr/local/libexec/vdi-usb-device-qmp.py detach --vm 100 --guest-ejected`。
3. 盒子:`sudo systemctl disable --now vdi-usb-monitor.service`;PVE:
`systemctl disable --now vdi-usb-tunnel-100.service`。
4. 仅移除/恢复本功能 udev 规则,`udevadm control --reload-rules`,再拔插让 Linux 本地重新识别。
5. 如移除密钥,只删 authorized_keys 中 `vdi-usb-poc-vm100` 对应公钥行;保留其他公钥,
不直接覆盖整份可能已经更新的 authorized_keys。
## 官方依据
- [usbredir 独立协议](https://www.spice-space.org/usbredir.html)
- [Ubuntu usbredirect](https://manpages.ubuntu.com/manpages/noble/man1/usbredirect.1.html)
- [QEMU USB 接入](https://www.qemu.org/docs/master/system/devices/usb.html)
- [QEMU filter 实现](https://github.com/qemu/qemu/blob/master/hw/usb/redirect.c)

View File

@ -38,3 +38,7 @@ VDI_TEST_SANITIZE=1 ASAN_OPTIONS=detect_leaks=1 bash tools/test-moonlight-latenc
新增 `test_input_priority.cpp` 验证优先调度不越过焦点/窗口/退出/解码器重置事件,保持输入 FIFO,隔离其他窗口与合成触摸,并验证数量与时间限制。普通与 ASAN 版本均通过。当前 RKMPP/EGL 没有主线程呈现事件,新调度开关不能用作该路径的性能提升宣传。
现场数据、局限与完整回退入口见 `docs/input-rendering-optimization-20261103.md`。
GPU修复后实测见`docs/2k60-validation-20261103.md`:当前正常二进制保持不变,VDD60Hz/串流45FPS已配置并从kiosk重连;约43~44FPS渲染。2K60请求受当前盒子呈现路径限制,不能宣称稳定60。`experiments/0004-vdi-egl-exact-size-sampling.patch`为未采用的精确尺寸采样实验,未进入正常构建器;默认仍为原GL_LINEAR取样。该实验候选二进制也未被正常wrapper使用。
独立光标已完成受控X11辅助程序原型,结果见`docs/independent-cursor-feasibility-20261103.md`。尚无自动服务端形状同步和可靠帧交接,未加入正常构建/启动流程;不能把Ctrl+Alt+Shift+C显示本地箭头和Sunshine全局N切换直接当生产方案。现有客户端保持单个Windows远程白色光标。

View File

@ -0,0 +1,49 @@
--- a/app/streaming/video/ffmpeg-renderers/eglvid.h
+++ b/app/streaming/video/ffmpeg-renderers/eglvid.h
@@ -57,6 +57,8 @@
int m_GlesMajorVersion;
int m_GlesMinorVersion;
bool m_HasExtUnpackSubimage;
+ bool m_VdiNearestOneToOne;
+ int m_VdiTextureFilter;
#define NV12_PARAM_YUVMAT 0
#define NV12_PARAM_OFFSET 1
--- a/app/streaming/video/ffmpeg-renderers/eglvid.cpp
+++ b/app/streaming/video/ffmpeg-renderers/eglvid.cpp
@@ -75,7 +75,9 @@
m_eglClientWaitSync(nullptr),
m_GlesMajorVersion(0),
m_GlesMinorVersion(0),
- m_HasExtUnpackSubimage(false)
+ m_HasExtUnpackSubimage(false),
+ m_VdiNearestOneToOne(qEnvironmentVariableIntValue("VDI_EGL_NEAREST_1TO1") == 1),
+ m_VdiTextureFilter(GL_LINEAR)
{
SDL_assert(backendRenderer);
SDL_assert(backendRenderer->canExportEGL());
@@ -812,6 +814,24 @@
dst.w = drawableWidth;
dst.h = drawableHeight;
StreamUtils::scaleSourceToDestinationSurface(&src, &dst);
+
+ // At exact pixel size no resampling is needed. Keep linear filtering for
+ // scaled video and all other backends. Never change overlay filtering.
+ if (m_VdiNearestOneToOne && m_EGLImagePixelFormat == AV_PIX_FMT_DRM_PRIME) {
+ const int filter = dst.w == frame->width && dst.h == frame->height ? GL_NEAREST : GL_LINEAR;
+ if (filter != m_VdiTextureFilter) {
+ for (ssize_t i = 0; i < plane_count; ++i) {
+ glActiveTexture(GL_TEXTURE0 + i);
+ glBindTexture(GL_TEXTURE_EXTERNAL_OES, m_Textures[i]);
+ glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GL_TEXTURE_MIN_FILTER, filter);
+ glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GL_TEXTURE_MAG_FILTER, filter);
+ }
+ m_VdiTextureFilter = filter;
+ EGL_LOG(Info, "VDI video texture filter: %s (%dx%d -> %dx%d)",
+ filter == GL_NEAREST ? "nearest" : "linear",
+ frame->width, frame->height, dst.w, dst.h);
+ }
+ }
glViewport(dst.x, dst.y, dst.w, dst.h);
glUseProgram(m_ShaderProgram);

1
terminal/usb-monitor/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target/

7
terminal/usb-monitor/Cargo.lock generated Normal file
View File

@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "vdi-usb-monitor"
version = "0.1.0"

View File

@ -0,0 +1,11 @@
[package]
name = "vdi-usb-monitor"
version = "0.1.0"
edition = "2021"
rust-version = "1.75"
[dependencies]
[profile.release]
strip = true
opt-level = "s"

View File

@ -0,0 +1,28 @@
# USB 设备透传 POC
将盒子上指定的 U 盘经 usbredir + SSH + QEMU 接入 Windows,枚举真实 VID/PID/序列号和
USBSTOR,使用 Windows 自带驱动。这是 Moonlight 显示会话之外的设备通道,不是网络文件盘。
当前只支持明确登记的一个纯 USB 存储设备、一个盒子和一个 VM;盒子不调用 Proxmox API。
- `src/main.rs`:Rust 标准库实现设备扫描、精确匹配、本地挂载/占用保护、导出及拔插恢复。
- `etc/vdi-usb-monitor.service`:盒子服务,读取 root 管理的单设备白名单。
- `../../tools/deploy-usb-device-poc.py`:管理员部署和受限 SSH 隧道。
- `../../tools/usb-device-qmp.py`:在 PVE 节点操作独立 QMP 热插拔端点。
- `../../tools/test-usb-device-poc.ps1`:Windows USB 身份和文件回读校验。
在 ARM64 Linux 上安装 `rustc cargo usbredirect`,运行:
```sh
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
```
`list` 输出 JSON。`serve` 必须指定非空序列号,只能监听 IPv4 回环地址;
拒绝键鼠、Hub、包含非存储接口的复合设备以及本地尚在使用的磁盘。
本地已挂载时不会强制卸载,应先关闭本地文件操作并正常卸载。
完整现场、部署、限制和回退见
[`docs/usb-device-poc-20261103.md`](../../docs/usb-device-poc-20261103.md)。

View File

@ -0,0 +1,22 @@
[Unit]
Description=VDI approved USB storage redirection (device POC)
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
Restart=on-failure
RestartSec=3
KillMode=control-group
TimeoutStopSec=10
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
UMask=0077
Nice=10
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,233 @@
//! 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};
#[derive(Debug, Clone)]
struct Device {
path: PathBuf,
bus_id: String,
vendor: String,
product: String,
serial: String,
name: String,
bus: u16,
address: u16,
classes: Vec<String>,
device_class: String,
}
fn read(path: &Path, name: &str) -> String {
fs::read_to_string(path.join(name)).unwrap_or_default().trim().to_string()
}
fn storage_only(device_class: &str, classes: &[String]) -> bool {
matches!(device_class, "00" | "08") && !classes.is_empty()
&& classes.iter().all(|class| class == "08")
}
fn scan(root: &Path) -> Result<Vec<Device>, String> {
let entries: Vec<_> = fs::read_dir(root).map_err(|e| e.to_string())?
.filter_map(Result::ok).collect();
let mut devices = Vec::new();
for entry in &entries {
let p = entry.path();
let vendor = read(&p, "idVendor");
if vendor.is_empty() { continue; }
let bus_id = entry.file_name().to_string_lossy().into_owned();
let prefix = format!("{}:", bus_id);
let mut classes: Vec<_> = entries.iter().filter(|e|
e.file_name().to_string_lossy().starts_with(&prefix))
.map(|e| read(&e.path(), "bInterfaceClass")).collect();
classes.sort();
devices.push(Device {
path: p.canonicalize().map_err(|e| e.to_string())?,
bus_id, vendor, product: read(&p, "idProduct"), serial: read(&p, "serial"),
name: read(&p, "product"), bus: read(&p, "busnum").parse().unwrap_or(0),
address: read(&p, "devnum").parse().unwrap_or(0),
classes, device_class: read(&p, "bDeviceClass"),
});
}
devices.sort_by(|a, b| a.bus_id.cmp(&b.bus_id));
Ok(devices)
}
fn mounted_devices(mountinfo: &str, numbers: &BTreeSet<String>) -> bool {
mountinfo.lines().any(|line| line.split_whitespace().nth(2)
.map(|n| numbers.contains(n)).unwrap_or(false))
}
fn check_local_use(device: &Device) -> Result<(), String> {
let mut numbers = BTreeSet::new();
for entry in fs::read_dir("/sys/class/block").map_err(|e| e.to_string())? {
let p = entry.map_err(|e| e.to_string())?.path();
if !p.canonicalize().map_err(|e| e.to_string())?.starts_with(&device.path) {
continue;
}
numbers.insert(read(&p, "dev"));
if fs::read_dir(p.join("holders")).map_err(|e| e.to_string())?.next().is_some() {
return Err("block device has holders (RAID/LVM/encryption); refusing export".into());
}
let swaps = fs::read_to_string("/proc/swaps").map_err(|e| e.to_string())?;
if swaps.lines().skip(1).filter_map(|l| l.split_whitespace().next())
.any(|s| Path::new(s).canonicalize().ok() ==
Path::new("/dev").join(p.file_name().unwrap()).canonicalize().ok()) {
return Err("USB device is used as swap".into());
}
}
let mounts = fs::read_to_string("/proc/self/mountinfo").map_err(|e| e.to_string())?;
if mounted_devices(&mounts, &numbers) {
return Err("USB filesystem is mounted locally; unmount safely before export".into());
}
Ok(())
}
fn json_string(s: &str) -> String {
let mut out = String::from("\"");
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"), '"' => out.push_str("\\\""),
c if c.is_control() => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"'); out
}
fn hex_id(s: &str) -> bool {
s.len() == 4 && s.bytes().all(|c| c.is_ascii_hexdigit())
}
#[derive(Debug)]
struct Policy { vendor: String, product: String, serial: String, 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 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());
}
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());
}
Ok(d)
}
fn run() -> Result<(), String> {
let args: Vec<_> = env::args().skip(1).collect();
if args == ["list"] {
let devices = scan(Path::new("/sys/bus/usb/devices"))?;
println!("[{}]", devices.iter().map(|d| format!(
"{{\"bus_id\":{},\"vendor\":{},\"product\":{},\"serial\":{},\"name\":{},\"storage_only\":{}}}",
json_string(&d.bus_id), json_string(&d.vendor), json_string(&d.product),
json_string(&d.serial), json_string(&d.name), storage_only(&d.device_class, &d.classes)))
.collect::<Vec<_>>().join(","));
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());
}
let policy = parse_policy(&args[1..])?;
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)?;
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")
.args(["--device", &selector, "--as", &policy.listen.to_string(), "--keepalive"])
.spawn().map_err(|e| e.to_string())?;
loop {
if let Some(status) = child.try_wait().map_err(|e| e.to_string())? {
return Err(format!("export session ended: {status}; waiting before retry"));
}
thread::sleep(Duration::from_secs(1));
let present = scan(Path::new("/sys/bus/usb/devices")).ok().map(|current| {
select(&current, &policy).map(|d| d.bus == device.bus
&& d.address == device.address && d.bus_id == device.bus_id).unwrap_or(false)
}).unwrap_or(false);
if !present {
let _ = child.kill();
let _ = child.wait();
return Err("approved USB device disconnected; waiting for reinsertion".into());
}
}
});
if let Err(message) = result {
if message != last { eprintln!("{message}"); last = message; }
}
thread::sleep(Duration::from_secs(2));
}
}
fn main() {
if let Err(e) = run() { eprintln!("{e}"); process::exit(1); }
}
#[cfg(test)]
mod tests {
use super::*;
fn policy(extra: &str) -> Vec<String> {
format!("--vendor 0951 --product 1666 --serial TEST --listen {extra}")
.split_whitespace().map(String::from).collect()
}
#[test]
fn refuses_hid_composites_and_hubs() {
assert!(storage_only("00", &["08".into()]));
assert!(!storage_only("00", &["08".into(), "03".into()]));
assert!(!storage_only("09", &["08".into()]));
assert!(!storage_only("00", &[]));
}
#[test]
fn export_listener_cannot_be_exposed_to_lan() {
assert!(parse_policy(&policy("127.0.0.1:40001")).is_ok());
for address in ["0.0.0.0:40001", "192.168.1.188:40001", "127.0.0.1:0"] {
assert!(parse_policy(&policy(address)).is_err());
}
}
#[test]
fn mounted_partition_blocks_parent_disk_export() {
let numbers = BTreeSet::from(["8:0".into(), "8:1".into()]);
assert!(mounted_devices("24 1 8:1 / /media/KINGSTON rw - vfat /dev/sda1 rw", &numbers));
assert!(!mounted_devices("24 1 179:2 / / rw - ext4 /dev/mmcblk1p2 rw", &numbers));
}
#[test]
fn wrong_serial_and_duplicate_devices_are_not_exported() {
let p = parse_policy(&policy("127.0.0.1:40001")).unwrap();
let mut d = Device {
path: PathBuf::from("/sys/test"), bus_id: "3-1".into(),
vendor: "0951".into(), product: "1666".into(), serial: "OTHER".into(),
name: "storage".into(), bus: 3, address: 5,
classes: vec!["08".into()], device_class: "00".into(),
};
assert!(select(&[d.clone()], &p).is_err());
d.serial = "TEST".into();
assert!(select(&[d.clone()], &p).is_ok());
assert!(select(&[d.clone(), d], &p).is_err());
}
#[test]
fn enumeration_json_escapes_control_characters() {
assert_eq!(json_string("a\n\"\\"), "\"a\\u000a\\\"\\\\\"");
}
}

View File

@ -6,9 +6,11 @@ task_root="${VDI_RKMPP_BUILD_ROOT:-$HOME/vdi-build/rkmpp-experiment}"
label="${1:?label required}"
fps="${2:?FPS required}"
priority="${3:-0}"
hold="${VDI_BENCH_HOLD_SECONDS:-12}"
[[ "$label" =~ ^[a-zA-Z0-9-]+$ ]] || exit 2
[[ "$fps" = 30 || "$fps" = 60 ]] || exit 2
[[ "$fps" = 30 || "$fps" = 45 || "$fps" = 60 ]] || exit 2
[[ "$priority" = 0 || "$priority" = 1 ]] || exit 2
[[ "$hold" =~ ^[0-9]+$ ]] && ((hold <= 180)) || exit 2
exec 9>"$task_root/mouse-bench.lock"
flock -n 9 || { echo 'Another benchmark is active'; exit 1; }
if pgrep -f '^/home/armbian/vdi-build/rkmpp-experiment/build/moonlight/app/moonlight.* stream ' >/dev/null; then
@ -20,8 +22,12 @@ export LD_LIBRARY_PATH="$task_root/runtime/lib:$task_root/deps/usr/lib/aarch64-l
export QML2_IMPORT_PATH="$task_root/deps/usr/lib/aarch64-linux-gnu/qt5/qml"
export QT_PLUGIN_PATH="$task_root/deps/usr/lib/aarch64-linux-gnu/qt5/plugins"
export H264_DECODER_HINT=h264_rkmpp HEVC_DECODER_HINT=hevc_rkmpp
export VDI_LATEST_FRAME=1 VDI_INPUT_TIMING=1 VDI_INPUT_PRIORITY="$priority"
export VDI_LATEST_FRAME="${VDI_LATEST_FRAME:-1}" VDI_INPUT_TIMING=1 VDI_INPUT_PRIORITY="$priority"
export GL_IS_SLOW=0 SDL_RENDER_DRIVER=opengles2 SDL_VIDEO_X11_NET_WM_BYPASS_COMPOSITOR=1
sync_args=(--no-vsync --no-frame-pacing)
if [ "${VDI_BENCH_VSYNC:-0}" = 1 ]; then
sync_args=(--vsync --no-frame-pacing)
fi
client="${VDI_RKMPP_CLIENT:-$task_root/build/moonlight/app/moonlight}"
mkdir -p "$task_root/mouse-bench"
log="$task_root/mouse-bench/$label.log"
@ -43,8 +49,8 @@ trap 'exit 130' INT
trap 'exit 143' TERM
"$client" stream 192.168.1.70 Desktop --resolution 2560x1440 --fps "$fps" \
--bitrate 30000 --display-mode fullscreen --video-codec H.264 \
--video-decoder hardware --absolute-mouse --keep-awake --no-vsync \
--no-frame-pacing --audio-on-host > "$log" 2>&1 &
--video-decoder hardware --absolute-mouse --keep-awake "${sync_args[@]}" \
--audio-on-host > "$log" 2>&1 &
child=$!
ready=0
for ((i=0;i<35;i++)); do
@ -56,7 +62,10 @@ test "$ready" = 1 || { echo 'No video readiness'; exit 1; }
echo "Ready: $label; injecting motion"
python3 "$task_root/mouse-bench/inject-mouse-sweep.py"
python3 "$task_root/mouse-bench/inject-input-burst.py"
sleep 12
for ((i=0;i<hold;i++)); do
kill -0 "$child" || { echo 'Benchmark stream exited during hold'; exit 1; }
sleep 1
done
# Do not send the exit chord into an unrelated foreground application.
focus_pid=$(xdotool getwindowfocus getwindowpid 2>/dev/null || true)
if [ "$focus_pid" != "$child" ]; then

122
tools/cursor-poc-host.ps1 Normal file
View File

@ -0,0 +1,122 @@
# Scoped interactive cursor probe. No theme, driver, display or Sunshine changes.
$ErrorActionPreference='Stop'
$folder='C:\ProgramData\VDI\cursor-poc-20261103'
Set-Content "$folder\pid.txt" $PID
try {
Add-Type -ReferencedAssemblies System.Windows.Forms,System.Drawing -TypeDefinition @'
using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Runtime.InteropServices;
using System.Windows.Forms;
public class VDICursorShape {
public string name; public int width,height,hotX,hotY,invertedPixels;
public bool monochrome; public uint[] argb;
}
public class VDICursorProbe : Form {
[StructLayout(LayoutKind.Sequential)] struct POINT { public int x,y; }
[StructLayout(LayoutKind.Sequential)] struct CI { public int size,flags; public IntPtr cursor; public POINT point; }
[StructLayout(LayoutKind.Sequential)] struct II { public bool icon; public int x,y; public IntPtr mask,color; }
[DllImport("user32.dll")] static extern bool GetCursorInfo(ref CI ci);
[DllImport("user32.dll")] static extern bool GetIconInfo(IntPtr h,out II info);
[DllImport("user32.dll")] static extern bool DrawIconEx(IntPtr dc,int x,int y,IntPtr icon,int w,int h,int step,IntPtr brush,int flags);
[DllImport("gdi32.dll")] static extern bool DeleteObject(IntPtr h);
[DllImport("user32.dll")] public static extern bool SetProcessDPIAware();
public static readonly string[] Names={"arrow","ibeam","sizewe","wait"};
static readonly Cursor[] Shapes={Cursors.Default,Cursors.IBeam,Cursors.SizeWE,Cursors.WaitCursor};
static Bitmap Draw(IntPtr handle,int w,int h,Color background) {
Bitmap result=new Bitmap(w,h,System.Drawing.Imaging.PixelFormat.Format32bppArgb);
using(Graphics g=Graphics.FromImage(result)) {
g.Clear(background); IntPtr dc=g.GetHdc();
try { if(!DrawIconEx(dc,0,0,handle,w,h,0,IntPtr.Zero,3)) throw new Exception("DrawIconEx failed"); }
finally { g.ReleaseHdc(dc); }
}
return result;
}
public static VDICursorShape Export(string name,IntPtr handle) {
II info; if(!GetIconInfo(handle,out info)) throw new Exception("GetIconInfo failed");
try {
int w,h;
using(Bitmap mask=Bitmap.FromHbitmap(info.mask)) { w=mask.Width; h=mask.Height/(info.color==IntPtr.Zero?2:1); }
if(w<1 || h<1 || w>128 || h>128) throw new Exception("Unsupported cursor dimensions");
VDICursorShape s=new VDICursorShape {name=name,width=w,height=h,hotX=info.x,hotY=info.y,monochrome=info.color==IntPtr.Zero,argb=new uint[w*h]};
using(Bitmap black=Draw(handle,w,h,Color.Black))
using(Bitmap white=Draw(handle,w,h,Color.White)) {
for(int y=0;y<h;y++) for(int x=0;x<w;x++) {
Color b=black.GetPixel(x,y), z=white.GetPixel(x,y);
int dr=z.R-b.R,dg=z.G-b.G,db=z.B-b.B;
// XOR/inverted cursors need destination-dependent composition.
// Preserve the unsupported count; never silently call them alpha cursors.
if(dr<0 || dg<0 || db<0 || Math.Abs(dr-dg)>2 || Math.Abs(dr-db)>2) { ++s.invertedPixels; continue; }
int a=255-dr;
if(a==0) continue;
uint r=(uint)Math.Min(255,(b.R*255+a/2)/a),g=(uint)Math.Min(255,(b.G*255+a/2)/a),bl=(uint)Math.Min(255,(b.B*255+a/2)/a);
s.argb[y*w+x]=((uint)a<<24)|(r<<16)|(g<<8)|bl;
}
}
return s;
} finally { if(info.mask!=IntPtr.Zero) DeleteObject(info.mask); if(info.color!=IntPtr.Zero) DeleteObject(info.color); }
}
public static VDICursorShape[] ExportAll() {
List<VDICursorShape> result=new List<VDICursorShape>();
for(int i=0;i<Names.Length;i++) result.Add(Export(Names[i],Shapes[i].Handle));
return result.ToArray();
}
readonly Timer timer=new Timer(); readonly string folder; readonly DateTime started=DateTime.UtcNow;
bool hidden; string shape="arrow",lastObserved=""; int clicks;
public VDICursorProbe(string path) {
folder=path; Text="VDI Independent Cursor Probe"; FormBorderStyle=FormBorderStyle.None;
Bounds=Screen.PrimaryScreen.Bounds; TopMost=true; DoubleBuffered=true;
File.WriteAllText(Path.Combine(folder,"clicks.csv"),"utc,x,y,shape\n");
File.WriteAllText(Path.Combine(folder,"states.csv"),"utc,shape,flags,x,y\n");
timer.Interval=100;
timer.Tick+=delegate {
if((DateTime.UtcNow-started).TotalMinutes>8) { Close(); return; }
CI ci=new CI(); ci.size=Marshal.SizeOf(typeof(CI));
if(!GetCursorInfo(ref ci)) return;
string observed="unknown";
for(int i=0;i<Shapes.Length;i++) if(ci.cursor==Shapes[i].Handle) observed=Names[i];
string observedKey=observed+","+ci.flags;
if(observedKey!=lastObserved) {
File.AppendAllText(Path.Combine(folder,"states.csv"),DateTime.UtcNow.ToString("o")+","+observedKey+","+ci.point.x+","+ci.point.y+"\n");
lastObserved=observedKey;
}
string json="{\"version\":1,\"shape\":\""+observed+"\",\"visible\":"+(((ci.flags&1)!=0)?"true":"false")+",\"x\":"+ci.point.x+",\"y\":"+ci.point.y+",\"utc\":\""+DateTime.UtcNow.ToString("o")+"\"}";
string target=Path.Combine(folder,"state.json"),temp=target+".new";
File.WriteAllText(temp,json);
if(File.Exists(target)) File.Replace(temp,target,null); else File.Move(temp,target);
Invalidate(new Rectangle(0,0,Width,140));
};
timer.Start();
}
protected override void OnMouseMove(MouseEventArgs e) {
base.OnMouseMove(e);
bool nextHidden=e.Y>Height*3/4;
if(nextHidden!=hidden) { if(nextHidden) Cursor.Hide(); else Cursor.Show(); hidden=nextHidden; }
int index=Math.Min(3,e.X*4/Math.Max(1,Width)); shape=Names[index]; Cursor=Shapes[index];
}
protected override void OnMouseDown(MouseEventArgs e) {
base.OnMouseDown(e); ++clicks;
File.AppendAllText(Path.Combine(folder,"clicks.csv"),DateTime.UtcNow.ToString("o")+","+e.X+","+e.Y+","+shape+"\n"); Invalidate();
}
protected override void OnPaint(PaintEventArgs e) {
e.Graphics.Clear(Color.FromArgb(24,32,44));
using(Font f=new Font("Consolas",23)) for(int i=0;i<4;i++) {
e.Graphics.DrawRectangle(Pens.SlateGray,i*Width/4,150,Width/4-2,Height*3/4-150);
e.Graphics.DrawString(Names[i],f,Brushes.White,i*Width/4+40,200);
}
using(Font f=new Font("Consolas",24)) {
e.Graphics.DrawString("Independent cursor probe | clicks: "+clicks+" | "+DateTime.UtcNow.ToString("HH:mm:ss.fff"),f,Brushes.White,40,40);
e.Graphics.DrawString("BOTTOM AREA: cursor hidden",f,Brushes.Gold,40,Height*3/4+60);
}
}
protected override void OnFormClosed(FormClosedEventArgs e) {
timer.Stop(); timer.Dispose(); if(hidden) { Cursor.Show(); hidden=false; } base.OnFormClosed(e);
}
}
'@
[VDICursorProbe]::SetProcessDPIAware() | Out-Null
@{version=1; shapes=[VDICursorProbe]::ExportAll()} | ConvertTo-Json -Depth 6 -Compress | Set-Content -Encoding utf8 "$folder\shapes.json"
[System.Windows.Forms.Application]::Run((New-Object VDICursorProbe($folder)))
} catch { $_ | Out-String | Set-Content "$folder\error.txt"; throw }

223
tools/cursor-poc-x11.py Normal file
View File

@ -0,0 +1,223 @@
#!/usr/bin/env python3
"""Temporary X11 local-cursor proof, NOT a production Moonlight cursor protocol.
Requires an already authenticated Moonlight session. Never changes Sunshine.
The caller owns remote-video-cursor handoff and must restore it in finally.
Only supports exported alpha cursors without destination-dependent XOR pixels.
"""
import argparse
import ctypes as C
import fcntl
import json
import math
import os
from pathlib import Path
import signal
import subprocess
import time
class XcursorImage(C.Structure):
_fields_ = [("version", C.c_uint32), ("size", C.c_uint32),
("width", C.c_uint32), ("height", C.c_uint32),
("xhot", C.c_uint32), ("yhot", C.c_uint32),
("delay", C.c_uint32), ("pixels", C.POINTER(C.c_uint32))]
class XfixesImage(C.Structure):
_fields_ = [("x", C.c_short), ("y", C.c_short),
("width", C.c_ushort), ("height", C.c_ushort),
("xhot", C.c_ushort), ("yhot", C.c_ushort),
("serial", C.c_ulong), ("pixels", C.POINTER(C.c_ulong)),
("atom", C.c_ulong), ("name", C.c_char_p)]
def validate_assets(data):
if not isinstance(data, dict) or type(data.get("version")) is not int or data.get("version") != 1 or not isinstance(data.get("shapes"), list) or not 1 <= len(data["shapes"]) <= 32:
raise ValueError("Invalid asset version/count")
result = {}
for s in data["shapes"]:
if not isinstance(s, dict):
raise ValueError("Invalid shape")
name = s.get("name")
if not isinstance(name, str) or not name.isascii() or not name.isalnum() or len(name) > 32 or name in result:
raise ValueError("Invalid/duplicate shape name")
for key in ("width", "height", "hotX", "hotY", "invertedPixels"):
if type(s.get(key)) is not int:
raise ValueError("Noninteger cursor metadata")
w, h = s["width"], s["height"]
if not 1 <= w <= 128 or not 1 <= h <= 128 or not 0 <= s["hotX"] < w or not 0 <= s["hotY"] < h:
raise ValueError("Invalid size/hotspot")
if s["invertedPixels"] != 0:
raise ValueError("Destination-dependent XOR cursor requires fallback")
pixels = s.get("argb")
if not isinstance(pixels, list) or len(pixels) != w*h or any(type(p) is not int or not 0 <= p <= 0xffffffff for p in pixels):
raise ValueError("Invalid ARGB pixels")
result[name] = s
return result
def validate_state(state, shapes):
if not isinstance(state, dict) or type(state.get("version")) is not int or state.get("version") != 1 or type(state.get("visible")) is not bool or not isinstance(state.get("shape"), str) or state.get("shape") not in shapes:
raise ValueError("Invalid state/unknown shape")
# A bounded wall-clock lease prevents stale local pointers after the bridge dies.
expires = state.get("expires")
if type(expires) not in (int, float) or not math.isfinite(expires) or not time.time() < expires <= time.time()+5:
raise ValueError("Expired/invalid state lease")
return state["shape"] if state["visible"] else "__hidden"
def read_json(path, limit):
with open(path, "rb") as f:
raw = f.read(limit+1)
if len(raw) > limit:
raise ValueError("Oversize JSON")
return json.loads(raw)
class LocalCursor:
def __init__(self, window):
self.x = C.CDLL("libX11.so.6")
self.c = C.CDLL("libXcursor.so.1")
self.f = C.CDLL("libXfixes.so.3")
self.x.XOpenDisplay.argtypes = [C.c_char_p]; self.x.XOpenDisplay.restype = C.c_void_p
self.x.XDefineCursor.argtypes = [C.c_void_p, C.c_ulong, C.c_ulong]
self.x.XSync.argtypes = [C.c_void_p, C.c_int]
self.x.XFree.argtypes = [C.c_void_p]
self.x.XFreeCursor.argtypes = [C.c_void_p, C.c_ulong]
self.x.XCloseDisplay.argtypes = [C.c_void_p]
self.f.XFixesGetCursorImage.argtypes = [C.c_void_p]; self.f.XFixesGetCursorImage.restype = C.POINTER(XfixesImage)
self.f.XFixesHideCursor.argtypes = [C.c_void_p, C.c_ulong]
self.f.XFixesShowCursor.argtypes = [C.c_void_p, C.c_ulong]
self.c.XcursorImageCreate.argtypes = [C.c_int, C.c_int]; self.c.XcursorImageCreate.restype = C.POINTER(XcursorImage)
self.c.XcursorImageDestroy.argtypes = [C.POINTER(XcursorImage)]
self.c.XcursorImageLoadCursor.argtypes = [C.c_void_p, C.POINTER(XcursorImage)]; self.c.XcursorImageLoadCursor.restype = C.c_ulong
self.d = self.x.XOpenDisplay(None)
if not self.d:
raise RuntimeError("Cannot open X display")
self.window = window
self.cursors = {}
self.original = self.create(self.snapshot(), premultiplied=True)
def snapshot(self):
image = self.f.XFixesGetCursorImage(self.d)
if not image:
raise RuntimeError("No XFixes cursor image")
try:
i = image.contents
if not 1 <= i.width <= 128 or not 1 <= i.height <= 128:
raise RuntimeError("Unsupported X cursor dimensions")
return dict(width=i.width, height=i.height, hotX=i.xhot, hotY=i.yhot,
x=i.x, y=i.y, argb=[int(i.pixels[p]) & 0xffffffff for p in range(i.width*i.height)])
finally:
self.x.XFree(image)
def create(self, s, premultiplied=False):
image = self.c.XcursorImageCreate(s["width"], s["height"])
if not image:
raise RuntimeError("Xcursor allocation failed")
try:
image.contents.xhot, image.contents.yhot = s["hotX"], s["hotY"]
for j, p in enumerate(s["argb"]):
if premultiplied:
image.contents.pixels[j] = p
continue
# XRender/Xcursor uses premultiplied alpha; Windows export is straight ARGB.
a = p >> 24
r, g, b = ((p >> shift) & 255 for shift in (16, 8, 0))
image.contents.pixels[j] = (a << 24) | ((r*a+127)//255 << 16) | ((g*a+127)//255 << 8) | ((b*a+127)//255)
cursor = self.c.XcursorImageLoadCursor(self.d, image)
if not cursor:
raise RuntimeError("Xcursor creation failed")
return cursor
finally:
self.c.XcursorImageDestroy(image)
def apply(self, cursor):
self.x.XDefineCursor(self.d, self.window, cursor)
self.x.XSync(self.d, 0)
def close(self, sdl_pid=None):
self.apply(self.original)
try:
if sdl_pid is not None:
# X11 cursor resources owned by this connection disappear when
# it closes. Have SDL restore its own hidden cursor first. The
# temporary XFixes hide prevents the first toggle flashing the
# default local arrow. This POC requires the same focused stream.
active = subprocess.check_output(["xdotool", "getwindowfocus", "getwindowpid"],text=True).strip()
if active != str(sdl_pid):
raise RuntimeError("Cannot return cursor ownership: stream lost focus; reconnect normal client")
self.f.XFixesHideCursor(self.d,self.window); self.x.XSync(self.d,0)
try:
for unused in range(2):
subprocess.run(["xdotool","key","ctrl+alt+shift+c"],check=True)
time.sleep(.08)
finally:
self.f.XFixesShowCursor(self.d,self.window); self.x.XSync(self.d,0)
finally:
for cursor in [self.original, *self.cursors.values()]:
self.x.XFreeCursor(self.d, cursor)
self.x.XCloseDisplay(self.d)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--window", type=int, required=True)
parser.add_argument("--pid", type=int, required=True)
parser.add_argument("--assets", type=Path, required=True)
parser.add_argument("--state", type=Path, required=True)
parser.add_argument("--seconds", type=int, default=120)
parser.add_argument("--snapshot", type=Path)
args = parser.parse_args()
if not 1 <= args.seconds <= 180 or args.window <= 0 or args.pid <= 1:
parser.error("Invalid window/pid/duration")
command = Path(f"/proc/{args.pid}/cmdline").read_bytes().replace(b"\0", b" ")
if b"/moonlight " not in command or b" stream " not in command or b" --absolute-mouse " not in command:
parser.error("Target must be the absolute-mode Moonlight stream")
actual_pid = subprocess.check_output(["xdotool", "getwindowpid", str(args.window)], text=True).strip()
focused_window = subprocess.check_output(["xdotool", "getwindowfocus"], text=True).strip()
if actual_pid != str(args.pid) or focused_window != str(args.window):
parser.error("Target must be the same focused Moonlight window")
# Keep the experiment exclusive, with owner-only local files.
lock = open(args.state.with_suffix(".lock"), "w")
os.chmod(lock.name, 0o600)
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
shapes = validate_assets(read_json(args.assets, 4*1024*1024))
cursor = LocalCursor(args.window)
if any(cursor.snapshot()["argb"]):
cursor.x.XFreeCursor(cursor.d, cursor.original)
cursor.x.XCloseDisplay(cursor.d)
parser.error("POC requires the original local cursor to be hidden")
stop = False
def stopped(*unused):
nonlocal stop
stop = True
signal.signal(signal.SIGTERM, stopped); signal.signal(signal.SIGINT, stopped)
try:
cursor.cursors = {name: cursor.create(s) for name, s in shapes.items()}
cursor.cursors["__hidden"] = cursor.create(dict(width=1, height=1, hotX=0, hotY=0, argb=[0]))
current = None
end = time.monotonic()+args.seconds
while not stop and time.monotonic() < end:
if not Path(f"/proc/{args.pid}").exists():
break
try:
desired = validate_state(read_json(args.state, 1024), shapes)
except (ValueError, OSError) as e:
print(json.dumps(dict(event="lease-ended", reason=str(e))), flush=True)
break
if desired != current:
cursor.apply(cursor.cursors[desired]); current = desired
s = cursor.snapshot()
if args.snapshot:
args.snapshot.write_text(json.dumps(s))
print(json.dumps(dict(event="applied", shape=current, x=s["x"], y=s["y"], width=s["width"], height=s["height"], hotX=s["hotX"], hotY=s["hotY"])), flush=True)
time.sleep(.01)
finally:
cursor.close(sdl_pid=args.pid)
print('{"event":"sdl-hidden-cursor-restored"}', flush=True)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""Install a one-device usbredir POC 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.
"""
import argparse
import json
import os
from pathlib import Path
import re
import shlex
import subprocess
import time
def box_run(box, command, stdin=None):
result = subprocess.run(
["ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes", box, command],
input=stdin, capture_output=True, text=True, encoding="utf-8", timeout=120)
if result.returncode:
raise RuntimeError(result.stderr + result.stdout)
return result.stdout
def pve_run(client, command):
_, out, err = client.exec_command(command, timeout=60)
stdout, stderr = out.read().decode(), err.read().decode()
if out.channel.recv_exit_status():
raise RuntimeError(stderr + stdout)
return stdout
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")
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:
raise ValueError("invalid VM ID")
if box_run(args.box, "systemctl is-active vdi-usb-monitor.service || true").strip() == "active":
raise RuntimeError("Safely eject the guest USB and stop the box export service before redeployment")
repo = Path(__file__).resolve().parents[1]
box_host = args.box.split("@", 1)[1]
# Copy the existing trusted host keys; never trust an unauthenticated keyscan.
known = subprocess.run(["ssh-keygen", "-F", box_host], capture_output=True,
text=True, check=True).stdout
known = "\n".join(line for line in known.splitlines() if not line.startswith("#")) + "\n"
if not known.strip():
raise RuntimeError("Box host key must already be trusted by OpenSSH")
client = paramiko.SSHClient()
client.load_system_host_keys(str(Path.home() / ".ssh" / "known_hosts"))
client.set_missing_host_key_policy(paramiko.RejectPolicy())
client.connect(args.pve, username="root", password=password or os.getenv("VDI_PVE_SSH_PASSWORD"),
timeout=10, allow_agent=True, look_for_keys=True)
try:
stamp = time.strftime("%Y%m%d-%H%M%S")
host_dir = f"/etc/vdi-usb-poc/vm{args.vm}"
pve_run(client, f"install -d -m 700 {host_dir}; test -f {host_dir}/id_ed25519 || "
f"ssh-keygen -q -t ed25519 -N '' -C vdi-usb-poc-vm{args.vm} -f {host_dir}/id_ed25519")
pubkey = pve_run(client, f"cat {host_dir}/id_ed25519.pub").strip()
backup = f"/home/{args.box.split('@')[0]}/vdi-terminal/backups/usb-device-{stamp}"
# append a restricted tunnel-only key while preserving existing access.
auth_script = '''import pathlib,shutil,sys
p=pathlib.Path.home()/'.ssh'/'authorized_keys'
b=pathlib.Path(sys.argv[1]);b.mkdir(parents=True,exist_ok=True)
if p.exists():shutil.copy2(p,b/'authorized_keys')
p.parent.mkdir(mode=0o700,exist_ok=True)
text=p.read_text() if p.exists() else ''
line=sys.argv[2]
if line not in text.splitlines():p.write_text(text.rstrip()+'\\n'+line+'\\n')
p.chmod(0o600)
'''
auth_line = (f'from="{args.pve}",restrict,port-forwarding,permitopen="127.0.0.1:40001",'
f'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')
box_install = '''import pathlib,shutil,subprocess,sys
stage,backup,env,rule=sys.argv[1:]
backup=pathlib.Path(backup)
files={
'/etc/systemd/system/vdi-usb-monitor.service':pathlib.Path(stage,'vdi-usb-monitor.service').read_bytes(),
'/etc/vdi-usb-monitor/device.env':env.encode(),
'/etc/udev/rules.d/99-vdi-usb-device-poc.rules':rule.encode(),
}
for name,data in files.items():
p=pathlib.Path(name)
if p.exists():shutil.copy2(p,backup/p.name)
p.parent.mkdir(parents=True,exist_ok=True)
p.write_bytes(data)
p.chmod(0o600 if p.suffix=='.env' else 0o644)
binary=pathlib.Path('/usr/local/libexec/vdi-usb-monitor')
if binary.exists():shutil.copy2(binary,backup/binary.name)
binary.parent.mkdir(parents=True,exist_ok=True)
shutil.copy2(pathlib.Path.home()/'vdi-build/usb-monitor/target/release/vdi-usb-monitor',binary)
binary.chmod(0o755)
subprocess.run(['udevadm','control','--reload-rules'],check=True)
subprocess.run(['systemctl','daemon-reload'],check=True)
subprocess.run(['systemctl','enable','vdi-usb-monitor.service'],check=True)
print('Prepared USB service; not started; existing mounted data untouched')
'''
# sudo changes HOME; use the explicitly selected build path instead.
box_install = box_install.replace("pathlib.Path.home()/'vdi-build", f"pathlib.Path('/home/{args.box.split('@')[0]}')/'vdi-build")
box_run(args.box, "sudo -n python3 - " + " ".join(map(shlex.quote, [stage, backup, env, rule])), box_install)
unit = f'''[Unit]
Description=VDI USB POC encrypted tunnel for VM {args.vm}
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/ssh -NT -i {host_dir}/id_ed25519 -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile={host_dir}/known_hosts -o ExitOnForwardFailure=yes -o ServerAliveInterval=10 -o ServerAliveCountMax=3 -L 127.0.0.1:40001:127.0.0.1:40001 {args.box}
Restart=on-failure
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),
("/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)
finally:
sftp.close()
pve_run(client, f"systemctl daemon-reload && systemctl enable --now vdi-usb-tunnel-{args.vm}.service")
print(json.dumps({"box_backup": backup, "pve_directory": host_dir,
"state": "prepared; unmount USB safely before starting export"}, indent=2))
finally:
client.close()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
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)
install(parser.parse_args())

View File

@ -0,0 +1,75 @@
"""Back up and deploy the VDD login refresh script in VM100, without rebooting."""
import argparse
import base64
from pathlib import Path
import runpy
import sys
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
parser = argparse.ArgumentParser(description=__doc__)
parser.parse_args()
root = Path(__file__).resolve().parent
term = runpy.run_path(str(root / "pve_term_exec.py"))
vm = runpy.run_path(str(root / "pve_vm.py"))
ticket, csrf = term["login"]()
state = vm["api"]("/api2/json/nodes/starhe/qemu/100/status/current", ticket=ticket)
if state.get("status") != "running":
sys.exit("VM100 is not running; nothing changed")
payload = base64.b64encode((root / "set-2k-display.ps1").read_bytes()).decode()
command = r"""
$ErrorActionPreference='Stop'
$target='C:\ProgramData\VDI\set-2k-display.ps1'
$task='VDISet2KDisplay'
$entry=Get-ScheduledTask -TaskName $task
if($entry.State -eq 'Running') { throw 'Display task is already running; inspect before deployment' }
if(-not (Test-Path -LiteralPath $target)) { throw 'Original display script is missing' }
if(($entry.Actions | Select-Object -First 1).Arguments -notlike '*set-2k-display.ps1*') {
throw 'Unexpected display task action; do not change it'
}
$backup='C:\ProgramData\VDI\backups\display-refresh-'+(Get-Date -Format 'yyyyMMdd-HHmmss')
New-Item -ItemType Directory -Path $backup | Out-Null
Copy-Item -LiteralPath $target -Destination "$backup\set-2k-display.ps1"
Export-ScheduledTask -TaskName $task | Set-Content -Encoding UTF8 "$backup\VDISet2KDisplay.xml"
if(Test-Path 'C:\ProgramData\VDI\set-2k-result.txt') {
Copy-Item 'C:\ProgramData\VDI\set-2k-result.txt' "$backup\previous-result.txt"
}
$temporary=$target+'.new'
try {
[IO.File]::WriteAllBytes($temporary,[Convert]::FromBase64String('__PAYLOAD__'))
Move-Item -LiteralPath $temporary -Destination $target -Force
# Prove freshness by a newly created result file; scheduler LastRunTime can
# lag wall-clock time after VM clock correction and is not a reliable token.
Remove-Item -LiteralPath 'C:\ProgramData\VDI\set-2k-result.txt' -ErrorAction SilentlyContinue
Start-ScheduledTask -TaskName $task
Start-Sleep -Seconds 2
for($i=0;$i -lt 20;$i++) {
if((Get-ScheduledTask -TaskName $task).State -ne 'Running') { break }
Start-Sleep -Seconds 1
}
$info=Get-ScheduledTaskInfo -TaskName $task
if((Get-ScheduledTask -TaskName $task).State -eq 'Running' -or
$info.LastTaskResult -ne 0) {
throw 'New display task did not complete successfully'
}
$log=Get-Content 'C:\ProgramData\VDI\set-2k-result.txt' -Raw
if($log -notmatch 'result=0 mode=2560x1440@60') { throw '60Hz read-back missing in display task log' }
@{backup=$backup; task_result=$info.LastTaskResult; result=$log.Trim()} | ConvertTo-Json -Compress
} catch {
if(Test-Path 'C:\ProgramData\VDI\set-2k-result.txt') {
Copy-Item 'C:\ProgramData\VDI\set-2k-result.txt' "$backup\failed-result.txt"
Get-Content "$backup\failed-result.txt"
}
if($info) { $info | Select-Object LastTaskResult,LastRunTime | ConvertTo-Json -Compress }
Stop-ScheduledTask -TaskName $task -ErrorAction SilentlyContinue
Copy-Item -LiteralPath "$backup\set-2k-display.ps1" -Destination $target -Force
Start-ScheduledTask -TaskName $task
throw
} finally {
Remove-Item -LiteralPath $temporary -ErrorAction SilentlyContinue
}
""".replace("__PAYLOAD__", payload)
out, error = vm["agent"](ticket, csrf, ["powershell.exe", "-NoProfile", "-Command", command])
print(out)
if error or '"task_result":0' not in out:
print(error, file=sys.stderr)
sys.exit(1)

View File

@ -0,0 +1,73 @@
"""Scoped VM100 interactive cursor exporter. No new network listener or system cursor theme."""
import argparse
import base64
import json
from pathlib import Path
import runpy
import sys
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("action", choices=["start", "status", "export", "stop"])
parser.add_argument("--output", type=Path)
args = parser.parse_args()
root = Path(__file__).resolve().parent
t = runpy.run_path(str(root / "pve_term_exec.py"))
v = runpy.run_path(str(root / "pve_vm.py"))
ticket, csrf = t["login"]()
if v["api"]("/api2/json/nodes/starhe/qemu/100/status/current", ticket=ticket).get("status") != "running":
sys.exit("VM100 is not running")
command = r"$ErrorActionPreference='Stop'; $folder='C:\ProgramData\VDI\cursor-poc-20261103'; $task='VDICursorPOC'; "
if args.action == "start":
payload = base64.b64encode((root / "cursor-poc-host.ps1").read_bytes()).decode()
command += r"""
if(Get-ScheduledTask -TaskName $task -ErrorAction SilentlyContinue) { throw 'Probe already exists' }
$user=(Get-CimInstance Win32_ComputerSystem).UserName
if(-not $user) { throw 'No interactive user' }
New-Item -ItemType Directory -Force $folder | Out-Null
$acl=Get-Acl $folder
$acl.SetAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($user,'Modify','ContainerInherit,ObjectInherit','None','Allow')))
Set-Acl $folder $acl
Remove-Item "$folder\error.txt","$folder\state.json","$folder\shapes.json" -ErrorAction SilentlyContinue
[IO.File]::WriteAllBytes("$folder\probe.ps1",[Convert]::FromBase64String('__PAYLOAD__'))
$a=New-ScheduledTaskAction -Execute powershell.exe -Argument "-NoProfile -STA -ExecutionPolicy Bypass -WindowStyle Hidden -File $folder\probe.ps1"
$p=New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Limited
$s=New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 9)
Register-ScheduledTask -TaskName $task -Action $a -Principal $p -Settings $s | Out-Null
Start-ScheduledTask -TaskName $task
Start-Sleep -Seconds 3
if(Test-Path "$folder\error.txt") { throw (Get-Content "$folder\error.txt" -Raw) }
if(-not(Test-Path "$folder\shapes.json")) { throw 'No fresh cursor export' }
'Cursor probe started'
""".replace("__PAYLOAD__", payload)
elif args.action == "export":
command += 'Get-Content "$folder\\shapes.json" -Raw'
elif args.action == "status":
command += r"""
Get-Content "$folder\state.json" -Raw
Get-Content "$folder\clicks.csv"
Get-Content "$folder\states.csv"
Get-Content "$folder\error.txt" -ErrorAction SilentlyContinue
"""
else:
command += r"""
Stop-ScheduledTask -TaskName $task -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName $task -Confirm:$false -ErrorAction SilentlyContinue
if(Test-Path "$folder\pid.txt") {
$probePid=[int](Get-Content "$folder\pid.txt")
$process=Get-CimInstance Win32_Process -Filter "ProcessId=$probePid"
if($process.CommandLine -like '*cursor-poc-20261103*') { Stop-Process -Id $probePid -ErrorAction SilentlyContinue }
}
'Cursor probe stopped'
"""
out, error = v["agent"](ticket, csrf, ["powershell.exe", "-NoProfile", "-Command", command])
if error or not out or out == "TIMEOUT":
sys.exit(error or out or "Empty guest-agent result")
if args.action == "export":
data = json.loads(out.lstrip("\ufeff"))
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(data, separators=(",", ":")), encoding="utf-8")
print(json.dumps([{k: s[k] for k in ("name", "width", "height", "hotX", "hotY", "monochrome", "invertedPixels")} for s in data["shapes"]]))
else:
print(out)

View File

@ -4,14 +4,19 @@ import base64
from pathlib import Path
import runpy
import sys
import re
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("action", choices=["Query", "List", "Apply", "Restore"])
parser.add_argument("--hz", type=int, default=60)
parser.add_argument("--backup-tag", default="", help="Separate backup for this verification run")
args = parser.parse_args()
if not 24 <= args.hz <= 240:
parser.error("--hz must be in 24..240")
if args.backup_tag and not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", args.backup_tag):
parser.error("--backup-tag must contain 1..64 letters, digits, underscores or hyphens")
backup_name = "display-before" + ("-" + args.backup_tag if args.backup_tag else "") + ".json"
root = Path(__file__).resolve().parent
term = runpy.run_path(str(root / "pve_term_exec.py"))
vm = runpy.run_path(str(root / "pve_vm.py"))
@ -33,7 +38,7 @@ $rule=New-Object Security.AccessControl.FileSystemAccessRule($user,'Modify','Con
$acl.SetAccessRule($rule)
Set-Acl -LiteralPath $folder -AclObject $acl
[IO.File]::WriteAllBytes("$folder\mode.ps1",[Convert]::FromBase64String('__VDI_PAYLOAD__'))
$run="try { & '$folder\mode.ps1' -Action __VDI_ACTION__ -RefreshRate __VDI_HZ__ | Out-File -Encoding utf8 -Width 300 '$folder\mode-result.txt' } catch { `$_ | Out-String | Out-File -Encoding utf8 -Width 300 '$folder\mode-result.txt'; exit 1 }"
$run="try { & '$folder\mode.ps1' -Action __VDI_ACTION__ -RefreshRate __VDI_HZ__ -BackupPath '$folder\__VDI_BACKUP__' | Out-File -Encoding utf8 -Width 300 '$folder\mode-result.txt' } catch { `$_ | Out-String | Out-File -Encoding utf8 -Width 300 '$folder\mode-result.txt'; exit 1 }"
$encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($run))
$action=New-ScheduledTaskAction -Execute powershell.exe -Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand $encoded"
$principal=New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Limited
@ -53,7 +58,7 @@ try {
Stop-ScheduledTask -TaskName $task -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName $task -Confirm:$false
}
""".replace("__VDI_ACTION__", args.action).replace("__VDI_HZ__", str(args.hz)).replace("__VDI_PAYLOAD__", payload)
""".replace("__VDI_ACTION__", args.action).replace("__VDI_HZ__", str(args.hz)).replace("__VDI_BACKUP__", backup_name).replace("__VDI_PAYLOAD__", payload)
out, error = vm["agent"](ticket, csrf, ["powershell.exe", "-NoProfile", "-Command", command])
print(out)
if error or "task_result=0" not in out:

View File

@ -0,0 +1,55 @@
"""Query or detach extra Windows desktop outputs in VM100's interactive session."""
import argparse
import base64
from pathlib import Path
import runpy
import sys
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument("action", choices=["Query","Apply","Restore"])
args=parser.parse_args()
root=Path(__file__).resolve().parent
t=runpy.run_path(str(root/"pve_term_exec.py")); v=runpy.run_path(str(root/"pve_vm.py"))
ticket,csrf=t["login"]()
if v["api"]("/api2/json/nodes/starhe/qemu/100/status/current",ticket=ticket).get("status")!="running":
sys.exit("VM100 is not running; nothing changed")
payload=base64.b64encode((root/"set-single-vdi-display.ps1").read_bytes()).decode()
command=r"""
$ErrorActionPreference='Stop'
$folder='C:\ProgramData\VDI\single-display-20261103'
$task='VDISingleDisplayProbe'
if(Get-ScheduledTask -TaskName $task -ErrorAction SilentlyContinue) { throw 'Task already exists' }
$user=(Get-CimInstance Win32_ComputerSystem).UserName
if(-not $user) { throw 'No interactive user' }
New-Item -ItemType Directory -Force $folder | Out-Null
$acl=Get-Acl $folder
$acl.SetAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($user,'Modify','ContainerInherit,ObjectInherit','None','Allow')))
Set-Acl $folder $acl
[IO.File]::WriteAllBytes("$folder\single.ps1",[Convert]::FromBase64String('__PAYLOAD__'))
Remove-Item "$folder\result.txt" -ErrorAction SilentlyContinue
$run="try { & '$folder\single.ps1' -Action __ACTION__ | Out-File -Encoding utf8 -Width 500 '$folder\result.txt' } catch { `$_ | Out-String | Out-File -Encoding utf8 -Width 500 '$folder\result.txt'; exit 1 }"
$encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($run))
$a=New-ScheduledTaskAction -Execute powershell.exe -Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand $encoded"
$p=New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Limited
$s=New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 2)
Register-ScheduledTask -TaskName $task -Action $a -Principal $p -Settings $s | Out-Null
try {
Start-ScheduledTask -TaskName $task
Start-Sleep -Seconds 2
for($i=0;$i-lt 30;$i++) {
if((Get-ScheduledTask -TaskName $task).State-ne 'Running') { break }
Start-Sleep -Seconds 1
}
if((Get-ScheduledTask -TaskName $task).State-eq 'Running') { throw 'Probe timed out' }
Get-Content "$folder\result.txt" -Raw
'task_result='+(Get-ScheduledTaskInfo -TaskName $task).LastTaskResult
} finally {
Stop-ScheduledTask -TaskName $task -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName $task -Confirm:$false
}
""".replace("__PAYLOAD__",payload).replace("__ACTION__",args.action)
out,err=v["agent"](ticket,csrf,["powershell.exe","-NoProfile","-Command",command])
print(out)
if err or "task_result=0" not in out:
sys.exit(err or "Display operation did not succeed")

View File

@ -1,3 +1,4 @@
param([ValidateSet(30, 60)][int]$RefreshRate = 60)
$ErrorActionPreference = 'Stop'
Add-Type @'
using System;
@ -45,12 +46,12 @@ public static class VdiDisplayMode {
if (!EnumDisplaySettings(name, -1, ref m)) return "unavailable";
return m.width + "x" + m.height + "@" + m.hz;
}
public static int Apply(string name) {
public static int Apply(string name, int refreshRate) {
for (int i = 0; ; i++) {
var m = new Mode();
m.size = (short)Marshal.SizeOf(typeof(Mode));
if (!EnumDisplaySettings(name, i, ref m)) return -99;
if (m.width == 2560 && m.height == 1440 && m.hz == 30) {
if (m.width == 2560 && m.height == 1440 && m.hz == refreshRate) {
m.fields = 0x80000 | 0x100000 | 0x400000;
int test = ChangeDisplaySettingsEx(name, ref m, IntPtr.Zero, 2, IntPtr.Zero);
if (test != 0) return test;
@ -61,13 +62,18 @@ public static class VdiDisplayMode {
}
'@
$log = 'C:/ProgramData/VDI/set-2k-result.txt'
$expectedMode = "2560x1440@$RefreshRate"
try {
for ($i = 0; $i -lt 12; $i++) {
$dev = [VdiDisplayMode]::Find()
if ($dev) {
$result = [VdiDisplayMode]::Apply($dev)
if ([VdiDisplayMode]::Current($dev) -eq $expectedMode) {
"$(Get-Date -Format o) device=$dev result=0 mode=$expectedMode (already correct)" | Set-Content $log
exit 0
}
$result = [VdiDisplayMode]::Apply($dev, $RefreshRate)
$mode = [VdiDisplayMode]::Current($dev)
if ($result -eq 0 -and $mode -eq '2560x1440@30') {
if ($result -eq 0 -and $mode -eq $expectedMode) {
"$(Get-Date -Format o) device=$dev result=0 mode=$mode" | Set-Content $log
exit 0
}

View File

@ -0,0 +1,134 @@
param(
[ValidateSet('Query','Apply','Restore')][string]$Action='Query',
[string]$BackupPath='C:\ProgramData\VDI\single-display-20261103\before.xml'
)
$ErrorActionPreference='Stop'
# Run as the logged-in desktop user. Detach desktop outputs, never disable GPUs.
Add-Type -ReferencedAssemblies System.Xml,System.Core,System -TypeDefinition @'
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using System.Xml.Serialization;
public static class VdiSingleDisplay {
[StructLayout(LayoutKind.Sequential,CharSet=CharSet.Unicode)]
public struct Device {
public int cb;
[MarshalAs(UnmanagedType.ByValTStr,SizeConst=32)] public string Name;
[MarshalAs(UnmanagedType.ByValTStr,SizeConst=128)] public string Description;
public int Flags;
[MarshalAs(UnmanagedType.ByValTStr,SizeConst=128)] public string Id;
[MarshalAs(UnmanagedType.ByValTStr,SizeConst=128)] public string Key;
}
[StructLayout(LayoutKind.Sequential,CharSet=CharSet.Unicode)]
public struct Mode {
[MarshalAs(UnmanagedType.ByValTStr,SizeConst=32)] public string name;
public short spec,driver,size,extra;
public int fields,x,y,orientation,fixedOutput;
public short color,duplex,yres,tt,collate;
[MarshalAs(UnmanagedType.ByValTStr,SizeConst=32)] public string form;
public short logPixels;
public int bpp,width,height,flags,hz,icmMethod,icmIntent,media,dither,reserved1,reserved2,panWidth,panHeight;
}
public class Record { public Device device; public Mode mode; public bool hasMode; }
[DllImport("user32.dll",CharSet=CharSet.Unicode)] static extern bool EnumDisplayDevices(string name,uint index,ref Device device,uint flags);
[DllImport("user32.dll",CharSet=CharSet.Unicode)] static extern bool EnumDisplaySettings(string name,int index,ref Mode mode);
[DllImport("user32.dll",CharSet=CharSet.Unicode)] static extern int ChangeDisplaySettingsEx(string name,ref Mode mode,IntPtr hwnd,uint flags,IntPtr param);
[DllImport("user32.dll",CharSet=CharSet.Unicode,EntryPoint="ChangeDisplaySettingsExW")]
static extern int Commit(string name,IntPtr mode,IntPtr hwnd,uint flags,IntPtr param);
public static Record[] Query() {
var result=new List<Record>();
for(uint i=0;;i++) {
Device d=new Device(); d.cb=Marshal.SizeOf(typeof(Device));
if(!EnumDisplayDevices(null,i,ref d,0)) return result.ToArray();
if((d.Flags&8)!=0) continue;
Mode m=new Mode(); m.size=(short)Marshal.SizeOf(typeof(Mode));
bool ok=EnumDisplaySettings(d.Name,-1,ref m);
result.Add(new Record {device=d,mode=m,hasMode=ok});
}
}
static void Check(int code,string operation) {
if(code!=0) throw new InvalidOperationException(operation+" failed: "+code+" (no reboot will be requested)");
}
public static string Target(Record[] records) {
string found=null;
foreach(var r in records) if((r.device.Flags&1)!=0 && r.device.Description.IndexOf("Virtual Display Driver",StringComparison.OrdinalIgnoreCase)>=0) {
if(found!=null) throw new InvalidOperationException("Multiple active VDD outputs; refuse ambiguous selection");
if(!r.hasMode || r.mode.width<640 || r.mode.height<480) throw new InvalidOperationException("Invalid active VDD mode");
found=r.device.Name;
}
if(found==null) throw new InvalidOperationException("No active VDD; do not detach anything");
return found;
}
public static void Save(Record[] records,string path) {
using(var stream=new FileStream(path,FileMode.CreateNew,FileAccess.Write)) new XmlSerializer(typeof(Record[])).Serialize(stream,records);
}
public static Record[] Load(string path) {
using(var stream=File.OpenRead(path)) return (Record[])new XmlSerializer(typeof(Record[])).Deserialize(stream);
}
static void Stage(Record record,bool active,bool primary) {
Mode m=record.mode; m.size=(short)Marshal.SizeOf(typeof(Mode)); m.extra=0;
if(active) {
if(!record.hasMode) throw new InvalidOperationException("Missing original mode");
m.fields=0x20|0x40000|0x80000|0x100000|0x400000;
} else {
m.width=m.height=0; m.fields=0x20|0x80000|0x100000;
}
Check(ChangeDisplaySettingsEx(record.device.Name,ref m,IntPtr.Zero,0x10000001u|(primary?0x10u:0u),IntPtr.Zero),"Stage "+record.device.Name);
}
public static void Apply(Record[] before,string keep) {
foreach(var r0 in before) if(r0.device.Name==keep) {
Record r=new Record {device=r0.device,mode=r0.mode,hasMode=r0.hasMode};
Mode m=r.mode; m.x=m.y=0; r.mode=m; Stage(r,true,true);
}
foreach(var r in before) if(r.device.Name!=keep && (r.device.Flags&1)!=0) Stage(r,false,false);
Check(Commit(null,IntPtr.Zero,IntPtr.Zero,0,IntPtr.Zero),"Commit single display");
}
public static void Restore(Record[] before) {
Record[] now=Query();
foreach(var old in before) {
bool matched=false;
foreach(var r in now) if(r.device.Name==old.device.Name && r.device.Id==old.device.Id && r.device.Key==old.device.Key) matched=true;
if(!matched) throw new InvalidOperationException("Display identity changed; refuse stale restore: "+old.device.Name);
}
foreach(var r in before) if((r.device.Flags&4)!=0) Stage(r,true,true);
foreach(var r in before) if((r.device.Flags&4)==0) {
bool currentlyActive=false;
foreach(var current in now) if(current.device.Name==r.device.Name) currentlyActive=(current.device.Flags&1)!=0;
if((r.device.Flags&1)!=0 || currentlyActive) Stage(r,(r.device.Flags&1)!=0,false);
}
Check(Commit(null,IntPtr.Zero,IntPtr.Zero,0,IntPtr.Zero),"Restore topology");
}
}
'@
function Report {
@([VdiSingleDisplay]::Query() | ForEach-Object {
@{device=$_.device.Name; description=$_.device.Description; active=(($_.device.Flags-band 1)-ne 0);
primary=(($_.device.Flags-band 4)-ne 0); width=$_.mode.width; height=$_.mode.height; hz=$_.mode.hz; x=$_.mode.x; y=$_.mode.y}
}) | ConvertTo-Json -Depth 4 -Compress
}
if($Action -eq 'Query') { Report; exit 0 }
if($Action -eq 'Restore') { [VdiSingleDisplay]::Restore([VdiSingleDisplay]::Load($BackupPath)); Report; exit 0 }
$before=[VdiSingleDisplay]::Query()
$keep=[VdiSingleDisplay]::Target($before)
$target=@($before | Where-Object {$_.device.Name -eq $keep})[0]
if($target.mode.width -ne 2560 -or $target.mode.height -ne 1440 -or $target.mode.hz -ne 60 -or ($target.device.Flags-band 4)-eq 0) {
throw 'Required existing 2560x1440@60 VDD primary is absent; nothing changed'
}
$active=@($before | Where-Object {($_.device.Flags-band 1)-ne 0})
if($active.Count -eq 1 -and ($active[0].device.Flags-band 4)-ne 0) { Report; exit 0 }
New-Item -ItemType Directory -Force (Split-Path $BackupPath) | Out-Null
[VdiSingleDisplay]::Save($before,$BackupPath)
try {
[VdiSingleDisplay]::Apply($before,$keep)
Start-Sleep -Seconds 2
$actual=@([VdiSingleDisplay]::Query() | Where-Object {($_.device.Flags-band 1)-ne 0})
if($actual.Count -ne 1 -or $actual[0].device.Name -ne $keep -or ($actual[0].device.Flags-band 4)-eq 0) { throw 'Single VDD primary read-back failed' }
$old=@($before | Where-Object {$_.device.Name -eq $keep})[0]
if($actual[0].mode.width-ne $old.mode.width -or $actual[0].mode.height-ne $old.mode.height -or $actual[0].mode.hz-ne $old.mode.hz) { throw 'VDD mode changed unexpectedly' }
Report
} catch {
$failure=$_
[VdiSingleDisplay]::Restore($before)
throw $failure
}

51
tools/set-terminal-fps.py Normal file
View File

@ -0,0 +1,51 @@
"""Query or change only the Windows desktop FPS in the box's local POC config."""
import argparse
import subprocess
import sys
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--fps", type=int, choices=[30, 45, 60], required=True)
parser.add_argument("--apply", action="store_true")
args = parser.parse_args()
program = r'''
from pathlib import Path
from datetime import datetime
import json, os, shutil, stat, tempfile
path=Path.home()/'.config/vdi-terminal/desktops.json'
original=path.read_bytes()
data=json.loads(original)
matches=[d for d in data['desktops'] if d.get('id')=='desk-win10-01']
if len(matches)!=1 or matches[0].get('endpoint')!='192.168.1.70':
raise RuntimeError('Unexpected Windows desktop identity; inspect before changing')
desktop=matches[0]
before=desktop.get('fps')
backup=None
if __APPLY__:
backup=Path.home()/'vdi-terminal/backups'/('fps-'+datetime.now().strftime('%Y%m%d-%H%M%S-%f'))
backup.mkdir(parents=True)
shutil.copy2(path,backup/'desktops.json')
desktop['fps']=__FPS__
name=None
try:
with tempfile.NamedTemporaryFile(dir=path.parent,prefix='.desktops-',delete=False) as stream:
name=stream.name
stream.write((json.dumps(data,ensure_ascii=False,indent=2)+'\n').encode())
os.chmod(name,stat.S_IMODE(path.stat().st_mode))
if path.read_bytes()!=original:
raise RuntimeError('Desktop configuration changed concurrently; left it intact')
os.replace(name,path)
name=None
if json.loads(path.read_bytes())!=data:
raise RuntimeError('Desktop configuration read-back mismatch')
finally:
if name is not None: os.unlink(name)
print(json.dumps({'desktop':desktop['id'],'before':before,'fps':desktop['fps'],
'requested':__FPS__,'applied':__APPLY__,
'backup':str(backup) if backup else None}))
'''.replace("__FPS__", str(args.fps)).replace("__APPLY__", str(args.apply))
result = subprocess.run(["ssh", "-o", "ConnectTimeout=8", "armbian@192.168.1.188", "python3 -"],
input=program, capture_output=True, text=True, encoding="utf-8")
print(result.stdout, end="")
if result.stderr:
print(result.stderr, end="", file=sys.stderr)
sys.exit(result.returncode)

50
tools/test-cursor-poc.py Normal file
View File

@ -0,0 +1,50 @@
"""Cursor asset boundary/fallback tests; run on Linux (same fcntl environment as box)."""
import copy
import importlib.util
from pathlib import Path
import tempfile
import time
import unittest
spec=importlib.util.spec_from_file_location("cursor_poc",Path(__file__).with_name("cursor-poc-x11.py"))
m=importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
class Boundaries(unittest.TestCase):
def setUp(self):
self.asset=dict(version=1,shapes=[dict(name="arrow",width=1,height=1,hotX=0,hotY=0,invertedPixels=0,argb=[0xffffffff])])
def bad(self,key,value):
data=copy.deepcopy(self.asset); data["shapes"][0][key]=value
with self.assertRaises(ValueError): m.validate_assets(data)
def test_valid(self): self.assertIn("arrow",m.validate_assets(self.asset))
def test_dimensions(self):
for v in (-1,0,129,True,"32"): self.bad("width",v)
def test_hotspot(self):
for v in (-1,1): self.bad("hotX",v)
def test_xor_requires_fallback(self): self.bad("invertedPixels",1)
def test_pixels(self):
for v in ([],[True],[-1],[0x100000000],[0,0]): self.bad("argb",v)
def test_duplicate_shapes(self):
self.asset["shapes"]*=2
with self.assertRaises(ValueError): m.validate_assets(self.asset)
def test_envelopes(self):
for v in (None,[],{},dict(version=True,shapes=self.asset["shapes"])):
with self.assertRaises(ValueError): m.validate_assets(v)
def test_lease(self):
shapes=m.validate_assets(self.asset)
for expires in (time.time()-1,time.time()+60,float("nan"),float("inf"),True):
with self.assertRaises(ValueError): m.validate_state(dict(version=1,visible=True,shape="arrow",expires=expires),shapes)
def test_state(self):
shapes=m.validate_assets(self.asset)
state=dict(version=1,visible=True,shape="arrow",expires=time.time()+1)
self.assertEqual(m.validate_state(state,shapes),"arrow")
state["visible"]=False
self.assertEqual(m.validate_state(state,shapes),"__hidden")
for key,value in (("visible",1),("shape","missing"),("shape",[]),("version",True)):
bad=dict(state); bad[key]=value
with self.assertRaises(ValueError): m.validate_state(bad,shapes)
def test_oversize_file(self):
with tempfile.TemporaryDirectory() as d:
p=Path(d)/"data.json"; p.write_text(" "*1025)
with self.assertRaises(ValueError): m.read_json(p,1024)
if __name__=="__main__": unittest.main()

View File

@ -0,0 +1,34 @@
param(
[Parameter(Mandatory=$true)][ValidatePattern('^[A-Z]$')][string]$DriveLetter,
[Parameter(Mandatory=$true)][ValidatePattern('^[A-Za-z0-9_.-]+$')][string]$UsbSerial,
[ValidateRange(1,64)][int]$SizeMiB=1
)
$ErrorActionPreference='Stop'
$disk=@(Get-CimInstance Win32_DiskDrive | Where-Object {
$_.InterfaceType -eq 'USB' -and $_.PNPDeviceID -like "USBSTOR\*\$UsbSerial&*"
})
if($disk.Count -ne 1){throw 'Expected exactly one approved USB disk'}
$partition=Get-Partition -DriveLetter $DriveLetter
if($partition.DiskNumber -ne $disk[0].Index){throw 'Drive letter does not belong to the approved USB disk'}
$stamp=Get-Date -Format 'yyyyMMdd-HHmmss'
$name="VDI_USB_TEST_$stamp"
$local=Join-Path $env:TEMP $name
$usb=Join-Path "${DriveLetter}:\" $name
if((Test-Path -LiteralPath $local) -or (Test-Path -LiteralPath $usb)){throw 'Test directory already exists'}
New-Item -ItemType Directory -Path $local,$usb | Out-Null
$bytes=New-Object byte[] ($SizeMiB*1MB)
$rng=[Security.Cryptography.RandomNumberGenerator]::Create()
try{$rng.GetBytes($bytes)}finally{$rng.Dispose()}
$source=Join-Path $local 'source.bin'
[IO.File]::WriteAllBytes($source,$bytes)
$target=Join-Path $usb 'from-windows.bin'
$stream=[IO.File]::Open($target,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None)
try{$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true)}finally{$stream.Dispose()}
$copy=Join-Path $local 'readback.bin'
Copy-Item -LiteralPath $target -Destination $copy
$a=(Get-FileHash -LiteralPath $source -Algorithm SHA256).Hash
$b=(Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash
$c=(Get-FileHash -LiteralPath $copy -Algorithm SHA256).Hash
if($a -ne $b -or $a -ne $c){throw 'USB read/write hash mismatch'}
[IO.File]::WriteAllText((Join-Path $usb 'readme.txt'),"VDI USB device POC test. Only this directory was created by the test.`r`n",[Text.Encoding]::UTF8)
[pscustomobject]@{Result='PASS';Model=$disk[0].Model;InterfaceType=$disk[0].InterfaceType;PNPDeviceID=$disk[0].PNPDeviceID;DriveLetter=$DriveLetter;TestDirectory=$usb;LocalDirectory=$local;Bytes=$bytes.Length;SHA256=$a} | ConvertTo-Json

View File

@ -0,0 +1,50 @@
import importlib.util
from pathlib import Path
import unittest
spec = importlib.util.spec_from_file_location("usb_qmp", Path(__file__).parents[1] / "usb-device-qmp.py")
usb = importlib.util.module_from_spec(spec)
spec.loader.exec_module(usb)
class FakeQmp:
def __init__(self, occupied=False, fail_add=False):
self.calls = []
self.occupied = occupied
self.fail_add = fail_add
def call(self, name, args=None):
self.calls.append((name, args))
if name == "query-chardev":
return [{"label": "vdi_usb_box_100"}] if self.occupied else []
if name == "qom-list":
return [{"name": "tablet"}, {"name": "hostpci0"}]
if name == "device_add" and self.fail_add:
raise RuntimeError("bad USB bus")
return {}
class UsbQmpTests(unittest.TestCase):
def test_only_loopback_tunnel_and_approved_storage_are_attached(self):
q = FakeQmp()
usb.attach(q, 100, 40001, "ehci-2.0", "0951", "1666")
channel = next(a for n, a in q.calls if n == "chardev-add")
self.assertEqual(channel["backend"]["data"]["addr"]["data"]["host"], "127.0.0.1")
device = next(a for n, a in q.calls if n == "device_add")
self.assertEqual(device["filter"], "0x08:0x0951:0x1666:-1:1|-1:-1:-1:-1:0")
def test_busy_endpoint_is_never_replaced(self):
q = FakeQmp(occupied=True)
with self.assertRaisesRegex(RuntimeError, "already exists"):
usb.attach(q, 100, 40001, "ehci-2.0", "0951", "1666")
self.assertFalse(any(n in ("chardev-add", "chardev-remove", "device_del") for n, _ in q.calls))
def test_failed_device_add_rolls_back_only_its_new_channel(self):
q = FakeQmp(fail_add=True)
with self.assertRaisesRegex(RuntimeError, "bad USB bus"):
usb.attach(q, 100, 40001, "ehci-2.0", "0951", "1666")
self.assertEqual(q.calls[-1], ("chardev-remove", {"id": "vdi_usb_box_100"}))
if __name__ == "__main__":
unittest.main()

135
tools/usb-device-qmp.py Normal file
View File

@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""Run on the PVE node as an administrator; the terminal never calls PVE APIs.
Runtime-only USB redirection, carried over a separately authenticated SSH tunnel.
No VM config writes, drivers, controller replacement or VM restarts.
"""
import argparse
import json
import re
import socket
import time
class Qmp:
def __init__(self, vm):
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.sock.settimeout(10)
self.sock.connect(f"/var/run/qemu-server/{vm}.qmp")
self.stream = self.sock.makefile("rwb", buffering=0)
greeting = json.loads(self.stream.readline())
if "QMP" not in greeting:
raise RuntimeError("Not a QMP socket")
self.sequence = 0
self.call("qmp_capabilities")
def call(self, command, arguments=None):
self.sequence += 1
ident = self.sequence
message = {"execute": command, "id": ident}
if arguments is not None:
message["arguments"] = arguments
self.stream.write(json.dumps(message).encode() + b"\n")
while True:
raw = self.stream.readline()
if not raw:
raise RuntimeError("QMP disconnected")
reply = json.loads(raw)
if reply.get("id") != ident:
continue
if "error" in reply:
raise RuntimeError(f"{command}: {reply['error']}")
return reply["return"]
def close(self):
self.stream.close()
self.sock.close()
def ids(vm):
return f"vdi_usb_box_{vm}", f"vdi_usb_redir_{vm}"
def peripherals(qmp):
return {x["name"] for x in qmp.call("qom-list", {"path": "/machine/peripheral"})}
def attach(qmp, vm, port, bus, vendor, product):
channel, device = ids(vm)
channels = {x["label"]: x for x in qmp.call("query-chardev")}
if channel in channels or device in peripherals(qmp):
raise RuntimeError("POC endpoint already exists; inspect status before changing it")
qmp.call("chardev-add", {
"id": channel,
"backend": {"type": "socket", "data": {
"addr": {"type": "inet", "data": {"host": "127.0.0.1", "port": str(port)}},
"server": False, "reconnect-ms": 1000,
}},
})
try:
qmp.call("device_add", {
"driver": "usb-redir", "id": device, "chardev": channel, "bus": bus,
# QEMU uses ':' within rules; spice-client uses ',' instead.
"filter": f"0x08:0x{vendor}:0x{product}:-1:1|-1:-1:-1:-1:0",
})
except Exception:
# Roll back only the channel created by this invocation.
qmp.call("chardev-remove", {"id": channel})
raise
def detach(qmp, vm):
channel, device = ids(vm)
if device in peripherals(qmp):
qmp.call("device_del", {"id": device})
deadline = time.monotonic() + 8
while device in peripherals(qmp):
if time.monotonic() >= deadline:
raise RuntimeError("USB removal pending; leave its channel intact and inspect guest")
time.sleep(0.2)
if any(x["label"] == channel for x in qmp.call("query-chardev")):
qmp.call("chardev-remove", {"id": channel})
def status(qmp, vm):
channel, device = ids(vm)
return {
"vm": vm,
"device_present": device in peripherals(qmp),
"channels": [x for x in qmp.call("query-chardev") if x["label"] == channel],
"usb": qmp.call("human-monitor-command", {"command-line": "info usb"}),
"vm_status": qmp.call("query-status"),
}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("action", choices=["status", "attach", "detach"])
parser.add_argument("--vm", type=int, required=True)
parser.add_argument("--port", type=int, default=40001)
parser.add_argument("--bus", default="ehci-2.0")
parser.add_argument("--vendor")
parser.add_argument("--product")
parser.add_argument("--guest-ejected", action="store_true",
help="Confirm the guest has safely ejected the USB device before detach")
args = parser.parse_args()
if args.vm < 100 or not 1 <= args.port <= 65535:
parser.error("invalid VM ID or local tunnel port")
if args.action == "attach" and not all(
re.fullmatch(r"[0-9a-fA-F]{4}", x or "") for x in [args.vendor, args.product]):
parser.error("attach requires an exact four-digit --vendor and --product")
if args.action == "detach" and not args.guest_ejected:
parser.error("safely eject in the guest first, then pass --guest-ejected")
qmp = Qmp(args.vm)
try:
if args.action == "attach":
attach(qmp, args.vm, args.port, args.bus, args.vendor.lower(), args.product.lower())
elif args.action == "detach":
detach(qmp, args.vm)
print(json.dumps(status(qmp, args.vm), ensure_ascii=False, indent=2))
finally:
qmp.close()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""Controlled existing-stream POC; always restores the remote-video cursor toggle.
Only run with cursor-poc-host.ps1's disposable foreground window on Windows.
Shape selections here are scripted replay, NOT automatic host synchronization.
"""
import importlib.util
import json
import os
from pathlib import Path
import signal
import subprocess as S
import time
ROOT = Path(__file__).resolve().parent
os.environ.update(DISPLAY=":0", XAUTHORITY="/home/armbian/.Xauthority")
spec = importlib.util.spec_from_file_location("cursor_poc", ROOT / "cursor-poc-x11.py")
m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
OUT = ROOT / "cursor-poc-evidence-v3"
OUT.mkdir(exist_ok=False) # Never overwrite earlier evidence.
def xd(*args):
return S.check_output(["xdotool", *map(str,args)], text=True).strip()
pid = int(xd("getwindowfocus", "getwindowpid"))
window = int(xd("getwindowfocus"))
cmd = Path(f"/proc/{pid}/cmdline").read_bytes().replace(b"\0", b" ")
if b"/moonlight " not in cmd or b" stream 192.168.1.70 Desktop " not in cmd or b" --absolute-mouse " not in cmd:
raise RuntimeError("Unexpected foreground process")
assets = m.validate_assets(m.read_json(ROOT / "windows-shapes.json", 4*1024*1024))
cursor = m.LocalCursor(window)
if any(cursor.snapshot()["argb"]):
cursor.close(); raise RuntimeError("Original local cursor is not hidden")
hidden_remote = False
paused = False
records = []
def interrupted(signum, frame):
raise SystemExit(128+signum)
signal.signal(signal.SIGTERM, interrupted)
signal.signal(signal.SIGINT, interrupted)
def focus():
if int(xd("getwindowfocus", "getwindowpid")) != pid:
raise RuntimeError("Moonlight lost foreground; stop the POC")
def capture(name):
with (OUT / (name+".xwd")).open("wb") as f:
S.run(["xwd", "-root", "-silent"], stdout=f, check=True)
try:
cursor.cursors = {k: cursor.create(v) for k,v in assets.items()}
cursor.cursors["__hidden"] = cursor.create(dict(width=1,height=1,hotX=0,hotY=0,argb=[0]))
focus(); xd("mousemove", 320, 500); time.sleep(.8)
capture("01-remote-cursor")
focus(); xd("key", "ctrl+alt+shift+n"); hidden_remote=True
xd("mousemove",321,500); time.sleep(1.0)
capture("02-video-cursor-disabled")
for name,x in (("arrow",320),("ibeam",960),("sizewe",1600),("wait",2240),("__hidden",320)):
focus(); y=1250 if name=="__hidden" else 500
xd("mousemove", x,y); time.sleep(.25)
cursor.apply(cursor.cursors[name]); time.sleep(.05)
snap=cursor.snapshot()
(OUT/(name+".json")).write_text(json.dumps(snap))
if name=="__hidden":
assert not any(snap["argb"]), "Hidden cursor still has visible pixels"
else:
a=assets[name]
assert (snap["width"],snap["height"],snap["hotX"],snap["hotY"]) == (a["width"],a["height"],a["hotX"],a["hotY"])
assert any(snap["argb"]), "Visible cursor has no pixels"
assert (snap["x"],snap["y"])==(x,y)
records.append(dict(shape=name,x=x,y=y,visible=any(snap["argb"])))
time.sleep(.3) # Allow the host probe to observe the real shape/visibility.
focus(); xd("mousemove",320,500); cursor.apply(cursor.cursors["arrow"]); time.sleep(.4)
# Freeze only this client for < 0.5s; X11 cursor must move while its video/input
# event processing is stopped. SIGCONT is guaranteed before cursor restoration.
os.kill(pid,signal.SIGSTOP); paused=True
try:
first=cursor.snapshot()
xd("mousemove",500,650); time.sleep(.12)
second=cursor.snapshot()
assert (second["x"],second["y"])==(500,650)
assert first["argb"]==second["argb"]
records.append(dict(test="client-paused-local-motion",before=[first["x"],first["y"]],after=[second["x"],second["y"]],passed=True))
finally:
os.kill(pid,signal.SIGCONT); paused=False
time.sleep(.5)
# Click only within the disposable Windows form. Scripted shape replay is
# deliberately distinct from the not-yet-implemented live cursor protocol.
for i in range(32):
focus(); n=i%4; x=320+640*n; y=600+(i//4)*20
cursor.apply(cursor.cursors[("arrow","ibeam","sizewe","wait")[n]])
xd("mousemove",x,y); time.sleep(.06); xd("click",1); time.sleep(.06)
capture("03-local-mode-video-only")
# Verify the actual helper's lease failure path while video cursor is hidden.
cursor.apply(cursor.original)
lease=OUT/"lease.json"
lease.write_text(json.dumps(dict(version=1,shape="arrow",visible=True,expires=time.time()+.8)))
result=S.run(["python3",str(ROOT/"cursor-poc-x11.py"),"--window",str(window),"--pid",str(pid),
"--assets",str(ROOT/"windows-shapes.json"),"--state",str(lease),"--seconds","3"],
capture_output=True,text=True,timeout=5)
(OUT/"lease-expiry.log").write_text(result.stdout+result.stderr)
assert result.returncode==0 and "lease-ended" in result.stdout and "sdl-hidden-cursor-restored" in result.stdout
# Xorg can briefly have no current cursor image after another connection's
# final cursor resource is released. Trigger pointer recomputation before
# checking the *post-exit* image; do not mistake this for an alpha image.
xd("mousemove",2240,741); time.sleep(.1)
assert not any(cursor.snapshot()["argb"])
records.append(dict(test="helper-lease-expiry",passed=True))
finally:
if paused: os.kill(pid,signal.SIGCONT)
try:
cursor.close(sdl_pid=pid)
finally:
# Remote restoration must still run when local cleanup raises an error.
if hidden_remote:
xd("windowactivate","--sync",window); focus()
xd("key","ctrl+alt+shift+n"); time.sleep(.8)
capture("04-remote-cursor-restored")
(OUT/"results.json").write_text(json.dumps(records,indent=2))
print(json.dumps(dict(restored_remote_cursor=hidden_remote,records=records)),flush=True)