Compare commits
5 Commits
1bed1948b5
...
b2de05c6a6
| Author | SHA1 | Date | |
|---|---|---|---|
| b2de05c6a6 | |||
| 853bcb7574 | |||
| 1439028833 | |||
| b29b59c62f | |||
| a98d9ddeee |
43
MEMORY.md
@ -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,保留焦点/窗口/退出等边界;
|
||||
|
||||
55
docs/2k60-validation-20261103.md
Normal 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,不能称零延迟。
|
||||
199
docs/evidence/2k60-20261103/clicks-first-three.txt
Normal 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}
|
||||
|
||||
|
||||
199
docs/evidence/2k60-20261103/clicks-second-series-after45.txt
Normal 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}
|
||||
|
||||
|
||||
135
docs/evidence/2k60-20261103/clicks-second-series-before45.txt
Normal 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}
|
||||
|
||||
|
||||
135
docs/evidence/2k60-20261103/clicks-sustained.txt
Normal 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}
|
||||
|
||||
|
||||
BIN
docs/evidence/2k60-20261103/desktop-selection.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
11
docs/evidence/2k60-20261103/final-host.txt
Normal 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
|
||||
|
||||
|
||||
|
||||
BIN
docs/evidence/2k60-20261103/normal-windows-45fps.png
Normal file
|
After Width: | Height: | Size: 4.4 MiB |
|
After Width: | Height: | Size: 7.0 KiB |
BIN
docs/evidence/cursor-poc-20261103/01-remote-cursor.png
Normal file
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
BIN
docs/evidence/cursor-poc-20261103/02-video-cursor-disabled.png
Normal file
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
BIN
docs/evidence/cursor-poc-20261103/03-local-mode-video-only.png
Normal file
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 14 KiB |
BIN
docs/evidence/cursor-poc-20261103/04-remote-cursor-restored.png
Normal file
|
After Width: | Height: | Size: 154 KiB |
1
docs/evidence/cursor-poc-20261103/__hidden.json
Normal file
@ -0,0 +1 @@
|
||||
{"width": 1, "height": 1, "hotX": 0, "hotY": 0, "x": 320, "y": 1250, "argb": [0]}
|
||||
1
docs/evidence/cursor-poc-20261103/arrow.json
Normal 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]}
|
||||
BIN
docs/evidence/cursor-poc-20261103/before.png
Normal file
|
After Width: | Height: | Size: 139 KiB |
@ -0,0 +1,5 @@
|
||||
{
|
||||
"clicks": 32,
|
||||
"correct_regions_and_shapes": 32,
|
||||
"max_coordinate_quantization_px": 1
|
||||
}
|
||||
BIN
docs/evidence/cursor-poc-20261103/cursor-poc-evidence-v3.tar.gz
Normal file
3
docs/evidence/cursor-poc-20261103/final-display.txt
Normal file
@ -0,0 +1,3 @@
|
||||
{"action":"Query","device":"\\\\.\\DISPLAY3","hz":60,"width":2560,"bpp":32,"height":1440}
|
||||
task_result=0
|
||||
|
||||
10
docs/evidence/cursor-poc-20261103/final-host.txt
Normal 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]
|
||||
|
||||
|
||||
|
||||
2
docs/evidence/cursor-poc-20261103/final-local-cursor.txt
Normal file
@ -0,0 +1,2 @@
|
||||
{'width': 1, 'height': 1, 'hotX': 0, 'hotY': 0, 'x': 100, 'y': 100}
|
||||
visible_local_pixels 0
|
||||
BIN
docs/evidence/cursor-poc-20261103/final-normal-desktop.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
docs/evidence/cursor-poc-20261103/first-01-remote-cursor.png
Normal file
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 139 KiB |
1
docs/evidence/cursor-poc-20261103/first-__hidden.json
Normal file
@ -0,0 +1 @@
|
||||
{"width": 1, "height": 1, "hotX": 0, "hotY": 0, "x": 320, "y": 1250, "argb": [0]}
|
||||
1
docs/evidence/cursor-poc-20261103/first-arrow.json
Normal 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]}
|
||||
1
docs/evidence/cursor-poc-20261103/first-ibeam.json
Normal 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]}
|
||||
1
docs/evidence/cursor-poc-20261103/first-lease.json
Normal file
@ -0,0 +1 @@
|
||||
{"version": 1, "shape": "arrow", "visible": true, "expires": 1793689477.871138}
|
||||
44
docs/evidence/cursor-poc-20261103/first-results.json
Normal 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
|
||||
}
|
||||
]
|
||||
1
docs/evidence/cursor-poc-20261103/first-sizewe.json
Normal 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]}
|
||||
1
docs/evidence/cursor-poc-20261103/first-wait.json
Normal file
74
docs/evidence/cursor-poc-20261103/host-final-probe.txt
Normal 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
|
||||
|
||||
74
docs/evidence/cursor-poc-20261103/host-first.txt
Normal 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
|
||||
|
||||
1
docs/evidence/cursor-poc-20261103/ibeam.json
Normal 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]}
|
||||
1
docs/evidence/cursor-poc-20261103/lease.json
Normal file
@ -0,0 +1 @@
|
||||
{"version": 1, "shape": "arrow", "visible": true, "expires": 1793689894.0310185}
|
||||
BIN
docs/evidence/cursor-poc-20261103/remote-before-crop.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
docs/evidence/cursor-poc-20261103/remote-hidden-crop.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
48
docs/evidence/cursor-poc-20261103/results.json
Normal 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
|
||||
}
|
||||
]
|
||||
1
docs/evidence/cursor-poc-20261103/sizewe.json
Normal 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]}
|
||||
42
docs/evidence/cursor-poc-20261103/video-cursor-regions.json
Normal 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
|
||||
}
|
||||
}
|
||||
1
docs/evidence/cursor-poc-20261103/wait.json
Normal file
1
docs/evidence/cursor-poc-20261103/windows-shapes.json
Normal file
BIN
docs/evidence/single-display-20261103/after-desktop.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
19
docs/evidence/single-display-20261103/after-health.txt
Normal 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
|
||||
|
||||
|
||||
|
||||
7
docs/evidence/single-display-20261103/after-query.txt
Normal 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
|
||||
|
||||
|
||||
12
docs/evidence/single-display-20261103/after-terminal.txt
Normal 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
|
||||
7
docs/evidence/single-display-20261103/apply.txt
Normal 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
|
||||
|
||||
|
||||
7
docs/evidence/single-display-20261103/before.txt
Normal 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
|
||||
|
||||
|
||||
28
docs/evidence/usb-device-20261103/final-box-state.txt
Normal 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
|
||||
16
docs/evidence/usb-device-20261103/final-pve-state.json
Normal 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
|
||||
}
|
||||
}
|
||||
49
docs/evidence/usb-device-20261103/final-windows-state.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
21
docs/evidence/usb-device-20261103/windows-hotplug-test.json
Normal 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"
|
||||
}
|
||||
11
docs/evidence/usb-device-20261103/windows-rw-test.json
Normal 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"
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
# 独立光标二次开发设计与接入条件
|
||||
|
||||
状态:源码核实完成,尚未实现 Sunshine/Moonlight 的独立光标通道,不应标记为已部署。当前保留单个 Windows 远程白色光标。
|
||||
状态:已通过受控的 Windows 光标导出 + 盒子 X11 本地指针原型验证;尚未实现 Sunshine/Moonlight 的自动独立光标通道,不应标记为生产功能已部署。测试已结束,当前恢复单个 Windows 远程白色光标。实测结果见 [独立光标可行性验证](independent-cursor-feasibility-20261103.md)。
|
||||
|
||||
## 已核实的实际路径
|
||||
|
||||
|
||||
53
docs/independent-cursor-feasibility-20261103.md
Normal 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)。
|
||||
@ -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 配置修改和串流测试,继续完成盒子端开发与文档,尚未达到最终性能验收。
|
||||
|
||||
@ -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与独立光标尚未完成。
|
||||
|
||||
35
docs/single-display-20261103.md
Normal 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)。
|
||||
117
docs/usb-device-poc-20261103.md
Normal 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)
|
||||
@ -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远程白色光标。
|
||||
|
||||
@ -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
@ -0,0 +1 @@
|
||||
/target/
|
||||
7
terminal/usb-monitor/Cargo.lock
generated
Normal 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"
|
||||
11
terminal/usb-monitor/Cargo.toml
Normal 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"
|
||||
28
terminal/usb-monitor/README.md
Normal 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)。
|
||||
22
terminal/usb-monitor/etc/vdi-usb-monitor.service
Normal 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
|
||||
233
terminal/usb-monitor/src/main.rs
Normal 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(¤t, &policy).map(|d| d.bus == device.bus
|
||||
&& d.address == device.address && d.bus_id == device.bus_id).unwrap_or(false)
|
||||
}).unwrap_or(false);
|
||||
if !present {
|
||||
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\\\"\\\\\"");
|
||||
}
|
||||
}
|
||||
@ -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
@ -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
@ -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()
|
||||
155
tools/deploy-usb-device-poc.py
Normal 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())
|
||||
75
tools/deploy-vdi-display-refresh.py
Normal 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)
|
||||
73
tools/run-cursor-poc-host.py
Normal 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)
|
||||
@ -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:
|
||||
|
||||
55
tools/run-single-vdi-display.py
Normal 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")
|
||||
@ -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
|
||||
}
|
||||
|
||||
134
tools/set-single-vdi-display.ps1
Normal 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
@ -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
@ -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()
|
||||
34
tools/test-usb-device-poc.ps1
Normal 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
|
||||
50
tools/tests/test_usb_device_qmp.py
Normal 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
@ -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()
|
||||
120
tools/verify-cursor-poc-box.py
Normal 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)
|
||||