Compare commits

...

5 Commits

Author SHA1 Message Date
8d24a5b2f2 功能:kiosk 会话就绪检测重构(Moonlight 输出解析器/事件化回调/单元测试);GPU 直通实操记录(vfio Subsystem 注入、sysfs vBIOS、7zr+pnputil 安装、566.36 驱动、Sunshine 配对断链修复) 2026-10-30 14:42:11 +08:00
2cc80989c6 文档:GPU 直通实施记录——vfio/驱动安装成功,阻塞于 GM204M vBIOS 缺失(Code 43),已回退保环境并列出三条后续路线 2026-10-30 13:56:25 +08:00
ade16efe49 功能:GPU 数据展示看板上线(http://192.168.1.51:8090,systemd 自启);修正 GPU/IOMMU 实测事实
- tools/gpu_dashboard.py:宿主机端采集(lspci/sysfs/qm),直通前显示驱动/解绑/IOMMU 状态,直通后经 guest agent 采 nvidia-smi 实时利用率/显存/温度/NVENC 会话
- 实测修正:GPU 为 CLEVO 准系统 GM204M/965M(非笔记本误判);IOMMU 已启用(组 2 = 00:01.0 + 01:00.0 整组直通);Maxwell 需 R580 legacy 驱动
- docs/gpu-passthrough-20261030.md:明日直通操作单
2026-10-30 09:52:18 +08:00
27edbe2d82 功能:开机直进桌面列表——设备身份自动登录,移除登录页与退出登录入口
- 构造时以设备 SN 走演示认证,kConfig->kAuthenticating->kDesktopList 同步完成,开机不再显示登录页
- 列表页移除"退出登录"按钮(无登录概念无去向);管理菜单"退出终端"保留
- 登录页与认证代码保留,接平台正式认证后由 DesktopService 驱动恢复登录流程
- 已部署盒子并抓屏验证;文档与 MEMORY 同步
2026-10-30 09:19:17 +08:00
8ec7b1ea49 清理:删除 RDP 时代文档与临时截图/缓存/.zcode;纳管正式产品 Word、VDD 安装资产、对比记录与 PVE 运维工具脚本 2026-10-29 17:44:25 +08:00
30 changed files with 1875 additions and 349 deletions

View File

@ -9,7 +9,8 @@
- Proxmox:9.2.2 节点 `starhe`。VM 100 win10-test `192.168.1.70`(Sunshine/Moonlight 云桌面); - Proxmox:9.2.2 节点 `starhe`。VM 100 win10-test `192.168.1.70`(Sunshine/Moonlight 云桌面);
VM 101 kylin-test `192.168.1.71`(test1)、VM 102 ubuntu-test `192.168.1.72`(test2), VM 101 kylin-test `192.168.1.71`(test1)、VM 102 ubuntu-test `192.168.1.72`(test2),
密码均 `yx66668888`,SSH 可登、图形会话在跑,**尚未装 Sunshine**,暂不能串流 密码均 `yx66668888`,SSH 可登、图形会话在跑,**尚未装 Sunshine**,暂不能串流
- `kiosk-shell`(Qt/C++):登录页 → 桌面列表 → Moonlight 会话;**RDP 代码已于 2026-10-29 - `kiosk-shell`(Qt/C++):**开机直进桌面列表**(2026-10-30 起,设备 SN 即用户自动登录,
登录页代码保留待平台正式认证接入)→ 选择桌面 → Moonlight 会话;**RDP 代码已于 2026-10-29
全部删除**(git 历史可查),Moonlight 是唯一会话通道,无 freerdp 构建依赖 全部删除**(git 历史可查),Moonlight 是唯一会话通道,无 freerdp 构建依赖
- 新增退出三层与会话过场层:`Ctrl+Alt+Shift+Q` 退串流回列表;列表页"退出登录"; - 新增退出三层与会话过场层:`Ctrl+Alt+Shift+Q` 退串流回列表;列表页"退出登录";
登录页无退出按钮(退出终端只在管理菜单);点连接有全屏过场层,首帧后淡出。 登录页无退出按钮(退出终端只在管理菜单);点连接有全屏过场层,首帧后淡出。
@ -17,6 +18,17 @@
- 代码位置:`terminal/kiosk-shell` - 代码位置:`terminal/kiosk-shell`
- 远程安装路径:`~/vdi-terminal/bin/`(`kiosk-shell`、`vdi-kiosk-monitor`、`vdi-moonlight`) - 远程安装路径:`~/vdi-terminal/bin/`(`kiosk-shell`、`vdi-kiosk-monitor`、`vdi-moonlight`)
## GPU 直通进行中(2026-10-30 下午,见 moonlight-kiosk 文档当日追加节)
- 宿主侧已就绪:nouveau 已黑名单、GPU 在 vfio-pci、IOMMU 组 2(root port+GPU)
- 关键坑:vfio 清零 Subsystem ID(必须 `x-pci-sub-vendor-id/device-id` 注入 1558:6557);
移动卡 vBIOS 需 sysfs 抽取(/usr/share/kvm/gtx965m.rom);NVIDIA 安装器在 SYSTEM 会话
挂起(用 7zr 解包 + pnputil);582.78 绑定后 D3D 全灭已换 566.36(D3D 待验证)
- **当前 VM 100 未挂 GPU**:另一并行会话在改配置(hv_vendor_id 方案),本会话已停手避撞,
两个会话不要同时改 VM 100 配置!
- Sunshine 配对 2026.914 改为"Web UI 批准式":`moonlight pair --pin NNNN` +
`GET /api/csrf-token` + `POST /api/pin {pairing_id,pin}`;Web 凭据 vdiadmin/VdiPass2026;
删 sunshine_state.json = 全部失配;盒子 Moonlight 配置目录缓存旧 uniqueid 会卡死配对
## 必须遵守 ## 必须遵守
- 所有实现严格参考用户提供的两个正式 Word 和会议纪要;文档未确定的技术只能标记待调研,不得擅自定版。 - 所有实现严格参考用户提供的两个正式 Word 和会议纪要;文档未确定的技术只能标记待调研,不得擅自定版。
- 终端先做,后接自研管理平台 API;终端不直接调用 Proxmox API。 - 终端先做,后接自研管理平台 API;终端不直接调用 Proxmox API。
@ -53,7 +65,9 @@
- 完整方案:`docs/web-console-mvp-plan-20261028.md`。 - 完整方案:`docs/web-console-mvp-plan-20261028.md`。
## 尚未完成 ## 尚未完成
- 服务端 GPU 直通(宿主机 starhe 有 GTX 965M / IOMMU Group 2 独占,Intel 核显留给主机显示) - 服务端 GPU 直通:GPU 为 CLEVO 准系统上的 GM204M/965M(10de:13d9,宿主被 nouveau 占用需解绑);
**IOMMU 已启用**(组 2 = root port 00:01.0 + GPU 01:00.0,整组直通);VM 内需装 R580 legacy 驱动。
操作单与 GPU 看板见 `docs/gpu-passthrough-20261030.md`,看板 `http://192.168.1.51:8090/`(systemd 自启)
- RK3528 VPU 硬解(h264_v4l2m2m 等缺可用设备,需 MPP FFmpeg 补丁或 V4L2 request 驱动) - RK3528 VPU 硬解(h264_v4l2m2m 等缺可用设备,需 MPP FFmpeg 补丁或 V4L2 request 驱动)
- 2K 分辨率链路验证(虚拟显示器 + 盒子显示模式需同步调整) - 2K 分辨率链路验证(虚拟显示器 + 盒子显示模式需同步调整)
- Weston/DRM/mppvideodec - Weston/DRM/mppvideodec

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,33 +0,0 @@
# FreeRDP 3 客户端 API 参考(Ubuntu 24.04 / freerdp3-dev)
> 用途:kiosk-shell 内嵌 FreeRDP 客户端时使用,避免拉起外部进程(开发方案 3.2 节)。
> 记录时间:2026-10-23
## 包名
- 运行时:`libfreerdp-client3-3`、`libfreerdp3-3`
- 开发:`freerdp3-dev`(Ubuntu 24.04 提供 `freerdp3-dev` 与 `libfreerdp-client3-3-dev`)
- pkg-config 名称:`freerdp3`、`freerdp3-client`(安装后以 `pkg-config --list-all | grep freerdp` 实测为准)
## 关键头文件
| 头文件 | 用途 |
|---|---|
| `freerdp/client.h` | 客户端主入口、freerdp_client_context_new / freerdp_client_context_free |
| `freerdp/freerdp.h` | 核心上下文 |
| `freerdp/settings.h` | 连接参数设置(freerdp_settings_new / set) |
| `freerdp/channels/cliprdr.h` | 剪贴板通道 |
| `freerdp/gdi/gfx.h` | GFX 图形管线(AVC420/AVC444/RFX 能力) |
| `freerdp/crypto/tls.h` | TLS |
## 最小连接流程(伪代码)
```c
freerdp_client_context* ctx = freerdp_client_context_new();
RdpClientContext* cctx = ...;
freerdp_settings_new(0); // 释放: freerdp_settings_free
freerdp_set_pointer / freerdp_set_keyboard ... // 输入回调
ctx->Connect = ...; // 事件表
freerdp_client_context_new / connect
```
## 注意事项
- `/gfx` 命令行语法在 3.32.1 上必须用 `|` 分隔的能力组合(如 `progressive|RFX|AVC420|AVC444`),
单独 `AVC420` 会解析失败。API 侧对应 `gfx` 能力位设置。

View File

@ -1,67 +0,0 @@
# FreeRDP 3 内嵌客户端实现笔记(Ubuntu 24.04 / freerdp3-dev 3.32.1)
> 对应《VDI 云桌面软件开发方案》3.2 / 3.3 节:以 libfreerdp3 API 内嵌(非拉起外部进程)。
> 记录时间:2026-10-23,实测于 RK3528 盒子(Armbian / Ubuntu Noble / aarch64)
## 1. API 结构(与 FreeRDP 2.x 差异很大)
| 用途 | FreeRDP 3 写法 |
|---|---|
| 创建实例 | `freerdp* instance = freerdp_new();` |
| 建立上下文 | `freerdp_context_new_ex(instance, settings)` |
| 上下文 | `instance->context`(`rdpContext*`,公开结构体) |
| 事件回调 | `instance->PostConnect` / `instance->PostDisconnect` / `instance->AuthenticateEx` |
| 更新回调 | `instance->context->update->BitmapUpdate` / `->EndPaint` |
| GDI | `gdi_init(instance, PIXEL_FORMAT_BGRX32)` |
| 取帧 | `context->gdi->primary_buffer` + `context->gdi->stride` |
设置项键名(与 2.x 不同):
`FreeRDP_ServerHostname` / `FreeRDP_Username` / `FreeRDP_Password` / `FreeRDP_Domain` /
`FreeRDP_DesktopWidth` / `FreeRDP_DesktopHeight` / `FreeRDP_ColorDepth` / `FreeRDP_Fullscreen` /
`FreeRDP_IgnoreCertificate` / `FreeRDP_GfxProgressive` / `FreeRDP_GfxH264` / `FreeRDP_GfxAVC444` …
## 2. 三个必须踩过才知道的坑
### 坑一:gdi_init 必须在 PostConnect 里调用
在 `freerdp_context_new_ex()` 之后立刻调用 `gdi_init()` 会 **SIGSEGV**
(`gdi_init_ex` 段错误)。官方 x11 客户端(`client/X11/xf_client.c` 的 `xf_post_connect`)
把 `gdi_init` 放在 PostConnect 回调内,因为 GDI 依赖连接协商后的桌面尺寸与颜色格式。
### 坑二:不能用 instance->ContextSize 扩展 rdpContext
常见做法是把自定义指针塞进扩展的 rdpContext,但 `gdi_init_ex()` 会按 `ContextSize`
重新分配 context 缓冲区,扩展字段随即失效 → 段错误。
改用静态注册表按 `freerdp*` 记录会话对象;GFX 通道回调则用官方的
`RdpgfxClientContext.custom` 字段。
### 坑三:NLA 认证必须传空字符串而非 nullptr
Windows 端启用 NLA(CredSSP)时,认证回调里把密码置为 `nullptr` 会导致
`SEC_E_NO_CREDENTIALS [0x8009030e]`。必须传 `strdup("")`。
认证回调签名:
`BOOL (*pAuthenticateEx)(freerdp*, char** username, char** password, char** domain, rdp_auth_reason)`
## 3. /gfx 语法(命令行与设置项的对应)
FreeRDP 3.32.1 的 `/gfx` **不接受单个管道名**,必须是 `|` 分隔的能力组合:
```
/gfx:progressive|RFX|AVC420|AVC444 ← 可解析
/gfx:AVC420 ← parsing failed [-1000]
/gfx:AVC444 ← parsing failed [-1000]
```
API 侧对应 `FreeRDP_GfxProgressive` / `FreeRDP_GfxH264` / `FreeRDP_GfxAVC444` 等布尔项。
## 4. 像素格式
`PIXEL_FORMAT_BGRX32` 的内存布局是 **B,G,R,X** 四字节/像素。
QImage 没有对应格式,输出前需手动做 BGR→RGB 转换。
注意 `PIXEL_FORMAT_*` 是计算宏 `FREERDP_PIXEL_FORMAT(...)`,**不能硬编码数值**。
## 5. 后续演进(对应 freerdp-rk 分支)
当前实现是 CPU 侧 GDI 缓冲 → Qt 绘制。开发方案 3.3 节要求改为:
```text
FreeRDP3 解复用(gfx 通道) → GStreamer h264parse → mppvideodec(VPU硬解, zero-copy DMABuf)
→ waylandsink(DRM plane)
```
盒子已具备 `/dev/mpp_service`(VPU)与 `/dev/rga`,但缺 `mppvideodec` GStreamer 插件
(Ubuntu 官方源没有,需 Rockchip BSP / Armbian 额外源或自研)。

View File

@ -0,0 +1,93 @@
# GPU 直通前置核查与数据看板(2026-10-30)
## 硬件事实(SSH 到宿主机实测,修正旧文档误判)
宿主机 `starhe`(192.168.1.51,pve-manager/9.2.21,内核 7.0.2-6-pve):
```text
00:02.0 VGA : Intel 4th Gen Core (Haswell) iGPU [8086:0416] 驱动 i915(宿主显示用,保留)
01:00.0 3D : NVIDIA GM204M [GeForce GTX 965M] [10de:13d9] 驱动 nouveau(直通前需解绑)
两者 Subsystem 均为 CLEVO/KAPOK [1558:6557]
```
- 机身是 CLEVO 准系统(外形像台式机箱),GPU 是移动版 GM204M/965M——
此前"笔记本"与"IOMMU Group 2 独占"的记载部分有误,以本页为准。
- HDMI 音频功能(01:00.1)不存在,无需处理。
- **IOMMU 实测已启用**(/sys/kernel/iommu_groups 有 15 组,DMAR 生效;
内核 6.8+ 默认开启,不需要显式 intel_iommu=on)。
- GPU 位于 **IOMMU 组 2**,组内设备:`0000:00:01.0`(root port)+ `0000:01:00.0`(GPU),
直通需整组传入。
- VM 100:q35 + SeaBIOS、4 核 8G、win10,未配置 hostpci。
## 明日直通操作单(按序执行)
1. 约窗口:宿主机重启会让全部 VM(100/101/102)停机。
2. 解绑 nouveau:`/etc/modprobe.d/blacklist.conf` 加 `blacklist nouveau`,
`update-initramfs -u`,重启宿主机(重启后 `lsmod | grep nouveau` 应为空)。
3. 绑 vfio:`/etc/modprobe.d/vfio.conf` 写 `options vfio-pci ids=10de:13d9`
(含 root port 的话用 QEMU 参数整组传),重启后确认
`lspci -nnk -s 01:00.0` 显示 `Kernel driver in use: vfio-pci`(看板会自动变绿)。
4. 挂载:`qm set 100 -hostpci0 00:01.0,pcie=1`(按组传;若启动报隔离不满足,
用 `-hostpci0 0000:00:01.0;0000:01:00.0` 形式补传 root port)。
vBIOS ROM 先不挂,Code 43 在 R465+ 驱动已移除检测。
5. VM 内装驱动:Maxwell 已进 legacy 分支,装 **R580 系**驱动(提前下载好放 VM)。
6. 验证:VM 内 `nvidia-smi` 出卡 → 看板"VM 内实时数据"卡片自动出现利用率/显存/温度。
7. Sunshine 切 NVENC(配置 encoder=nvenc 或按其自动探测),1080p60 复测帧率。
## GPU 数据看板(已上线)
- 地址:`http://192.168.1.51:8090/`(自动刷新 4 秒)
- 源码:`tools/gpu_dashboard.py`(宿主机 `/root/gpu_dashboard.py`,
systemd 单元 `gpu-dashboard.service` 已设开机自启)
- 直通前显示:型号/驱动/直通阶段/IOMMU 状态与组内设备/VM 配置;
直通后自动经 `qm guest exec 100` 采集 nvidia-smi:利用率、显存、温度、
驱动版本、功耗、NVENC 编码会话数。
- 手动重启:`systemctl restart gpu-dashboard`。
- 后续:接管理平台后,该数据源可换成平台 GPU 池接口(Web 控制台监控模块复用)。
---
# 2026-10-30 实施记录:直通落地成功,卡在 vBIOS,已回退保环境
## 已完成并持久化
1. 宿主机:blacklist nouveau + vfio.conf(ids=10de:13d9,8086:0c01) + initramfs 更新 + 重启;
重启后 `01:00.0` 由 vfio-pci 接管,nouveau 0。
2. VM 100:hostpci0=0000:01:00.0,pcie=1 挂载成功;客户机出现 `3D Video Controller`。
3. 驱动:582.66 WHQL 经"PC 下载 → 宿主机 8091 HTTP → VM 内网拉取"进入 VM
(VM 直连 NVIDIA CDN 被 403,需走内网中转);安装器兼容性检测拒绝(桌面包 INF 无移动 ID),
改用 `pnputil /add-driver Display.Driver/nvcvi.inf /install` 成功——
nvcvi.inf 含我们的精确 ID `PCI\VEN_10DE&DEV_13D9&SUBSYS_65571558`,
设备绑定成功、`C:\Windows\System32\nvidia-smi.exe` 出现。
4. 全部 VM 已设 onboot 自启;宿主机新增 systemd 服务:`gpu-dashboard`(8090)、`dl-http`(8091)。
## 阻塞点:GM204M 缺少可用 vBIOS,驱动初始化失败(Code 43)
- 宿主机 dmesg:`Invalid PCI ROM header signature: expecting 0xaa55, got 0xffff` ——
该 Optimus 移动卡的 ROM BAR 读不出 vBIOS;sysfs 转储 0 字节。
- 已尝试无效:`cpu host,hidden=1`;`args -cpu host,-hypervisor,hv_vendor_id=...,kvm=off`;
`rombar=0`(设备直接不枚举)。
- 供体 ROM 实验:TechPowerUp 上两个 "GTX 965M" 条目实为 GM206(1427) 改版(芯片不符);
同 GM204 的 Clevo GTX 970M(13D8) ROM 经 PCIR 补丁(vendor 10de + device 13d9 + 子系统 6557)
后 guest 仍不枚举设备。
- 结论:**需要这块卡(或同款 GM204M/13D9)的真 vBIOS 才能完成驱动初始化**。
## 已回退(保客户演示环境)
- VM 100 已摘除 hostpci 并重启,Sunshine 正常(x264 路线),47989 开放;
失败安装无残留(无 NVIDIA 目录)。
- 直通重新启用只需两条命令:`qm set 100 -hostpci0 0000:01:00.0,pcie=1[,romfile=...]` + 重启 VM。
## 后续选项(按代价排序)
1. **宿主机整机冷断电 30 秒以上**(现场 1 分钟操作;Maxwell 多次热重启后设备状态可能损坏,
冷启动是社区最后一招)→ 开机后重挂 `romfile=gtx965m-fixed.rom`(已留存
`/usr/share/kvm/gtx965m-fixed.rom`,即 970M ROM 补丁版)再试。
2. **获取真 GM204M(13D9) vBIOS**:任何可进系统的 965M 机器用 GPU-Z 导出
(同事/二手渠道),或从 CLEVO BIOS 刷写包提取;放到 `/usr/share/kvm/` 后 romfile 挂载。
3. 业务备选:若 965M 直通确认不可行,NVENC 需换卡(桌面 GM204 及以上)或改用其他硬件方案。
## GPU 数据看板
`http://192.168.1.51:8090/`(systemd `gpu-dashboard`,源码 `tools/gpu_dashboard.py`)。
驱动一旦可初始化,"VM 内实时数据"卡片自动显示利用率/显存/温度/NVENC 会话,无需改代码。

View File

@ -0,0 +1,39 @@
@echo off
REM ============================================================
REM MttVDD 虚拟显示驱动 - 一键安装(管理员运行)
REM 用法:右键本文件 -> 以管理员身份运行
REM ============================================================
setlocal
cd /d "%~dp0"
echo [1/4] 关闭可能占用驱动的程序...
sc stop SunshineService >nul 2>&1
taskkill /f /im sunshine.exe >nul 2>&1
echo [2/4] 安装驱动签名证书...
certutil -addstore -f "Root" "Virtual_Display_Driver.cer"
if errorlevel 1 (
echo [错误] 证书安装失败,请确认以管理员身份运行
pause
exit /b 1
)
echo [3/4] 安装 MttVDD 驱动...
pnputil /add-driver "MttVDD.inf" /install
if errorlevel 1 (
echo [警告] 驱动安装返回错误,尝试另一种方式...
rundll32.exe setupapi.dll,InstallHinfSection DefaultInstall 132 .\MttVDD.inf
)
echo [4/4] 显示当前虚拟显示器状态...
pnputil /enum-devices /class Display
echo.
echo ============================================================
echo 安装完成。请重启 Windows 后:
echo 1) 设置 - 显示 - 找到 MttVDD 虚拟显示器
echo 2) 分辨率选 1920x1080,刷新率选 60Hz
echo 3) 重启 SunshineService 服务
echo 重启方式:开始菜单搜索 services.msc -> SunshineService -> 重新启动
echo ============================================================
pause

View File

@ -250,3 +250,73 @@ Desktop Duplication 的脏帧节奏,叠加 QEMU 显示栈),不是编码器
两台均未装 Sunshine(47989 等端口关闭),还不能被 Moonlight 连接。 两台均未装 Sunshine(47989 等端口关闭),还不能被 Moonlight 连接。
接入桌面列表的步骤:装 Sunshine(Linux 版)→ 配对 → 在盒子"桌面连接设置"里 接入桌面列表的步骤:装 Sunshine(Linux 版)→ 配对 → 在盒子"桌面连接设置"里
把 Ubuntu/麒麟卡片的地址填上即可(kiosk 代码已支持,无需改代码)。 把 Ubuntu/麒麟卡片的地址填上即可(kiosk 代码已支持,无需改代码)。
---
# 2026-10-30 下午追加:GPU 直通实操 + 配对断链修复(大量新坑位)
## 直通实操结论(VM 100 / GTX 965M CLEVO 1558:6557)
1. **vfio reset 会把 GPU 的 Subsystem ID 清零**(Windows 里实例变成
`SUBSYS_00000000`),而 NVIDIA 移动卡驱动全部按 SUBSYS 匹配
(本卡在 `nvcvi.inf` 的 `SUBSYS_65571558`),不注入就永远"无法安装"。
解法 = QEMU 设备参数 `x-pci-sub-vendor-id=0x1558,x-pci-sub-device-id=0x6557`。
2. **移动卡 vBIOS 不在卡上**(ROM BAR 为空,宿主 dmesg 反复
"No more images in PCI ROM")。宿主开机时系统 BIOS 已 shadow 过 vBIOS,
可从 sysfs 抽取:停 VM → `echo 0000:01:00.0 > /sys/bus/pci/drivers/vfio-pci/unbind`
→ `echo 1 > rom; cat rom > gtx965m.rom`(108KB,55AA 开头有效)→ 重绑 vfio。
3. **NVIDIA 安装器(-s/-x)在 qemu-ga SYSTEM 会话下静默挂起**(CPU 时间 ~0s,
无解包无日志)。VM 内装驱动的可行路径:PC 端 7zr 解包 → 取 Display.Driver →
zip 传入 VM → `pnputil /add-driver nvcvi.inf /install`。
国内线路被 NVIDIA CDN 403,直链需 PC 中转(PC→scp 宿主机→宿主机 HTTP→VM 拉)。
4. **582.78 绑定成功但 D3D 全灭**:PnP Status OK(需手动
`pnputil /restart-device` 踢一次,开机自启是 Code 43),但 dxdiag 看不到适配器、
NVML 初始化失败、所有硬编编码器 D3D11 建设备报 0x887A0004 (DEVICE_REMOVED)。
已换 **566.36**(2024-12,Maxwell+VM 社区验证充分),包已装、设备已绑定;
D3D 是否健康**尚未验证**(GPU 在替换期间被移出 VM,见下)。
5. **可用挂载参数模板**(未来重启 VM 用):
`-device vfio-pci,host=0000:01:00.0,bus=pcie.0,addr=0x10,x-pci-sub-vendor-id=0x1558,x-pci-sub-device-id=0x6557,romfile=gtx965m.rom`
(rom 放 /usr/share/kvm/;另一会话在测 `hv_vendor_id=NVidiaFIX,kvm=off` 方案)。
6. **local-lvm thin 池曾被驱动文件写满**(100% → error IO → VM 被 QEMU 暂停),
已 `lvextend -L +13G pve/data`(49→62GB)。VM 内 C:\nvsetup 有 ~12GB 驱动
中间产物可清理;池仍超配(160GB 卷/62GB 池),建议后续扩存储。
## 配对断链与修复(重要运维知识)
- `sunshine_state.json` 存服务器 uniqueid 与已配对客户端。**删它 = 所有客户端失配**。
修复后的新流程(2026.914 的"Web UI 批准式"配对):
1. 盒子:`moonlight-qt pair 192.168.1.70 --pin 4321`(**PIN 由客户端生成**,可 `--pin` 指定);
2. 宿主/PC 侧调 Sunshine Web API 批准:
`GET /api/csrf-token`(Basic Auth)→
`POST /api/pin` body `{"pairing_id":"<GET /api/pin 里拿>","pin":"4321"}`,
头带 `X-CSRF-Token` + Basic Auth(无 /api/login;POST 必须 CSRF 头)。
- Web 凭据已重置为 `vdiadmin / VdiPass2026`(写在本页,勿提交正式环境)。
- **旧版缓存 uniqueid 会让 pair 卡 "Found unexpected PC" 死循环**:
盒子 `~/.config/Moonlight Game Streaming Project/` 整目录删除后重配即可。
- Sunshine 重启不再换 uniqueid(持久化已恢复);**若再出现"每次重启换 uniqueid",
先查 sunshine_state.json 是否能写**。
- NVIDIA 设备半坏时 Sunshine 捕获初始化会崩循环(服务恢复 5-6 分钟拉起一次),
表象是"每几分钟换一次 UUID + 配对全部失败"。根因仍是驱动 D3D 问题。
## 当前现场(2026-10-30 14:50)
- VM 100:**GPU 未挂载**(另一并行会话用 `hv_vendor_id=NVidiaFIX` 方案接手了配置,
为避免两个会话互相覆盖,本会话已停止改 VM 配置);VM 内 566.36 驱动已装好待用。
- 盒子:kiosk 单链已恢复、配对已修复、串流实测 ~23 FPS(软编基线)可正常进出。
- 宿主机 /root 下留有 nv582.78.exe、DisplayDriver.zip、DD566.zip、gtx965m.rom;
8018 端口有临时 HTTP 服务(http8018svc)供 VM 拉文件,用完可
`systemctl stop http8018svc`。
---
# 2026-10-30 追加:开机直进桌面列表
按产品决定,盒子开机不再显示 kiosk 登录页,直接进入"选择云桌面"列表页:
- 构造时以设备身份(SN)自动走 DesktopService 演示认证(SN 即当前用户展示),
状态链 `kConfig -> kAuthenticating -> kDesktopList` 同步完成,无登录页闪烁;
- 列表页右上角"退出登录"按钮移除(无登录概念后无去向),管理菜单"退出终端"保留;
- **登录页与 DesktopService 认证代码全部保留**:接入管理平台正式认证
(/api/v1/terminal/session/login)后,由 DesktopService 决定是否回到登录流程,
页面代码无需重写;git 历史含完整"登录页 -> 桌面列表"产品流程版本。
- 已编译部署盒子并抓屏验证:开机落在桌面列表,Windows 可连接、Ubuntu/麒麟待分配。

View File

@ -0,0 +1,82 @@
# RDP 与 Moonlight 实测对比(2026-10-28)
## 测试环境
```text
终端:RK3528 PRO DDR4,Armbian 25.05,aarch64,LightDM + Xorg
显示器:1920×1440 @ 75Hz
云桌面:VM 100(win10-test),Proxmox VE 9.2.21,节点 starhe
终端到云桌面:192.168.1.188 -> 192.168.1.70,同一局域网 WiFi
```
服务端显示环境改造(本次关键前提):
```text
改造前:Microsoft Basic Display Adapter,1920×1080 @ 1Hz
改造后:Virtual Display Driver 8.35.11.365,1920×1080 @ 60Hz
```
改造前 Sunshine 抓不到稳定帧(1Hz),改造后可正常编码。
## 对比数据
| 指标 | RDP(当前 kiosk-shell) | Moonlight 1080p30 | Moonlight 1080p60 |
|---|---|---|---|
| 协议/编码 | RDP GFX,CPU 软解 | H.264 / libx264 软编 | H.264 / libx264 软编 |
| 分辨率 | 1920×1440 | 1920×1080 | 1920×1080 |
| 终端 CPU | 约 111%(单核近满载) | 约 26% | 约 26% |
| 终端内存 | 约 232 MB | 约 192 MB | 约 165 MB |
| 终端温度 | 约 58~65℃ | 约 56℃ | 约 53℃ |
| 实际帧率 | 无可靠统计(观感为低帧+卷帘) | 12.83 FPS | 22.79 FPS |
| 网络丢包 | 未测 | 0.00% | 0.00% |
| 网络抖动丢帧 | 未测 | 0.08% | 15.28% |
| 网络延迟 | 未测 | 2 ms | 6 ms |
| 主机处理延迟 | 未测 | 14.4 / 27.6 / 平均 18.1 ms | 18.8 / 78.3 / 平均 38.2 ms |
| 平均渲染耗时 | 未测 | 3.34 ms | 46.26 ms |
| 画面观感 | 卷帘、翻页 | 完整清晰,无撕裂 | 完整清晰,无撕裂 |
## 结论
1. **卷帘问题不是 RDP 协议的限制,是当前 RDP 客户端实现路径的问题。**
Moonlight 在同一台盒子、同一台云桌面、同一网络下画面完整清晰,无卷帘无撕裂,
抓屏确认 1080p 画面文字锐利、任务栏时间正确。
2. **Moonlight 的瓶颈在服务端,不在终端,也不<E4B99F><E4B88D><EFBFBD>网络。**
1080p30 时网络丢包 0.00%、延迟 2 ms、渲染 3.34 ms、终端 CPU 26%,
但实际帧率仅 12.83 FPS;1080p60 时主机处理延迟升到平均 38.2 ms、渲染 46.26 ms。
服务端当前使用 libx264 软件编码(VM 内无 NVIDIA 直通,`nvidia-smi` 不存在),
4 核 CPU 同时承担 1080p 编码与桌面捕获,是帧率上限的真正原因。
3. **RDP 路径的 CPU 代价约为 Moonlight 的 4 倍**(111% vs 26%),
因为 RDP 走 `FreeRDP GDI -> CPU 内存复制 -> QImage/QPixmap -> QLabel -> Xorg`,
而 Moonlight 只做一次解码和一次窗口呈现。
4. **RK3528 VPU 硬解在当前 moonlight-qt 6.2.0 下不可用。**
`h264_v4l2m2m` / `hevc_v4l2m2m` / `hevc_v4l2request` 均报
`Could not find a valid device`,即使盒子存在 /dev/mpp_service、/dev/rga 和
/dev/dri/renderD128。需 Rockchip MPP FFmpeg 补丁或 V4L2 request 驱动。
## 后续建议
优先级从高到低:
1. **服务端 GPU 直通**:将宿主机 GTX 965M(PCI 01:00.0,IOMMU Group 2 独占)
直通给 VM 100,使 Sunshine 可用 NVENC 硬编码。预期把 1080p 帧率从 13 FPS
提升到 60 FPS,同时释放服务端 CPU。需先确认内核 `intel_iommu=on`、
`vfio-pci` 已绑定,以及该笔记本 GPU 的 vBIOS ROM 提取。
2. **消除帧率随请求升高而抖动**(60 FPS 时抖动丢帧 15.28%):服务端稳定后复测。
3. **RK3528 硬解适配**:解决 V4L2 request/m2m 设备缺失,这是达成 4K 的前提。
4. **协议选型建议**:
- 若 GPU 直通可行,Moonlight 在 CAD/3D/视频场景优于 RDP,建议按产品方案采用双协议:
普通办公用 RDP,GPU/CAD/3D 用 Moonlight;
- 同时保留并优化 RDP 路径的 Weston + MPP/VPU + DMABUF/DRM 零拷贝改造,
使 RDP 在办公场景达到可接受体验。
5. 当前 Moonlight 未接 VPU,1080p 软件解码仅 26% CPU 但 4K 会超出能力,
需在 4K 目标确定后优先解决硬解。
## 附:本次变更
- VM 100:安装 Virtual Display Driver(MttVDD),提供 1920×1080@60Hz 虚拟显示器。
- VM 100:安装 Sunshine v2026.914.233613,Web UI 账号 admin / vdi12345。
- 终端:安装 moonlight-qt 6.2.0,已与 Sunshine 完成配对。
- 终端:kiosk-shell 自启动已恢复(~/.config/autostart/vdi-kiosk.desktop)。

View File

@ -0,0 +1,74 @@
# Sunshine 显卡环境配置指引(转给操作 VM 的同事)
## 背景
我们在 VM 100(win10-test,192.168.1.70)里装了 Sunshine 用于 Moonlight 串流验证。当前卡在:VM 没有可用 GPU,Windows 只有「Microsoft 基本显示适配器」,分辨率 1024×768、刷新率 1Hz,Sunshine 无法按 30/60fps 捕获编码。
不需要动 Proxmox,只需要在 Windows 里完成两件事:装虚拟显示器驱动 + 确认 Sunshine 配置。
## 第 1 步:安装虚拟显示器驱动(IddSampleDriver)
让 Windows 出现一个虚拟显卡,可自定义分辨率和刷新率:
1. 下载虚拟显示驱动包(ParrotGeek 的 IddSampleDriver,或等效的「Virtual Display Driver」)。
常用地址:https://github.com/itsmikethetech/Virtual-Display-Driver/releases
(如果 GitHub 打不开,用镜像:把链接前面加 https://gh-proxy.com/ )
2. 解压后右键 `IddSampleDriver.inf` →「安装」。
3. 重启 Windows。
4. 装好后设备管理器会出现「IddSampleDriver Device」,显示设置里会多出一个虚拟显示器。
5. 把虚拟显示器设置为:
- 分辨率:1920×1080(先用 1080p,稳定后再试 2K)
- 刷新率:60Hz(关键!当前 1Hz 是主要问题)
- 如果看到多显示器,把虚拟屏设为「扩展这些显示器」, Sunshine 默认抓主屏;也可以只保留虚拟屏。
## 第 2 步:确认 NVIDIA 独显是否可用(如果宿主已直通/有驱动)
打开 PowerShell 执行:
```powershell
nvidia-smi
```
- 如果能输出显卡信息(GTX 965M):很好,说明 VM 内能用 NVIDIA。
- 请安装 GeForce 驱动(如未装),然后告知我们,我们把 Sunshine 切到 NVENC 硬编码。
- 如果提示找不到命令或没有 NVIDIA 设备:跳过此步,走纯软件编码 + 虚拟显示器路线(也够验证流畅度)。
## 第 3 步:检查 Sunshine 服务状态
Sunshine 已装在 `C:\Program Files\Sunshine\`,服务名 `SunshineService`。
PowerShell 执行:
```powershell
Get-Service SunshineService
Restart-Service SunshineService
```
重启服务后再看日志确认识别到新显示器:
```powershell
Get-Content "C:\Program Files\Sunshine\config\sunshine.log" -Tail 60
```
需要看到的关键行(类似):
```
Desktop resolution [1920x1080]
Display refresh rate [60 Hz]
```
## 第 4 步:告知我们结果
只需回三条信息:
1. 虚拟显示器是否装好,分辨率/刷新率是多少;
2. `nvidia-smi` 是否有输出;
3. sunshine.log 最后几十行里的 Desktop resolution / Display refresh rate 数值。
我们这边会立刻重新发起 Moonlight 串流测试并采集性能数据。
## 备注
- Web UI(可选):https://192.168.1.70:47990 账号 admin / 密码 vdi12345
- 装驱动需要重启 VM,重启后 SunshineService 会自启,无需其他操作。
- Sunshine 的串流端口 47984/47989/47990/48010 已确认开放,防火墙无需改动。

20
docs/vdd_settings.xml Normal file
View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<display_config>
<target_display>
<target_x>0</target_x>
<target_y>0</target_y>
<target_width>1920</target_width>
<target_height>1080</target_height>
<target_refresh_rate>60</target_refresh_rate>
</target_display>
<adapter_index>0</adapter_index>
<modes>
<mode>
<width>1920</width>
<height>1080</height>
<refresh_rate>60</refresh_rate>
</mode>
</modes>
</display_config>
</root>

View File

@ -0,0 +1,287 @@
# VDI Web 管理控制台 MVP 实施方案
> 版本:v0.1(功能先行,UI 稿待项目经理提供)
> 日期:2026-10-28
> 依据:`VDI云桌面软件开发方案(1).docx` 第 2、4、5 节及 `VDI云桌面完整方案-云管端.docx` 管理平台模块表。
## 1. 先明确两个“登录”
本项目有两个不同的界面,必须分开:
| 界面 | 使用者 | 运行位置 | 职责 |
|---|---|---|---|
| 终端 kiosk-shell | 云桌面最终用户 | RK3528 盒子本地全屏 | 首配、设备绑定、用户登录、选择/进入被授权桌面、会话状态 |
| Web 管理控制台 console | 超级管理员、运维、只读人员 | PC 浏览器 | 终端、桌面池、用户绑定、GPU、监控、OTA、审计管理 |
正式文档中的 `console/` 指第二项。它不应运行在盒子上,也不应直接发起 RDP/Moonlight;它通过平台 API 管理资源和下发策略。
“盒子开机直连 Moonlight”只能作为当前串流技术验证,不是最终产品登录流程。最终终端流程应恢复为:
```text
盒子开机
-> kiosk-shell 全屏终端登录/首配页
-> vdt-agent 注册并获取终端身份
-> 用户登录或终端绑定校验
-> 平台返回该用户可用的桌面列表及短期连接信息
-> 用户选择桌面
-> kiosk-shell 按 protocol=rdp|moonlight 启动会话
-> 断开后回桌面列表/登录页
```
## 2. Web 控制台 MVP 目标
第一版目标不是做漂亮的大屏,而是跑通一个可演示、可替换 UI、可接真实后端的后台系统:
1. 用户能登录和退出;
2. 根据角色控制菜单和按钮;
3. 管理员能查看终端、桌面、用户及绑定关系;
4. 能对终端下发命令、对桌面执行开关机/重置;
5. 能查看基础状态、操作结果和审计记录;
6. 后端未完成时使用 Mock API,后端接口上线后不改页面业务逻辑即可切换。
## 3. 技术栈与工程结构
按定版文档执行:Vue 3 + Vite + TypeScript 严格模式 + Element Plus。
建议补充:Pinia(状态)、Vue Router、Axios、Vitest、Playwright、MSW(Mock API)。
```text
console/
├─ src/
│ ├─ api/ # OpenAPI 生成 SDK 的薄封装
│ ├─ assets/
│ ├─ components/ # 领域无关组件
│ ├─ layouts/ # 登录布局、控制台布局
│ ├─ mocks/ # MSW handlers 与演示数据
│ ├─ router/ # 路由、鉴权守卫、RBAC meta
│ ├─ stores/ # auth、app、notification
│ ├─ types/ # 页面 ViewModel;API 类型由生成器管理
│ ├─ views/
│ │ ├─ login/
│ │ ├─ dashboard/
│ │ ├─ terminals/
│ │ ├─ desktops/
│ │ ├─ users/
│ │ ├─ gpu/
│ │ ├─ monitoring/
│ │ ├─ ota/
│ │ └─ audit/
│ ├─ App.vue
│ └─ main.ts
├─ e2e/
├─ public/
├─ .env.development
├─ .env.mock
├─ package.json
└─ vite.config.ts
```
约束:页面不能直接拼 URL 或写 Axios;所有网络调用只经过 `api/`。这样 Mock 与真实后端可无感切换。
## 4. 页面与优先级
### M0:登录与应用框架
- 登录页:用户名、密码、登录按钮、错误状态、加载状态;
- 控制台壳:侧边导航、顶部栏、面包屑、当前账号、退出;
- 401 自动清理会话并回登录;
- 403 权限提示;
- 路由级 RBAC:`super_admin`、`operator`、`viewer`;
- 开发环境使用 Mock 登录,生产环境只调用真实 API。
### M1:核心演示闭环
1. 仪表盘:在线终端、活动会话、运行桌面、告警数量;
2. 终端管理:列表、搜索、状态筛选、详情、重启、重置会话;
3. 桌面管理:列表、状态筛选、开机、关机、重启、重置;
4. 用户与绑定:用户列表、用户详情、终端/桌面绑定与解绑;
5. 审计日志:操作者、动作、目标、结果、时间、请求 ID。
M1 完成后已经可以给项目经理演示完整业务流程。
### M2:完整文档范围
- 桌面池:模板、容量、回收策略;
- GPU 池:节点/PCI 地址/型号/状态/绑定桌面;
- 监控:会话在线数、FPS、RTT、节点水位、告警;
- OTA 中心:版本、批次、灰度比例、进度、失败终端;
- CSV 用户/绑定批量导入向导;
- 配置模板与配置版本下发。
## 5. 登录与权限方案
### 登录接口
```http
POST /api/v1/auth/login
Content-Type: application/json
{
"username": "admin",
"password": "******"
}
```
建议响应:
```json
{
"accessToken": "jwt",
"expiresIn": 3600,
"user": {
"id": "usr_001",
"username": "admin",
"displayName": "系统管理员",
"role": "super_admin"
}
}
```
建议第一版 access token 仅存内存;刷新 token 使用 `HttpOnly + Secure + SameSite` Cookie。若后端尚未支持 refresh,MVP 可暂存 `sessionStorage`,但不要长期使用 `localStorage` 保存高权限令牌。
### 权限矩阵
| 能力 | super_admin | operator | viewer |
|---|---:|---:|---:|
| 查看所有页面 | 是 | 是 | 是 |
| 终端远程命令 | 是 | 是 | 否 |
| 桌面生命周期操作 | 是 | 是 | 否 |
| 用户/绑定修改 | 是 | 否 | 否 |
| GPU 绑定 | 是 | 否 | 否 |
| OTA 发布/灰度 | 是 | 是 | 否 |
| 只读监控/审计 | 是 | 是 | 是 |
前端 RBAC 只是交互约束,真正授权必须由 `api-gateway` 再验证,不能只隐藏按钮。
## 6. 第一版 API 契约
文档已明确的接口优先沿用:
```text
POST /api/v1/auth/login
GET /api/v1/users
POST /api/v1/desktop/assign
POST /api/v1/pool/{id}/action
GET /api/v1/gpu/cards
POST /api/v1/gpu/bind
GET /api/v1/monitor/query
```
为控制台 MVP 补齐建议接口(需后端确认并写入 OpenAPI):
```text
GET /api/v1/auth/me
POST /api/v1/auth/logout
GET /api/v1/terminals
GET /api/v1/terminals/{id}
POST /api/v1/terminals/{id}/commands
GET /api/v1/desktops
GET /api/v1/desktops/{id}
POST /api/v1/desktops/{id}/actions
GET /api/v1/bindings
DELETE /api/v1/bindings/{id}
GET /api/v1/audit-logs
```
所有列表统一查询参数:`page`、`pageSize`、`keyword`、`status`、`sort`;统一响应:
```json
{
"items": [],
"page": 1,
"pageSize": 20,
"total": 0,
"requestId": "req_xxx"
}
```
所有命令型接口必须返回 `commandId`,页面展示“已提交”,再轮询或通过 WebSocket/MQTT 网关显示最终结果,不能把 HTTP 200 当作设备已经执行成功。
## 7. Mock 先行策略
后端还没完成时,前端不应硬编码页面数据。使用 MSW 模拟真实 HTTP:
- 固定 3 个测试账号,对应三角色;
- 终端包含在线/离线/会话中/升级中;
- 桌面包含运行/停止/错误/任务中;
- 所有命令先进入 `pending`,1–3 秒后变成 `success` 或 `failed`;
- 登录失败、401、403、网络超时、空列表都要有测试场景;
- `VITE_API_MODE=mock|real` 切换数据源。
项目经理 UI 稿到后,Mock 数据仍然可用于逐页视觉验收。
## 8. 与终端登录流程的接口边界
管理员 Web 控制台不直接把 VM 密码写进终端,也不让终端调用 Proxmox API。
建议终端用户登录流程由 `auth-svc` 单独提供:
```text
POST /api/v1/terminal/session/login
GET /api/v1/terminal/session/desktops
POST /api/v1/terminal/session/connect-ticket
```
`connect-ticket` 返回短期、一次性连接信息:
```json
{
"desktopId": "desk_001",
"protocol": "moonlight",
"endpoint": "192.168.1.70",
"app": "Desktop",
"display": { "width": 1920, "height": 1080, "fps": 60 },
"ticket": "short-lived-token",
"expiresAt": "2026-10-28T16:30:00+08:00"
}
```
这部分不是 `console/` 页面,但必须和 Web 控制台的“用户/终端/桌面绑定”共享同一套后端数据。
## 9. UI 稿到来前的页面设计原则
第一版只做稳定工作台,不做营销式页面:
- 左侧导航 + 顶部工具栏 + 主内容区;
- 列表优先,详情使用独立页或抽屉;
- 状态使用图标/标签,不依赖单一颜色;
- 危险操作二次确认;
- 所有异步操作显示提交、执行中、成功、失败四态;
- 页面级空态、错误态、无权限态必须完整;
- 颜色、字号、间距集中为设计 token,避免 UI 稿到后逐页改 CSS;
- 表格列、筛选区、表单字段以配置驱动,减少视觉改版成本。
## 10. 验收标准
M1 应达到:
1. 三种角色都能登录,菜单/按钮权限正确;
2. 刷新页面仍能恢复会话,令牌过期自动回登录;
3. 终端、桌面、用户/绑定、审计五条流程可演示;
4. 命令有执行中与最终结果,不出现“点击无反馈”;
5. API 全走统一 SDK/适配层,无页面直连 Axios;
6. Vitest 覆盖 auth store、路由守卫、权限判断;
7. Playwright 覆盖登录、终端命令、桌面操作、用户绑定四条主路径;
8. `npm run lint`、`npm run typecheck`、`npm run test`、`npm run build` 全通过。
## 11. 推荐实施顺序
```text
第 1 步:初始化 console 工程、路由、布局、设计 token
第 2 步:OpenAPI 初稿 + API SDK 生成 + MSW Mock
第 3 步:登录、会话恢复、RBAC、错误处理
第 4 步:终端管理 + 桌面管理
第 5 步:用户与绑定 + 审计日志
第 6 步:仪表盘聚合数据
第 7 步:Playwright E2E 与生产构建
第 8 步:项目经理 UI 稿到后替换视觉层
第 9 步:接真实 Go API,关闭 Mock
```
## 12. 当前决策建议
- 现在先开发 `console/` 的 M0 + M1,不等 UI 稿;
- 同时为 `terminal/kiosk-shell` 恢复“登录/桌面列表后连接”的产品流程,不再把开机直连 Moonlight 当最终形态;
- Web 控制台第一版只接 Mock,API 契约先和后端负责人确认;
- Moonlight/RDP 统一抽象成终端连接票据的 `protocol` 字段,Web 控制台只管理策略,不直接操控串流客户端;
- UI 稿到后只调整布局组件与 design tokens,业务路由、权限、API、测试保留。

View File

@ -14,6 +14,7 @@ endif()
# ---- Qt5 ---- # ---- Qt5 ----
find_package(Qt5 REQUIRED COMPONENTS Core Gui Widgets) find_package(Qt5 REQUIRED COMPONENTS Core Gui Widgets)
include(CTest)
add_executable(kiosk-shell add_executable(kiosk-shell
src/main.cpp src/main.cpp
@ -52,4 +53,39 @@ target_include_directories(vdi-kiosk-monitor PRIVATE
find_package(Threads REQUIRED) find_package(Threads REQUIRED)
target_link_libraries(vdi-kiosk-monitor PRIVATE Threads::Threads) target_link_libraries(vdi-kiosk-monitor PRIVATE Threads::Threads)
if(BUILD_TESTING)
find_package(Qt5 REQUIRED COMPONENTS Test)
add_executable(test-moonlight-session
tests/test_moonlight_session.cpp
src/moonlight_session.cpp
)
target_include_directories(test-moonlight-session PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src
)
target_link_libraries(test-moonlight-session PRIVATE
Qt5::Core
Qt5::Test
)
add_test(NAME moonlight-session COMMAND test-moonlight-session)
add_executable(test-desktop-list-page
tests/test_desktop_list_page.cpp
src/desktop_list_page.cpp
)
target_include_directories(test-desktop-list-page PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src
)
target_link_libraries(test-desktop-list-page PRIVATE
Qt5::Core
Qt5::Gui
Qt5::Widgets
Qt5::Test
)
add_test(NAME desktop-list-page COMMAND test-desktop-list-page)
set_tests_properties(desktop-list-page PROPERTIES
ENVIRONMENT "QT_QPA_PLATFORM=offscreen"
)
endif()
install(TARGETS kiosk-shell vdi-kiosk-monitor RUNTIME DESTINATION bin) install(TARGETS kiosk-shell vdi-kiosk-monitor RUNTIME DESTINATION bin)

View File

@ -35,11 +35,10 @@ DesktopListPage::DesktopListPage(QWidget* parent) : QWidget(parent) {
auto* infoRow = new QHBoxLayout(); auto* infoRow = new QHBoxLayout();
labelUser_ = new QLabel(this); labelUser_ = new QLabel(this);
labelUser_->setStyleSheet(QStringLiteral("color: #8a94a6;")); labelUser_->setStyleSheet(QStringLiteral("color: #8a94a6;"));
buttonLogout_ = new QPushButton(QStringLiteral("退出登录"), this); // 无登录页演示模式下不设"退出登录"(用户身份由设备/平台承担,
buttonLogout_->setMinimumSize(110, 38); // 正式认证接入后由 DesktopService 驱动恢复登录流程)
infoRow->addWidget(labelUser_); infoRow->addWidget(labelUser_);
addStretchToRow(infoRow); addStretchToRow(infoRow);
infoRow->addWidget(buttonLogout_);
root->addLayout(infoRow); root->addLayout(infoRow);
// ---- 卡片区 ---- // ---- 卡片区 ----
@ -62,12 +61,6 @@ DesktopListPage::DesktopListPage(QWidget* parent) : QWidget(parent) {
labelStatus_->setAlignment(Qt::AlignCenter); labelStatus_->setAlignment(Qt::AlignCenter);
labelStatus_->setStyleSheet(QStringLiteral("color: #8a94a6;")); labelStatus_->setStyleSheet(QStringLiteral("color: #8a94a6;"));
root->addWidget(labelStatus_); root->addWidget(labelStatus_);
connect(buttonLogout_, &QPushButton::clicked, this, [this]() {
if (!busy_) {
emit logoutRequested();
}
});
} }
void DesktopListPage::setDesktops(const std::vector<DesktopInfo>& desktops) { void DesktopListPage::setDesktops(const std::vector<DesktopInfo>& desktops) {
@ -76,6 +69,8 @@ void DesktopListPage::setDesktops(const std::vector<DesktopInfo>& desktops) {
} }
void DesktopListPage::rebuildCards() { void DesktopListPage::rebuildCards() {
actionButtons_.clear();
// 清空旧卡片(最后一项是 stretch) // 清空旧卡片(最后一项是 stretch)
while (cardsLayout_->count() > 1) { while (cardsLayout_->count() > 1) {
QLayoutItem* item = cardsLayout_->takeAt(0); QLayoutItem* item = cardsLayout_->takeAt(0);
@ -134,6 +129,8 @@ void DesktopListPage::rebuildCards() {
// “设置”:每张卡都可编辑连接参数(协议/地址/应用名/分辨率/帧率) // “设置”:每张卡都可编辑连接参数(协议/地址/应用名/分辨率/帧率)
auto* editBtn = new QPushButton(QStringLiteral("设置"), card); auto* editBtn = new QPushButton(QStringLiteral("设置"), card);
editBtn->setMinimumSize(88, 42); editBtn->setMinimumSize(88, 42);
editBtn->setEnabled(!busy_);
actionButtons_.push_back(editBtn);
connect(editBtn, &QPushButton::clicked, this, [this, id = d.id]() { connect(editBtn, &QPushButton::clicked, this, [this, id = d.id]() {
if (!busy_) { if (!busy_) {
emit editRequested(id); emit editRequested(id);
@ -148,8 +145,11 @@ void DesktopListPage::rebuildCards() {
"QPushButton { background: #2f6feb; color: white; border: none; " "QPushButton { background: #2f6feb; color: white; border: none; "
"border-radius: 6px; font-weight: bold; }" "border-radius: 6px; font-weight: bold; }"
"QPushButton:disabled { background: #3a4a63; }")); "QPushButton:disabled { background: #3a4a63; }"));
connectBtn->setEnabled(!busy_);
actionButtons_.push_back(connectBtn);
connect(connectBtn, &QPushButton::clicked, this, [this, id = d.id]() { connect(connectBtn, &QPushButton::clicked, this, [this, id = d.id]() {
if (!busy_) { if (!busy_) {
setBusy(true);
setStatus(QStringLiteral("正在连接,请稍候…")); setStatus(QStringLiteral("正在连接,请稍候…"));
emit desktopSelected(id); emit desktopSelected(id);
} }
@ -175,8 +175,11 @@ void DesktopListPage::setStatus(const QString& text, bool error) {
void DesktopListPage::setBusy(bool busy) { void DesktopListPage::setBusy(bool busy) {
busy_ = busy; busy_ = busy;
buttonLogout_->setEnabled(!busy); for (QPushButton* button : actionButtons_) {
// 连接按钮在 rebuildCards 时根据 busy_ 生成;这里统一刷新点击行为由 busy_ 拦截 if (button) {
button->setEnabled(!busy_);
}
}
} }
QString DesktopListPage::currentUser() const { QString DesktopListPage::currentUser() const {

View File

@ -38,8 +38,6 @@ signals:
void desktopSelected(const QString& desktopId); void desktopSelected(const QString& desktopId);
// 用户点击某张卡片的“设置”按钮 // 用户点击某张卡片的“设置”按钮
void editRequested(const QString& desktopId); void editRequested(const QString& desktopId);
// 用户点击退出登录
void logoutRequested();
private: private:
void rebuildCards(); void rebuildCards();
@ -50,7 +48,7 @@ private:
QLabel* labelUser_ = nullptr; QLabel* labelUser_ = nullptr;
QLabel* labelStatus_ = nullptr; QLabel* labelStatus_ = nullptr;
QVBoxLayout* cardsLayout_ = nullptr; QVBoxLayout* cardsLayout_ = nullptr;
QPushButton* buttonLogout_ = nullptr; std::vector<QPushButton*> actionButtons_;
bool busy_ = false; bool busy_ = false;
}; };

View File

@ -2,6 +2,7 @@
#include <QApplication> #include <QApplication>
#include <QDateTime> #include <QDateTime>
#include <QDebug>
#include <QFile> #include <QFile>
#include <QMenu> #include <QMenu>
#include <QShortcut> #include <QShortcut>
@ -17,12 +18,9 @@
namespace vdi { namespace vdi {
namespace { namespace {
// 首帧检测:moonlight-qt 是独立进程,kiosk 拿不到解码回调。 constexpr int kTicketTimeoutMs = 10000;
// 软解 1080p60 的 CPU 占用远高于启动握手期,因此轮询进程 CPU 时间, constexpr int kProcessStartTimeoutMs = 5000;
// 累计超过阈值即认为串流已出画;超时则强制淡出兜底(时钟频率 100 tick/秒)。 constexpr int kStreamReadyTimeoutMs = 30000;
constexpr int kFirstFramePollMs = 500;
constexpr qint64 kFirstFrameCpuTicks = 120;
constexpr qint64 kFirstFrameTimeoutMs = 15000;
} // namespace } // namespace
@ -35,10 +33,16 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
setupHeartbeat(); setupHeartbeat();
config_ = loadConfig(); config_ = loadConfig();
fsm_.transitionTo(State::kConfig, QStringLiteral("就绪"));
// 开机永远进入登录页;会话只能由用户在桌面列表选择后启动。 const QString deviceUser = QString::fromStdString(config_.sn).trimmed();
fsm_.transitionTo(State::kConfig, QStringLiteral("等待登录")); desktopService_.login(deviceUser.isEmpty() ? QStringLiteral("terminal") : deviceUser,
showLogin(); QStringLiteral("kiosk"), [this](const AuthResult& result) {
if (result.ok) {
currentUser_ = result.displayName;
}
fsm_.transitionTo(State::kAuthenticating, QStringLiteral("自动登录"));
fsm_.transitionTo(State::kDesktopList, QStringLiteral("请选择要进入的云桌面"));
});
} }
MainWindow::~MainWindow() { MainWindow::~MainWindow() {
@ -54,7 +58,6 @@ void MainWindow::setupUi() {
stack_->setStyleSheet(QStringLiteral("background: #0f1420;")); stack_->setStyleSheet(QStringLiteral("background: #0f1420;"));
setCentralWidget(stack_); setCentralWidget(stack_);
// 登录页
auto* login = new LoginPage(config_, this); auto* login = new LoginPage(config_, this);
loginPage_ = login; loginPage_ = login;
login->setStyleSheet( login->setStyleSheet(
@ -66,10 +69,8 @@ void MainWindow::setupUi() {
"border-radius: 6px; color: #e6e9ef; }" "border-radius: 6px; color: #e6e9ef; }"
"QPushButton:hover { background: #2b3654; }")); "QPushButton:hover { background: #2b3654; }"));
stack_->addWidget(login); stack_->addWidget(login);
connect(login, &LoginPage::loginRequested, this, &MainWindow::onLoginRequested); connect(login, &LoginPage::loginRequested, this, &MainWindow::onLoginRequested);
// 桌面列表页
auto* list = new DesktopListPage(this); auto* list = new DesktopListPage(this);
desktopListPage_ = list; desktopListPage_ = list;
list->setStyleSheet( list->setStyleSheet(
@ -78,36 +79,43 @@ void MainWindow::setupUi() {
"border-radius: 6px; color: #e6e9ef; }" "border-radius: 6px; color: #e6e9ef; }"
"QPushButton:hover { background: #2b3654; }")); "QPushButton:hover { background: #2b3654; }"));
stack_->addWidget(list); stack_->addWidget(list);
connect(list, &DesktopListPage::desktopSelected, this, &MainWindow::onDesktopSelected); connect(list, &DesktopListPage::desktopSelected, this, &MainWindow::onDesktopSelected);
connect(list, &DesktopListPage::editRequested, this, &MainWindow::onEditDesktopRequested); connect(list, &DesktopListPage::editRequested, this, &MainWindow::onEditDesktopRequested);
connect(list, &DesktopListPage::logoutRequested, this, &MainWindow::onLogoutRequested);
// 过场层:独立置顶窗口,盖住 moonlight-qt 启动期的小窗口闪烁与分辨率切换,
// 首帧检测到后淡出。不属于 stack_(串流期间整个 kiosk 都被 moonlight 覆盖)。
overlay_ = new TransitionOverlay(this); overlay_ = new TransitionOverlay(this);
// 状态条
statusBar_ = new QStatusBar(this); statusBar_ = new QStatusBar(this);
statusBar_->setStyleSheet(QStringLiteral("QStatusBar { background: #161d2e; color: #8a94a6; }")); statusBar_->setStyleSheet(QStringLiteral("QStatusBar { background: #161d2e; color: #8a94a6; }"));
setStatusBar(statusBar_); setStatusBar(statusBar_);
// 串流首帧检测 connectionTimer_ = new QTimer(this);
firstFrameTimer_ = new QTimer(this); connectionTimer_->setSingleShot(true);
firstFrameTimer_->setInterval(kFirstFramePollMs); connect(connectionTimer_, &QTimer::timeout, this, [this]() {
connect(firstFrameTimer_, &QTimer::timeout, this, &MainWindow::watchFirstFrame); QString message;
switch (connectionPhase_) {
case ConnectionPhase::kTicket:
message = QStringLiteral("获取连接信息超时");
break;
case ConnectionPhase::kProcessStart:
message = QStringLiteral("Moonlight 启动超时");
break;
case ConnectionPhase::kStreamReady:
message = QStringLiteral("云桌面画面就绪超时");
break;
default:
return;
}
failConnection(connectionAttempt_, message);
});
moonlightSession_ = std::make_unique<MoonlightSession>();
} }
void MainWindow::setupHotkey() { void MainWindow::setupHotkey() {
// 全局热键 Ctrl+Alt+Shift+M 呼出管理菜单(方案 3.2 节)
auto* menuShortcut = new QShortcut(QKeySequence(QStringLiteral("Ctrl+Alt+Shift+M")), this); auto* menuShortcut = new QShortcut(QKeySequence(QStringLiteral("Ctrl+Alt+Shift+M")), this);
menuShortcut->setContext(Qt::ApplicationShortcut); menuShortcut->setContext(Qt::ApplicationShortcut);
connect(menuShortcut, &QShortcut::activated, this, [this]() { onMenuAction(0); }); connect(menuShortcut, &QShortcut::activated, this, [this]() { onMenuAction(0); });
// Ctrl+Alt+Shift+Q 退出串流回桌面列表。
// 主路径是 moonlight-qt 自带的同一快捷键(串流独占键盘时由它退出,
// 进程退出后 onMoonlightState 自动回列表);这里兜底处理
// 串流尚未建立、假死或焦点仍在 kiosk 的情况。
auto* quitShortcut = new QShortcut(QKeySequence(QStringLiteral("Ctrl+Alt+Shift+Q")), this); auto* quitShortcut = new QShortcut(QKeySequence(QStringLiteral("Ctrl+Alt+Shift+Q")), this);
quitShortcut->setContext(Qt::ApplicationShortcut); quitShortcut->setContext(Qt::ApplicationShortcut);
connect(quitShortcut, &QShortcut::activated, this, [this]() { connect(quitShortcut, &QShortcut::activated, this, [this]() {
@ -124,8 +132,6 @@ void MainWindow::setupHotkey() {
} }
void MainWindow::setupHeartbeat() { void MainWindow::setupHeartbeat() {
// 定期写心跳文件,供看门狗 Monitor 判断主进程是否假死
// 对应会议纪要要求的双进程架构
const QString path = QStringLiteral("/tmp/vdi-kiosk.heartbeat"); const QString path = QStringLiteral("/tmp/vdi-kiosk.heartbeat");
heartbeatTimer_ = new QTimer(this); heartbeatTimer_ = new QTimer(this);
heartbeatTimer_->setInterval(5000); heartbeatTimer_->setInterval(5000);
@ -133,7 +139,6 @@ void MainWindow::setupHeartbeat() {
QFile file(path); QFile file(path);
if (file.open(QIODevice::WriteOnly | QIODevice::Text)) { if (file.open(QIODevice::WriteOnly | QIODevice::Text)) {
file.write(QByteArray::number(QDateTime::currentSecsSinceEpoch())); file.write(QByteArray::number(QDateTime::currentSecsSinceEpoch()));
file.close();
} }
}); });
heartbeatTimer_->start(); heartbeatTimer_->start();
@ -155,13 +160,9 @@ void MainWindow::onStateChanged(State from, State to, const QString& detail) {
refreshDesktopList(); refreshDesktopList();
break; break;
case State::kConnecting: case State::kConnecting:
firstFrameClock_.restart();
overlay_->start(QStringLiteral("正在进入云桌面")); overlay_->start(QStringLiteral("正在进入云桌面"));
firstFrameTimer_->start();
break; break;
case State::kSession: case State::kSession:
// 串流画面由 moonlight-qt 全屏呈现;过场层由首帧检测负责淡出
break;
case State::kReconnect: case State::kReconnect:
break; break;
case State::kError: case State::kError:
@ -230,25 +231,44 @@ void MainWindow::onLoginRequested(const QString& username, const QString& passwo
void MainWindow::onDesktopSelected(const QString& desktopId) { void MainWindow::onDesktopSelected(const QString& desktopId) {
if (!fsm_.canConnect()) { if (!fsm_.canConnect()) {
if (desktopListPage_) {
desktopListPage_->setBusy(false);
}
return; return;
} }
desktopService_.createTicket(desktopId, [this](const ConnectionTicket& ticket,
const QString& error) { const quint64 attempt = ++connectionAttempt_;
if (!error.isEmpty()) { connectingDesktopId_ = desktopId;
if (desktopListPage_) { connectionClock_.restart();
desktopListPage_->setBusy(false); phaseClock_.restart();
desktopListPage_->setStatus(error, true); connectionPhase_ = ConnectionPhase::kTicket;
} logConnectionPhase(attempt, QStringLiteral("ticket"), QStringLiteral("started"));
if (!fsm_.transitionTo(State::kConnecting, QStringLiteral("正在获取连接信息"))) {
failConnection(attempt, QStringLiteral("当前状态无法发起连接"));
return;
}
armConnectionTimeout(attempt, ConnectionPhase::kTicket, kTicketTimeoutMs);
desktopService_.createTicket(desktopId, [this, attempt](const ConnectionTicket& ticket,
const QString& error) {
if (attempt != connectionAttempt_ || connectionPhase_ != ConnectionPhase::kTicket) {
return; return;
} }
connectionTimer_->stop();
if (!error.isEmpty()) {
failConnection(attempt, error);
return;
}
logConnectionPhase(attempt, QStringLiteral("ticket"), QStringLiteral("ready"));
activeTicket_ = ticket; activeTicket_ = ticket;
startMoonlightSession(ticket); if (!startMoonlightSession(ticket, attempt)) {
failConnection(attempt, QStringLiteral("已有串流进程正在运行"));
}
}); });
} }
void MainWindow::onEditDesktopRequested(const QString& desktopId) { void MainWindow::onEditDesktopRequested(const QString& desktopId) {
// 桌面连接设置:编辑地址/应用名/分辨率/帧率,保存后立即刷新列表。
// 现场环境地址不同或走 VPN 隧道时,在这里改地址即可,无需改代码。
DesktopSettingsDialog dialog(desktops_, desktopId, this); DesktopSettingsDialog dialog(desktops_, desktopId, this);
dialog.setStyleSheet( dialog.setStyleSheet(
QStringLiteral("QWidget { background: #0f1420; color: #e6e9ef; }" QStringLiteral("QWidget { background: #0f1420; color: #e6e9ef; }"
@ -268,78 +288,126 @@ void MainWindow::onEditDesktopRequested(const QString& desktopId) {
} }
} }
void MainWindow::onLogoutRequested() { bool MainWindow::startMoonlightSession(const ConnectionTicket& ticket, quint64 attempt) {
stopSessions();
desktopService_.logout();
currentUser_.clear();
fsm_.transitionTo(State::kConfig, QStringLiteral("已退出登录"));
}
void MainWindow::startMoonlightSession(const ConnectionTicket& ticket) {
if (!fsm_.transitionTo(State::kConnecting, ticket.endpoint)) {
return;
}
MoonlightSession::Options opts; MoonlightSession::Options opts;
opts.decoder = QStringLiteral("software"); // RK3528 无可用 V4L2 硬解设备 opts.decoder = QStringLiteral("software");
opts.videoCodec = QStringLiteral("H.264"); opts.videoCodec = QStringLiteral("H.264");
opts.audioOnHost = true; opts.audioOnHost = true;
moonlightSession_ = std::make_unique<MoonlightSession>(/*parent=*/nullptr);
moonlightSession_->setOptions(opts); moonlightSession_->setOptions(opts);
moonlightSession_->start(ticket, [this](bool running, const QString& message) {
phaseClock_.restart();
connectionPhase_ = ConnectionPhase::kProcessStart;
logConnectionPhase(attempt, QStringLiteral("process_start"), QStringLiteral("started"));
armConnectionTimeout(attempt, ConnectionPhase::kProcessStart, kProcessStartTimeoutMs);
return moonlightSession_->start(ticket, [this, attempt](const MoonlightSession::Event& event) {
QMetaObject::invokeMethod( QMetaObject::invokeMethod(
this, this, [this, attempt, event]() { onMoonlightEvent(attempt, event); },
[this, running, message]() { onMoonlightState(running, message); },
Qt::QueuedConnection); Qt::QueuedConnection);
}); });
} }
void MainWindow::onMoonlightState(bool running, const QString& message) { void MainWindow::onMoonlightEvent(quint64 attempt, const MoonlightSession::Event& event) {
if (running) { if (attempt != connectionAttempt_) {
// 进程已拉起:转入会话态;过场层等首帧检测淡出
fsm_.transitionTo(State::kSession, message);
updateStatusBar(QStringLiteral("SESSION %1").arg(message));
return; return;
} }
// 进程退出:回桌面列表
moonlightSession_.reset(); switch (event.type) {
stopSessions(); case MoonlightSession::EventType::kStarted:
if (fsm_.current() == State::kConnecting || fsm_.current() == State::kSession) { if (connectionPhase_ != ConnectionPhase::kProcessStart) {
fsm_.transitionTo(State::kDesktopList, message); return;
}
connectionTimer_->stop();
logConnectionPhase(attempt, QStringLiteral("process_start"), QStringLiteral("ready"));
phaseClock_.restart();
armConnectionTimeout(attempt, ConnectionPhase::kStreamReady, kStreamReadyTimeoutMs);
updateStatusBar(QStringLiteral("CONNECTING 正在初始化云桌面画面"));
break;
case MoonlightSession::EventType::kStreamReady:
if (connectionPhase_ != ConnectionPhase::kStreamReady) {
return;
}
waitForMoonlightWindow(attempt);
break;
case MoonlightSession::EventType::kFailed:
failConnection(attempt, event.message);
break;
case MoonlightSession::EventType::kFinished: {
connectionTimer_->stop();
logConnectionPhase(attempt, QStringLiteral("session"), QStringLiteral("finished"));
++connectionAttempt_;
connectionPhase_ = ConnectionPhase::kIdle;
overlay_->finish();
if (fsm_.isIn(State::kConnecting) || fsm_.isIn(State::kSession) ||
fsm_.isIn(State::kReconnect)) {
fsm_.transitionTo(State::kDesktopList, event.message);
}
if (desktopListPage_) {
desktopListPage_->setBusy(false);
desktopListPage_->setStatus(event.message);
}
break;
}
}
}
void MainWindow::waitForMoonlightWindow(quint64 attempt) {
if (attempt != connectionAttempt_ || connectionPhase_ != ConnectionPhase::kStreamReady) {
return;
}
if (!moonlightSession_->activateStreamWindow()) {
QTimer::singleShot(100, this, [this, attempt]() { waitForMoonlightWindow(attempt); });
return;
}
connectionTimer_->stop();
logConnectionPhase(attempt, QStringLiteral("stream_ready"), QStringLiteral("ready"));
connectionPhase_ = ConnectionPhase::kSession;
fsm_.transitionTo(State::kSession, QStringLiteral("云桌面画面已就绪"));
overlay_->onFirstFrame();
}
void MainWindow::failConnection(quint64 attempt, const QString& message) {
if (attempt != connectionAttempt_) {
return;
}
connectionTimer_->stop();
logConnectionPhase(attempt, QStringLiteral("connection"), QStringLiteral("failed"), message);
++connectionAttempt_;
connectionPhase_ = ConnectionPhase::kIdle;
if (moonlightSession_) {
moonlightSession_->stop();
}
overlay_->finish();
if (fsm_.isIn(State::kConnecting) || fsm_.isIn(State::kSession) ||
fsm_.isIn(State::kReconnect)) {
fsm_.transitionTo(State::kDesktopList, QStringLiteral("连接失败"));
} }
if (desktopListPage_) { if (desktopListPage_) {
desktopListPage_->setBusy(false); desktopListPage_->setBusy(false);
desktopListPage_->setStatus(message); desktopListPage_->setStatus(QStringLiteral("连接失败:%1").arg(message), true);
} }
} }
void MainWindow::watchFirstFrame() { void MainWindow::armConnectionTimeout(quint64 attempt, ConnectionPhase phase, int timeoutMs) {
if (!moonlightSession_ || !moonlightSession_->isRunning()) { if (attempt != connectionAttempt_) {
return; // 进程退出路径由 onMoonlightState 收尾 return;
}
qint64 cpuTicks = -1;
// /proc/<pid>/stat 的 comm 字段可含空格,从最后一个 ')' 后取字段:
// 第 0 个是 state(整行第 3 字段),utime/stime 是第 11/12 个。
QFile statFile(QStringLiteral("/proc/%1/stat").arg(moonlightSession_->processId()));
if (statFile.open(QIODevice::ReadOnly | QIODevice::Text)) {
const QString content = QString::fromLatin1(statFile.readAll());
const QStringList fields =
content.mid(content.lastIndexOf(')') + 1).split(' ', Qt::SkipEmptyParts);
if (fields.size() > 12) {
cpuTicks = fields.value(11).toLongLong() + fields.value(12).toLongLong();
}
}
if (cpuTicks >= kFirstFrameCpuTicks || firstFrameClock_.elapsed() >= kFirstFrameTimeoutMs) {
onFirstFrameDetected();
} }
connectionPhase_ = phase;
connectionTimer_->start(timeoutMs);
} }
void MainWindow::onFirstFrameDetected() { void MainWindow::logConnectionPhase(quint64 attempt, const QString& phase,
firstFrameTimer_->stop(); const QString& result, const QString& error) {
if (overlay_) { const qint64 totalMs = connectionClock_.isValid() ? connectionClock_.elapsed() : 0;
overlay_->onFirstFrame(); const qint64 phaseMs = phaseClock_.isValid() ? phaseClock_.elapsed() : 0;
} qInfo().noquote()
<< QStringLiteral("vdi_connection attempt=%1 desktop=%2 phase=%3 result=%4 phase_ms=%5 total_ms=%6 error=%7")
.arg(attempt)
.arg(connectingDesktopId_, phase, result)
.arg(phaseMs)
.arg(totalMs)
.arg(error);
} }
void MainWindow::onQuitRequested() { void MainWindow::onQuitRequested() {
@ -348,12 +416,17 @@ void MainWindow::onQuitRequested() {
} }
void MainWindow::stopSessions() { void MainWindow::stopSessions() {
if (firstFrameTimer_) { stopSessions(true);
firstFrameTimer_->stop(); }
void MainWindow::stopSessions(bool invalidateAttempt) {
connectionTimer_->stop();
if (invalidateAttempt) {
++connectionAttempt_;
} }
connectionPhase_ = ConnectionPhase::kIdle;
if (moonlightSession_) { if (moonlightSession_) {
moonlightSession_->stop(); moonlightSession_->stop();
moonlightSession_.reset();
} }
if (overlay_) { if (overlay_) {
overlay_->finish(); overlay_->finish();
@ -362,19 +435,24 @@ void MainWindow::stopSessions() {
void MainWindow::onMenuAction(int action) { void MainWindow::onMenuAction(int action) {
switch (action) { switch (action) {
case 1: // 重新连接当前桌面 case 1:
if (fsm_.canConnect() && !activeTicket_.desktopId.isEmpty()) { if (fsm_.canConnect() && !activeTicket_.desktopId.isEmpty()) {
stopSessions(); const quint64 attempt = ++connectionAttempt_;
startMoonlightSession(activeTicket_); connectingDesktopId_ = activeTicket_.desktopId;
connectionClock_.restart();
if (fsm_.transitionTo(State::kConnecting, activeTicket_.endpoint) &&
!startMoonlightSession(activeTicket_, attempt)) {
failConnection(attempt, QStringLiteral("已有串流进程正在运行"));
}
} }
break; break;
case 2: // 断开回桌面列表 case 2:
stopSessions(); stopSessions();
if (fsm_.current() != State::kConfig) { if (fsm_.current() != State::kConfig) {
fsm_.transitionTo(State::kDesktopList, "已断开"); fsm_.transitionTo(State::kDesktopList, QStringLiteral("已断开"));
} }
break; break;
case 3: // 退出终端(维护功能,只保留在管理菜单) case 3:
onQuitRequested(); onQuitRequested();
break; break;
default: { default: {

View File

@ -1,15 +1,6 @@
// Kiosk 主窗口 // Kiosk 主窗口
// //
// 产品流程编排: // 产品流程编排:开机 -> 桌面列表 -> 获取连接票据 -> Moonlight 会话 -> 返回列表。
// 开机 -> 登录页 -> 认证 -> 桌面列表 -> 选择桌面
// -> 获取连接票据 -> Moonlight 串流会话(外部进程 moonlight-qt)
// -> 会话结束回桌面列表;退出登录回登录页
//
// 对应《VDI 云桌面软件开发方案》3.2 节的状态机,
// 并按产品流程扩展 AUTHENTICATING / DESKTOP_LIST 两个状态。
// 全局热键 Ctrl+Alt+Shift+M 呼出管理菜单(含退出终端);
// Ctrl+Alt+Shift+Q 退出串流:moonlight-qt 自带同名快捷键,
// kiosk 侧保留一份兜底,串流未建立或焦点仍在 kiosk 时也能退出。
#ifndef KIOSK_SHELL_MAIN_WINDOW_H #ifndef KIOSK_SHELL_MAIN_WINDOW_H
#define KIOSK_SHELL_MAIN_WINDOW_H #define KIOSK_SHELL_MAIN_WINDOW_H
@ -28,7 +19,6 @@
class QStackedWidget; class QStackedWidget;
class QStatusBar; class QStatusBar;
class QTimer; class QTimer;
class QEvent;
namespace vdi { namespace vdi {
@ -47,30 +37,43 @@ private slots:
void onLoginRequested(const QString& username, const QString& password); void onLoginRequested(const QString& username, const QString& password);
void onDesktopSelected(const QString& desktopId); void onDesktopSelected(const QString& desktopId);
void onEditDesktopRequested(const QString& desktopId); void onEditDesktopRequested(const QString& desktopId);
void onLogoutRequested();
void onQuitRequested(); void onQuitRequested();
void onMoonlightState(bool running, const QString& message);
void onMenuAction(int action); void onMenuAction(int action);
private: private:
enum class ConnectionPhase {
kIdle,
kTicket,
kProcessStart,
kStreamReady,
kSession,
};
void setupUi(); void setupUi();
void setupHotkey(); void setupHotkey();
void setupHeartbeat(); void setupHeartbeat();
void onStateChanged(vdi::State from, vdi::State to, const QString& detail); void onStateChanged(vdi::State from, vdi::State to, const QString& detail);
void refreshDesktopList(); void refreshDesktopList();
void startMoonlightSession(const ConnectionTicket& ticket); bool startMoonlightSession(const ConnectionTicket& ticket, quint64 attempt);
void onMoonlightEvent(quint64 attempt, const MoonlightSession::Event& event);
void waitForMoonlightWindow(quint64 attempt);
void stopSessions(); void stopSessions();
void stopSessions(bool invalidateAttempt);
void failConnection(quint64 attempt, const QString& message);
void armConnectionTimeout(quint64 attempt, ConnectionPhase phase, int timeoutMs);
void logConnectionPhase(quint64 attempt, const QString& phase,
const QString& result = QString(),
const QString& error = QString());
void showLogin(); void showLogin();
void showDesktopList(); void showDesktopList();
void updateStatusBar(const QString& text); void updateStatusBar(const QString& text);
void watchFirstFrame();
void onFirstFrameDetected();
StateMachine fsm_; StateMachine fsm_;
DesktopService desktopService_; DesktopService desktopService_;
std::unique_ptr<MoonlightSession> moonlightSession_; std::unique_ptr<MoonlightSession> moonlightSession_;
ConnectionTicket activeTicket_; ConnectionTicket activeTicket_;
QString currentUser_; QString currentUser_;
QString connectingDesktopId_;
std::vector<DesktopInfo> desktops_; std::vector<DesktopInfo> desktops_;
QPointer<LoginPage> loginPage_; QPointer<LoginPage> loginPage_;
@ -78,9 +81,12 @@ private:
QStackedWidget* stack_ = nullptr; QStackedWidget* stack_ = nullptr;
QStatusBar* statusBar_ = nullptr; QStatusBar* statusBar_ = nullptr;
TransitionOverlay* overlay_ = nullptr; TransitionOverlay* overlay_ = nullptr;
QTimer* firstFrameTimer_ = nullptr; QTimer* connectionTimer_ = nullptr;
QTimer* heartbeatTimer_ = nullptr; QTimer* heartbeatTimer_ = nullptr;
QElapsedTimer firstFrameClock_; QElapsedTimer connectionClock_;
QElapsedTimer phaseClock_;
quint64 connectionAttempt_ = 0;
ConnectionPhase connectionPhase_ = ConnectionPhase::kIdle;
TerminalConfig config_; TerminalConfig config_;
}; };

View File

@ -159,6 +159,12 @@ int Monitor::run(const std::string& command) {
log("主进程已退出, code=" + std::to_string(exitCode) + log("主进程已退出, code=" + std::to_string(exitCode) +
(signalNum ? (", signal=" + std::to_string(signalNum)) : "")); (signalNum ? (", signal=" + std::to_string(signalNum)) : ""));
// shell 主进程退出时 Moonlight 等后代可能仍留在原进程组中。
// 重启前清理整个组,避免旧串流窗口与新实例并存。
killTree(child, SIGTERM);
std::this_thread::sleep_for(std::chrono::milliseconds(300));
killTree(child, SIGKILL);
if (++restartAttempts_ > config_.maxRestartAttempts) { if (++restartAttempts_ > config_.maxRestartAttempts) {
log("连续重启超过 " + std::to_string(config_.maxRestartAttempts) + log("连续重启超过 " + std::to_string(config_.maxRestartAttempts) +
" 次,放弃重启(熔断)"); " 次,放弃重启(熔断)");

View File

@ -1,17 +1,104 @@
#include "moonlight_session.h" #include "moonlight_session.h"
#include <QDate> #include <QDate>
#include <QDateTime>
#include <QDir> #include <QDir>
#include <QFile> #include <QFile>
#include <QProcessEnvironment>
#include <QTextStream> #include <QTextStream>
#include <QTimer>
namespace vdi { namespace vdi {
namespace {
MoonlightSession::MoonlightSession(QObject* parent) : QObject(parent) {} constexpr int kReadyStableMs = 750;
constexpr int kTerminateGraceMs = 2000;
bool isTestDecodeLine(const QByteArray& line) {
return line.toLower().contains("test decode successful");
}
bool isRendererLine(const QByteArray& line) {
const QByteArray lower = line.toLower();
return lower.contains("renderer '") && lower.contains(" chosen");
}
bool isTerminalErrorLine(const QByteArray& line) {
const QByteArray lower = line.toLower();
if (lower.contains("会话已终止")) {
return true;
}
const QByteArray marker("connection terminated:");
const int markerPos = lower.indexOf(marker);
if (markerPos < 0) {
return false;
}
bool ok = false;
const int code = lower.mid(markerPos + marker.size()).trimmed().toInt(&ok);
return !ok || code != 0;
}
} // namespace
void MoonlightOutputParser::reset() {
buffer_.clear();
hasTestDecode_ = false;
hasRenderer_ = false;
hasTerminalError_ = false;
}
void MoonlightOutputParser::append(const QByteArray& output) {
buffer_.append(output);
int newline = -1;
while ((newline = buffer_.indexOf('\n')) >= 0) {
const QByteArray line = buffer_.left(newline).trimmed();
buffer_.remove(0, newline + 1);
hasTestDecode_ = hasTestDecode_ || isTestDecodeLine(line);
hasRenderer_ = hasRenderer_ || isRendererLine(line);
hasTerminalError_ = hasTerminalError_ || isTerminalErrorLine(line);
}
}
bool MoonlightOutputParser::isReady() const {
return hasTestDecode_ && hasRenderer_ && !hasTerminalError_;
}
MoonlightSession::MoonlightSession(QObject* parent) : QObject(parent) {
process_ = new QProcess(this);
process_->setProcessChannelMode(QProcess::MergedChannels);
connect(process_, &QProcess::started, this, &MoonlightSession::onProcessStarted);
connect(process_,
QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished), this,
&MoonlightSession::onProcessFinished);
connect(process_, &QProcess::errorOccurred, this, &MoonlightSession::onProcessError);
connect(process_, &QProcess::readyReadStandardOutput, this,
&MoonlightSession::onProcessOutput);
readyTimer_ = new QTimer(this);
readyTimer_->setSingleShot(true);
connect(readyTimer_, &QTimer::timeout, this, [this]() {
if (!readyEventDelivered_ && !terminalEventDelivered_ && isRunning()) {
readyEventDelivered_ = true;
emitEvent(EventType::kStreamReady, QStringLiteral("串流画面已就绪"));
}
});
killTimer_ = new QTimer(this);
killTimer_->setSingleShot(true);
connect(killTimer_, &QTimer::timeout, this, [this]() {
if (isRunning()) {
process_->kill();
}
});
}
MoonlightSession::~MoonlightSession() { MoonlightSession::~MoonlightSession() {
stop(); callback_ = nullptr;
readyTimer_->stop();
killTimer_->stop();
if (isRunning()) {
process_->kill();
process_->waitForFinished(1000);
}
} }
void MoonlightSession::setOptions(const Options& options) { void MoonlightSession::setOptions(const Options& options) {
@ -19,23 +106,55 @@ void MoonlightSession::setOptions(const Options& options) {
} }
bool MoonlightSession::isRunning() const { bool MoonlightSession::isRunning() const {
return process_ && process_->state() != QProcess::NotRunning; return process_->state() != QProcess::NotRunning;
} }
qint64 MoonlightSession::processId() const { qint64 MoonlightSession::processId() const {
return isRunning() ? process_->processId() : 0; return isRunning() ? process_->processId() : 0;
} }
bool MoonlightSession::start(const ConnectionTicket& ticket, StateCallback callback) { bool MoonlightSession::activateStreamWindow() {
if (!isRunning()) {
return false;
}
QProcess finder;
finder.start(QStringLiteral("xdotool"),
{QStringLiteral("search"), QStringLiteral("--onlyvisible"),
QStringLiteral("--pid"), QString::number(process_->processId()),
QStringLiteral("--class"), QStringLiteral("com.moonlight_stream.Moonlight")});
if (!finder.waitForFinished(500) || finder.exitCode() != 0) {
return false;
}
const QString windowId = QString::fromUtf8(finder.readAllStandardOutput())
.split(QLatin1Char('\n'), Qt::SkipEmptyParts)
.value(0).trimmed();
if (windowId.isEmpty()) {
return false;
}
return QProcess::execute(QStringLiteral("xdotool"),
{QStringLiteral("windowactivate"), QStringLiteral("--sync"),
windowId}) == 0;
}
bool MoonlightSession::start(const ConnectionTicket& ticket, EventCallback callback) {
if (isRunning()) { if (isRunning()) {
return false; return false;
} }
callback_ = std::move(callback);
// moonlight-qt 需要 X11 会话环境;由 kiosk-shell 继承当前 DISPLAY/XAUTHORITY callback_ = std::move(callback);
process_ = new QProcess(this); stopRequested_ = false;
terminalEventDelivered_ = false;
readyEventDelivered_ = false;
outputParser_.reset();
readyTimer_->stop();
killTimer_->stop();
process_->setProgram(QStringLiteral("moonlight-qt")); process_->setProgram(QStringLiteral("moonlight-qt"));
process_->setProcessChannelMode(QProcess::MergedChannels); QProcessEnvironment environment = QProcessEnvironment::systemEnvironment();
environment.insert(QStringLiteral("GL_IS_SLOW"), QStringLiteral("0"));
process_->setProcessEnvironment(environment);
QStringList args; QStringList args;
args << QStringLiteral("stream") args << QStringLiteral("stream")
@ -50,96 +169,109 @@ bool MoonlightSession::start(const ConnectionTicket& ticket, StateCallback callb
<< QStringLiteral("--video-decoder") << options_.decoder << QStringLiteral("--video-decoder") << options_.decoder
<< QStringLiteral("--absolute-mouse") << QStringLiteral("--absolute-mouse")
<< QStringLiteral("--keep-awake"); << QStringLiteral("--keep-awake");
if (options_.vsync) { args << (options_.vsync ? QStringLiteral("--vsync") : QStringLiteral("--no-vsync"));
args << QStringLiteral("--vsync"); args << (options_.framePacing ? QStringLiteral("--frame-pacing")
} else { : QStringLiteral("--no-frame-pacing"));
args << QStringLiteral("--no-vsync");
}
if (options_.framePacing) {
args << QStringLiteral("--frame-pacing");
} else {
args << QStringLiteral("--no-frame-pacing");
}
if (options_.performanceOverlay) { if (options_.performanceOverlay) {
args << QStringLiteral("--performance-overlay"); args << QStringLiteral("--performance-overlay");
} }
if (options_.audioOnHost) { args << (options_.audioOnHost ? QStringLiteral("--audio-on-host")
args << QStringLiteral("--audio-on-host"); : QStringLiteral("--no-audio-on-host"));
} else {
args << QStringLiteral("--no-audio-on-host");
}
process_->setArguments(args); process_->setArguments(args);
// 日志追加到 moonlight-yyyyMMdd.log,便于现场排查 const QString logDir = QDir::homePath() + QStringLiteral("/vdi-terminal/logs");
const QString logDir =
QDir::homePath() + QStringLiteral("/vdi-terminal/logs");
QDir().mkpath(logDir); QDir().mkpath(logDir);
const QString logPath = logDir + QStringLiteral("/moonlight-%1.log") logPath_ = logDir + QStringLiteral("/moonlight-%1.log")
.arg(QDate::currentDate().toString(QStringLiteral("yyyyMMdd"))); .arg(QDate::currentDate().toString(QStringLiteral("yyyyMMdd")));
// QProcess::finished 在 Qt5 里有两个重载((int) 已废弃),必须用 QOverload 消歧
connect(process_,
QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished), this,
&MoonlightSession::onProcessFinished);
connect(process_, &QProcess::errorOccurred, this, &MoonlightSession::onProcessError);
connect(process_, &QProcess::readyReadStandardOutput, this, [this, logPath]() {
QFile log(logPath);
if (log.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text)) {
QTextStream(&log) << QString::fromUtf8(process_->readAllStandardOutput());
}
});
if (callback_) {
callback_(true, QStringLiteral("串流进程已启动"));
}
process_->start(); process_->start();
return true; return true;
} }
void MoonlightSession::onProcessStarted() {
emitEvent(EventType::kStarted, QStringLiteral("串流进程已启动"));
}
void MoonlightSession::onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus) { void MoonlightSession::onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus) {
QString message; readyTimer_->stop();
if (exitStatus == QProcess::CrashExit) { killTimer_->stop();
message = QStringLiteral("串流异常退出 (code=%1)").arg(exitCode);
} else { if (stopRequested_) {
message = QStringLiteral("串流已结束 (code=%1)").arg(exitCode); emitTerminalEvent(EventType::kFinished, QStringLiteral("串流已结束"));
} return;
process_->deleteLater();
process_ = nullptr;
if (callback_) {
callback_(false, message);
} }
const QString message = exitStatus == QProcess::CrashExit
? QStringLiteral("串流异常退出 (code=%1)").arg(exitCode)
: QStringLiteral("串流已结束 (code=%1)").arg(exitCode);
emitTerminalEvent(exitStatus == QProcess::CrashExit ? EventType::kFailed
: EventType::kFinished,
message);
} }
void MoonlightSession::onProcessError(QProcess::ProcessError error) { void MoonlightSession::onProcessError(QProcess::ProcessError error) {
QString message; if (error == QProcess::Crashed) {
switch (error) { return;
case QProcess::FailedToStart:
message = QStringLiteral("无法启动 moonlight-qt,请检查安装");
break;
case QProcess::Crashed:
return; // 由 finished(CrashExit) 统一上报
default:
message = QStringLiteral("串流进程错误 (%1)").arg(static_cast<int>(error));
break;
} }
if (callback_) {
callback_(false, message); const QString message = error == QProcess::FailedToStart
? QStringLiteral("无法启动 moonlight-qt,请检查安装")
: QStringLiteral("串流进程错误 (%1)").arg(static_cast<int>(error));
emitTerminalEvent(EventType::kFailed, message);
}
void MoonlightSession::onProcessOutput() {
const QByteArray output = process_->readAllStandardOutput();
if (output.isEmpty()) {
return;
}
QFile log(logPath_);
if (log.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text)) {
log.write(output);
}
const bool wasReady = outputParser_.isReady();
outputParser_.append(output);
if (outputParser_.hasTerminalError()) {
readyTimer_->stop();
emitTerminalEvent(EventType::kFailed, QStringLiteral("云桌面连接已中断"));
stop();
return;
}
if (!wasReady && outputParser_.isReady()) {
scheduleReadyEvent();
} }
} }
void MoonlightSession::stop() { void MoonlightSession::stop() {
if (!process_) { if (!isRunning()) {
return; return;
} }
QProcess* proc = process_; stopRequested_ = true;
process_ = nullptr; // 先断开自身引用,避免 finished 回调再触发状态迁移 readyTimer_->stop();
disconnect(proc, nullptr, this, nullptr); process_->terminate();
proc->terminate(); killTimer_->start(kTerminateGraceMs);
if (!proc->waitForFinished(2000)) { }
proc->kill();
proc->waitForFinished(1000); void MoonlightSession::emitEvent(EventType type, const QString& message) {
if (callback_) {
callback_(Event{type, message});
}
}
void MoonlightSession::emitTerminalEvent(EventType type, const QString& message) {
if (terminalEventDelivered_) {
return;
}
terminalEventDelivered_ = true;
emitEvent(type, message);
}
void MoonlightSession::scheduleReadyEvent() {
if (!readyEventDelivered_ && !terminalEventDelivered_ && isRunning()) {
readyTimer_->start(kReadyStableMs);
} }
proc->deleteLater();
} }
} // namespace vdi } // namespace vdi

View File

@ -1,60 +1,96 @@
// Moonlight 会话(外部进程) // Moonlight 会话(外部进程)
// //
// 以 QProcess 参数数组直启 moonlight-qt(不拼 shell 命令), // 以 QProcess 参数数组直启 moonlight-qt(不拼 shell 命令)。
// 串流期间 moonlight-qt 独占全屏显示;进程退出后回调 MainWindow 返回桌面列表。 // 进程启动、串流就绪和终止是不同事件,调用方据此控制过场和状态机。
// 参数来自 ConnectionTicket,不再读取全局静态配置。
#ifndef KIOSK_SHELL_MOONLIGHT_SESSION_H #ifndef KIOSK_SHELL_MOONLIGHT_SESSION_H
#define KIOSK_SHELL_MOONLIGHT_SESSION_H #define KIOSK_SHELL_MOONLIGHT_SESSION_H
#include <QByteArray>
#include <QProcess> #include <QProcess>
#include <QString> #include <QString>
#include <functional> #include <functional>
#include "desktop_session.h" #include "desktop_session.h"
class QTimer;
namespace vdi { namespace vdi {
class MoonlightOutputParser {
public:
void reset();
void append(const QByteArray& output);
bool isReady() const;
bool hasTestDecode() const { return hasTestDecode_; }
bool hasRenderer() const { return hasRenderer_; }
bool hasTerminalError() const { return hasTerminalError_; }
private:
QByteArray buffer_;
bool hasTestDecode_ = false;
bool hasRenderer_ = false;
bool hasTerminalError_ = false;
};
class MoonlightSession : public QObject { class MoonlightSession : public QObject {
Q_OBJECT Q_OBJECT
public: public:
struct Options { struct Options {
// 低延迟开关(实测 A/B 后定版)
bool vsync = true; bool vsync = true;
bool framePacing = true; bool framePacing = true;
bool performanceOverlay = false; bool performanceOverlay = false;
bool audioOnHost = true; bool audioOnHost = true;
QString videoCodec = QStringLiteral("H.264"); QString videoCodec = QStringLiteral("H.264");
QString decoder = QStringLiteral("software"); // RK3528 无可用 V4L2 硬解设备 QString decoder = QStringLiteral("software");
}; };
using StateCallback = std::function<void(bool running, const QString& message)>; enum class EventType {
kStarted,
kStreamReady,
kFailed,
kFinished,
};
struct Event {
EventType type;
QString message;
};
using EventCallback = std::function<void(const Event& event)>;
explicit MoonlightSession(QObject* parent = nullptr); explicit MoonlightSession(QObject* parent = nullptr);
~MoonlightSession() override; ~MoonlightSession() override;
void setOptions(const Options& options); void setOptions(const Options& options);
bool start(const ConnectionTicket& ticket, EventCallback callback);
// 启动串流;成功返回 true(不代表串流已建立,仅代表进程已拉起)
bool start(const ConnectionTicket& ticket, StateCallback callback);
// 终止串流(管理菜单/退出登录时调用)
void stop(); void stop();
bool isRunning() const; bool isRunning() const;
// 串流进程 PID(未运行返回 0),供首帧检测读 /proc 统计
qint64 processId() const; qint64 processId() const;
bool activateStreamWindow();
private slots: private slots:
void onProcessStarted();
void onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus); void onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus);
void onProcessError(QProcess::ProcessError error); void onProcessError(QProcess::ProcessError error);
void onProcessOutput();
private: private:
void emitEvent(EventType type, const QString& message);
void emitTerminalEvent(EventType type, const QString& message);
void scheduleReadyEvent();
QProcess* process_ = nullptr; QProcess* process_ = nullptr;
QTimer* readyTimer_ = nullptr;
QTimer* killTimer_ = nullptr;
Options options_; Options options_;
StateCallback callback_; EventCallback callback_;
MoonlightOutputParser outputParser_;
QString logPath_;
bool stopRequested_ = false;
bool terminalEventDelivered_ = false;
bool readyEventDelivered_ = false;
}; };
} // namespace vdi } // namespace vdi

View File

@ -2,7 +2,9 @@
#include <QEvent> #include <QEvent>
#include <QFont> #include <QFont>
#include <QGuiApplication>
#include <QLabel> #include <QLabel>
#include <QScreen>
#include <QTimer> #include <QTimer>
#include <QVBoxLayout> #include <QVBoxLayout>
#include <QVariant> #include <QVariant>
@ -10,7 +12,7 @@
namespace vdi { namespace vdi {
namespace { namespace {
constexpr int kTickMs = 400; // 省略号/旋轮动画与置顶保活周期 constexpr int kTickMs = 400; // 省略号/旋轮动画周期
constexpr int kFadeStepMs = 40; // 淡出步进周期 constexpr int kFadeStepMs = 40; // 淡出步进周期
constexpr int kFadeSteps = 25; // 全程约 1 秒淡出 constexpr int kFadeSteps = 25; // 全程约 1 秒淡出
@ -19,9 +21,10 @@ const char* kSpinnerFrames[] = {"◐", "◓", "◑", "◒"};
} // namespace } // namespace
TransitionOverlay::TransitionOverlay(QWidget* parent) : QWidget(parent) { TransitionOverlay::TransitionOverlay(QWidget* parent) : QWidget(parent) {
// 独立置顶无边框窗口:串流启动后 moonlight-qt 会盖住 kiosk 主窗口, // 独立置顶无边框窗口只在启动时映射一次,后续不再与 Moonlight 抢焦点。
// 过场层必须压在它之上才起作用(onTick 周期性 raise 兜底)。 setWindowFlags(Qt::Window | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint |
setWindowFlags(Qt::Window | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint); Qt::WindowDoesNotAcceptFocus);
setAttribute(Qt::WA_ShowWithoutActivating);
setStyleSheet(QStringLiteral("TransitionOverlay { background-color: #000000; }")); setStyleSheet(QStringLiteral("TransitionOverlay { background-color: #000000; }"));
auto* layout = new QVBoxLayout(this); auto* layout = new QVBoxLayout(this);
@ -63,19 +66,17 @@ void TransitionOverlay::start(const QString& title) {
labelTitle_->setText(title); labelTitle_->setText(title);
labelSpinner_->setText(QString::fromUtf8(kSpinnerFrames[0])); labelSpinner_->setText(QString::fromUtf8(kSpinnerFrames[0]));
showFullScreen(); if (QScreen* targetScreen = QGuiApplication::primaryScreen()) {
setGeometry(targetScreen->geometry());
}
show();
raise(); raise();
activateWindow();
grabKeyboard();
grabMouse();
tickTimer_->start(kTickMs); tickTimer_->start(kTickMs);
} }
void TransitionOverlay::finish() { void TransitionOverlay::finish() {
tickTimer_->stop(); tickTimer_->stop();
fadeTimer_->stop(); fadeTimer_->stop();
releaseKeyboard();
releaseMouse();
hide(); hide();
setWindowOpacity(1.0); setWindowOpacity(1.0);
} }
@ -86,8 +87,6 @@ void TransitionOverlay::onFirstFrame() {
return; return;
} }
tickTimer_->stop(); tickTimer_->stop();
releaseKeyboard();
releaseMouse();
fadeTimer_->start(kFadeStepMs); fadeTimer_->start(kFadeStepMs);
} }
@ -98,8 +97,6 @@ void TransitionOverlay::onTick() {
labelTitle_->setText(base + QString(dots_, QLatin1Char('.'))); labelTitle_->setText(base + QString(dots_, QLatin1Char('.')));
} }
labelSpinner_->setText(QString::fromUtf8(kSpinnerFrames[dots_])); labelSpinner_->setText(QString::fromUtf8(kSpinnerFrames[dots_]));
// X11 下 moonlight 的全屏窗口可能后映射,周期性抬回最上层
raise();
} }
void TransitionOverlay::onFadeStep() { void TransitionOverlay::onFadeStep() {

View File

@ -0,0 +1,53 @@
#include <QPushButton>
#include <QSignalSpy>
#include <QtTest>
#include "desktop_list_page.h"
using namespace vdi;
class DesktopListPageTest : public QObject {
Q_OBJECT
private slots:
void firstClickDisablesActionsAndBlocksDuplicate();
};
void DesktopListPageTest::firstClickDisablesActionsAndBlocksDuplicate() {
DesktopListPage page;
DesktopInfo desktop;
desktop.id = QStringLiteral("desktop-1");
desktop.name = QStringLiteral("Windows 10");
desktop.os = QStringLiteral("Windows");
desktop.state = DesktopState::kAvailable;
page.setDesktops({desktop});
QPushButton* connectButton = nullptr;
QPushButton* editButton = nullptr;
const auto buttons = page.findChildren<QPushButton*>();
for (QPushButton* button : buttons) {
if (button->text() == QStringLiteral("连接")) {
connectButton = button;
} else if (button->text() == QStringLiteral("设置")) {
editButton = button;
}
}
QVERIFY(connectButton);
QVERIFY(editButton);
QSignalSpy spy(&page, &DesktopListPage::desktopSelected);
connectButton->click();
QCOMPARE(spy.count(), 1);
QVERIFY(!connectButton->isEnabled());
QVERIFY(!editButton->isEnabled());
connectButton->click();
QCOMPARE(spy.count(), 1);
page.setBusy(false);
QVERIFY(connectButton->isEnabled());
QVERIFY(editButton->isEnabled());
}
QTEST_MAIN(DesktopListPageTest)
#include "test_desktop_list_page.moc"

View File

@ -0,0 +1,94 @@
#include <QtTest>
#include "moonlight_session.h"
using namespace vdi;
class MoonlightSessionTest : public QObject {
Q_OBJECT
private slots:
void parsesSplitReadyMarkers();
void requiresBothReadyMarkers();
void rejectsSecondStart();
};
void MoonlightSessionTest::parsesSplitReadyMarkers() {
MoonlightOutputParser parser;
parser.append("Test decode succ");
QVERIFY(!parser.isReady());
parser.append("essful\nRenderer 'SDL' with 'DRM' backend ");
QVERIFY(parser.hasTestDecode());
QVERIFY(!parser.hasRenderer());
parser.append("chosen\n");
QVERIFY(parser.isReady());
}
void MoonlightSessionTest::requiresBothReadyMarkers() {
MoonlightOutputParser parser;
parser.append("Test decode successful\n");
QVERIFY(!parser.isReady());
parser.reset();
parser.append("Renderer 'SDL' chosen\n");
QVERIFY(!parser.isReady());
parser.append("Connection terminated: -1\n");
QVERIFY(parser.hasTerminalError());
QVERIFY(!parser.isReady());
}
void MoonlightSessionTest::rejectsSecondStart() {
QTemporaryDir tempDir;
QVERIFY(tempDir.isValid());
const QString executable = tempDir.filePath(QStringLiteral("moonlight-qt"));
QFile script(executable);
QVERIFY(script.open(QIODevice::WriteOnly | QIODevice::Text));
script.write("#!/bin/sh\n"
"printf 'Test decode successful\\n'\n"
"printf \"Renderer 'SDL' with 'DRM' backend chosen\\n\"\n"
"sleep 5\n");
script.close();
QVERIFY(script.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner |
QFileDevice::ExeOwner | QFileDevice::ReadGroup |
QFileDevice::ExeGroup | QFileDevice::ReadOther |
QFileDevice::ExeOther));
const QByteArray oldPath = qgetenv("PATH");
qputenv("PATH", tempDir.path().toUtf8() + ':' + oldPath);
ConnectionTicket ticket;
ticket.endpoint = QStringLiteral("127.0.0.1");
ticket.app = QStringLiteral("Desktop");
MoonlightSession session;
int started = 0;
int ready = 0;
int terminal = 0;
const auto callback = [&](const MoonlightSession::Event& event) {
if (event.type == MoonlightSession::EventType::kStarted) {
++started;
} else if (event.type == MoonlightSession::EventType::kStreamReady) {
++ready;
} else if (event.type == MoonlightSession::EventType::kFailed ||
event.type == MoonlightSession::EventType::kFinished) {
++terminal;
}
};
QVERIFY(session.start(ticket, callback));
QTRY_COMPARE_WITH_TIMEOUT(started, 1, 2000);
QVERIFY(!session.start(ticket, callback));
QTRY_COMPARE_WITH_TIMEOUT(ready, 1, 3000);
session.stop();
QTRY_COMPARE_WITH_TIMEOUT(terminal, 1, 4000);
QCOMPARE(ready, 1);
QCOMPARE(terminal, 1);
qputenv("PATH", oldPath);
}
QTEST_GUILESS_MAIN(MoonlightSessionTest)
#include "test_moonlight_session.moc"

View File

@ -15,25 +15,27 @@ echo "==> 检查盒子连通性"
ssh -o ConnectTimeout=8 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'echo "box ok: $(hostname)"' ssh -o ConnectTimeout=8 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'echo "box ok: $(hostname)"'
echo "==> 同步源码到盒子" echo "==> 同步源码到盒子"
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'rm -rf ~/vdi-build/repo/kiosk-shell/src && mkdir -p ~/vdi-build/repo/kiosk-shell/src' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'rm -rf ~/vdi-build/repo/kiosk-shell/src ~/vdi-build/repo/kiosk-shell/tests && mkdir -p ~/vdi-build/repo/kiosk-shell/src ~/vdi-build/repo/kiosk-shell/tests'
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/CMakeLists.txt" "$BOX":~/vdi-build/repo/kiosk-shell/ scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/CMakeLists.txt" "$BOX":~/vdi-build/repo/kiosk-shell/
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC"/src/*.cpp "$SRC"/src/*.h "$BOX":~/vdi-build/repo/kiosk-shell/src/ scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC"/src/*.cpp "$SRC"/src/*.h "$BOX":~/vdi-build/repo/kiosk-shell/src/
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC"/tests/*.cpp "$BOX":~/vdi-build/repo/kiosk-shell/tests/
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/scripts/vdi-moonlight" "$BOX":~/vdi-terminal/bin/vdi-moonlight scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/scripts/vdi-moonlight" "$BOX":~/vdi-terminal/bin/vdi-moonlight
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/etc/moonlight.conf" "$BOX":~/.config/vdi-terminal/moonlight.conf scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/etc/moonlight.conf" "$BOX":/tmp/vdi-moonlight.conf
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'mkdir -p ~/.config/vdi-terminal; test -f ~/.config/vdi-terminal/moonlight.conf || cp /tmp/vdi-moonlight.conf ~/.config/vdi-terminal/moonlight.conf; rm -f /tmp/vdi-moonlight.conf'
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/vdi-kiosk.desktop" "$BOX":~/.config/autostart/vdi-kiosk.desktop scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/vdi-kiosk.desktop" "$BOX":~/.config/autostart/vdi-kiosk.desktop
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/vdi-moonlight.desktop" "$BOX":~/.config/autostart/vdi-moonlight.desktop scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/vdi-moonlight.desktop" "$BOX":~/.config/autostart/vdi-moonlight.desktop
echo "==> 在盒子上编译" echo "==> 在盒子上编译"
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'sed -i "s/\r$//" ~/vdi-build/repo/kiosk-shell/src/*.cpp ~/vdi-build/repo/kiosk-shell/src/*.h ~/vdi-terminal/bin/vdi-moonlight; chmod +x ~/vdi-terminal/bin/vdi-moonlight; cd ~/vdi-build/repo/kiosk-shell && (cmake -G Ninja -B build -DCMAKE_BUILD_TYPE=Release > /tmp/cmake.log 2>&1 || (tail -30 /tmp/cmake.log; exit 1)) && cmake --build build 2>&1 | tail -8' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'sed -i "s/\r$//" ~/vdi-build/repo/kiosk-shell/src/*.cpp ~/vdi-build/repo/kiosk-shell/src/*.h ~/vdi-build/repo/kiosk-shell/tests/*.cpp ~/vdi-terminal/bin/vdi-moonlight; chmod +x ~/vdi-terminal/bin/vdi-moonlight; cd ~/vdi-build/repo/kiosk-shell && (cmake -G Ninja -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON > /tmp/cmake.log 2>&1 || (tail -30 /tmp/cmake.log; exit 1)) && cmake --build build && QT_QPA_PLATFORM=offscreen ctest --test-dir build --output-on-failure'
echo "==> 安装二进制" echo "==> 安装二进制"
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'pkill -f "/vdi-kiosk-monitor " 2>/dev/null || true; pkill -f "/bin/sh /home/armbian/vdi-terminal/bin/vdi-moonlight" 2>/dev/null || true; pkill -f "^moonlight-qt stream" 2>/dev/null || true; sleep 2; cp ~/vdi-build/repo/kiosk-shell/build/kiosk-shell ~/vdi-terminal/bin/kiosk-shell && cp ~/vdi-build/repo/kiosk-shell/build/vdi-kiosk-monitor ~/vdi-terminal/bin/vdi-kiosk-monitor && chmod +x ~/vdi-terminal/bin/kiosk-shell ~/vdi-terminal/bin/vdi-kiosk-monitor && ls -la ~/vdi-terminal/bin/' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'monitor_pids=$(pgrep -f "^/home/armbian/vdi-terminal/bin/vdi-kiosk-monitor -- " 2>/dev/null || true); test -z "$monitor_pids" || kill $monitor_pids 2>/dev/null || true; pair_pids=$(pgrep -f "^bash -c tail -f .*(pin|pair)_fifo" 2>/dev/null || true); test -z "$pair_pids" || kill $pair_pids 2>/dev/null || true; tail_pids=$(pgrep -f "^tail -f .*(pin|pair)_fifo" 2>/dev/null || true); test -z "$tail_pids" || kill $tail_pids 2>/dev/null || true; rm -f ~/pair_fifo /tmp/pin_fifo; for name in vdi-moonlight moonlight-qt kiosk-shell; do pids=$(pidof "$name" 2>/dev/null || true); test -z "$pids" || kill $pids 2>/dev/null || true; done; for i in $(seq 1 40); do pgrep -f "^/home/armbian/vdi-terminal/bin/vdi-kiosk-monitor -- " >/dev/null 2>&1 || break; sleep 0.2; done; monitor_pids=$(pgrep -f "^/home/armbian/vdi-terminal/bin/vdi-kiosk-monitor -- " 2>/dev/null || true); test -z "$monitor_pids" || kill -9 $monitor_pids 2>/dev/null || true; cp ~/vdi-build/repo/kiosk-shell/build/kiosk-shell ~/vdi-terminal/bin/kiosk-shell && cp ~/vdi-build/repo/kiosk-shell/build/vdi-kiosk-monitor ~/vdi-terminal/bin/vdi-kiosk-monitor && chmod +x ~/vdi-terminal/bin/kiosk-shell ~/vdi-terminal/bin/vdi-kiosk-monitor && ls -la ~/vdi-terminal/bin/'
echo "==> 收敛自启项(只保留 kiosk-shell 一条链)" echo "==> 收敛自启项(只保留 kiosk-shell 一条链)"
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'mkdir -p ~/.config/autostart; rm -f ~/.config/autostart/vdi-kiosk.desktop.bak; sed -i "s/^Hidden=.*/Hidden=true/;s/^X-GNOME-Autostart-enabled=.*/X-GNOME-Autostart-enabled=false/" ~/.config/autostart/vdi-moonlight.desktop 2>/dev/null || true; grep -H . ~/.config/autostart/*.desktop' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'mkdir -p ~/.config/autostart; rm -f ~/.config/autostart/vdi-kiosk.desktop.bak; sed -i "s/^Hidden=.*/Hidden=true/;s/^X-GNOME-Autostart-enabled=.*/X-GNOME-Autostart-enabled=false/" ~/.config/autostart/vdi-moonlight.desktop 2>/dev/null || true; grep -H . ~/.config/autostart/*.desktop'
echo "==> 启动受管栈并验证" echo "==> 启动受管栈并验证"
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'rm -f /tmp/vdi-kiosk.heartbeat; nohup /home/armbian/vdi-terminal/bin/vdi-kiosk-monitor -- /home/armbian/vdi-terminal/bin/kiosk-shell > /tmp/vdi-monitor.log 2>&1 & sleep 8; echo "--- 进程 ---"; pgrep -af "vdi-kiosk-monitor|kiosk-shell|moonlight-qt" | grep -v pgrep; echo "--- 看门狗日志 ---"; tail -5 /tmp/vdi-monitor.log; echo "--- 心跳 ---"; cat /tmp/vdi-kiosk.heartbeat 2>/dev/null; echo' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'rm -f /tmp/vdi-kiosk.heartbeat; nohup env DISPLAY=:0 XAUTHORITY=/home/armbian/.Xauthority /home/armbian/vdi-terminal/bin/vdi-kiosk-monitor -- /home/armbian/vdi-terminal/bin/kiosk-shell > /tmp/vdi-monitor.log 2>&1 & sleep 8; echo "--- 进程 ---"; pgrep -af "vdi-kiosk-monitor|kiosk-shell|moonlight-qt" | grep -v pgrep; echo "--- 看门狗日志 ---"; tail -5 /tmp/vdi-monitor.log; echo "--- 心跳 ---"; cat /tmp/vdi-kiosk.heartbeat 2>/dev/null; echo'
echo "==> 抓屏确认当前界面(应为登录页)" echo "==> 抓屏确认当前界面(应为桌面列表)"
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'DISPLAY=:0 xwd -root -silent' 2>/dev/null | python "$REPO/tools/xwd2png.py" /tmp/kiosk-login.png && echo "captured /tmp/kiosk-login.png" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'DISPLAY=:0 xwd -root -silent' 2>/dev/null | python "$REPO/tools/xwd2png.py" /tmp/kiosk-login.png && echo "captured /tmp/kiosk-login.png"

249
tools/gpu_dashboard.py Normal file
View File

@ -0,0 +1,249 @@
#!/usr/bin/env python3
"""服务器 GPU 状态看板(部署在 PVE 宿主机上,仅依赖 python3 标准库)。
数据源:
- lspci / /sys / lsmod:GPU 型号、当前驱动(nouveau/vfio-pci/nvidia)、IOMMU 状态
- qm config/status:VM 100 的直通配置(hostpci0)与运行状态
- 直通生效后:qm guest exec 在 VM 100 内执行 nvidia-smi 采集实时数据
(利用率 / 显存 / 温度 / 驱动版本 / NVENC 编码会话)
用法:python3 gpu_dashboard.py [端口] # 默认 8090,浏览器访问 http://<宿主机IP>:8090/
"""
import json
import subprocess
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
VMID = 100
CACHE_TTL = 2.0
GUEST_TIMEOUT = 25
_lock = threading.Lock()
_cache = {"t": 0.0, "data": None}
def run(cmd, timeout=15):
try:
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
return (r.stdout or "").strip()
except Exception as e:
return "ERROR: %s" % e
def read(path):
try:
with open(path) as f:
return f.read().strip()
except Exception:
return ""
def gpu_identity():
out = run("lspci -nnk -s 01:00.0")
name = ""
driver = ""
for line in out.splitlines():
if "NVIDIA" in line and not name:
idx = line.find("NVIDIA")
name = line[idx:].strip()
if "Kernel driver in use" in line:
driver = line.split(":", 1)[-1].strip()
audio = ""
for line in run("lspci -nnk -s 01:00.1").splitlines():
if "Kernel driver in use" in line:
audio = line.split(":", 1)[-1].strip()
return {
"name": name or "NVIDIA GPU (0000:01:00.0)",
"driver": driver or "(无,未绑定)",
"audio_driver": audio or "(无)",
}
def iommu_state():
# 新内核(6.8+)默认启用 Intel DMAR,无需显式 intel_iommu=on;
# 以 iommu_groups 是否存在且 GPU 已入组为准,内核参数仅作展示。
cmdline = read("/proc/cmdline")
flag = "intel_iommu=on" in cmdline or "iommu=on" in cmdline
groups = run("find /sys/kernel/iommu_groups -maxdepth 1 -mindepth 1 -type d 2>/dev/null | wc -l")
n = int(groups) if groups.isdigit() else 0
gpu_group = run("for d in /sys/kernel/iommu_groups/*/devices/0000:01:00.0; do "
"[ -e \"$d\" ] && dirname \"$d\" | sed 's|.*iommu_groups/||;s|/devices||'; done")
members = run("ls /sys/kernel/iommu_groups/%s/devices/ 2>/dev/null" % gpu_group) if gpu_group else ""
if n == 0:
state, ok = "未启用(iommu 组数为 0,需 BIOS 开 VT-d + 内核参数 intel_iommu=on)", False
elif not gpu_group:
state, ok = "已启用,但 GPU 不在任何组内(异常)", False
else:
state, ok = "已启用(DMAR 生效)", True
return {"state": state, "ok": ok, "kernel_flag": flag, "groups": n,
"gpu_group": gpu_group, "group_members": members}
def passthrough_state():
cfg = run("qm config %d 2>/dev/null" % VMID)
hostpci = ""
for line in cfg.splitlines():
if line.startswith("hostpci"):
hostpci = line
break
status = run("qm status %d 2>/dev/null" % VMID).replace("status: ", "").replace("status:", "").strip()
return {"hostpci": hostpci or "(未配置)", "vm_status": status or "unknown"}
def guest_gpu():
"""直通后在 VM 内经 guest agent 执行 nvidia-smi;未直通/无驱动时返回 None。"""
q = ("nvidia-smi --query-gpu=name,utilization.gpu,memory.used,memory.total,"
"temperature.gpu,driver_version,power.draw --format=csv,noheader")
out = run("qm guest exec %d -- powershell -NoProfile -Command \"%s\"" % (VMID, q),
timeout=GUEST_TIMEOUT)
try:
d = json.loads(out)
except Exception:
return None
if d.get("returncode") != 0:
return None
text = (d.get("out-data") or "").strip()
if not text or "nvidia-smi" in (d.get("err-data") or ""):
return None
parts = [p.strip() for p in text.split(",")]
if len(parts) < 6:
return None
info = {
"name": parts[0],
"utilization": parts[1],
"vram_used": parts[2],
"vram_total": parts[3],
"temperature": parts[4],
"driver": parts[5],
"power": parts[6] if len(parts) > 6 else "",
}
enc = run("qm guest exec %d -- powershell -NoProfile -Command \""
"nvidia-smi --query-gpu=encoder.stats.sessionCount --format=csv,noheader\"" % VMID,
timeout=GUEST_TIMEOUT)
try:
d2 = json.loads(enc)
v = (d2.get("out-data") or "").strip()
if d2.get("returncode") == 0 and v:
info["enc_sessions"] = v
except Exception:
pass
return info
def collect():
ident = gpu_identity()
driver = ident["driver"]
if "vfio-pci" in driver:
stage = "vfio 已接管(可直通/已解绑 nouveau)"
elif "nouveau" in driver:
stage = "未直通(GPU 由宿主 nouveau 占用,直通前需解绑)"
elif "nvidia" in driver:
stage = "未直通(GPU 由宿主 nvidia 驱动占用)"
else:
stage = "GPU 未绑定驱动"
data = {
"time": time.strftime("%Y-%m-%d %H:%M:%S"),
"gpu": ident,
"stage": stage,
"iommu": iommu_state(),
"pt": passthrough_state(),
"guest": guest_gpu() if "hostpci" in passthrough_state()["hostpci"] else None,
}
return data
def get_data():
with _lock:
now = time.time()
if _cache["data"] is not None and now - _cache["t"] < CACHE_TTL:
return _cache["data"]
data = collect()
_cache["data"] = data
_cache["t"] = now
return data
HTML = """<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="utf-8">
<title>服务器 GPU 状态</title>
<style>
body { background:#0f1420; color:#e6e9ef; font-family:"Microsoft YaHei",sans-serif;
margin:0; padding:32px; }
h1 { font-size:22px; margin:0 0 4px; }
.sub { color:#8a94a6; font-size:12px; margin-bottom:24px; }
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(280px,1fr)); gap:14px; max-width:1100px; }
.card { background:#1a2233; border:1px solid #2b3654; border-radius:10px; padding:16px 18px; }
.card h3 { margin:0 0 10px; font-size:13px; color:#8a94a6; font-weight:normal; }
.card .v { font-size:15px; line-height:1.7; word-break:break-all; }
.ok { color:#3fb950; } .bad { color:#e05561; } .warn { color:#d29922; }
.big { font-size:30px; font-weight:bold; }
</style></head><body>
<h1>服务器 GPU 状态</h1>
<div class="sub" id="time">加载中…(每 4 秒自动刷新)</div>
<div class="grid">
<div class="card"><h3>显卡</h3><div class="v" id="gpu">-</div></div>
<div class="card"><h3>直通阶段</h3><div class="v" id="stage">-</div></div>
<div class="card"><h3>IOMMU / VT-d</h3><div class="v" id="iommu">-</div></div>
<div class="card"><h3>VM %d 直通配置</h3><div class="v" id="pt">-</div></div>
<div class="card"><h3>VM 内实时数据(nvidia-smi)</h3><div class="v" id="guest">直通生效后显示</div></div>
</div>
<script>
const $ = id => document.getElementById(id);
function esc(s){ return String(s).replace(/</g,"&lt;"); }
function render(d){
$("time").textContent = "更新于 " + d.time + "(每 4 秒自动刷新)";
$("gpu").innerHTML = "<b>" + esc(d.gpu.name) + "</b><br>当前驱动:" + esc(d.gpu.driver) +
"<br>HDMI 音频驱动:" + esc(d.gpu.audio_driver);
const stColor = d.stage.includes("未直通") ? "warn" : "ok";
$("stage").innerHTML = "<span class='" + stColor + "'>" + esc(d.stage) + "</span>";
$("iommu").innerHTML = (d.iommu.ok ? "<span class='ok'>" : "<span class='bad'>") +
esc(d.iommu.state) + "</span><br>内核参数 intel_iommu=on:" +
(d.iommu.kernel_flag ? "已设置" : "未设置(新内核默认开启,非必须)") +
(d.iommu.gpu_group ? "<br>GPU 所在组:" + esc(d.iommu.gpu_group) +
(d.iommu.group_members ? "<br>组内设备:" + esc(d.iommu.group_members) : "") : "");
$("pt").innerHTML = "运行状态:" + esc(d.pt.vm_status) + "<br>hostpci:" + esc(d.pt.hostpci);
if (d.guest) {
$("guest").innerHTML =
"<span class='big'>" + esc(d.guest.utilization) + "</span> 利用率<br>" +
"显存:" + esc(d.guest.vram_used) + " / " + esc(d.guest.vram_total) + "<br>" +
"温度:" + esc(d.guest.temperature) +
(d.guest.power ? " 功耗:" + esc(d.guest.power) : "") + "<br>" +
"驱动:" + esc(d.guest.driver) +
(d.guest.enc_sessions !== undefined ? "<br>NVENC 编码会话:" + esc(d.guest.enc_sessions) : "");
} else {
$("guest").innerHTML = "<span class='warn'>直通生效并在 VM 内装好驱动后,这里显示实时利用率/显存/温度/NVENC 会话</span>";
}
}
function tick(){ fetch("/api").then(r=>r.json()).then(render).catch(()=>{}); }
tick(); setInterval(tick, 4000);
</script></body></html>""" % VMID
class Handler(BaseHTTPRequestHandler):
def _send(self, code, body, ctype):
raw = body.encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", ctype + "; charset=utf-8")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
def do_GET(self):
if self.path.startswith("/api"):
self._send(200, json.dumps(get_data(), ensure_ascii=False), "application/json")
elif self.path.startswith("/healthz"):
self._send(200, "ok", "text/plain")
else:
self._send(200, HTML, "text/html")
def log_message(self, *args):
pass
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8090
srv = ThreadingHTTPServer(("0.0.0.0", port), Handler)
print("GPU dashboard on http://0.0.0.0:%d/ (VM %d)" % (port, VMID), flush=True)
srv.serve_forever()

View File

@ -0,0 +1,29 @@
#!/bin/sh
set -u
LOG_DIR="$HOME/vdi-terminal/logs"
LOG_FILE="$LOG_DIR/overnight-$(date +%Y%m%d).log"
mkdir -p "$LOG_DIR"
log() { printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S %z')" "$*" >> "$LOG_FILE"; }
log "monitor_start pid=$$ interval=60s"
while :; do
now=$(date '+%Y-%m-%d %H:%M:%S %z')
shell_pid=$(pgrep -f '^/home/armbian/vdi-terminal/bin/kiosk-shell$' | head -n 1 || true)
monitor_pid=$(pgrep -f '^/home/armbian/vdi-terminal/bin/vdi-kiosk-monitor' | head -n 1 || true)
if [ -n "$shell_pid" ]; then
ps_line=$(ps -o pcpu=,pmem=,rss=,etime= -p "$shell_pid" | tr '\n' ' ')
else
ps_line="missing"
fi
temp=$(awk '{printf "%.1f", $1/1000}' /sys/class/thermal/thermal_zone0/temp 2>/dev/null || printf "unknown")
mem=$(awk '/MemTotal:|MemAvailable:|SwapFree:/{printf "%s=%sKB ",$1,$2}' /proc/meminfo 2>/dev/null || true)
heartbeat="missing"
if [ -f /tmp/vdi-kiosk.heartbeat ]; then
heartbeat=$(stat -c '%Y' /tmp/vdi-kiosk.heartbeat 2>/dev/null || printf 'unknown')
fi
rdp=$(ss -tnp 2>/dev/null | grep '192.168.1.70:3389' | tr '\n' ';' || true)
starts=$(grep -c '主进程已启动' /tmp/vdi-kiosk-monitor.log 2>/dev/null || printf 0)
exits=$(grep -c '主进程已退出' /tmp/vdi-kiosk-monitor.log 2>/dev/null || printf 0)
crashes=$(grep -ciE 'segmentation fault|bus error|double free|invalid pointer|aborted|terminate called' /tmp/vdi-kiosk-monitor.log 2>/dev/null || printf 0)
log "sample now=$now shell_pid=${shell_pid:-missing} monitor_pid=${monitor_pid:-missing} ps=[$ps_line] temp_c=$temp heartbeat_epoch=$heartbeat rdp=[$rdp] starts=$starts exits=$exits crashes=$crashes $mem"
sleep 60
done

89
tools/pve_term_exec.py Normal file
View File

@ -0,0 +1,89 @@
"""Run a command on the PVE node via the same VNC term channel the web console uses.
Usage: python3 pve_term_exec.py "<command>"
Reads ticket from /tmp/pve_t.txt (refreshed automatically if expired).
Prints the command output captured from the terminal stream.
"""
import base64
import json
import ssl
import struct
import sys
import time
import urllib.parse
import urllib.request
HOST = "https://192.168.1.51:8006"
NODE = "starhe"
CTX = ssl.create_default_context()
CTX.check_hostname = False
CTX.verify_mode = ssl.CERT_NONE
def http(path, data=None, ticket=None, csrf=None):
body = urllib.parse.urlencode(data).encode() if data is not None else None
req = urllib.request.Request(HOST + path, body)
if ticket:
req.add_header("Cookie", "PVEAuthCookie=" + ticket)
if csrf:
req.add_header("CSRFPreventionToken", csrf)
with urllib.request.urlopen(req, context=CTX, timeout=30) as r:
raw = r.read()
return json.loads(raw)["data"] if raw else None
def login():
d = http("/api2/json/access/ticket",
{"username": "root@pam", "password": "yx66668888"})
return d["ticket"], d["CSRFPreventionToken"]
def run_cmd(command, timeout=60):
ticket, csrf = login()
# 1. open a term proxy (VNC channel to node shell)
r = http("/api2/json/nodes/%s/termproxy" % NODE, {}, ticket, csrf)
port, ticket_vnc = r["port"], r["ticket"]
user, realm_sep, _ = "root", "@", "pam"
import socket
sock = socket.create_connection(("192.168.1.51", port), timeout=10)
# PVE handshake: username@realm\n ticket\n binary handshake
sock.sendall(b"root@pam\n" + ticket_vnc.encode() + b"\n")
gh = sock.recv(16)
maj, mino = struct.unpack("BB", gh[0:2])
said = sock.recv(16 * 4)
# client sends its own handshake
ch = struct.pack("BB", maj, mino) + b"PVEVNC Client 00" + b"\x00" * 0
sock.sendall(ch)
# 2. run the command in the shell by typing it and reading output
marker = "PVEDONE%s" % int(time.time())
payload = command + " ; echo " + marker + "\n"
time.sleep(0.5)
sock.sendall(payload.encode())
buf = b""
deadline = time.time() + timeout
while time.time() < deadline:
try:
sock.settimeout(2)
chunk = sock.recv(65536)
if not chunk:
break
buf += chunk
if marker.encode() in buf.replace(b"\x1b", b"").replace(b"[?2004l", b""):
break
except socket.timeout:
if marker.encode() in buf:
break
continue
sock.close()
# strip terminal control chars roughly and the echoed command
text = buf.decode("utf-8", "replace")
text = text.replace(marker, "")
lines = [l for l in text.split("\r\n")]
return "\n".join(lines)
if __name__ == "__main__":
print(run_cmd(sys.argv[1]))

55
tools/pve_vm.py Normal file
View File

@ -0,0 +1,55 @@
import json
import ssl
import sys
import urllib.parse
import urllib.request
HOST = "https://192.168.1.51:8006"
CTX = ssl.create_default_context()
CTX.check_hostname = False
CTX.verify_mode = ssl.CERT_NONE
def api(path, data=None, ticket=None, csrf=None, json_body=None):
body = None
headers = {}
if json_body is not None:
body = json.dumps(json_body).encode()
headers["Content-Type"] = "application/json"
elif data is not None:
body = urllib.parse.urlencode(data).encode()
req = urllib.request.Request(HOST + path, body, headers)
if ticket:
req.add_header("Cookie", "PVEAuthCookie=" + ticket)
if csrf:
req.add_header("CSRFPreventionToken", csrf)
with urllib.request.urlopen(req, context=CTX, timeout=40) as r:
raw = r.read()
return json.loads(raw)["data"] if raw else None
def agent(ticket, csrf, command, timeout=90):
r = api("/api2/json/nodes/starhe/qemu/100/agent/exec",
json_body={"command": command}, ticket=ticket, csrf=csrf)
pid = r["pid"]
for _ in range(timeout // 2):
import time
time.sleep(2)
st = api("/api2/json/nodes/starhe/qemu/100/agent/exec-status?pid=%d" % pid,
ticket=ticket)
if st.get("exited"):
return st.get("out-data") or "", st.get("err-data") or ""
return "TIMEOUT", ""
if __name__ == "__main__":
d = api("/api2/json/access/ticket",
{"username": "root@pam", "password": "yx66668888"})
ticket, csrf = d["ticket"], d["CSRFPreventionToken"]
ps = sys.argv[1]
out, err = agent(ticket, csrf, [
"C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe",
"-NoProfile", "-Command", ps])
print(out)
if err:
print("ERR:", err[:300])

84
tools/xwd2png.py Normal file
View File

@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Convert an XWD dump (from xwd) into a PNG, without external image libraries.
Usage: xwd -root -silent | python3 xwd2png.py out.png
"""
import struct
import sys
import zlib
def read_header(data):
fields = struct.unpack(">25I", data[:100])
names = [
"header_size", "file_version", "pixmap_format", "pixmap_depth",
"pixmap_width", "pixmap_height", "xoffset", "byte_order",
"bitmap_unit", "bitmap_bit_order", "bitmap_pad", "bits_per_pixel",
"bytes_per_line", "visual_class", "red_mask", "green_mask",
"blue_mask", "bits_per_rgb", "colormap_entries", "ncolors",
"window_width", "window_height", "window_x", "window_y",
"window_bdrwidth",
]
return dict(zip(names, fields))
def write_png(path, width, height, rgb_rows):
raw = b"".join(b"\x00" + row for row in rgb_rows)
def chunk(tag, payload):
return (struct.pack(">I", len(payload)) + tag + payload +
struct.pack(">I", zlib.crc32(tag + payload) & 0xFFFFFFFF))
header = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
png = (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) +
chunk(b"IDAT", zlib.compress(raw, 6)) + chunk(b"IEND", b""))
with open(path, "wb") as handle:
handle.write(png)
def main():
out_path = sys.argv[1] if len(sys.argv) > 1 else "out.png"
data = sys.stdin.buffer.read()
head = read_header(data)
width = head["pixmap_width"]
height = head["pixmap_height"]
bpp = head["bits_per_pixel"]
stride = head["bytes_per_line"]
offset = head["header_size"] + head["ncolors"] * 12
pixels = data[offset:]
if bpp == 32:
rows = []
for y in range(height):
line = pixels[y * stride:y * stride + width * 4]
out = bytearray(width * 3)
for x in range(width):
src = x * 4
dst = x * 3
out[dst] = line[src + 2]
out[dst + 1] = line[src + 1]
out[dst + 2] = line[src]
rows.append(bytes(out))
elif bpp == 24:
rows = []
for y in range(height):
line = pixels[y * stride:y * stride + width * 3]
out = bytearray(width * 3)
for x in range(width):
src = x * 3
dst = x * 3
out[dst] = line[src + 2]
out[dst + 1] = line[src + 1]
out[dst + 2] = line[src]
rows.append(bytes(out))
else:
sys.stderr.write("unsupported bits_per_pixel=%s\n" % bpp)
return 2
write_png(out_path, width, height, rows)
print("%s %dx%d" % (out_path, width, height))
return 0
if __name__ == "__main__":
sys.exit(main())