Compare commits

..

No commits in common. "8d24a5b2f2327b54efc5dfdcf87ee4ac5fdae4bf" and "ca156722565d4e691a0c7450cb12173395f36d35" have entirely different histories.

54 changed files with 938 additions and 4385 deletions

View File

@ -6,74 +6,33 @@
- 项目目录:`D:\DAIMA\vdi-cloud`
- 终端盒子:RK3528 PRO DDR4,Armbian Ubuntu Noble,aarch64,IP `192.168.1.188`
- 当前图形:LightDM + Xorg;正式文档要求 Weston + Wayland + Qt,当前只是 POC
- Proxmox:9.2.2 节点 `starhe`。VM 100 win10-test `192.168.1.70`(Sunshine/Moonlight 云桌面);
VM 101 kylin-test `192.168.1.71`(test1)、VM 102 ubuntu-test `192.168.1.72`(test2),
密码均 `yx66668888`,SSH 可登、图形会话在跑,**尚未装 Sunshine**,暂不能串流
- `kiosk-shell`(Qt/C++):**开机直进桌面列表**(2026-10-30 起,设备 SN 即用户自动登录,
登录页代码保留待平台正式认证接入)→ 选择桌面 → Moonlight 会话;**RDP 代码已于 2026-10-29
全部删除**(git 历史可查),Moonlight 是唯一会话通道,无 freerdp 构建依赖
- 新增退出三层与会话过场层:`Ctrl+Alt+Shift+Q` 退串流回列表;列表页"退出登录";
登录页无退出按钮(退出终端只在管理菜单);点连接有全屏过场层,首帧后淡出。
详见 `docs/moonlight-kiosk-20261028.md` 2026-10-29 下午节
- Proxmox:9.2.2,Windows 10 22H2 VM `192.168.1.70:3389`,用户 `test1`
- FreeRDP 3.32.1 已安装,命令 `xfreerdp3`
- `kiosk-shell` 已按 Qt/C++ + libfreerdp3 内嵌实现,已真实显示 Windows 10 远程桌面
- 代码位置:`terminal/kiosk-shell`
- 远程安装路径:`~/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 会卡死配对
- 远程安装路径:`~/vdi-terminal/bin/kiosk-shell`
## 必须遵守
- 所有实现严格参考用户提供的两个正式 Word 和会议纪要;文档未确定的技术只能标记待调研,不得擅自定版。
- 终端先做,后接自研管理平台 API;终端不直接调用 Proxmox API。
- 正式状态机:`BOOT -> CONFIG -> CONNECTING -> SESSION -> RECONNECT -> ERROR`
- 正式 Kiosk 选型:Weston + Wayland + Qt;会话通道统一 Moonlight(RDP 已删,勿在代码里复活协议分支)。
- 正式 Kiosk 选型:Weston + Wayland + Qt;FreeRDP 以 libfreerdp3 API 内嵌,不拉外部进程。
- `/cert:ignore`、空密码仅限局域网 POC。
- 项目经理新增待调研:RDP 是否适合 GPU/CAD/3D;研究 Moonlight/Sunshine,但不要在调研前删除 RDP、确定 Moonlight 参数或定版双协议。
## 已知关键结论
- WiFi:约 -47 dBm,协商约 200 Mbps,零丢包/重传;卷帘问题不能简单归因于 WiFi。
- HDMI 当前原生:`1920x1440@75Hz`(10-23 记录);2026-10-28 实测盒子系统已是 `1920x1080@60Hz`,
与 Sunshine 侧虚拟显示器一致,串流 1:1 无缩放。以现场 `xrandr` 为准。
- HDMI 当前原生:`1920x1440@75Hz`,不要请求不存在的 2560x1440 模式。
- `/dev/mpp_service`、`/dev/rga` 存在,但 `mppvideodec` GStreamer 插件未确认;Weston/DRM 零拷贝待验证。
- FreeRDP 3:`gdi_init` 必须在 PostConnect;NLA 空密码传 `strdup("")`;BGRX32 需转换为 Qt RGB;`/gfx` 是能力组合语义。
- Moonlight/Sunshine(2026-10-28 实测):Windows 防火墙挡 ICMP,ping 不通不代表不通,
可达性判断必须用 TCP 探测;`moonlight-qt` 的 app id 会随服务端重装失效,
命令行要用应用名 `Desktop`;RK3528 上 `--video-decoder auto` 会选到不可用的
`h264_v4l2m2m`,必须显式 `software`。
- **21.6 FPS 上限根因(2026-10-29 修正)**:不是 x264 编码算力——ultrafast 前后帧率分毫不差,
串流中 VM CPU 仅 0.5%、sunshine 累计 14 秒 CPU/3 分钟会话,鼠标运动也不变;
真正瓶颈在虚拟显示/捕获节奏(MttVDD 呈现 + Desktop Duplication 脏帧 + QEMU 显示栈,
VM CPU 型号 `QEMU Virtual CPU version 2.5+` 未透传宿主 AVX2)。
根治 = GPU 直通(NVENC + 真实显示栈);备选 = VirtIO-GPU / cpu=host 排除 QEMU 显示栈因素。
客户端解码 10~15 ms、网络丢包 0%、延迟 3 ms,链路健康。
## Web 控制台(2026-10-28 方案确认)
- `console/` 是管理员 Web 管理控制台,不是 RK3528 盒子的用户登录页。
- 正式技术栈:Vue3 + Vite + TypeScript 严格模式 + Element Plus;后端通过 api-gateway,JWT + RBAC。
- 页面:仪表盘、终端管理、桌面管理、用户与绑定、GPU 池、监控、OTA、审计日志。
- 角色:超级管理员、运维、只读。
- 终端产品流程仍应是 `kiosk-shell 登录/首配 -> 桌面列表 -> 获取短期连接票据 -> RDP/Moonlight 会话`;
当前开机直连 Moonlight 仅是串流技术验证,不是最终产品登录流程。
- 开发策略:先做功能骨架、Mock API、OpenAPI 契约、RBAC 与 E2E;项目经理 UI 稿到后替换视觉层。
- 完整方案:`docs/web-console-mvp-plan-20261028.md`。
## 尚未完成
- 服务端 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 驱动)
- 2K 分辨率链路验证(虚拟显示器 + 盒子显示模式需同步调整)
- Monitor 编译/崩溃/假死重启测试
- 开机自启
- 网络状态显示修复确认
- Weston/DRM/mppvideodec
- 后端 API 注册、配置、MQTT 心跳(Moonlight 路径目前是本地静态配置)
- 终端音频出音现场确认
- 协议最终选型定版(RDP / Moonlight / 双协议,需实测数据 + 产品方案共同确认)
- 监控与运维脚本、OTA、USB 重定向等终端其余能力
- 后端 API 注册、配置、MQTT 心跳
- P100 GPU 直通及 CUDA/CAD/3D
- Moonlight/Sunshine 调研
先读 `PROJECT_CONTEXT.md`,再查看 `git status`,不要丢弃未提交改动。

View File

@ -276,99 +276,63 @@ D:\DAIMA\vdi-cloud\docs\freerdp3-embedding-notes.md
注意:此前对“X11 无 glamor 硬件合成”的判断不准确,Xorg 日志实际显示 `glamor X acceleration enabled on Mali450`;后续不要继续引用这个错误结论。正确表述是:当前路径仍未达到目标流畅度,具体瓶颈需要后续基准测试确认。
### 6.6 Moonlight 云桌面开机直达(2026-10-28)
RDP/Moonlight 实测对比见 `docs/rdp-vs-moonlight-comparison-20261028.md`,
落地记录见 `docs/moonlight-kiosk-20261028.md`。
盒子当前开机默认走 Moonlight 直连云桌面,链路:
```text
~/.config/autostart/vdi-moonlight.desktop
-> vdi-kiosk-monitor --heartbeat-file /tmp/vdi-moonlight.heartbeat --max-restart 200
-- /home/armbian/vdi-terminal/bin/vdi-moonlight
-> moonlight-qt stream 192.168.1.70 Desktop (fullscreen, 1920x1080, H.264, 软解)
```
仓库新增:`terminal/kiosk-shell/scripts/vdi-moonlight`、`etc/moonlight.conf`、
`vdi-moonlight.desktop`;`src/monitor.cpp` 改为按进程组结束整棵进程树。
实测:满屏云桌面 1:1 无缩放;杀客户端 5 秒内重连;杀启动器 3 秒内被看门狗拉起;
性能叠图 1080p60 实际 21.59 FPS、网络丢包 0%、延迟 3 ms、解码 15.04 ms、抖动丢帧 13.64%。
关键坑位(后续维护必须知道):
1. Windows 防火墙屏蔽 ICMP,盒子 ping 不到云桌面主机,但 TCP/UDP 正常;
可达性判断必须用 TCP 探测,ping 会误判;
2. `moonlight-qt` 的应用 id 随服务端重装/升级失效,旧 id 会报“找不到应用程序 <id>”;
命令行要用应用名 `Desktop`;
3. RK3528 上 `--video-decoder auto` 会选到不可用的 `h264_v4l2m2m`,
必须显式 `--video-decoder software`;
4. RDP 路径的自启项已禁用但保留,可随时切回。
## 7. 当前未完成/未验证
1. 服务端 GPU 直通未做:宿主机 `starhe` 有 GTX 965M(`0000:01:00.0`,IOMMU Group 2 独占),
主机同时有 Intel 核显 `0000:00:02.0` 可继续驱动本机显示,具备直通条件;
直通后 Sunshine 才能用 NVENC 硬编码,把 1080p 帧率从 ~21.6 FPS 提到 60 FPS;
2. RK3528 VPU 硬解不可用:`h264_v4l2m2m` / `hevc_v4l2m2m` / `hevc_v4l2request` 均报
`Could not find a valid device`,当前只能用软件解码,4K 目标必须先解决;
3. 2K 分辨率链路未验证:盒子屏幕现为 1920x1080@60Hz,Sunshine 侧虚拟显示器也是 1920x1080@60Hz;
4. 当前实现仍使用 X11 POC,正式方案要求 Weston/Wayland;
5. 当前 RDP 会话使用 CPU GDI -> Qt 复制,尚未接 GStreamer/mppvideodec/VPU;
6. `mppvideodec` 来源和可用性未确认;
7. 终端注册、MQTT 心跳、配置下发、日志上报、OTA、USB 尚未接后端 API;
8. 前端/后端管理平台尚未接入;Moonlight 路径目前读本地静态配置文件;
9. 终端音频出音未现场确认(`--audio-on-host` 已开);
10. 协议最终选型未定版:实测已证明 Moonlight 画面质量与 CPU 代价优于 RDP,
但“办公用 RDP、GPU/CAD/3D 用 Moonlight”的双协议结论仍需产品方案确认。
1. 当前工作区最后一次编译 Monitor 的命令被取消,Monitor 代码尚未确认编译通过;
2. Monitor 的主进程心跳机制尚未做真实“崩溃/假死/自动重启”测试;
3. kiosk-shell 开机自启尚未配置;
4. 网络状态显示修复(此前显示错误的 `600`)尚未确认最新二进制效果;
5. 当前实现仍使用 X11 POC,正式方案要求 Weston/Wayland;
6. 当前 RDP 会话使用 CPU GDI -> Qt 复制,尚未接 GStreamer/mppvideodec/VPU;
7. `mppvideodec` 来源和可用性未确认;
8. 终端注册、MQTT 心跳、配置下发、日志上报、OTA、USB 尚未接后端 API;
9. 前端/后端管理平台尚未接入;
10. GPU/P100 直通与 CUDA/CAD/3D 性能尚未完成;
11. Moonlight/Sunshine 方向只是项目经理新增的周一调研项,未安装、未验证、未纳入代码定版。
## 8. 下一步建议(按优先级)
### A. 服务端 GPU 直通(当前最高价值项)
### A. 收尾当前终端 POC
Sunshine 在 VM 内用 libx264 软件编码,4 核同时承担 1080p 捕获与编码,
是 21.6 FPS 与 13.64% 抖动丢帧的唯一原因;客户端侧链路完全健康。
1. 编译并验证 `vdi-kiosk-monitor`;
2. 给 kiosk-shell 与 Monitor 做真实崩溃/假死重启测试;
3. 配置当前开发盒子的用户级开机自启,开机直达登录页;
4. 修复并验证网络状态显示;
5. 保留当前能进入 Windows 10 的版本作为可回退基线。
### B. 按项目经理新增方向做 GPU 协议调研
周一先验证,不先改代码定版:
```text
宿主机 starhe(Xeon E3-1200 v3 + Intel 核显 + GTX 965M)
-> 确认内核 IOMMU 已启用(PCI 设备已有独立 IOMMU group)
-> vfio-pci 绑定 0000:01:00.0,提取 vBIOS ROM
-> VM 100 增加 hostpci,Windows 内装 GeForce 驱动
-> nvidia-smi 正常后 Sunshine 切 NVENC 硬编码
-> 复测 1080p60 帧率、抖动丢帧、服务端 CPU
P100 是否成功 PCIe 直通到 Windows 10 VM
-> VM 内 nvidia-smi 是否正常
-> CUDA/3D 应用能否使用 GPU
-> Sunshine 是否能在 VM 内捕获/硬编码桌面
-> RK3528 上是否有可用 Moonlight ARM64 客户端
-> Moonlight 是否能调用 VPU 硬解
-> RDP 与 Moonlight 的 CAD/3D/视频延迟、帧率、CPU、温度、带宽对比
```
主机 root 密码已知(`yx66668888`),可用 `SSH_ASKPASS` 非交互登录。
### B. 收尾当前终端 POC
1. 保留当前 Moonlight 开机直达版本作为可回退基线(`docs/moonlight-kiosk-20261028.md`);
2. 确认开机自启在真实重启后生效(当前只验证了手动拉起与被守护重启);
3. 网络状态显示修复(此前显示错误的 `600`)尚未确认最新二进制效果;
4. RDP 路径代码保留但自启已禁用,按需切回。
### C. 协议策略与代码结构
实测结论已经支持双协议方向,但仍未定版:
可能的最终协议策略(只能在实测后决定):
```text
普通办公 -> RDP
GPU/CAD/3D -> Moonlight
```
但也可能仍然统一使用 RDP;由测试结论决定。
如果确认需要双协议,代码结构应演进为:
```text
DesktopSession 接口
├─ RdpSession # 保留当前 libfreerdp3
└─ MoonlightSession # 当前是 shell 启动器 + moonlght-qt 子进程
└─ MoonlightSession # 调研后再实现
SessionManager # 根据 API 下发 protocol 选择实现
```
不要在定版前擅自确定 Moonlight 的嵌入方式、认证字段或删除 RDP 代码。
不要在调研前擅自确定 Moonlight 端口、认证字段、库、嵌入方式或把 RDP 删除。
### C. 后续 API 对接

33
docs/api-free-rdp3.md Normal file
View File

@ -0,0 +1,33 @@
# 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

@ -0,0 +1,67 @@
# 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

@ -1,93 +0,0 @@
# 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

@ -1,39 +0,0 @@
@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

@ -1,322 +0,0 @@
# Moonlight 云桌面开机直达落地记录(2026-10-28)
## 起因
上一个对话在做完 RDP/Moonlight 实测对比(见 `rdp-vs-moonlight-comparison-20261028.md`)后,
提出把 Moonlight 产品化为开机直连云桌面,但只列了 5 条待办就中断了。
现场表现:登录盒子后看到的是 MATE 的绿色桌面,不是云桌面。
## 根因
盒子自启项 `~/.config/autostart/vdi-kiosk.desktop` 仍然拉起的是 RDP 版 `kiosk-shell`,
而 Moonlight 从来没有进入过开机自启链;同时命令行直连串流还存在两处硬伤:
1. **应用 id 失效**:Moonlight 图形界面里配对的记录里,`Desktop` 的 app id 是 `881448767`,
但 Sunshine 重装/升级后服务端的应用 id 已经变了,用旧 id 启动直接报
`Qt Critical: 找不到应用程序 881448767`,串流根本起不来。
改用应用**名称** `Desktop` 启动即可,服务端当前返回的应用列表是
`Desktop` / `Steam Big Picture`。
2. **硬解路径不可用**:`moonlight-qt` 的 `--video-decoder auto` 会优先尝试
`h264_v4l2m2m`,在 RK3528 上报 `Could not find a valid device`
(尽管存在 `/dev/mpp_service`、`/dev/rga`、`/dev/dri/renderD128`)。
显式指定 `--video-decoder software` 后串流稳定。
另外两个在联调中发现并修掉的问题:
3. **Monitor 只杀直接子进程**:`vdi-kiosk-monitor` 通过 `sh -c` 启动主进程,
杀掉它只会结束 `sh -c` 包装进程,真正的启动器变成孤儿并继续拉起第二条串流,
两条流同时抢显示设备。已改为子进程独立进程组、按组结束整棵进程树。
4. **主机可达性判断用错了协议**:Windows 防火墙屏蔽 ICMP,盒子 ping 不到 `192.168.1.70`,
但 TCP/UDP 正常。可达性判断改成 TCP 连接探测(`bash /dev/tcp`)。
## 已交付
仓库内新增(`terminal/kiosk-shell/`):
```text
scripts/vdi-moonlight # 云桌面串流启动器(POSIX sh)
etc/moonlight.conf # 串流参数模板
vdi-moonlight.desktop # 开机自启条目(Monitor + 启动器)
```
修改:`src/monitor.cpp`(进程组管理)、`src/monitor_main.cpp` 无改动。
盒子上的部署位置:
```text
~/vdi-terminal/bin/vdi-moonlight # 启动器
~/vdi-terminal/bin/vdi-kiosk-monitor # 看门狗(已按本次修改重新编译)
~/.config/vdi-terminal/moonlight.conf # 串流参数
~/.config/autostart/vdi-moonlight.desktop # 开机自启(已启用)
~/.config/autostart/vdi-kiosk.desktop # RDP 旧路径(已禁用,保留作回退)
```
启动链:
```text
systemd/user autostart
-> vdi-kiosk-monitor --heartbeat-file /tmp/vdi-moonlight.heartbeat --max-restart 200
-- /home/armbian/vdi-terminal/bin/vdi-moonlight
-> moonlight-qt stream 192.168.1.70 Desktop ... (fullscreen)
```
启动器职责:读本地配置 → 等云桌面主机就绪(TCP 探测)→ 全屏串流 →
每 5 秒写心跳 → 检测串流假死(CPU 与网络均无流量超过 `hang-timeout`)→ 失败重试。
## 实测结果
全部在 RK3528 盒子(192.168.1.188)上实测:
| 项目 | 结果 |
|---|---|
| 开机/手动拉起后屏幕内容 | 满屏 Windows 10 云桌面,1920x1080 与屏幕 1:1 无缩放 |
| 杀掉 moonlight-qt 客户端 | 启动器 5 秒内重连成功 |
| 杀掉启动器 | 看门狗 3 秒后重启启动器,重新串流 |
| 心跳文件 | `/tmp/vdi-moonlight.heartbeat` 每 5 秒刷新 |
| 串流进程数 | 始终 1 条,无孤儿进程 |
`moonlight-qt --performance-overlay` 实测(1080p60 / H.264 / 软解):
```text
Video stream: 1920x1080 21.59 FPS (Codec: H.264)
Incoming frame rate from network: 21.59 FPS
Decoding frame rate: 21.59 FPS
Rendering frame rate: 18.15 FPS
Frames dropped by your network connection: 0.00%
Frames dropped due to network jitter: 13.64%
Average network latency: 3 ms (variance: 1 ms)
Average decoding time: 15.04 ms
Average frame queue delay: 47.50 ms
Average rendering time (including monitor V-sync latency): 51.09 ms
```
结论:链路健康,网络丢包 0%,延迟 3 ms;21.6 FPS 的上限来自**服务端**,
VM 100 内 Sunshine 走 libx264 软件编码(无 GPU 直通),抖动丢帧 13.64% 同源。
## 仍未验证 / 下一步
1. **服务端 GPU 直通**:宿主机 `starhe` 有 GTX 965M(`0000:01:00.0`,IOMMU Group 2 独占),
主机同时有 Intel 核显 `0000:00:02.0` 可继续驱动本机显示,具备直通条件。
直通后 Sunshine 可用 NVENC 硬编码,是把帧率提到 60 FPS、改善鼠标拖尾/卡顿的前提。
2. **鼠标卡顿**:性能叠图实测渲染帧率仅 18.15 FPS、帧队列延迟 47.50 ms、
渲染/VSync 51.09 ms,再叠加服务端 libx264 软编码,视频链路本身已累计约 100 ms。
`--absolute-mouse` 已启用,输入模式没有配置错误;体感滞后来自低帧率和视频队列,
根治仍是 Sunshine NVENC。短期可降为 1080p30、使用 `--no-vsync --no-frame-pacing`
做 A/B 测试,但可能增加撕裂,不能替代服务端硬编码。
3. **RK3528 硬解**:`h264_v4l2m2m` / `hevc_v4l2m2m` 缺少可用设备,需要 Rockchip MPP 的
FFmpeg 补丁或 V4L2 request 驱动。当前 1080p 软解可用,4K 需要硬解。
4. **2K 分辨率**:盒子当前屏幕是 1920x1080@60Hz,Sunshine 侧虚拟显示器也是 1920x1080@60Hz。
若产品要求 2K,需要同时调整虚拟显示器分辨率和盒子端显示模式。
5. **音频**:`--audio-on-host` 已开启,但终端出音未经现场耳朵确认。
6. **产品登录流程**:当前开机直连 Moonlight 只是串流技术验证,不是最终产品形态。
最终应由 `kiosk-shell` 显示首配/登录/桌面列表,用户选择后再获取短期连接票据并启动会话;
管理员 Web 控制台是独立的 `console/`,方案见 `docs/web-console-mvp-plan-20261028.md`。
7. **与 RDP 的关系**:RDP 路径代码保留在 `src/rdp_session.*`,自启项已禁用,可随时切回。
正式协议选型仍需按实测数据与产品方案确认,不能仅凭本记录定版。
8. **管理平台 API**:Moonlight 路径目前是本地配置文件驱动,接入自研管理平台后
应由 API 下发主机/分辨率/帧率等参数,替换 `etc/moonlight.conf` 的静态配置。
9. **重启验收异常**:2026-10-28 15:54 执行真实重启后,盒子在 3 分钟内未恢复
`192.168.1.188` 的网络/SSH,局域网扫描也未发现其 SSH 地址。重启前已确认
NetworkManager 连接 `YXTech` 的 `autoconnect=yes`,但真实重启后的开机自启验收因此未完成;
需要现场查看盒子 HDMI 画面/电源状态,并通过串口或本地键盘确认启动与 WiFi 情况。
## 附:常用运维命令
```bash
# 查看状态
/home/armbian/vdi-terminal/bin/vdi-moonlight status
# 查看日志
tail -f ~/vdi-terminal/logs/moonlight-$(date +%Y%m%d).log
tail -f /tmp/vdi-monitor.log
# 手动重启串流
pkill -f '^moonlight-qt stream' # 启动器会自动重连
# 切回 RDP 路径
sed -i 's/^X-GNOME-Autostart-enabled=false/X-GNOME-Autostart-enabled=true/' \
~/.config/autostart/vdi-kiosk.desktop
sed -i 's/^X-GNOME-Autostart-enabled=true/X-GNOME-Autostart-enabled=false/' \
~/.config/autostart/vdi-moonlight.desktop
```
---
# 2026-10-29 追加:登录/桌面列表产品流程落地与网络根因修复
## 新增验证结论(全部实测)
1. **产品流程全面跑通**:开机 → 登录页(本地演示认证)→ 桌面列表
(Windows 可连接 / Ubuntu、麒麟待分配)→ 点击连接 → Moonlight 全屏串流
Windows 10 桌面 → 串流结束自动回桌面列表。
2. **重启验收通过**:真实重启后固定 IP `192.168.1.188` 自动恢复,
唯一自启链 `vdi-kiosk-monitor -> kiosk-shell` 生效,开机只进登录页,
不再出现"先 RDP 后 Moonlight"双链竞争。
3. **Monitor 崩溃恢复**:kill -9 kiosk-shell 后 4 秒内自动重启,心跳正常。
## 昨晚两次串流失败的根因(重要)
用户给盒子配静态 IP `192.168.1.188` 时,是在 DHCP 连接上"添加地址",
导致 wlan0 同时持有 `.185`(DHCP)和 `.188`(静态)两个地址。
双地址引发 ARP 混乱,实测:
```text
盒子 -> 网关 ping 丢包 66%~85%(间歇)
PC -> 盒子 ping 丢包 33%
UDP 双向近乎全丢(Moonlight 的视频/控制都是 UDP)
TCP/SSH 基本正常
```
Moonlight 两次失败(38 秒断流、第二次连不上)均为该丢包所致,
与 Moonlight 参数、Sunshine 无关。WiFi 信号本身满格(-38dBm)。
修复:`nmcli` 将 YXTech 连接改为 `ipv4.method=manual` 纯静态,
只保留 `.188` 一个地址。修复后网关 ping 0% 丢包(60/60),
串流稳定运行 7 分钟以上直至手动结束。
**教训:给无线终端配静态 IP 必须用 `ipv4.method=manual` 整体替换,
不能在 DHCP 连接上叠加第二个地址。**
## 鼠标延迟 A/B 实测(1080p60,服务端 libx264)
| 指标 | 基线 vsync+frame-pacing | no-vsync + no-frame-pacing |
|---|---|---|
| 串流帧率 | 21.64 FPS | 21.60 FPS |
| 渲染帧率 | 18.20 FPS | 18.16 FPS |
| 网络丢帧 | 0.00% | 0.00% |
| 抖动丢帧 | 15.91% | 13.64% |
| 帧队列延迟 | 44.13 ms | 47.21 ms |
| 渲染/VSync | 48.79 ms | 49.49 ms |
结论:关闭 vsync/frame-pacing **无改善**(队列延迟反而略升)。
~45ms 队列延迟 ≈ 一个帧间隔(21.6FPS → 46ms/帧),
瓶颈是服务端软编码帧率,不是客户端参数。
决定:保持 vsync + frame-pacing 开启(C++ 默认),
`etc/moonlight.conf` 默认值已同步改回 `1`。
鼠标卡顿的根治手段仍是服务端 GPU 直通 + NVENC。
---
# 2026-10-29 下午追加:四件事落地(删 RDP / 退出设计 / 过场层 / ultrafast 实测)
## 已交付(全部编译部署到盒子并实测)
1. **删 RDP**:`rdp_session.*`、FreeRDP 渲染管线、CMake 构建依赖全部移除,
协议概念(`Protocol` 枚举)从代码删除,Moonlight 是唯一会话通道;
RDP 代码只存在于 git 历史(网关备用)。部署后 `ldd kiosk-shell` 无任何 freerdp 依赖。
2. **退出设计三层**:
- 会话内 `Ctrl+Alt+Shift+Q` 退出串流回桌面列表——moonlight-qt 自带快捷键为主路径,
kiosk 侧保留同一快捷键兜底(串流未建立/焦点仍在 kiosk 时生效);
- 桌面列表右上角"退出登录"→ 登录页(已有);
- 登录页删除"退出"按钮(退出终端是维护功能,只留在管理菜单 Ctrl+Alt+Shift+M)。
3. **加载过场层**:新增 `src/transition_overlay.*`,点连接后全屏置顶黑层
("正在进入云桌面…"动态省略号 + 旋轮),盖住 moonlight-qt 启动期左上角小窗闪烁;
首帧检测用 moonlight 进程 CPU 时间近似(软解 CPU 远高于握手期,阈值 120 tick / 15s 兜底),
检测到后 1 秒淡出。活动期间吞键盘鼠标、周期 raise 保持置顶。
4. **Sunshine x264 预设 ultrafast**:VM 100 的 `C:\Program Files\Sunshine\config\sunshine.conf`
写入 `sw_preset = ultrafast`(该版本支持 `sw_preset`/`sw_tune` 键),服务已重启生效。
## 关键实测结论:21.6 FPS 帽子不在 x264,旧根因判定被推翻
ultrafast 前后对照(1080p60 / H.264 / 盒子软解,同参数同网络):
| 指标 | medium(基线 10-28) | ultrafast(本次) |
|---|---|---|
| Video stream FPS | 21.59~21.64 | 21.57~21.61 |
| 抖动丢帧 | 13.64%~15.91% | 13.64%(分毫不差) |
| 客户端解码 | 15.04 ms | 10.51~11.14 ms |
决定性证据:
- 串流进行中 VM 内 `Processor(_Total)` CPU 仅 0.2%~0.5%,
sunshine 进程自服务重启起累计 CPU 时间仅 14 秒(含三次共约 3 分钟会话);
- 用 xdotool 持续晃鼠标制造画面运动,帧率与 CPU 都不变;
- libx264 只有 `MMX2 SSE2Fast SSSE3 SSE4.2`——VM CPU 型号是
`QEMU Virtual CPU version 2.5+`(kvm64 类),宿主指令集(含 AVX2)没有透传。
结论:**21.6 FPS 的上限来自虚拟显示/捕获节奏(IddSampleDriver/MttVDD 呈现与
Desktop Duplication 的脏帧节奏,叠加 QEMU 显示栈),不是编码器算力**——
编码器近乎空转,预设怎么调都不会变。ultrafast 保留(无害,客户端解码也略降)。
根治路径按优先级:GPU 直通(NVENC + 真实显示栈)> 换 VM 显示方案
(VirtIO-GPU / cpu=host 先排除 QEMU 显示栈因素)> 都不是调 Sunshine 参数能解决的。
## 运维新装 Linux 云桌面 VM(2026-10-29 接入信息)
| VM | Proxmox | 地址 | 账号 | 实测 |
|---|---|---|---|---|
| kylin-test | 101 | 192.168.1.71 | test1 / yx66668888 | SSH 可登,银河麒麟 V10 SP1 桌面版,UKUI + Xorg,图形会话运行中 |
| ubuntu-test | 102 | 192.168.1.72 | test2 / yx66668888 | SSH 可登,Ubuntu 24.04.4 LTS,GNOME + Xorg,图形会话运行中(4 核 / 5.8GiB) |
两台均未装 Sunshine(47989 等端口关闭),还不能被 Moonlight 连接。
接入桌面列表的步骤:装 Sunshine(Linux 版)→ 配对 → 在盒子"桌面连接设置"里
把 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

@ -1,82 +0,0 @@
# 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

@ -1,74 +0,0 @@
# 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 已确认开放,防火墙无需改动。

View File

@ -1,20 +0,0 @@
<?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

@ -1,287 +0,0 @@
# 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

@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.16)
project(kiosk-shell CXX)
# 对应《VDI 云桌面软件开发方案》2.2 节技术栈定版:
# kiosk 用 C/C++/Qt;会话通道统一 Moonlight(RDP 渲染管线已移除,代码存于 git 历史)
# kiosk 与 freerdp 插件用 C/C++(贴合 GTK/GStreamer 生态)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
@ -14,20 +14,18 @@ endif()
# ---- Qt5 ----
find_package(Qt5 REQUIRED COMPONENTS Core Gui Widgets)
include(CTest)
# ---- FreeRDP 3 ----
find_package(PkgConfig REQUIRED)
pkg_check_modules(FREERDP REQUIRED IMPORTED_TARGET freerdp3)
add_executable(kiosk-shell
src/main.cpp
src/main_window.cpp
src/login_page.cpp
src/desktop_list_page.cpp
src/desktop_settings_dialog.cpp
src/desktop_service.cpp
src/desktop_store.cpp
src/moonlight_session.cpp
src/transition_overlay.cpp
src/state_machine.cpp
src/config.cpp
src/rdp_session.cpp
)
target_include_directories(kiosk-shell PRIVATE
@ -38,54 +36,7 @@ target_link_libraries(kiosk-shell PRIVATE
Qt5::Core
Qt5::Gui
Qt5::Widgets
PkgConfig::FREERDP
)
# 看门狗 Monitor:独立进程,监控主进程假死/崩溃
add_executable(vdi-kiosk-monitor
src/monitor_main.cpp
src/monitor.cpp
)
target_include_directories(vdi-kiosk-monitor PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src
)
find_package(Threads REQUIRED)
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 RUNTIME DESTINATION bin)

View File

@ -1,40 +0,0 @@
# VDI 云桌面串流参数
#
# 本文件当前仅供 scripts/vdi-moonlight 调试脚本使用;
# 产品流程中 kiosk-shell 的 MoonlightSession 由连接票据直接传参,不读本文件。
#
# host/port Sunshine 服务端地址与端口(当前联调环境为 VM 100)
# app 应用名。用名称而不是 app id:Sunshine 重装或升级后 app id 会变。
# width/height 分辨率,需与服务端显示模式匹配(当前虚拟显示器 1920x1080@60Hz)
# fps 目标帧率
# bitrate 码率,单位 Kbps
# codec 视频编码:H.264 / HEVC / AV1 / auto
# decoder 解码器:software / hardware / auto
# RK3528 当前没有可用的 V4L2 request/m2m 设备,auto 会选到
# h264_v4l2m2m 并报 "Could not find a valid device",故用 software。
# vsync 1=开启垂直同步(--vsync),0=关闭(--no-vsync)
# 2026-10-29 A/B 实测:关闭 vsync/frame-pacing 对队列与渲染延迟
# 无改善(队列 47.2ms vs 基线 44.1ms),延迟瓶颈是服务端软编码
# 帧率(~21.6FPS,每帧约46ms),保持开启以获得更平滑的画面。
# frame-pacing 1=开启帧率匀速(--frame-pacing),0=关闭(--no-frame-pacing)
# performance-overlay 1=显示性能叠图(调试用)
# hang-timeout 串流假死判定时间(秒),0 表示关闭该检测
host=192.168.1.70
port=47984
app=Desktop
width=1920
height=1080
fps=60
bitrate=20000
codec=H.264
decoder=software
display-mode=fullscreen
audio=1
absolute-mouse=1
vsync=1
frame-pacing=1
performance-overlay=0
retry=3
retry-delay=5
hang-timeout=90
wait-host=120

View File

@ -1,269 +0,0 @@
#!/bin/sh
# VDI 云桌面串流启动器(Moonlight 客户端侧)
#
# 职责:
# 1. 读取本地配置(主机、应用、分辨率、帧率、码率、编解码)
# 2. 以软件解码拉起 moonlight-qt,全屏串流云桌面
# 3. 写心跳文件,供 vdi-kiosk-monitor 判断是否假死
# 4. 检测串流假死(CPU 与网络均无流量)并杀掉重连
#
# 被 vdi-kiosk-monitor 守护:
# vdi-kiosk-monitor -- /home/armbian/vdi-terminal/bin/vdi-moonlight
#
# 说明:RK3528 当前没有可用的 V4L2 request/m2m 硬解设备,moonlight-qt 的
# --video-decoder auto 会选到 h264_v4l2m2m 并报 "Could not find a valid device",
# 因此配置里显式指定 decoder=software。接入 MPP/VPU 硬解后再改回 auto。
set -u
CONF="${VDI_MOONLIGHT_CONF:-$HOME/.config/vdi-terminal/moonlight.conf}"
HEARTBEAT="${VDI_MOONLIGHT_HEARTBEAT:-/tmp/vdi-moonlight.heartbeat}"
PIDFILE="${VDI_MOONLIGHT_PIDFILE:-/tmp/vdi-moonlight.pid}"
LAUNCHER_PIDFILE="${VDI_MOONLIGHT_LAUNCHER_PIDFILE:-/tmp/vdi-moonlight-launcher.pid}"
LOG_DIR="${VDI_MOONLIGHT_LOG_DIR:-$HOME/vdi-terminal/logs}"
LOG_FILE="$LOG_DIR/moonlight-$(date +%Y%m%d).log"
ML_PID=""
# 默认值,均可被配置文件覆盖
HOST=""
PORT=""
APP="Desktop"
WIDTH=1920
HEIGHT=1080
FPS=60
BITRATE=20000
CODEC="H.264"
DECODER="software"
DISPLAY_MODE="fullscreen"
AUDIO=1
ABSOLUTE_MOUSE=1
VSYNC=0
FRAME_PACING=0
PERFORMANCE_OVERLAY=0
RETRY=3
RETRY_DELAY=5
HANG_TIMEOUT=90
WAIT_HOST=120
mkdir -p "$LOG_DIR"
log() {
printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$LOG_FILE"
}
# 读取 key=value 形式的配置
load_config() {
[ -f "$CONF" ] || return 0
while IFS='=' read -r key value; do
case "$key" in
''|\#*) continue ;;
esac
value="${value%\"}"; value="${value#\"}"
case "$key" in
host) HOST="$value" ;;
port) PORT="$value" ;;
app) APP="$value" ;;
width) WIDTH="$value" ;;
height) HEIGHT="$value" ;;
fps) FPS="$value" ;;
bitrate) BITRATE="$value" ;;
codec) CODEC="$value" ;;
decoder) DECODER="$value" ;;
display-mode) DISPLAY_MODE="$value" ;;
audio) AUDIO="$value" ;;
absolute-mouse) ABSOLUTE_MOUSE="$value" ;;
vsync) VSYNC="$value" ;;
frame-pacing) FRAME_PACING="$value" ;;
performance-overlay) PERFORMANCE_OVERLAY="$value" ;;
retry) RETRY="$value" ;;
retry-delay) RETRY_DELAY="$value" ;;
hang-timeout) HANG_TIMEOUT="$value" ;;
wait-host) WAIT_HOST="$value" ;;
esac
done < "$CONF"
}
# 默认路由出接口的累计收字节数,用于判断串流是否还有数据流入
rx_bytes() {
iface=$(awk '$2=="00000000" {print $1; exit}' /proc/net/route)
[ -n "$iface" ] || iface=lo
awk -v i="$iface:" '$1==i {print $2; exit}' /proc/net/dev
}
# 进程累计 CPU 时间(utime+stime),用于判断串流是否还在解码
cpu_ticks() {
pid="$1"
[ -r "/proc/$pid/stat" ] || { echo 0; return; }
cut -d')' -f2 "/proc/$pid/stat" 2>/dev/null | awk '{print $12+$13}'
}
host_reachable() {
# 用 TCP 探测 Sunshine 的 HTTPS 控制端口。
# 注意:Windows 防火墙默认屏蔽 ICMP,ping 不通不代表服务不可用,
# 所以优先做 TCP 连接探测,ping 只作为最后兜底。
probe_port="${PORT:-47984}"
if command -v nc >/dev/null 2>&1; then
nc -z -w 2 "$HOST" "$probe_port" 2>/dev/null && return 0
return 1
fi
if command -v bash >/dev/null 2>&1; then
timeout 3 bash -c "exec 3<>/dev/tcp/$HOST/$probe_port" 2>/dev/null && return 0
return 1
fi
ping -c 1 -W 2 "$HOST" >/dev/null 2>&1
}
kill_stale() {
# 清理上次崩溃残留的串流进程,避免孤儿进程抢占显示。
# 顺序很重要:先结束残留的启动器实例(否则它会再拉起一条串流),
# 再清理残留的串流进程。
if [ -f "$LAUNCHER_PIDFILE" ]; then
old=$(cat "$LAUNCHER_PIDFILE" 2>/dev/null)
if [ -n "$old" ] && [ "$old" != "$$" ]; then
kill -9 "$old" 2>/dev/null
fi
rm -f "$LAUNCHER_PIDFILE"
fi
if [ -f "$PIDFILE" ]; then
old=$(cat "$PIDFILE" 2>/dev/null)
[ -n "$old" ] && kill -9 "$old" 2>/dev/null
rm -f "$PIDFILE"
fi
# 用 ^ 锚定命令行开头,避免误杀包含该字符串的 shell 包装进程
pkill -f '^moonlight-qt stream' 2>/dev/null
sleep 1
}
disable_screensaver() {
# Kiosk 场景不允许息屏把画面遮掉
command -v gsettings >/dev/null 2>&1 || return 0
gsettings set org.mate.screensaver idle-activation-enabled false 2>/dev/null
gsettings set org.mate.screensaver lock-enabled false 2>/dev/null
}
build_cmd() {
# moonlight-qt stream 只接受 <host> <app> 位置参数,没有 --port 选项,
# 服务端端口由 moonlight 自身按 GameStream 协议协商。
CMD="moonlight-qt stream $HOST $APP"
CMD="$CMD --resolution ${WIDTH}x${HEIGHT}"
CMD="$CMD --fps $FPS"
CMD="$CMD --bitrate $BITRATE"
CMD="$CMD --display-mode $DISPLAY_MODE"
CMD="$CMD --video-codec $CODEC"
CMD="$CMD --video-decoder $DECODER"
CMD="$CMD --keep-awake"
[ "$ABSOLUTE_MOUSE" = "1" ] && CMD="$CMD --absolute-mouse"
[ "$AUDIO" = "1" ] && CMD="$CMD --audio-on-host" || CMD="$CMD --no-audio-on-host"
# 低延迟开关:默认关闭 vsync 与 frame-pacing,削减帧队列/呈现延迟
[ "$VSYNC" = "1" ] && CMD="$CMD --vsync" || CMD="$CMD --no-vsync"
[ "$FRAME_PACING" = "1" ] && CMD="$CMD --frame-pacing" || CMD="$CMD --no-frame-pacing"
[ "$PERFORMANCE_OVERLAY" = "1" ] && CMD="$CMD --performance-overlay"
}
run_stream() {
build_cmd
log "启动串流: $CMD"
# shellcheck disable=SC2086
$CMD >> "$LOG_FILE" 2>&1 &
ML_PID=$!
echo "$ML_PID" > "$PIDFILE"
# 收到终止信号时把串流进程一起带走,避免留下占用显示的孤儿进程
trap 'if [ -n "$ML_PID" ] && kill -0 "$ML_PID" 2>/dev/null; then kill -TERM "$ML_PID" 2>/dev/null; sleep 1; kill -9 "$ML_PID" 2>/dev/null; fi; rm -f "$PIDFILE"; exit 143' TERM INT
last_cpu=$(cpu_ticks "$ML_PID")
last_rx=$(rx_bytes)
flat_since=$(date +%s)
while kill -0 "$ML_PID" 2>/dev/null; do
sleep 5
date +%s > "$HEARTBEAT" 2>/dev/null
cur_cpu=$(cpu_ticks "$ML_PID")
cur_rx=$(rx_bytes)
now=$(date +%s)
if [ "$cur_cpu" != "$last_cpu" ] || [ "$cur_rx" != "$last_rx" ]; then
flat_since=$now
elif [ "$HANG_TIMEOUT" -gt 0 ] && [ $((now - flat_since)) -ge "$HANG_TIMEOUT" ]; then
log "检测到串流假死(${HANG_TIMEOUT}s 内无 CPU 与网络活动),重启串流"
kill -9 "$ML_PID" 2>/dev/null
wait "$ML_PID" 2>/dev/null
return 3
fi
last_cpu=$cur_cpu
last_rx=$cur_rx
done
wait "$ML_PID"
code=$?
rm -f "$PIDFILE"
log "串流进程退出, code=$code"
return $code
}
case "${1:-start}" in
status)
if pgrep -f 'moonlight-qt stream' >/dev/null 2>&1; then
echo "moonlight: running"
exit 0
fi
echo "moonlight: stopped"
exit 1
;;
stop)
kill_stale
echo "moonlight: stopped"
exit 0
;;
esac
load_config
if [ -z "$HOST" ]; then
log "配置缺失或缺少 host: $CONF"
exit 2
fi
export DISPLAY="${VDI_DISPLAY:-:0}"
export XAUTHORITY="${VDI_XAUTHORITY:-$HOME/.Xauthority}"
disable_screensaver
kill_stale
echo "$$" > "$LAUNCHER_PIDFILE"
# 等待云桌面主机就绪(开机自启时虚拟机可能还没起来)
waited=0
log "等待云桌面主机 $HOST 就绪(最长 ${WAIT_HOST}s)"
while [ "$waited" -lt "$WAIT_HOST" ]; do
if host_reachable; then
log "主机 $HOST 已就绪(等待 ${waited}s)"
break
fi
sleep 5
waited=$((waited + 5))
done
if [ "$waited" -ge "$WAIT_HOST" ]; then
log "等待主机 $HOST 超时 ${WAIT_HOST}s,仍尝试启动串流"
fi
attempt=1
while [ "$attempt" -le "$RETRY" ]; do
run_stream
code=$?
if [ "$code" = "0" ]; then
log "串流正常结束"
exit 0
fi
if [ "$code" = "3" ]; then
# 假死重启,直接下一轮
attempt=$((attempt + 1))
continue
fi
log "第 $attempt/$RETRY 次串流失败 (code=$code),${RETRY_DELAY}s 后重试"
attempt=$((attempt + 1))
sleep "$RETRY_DELAY"
done
log "连续 $RETRY 次串流失败,退出,交由 Monitor 重启"
exit 1

View File

@ -92,10 +92,6 @@ TerminalConfig loadConfig() {
return cfg;
}
bool hasConnectionConfig(const TerminalConfig& cfg) {
return !cfg.server.empty() && !cfg.username.empty();
}
bool saveConfig(const TerminalConfig& cfg) {
std::string dir = configDir();
mkdir(dir.c_str(), 0755);

View File

@ -39,9 +39,6 @@ std::string configPath();
// 读取配置;文件不存在时返回默认值
TerminalConfig loadConfig();
// 判断是否已经有 Agent/API 下发的可用桌面连接配置。
bool hasConnectionConfig(const TerminalConfig& cfg);
// 保存配置
bool saveConfig(const TerminalConfig& cfg);

View File

@ -1,196 +0,0 @@
#include "desktop_list_page.h"
#include <QFont>
#include <QFrame>
#include <QHBoxLayout>
#include <QLabel>
#include <QPushButton>
#include <QScrollArea>
#include <QVBoxLayout>
namespace vdi {
namespace {
// 在信息行里加一个拉伸占位(独立函数避免在类外重复声明)
void addStretchToRow(QHBoxLayout* row) {
row->addStretch(1);
}
} // namespace
DesktopListPage::DesktopListPage(QWidget* parent) : QWidget(parent) {
auto* root = new QVBoxLayout(this);
root->setContentsMargins(80, 50, 80, 40);
root->setSpacing(16);
// ---- 标题区 ----
auto* title = new QLabel(QStringLiteral("选择云桌面"), this);
QFont titleFont = title->font();
titleFont.setPointSize(26);
titleFont.setBold(true);
title->setFont(titleFont);
title->setAlignment(Qt::AlignCenter);
root->addWidget(title);
auto* infoRow = new QHBoxLayout();
labelUser_ = new QLabel(this);
labelUser_->setStyleSheet(QStringLiteral("color: #8a94a6;"));
// 无登录页演示模式下不设"退出登录"(用户身份由设备/平台承担,
// 正式认证接入后由 DesktopService 驱动恢复登录流程)
infoRow->addWidget(labelUser_);
addStretchToRow(infoRow);
root->addLayout(infoRow);
// ---- 卡片区 ----
auto* scroll = new QScrollArea(this);
scroll->setWidgetResizable(true);
scroll->setFrameShape(QFrame::NoFrame);
scroll->setStyleSheet(QStringLiteral(
"QScrollArea { background: transparent; }"
"QWidget { background: transparent; }"));
auto* cardsHost = new QWidget(scroll);
cardsLayout_ = new QVBoxLayout(cardsHost);
cardsLayout_->setSpacing(14);
cardsLayout_->addStretch(1);
scroll->setWidget(cardsHost);
root->addWidget(scroll, 1);
// ---- 状态条 ----
labelStatus_ = new QLabel(this);
labelStatus_->setAlignment(Qt::AlignCenter);
labelStatus_->setStyleSheet(QStringLiteral("color: #8a94a6;"));
root->addWidget(labelStatus_);
}
void DesktopListPage::setDesktops(const std::vector<DesktopInfo>& desktops) {
desktops_ = desktops;
rebuildCards();
}
void DesktopListPage::rebuildCards() {
actionButtons_.clear();
// 清空旧卡片(最后一项是 stretch)
while (cardsLayout_->count() > 1) {
QLayoutItem* item = cardsLayout_->takeAt(0);
if (item->widget()) {
item->widget()->deleteLater();
}
delete item;
}
for (const DesktopInfo& d : desktops_) {
auto* card = new QFrame(this);
card->setMinimumHeight(96);
card->setStyleSheet(QStringLiteral(
"QFrame { background: #1a2233; border: 1px solid #2b3654; border-radius: 10px; }"
"QFrame:hover { border: 1px solid #2f6feb; }"
"QLabel { border: none; background: transparent; }"));
auto* row = new QHBoxLayout(card);
row->setContentsMargins(24, 16, 24, 16);
row->setSpacing(18);
auto* textCol = new QVBoxLayout();
textCol->setSpacing(4);
auto* name = new QLabel(d.name, card);
QFont nameFont = name->font();
nameFont.setPointSize(15);
nameFont.setBold(true);
name->setFont(nameFont);
auto* osLine = new QLabel(d.os, card);
osLine->setStyleSheet(QStringLiteral("color: #8a94a6;"));
textCol->addWidget(name);
textCol->addWidget(osLine);
row->addLayout(textCol, 1);
// 状态标签(会话通道统一 Moonlight,无需按桌面标注协议)
QString stateText;
QString stateColor;
switch (d.state) {
case DesktopState::kAvailable:
stateText = QStringLiteral("可连接");
stateColor = QStringLiteral("#3fb950");
break;
case DesktopState::kPending:
stateText = QStringLiteral("任务进行中");
stateColor = QStringLiteral("#d29922");
break;
case DesktopState::kUnassigned:
stateText = QStringLiteral("待分配");
stateColor = QStringLiteral("#8a94a6");
break;
}
auto* state = new QLabel(stateText, card);
state->setStyleSheet(QStringLiteral("color: %1; font-weight: bold;").arg(stateColor));
row->addWidget(state);
// “设置”:每张卡都可编辑连接参数(协议/地址/应用名/分辨率/帧率)
auto* editBtn = new QPushButton(QStringLiteral("设置"), card);
editBtn->setMinimumSize(88, 42);
editBtn->setEnabled(!busy_);
actionButtons_.push_back(editBtn);
connect(editBtn, &QPushButton::clicked, this, [this, id = d.id]() {
if (!busy_) {
emit editRequested(id);
}
});
row->addWidget(editBtn);
if (d.state == DesktopState::kAvailable) {
auto* connectBtn = new QPushButton(QStringLiteral("连接"), card);
connectBtn->setMinimumSize(110, 42);
connectBtn->setStyleSheet(QStringLiteral(
"QPushButton { background: #2f6feb; color: white; border: none; "
"border-radius: 6px; font-weight: bold; }"
"QPushButton:disabled { background: #3a4a63; }"));
connectBtn->setEnabled(!busy_);
actionButtons_.push_back(connectBtn);
connect(connectBtn, &QPushButton::clicked, this, [this, id = d.id]() {
if (!busy_) {
setBusy(true);
setStatus(QStringLiteral("正在连接,请稍候…"));
emit desktopSelected(id);
}
});
row->addWidget(connectBtn);
} else {
auto* btn = new QPushButton(QStringLiteral("连接"), card);
btn->setMinimumSize(110, 42);
btn->setEnabled(false);
row->addWidget(btn);
}
cardsLayout_->insertWidget(cardsLayout_->count() - 1, card);
}
}
void DesktopListPage::setStatus(const QString& text, bool error) {
labelStatus_->setText(text);
labelStatus_->setStyleSheet(
QStringLiteral("color: %1;").arg(error ? QStringLiteral("#e05561")
: QStringLiteral("#8a94a6")));
}
void DesktopListPage::setBusy(bool busy) {
busy_ = busy;
for (QPushButton* button : actionButtons_) {
if (button) {
button->setEnabled(!busy_);
}
}
}
QString DesktopListPage::currentUser() const {
return currentUser_;
}
void DesktopListPage::setCurrentUser(const QString& user) {
currentUser_ = user;
labelUser_->setText(user.isEmpty()
? QStringLiteral("当前用户:未登录")
: QStringLiteral("当前用户:%1").arg(user));
}
} // namespace vdi

View File

@ -1,57 +0,0 @@
// 桌面列表页
//
// 登录成功后展示当前用户可用的云桌面卡片;
// 选择桌面后由 MainWindow 获取连接票据并启动对应协议会话;
// 会话退出后返回本页。
#ifndef KIOSK_SHELL_DESKTOP_LIST_PAGE_H
#define KIOSK_SHELL_DESKTOP_LIST_PAGE_H
#include <QWidget>
#include <vector>
#include "desktop_session.h"
class QLabel;
class QPushButton;
class QVBoxLayout;
namespace vdi {
class DesktopListPage : public QWidget {
Q_OBJECT
public:
explicit DesktopListPage(QWidget* parent = nullptr);
// 用桌面数据刷新卡片
void setDesktops(const std::vector<DesktopInfo>& desktops);
void setStatus(const QString& text, bool error = false);
void setBusy(bool busy);
QString currentUser() const;
void setCurrentUser(const QString& user);
signals:
// 用户点击一张可用桌面卡片
void desktopSelected(const QString& desktopId);
// 用户点击某张卡片的“设置”按钮
void editRequested(const QString& desktopId);
private:
void rebuildCards();
std::vector<DesktopInfo> desktops_;
QString currentUser_;
QLabel* labelUser_ = nullptr;
QLabel* labelStatus_ = nullptr;
QVBoxLayout* cardsLayout_ = nullptr;
std::vector<QPushButton*> actionButtons_;
bool busy_ = false;
};
} // namespace vdi
#endif // KIOSK_SHELL_DESKTOP_LIST_PAGE_H

View File

@ -1,75 +0,0 @@
#include "desktop_service.h"
#include "desktop_store.h"
namespace vdi {
void DesktopService::login(const QString& username, const QString& password, AuthCallback callback) {
// 本地演示认证:账号与密码非空即通过。
// 正式版本替换为 /api/v1/terminal/session/login(SN+用户身份校验)。
AuthResult result;
if (username.trimmed().isEmpty() || password.isEmpty()) {
result.ok = false;
result.message = QStringLiteral("请输入用户名和密码");
} else {
result.ok = true;
result.displayName = username.trimmed();
currentUser_ = username.trimmed();
}
if (callback) {
callback(result);
}
}
void DesktopService::listDesktops(DesktopListCallback callback) {
// 桌面与连接参数来自本地连接配置(现场可配);
// 正式版本替换为 /api/v1/terminal/session/desktops(按用户绑定返回)。
std::vector<DesktopInfo> desktops = DesktopStore::load();
if (callback) {
callback(std::move(desktops), QString());
}
}
void DesktopService::createTicket(const QString& desktopId, TicketCallback callback) {
// 本地演示票据:由连接配置构造。
// 正式版本替换为 /api/v1/terminal/session/connect-ticket(短期一次性票据)。
ConnectionTicket ticket;
ticket.desktopId = desktopId;
QString error;
listDesktops([&ticket, &error, desktopId](std::vector<DesktopInfo> list, const QString& err) {
if (!err.isEmpty()) {
error = err;
return;
}
for (const DesktopInfo& d : list) {
if (d.id != desktopId) {
continue;
}
if (d.state != DesktopState::kAvailable) {
error = QStringLiteral("桌面 %1 尚未配置连接地址,请先在“设置”中完成配置")
.arg(d.name);
return;
}
ticket.endpoint = d.endpoint;
ticket.app = d.app;
ticket.width = d.width;
ticket.height = d.height;
ticket.fps = d.fps;
return;
}
if (error.isEmpty() && ticket.endpoint.isEmpty()) {
error = QStringLiteral("桌面不存在");
}
});
if (callback) {
callback(ticket, error);
}
}
void DesktopService::logout() {
currentUser_.clear();
}
} // namespace vdi

View File

@ -1,45 +0,0 @@
// 桌面服务适配层
//
// 职责:登录认证、桌面列表、连接票据。
// 当前为本地演示实现(账号非空即通过;Windows 桌面走 Moonlight,
// Ubuntu/麒麟未分配连接信息)。后续接入管理平台时替换/扩展本类为
// API 适配器(/api/v1/terminal/session/login|desktops|connect-ticket),
// 页面与状态机不感知数据来源变化。
#ifndef KIOSK_SHELL_DESKTOP_SERVICE_H
#define KIOSK_SHELL_DESKTOP_SERVICE_H
#include <QString>
#include <functional>
#include <vector>
#include "desktop_session.h"
namespace vdi {
class DesktopService {
public:
using AuthCallback = std::function<void(const AuthResult&)>;
using DesktopListCallback = std::function<void(std::vector<DesktopInfo>, const QString& error)>;
using TicketCallback = std::function<void(const ConnectionTicket&, const QString& error)>;
// 异步认证(本地实现立即回调;API 版本将走 HTTPS)
void login(const QString& username, const QString& password, AuthCallback callback);
// 拉取当前用户可用的桌面列表
void listDesktops(DesktopListCallback callback);
// 为指定桌面申请连接票据
void createTicket(const QString& desktopId, TicketCallback callback);
void logout();
QString currentUser() const { return currentUser_; }
private:
QString currentUser_;
};
} // namespace vdi
#endif // KIOSK_SHELL_DESKTOP_SERVICE_H

View File

@ -1,56 +0,0 @@
// 桌面与会话领域模型
//
// 产品流程:登录 -> 桌面列表 -> 选择桌面 -> 获取连接票据 -> 启动 Moonlight 会话
//
// 协议统一 Moonlight(RDP 渲染路径已移除,内嵌 RDP 代码保留在 git 历史作网关备用)。
// 当前阶段由本地 DesktopService 提供演示数据;后续接入管理平台后,
// 由 API 适配器返回同样的模型,页面与状态机不感知数据来源。
#ifndef KIOSK_SHELL_DESKTOP_SESSION_H
#define KIOSK_SHELL_DESKTOP_SESSION_H
#include <QString>
#include <vector>
namespace vdi {
// 桌面卡片的运行状态
enum class DesktopState {
kAvailable, // 可连接
kPending, // 任务进行中(开机/重置等)
kUnassigned, // 尚未配置连接地址,暂不可连接
};
struct DesktopInfo {
QString id;
QString name; // 显示名,如 "Windows 10"
QString os; // 操作系统描述
DesktopState state = DesktopState::kUnassigned;
// 连接参数(kUnassigned 时为空)
QString endpoint; // 主机地址(IP / 域名 / VPN 隧道地址)
QString app; // Sunshine 应用名
int width = 1920;
int height = 1080;
int fps = 60;
};
// 一次会话的连接凭据;当前为本地构造,后续由平台 connect-ticket 接口下发
struct ConnectionTicket {
QString desktopId;
QString endpoint;
QString app;
int width = 1920;
int height = 1080;
int fps = 60;
};
struct AuthResult {
bool ok = false;
QString message;
QString displayName; // 登录用户显示名
};
} // namespace vdi
#endif // KIOSK_SHELL_DESKTOP_SESSION_H

View File

@ -1,136 +0,0 @@
#include "desktop_settings_dialog.h"
#include <QComboBox>
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QVBoxLayout>
#include <cstdio>
#include "desktop_store.h"
namespace vdi {
DesktopSettingsDialog::DesktopSettingsDialog(std::vector<DesktopInfo> desktops,
const QString& selectId, QWidget* parent)
: QDialog(parent), desktops_(std::move(desktops)) {
setWindowTitle(QStringLiteral("桌面连接设置"));
setMinimumWidth(560);
// Kiosk 主窗口是全屏窗口,必须置顶否则对话框会被完全遮挡
setWindowFlags(windowFlags() | Qt::WindowStaysOnTopHint);
auto* root = new QVBoxLayout(this);
root->setSpacing(14);
auto* hint = new QLabel(
QStringLiteral("为每张桌面配置连接参数。地址支持 IP、域名或 VPN 隧道地址;"
"地址留空表示待分配,卡片将不可连接。"),
this);
hint->setWordWrap(true);
hint->setStyleSheet(QStringLiteral("color: #8a94a6;"));
root->addWidget(hint);
auto* form = new QFormLayout();
form->setHorizontalSpacing(14);
form->setVerticalSpacing(12);
comboDesktop_ = new QComboBox(this);
for (const DesktopInfo& d : desktops_) {
comboDesktop_->addItem(d.name, d.id);
}
form->addRow(QStringLiteral("桌面"), comboDesktop_);
// 会话通道统一 Moonlight,无协议选项(地址/应用名即连接参数)
editEndpoint_ = new QLineEdit(this);
editEndpoint_->setPlaceholderText(QStringLiteral("例如 192.168.1.70 或 VPN 隧道地址"));
form->addRow(QStringLiteral("服务器地址"), editEndpoint_);
editApp_ = new QLineEdit(this);
editApp_->setPlaceholderText(QStringLiteral("Sunshine 应用名,通常为 Desktop"));
form->addRow(QStringLiteral("应用名"), editApp_);
comboResolution_ = new QComboBox(this);
const char* resolutions[] = {"1920x1080", "2560x1440", "1920x1440", "3840x2160", "1280x720"};
for (const char* r : resolutions) {
comboResolution_->addItem(QString::fromLatin1(r), QString::fromLatin1(r));
}
form->addRow(QStringLiteral("分辨率"), comboResolution_);
comboFps_ = new QComboBox(this);
comboFps_->addItem(QStringLiteral("60"), 60);
comboFps_->addItem(QStringLiteral("30"), 30);
form->addRow(QStringLiteral("帧率"), comboFps_);
root->addLayout(form);
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
buttons->button(QDialogButtonBox::Ok)->setText(QStringLiteral("保存"));
buttons->button(QDialogButtonBox::Cancel)->setText(QStringLiteral("取消"));
root->addWidget(buttons);
connect(comboDesktop_, QOverload<int>::of(&QComboBox::currentIndexChanged), this,
&DesktopSettingsDialog::onDesktopChanged);
connect(buttons, &QDialogButtonBox::accepted, this, &DesktopSettingsDialog::onAccepted);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
// 预选指定桌面
int index = 0;
if (!selectId.isEmpty()) {
for (int i = 0; i < comboDesktop_->count(); ++i) {
if (comboDesktop_->itemData(i).toString() == selectId) {
index = i;
break;
}
}
}
comboDesktop_->setCurrentIndex(index);
loadCurrent();
}
void DesktopSettingsDialog::onDesktopChanged(int index) {
Q_UNUSED(index);
loadCurrent();
}
void DesktopSettingsDialog::loadCurrent() {
const QString id = comboDesktop_->currentData().toString();
for (const DesktopInfo& d : desktops_) {
if (d.id != id) {
continue;
}
loading_ = true;
editEndpoint_->setText(d.endpoint);
editApp_->setText(d.app);
int resIndex = comboResolution_->findData(
QStringLiteral("%1x%2").arg(d.width).arg(d.height));
comboResolution_->setCurrentIndex(resIndex >= 0 ? resIndex : 0);
int fpsIndex = comboFps_->findData(d.fps);
comboFps_->setCurrentIndex(fpsIndex >= 0 ? fpsIndex : 0);
loading_ = false;
break;
}
}
void DesktopSettingsDialog::onAccepted() {
const QString id = comboDesktop_->currentData().toString();
for (DesktopInfo& d : desktops_) {
if (d.id != id) {
continue;
}
d.endpoint = editEndpoint_->text().trimmed();
d.app = editApp_->text().trimmed();
const QString res = comboResolution_->currentData().toString();
sscanf(res.toLatin1().constData(), "%dx%d", &d.width, &d.height);
d.fps = comboFps_->currentData().toInt();
d.state = d.endpoint.isEmpty() ? DesktopState::kUnassigned : DesktopState::kAvailable;
DesktopStore::update(d);
break;
}
accept();
}
} // namespace vdi

View File

@ -1,51 +0,0 @@
// 桌面连接设置对话框
//
// 现场环境每套部署的桌面地址不同:选择一张桌面卡,
// 编辑地址(IP / 域名 / VPN 隧道地址)、应用名、分辨率和帧率,
// 保存后立即生效。会话通道统一 Moonlight,无协议选项。
// 地址为空 = 待分配,卡片不可连接。
#ifndef KIOSK_SHELL_DESKTOP_SETTINGS_DIALOG_H
#define KIOSK_SHELL_DESKTOP_SETTINGS_DIALOG_H
#include <QDialog>
#include <vector>
#include "desktop_session.h"
class QComboBox;
class QLineEdit;
namespace vdi {
class DesktopSettingsDialog : public QDialog {
Q_OBJECT
public:
explicit DesktopSettingsDialog(std::vector<DesktopInfo> desktops,
const QString& selectId = QString(),
QWidget* parent = nullptr);
// 保存后的桌面列表(供调用方刷新)
std::vector<DesktopInfo> desktops() const { return desktops_; }
private slots:
void onDesktopChanged(int index);
void onAccepted();
private:
void loadCurrent();
std::vector<DesktopInfo> desktops_;
QComboBox* comboDesktop_ = nullptr;
QLineEdit* editEndpoint_ = nullptr;
QLineEdit* editApp_ = nullptr;
QComboBox* comboResolution_ = nullptr;
QComboBox* comboFps_ = nullptr;
bool loading_ = false;
};
} // namespace vdi
#endif // KIOSK_SHELL_DESKTOP_SETTINGS_DIALOG_H

View File

@ -1,152 +0,0 @@
#include "desktop_store.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include "config.h"
namespace vdi {
namespace {
// 三张默认桌面卡:id/name/os 固定,连接参数由现场配置。
// endpoint 为空 = 待分配,配置地址后即变为可连接。
std::vector<DesktopInfo> defaultDesktops(const QString& migratedServer) {
std::vector<DesktopInfo> list;
DesktopInfo win;
win.id = QStringLiteral("desk-win10-01");
win.name = QStringLiteral("Windows 10");
win.os = QStringLiteral("Windows 桌面");
win.endpoint = migratedServer; // 兼容老盒子:迁移 client.conf 的 server
win.app = QStringLiteral("Desktop");
win.width = 1920;
win.height = 1080;
win.fps = 60;
list.push_back(win);
DesktopInfo ubuntu;
ubuntu.id = QStringLiteral("desk-ubuntu-01");
ubuntu.name = QStringLiteral("Ubuntu");
ubuntu.os = QStringLiteral("Ubuntu 桌面");
list.push_back(ubuntu);
DesktopInfo kylin;
kylin.id = QStringLiteral("desk-kylin-01");
kylin.name = QStringLiteral("麒麟");
kylin.os = QStringLiteral("银河麒麟桌面");
list.push_back(kylin);
return list;
}
DesktopInfo fromJson(const QJsonObject& obj) {
DesktopInfo d;
d.id = obj.value(QStringLiteral("id")).toString();
d.name = obj.value(QStringLiteral("name")).toString();
d.os = obj.value(QStringLiteral("os")).toString();
d.endpoint = obj.value(QStringLiteral("endpoint")).toString();
d.app = obj.value(QStringLiteral("app")).toString();
d.width = obj.value(QStringLiteral("width")).toInt(1920);
d.height = obj.value(QStringLiteral("height")).toInt(1080);
d.fps = obj.value(QStringLiteral("fps")).toInt(60);
d.state = d.endpoint.isEmpty() ? DesktopState::kUnassigned : DesktopState::kAvailable;
return d;
}
QJsonObject toJson(const DesktopInfo& d) {
QJsonObject obj;
obj.insert(QStringLiteral("id"), d.id);
obj.insert(QStringLiteral("name"), d.name);
obj.insert(QStringLiteral("os"), d.os);
obj.insert(QStringLiteral("endpoint"), d.endpoint);
obj.insert(QStringLiteral("app"), d.app);
obj.insert(QStringLiteral("width"), d.width);
obj.insert(QStringLiteral("height"), d.height);
obj.insert(QStringLiteral("fps"), d.fps);
return obj;
}
} // namespace
QString DesktopStore::storePath() {
return QString::fromStdString(configDir()) + QStringLiteral("/desktops.json");
}
std::vector<DesktopInfo> DesktopStore::load() {
std::vector<DesktopInfo> result;
QFile file(storePath());
if (file.exists() && file.open(QIODevice::ReadOnly)) {
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
const QJsonArray arr = doc.object().value(QStringLiteral("desktops")).toArray();
for (const QJsonValue& v : arr) {
DesktopInfo d = fromJson(v.toObject());
if (!d.id.isEmpty()) {
result.push_back(d);
}
}
}
// 确保三张默认卡始终存在(新环境骨架 / 防止手工编辑删卡)。
// 兼容老盒子:client.conf 里只要配过 server,就迁移为 Windows 桌面地址。
const TerminalConfig cfg = loadConfig();
const QString migrated = result.empty() && !cfg.server.empty()
? QString::fromStdString(cfg.server)
: QString();
const std::vector<DesktopInfo> defaults = defaultDesktops(migrated);
for (const DesktopInfo& def : defaults) {
bool found = false;
for (const DesktopInfo& d : result) {
if (d.id == def.id) {
found = true;
break;
}
}
if (!found) {
result.push_back(def);
}
}
// 统一按 endpoint 推导状态:有地址=可连接,无地址=待分配
for (DesktopInfo& d : result) {
d.state = d.endpoint.isEmpty() ? DesktopState::kUnassigned : DesktopState::kAvailable;
}
return result;
}
bool DesktopStore::save(const std::vector<DesktopInfo>& desktops) {
QJsonArray arr;
for (const DesktopInfo& d : desktops) {
arr.append(toJson(d));
}
QJsonObject root;
root.insert(QStringLiteral("version"), 1);
root.insert(QStringLiteral("desktops"), arr);
const QString path = storePath();
QDir().mkpath(QFileInfo(path).absolutePath());
QFile file(path);
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
return false;
}
file.write(QJsonDocument(root).toJson(QJsonDocument::Indented));
return true;
}
bool DesktopStore::update(const DesktopInfo& info) {
std::vector<DesktopInfo> list = load();
for (DesktopInfo& d : list) {
if (d.id == info.id) {
d = info;
d.state = d.endpoint.isEmpty() ? DesktopState::kUnassigned : DesktopState::kAvailable;
return save(list);
}
}
return false;
}
} // namespace vdi

View File

@ -1,37 +0,0 @@
// 桌面连接配置持久化(JSON)
//
// 每个客户部署环境的桌面地址不同,连接参数不能写死在代码里。
// 当前阶段由本地文件维护(现场通过"桌面连接设置"界面配置);
// 后续接入管理平台后,本类改为平台下发配置的本地缓存,接口不变。
//
// 文件:~/.config/vdi-terminal/desktops.json
// 兼容:文件不存在时生成默认骨架;老盒子从 client.conf 的 server 迁移 Windows 地址。
#ifndef KIOSK_SHELL_DESKTOP_STORE_H
#define KIOSK_SHELL_DESKTOP_STORE_H
#include <QString>
#include <vector>
#include "desktop_session.h"
namespace vdi {
class DesktopStore {
public:
static QString storePath();
// 读取桌面列表;确保三张默认卡(Windows/Ubuntu/麒麟)始终存在。
// state 由 endpoint 是否为空推导:有地址=可连接,无地址=待分配。
static std::vector<DesktopInfo> load();
// 保存完整桌面列表
static bool save(const std::vector<DesktopInfo>& desktops);
// 更新单张桌面(按 id 匹配),保存成功返回 true
static bool update(const DesktopInfo& info);
};
} // namespace vdi
#endif // KIOSK_SHELL_DESKTOP_STORE_H

View File

@ -1,6 +1,7 @@
#include "login_page.h"
#include <QFile>
#include <QCheckBox>
#include <QComboBox>
#include <QFont>
#include <QGridLayout>
#include <QHBoxLayout>
@ -8,75 +9,35 @@
#include <QLineEdit>
#include <QProcess>
#include <QPushButton>
#include <QRegularExpression>
#include <QTextStream>
#include <QVBoxLayout>
namespace vdi {
namespace {
QString defaultRouteInterface();
QString interfaceIPv4(const QString& dev);
const char* kResolutionIds[] = {"1920x1440", "2560x1440", "1920x1080", "1280x720"};
QString detectNetworkSummary() {
// 取默认路由的出接口与该接口上的 IPv4 地址
// 用 QFile 读 /proc,避免 shell 引号在不同终端下的转义问题
const QString dev = defaultRouteInterface();
if (dev.isEmpty()) {
QProcess process;
process.start("sh", QStringList() << "-c" <<
"dev=$(ip -4 route show default | awk '{print $NF}' | head -n 1); "
"addr=$(ip -4 -o addr show dev \"$dev\" | awk '{print $4}' | head -n 1); "
"echo \"$dev $addr\"");
process.waitForFinished(2000);
const QString out = QString::fromUtf8(process.readAllStandardOutput()).trimmed();
if (out.isEmpty()) {
return QStringLiteral("网络:未连接");
}
const QString addr = interfaceIPv4(dev);
if (addr.isEmpty()) {
return QStringLiteral("网络:%1").arg(dev);
const QStringList parts = out.split(' ', Qt::SkipEmptyParts);
QString iface = parts.value(0);
QString addr = parts.value(1);
if (addr.contains('/')) {
addr = addr.split('/').value(0);
}
return QStringLiteral("网络:%1 (%2)").arg(dev, addr);
}
// 解析 /proc/net/route,找到默认路由(Destination 为 0.0.0.0)的出接口
QString defaultRouteInterface() {
QFile file(QStringLiteral("/proc/net/route"));
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
return QString();
if (iface.isEmpty()) {
return QStringLiteral("网络:未连接");
}
QTextStream stream(&file);
QString header = stream.readLine();
while (!stream.atEnd()) {
const QString line = stream.readLine();
const QStringList parts = line.split(QRegularExpression(QStringLiteral("\\s+")),
Qt::SkipEmptyParts);
// Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT
if (parts.size() < 4) {
continue;
}
if (parts[1] == QStringLiteral("00000000")) {
return parts[0];
}
}
return QString();
}
// 用 ip 命令取该接口的 IPv4
QString interfaceIPv4(const QString& dev) {
QProcess process;
process.start(QStringLiteral("ip"),
QStringList() << QStringLiteral("-4") << QStringLiteral("-o") << QStringLiteral("addr")
<< QStringLiteral("show") << QStringLiteral("dev") << dev);
if (!process.waitForFinished(2000)) {
return QString();
}
const QString out = QString::fromUtf8(process.readAllStandardOutput());
for (const QString& line : out.split('\n')) {
const QStringList parts = line.split(QRegularExpression(QStringLiteral("\\s+")),
Qt::SkipEmptyParts);
if (parts.size() >= 4 && parts[0] == dev) {
QString addr = parts[3];
if (addr.contains('/')) {
addr = addr.split('/').value(0);
}
return addr;
}
}
return QString();
return QStringLiteral("网络:%1 (%2)").arg(iface, addr);
}
} // namespace
@ -96,7 +57,7 @@ LoginPage::LoginPage(const TerminalConfig& initial, QWidget* parent)
title->setAlignment(Qt::AlignCenter);
root->addWidget(title);
auto* subtitle = new QLabel(QStringLiteral("登录后进入您的云桌面"), this);
auto* subtitle = new QLabel(QStringLiteral("RK3528 远程桌面接入"), this);
QFont subFont = subtitle->font();
subFont.setPointSize(13);
subtitle->setFont(subFont);
@ -115,25 +76,39 @@ LoginPage::LoginPage(const TerminalConfig& initial, QWidget* parent)
infoBox->addWidget(labelNetwork_);
root->addLayout(infoBox);
// ---- 登录表单 ----
// ---- 表单 ----
auto* grid = new QGridLayout();
grid->setHorizontalSpacing(14);
grid->setVerticalSpacing(12);
grid->setColumnStretch(1, 1);
editUser_ = new QLineEdit(this);
editUser_->setPlaceholderText(QStringLiteral("用户名"));
editPassword_ = new QLineEdit(this);
editServer_ = new QLineEdit(QString::fromStdString(cfg_.server), this);
editServer_->setPlaceholderText(QStringLiteral("服务器地址,例如 192.168.1.70"));
editUser_ = new QLineEdit(QString::fromStdString(cfg_.username), this);
editUser_->setPlaceholderText(QStringLiteral("登录账号"));
editPassword_ = new QLineEdit(QString::fromStdString(cfg_.password), this);
editPassword_->setEchoMode(QLineEdit::Password);
editPassword_->setPlaceholderText(QStringLiteral("密码"));
editPassword_->setPlaceholderText(QStringLiteral("登录密码(可留空)"));
comboResolution_ = new QComboBox(this);
for (const char* id : kResolutionIds) {
comboResolution_->addItem(QString::fromLatin1(id), QString::fromLatin1(id));
}
int resIndex = comboResolution_->findData(QString::fromStdString(cfg_.resolution));
comboResolution_->setCurrentIndex(resIndex >= 0 ? resIndex : 0);
checkRemember_ = new QCheckBox(QStringLiteral("记住连接参数"), this);
checkRemember_->setChecked(cfg_.remember);
struct Row {
QString label;
QWidget* widget;
};
const Row rows[] = {
{QStringLiteral("用户名"), editUser_},
{QStringLiteral("密码"), editPassword_},
{QStringLiteral("服务器地址"), editServer_},
{QStringLiteral("登录账号"), editUser_},
{QStringLiteral("登录密码"), editPassword_},
{QStringLiteral("显示分辨率"), comboResolution_},
};
int row = 0;
for (const Row& r : rows) {
@ -143,73 +118,84 @@ LoginPage::LoginPage(const TerminalConfig& initial, QWidget* parent)
grid->addWidget(r.widget, row, 1);
++row;
}
grid->addWidget(checkRemember_, row, 1);
root->addLayout(grid);
// ---- 按钮 ----
// 登录页不提供"退出"按钮:点击退出会被看门狗拉起,属于维护功能,
// 只保留在管理菜单(Ctrl+Alt+Shift+M -> 退出终端)
auto* buttonRow = new QHBoxLayout();
buttonRow->addStretch(1);
buttonLogin_ = new QPushButton(QStringLiteral("登 录"), this);
buttonLogin_->setMinimumSize(160, 46);
buttonLogin_->setStyleSheet(
buttonConnect_ = new QPushButton(QStringLiteral("连接"), this);
buttonConnect_->setMinimumSize(160, 46);
buttonConnect_->setStyleSheet(
QStringLiteral("QPushButton { background: #2f6feb; color: white; border: none; "
"border-radius: 6px; font-weight: bold; }"
"QPushButton:disabled { background: #3a4a63; }"));
buttonLogin_->setDefault(true);
buttonRow->addWidget(buttonLogin_);
buttonConnect_->setDefault(true);
buttonQuit_ = new QPushButton(QStringLiteral("退出"), this);
buttonQuit_->setMinimumSize(110, 46);
buttonRow->addWidget(buttonConnect_);
buttonRow->addWidget(buttonQuit_);
buttonRow->addStretch(1);
root->addLayout(buttonRow);
// ---- 状态条 ----
labelStatus_ = new QLabel(QStringLiteral("请输入用户名和密码登录"), this);
labelStatus_ = new QLabel(QStringLiteral("就绪"), this);
labelStatus_->setAlignment(Qt::AlignCenter);
labelStatus_->setStyleSheet(QStringLiteral("color: #8a94a6;"));
root->addWidget(labelStatus_);
root->addStretch(1);
connect(buttonLogin_, &QPushButton::clicked, this, &LoginPage::onLoginClicked);
// 密码框回车直接登录
connect(editPassword_, &QLineEdit::returnPressed, this, &LoginPage::onLoginClicked);
connect(editUser_, &QLineEdit::returnPressed, this, &LoginPage::onLoginClicked);
connect(buttonConnect_, &QPushButton::clicked, this, &LoginPage::onConnectClicked);
connect(buttonQuit_, &QPushButton::clicked, this, [this]() { emit quitRequested(); });
}
void LoginPage::setLoginHandler(LoginHandler handler) {
loginHandler_ = std::move(handler);
void LoginPage::setConnectHandler(ConnectHandler handler) {
connectHandler_ = std::move(handler);
}
void LoginPage::setStatus(const QString& text, bool error) {
labelStatus_->setText(text);
labelStatus_->setStyleSheet(
QStringLiteral("color: %1;").arg(error ? QStringLiteral("#e05561")
: QStringLiteral("#8a94a6")));
: QStringLiteral("#8a94a6")));
}
void LoginPage::setBusy(bool busy) {
buttonLogin_->setEnabled(!busy);
buttonLogin_->setText(busy ? QStringLiteral("登录中…") : QStringLiteral("登 录"));
buttonConnect_->setEnabled(!busy);
buttonConnect_->setText(busy ? QStringLiteral("连接中…") : QStringLiteral("连接"));
editServer_->setEnabled(!busy);
editUser_->setEnabled(!busy);
editPassword_->setEnabled(!busy);
comboResolution_->setEnabled(!busy);
}
TerminalConfig LoginPage::deviceConfig() const {
return cfg_;
TerminalConfig LoginPage::currentConfig() const {
TerminalConfig out = cfg_;
out.server = editServer_->text().trimmed().toStdString();
out.username = editUser_->text().trimmed().toStdString();
out.password = editPassword_->text().toStdString();
out.resolution = comboResolution_->currentData().toString().toStdString();
out.remember = checkRemember_->isChecked();
return out;
}
void LoginPage::onLoginClicked() {
const QString username = editUser_->text().trimmed();
const QString password = editPassword_->text();
if (username.isEmpty() || password.isEmpty()) {
setStatus(QStringLiteral("请输入用户名和密码"), true);
void LoginPage::onConnectClicked() {
TerminalConfig cfg = currentConfig();
if (cfg.server.empty()) {
setStatus(QStringLiteral("请填写服务器地址"), true);
return;
}
setBusy(true);
setStatus(QStringLiteral("正在认证…"));
setStatus(QStringLiteral("正在连接 %1 …").arg(QString::fromStdString(cfg.server)));
if (loginHandler_) {
loginHandler_(username, password);
if (cfg.remember) {
saveConfig(cfg);
}
emit loginRequested(username, password);
if (connectHandler_) {
connectHandler_(cfg);
}
emit connectRequested(cfg);
}
} // namespace vdi

View File

@ -1,8 +1,8 @@
// 登录页
// 首配 / 登录页
//
// 产品流程第一步:设备 SN/网络展示 + 用户认证。
// 当前为本地演示认证(账号密码非空通过),由 DesktopService 承担;
// 页面只负责输入采集与状态展示,不直接连接云桌面。
// 对应《VDI 云桌面软件开发方案》3.2 节:
// 首配页(无配置时进入)——SN 绑定、WiFi 扫码、服务器地址
// 状态条——网络 / 会话状态
#ifndef KIOSK_SHELL_LOGIN_PAGE_H
#define KIOSK_SHELL_LOGIN_PAGE_H
@ -13,6 +13,8 @@
#include "config.h"
class QLineEdit;
class QComboBox;
class QCheckBox;
class QLabel;
class QPushButton;
class QGridLayout;
@ -24,36 +26,42 @@ class LoginPage : public QWidget {
Q_OBJECT
public:
// 认证回调:由 MainWindow 接到 DesktopService
using LoginHandler = std::function<void(const QString& username, const QString& password)>;
using ConnectHandler = std::function<void(const TerminalConfig&)>;
explicit LoginPage(const TerminalConfig& initial, QWidget* parent = nullptr);
void setLoginHandler(LoginHandler handler);
void setConnectHandler(ConnectHandler handler);
void setStatus(const QString& text, bool error = false);
void setBusy(bool busy);
// 保留设备首配信息(SN/服务器地址),登录不再承载 Windows 直连参数
TerminalConfig deviceConfig() const;
// 供外部回填
TerminalConfig currentConfig() const;
signals:
void loginRequested(const QString& username, const QString& password);
void connectRequested(const vdi::TerminalConfig& cfg);
void quitRequested();
private slots:
void onLoginClicked();
void onConnectClicked();
private:
TerminalConfig cfg_;
LoginHandler loginHandler_;
ConnectHandler connectHandler_;
QLineEdit* editServer_ = nullptr;
QLineEdit* editUser_ = nullptr;
QLineEdit* editPassword_ = nullptr;
QComboBox* comboResolution_ = nullptr;
QCheckBox* checkRemember_ = nullptr;
QLabel* labelStatus_ = nullptr;
QLabel* labelSn_ = nullptr;
QLabel* labelNetwork_ = nullptr;
QPushButton* buttonLogin_ = nullptr;
QPushButton* buttonConnect_ = nullptr;
QPushButton* buttonQuit_ = nullptr;
};
} // namespace vdi
Q_DECLARE_METATYPE(vdi::TerminalConfig)
#endif // KIOSK_SHELL_LOGIN_PAGE_H

View File

@ -1,26 +1,69 @@
#include "main_window.h"
#include <QAction>
#include <QApplication>
#include <QDateTime>
#include <QDebug>
#include <QFile>
#include <QCloseEvent>
#include <QKeySequence>
#include <QLabel>
#include <QMenu>
#include <QMessageBox>
#include <QScreen>
#include <QShortcut>
#include <QStackedWidget>
#include <QStatusBar>
#include <QTimer>
#include <QVBoxLayout>
#include <freerdp/codec/color.h>
#include "desktop_list_page.h"
#include "desktop_settings_dialog.h"
#include "login_page.h"
#include "transition_overlay.h"
namespace vdi {
namespace {
constexpr int kTicketTimeoutMs = 10000;
constexpr int kProcessStartTimeoutMs = 5000;
constexpr int kStreamReadyTimeoutMs = 30000;
// FreeRDP 像素格式 -> QImage::Format
// 注意:FreeRDP 的像素格式值是计算宏(FREERDP_PIXEL_FORMAT),
// 不能硬编码数值,必须用宏直接比较。
// FreeRDP 主缓冲是 4 字节/像素(BGRX / BGRA / RGBA),
// QImage 没有对应的 4 字节 BGRA 格式,因此统一转成 RGB888 输出。
QImage::Format toQImageFormat(uint32_t freerdpFormat) {
(void)freerdpFormat;
return QImage::Format_RGB888;
}
// 把 FreeRDP 的 4 字节/像素缓冲转成 QImage 可用的 RGB888
// bytesPerLine 是 FreeRDP 的每行字节数(width*4)
QImage convertFrame(const uint8_t* data, uint32_t width, uint32_t height, uint32_t bytesPerLine,
uint32_t freerdpFormat) {
if (!data || width == 0 || height == 0) {
return QImage();
}
const bool bgr = (freerdpFormat == PIXEL_FORMAT_BGRX32) ||
(freerdpFormat == PIXEL_FORMAT_BGRA32);
QImage out(static_cast<int>(width), static_cast<int>(height), QImage::Format_RGB888);
for (uint32_t y = 0; y < height; ++y) {
const uint8_t* src = data + static_cast<size_t>(y) * bytesPerLine;
uint8_t* dst = out.scanLine(static_cast<int>(y));
for (uint32_t x = 0; x < width; ++x) {
const uint8_t* p = src + static_cast<size_t>(x) * 4;
if (bgr) {
dst[0] = p[2];
dst[1] = p[1];
dst[2] = p[0];
} else {
dst[0] = p[0];
dst[1] = p[1];
dst[2] = p[2];
}
dst += 3;
}
}
return out;
}
constexpr int kReconnectDelayMs = 3000;
constexpr int kMaxReconnectAttempts = 5;
} // namespace
@ -30,23 +73,15 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
});
setupUi();
setupHotkey();
setupHeartbeat();
// BOOT -> CONFIG
config_ = loadConfig();
fsm_.transitionTo(State::kConfig, QStringLiteral("就绪"));
const QString deviceUser = QString::fromStdString(config_.sn).trimmed();
desktopService_.login(deviceUser.isEmpty() ? QStringLiteral("terminal") : deviceUser,
QStringLiteral("kiosk"), [this](const AuthResult& result) {
if (result.ok) {
currentUser_ = result.displayName;
}
fsm_.transitionTo(State::kAuthenticating, QStringLiteral("自动登录"));
fsm_.transitionTo(State::kDesktopList, QStringLiteral("请选择要进入的云桌面"));
});
fsm_.transitionTo(State::kConfig, "启动完成");
showLogin();
}
MainWindow::~MainWindow() {
stopSessions();
stopSession();
}
void MainWindow::setupUi() {
@ -58,6 +93,8 @@ void MainWindow::setupUi() {
stack_->setStyleSheet(QStringLiteral("background: #0f1420;"));
setCentralWidget(stack_);
// 登录页
config_ = loadConfig();
auto* login = new LoginPage(config_, this);
loginPage_ = login;
login->setStyleSheet(
@ -65,83 +102,62 @@ void MainWindow::setupUi() {
"QLineEdit { background: #1a2233; border: 1px solid #2b3654; "
"border-radius: 4px; padding: 8px; color: #e6e9ef; }"
"QLineEdit:focus { border: 1px solid #2f6feb; }"
"QComboBox { background: #1a2233; border: 1px solid #2b3654; "
"border-radius: 4px; padding: 8px; color: #e6e9ef; }"
"QPushButton { background: #232d44; border: 1px solid #2b3654; "
"border-radius: 6px; color: #e6e9ef; }"
"QPushButton:hover { background: #2b3654; }"));
"QPushButton:hover { background: #2b3654; }"
"QCheckBox { color: #8a94a6; }"));
stack_->addWidget(login);
connect(login, &LoginPage::loginRequested, this, &MainWindow::onLoginRequested);
auto* list = new DesktopListPage(this);
desktopListPage_ = list;
list->setStyleSheet(
QStringLiteral("QWidget { background: #0f1420; color: #e6e9ef; }"
"QPushButton { background: #232d44; border: 1px solid #2b3654; "
"border-radius: 6px; color: #e6e9ef; }"
"QPushButton:hover { background: #2b3654; }"));
stack_->addWidget(list);
connect(list, &DesktopListPage::desktopSelected, this, &MainWindow::onDesktopSelected);
connect(list, &DesktopListPage::editRequested, this, &MainWindow::onEditDesktopRequested);
connect(login, &LoginPage::connectRequested, this, &MainWindow::onConnectRequested);
connect(login, &LoginPage::quitRequested, this, &MainWindow::onQuitRequested);
overlay_ = new TransitionOverlay(this);
// 会话视图
auto* sessionWidget = new QWidget(this);
auto* sessionLayout = new QVBoxLayout(sessionWidget);
sessionLayout->setContentsMargins(0, 0, 0, 0);
sessionView_ = new QLabel(sessionWidget);
sessionView_->setAlignment(Qt::AlignCenter);
sessionView_->setStyleSheet(QStringLiteral("background: #000000; color: #e6e9ef;"));
sessionView_->setText(QStringLiteral("正在建立远程会话…"));
sessionOverlay_ = new QLabel(sessionWidget);
sessionOverlay_->setAlignment(Qt::AlignCenter);
sessionOverlay_->setStyleSheet(
QStringLiteral("background: rgba(15,20,32,0.82); color: #e6e9ef; "
"font-size: 20px; padding: 40px;"));
sessionOverlay_->setText(QStringLiteral("正在连接…"));
sessionOverlay_->hide();
sessionLayout->addWidget(sessionView_);
sessionLayout->addWidget(sessionOverlay_);
stack_->addWidget(sessionWidget);
// 状态条
statusBar_ = new QStatusBar(this);
statusBar_->setStyleSheet(QStringLiteral("QStatusBar { background: #161d2e; color: #8a94a6; }"));
setStatusBar(statusBar_);
connectionTimer_ = new QTimer(this);
connectionTimer_->setSingleShot(true);
connect(connectionTimer_, &QTimer::timeout, this, [this]() {
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;
// 重连定时器
reconnectTimer_ = new QTimer(this);
reconnectTimer_->setSingleShot(true);
connect(reconnectTimer_, &QTimer::timeout, this, [this]() {
if (fsm_.current() == State::kReconnect && reconnectAttempts_ < kMaxReconnectAttempts) {
++reconnectAttempts_;
startSession(config_);
} else if (fsm_.current() == State::kReconnect) {
fsm_.transitionTo(State::kError, "重连失败,请检查网络");
}
failConnection(connectionAttempt_, message);
});
moonlightSession_ = std::make_unique<MoonlightSession>();
}
void MainWindow::setupHotkey() {
auto* menuShortcut = new QShortcut(QKeySequence(QStringLiteral("Ctrl+Alt+Shift+M")), this);
menuShortcut->setContext(Qt::ApplicationShortcut);
connect(menuShortcut, &QShortcut::activated, this, [this]() { onMenuAction(0); });
auto* quitShortcut = new QShortcut(QKeySequence(QStringLiteral("Ctrl+Alt+Shift+Q")), this);
quitShortcut->setContext(Qt::ApplicationShortcut);
connect(quitShortcut, &QShortcut::activated, this, [this]() {
if (fsm_.isIn(State::kConnecting) || fsm_.isIn(State::kSession) ||
fsm_.isIn(State::kReconnect)) {
stopSessions();
fsm_.transitionTo(State::kDesktopList, QStringLiteral("已退出串流"));
if (desktopListPage_) {
desktopListPage_->setBusy(false);
desktopListPage_->setStatus(QStringLiteral("已退出串流"));
}
}
});
}
void MainWindow::setupHeartbeat() {
const QString path = QStringLiteral("/tmp/vdi-kiosk.heartbeat");
heartbeatTimer_ = new QTimer(this);
heartbeatTimer_->setInterval(5000);
connect(heartbeatTimer_, &QTimer::timeout, this, [path]() {
QFile file(path);
if (file.open(QIODevice::WriteOnly | QIODevice::Text)) {
file.write(QByteArray::number(QDateTime::currentSecsSinceEpoch()));
}
});
heartbeatTimer_->start();
// 全局热键 Ctrl+Alt+Shift+M 呼出管理菜单(方案 3.2 节)
auto* shortcut = new QShortcut(QKeySequence(QStringLiteral("Ctrl+Alt+Shift+M")), this);
shortcut->setContext(Qt::ApplicationShortcut);
connect(shortcut, &QShortcut::activated, this, [this]() { onMenuAction(0); });
}
void MainWindow::onStateChanged(State from, State to, const QString& detail) {
@ -153,24 +169,28 @@ void MainWindow::onStateChanged(State from, State to, const QString& detail) {
case State::kConfig:
showLogin();
break;
case State::kAuthenticating:
break;
case State::kDesktopList:
showDesktopList();
refreshDesktopList();
break;
case State::kConnecting:
overlay_->start(QStringLiteral("正在进入云桌面"));
if (stack_->currentWidget() != sessionView_->parentWidget()) {
stack_->setCurrentWidget(sessionView_->parentWidget());
}
sessionOverlay_->setText(QStringLiteral("正在连接 %1 …")
.arg(QString::fromStdString(config_.server)));
sessionOverlay_->show();
break;
case State::kSession:
sessionOverlay_->hide();
break;
case State::kReconnect:
sessionOverlay_->setText(QStringLiteral("连接已断开,正在重连…"));
sessionOverlay_->show();
reconnectTimer_->start(kReconnectDelayMs);
break;
case State::kError:
stopSessions();
fsm_.transitionTo(State::kDesktopList, QStringLiteral("连接失败"));
if (desktopListPage_) {
desktopListPage_->setBusy(false);
desktopListPage_->setStatus(QStringLiteral("连接失败:%1").arg(detail), true);
sessionOverlay_->setText(QStringLiteral("连接失败:%1").arg(detail));
sessionOverlay_->show();
if (loginPage_) {
loginPage_->setBusy(false);
loginPage_->setStatus(QStringLiteral("连接失败:%1").arg(detail), true);
}
break;
case State::kBoot:
@ -186,289 +206,136 @@ void MainWindow::showLogin() {
stack_->setCurrentWidget(loginPage_);
if (loginPage_) {
loginPage_->setBusy(false);
loginPage_->setStatus(QStringLiteral("请输入用户名和密码登录"));
loginPage_->setStatus(QStringLiteral("就绪"));
}
}
void MainWindow::showDesktopList() {
if (!desktopListPage_) {
return;
}
stack_->setCurrentWidget(desktopListPage_);
desktopListPage_->setCurrentUser(currentUser_);
desktopListPage_->setBusy(false);
void MainWindow::onConnectRequested(const vdi::TerminalConfig& cfg) {
config_ = cfg;
reconnectAttempts_ = 0;
startSession(cfg);
}
void MainWindow::refreshDesktopList() {
desktopService_.listDesktops([this](std::vector<DesktopInfo> list, const QString& error) {
if (error.isEmpty()) {
desktops_ = list;
}
if (desktopListPage_) {
desktopListPage_->setDesktops(desktops_);
if (!error.isEmpty()) {
desktopListPage_->setStatus(error, true);
}
}
});
}
void MainWindow::onLoginRequested(const QString& username, const QString& password) {
desktopService_.login(username, password, [this](const AuthResult& result) {
if (!loginPage_) {
void MainWindow::startSession(const TerminalConfig& cfg) {
if (!fsm_.transitionTo(State::kConnecting, QString::fromStdString(cfg.server))) {
// 已经在 CONNECTING 或 SESSION,忽略重复触发
if (fsm_.current() != State::kConnecting) {
return;
}
if (!result.ok) {
loginPage_->setBusy(false);
loginPage_->setStatus(result.message, true);
return;
}
currentUser_ = result.displayName;
fsm_.transitionTo(State::kAuthenticating, QStringLiteral("认证成功"));
fsm_.transitionTo(State::kDesktopList, QStringLiteral("欢迎 %1").arg(currentUser_));
});
}
void MainWindow::onDesktopSelected(const QString& desktopId) {
if (!fsm_.canConnect()) {
if (desktopListPage_) {
desktopListPage_->setBusy(false);
}
return;
}
const quint64 attempt = ++connectionAttempt_;
connectingDesktopId_ = desktopId;
connectionClock_.restart();
phaseClock_.restart();
connectionPhase_ = ConnectionPhase::kTicket;
logConnectionPhase(attempt, QStringLiteral("ticket"), QStringLiteral("started"));
RdpOptions opts;
opts.server = cfg.server;
opts.username = cfg.username;
opts.password = cfg.password;
if (!fsm_.transitionTo(State::kConnecting, QStringLiteral("正在获取连接信息"))) {
failConnection(attempt, QStringLiteral("当前状态无法发起连接"));
return;
// 解析分辨率
int w = 1920;
int h = 1440;
if (sscanf(cfg.resolution.c_str(), "%dx%d", &w, &h) != 2) {
w = 1920;
h = 1440;
}
armConnectionTimeout(attempt, ConnectionPhase::kTicket, kTicketTimeoutMs);
opts.width = w;
opts.height = h;
opts.bpp = 32;
opts.fullscreen = true;
// 与 xfreerdp3 相同的 GFX 能力组合语义:
// FreeRDP 3.32.1 不接受单个管道名,必须成组。
opts.gfx = "progressive|RFX|AVC420|AVC444";
opts.ignoreCertificate = true; // POC 阶段;正式产品必须做证书校验
desktopService_.createTicket(desktopId, [this, attempt](const ConnectionTicket& ticket,
const QString& error) {
if (attempt != connectionAttempt_ || connectionPhase_ != ConnectionPhase::kTicket) {
return;
}
connectionTimer_->stop();
if (!error.isEmpty()) {
failConnection(attempt, error);
return;
}
logConnectionPhase(attempt, QStringLiteral("ticket"), QStringLiteral("ready"));
activeTicket_ = ticket;
if (!startMoonlightSession(ticket, attempt)) {
failConnection(attempt, QStringLiteral("已有串流进程正在运行"));
}
});
}
void MainWindow::onEditDesktopRequested(const QString& desktopId) {
DesktopSettingsDialog dialog(desktops_, desktopId, this);
dialog.setStyleSheet(
QStringLiteral("QWidget { background: #0f1420; color: #e6e9ef; }"
"QLineEdit { background: #1a2233; border: 1px solid #2b3654; "
"border-radius: 4px; padding: 8px; color: #e6e9ef; }"
"QComboBox { background: #1a2233; border: 1px solid #2b3654; "
"border-radius: 4px; padding: 8px; color: #e6e9ef; }"
"QPushButton { background: #232d44; border: 1px solid #2b3654; "
"border-radius: 6px; color: #e6e9ef; }"
"QLabel { color: #e6e9ef; }"));
if (dialog.exec() == QDialog::Accepted) {
desktops_ = dialog.desktops();
if (desktopListPage_) {
desktopListPage_->setDesktops(desktops_);
desktopListPage_->setStatus(QStringLiteral("连接配置已保存"));
}
}
}
bool MainWindow::startMoonlightSession(const ConnectionTicket& ticket, quint64 attempt) {
MoonlightSession::Options opts;
opts.decoder = QStringLiteral("software");
opts.videoCodec = QStringLiteral("H.264");
opts.audioOnHost = true;
moonlightSession_->setOptions(opts);
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) {
RdpCallbacks cb;
cb.onFrame = [this](const uint8_t* data, uint32_t width, uint32_t height, uint32_t stride,
uint32_t format) {
// 帧数据来自 FreeRDP 线程,切换到 GUI 线程更新
QMetaObject::invokeMethod(
this, [this, attempt, event]() { onMoonlightEvent(attempt, event); },
this, [this, data, width, height, stride, format]() {
onFrame(data, width, height, stride, format);
},
Qt::QueuedConnection);
});
};
cb.onState = [this](bool connected, const std::string& message) {
QMetaObject::invokeMethod(
this,
[this, connected, message]() {
onSessionState(connected, QString::fromStdString(message));
},
Qt::QueuedConnection);
};
stopSession();
session_ = std::make_unique<RdpSession>();
session_->start(opts, cb);
}
void MainWindow::onMoonlightEvent(quint64 attempt, const MoonlightSession::Event& event) {
if (attempt != connectionAttempt_) {
return;
void MainWindow::stopSession() {
if (session_) {
session_->stop();
session_.reset();
}
}
switch (event.type) {
case MoonlightSession::EventType::kStarted:
if (connectionPhase_ != ConnectionPhase::kProcessStart) {
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::onSessionState(bool connected, const QString& message) {
if (connected) {
reconnectAttempts_ = 0;
fsm_.transitionTo(State::kSession, message);
} else {
if (fsm_.current() == State::kConnecting) {
// 首次连接失败
fsm_.transitionTo(State::kError, message);
} else if (fsm_.current() == State::kSession) {
fsm_.transitionTo(State::kReconnect, message);
}
}
}
void MainWindow::waitForMoonlightWindow(quint64 attempt) {
if (attempt != connectionAttempt_ || connectionPhase_ != ConnectionPhase::kStreamReady) {
void MainWindow::onFrame(const uint8_t* data, uint32_t width, uint32_t height, uint32_t stride,
uint32_t format) {
if (!data || width == 0 || height == 0) {
return;
}
if (!moonlightSession_->activateStreamWindow()) {
QTimer::singleShot(100, this, [this, attempt]() { waitForMoonlightWindow(attempt); });
// FreeRDP 主缓冲是 4 字节/像素,需转换后交给 Qt
QImage image = convertFrame(data, width, height, stride, format);
if (image.isNull()) {
return;
}
lastFrame_ = image;
frameDirty_ = true;
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;
if (sessionView_) {
sessionView_->setPixmap(QPixmap::fromImage(lastFrame_).scaled(
sessionView_->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation));
}
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_) {
desktopListPage_->setBusy(false);
desktopListPage_->setStatus(QStringLiteral("连接失败:%1").arg(message), true);
}
}
void MainWindow::armConnectionTimeout(quint64 attempt, ConnectionPhase phase, int timeoutMs) {
if (attempt != connectionAttempt_) {
return;
}
connectionPhase_ = phase;
connectionTimer_->start(timeoutMs);
}
void MainWindow::logConnectionPhase(quint64 attempt, const QString& phase,
const QString& result, const QString& error) {
const qint64 totalMs = connectionClock_.isValid() ? connectionClock_.elapsed() : 0;
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() {
stopSessions();
stopSession();
QApplication::quit();
}
void MainWindow::stopSessions() {
stopSessions(true);
}
void MainWindow::stopSessions(bool invalidateAttempt) {
connectionTimer_->stop();
if (invalidateAttempt) {
++connectionAttempt_;
}
connectionPhase_ = ConnectionPhase::kIdle;
if (moonlightSession_) {
moonlightSession_->stop();
}
if (overlay_) {
overlay_->finish();
}
}
void MainWindow::onMenuAction(int action) {
switch (action) {
case 1:
if (fsm_.canConnect() && !activeTicket_.desktopId.isEmpty()) {
const quint64 attempt = ++connectionAttempt_;
connectingDesktopId_ = activeTicket_.desktopId;
connectionClock_.restart();
if (fsm_.transitionTo(State::kConnecting, activeTicket_.endpoint) &&
!startMoonlightSession(activeTicket_, attempt)) {
failConnection(attempt, QStringLiteral("已有串流进程正在运行"));
}
}
case 1: // 重新连接
reconnectAttempts_ = 0;
stopSession();
startSession(config_);
break;
case 2:
stopSessions();
if (fsm_.current() != State::kConfig) {
fsm_.transitionTo(State::kDesktopList, QStringLiteral("已断开"));
}
case 2: // 断开回登录页
stopSession();
fsm_.transitionTo(State::kConfig, "已断开");
break;
case 3:
case 3: // 退出终端
onQuitRequested();
break;
default: {
QMenu menu(this);
QAction* settings = menu.addAction(QStringLiteral("桌面连接设置"));
menu.addSeparator();
QAction* reconnect = menu.addAction(QStringLiteral("重新连接当前桌面"));
QAction* disconnect = menu.addAction(QStringLiteral("断开并返回桌面列表"));
QAction* reconnect = menu.addAction(QStringLiteral("重新连接"));
QAction* disconnect = menu.addAction(QStringLiteral("断开并返回登录页"));
menu.addSeparator();
QAction* quit = menu.addAction(QStringLiteral("退出终端"));
QAction* chosen = menu.exec(QCursor::pos());
if (chosen == settings) {
if (fsm_.current() == State::kDesktopList || fsm_.current() == State::kConfig) {
onEditDesktopRequested(QString());
}
} else if (chosen == reconnect) {
if (chosen == reconnect) {
onMenuAction(1);
} else if (chosen == disconnect) {
onMenuAction(2);
@ -480,4 +347,8 @@ void MainWindow::onMenuAction(int action) {
}
}
bool MainWindow::eventFilter(QObject* watched, QEvent* event) {
return QMainWindow::eventFilter(watched, event);
}
} // namespace vdi

View File

@ -1,21 +1,27 @@
// Kiosk 主窗口
//
// 产品流程编排:开机 -> 桌面列表 -> 获取连接票据 -> Moonlight 会话 -> 返回列表。
// 对应《VDI 云桌面软件开发方案》3.2 节:
// Weston(Wayland) + 全屏 Qt 壳进程
// 状态机 BOOT -> CONFIG -> CONNECTING -> SESSION -> RECONNECT -> ERROR
// 读取下发连接配置(地址/账号模板/显示档位)
// 状态条(网络/会话/OTA 进度)
// 首配页(SN 绑定、WiFi 扫码、服务器地址)
// 以 libfreerdp3 API 内嵌(非拉起外部进程)
// 全局热键 Ctrl+Alt+Shift+M 呼出菜单
#ifndef KIOSK_SHELL_MAIN_WINDOW_H
#define KIOSK_SHELL_MAIN_WINDOW_H
#include <QElapsedTimer>
#include <QImage>
#include <QMainWindow>
#include <QPointer>
#include <memory>
#include "config.h"
#include "desktop_service.h"
#include "desktop_session.h"
#include "moonlight_session.h"
#include "rdp_session.h"
#include "state_machine.h"
class QLabel;
class QStackedWidget;
class QStatusBar;
class QTimer;
@ -23,8 +29,6 @@ class QTimer;
namespace vdi {
class LoginPage;
class DesktopListPage;
class TransitionOverlay;
class MainWindow : public QMainWindow {
Q_OBJECT
@ -33,61 +37,38 @@ public:
explicit MainWindow(QWidget* parent = nullptr);
~MainWindow() override;
protected:
bool eventFilter(QObject* watched, QEvent* event) override;
private slots:
void onLoginRequested(const QString& username, const QString& password);
void onDesktopSelected(const QString& desktopId);
void onEditDesktopRequested(const QString& desktopId);
void onConnectRequested(const vdi::TerminalConfig& cfg);
void onQuitRequested();
void onFrame(const uint8_t* data, uint32_t width, uint32_t height, uint32_t stride,
uint32_t format);
void onSessionState(bool connected, const QString& message);
void onMenuAction(int action);
private:
enum class ConnectionPhase {
kIdle,
kTicket,
kProcessStart,
kStreamReady,
kSession,
};
void setupUi();
void setupHotkey();
void setupHeartbeat();
void onStateChanged(vdi::State from, vdi::State to, const QString& detail);
void refreshDesktopList();
bool startMoonlightSession(const ConnectionTicket& ticket, quint64 attempt);
void onMoonlightEvent(quint64 attempt, const MoonlightSession::Event& event);
void waitForMoonlightWindow(quint64 attempt);
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 startSession(const TerminalConfig& cfg);
void stopSession();
void showLogin();
void showDesktopList();
void updateStatusBar(const QString& text);
StateMachine fsm_;
DesktopService desktopService_;
std::unique_ptr<MoonlightSession> moonlightSession_;
ConnectionTicket activeTicket_;
QString currentUser_;
QString connectingDesktopId_;
std::vector<DesktopInfo> desktops_;
std::unique_ptr<RdpSession> session_;
QPointer<LoginPage> loginPage_;
QPointer<DesktopListPage> desktopListPage_;
QStackedWidget* stack_ = nullptr;
QLabel* sessionView_ = nullptr;
QLabel* sessionOverlay_ = nullptr;
QStatusBar* statusBar_ = nullptr;
TransitionOverlay* overlay_ = nullptr;
QTimer* connectionTimer_ = nullptr;
QTimer* heartbeatTimer_ = nullptr;
QElapsedTimer connectionClock_;
QElapsedTimer phaseClock_;
quint64 connectionAttempt_ = 0;
ConnectionPhase connectionPhase_ = ConnectionPhase::kIdle;
QTimer* reconnectTimer_ = nullptr;
TerminalConfig config_;
int reconnectAttempts_ = 0;
QImage lastFrame_;
bool frameDirty_ = false;
};
} // namespace vdi

View File

@ -1,209 +0,0 @@
#include "monitor.h"
#include <signal.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <cerrno>
#include <chrono>
#include <cstdio>
#include <cstring>
#include <ctime>
#include <string>
#include <thread>
#include <vector>
namespace vdi {
namespace {
Monitor* g_monitor = nullptr;
void onSignal(int) {
if (g_monitor) {
g_monitor->requestStop();
}
}
// 主进程写心跳文件;Monitor 读取其修改时间判断是否假死
bool heartbeatAgeMs(const std::string& path, long long& ageMs) {
struct stat st {};
if (stat(path.c_str(), &st) != 0) {
return false;
}
const auto now = std::chrono::system_clock::now();
const auto mtime = std::chrono::system_clock::from_time_t(st.st_mtime);
ageMs = std::chrono::duration_cast<std::chrono::milliseconds>(now - mtime).count();
return true;
}
} // namespace
Monitor::Monitor(MonitorConfig config) : config_(std::move(config)) {}
void Monitor::requestStop() {
stopRequested_ = true;
}
void Monitor::log(const std::string& message) const {
if (!config_.verbose) {
return;
}
const std::time_t now = std::time(nullptr);
char timeBuf[32] = {0};
std::strftime(timeBuf, sizeof(timeBuf), "%Y-%m-%d %H:%M:%S", std::localtime(&now));
std::fprintf(stderr, "[monitor %s] %s\n", timeBuf, message.c_str());
std::fflush(stderr);
}
bool Monitor::isProcessAlive(pid_t pid) const {
if (pid <= 0) {
return false;
}
return kill(pid, 0) == 0 || errno == EPERM;
}
// 结束主进程及其整棵进程树。
// 主进程通常还会再 fork 子进程(例如启动器拉起的串流客户端),
// 只杀直接子进程会留下孤儿进程继续占用显示设备和串流会话,
// 因此子进程启动时独立成组,结束时按进程组终止。
namespace {
void killTree(pid_t pid, int sig) {
if (pid <= 0) {
return;
}
if (kill(-pid, sig) != 0) {
kill(pid, sig);
}
}
} // namespace
bool Monitor::isHung() const {
long long ageMs = 0;
if (!heartbeatAgeMs(config_.heartbeatFile, ageMs)) {
// 没有心跳文件:主进程还没写过心跳
return false;
}
return ageMs > config_.heartbeatTimeoutMs;
}
int Monitor::run(const std::string& command) {
if (command.empty()) {
log("用法: monitor -- <主进程命令行>");
return 2;
}
g_monitor = this;
signal(SIGTERM, onSignal);
signal(SIGINT, onSignal);
// 子进程退出时不发送 SIGPIPE,避免 Monitor 自身被杀
signal(SIGPIPE, SIG_IGN);
log("看门狗启动,监控: " + command);
pid_t child = -1;
auto startTime = std::chrono::steady_clock::now();
while (!stopRequested_) {
if (child <= 0) {
// 启动主进程
child = fork();
if (child == 0) {
// 子进程:恢复默认信号处理并 exec
signal(SIGTERM, SIG_DFL);
signal(SIGINT, SIG_DFL);
signal(SIGPIPE, SIG_DFL);
// 独立成组,便于结束整棵进程树
setpgid(0, 0);
execl("/bin/sh", "sh", "-c", command.c_str(), nullptr);
_exit(127);
}
if (child < 0) {
log(std::string("fork 失败: ") + std::strerror(errno));
return 1;
}
// 父子都调用一次 setpgid,避免父子竞争导致进程组 ID 未及时生效
setpgid(child, child);
startTime = std::chrono::steady_clock::now();
log("主进程已启动, pid=" + std::to_string(child));
}
std::this_thread::sleep_for(std::chrono::milliseconds(config_.checkIntervalMs));
if (stopRequested_) {
log("收到退出信号,终止主进程");
if (child > 0) {
killTree(child, SIGTERM);
// 等待退出,超时则强杀
for (int i = 0; i < 20 && isProcessAlive(child); ++i) {
std::this_thread::sleep_for(std::chrono::milliseconds(200));
}
if (isProcessAlive(child)) {
killTree(child, SIGKILL);
}
waitpid(child, nullptr, 0);
}
log("看门狗退出");
return 0;
}
// 回收已退出的子进程
int status = 0;
const pid_t reaped = waitpid(child, &status, WNOHANG);
if (reaped == child) {
const int exitCode = WIFEXITED(status) ? WEXITSTATUS(status) : -1;
const int signalNum = WIFSIGNALED(status) ? WTERMSIG(status) : 0;
log("主进程已退出, code=" + std::to_string(exitCode) +
(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) {
log("连续重启超过 " + std::to_string(config_.maxRestartAttempts) +
" 次,放弃重启(熔断)");
return 1;
}
log(std::to_string(config_.restartBackoffMs) + "ms 后重启");
std::this_thread::sleep_for(std::chrono::milliseconds(config_.restartBackoffMs));
child = -1;
continue;
}
// 假死检测
if (isHung()) {
const auto elapsed =
std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - startTime)
.count();
// 启动宽限期内不判定假死
if (elapsed > config_.startupGraceMs) {
log("检测到主进程假死(心跳超时),执行 kill 并重启");
killTree(child, SIGKILL);
waitpid(child, &status, 0);
child = -1;
if (++restartAttempts_ > config_.maxRestartAttempts) {
log("连续重启超过 " + std::to_string(config_.maxRestartAttempts) +
" 次,放弃重启(熔断)");
return 1;
}
std::this_thread::sleep_for(std::chrono::milliseconds(config_.restartBackoffMs));
continue;
}
} else {
// 进程健康,重置重启计数
restartAttempts_ = 0;
}
}
return 0;
}
} // namespace vdi

View File

@ -1,65 +0,0 @@
// 看门狗 Monitor(双进程架构)
//
// 对应《VDI 云桌面软件开发方案》与会议纪要:
// "必须部署独立的 Monitor(看门狗)程序监控主进程状态,
// 主进程假死或崩溃时由 Monitor 执行 kill 操作并重启"
// "小型 ARM 设备长时间运行必然出现卡顿或假死,无守护机制将导致用户体验严重受损"
//
// 判定主进程假死的方式:
// 1. 进程不存在 -> 崩溃
// 2. 心跳文件超时 -> 假死(主进程定期写心跳,Monitor 检查更新时间)
// 3. 连续启动失败 -> 熔断,放弃重启避免无限重启循环
//
// 用法:
// monitor -- <主进程命令行...>
// 例:monitor -- /home/armbian/vdi-terminal/bin/kiosk-shell
#ifndef KIOSK_SHELL_MONITOR_H
#define KIOSK_SHELL_MONITOR_H
#include <chrono>
#include <string>
namespace vdi {
struct MonitorConfig {
// 心跳文件路径;主进程每 heartbeatInterval 写一次
std::string heartbeatFile = "/tmp/vdi-kiosk.heartbeat";
// 心跳超时判定(毫秒)
int heartbeatTimeoutMs = 15000;
// 检查间隔(毫秒)
int checkIntervalMs = 3000;
// 心跳文件超过该时间认为进程从未启动
int startupGraceMs = 20000;
// 连续失败多少次后熔断
int maxRestartAttempts = 5;
// 两次重启之间的间隔(毫秒)
int restartBackoffMs = 3000;
// 是否在每条日志后写 stdout
bool verbose = true;
};
class Monitor {
public:
explicit Monitor(MonitorConfig config = MonitorConfig());
// 运行看门狗主循环,阻塞直到被信号中断
int run(const std::string& command);
// 请求退出(供信号处理调用)
void requestStop();
private:
bool isProcessAlive(pid_t pid) const;
// 主进程是否被认为假死
bool isHung() const;
void log(const std::string& message) const;
MonitorConfig config_;
volatile bool stopRequested_ = false;
int restartAttempts_ = 0;
};
} // namespace vdi
#endif // KIOSK_SHELL_MONITOR_H

View File

@ -1,66 +0,0 @@
// 看门狗 Monitor 入口
//
// 用法:vdi-kiosk-monitor -- <主进程命令行>
// 例: vdi-kiosk-monitor -- /home/armbian/vdi-terminal/bin/kiosk-shell
//
// 对应会议纪要要求:
// "必须部署独立的 Monitor(看门狗)程序监控主进程状态,
// 主进程假死或崩溃时由 Monitor 执行 kill 操作并重启"
#include <cstdio>
#include <string>
#include "monitor.h"
int main(int argc, char** argv) {
if (argc < 2) {
std::fprintf(stderr, "用法: vdi-kiosk-monitor [--verbose] -- <主进程命令行>\n");
return 2;
}
int idx = 1;
vdi::MonitorConfig config;
config.verbose = true;
while (idx < argc && std::string(argv[idx]).rfind("--", 0) == 0) {
const std::string opt = argv[idx];
if (opt == "--") {
++idx;
break;
}
if (opt == "--quiet") {
config.verbose = false;
} else if (opt == "--verbose") {
config.verbose = true;
} else if (opt == "--heartbeat-timeout" && idx + 1 < argc) {
config.heartbeatTimeoutMs = std::atoi(argv[++idx]);
} else if (opt == "--check-interval" && idx + 1 < argc) {
config.checkIntervalMs = std::atoi(argv[++idx]);
} else if (opt == "--max-restart" && idx + 1 < argc) {
config.maxRestartAttempts = std::atoi(argv[++idx]);
} else if (opt == "--heartbeat-file" && idx + 1 < argc) {
config.heartbeatFile = argv[++idx];
} else {
// 未知选项视为主进程命令行的起点
break;
}
++idx;
}
if (idx >= argc) {
std::fprintf(stderr, "缺少主进程命令行\n");
return 2;
}
// 拼出主进程命令行
std::string command;
for (int i = idx; i < argc; ++i) {
if (i > idx) {
command += " ";
}
command += argv[i];
}
vdi::Monitor monitor(config);
return monitor.run(command);
}

View File

@ -1,277 +0,0 @@
#include "moonlight_session.h"
#include <QDate>
#include <QDir>
#include <QFile>
#include <QProcessEnvironment>
#include <QTextStream>
#include <QTimer>
namespace vdi {
namespace {
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() {
callback_ = nullptr;
readyTimer_->stop();
killTimer_->stop();
if (isRunning()) {
process_->kill();
process_->waitForFinished(1000);
}
}
void MoonlightSession::setOptions(const Options& options) {
options_ = options;
}
bool MoonlightSession::isRunning() const {
return process_->state() != QProcess::NotRunning;
}
qint64 MoonlightSession::processId() const {
return isRunning() ? process_->processId() : 0;
}
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()) {
return false;
}
callback_ = std::move(callback);
stopRequested_ = false;
terminalEventDelivered_ = false;
readyEventDelivered_ = false;
outputParser_.reset();
readyTimer_->stop();
killTimer_->stop();
process_->setProgram(QStringLiteral("moonlight-qt"));
QProcessEnvironment environment = QProcessEnvironment::systemEnvironment();
environment.insert(QStringLiteral("GL_IS_SLOW"), QStringLiteral("0"));
process_->setProcessEnvironment(environment);
QStringList args;
args << QStringLiteral("stream")
<< ticket.endpoint
<< ticket.app
<< QStringLiteral("--resolution")
<< QStringLiteral("%1x%2").arg(ticket.width).arg(ticket.height)
<< QStringLiteral("--fps") << QString::number(ticket.fps)
<< QStringLiteral("--bitrate") << QString::number(20000)
<< QStringLiteral("--display-mode") << QStringLiteral("fullscreen")
<< QStringLiteral("--video-codec") << options_.videoCodec
<< QStringLiteral("--video-decoder") << options_.decoder
<< QStringLiteral("--absolute-mouse")
<< QStringLiteral("--keep-awake");
args << (options_.vsync ? QStringLiteral("--vsync") : QStringLiteral("--no-vsync"));
args << (options_.framePacing ? QStringLiteral("--frame-pacing")
: QStringLiteral("--no-frame-pacing"));
if (options_.performanceOverlay) {
args << QStringLiteral("--performance-overlay");
}
args << (options_.audioOnHost ? QStringLiteral("--audio-on-host")
: QStringLiteral("--no-audio-on-host"));
process_->setArguments(args);
const QString logDir = QDir::homePath() + QStringLiteral("/vdi-terminal/logs");
QDir().mkpath(logDir);
logPath_ = logDir + QStringLiteral("/moonlight-%1.log")
.arg(QDate::currentDate().toString(QStringLiteral("yyyyMMdd")));
process_->start();
return true;
}
void MoonlightSession::onProcessStarted() {
emitEvent(EventType::kStarted, QStringLiteral("串流进程已启动"));
}
void MoonlightSession::onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus) {
readyTimer_->stop();
killTimer_->stop();
if (stopRequested_) {
emitTerminalEvent(EventType::kFinished, QStringLiteral("串流已结束"));
return;
}
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) {
if (error == QProcess::Crashed) {
return;
}
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() {
if (!isRunning()) {
return;
}
stopRequested_ = true;
readyTimer_->stop();
process_->terminate();
killTimer_->start(kTerminateGraceMs);
}
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);
}
}
} // namespace vdi

View File

@ -1,98 +0,0 @@
// Moonlight 会话(外部进程)
//
// 以 QProcess 参数数组直启 moonlight-qt(不拼 shell 命令)。
// 进程启动、串流就绪和终止是不同事件,调用方据此控制过场和状态机。
#ifndef KIOSK_SHELL_MOONLIGHT_SESSION_H
#define KIOSK_SHELL_MOONLIGHT_SESSION_H
#include <QByteArray>
#include <QProcess>
#include <QString>
#include <functional>
#include "desktop_session.h"
class QTimer;
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 {
Q_OBJECT
public:
struct Options {
bool vsync = true;
bool framePacing = true;
bool performanceOverlay = false;
bool audioOnHost = true;
QString videoCodec = QStringLiteral("H.264");
QString decoder = QStringLiteral("software");
};
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);
~MoonlightSession() override;
void setOptions(const Options& options);
bool start(const ConnectionTicket& ticket, EventCallback callback);
void stop();
bool isRunning() const;
qint64 processId() const;
bool activateStreamWindow();
private slots:
void onProcessStarted();
void onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus);
void onProcessError(QProcess::ProcessError error);
void onProcessOutput();
private:
void emitEvent(EventType type, const QString& message);
void emitTerminalEvent(EventType type, const QString& message);
void scheduleReadyEvent();
QProcess* process_ = nullptr;
QTimer* readyTimer_ = nullptr;
QTimer* killTimer_ = nullptr;
Options options_;
EventCallback callback_;
MoonlightOutputParser outputParser_;
QString logPath_;
bool stopRequested_ = false;
bool terminalEventDelivered_ = false;
bool readyEventDelivered_ = false;
};
} // namespace vdi
#endif // KIOSK_SHELL_MOONLIGHT_SESSION_H

View File

@ -0,0 +1,326 @@
#include "rdp_session.h"
#include <cstddef>
#include <cstring>
#include <map>
#include <mutex>
namespace vdi {
namespace {
// 从 freerdp 实例取回 C++ 会话对象。
// 注意:不能用 instance->ContextSize 扩展 rdpContext ——
// gdi_init_ex() 会按 ContextSize 重新分配 context 缓冲区,
// 扩展出来的字段会失效(实测段错误)。
// 因此改用 FreeRDP 官方提供的 custom 挂载点:
// rdpGdi->gfx->custom 用于 GFX 通道回调;
// 这里额外用一个静态注册表按 freerdp* 记录会话对象。
class SessionRegistry {
public:
static void add(freerdp* instance, RdpSession* session) {
std::lock_guard<std::mutex> lock(mutex());
table()[instance] = session;
}
static void remove(freerdp* instance) {
std::lock_guard<std::mutex> lock(mutex());
table().erase(instance);
}
static RdpSession* get(freerdp* instance) {
if (!instance) return nullptr;
std::lock_guard<std::mutex> lock(mutex());
auto it = table().find(instance);
return it == table().end() ? nullptr : it->second;
}
private:
static std::mutex& mutex() {
static std::mutex m;
return m;
}
static std::map<freerdp*, RdpSession*>& table() {
static std::map<freerdp*, RdpSession*> t;
return t;
}
};
RdpSession* sessionOf(freerdp* instance) {
return SessionRegistry::get(instance);
}
} // namespace
RdpSession::RdpSession() = default;
RdpSession::~RdpSession() {
stop();
}
bool RdpSession::start(const RdpOptions& options, RdpCallbacks callbacks) {
if (running_.load()) {
return false;
}
options_ = std::move(options);
callbacks_ = std::move(callbacks);
running_.store(true);
worker_ = std::thread(&RdpSession::run, this);
return true;
}
void RdpSession::stop() {
if (!running_.load()) {
return;
}
running_.store(false);
if (instance_) {
// 请求断开;连接线程会从事件循环返回
freerdp_disconnect(instance_);
}
if (worker_.joinable()) {
worker_.join();
}
instance_ = nullptr;
context_ = nullptr;
}
void RdpSession::applySettings(rdpSettings* settings) {
// FreeRDP 3 的键名与 2.x 不同:
// FreeRDP_ServerHostname / FreeRDP_Username / FreeRDP_DesktopWidth ...
freerdp_settings_set_string(settings, FreeRDP_ServerHostname, options_.server.c_str());
if (!options_.username.empty()) {
freerdp_settings_set_string(settings, FreeRDP_Username, options_.username.c_str());
}
if (!options_.password.empty()) {
freerdp_settings_set_string(settings, FreeRDP_Password, options_.password.c_str());
}
if (!options_.domain.empty()) {
freerdp_settings_set_string(settings, FreeRDP_Domain, options_.domain.c_str());
}
freerdp_settings_set_uint32(settings, FreeRDP_DesktopWidth,
static_cast<UINT32>(options_.width));
freerdp_settings_set_uint32(settings, FreeRDP_DesktopHeight,
static_cast<UINT32>(options_.height));
freerdp_settings_set_uint32(settings, FreeRDP_ColorDepth, static_cast<UINT32>(options_.bpp));
freerdp_settings_set_bool(settings, FreeRDP_Fullscreen, options_.fullscreen ? TRUE : FALSE);
freerdp_settings_set_bool(settings, FreeRDP_IgnoreCertificate,
options_.ignoreCertificate ? TRUE : FALSE);
freerdp_settings_set_bool(settings, FreeRDP_NetworkAutoDetect, TRUE);
freerdp_settings_set_bool(settings, FreeRDP_UseRdpSecurityLayer, TRUE);
freerdp_settings_set_bool(settings, FreeRDP_SupportDynamicChannels, TRUE);
// GFX 能力开关,对应命令行 /gfx 的能力组合语义。
// FreeRDP 3.32.1 单独指定 AVC420 会解析失败,必须成组出现。
freerdp_settings_set_bool(settings, FreeRDP_GfxProgressive, TRUE);
freerdp_settings_set_bool(settings, FreeRDP_GfxH264, TRUE);
freerdp_settings_set_bool(settings, FreeRDP_GfxAVC444, TRUE);
freerdp_settings_set_bool(settings, FreeRDP_GfxAVC444v2, TRUE);
freerdp_settings_set_bool(settings, FreeRDP_GfxThinClient, TRUE);
freerdp_settings_set_bool(settings, FreeRDP_GfxSmallCache, TRUE);
}
void RdpSession::run() {
// 1. 创建设置
rdpSettings* settings = freerdp_settings_new(0);
if (!settings) {
running_.store(false);
if (callbacks_.onState) {
callbacks_.onState(false, "无法创建设置");
}
return;
}
applySettings(settings);
// 2. 创建实例
freerdp* instance = freerdp_new();
if (!instance) {
freerdp_settings_free(settings);
running_.store(false);
if (callbacks_.onState) {
callbacks_.onState(false, "无法创建 FreeRDP 实例");
}
return;
}
instance_ = instance;
// 3. 注册会话对象(按 freerdp* 记录,供静态回调取回)
SessionRegistry::add(instance, this);
// 4. 注册事件
instance->PostConnect = onPostConnect;
instance->PostDisconnect = onPostDisconnect;
// NLA 认证:把配置中的账号密码交给 FreeRDP
instance->AuthenticateEx = onAuthenticateEx;
// 5. 建立上下文
if (!freerdp_context_new_ex(instance, settings)) {
if (callbacks_.onState) {
callbacks_.onState(false, "无法创建 RDP 上下文");
}
SessionRegistry::remove(instance);
freerdp_free(instance);
freerdp_settings_free(settings);
instance_ = nullptr;
running_.store(false);
return;
}
context_ = instance->context;
// 6. 注册输出回调
// GDI 的初始化(gdi_init)必须放在 PostConnect 里,
// 因为它依赖连接协商后的桌面尺寸与颜色格式。
if (context_->update) {
context_->update->BitmapUpdate = onBitmapUpdate;
context_->update->EndPaint = onEndPaint;
}
// 8. 连接(阻塞)
if (!freerdp_connect(instance)) {
if (callbacks_.onState) {
callbacks_.onState(false, "连接失败,请检查地址与网络");
}
SessionRegistry::remove(instance);
freerdp_context_free(instance);
freerdp_free(instance);
freerdp_settings_free(settings);
instance_ = nullptr;
context_ = nullptr;
running_.store(false);
return;
}
// 9. 事件循环
while (running_.load() && !freerdp_shall_disconnect_context(context_)) {
if (!freerdp_check_event_handles(context_)) {
continue;
}
}
connected_.store(false);
if (callbacks_.onState) {
callbacks_.onState(false, "会话已结束");
}
SessionRegistry::remove(instance);
freerdp_context_free(instance);
freerdp_free(instance);
freerdp_settings_free(settings);
instance_ = nullptr;
context_ = nullptr;
running_.store(false);
}
BOOL RdpSession::onAuthenticateEx(freerdp* instance, char** username, char** password, char** domain,
rdp_auth_reason reason) {
(void)reason;
RdpSession* self = sessionOf(instance);
if (!self) {
return FALSE;
}
// 注意:NLA/CredSSP 必须拿到非 nullptr 的凭据指针,
// 空密码也要传空字符串(传 nullptr 会导致 SEC_E_NO_CREDENTIALS)。
// 文档要求终端不长期持有凭据;正式产品改为后端下发一次性票据
// 或由 RDP 网关代理认证。
if (username) {
*username = strdup(self->options_.username.c_str());
}
if (password) {
*password = strdup(self->options_.password.c_str());
}
if (domain) {
*domain = strdup(self->options_.domain.c_str());
}
return TRUE;
}
BOOL RdpSession::onPostConnect(freerdp* instance) {
RdpSession* self = sessionOf(instance);
if (!self) {
return FALSE;
}
// GDI 初始化必须在连接建立后(官方 x11 客户端同样放在 PostConnect)
if (!gdi_init(instance, PIXEL_FORMAT_BGRX32)) {
if (self->callbacks_.onState) {
self->callbacks_.onState(false, "GDI 初始化失败");
}
return FALSE;
}
// 挂载 GFX 渲染管线:把解码后的帧通过回调交给 Qt
if (instance->context && instance->context->gdi && instance->context->gdi->gfx) {
instance->context->gdi->gfx->custom = self;
gdi_graphics_pipeline_init_ex(instance->context->gdi, instance->context->gdi->gfx, nullptr,
nullptr, onUpdateSurfaceArea);
}
self->connected_.store(true);
if (self->callbacks_.onState) {
self->callbacks_.onState(true, "已连接");
}
return TRUE;
}
void RdpSession::onPostDisconnect(freerdp* instance) {
RdpSession* self = sessionOf(instance);
if (self) {
self->connected_.store(false);
}
}
void RdpSession::emitFrame() {
if (!context_ || !context_->gdi) {
return;
}
rdpGdi* gdi = context_->gdi;
if (!gdi->primary_buffer) {
return;
}
frameWidth_ = static_cast<uint32_t>(gdi->width);
frameHeight_ = static_cast<uint32_t>(gdi->height);
frameStride_ = gdi->stride;
frameFormat_ = gdi->dstFormat;
if (callbacks_.onFrame) {
callbacks_.onFrame(gdi->primary_buffer, frameWidth_, frameHeight_, frameStride_,
frameFormat_);
}
}
BOOL RdpSession::onBitmapUpdate(rdpContext* context, const BITMAP_UPDATE* bitmap) {
(void)bitmap;
// GDI 层已经把位图画进 primary buffer,这里只标记有更新
if (context && context->instance) {
RdpSession* self = sessionOf(context->instance);
if (self) {
self->frameDirty_ = true;
}
}
return TRUE;
}
BOOL RdpSession::onEndPaint(rdpContext* context) {
// 一帧绘制完成,交给 Qt 刷新
if (context && context->instance) {
RdpSession* self = sessionOf(context->instance);
if (self) {
self->emitFrame();
}
}
return TRUE;
}
UINT RdpSession::onUpdateSurfaceArea(RdpgfxClientContext* gfxContext, UINT16 surfaceId,
UINT32 nrRects, const RECTANGLE_16* rects) {
(void)surfaceId;
(void)nrRects;
(void)rects;
if (!gfxContext) {
return ERROR_SUCCESS;
}
// custom 字段在建立管线时写入,指向 C++ 会话对象
auto* self = static_cast<RdpSession*>(gfxContext->custom);
if (!self) {
return ERROR_SUCCESS;
}
self->emitFrame();
return ERROR_SUCCESS;
}
} // namespace vdi

View File

@ -0,0 +1,106 @@
// RDP 会话封装(内嵌 FreeRDP 3 客户端 API)
//
// 对应《VDI 云桌面软件开发方案》3.2 / 3.3 节:
// "以 libfreerdp3 API 内嵌(非拉起外部进程),保证会话内无缝切换与全局热键"
// 协议层 FreeRDP 3;图形管线走 GFX;4K 档切 H.265 解码管线
//
// FreeRDP 3 API 结构(与 2.x 差异较大):
// freerdp* instance = freerdp_new(); // 主对象
// instance->context = <rdpContext*>; // 内部结构,公开
// instance->context->gdi // GDI 渲染状态
// instance->PostConnect / PostDisconnect // 事件回调
// instance->pClientEntryPoints // 客户端入口点
//
// 渲染:gdi_graphics_pipeline_init_ex 的 pcRdpgfxUpdateSurfaceArea 回调
// 拿到解码后的帧缓冲,交由 Qt 绘制。
// 后续 freerdp-rk 分支替换为 GStreamer + mppvideodec 零拷贝管线。
#ifndef KIOSK_SHELL_RDP_SESSION_H
#define KIOSK_SHELL_RDP_SESSION_H
#include <atomic>
#include <functional>
#include <string>
#include <thread>
#include <freerdp/freerdp.h>
#include <freerdp/gdi/gdi.h>
#include <freerdp/gdi/gfx.h>
#include <freerdp/settings.h>
namespace vdi {
struct RdpOptions {
std::string server; // 主机或 host:port
std::string username;
std::string password;
std::string domain;
int width = 1920;
int height = 1440;
int bpp = 32;
bool fullscreen = true;
// 图形管线能力组合。FreeRDP 3.32.1 的 /gfx 不接受单个管道名,
// 必须是 "|" 分隔的组合(实测 AVC420 单独使用会解析失败)。
std::string gfx = "progressive|RFX|AVC420|AVC444";
bool ignoreCertificate = true; // POC 阶段;正式产品必须做证书校验
};
struct RdpCallbacks {
// 每帧解码完成
std::function<void(const uint8_t* data, uint32_t width, uint32_t height, uint32_t stride,
uint32_t format)>
onFrame;
// 会话状态变化
std::function<void(bool connected, const std::string& message)> onState;
};
class RdpSession {
public:
RdpSession();
~RdpSession();
RdpSession(const RdpSession&) = delete;
RdpSession& operator=(const RdpSession&) = delete;
// 启动会话(内部线程执行阻塞式连接)
bool start(const RdpOptions& options, RdpCallbacks callbacks);
// 主动断开
void stop();
bool isRunning() const { return running_.load(); }
private:
void run();
void applySettings(rdpSettings* settings);
// FreeRDP 回调(静态,通过 instance->context 扩展结构找回 C++ 对象)
static BOOL onPostConnect(freerdp* instance);
static void onPostDisconnect(freerdp* instance);
// NLA 认证回调:把配置中的账号/密码交给 FreeRDP
static BOOL onAuthenticateEx(freerdp* instance, char** username, char** password, char** domain,
rdp_auth_reason reason);
// GDI 输出回调:位图更新与绘制完成
static BOOL onBitmapUpdate(rdpContext* context, const BITMAP_UPDATE* bitmap);
static BOOL onEndPaint(rdpContext* context);
static UINT onUpdateSurfaceArea(RdpgfxClientContext* context, UINT16 surfaceId, UINT32 nrRects,
const RECTANGLE_16* rects);
// 把当前 GDI 帧缓冲交给上层
void emitFrame();
RdpOptions options_;
RdpCallbacks callbacks_;
std::atomic<bool> running_{false};
std::atomic<bool> connected_{false};
freerdp* instance_ = nullptr;
rdpContext* context_ = nullptr;
std::thread worker_;
uint32_t frameWidth_ = 0;
uint32_t frameHeight_ = 0;
uint32_t frameStride_ = 0;
uint32_t frameFormat_ = 0;
bool frameDirty_ = false;
};
} // namespace vdi
#endif // KIOSK_SHELL_RDP_SESSION_H

View File

@ -4,14 +4,12 @@ namespace vdi {
const char* stateToString(State s) {
switch (s) {
case State::kBoot: return "BOOT";
case State::kConfig: return "CONFIG";
case State::kAuthenticating: return "AUTHENTICATING";
case State::kDesktopList: return "DESKTOP_LIST";
case State::kConnecting: return "CONNECTING";
case State::kSession: return "SESSION";
case State::kReconnect: return "RECONNECT";
case State::kError: return "ERROR";
case State::kBoot: return "BOOT";
case State::kConfig: return "CONFIG";
case State::kConnecting: return "CONNECTING";
case State::kSession: return "SESSION";
case State::kReconnect: return "RECONNECT";
case State::kError: return "ERROR";
}
return "UNKNOWN";
}
@ -23,24 +21,15 @@ bool StateMachine::isLegalTransition(State from, State to) {
case State::kBoot:
return to == State::kConfig || to == State::kError;
case State::kConfig:
return to == State::kAuthenticating || to == State::kConnecting ||
to == State::kError;
case State::kAuthenticating:
return to == State::kDesktopList || to == State::kConfig || to == State::kError;
case State::kDesktopList:
return to == State::kConnecting || to == State::kConfig || to == State::kError;
return to == State::kConnecting || to == State::kError;
case State::kConnecting:
return to == State::kSession || to == State::kReconnect ||
to == State::kDesktopList || to == State::kError;
return to == State::kSession || to == State::kReconnect || to == State::kError;
case State::kSession:
return to == State::kReconnect || to == State::kDesktopList ||
to == State::kConfig || to == State::kError;
return to == State::kReconnect || to == State::kConfig || to == State::kError;
case State::kReconnect:
return to == State::kConnecting || to == State::kDesktopList ||
to == State::kConfig || to == State::kError;
return to == State::kConnecting || to == State::kConfig || to == State::kError;
case State::kError:
return to == State::kConfig || to == State::kDesktopList ||
to == State::kConnecting;
return to == State::kConfig || to == State::kConnecting;
}
return false;
}
@ -58,8 +47,7 @@ bool StateMachine::transitionTo(State next, const QString& detail) {
}
bool StateMachine::canConnect() const {
return current_ == State::kDesktopList || current_ == State::kConfig ||
current_ == State::kReconnect || current_ == State::kError;
return current_ == State::kConfig || current_ == State::kReconnect || current_ == State::kError;
}
bool StateMachine::canDisconnect() const {

View File

@ -1,14 +1,14 @@
// kiosk-shell 状态机
//
// 对应《VDI 云桌面软件开发方案》3.2 节:
// 状态机:BOOT -> CONFIG/LOGIN -> CONNECTING -> SESSION(Moonlight 串流)
// 状态机:BOOT -> CONFIG(无配置)-> CONNECTING -> SESSION(嵌入 freerdp 窗口)
// -> RECONNECT -> ERROR
//
// 状态转换规则:
// BOOT 进程启动,读取本地配置
// CONFIG 无可用配置或用户停留在登录页,等待输入
// CONNECTING 正在建立 Moonlight 串流连接
// SESSION 远程会话进行中(moonlight-qt 独占全屏)
// CONNECTING 正在建立 RDP 连接
// SESSION 远程会话进行中
// RECONNECT 会话意外断开,等待重连
// ERROR 不可恢复错误,提示后回到 CONFIG
@ -22,14 +22,12 @@
namespace vdi {
enum class State {
kBoot, // 启动中
kConfig, // 首配 / 登录页
kAuthenticating, // 正在认证
kDesktopList, // 桌面列表(登录成功后)
kConnecting, // 连接中
kSession, // 会话中
kReconnect, // 断线等待重连
kError, // 错误
kBoot, // 启动中
kConfig, // 首配 / 登录页
kConnecting, // 连接中
kSession, // 会话中
kReconnect, // 断线等待重连
kError, // 错误
};
const char* stateToString(State s);

View File

@ -1,128 +0,0 @@
#include "transition_overlay.h"
#include <QEvent>
#include <QFont>
#include <QGuiApplication>
#include <QLabel>
#include <QScreen>
#include <QTimer>
#include <QVBoxLayout>
#include <QVariant>
namespace vdi {
namespace {
constexpr int kTickMs = 400; // 省略号/旋轮动画周期
constexpr int kFadeStepMs = 40; // 淡出步进周期
constexpr int kFadeSteps = 25; // 全程约 1 秒淡出
const char* kSpinnerFrames[] = {"◐", "◓", "◑", "◒"};
} // namespace
TransitionOverlay::TransitionOverlay(QWidget* parent) : QWidget(parent) {
// 独立置顶无边框窗口只在启动时映射一次,后续不再与 Moonlight 抢焦点。
setWindowFlags(Qt::Window | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint |
Qt::WindowDoesNotAcceptFocus);
setAttribute(Qt::WA_ShowWithoutActivating);
setStyleSheet(QStringLiteral("TransitionOverlay { background-color: #000000; }"));
auto* layout = new QVBoxLayout(this);
layout->setSpacing(28);
labelSpinner_ = new QLabel(this);
QFont spinFont = labelSpinner_->font();
spinFont.setPointSize(44);
labelSpinner_->setFont(spinFont);
labelSpinner_->setAlignment(Qt::AlignCenter);
labelSpinner_->setStyleSheet(QStringLiteral("color: #2f6feb; background: transparent;"));
layout->addStretch(1);
layout->addWidget(labelSpinner_);
labelTitle_ = new QLabel(this);
QFont titleFont = labelTitle_->font();
titleFont.setPointSize(20);
labelTitle_->setFont(titleFont);
labelTitle_->setAlignment(Qt::AlignCenter);
labelTitle_->setStyleSheet(QStringLiteral("color: #e6e9ef; background: transparent;"));
layout->addWidget(labelTitle_);
layout->addStretch(2);
tickTimer_ = new QTimer(this);
connect(tickTimer_, &QTimer::timeout, this, &TransitionOverlay::onTick);
fadeTimer_ = new QTimer(this);
connect(fadeTimer_, &QTimer::timeout, this, &TransitionOverlay::onFadeStep);
// 活动期间吞掉本窗口的键盘/鼠标事件,避免误触下层页面
installEventFilter(this);
}
void TransitionOverlay::start(const QString& title) {
tickTimer_->stop();
fadeTimer_->stop();
dots_ = 0;
setWindowOpacity(1.0);
setProperty("baseTitle", title);
labelTitle_->setText(title);
labelSpinner_->setText(QString::fromUtf8(kSpinnerFrames[0]));
if (QScreen* targetScreen = QGuiApplication::primaryScreen()) {
setGeometry(targetScreen->geometry());
}
show();
raise();
tickTimer_->start(kTickMs);
}
void TransitionOverlay::finish() {
tickTimer_->stop();
fadeTimer_->stop();
hide();
setWindowOpacity(1.0);
}
void TransitionOverlay::onFirstFrame() {
// 首帧已出画:停止动画并淡出;若尚未显示/已在淡出则忽略
if (!isVisible() || fadeTimer_->isActive()) {
return;
}
tickTimer_->stop();
fadeTimer_->start(kFadeStepMs);
}
void TransitionOverlay::onTick() {
dots_ = (dots_ + 1) % 4;
const QString base = property("baseTitle").toString();
if (!base.isEmpty()) {
labelTitle_->setText(base + QString(dots_, QLatin1Char('.')));
}
labelSpinner_->setText(QString::fromUtf8(kSpinnerFrames[dots_]));
}
void TransitionOverlay::onFadeStep() {
const double opacity = windowOpacity() - 1.0 / kFadeSteps;
if (opacity <= 0.0) {
finish();
return;
}
setWindowOpacity(opacity);
}
bool TransitionOverlay::eventFilter(QObject* watched, QEvent* event) {
if (watched == this && isVisible()) {
switch (event->type()) {
case QEvent::KeyPress:
case QEvent::KeyRelease:
case QEvent::MouseButtonPress:
case QEvent::MouseButtonRelease:
case QEvent::MouseMove:
case QEvent::Wheel:
return true;
default:
break;
}
}
return QWidget::eventFilter(watched, event);
}
} // namespace vdi

View File

@ -1,49 +0,0 @@
// 会话切换过场层
//
// 用途:用户点击"连接"后,Moonlight 客户端会先在屏幕左上角创建一个小窗口,
// 再扩展到全屏并解码首帧,这段时间画面闪烁、体验割裂。
// 本控件是覆盖在 kiosk 全屏窗口之上的不透明过场层(置顶),
// 盖住这个过程,直到收到首帧信号后淡出,让用户感知为"一次平滑的转场"。
#ifndef KIOSK_SHELL_TRANSITION_OVERLAY_H
#define KIOSK_SHELL_TRANSITION_OVERLAY_H
#include <QWidget>
class QLabel;
class QTimer;
namespace vdi {
class TransitionOverlay : public QWidget {
Q_OBJECT
public:
explicit TransitionOverlay(QWidget* parent = nullptr);
// 显示过场层并开始动画;title 为提示文字(如"正在进入 Windows 10")
void start(const QString& title);
// 隐藏过场层(带淡出)
void finish();
// 首次视频帧已到达
void onFirstFrame();
protected:
bool eventFilter(QObject* watched, QEvent* event) override;
private slots:
void onTick();
void onFadeStep();
private:
QLabel* labelTitle_ = nullptr;
QLabel* labelSpinner_ = nullptr;
QTimer* tickTimer_ = nullptr;
QTimer* fadeTimer_ = nullptr;
int dots_ = 0;
};
} // namespace vdi
#endif // KIOSK_SHELL_TRANSITION_OVERLAY_H

View File

@ -1,53 +0,0 @@
#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

@ -1,94 +0,0 @@
#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

@ -1,8 +0,0 @@
[Desktop Entry]
Type=Application
Name=VDI Cloud Desktop (Kiosk)
Comment=产品唯一自启入口:看门狗守护 kiosk-shell(登录 -> 桌面列表 -> 会话)
Exec=/home/armbian/vdi-terminal/bin/vdi-kiosk-monitor -- /home/armbian/vdi-terminal/bin/kiosk-shell
Terminal=false
X-GNOME-Autostart-enabled=true
X-GNOME-Autostart-Delay=2

View File

@ -1,9 +0,0 @@
[Desktop Entry]
Type=Application
Name=VDI Moonlight (debug)
Comment=独立 Moonlight 调试自启项;产品流程禁用,会话由 kiosk-shell 桌面列表拉起
Exec=/home/armbian/vdi-terminal/bin/vdi-kiosk-monitor --heartbeat-file /tmp/vdi-moonlight.heartbeat --max-restart 200 -- /home/armbian/vdi-terminal/bin/vdi-moonlight
Terminal=false
Hidden=true
X-GNOME-Autostart-enabled=false
X-GNOME-Autostart-Delay=5

View File

@ -1,41 +0,0 @@
#!/bin/bash
# 一键同步/编译/部署/验证 kiosk-shell 到 RK3528 盒子
#
# 用法(在 Windows 开发机的 Git Bash 里执行):
# bash tools/deploy_kiosk.sh
#
# 前置:盒子已恢复网络(ssh armbian@192.168.1.188 可达)。
set -e
BOX="${VDI_BOX:-armbian@192.168.1.188}"
REPO="$(cd "$(dirname "$0")/.." && pwd)"
SRC="$REPO/terminal/kiosk-shell"
echo "==> 检查盒子连通性"
ssh -o ConnectTimeout=8 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'echo "box ok: $(hostname)"'
echo "==> 同步源码到盒子"
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"/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/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-moonlight.desktop" "$BOX":~/.config/autostart/vdi-moonlight.desktop
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-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 "==> 安装二进制"
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 一条链)"
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 "==> 启动受管栈并验证"
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 "==> 抓屏确认当前界面(应为桌面列表)"
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"

View File

@ -1,249 +0,0 @@
#!/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

@ -1,29 +0,0 @@
#!/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

View File

@ -1,89 +0,0 @@
"""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]))

View File

@ -1,55 +0,0 @@
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])

View File

@ -1,44 +0,0 @@
#!/usr/bin/env python3
"""Rough brace/paren balance sanity check for edited C++ files."""
import os
import re
import sys
os.chdir(os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "terminal", "kiosk-shell"))
FILES = [
"src/desktop_settings_dialog.h",
"src/desktop_settings_dialog.cpp",
"src/desktop_store.h",
"src/desktop_store.cpp",
"src/main_window.cpp",
"src/main_window.h",
"src/desktop_list_page.cpp",
"src/desktop_list_page.h",
"src/desktop_service.cpp",
"src/desktop_service.h",
"src/desktop_session.h",
"src/moonlight_session.cpp",
"src/moonlight_session.h",
"src/transition_overlay.h",
"src/transition_overlay.cpp",
"src/login_page.cpp",
"src/login_page.h",
"src/state_machine.cpp",
"src/state_machine.h",
]
fail = False
for f in FILES:
src = open(f, encoding="utf-8").read()
src2 = re.sub(r"//[^\n]*", "", src)
src2 = re.sub(r'"(?:\\.|[^"\\])*"', '""', src2)
src2 = re.sub(r"'(?:\\.|[^'\\])*'", "''", src2)
counts = {c: src2.count(c) for c in "{}()"}
ok = counts["{"] == counts["}"] and counts["("] == counts[")"]
print(f, counts, "OK" if ok else "MISMATCH")
if not ok:
fail = True
sys.exit(1 if fail else 0)

View File

@ -1,84 +0,0 @@
#!/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())