Compare commits
10 Commits
ca15672256
...
8d24a5b2f2
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d24a5b2f2 | |||
| 2cc80989c6 | |||
| ade16efe49 | |||
| 27edbe2d82 | |||
| 8ec7b1ea49 | |||
| 9f1824ff21 | |||
| 653c4f627e | |||
| 8ee0cd2038 | |||
| 66f0cacb32 | |||
| a9e42a4084 |
65
MEMORY.md
65
MEMORY.md
@ -6,33 +6,74 @@
|
||||
- 项目目录:`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,Windows 10 22H2 VM `192.168.1.70:3389`,用户 `test1`
|
||||
- FreeRDP 3.32.1 已安装,命令 `xfreerdp3`
|
||||
- `kiosk-shell` 已按 Qt/C++ + libfreerdp3 内嵌实现,已真实显示 Windows 10 远程桌面
|
||||
- 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 下午节
|
||||
- 代码位置:`terminal/kiosk-shell`
|
||||
- 远程安装路径:`~/vdi-terminal/bin/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 会卡死配对
|
||||
|
||||
## 必须遵守
|
||||
- 所有实现严格参考用户提供的两个正式 Word 和会议纪要;文档未确定的技术只能标记待调研,不得擅自定版。
|
||||
- 终端先做,后接自研管理平台 API;终端不直接调用 Proxmox API。
|
||||
- 正式状态机:`BOOT -> CONFIG -> CONNECTING -> SESSION -> RECONNECT -> ERROR`
|
||||
- 正式 Kiosk 选型:Weston + Wayland + Qt;FreeRDP 以 libfreerdp3 API 内嵌,不拉外部进程。
|
||||
- 正式 Kiosk 选型:Weston + Wayland + Qt;会话通道统一 Moonlight(RDP 已删,勿在代码里复活协议分支)。
|
||||
- `/cert:ignore`、空密码仅限局域网 POC。
|
||||
- 项目经理新增待调研:RDP 是否适合 GPU/CAD/3D;研究 Moonlight/Sunshine,但不要在调研前删除 RDP、确定 Moonlight 参数或定版双协议。
|
||||
|
||||
## 已知关键结论
|
||||
- WiFi:约 -47 dBm,协商约 200 Mbps,零丢包/重传;卷帘问题不能简单归因于 WiFi。
|
||||
- HDMI 当前原生:`1920x1440@75Hz`,不要请求不存在的 2560x1440 模式。
|
||||
- HDMI 当前原生:`1920x1440@75Hz`(10-23 记录);2026-10-28 实测盒子系统已是 `1920x1080@60Hz`,
|
||||
与 Sunshine 侧虚拟显示器一致,串流 1:1 无缩放。以现场 `xrandr` 为准。
|
||||
- `/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`。
|
||||
|
||||
## 尚未完成
|
||||
- Monitor 编译/崩溃/假死重启测试
|
||||
- 开机自启
|
||||
- 网络状态显示修复确认
|
||||
- 服务端 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 分辨率链路验证(虚拟显示器 + 盒子显示模式需同步调整)
|
||||
- Weston/DRM/mppvideodec
|
||||
- 后端 API 注册、配置、MQTT 心跳
|
||||
- P100 GPU 直通及 CUDA/CAD/3D
|
||||
- Moonlight/Sunshine 调研
|
||||
- 后端 API 注册、配置、MQTT 心跳(Moonlight 路径目前是本地静态配置)
|
||||
- 终端音频出音现场确认
|
||||
- 协议最终选型定版(RDP / Moonlight / 双协议,需实测数据 + 产品方案共同确认)
|
||||
- 监控与运维脚本、OTA、USB 重定向等终端其余能力
|
||||
|
||||
先读 `PROJECT_CONTEXT.md`,再查看 `git status`,不要丢弃未提交改动。
|
||||
@ -276,63 +276,99 @@ 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. 当前工作区最后一次编译 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 方向只是项目经理新增的周一调研项,未安装、未验证、未纳入代码定版。
|
||||
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”的双协议结论仍需产品方案确认。
|
||||
|
||||
## 8. 下一步建议(按优先级)
|
||||
|
||||
### A. 收尾当前终端 POC
|
||||
### A. 服务端 GPU 直通(当前最高价值项)
|
||||
|
||||
1. 编译并验证 `vdi-kiosk-monitor`;
|
||||
2. 给 kiosk-shell 与 Monitor 做真实崩溃/假死重启测试;
|
||||
3. 配置当前开发盒子的用户级开机自启,开机直达登录页;
|
||||
4. 修复并验证网络状态显示;
|
||||
5. 保留当前能进入 Windows 10 的版本作为可回退基线。
|
||||
|
||||
### B. 按项目经理新增方向做 GPU 协议调研
|
||||
|
||||
周一先验证,不先改代码定版:
|
||||
Sunshine 在 VM 内用 libx264 软件编码,4 核同时承担 1080p 捕获与编码,
|
||||
是 21.6 FPS 与 13.64% 抖动丢帧的唯一原因;客户端侧链路完全健康。
|
||||
|
||||
```text
|
||||
P100 是否成功 PCIe 直通到 Windows 10 VM
|
||||
-> VM 内 nvidia-smi 是否正常
|
||||
-> CUDA/3D 应用能否使用 GPU
|
||||
-> Sunshine 是否能在 VM 内捕获/硬编码桌面
|
||||
-> RK3528 上是否有可用 Moonlight ARM64 客户端
|
||||
-> Moonlight 是否能调用 VPU 硬解
|
||||
-> RDP 与 Moonlight 的 CAD/3D/视频延迟、帧率、CPU、温度、带宽对比
|
||||
宿主机 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
|
||||
```
|
||||
|
||||
可能的最终协议策略(只能在实测后决定):
|
||||
主机 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 # 调研后再实现
|
||||
└─ MoonlightSession # 当前是 shell 启动器 + moonlght-qt 子进程
|
||||
SessionManager # 根据 API 下发 protocol 选择实现
|
||||
```
|
||||
|
||||
不要在调研前擅自确定 Moonlight 端口、认证字段、库、嵌入方式或把 RDP 删除。
|
||||
不要在定版前擅自确定 Moonlight 的嵌入方式、认证字段或删除 RDP 代码。
|
||||
|
||||
|
||||
### C. 后续 API 对接
|
||||
|
||||
|
||||
BIN
docs/VDI云桌面完整方案-云管端.docx
Normal file
BIN
docs/VDI云桌面完整方案-云管端.docx
Normal file
Binary file not shown.
BIN
docs/VDI云桌面软件开发方案(1).docx
Normal file
BIN
docs/VDI云桌面软件开发方案(1).docx
Normal file
Binary file not shown.
BIN
docs/VDI云终端产品方案-定版摘要(1).docx
Normal file
BIN
docs/VDI云终端产品方案-定版摘要(1).docx
Normal file
Binary file not shown.
@ -1,33 +0,0 @@
|
||||
# FreeRDP 3 客户端 API 参考(Ubuntu 24.04 / freerdp3-dev)
|
||||
|
||||
> 用途:kiosk-shell 内嵌 FreeRDP 客户端时使用,避免拉起外部进程(开发方案 3.2 节)。
|
||||
> 记录时间:2026-10-23
|
||||
|
||||
## 包名
|
||||
- 运行时:`libfreerdp-client3-3`、`libfreerdp3-3`
|
||||
- 开发:`freerdp3-dev`(Ubuntu 24.04 提供 `freerdp3-dev` 与 `libfreerdp-client3-3-dev`)
|
||||
- pkg-config 名称:`freerdp3`、`freerdp3-client`(安装后以 `pkg-config --list-all | grep freerdp` 实测为准)
|
||||
|
||||
## 关键头文件
|
||||
| 头文件 | 用途 |
|
||||
|---|---|
|
||||
| `freerdp/client.h` | 客户端主入口、freerdp_client_context_new / freerdp_client_context_free |
|
||||
| `freerdp/freerdp.h` | 核心上下文 |
|
||||
| `freerdp/settings.h` | 连接参数设置(freerdp_settings_new / set) |
|
||||
| `freerdp/channels/cliprdr.h` | 剪贴板通道 |
|
||||
| `freerdp/gdi/gfx.h` | GFX 图形管线(AVC420/AVC444/RFX 能力) |
|
||||
| `freerdp/crypto/tls.h` | TLS |
|
||||
|
||||
## 最小连接流程(伪代码)
|
||||
```c
|
||||
freerdp_client_context* ctx = freerdp_client_context_new();
|
||||
RdpClientContext* cctx = ...;
|
||||
freerdp_settings_new(0); // 释放: freerdp_settings_free
|
||||
freerdp_set_pointer / freerdp_set_keyboard ... // 输入回调
|
||||
ctx->Connect = ...; // 事件表
|
||||
freerdp_client_context_new / connect
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
- `/gfx` 命令行语法在 3.32.1 上必须用 `|` 分隔的能力组合(如 `progressive|RFX|AVC420|AVC444`),
|
||||
单独 `AVC420` 会解析失败。API 侧对应 `gfx` 能力位设置。
|
||||
@ -1,67 +0,0 @@
|
||||
# FreeRDP 3 内嵌客户端实现笔记(Ubuntu 24.04 / freerdp3-dev 3.32.1)
|
||||
|
||||
> 对应《VDI 云桌面软件开发方案》3.2 / 3.3 节:以 libfreerdp3 API 内嵌(非拉起外部进程)。
|
||||
> 记录时间:2026-10-23,实测于 RK3528 盒子(Armbian / Ubuntu Noble / aarch64)
|
||||
|
||||
## 1. API 结构(与 FreeRDP 2.x 差异很大)
|
||||
|
||||
| 用途 | FreeRDP 3 写法 |
|
||||
|---|---|
|
||||
| 创建实例 | `freerdp* instance = freerdp_new();` |
|
||||
| 建立上下文 | `freerdp_context_new_ex(instance, settings)` |
|
||||
| 上下文 | `instance->context`(`rdpContext*`,公开结构体) |
|
||||
| 事件回调 | `instance->PostConnect` / `instance->PostDisconnect` / `instance->AuthenticateEx` |
|
||||
| 更新回调 | `instance->context->update->BitmapUpdate` / `->EndPaint` |
|
||||
| GDI | `gdi_init(instance, PIXEL_FORMAT_BGRX32)` |
|
||||
| 取帧 | `context->gdi->primary_buffer` + `context->gdi->stride` |
|
||||
|
||||
设置项键名(与 2.x 不同):
|
||||
`FreeRDP_ServerHostname` / `FreeRDP_Username` / `FreeRDP_Password` / `FreeRDP_Domain` /
|
||||
`FreeRDP_DesktopWidth` / `FreeRDP_DesktopHeight` / `FreeRDP_ColorDepth` / `FreeRDP_Fullscreen` /
|
||||
`FreeRDP_IgnoreCertificate` / `FreeRDP_GfxProgressive` / `FreeRDP_GfxH264` / `FreeRDP_GfxAVC444` …
|
||||
|
||||
## 2. 三个必须踩过才知道的坑
|
||||
|
||||
### 坑一:gdi_init 必须在 PostConnect 里调用
|
||||
在 `freerdp_context_new_ex()` 之后立刻调用 `gdi_init()` 会 **SIGSEGV**
|
||||
(`gdi_init_ex` 段错误)。官方 x11 客户端(`client/X11/xf_client.c` 的 `xf_post_connect`)
|
||||
把 `gdi_init` 放在 PostConnect 回调内,因为 GDI 依赖连接协商后的桌面尺寸与颜色格式。
|
||||
|
||||
### 坑二:不能用 instance->ContextSize 扩展 rdpContext
|
||||
常见做法是把自定义指针塞进扩展的 rdpContext,但 `gdi_init_ex()` 会按 `ContextSize`
|
||||
重新分配 context 缓冲区,扩展字段随即失效 → 段错误。
|
||||
改用静态注册表按 `freerdp*` 记录会话对象;GFX 通道回调则用官方的
|
||||
`RdpgfxClientContext.custom` 字段。
|
||||
|
||||
### 坑三:NLA 认证必须传空字符串而非 nullptr
|
||||
Windows 端启用 NLA(CredSSP)时,认证回调里把密码置为 `nullptr` 会导致
|
||||
`SEC_E_NO_CREDENTIALS [0x8009030e]`。必须传 `strdup("")`。
|
||||
认证回调签名:
|
||||
`BOOL (*pAuthenticateEx)(freerdp*, char** username, char** password, char** domain, rdp_auth_reason)`
|
||||
|
||||
## 3. /gfx 语法(命令行与设置项的对应)
|
||||
FreeRDP 3.32.1 的 `/gfx` **不接受单个管道名**,必须是 `|` 分隔的能力组合:
|
||||
|
||||
```
|
||||
/gfx:progressive|RFX|AVC420|AVC444 ← 可解析
|
||||
/gfx:AVC420 ← parsing failed [-1000]
|
||||
/gfx:AVC444 ← parsing failed [-1000]
|
||||
```
|
||||
|
||||
API 侧对应 `FreeRDP_GfxProgressive` / `FreeRDP_GfxH264` / `FreeRDP_GfxAVC444` 等布尔项。
|
||||
|
||||
## 4. 像素格式
|
||||
`PIXEL_FORMAT_BGRX32` 的内存布局是 **B,G,R,X** 四字节/像素。
|
||||
QImage 没有对应格式,输出前需手动做 BGR→RGB 转换。
|
||||
注意 `PIXEL_FORMAT_*` 是计算宏 `FREERDP_PIXEL_FORMAT(...)`,**不能硬编码数值**。
|
||||
|
||||
## 5. 后续演进(对应 freerdp-rk 分支)
|
||||
当前实现是 CPU 侧 GDI 缓冲 → Qt 绘制。开发方案 3.3 节要求改为:
|
||||
|
||||
```text
|
||||
FreeRDP3 解复用(gfx 通道) → GStreamer h264parse → mppvideodec(VPU硬解, zero-copy DMABuf)
|
||||
→ waylandsink(DRM plane)
|
||||
```
|
||||
|
||||
盒子已具备 `/dev/mpp_service`(VPU)与 `/dev/rga`,但缺 `mppvideodec` GStreamer 插件
|
||||
(Ubuntu 官方源没有,需 Rockchip BSP / Armbian 额外源或自研)。
|
||||
93
docs/gpu-passthrough-20261030.md
Normal file
93
docs/gpu-passthrough-20261030.md
Normal file
@ -0,0 +1,93 @@
|
||||
# GPU 直通前置核查与数据看板(2026-10-30)
|
||||
|
||||
## 硬件事实(SSH 到宿主机实测,修正旧文档误判)
|
||||
|
||||
宿主机 `starhe`(192.168.1.51,pve-manager/9.2.21,内核 7.0.2-6-pve):
|
||||
|
||||
```text
|
||||
00:02.0 VGA : Intel 4th Gen Core (Haswell) iGPU [8086:0416] 驱动 i915(宿主显示用,保留)
|
||||
01:00.0 3D : NVIDIA GM204M [GeForce GTX 965M] [10de:13d9] 驱动 nouveau(直通前需解绑)
|
||||
两者 Subsystem 均为 CLEVO/KAPOK [1558:6557]
|
||||
```
|
||||
|
||||
- 机身是 CLEVO 准系统(外形像台式机箱),GPU 是移动版 GM204M/965M——
|
||||
此前"笔记本"与"IOMMU Group 2 独占"的记载部分有误,以本页为准。
|
||||
- HDMI 音频功能(01:00.1)不存在,无需处理。
|
||||
- **IOMMU 实测已启用**(/sys/kernel/iommu_groups 有 15 组,DMAR 生效;
|
||||
内核 6.8+ 默认开启,不需要显式 intel_iommu=on)。
|
||||
- GPU 位于 **IOMMU 组 2**,组内设备:`0000:00:01.0`(root port)+ `0000:01:00.0`(GPU),
|
||||
直通需整组传入。
|
||||
- VM 100:q35 + SeaBIOS、4 核 8G、win10,未配置 hostpci。
|
||||
|
||||
## 明日直通操作单(按序执行)
|
||||
|
||||
1. 约窗口:宿主机重启会让全部 VM(100/101/102)停机。
|
||||
2. 解绑 nouveau:`/etc/modprobe.d/blacklist.conf` 加 `blacklist nouveau`,
|
||||
`update-initramfs -u`,重启宿主机(重启后 `lsmod | grep nouveau` 应为空)。
|
||||
3. 绑 vfio:`/etc/modprobe.d/vfio.conf` 写 `options vfio-pci ids=10de:13d9`
|
||||
(含 root port 的话用 QEMU 参数整组传),重启后确认
|
||||
`lspci -nnk -s 01:00.0` 显示 `Kernel driver in use: vfio-pci`(看板会自动变绿)。
|
||||
4. 挂载:`qm set 100 -hostpci0 00:01.0,pcie=1`(按组传;若启动报隔离不满足,
|
||||
用 `-hostpci0 0000:00:01.0;0000:01:00.0` 形式补传 root port)。
|
||||
vBIOS ROM 先不挂,Code 43 在 R465+ 驱动已移除检测。
|
||||
5. VM 内装驱动:Maxwell 已进 legacy 分支,装 **R580 系**驱动(提前下载好放 VM)。
|
||||
6. 验证:VM 内 `nvidia-smi` 出卡 → 看板"VM 内实时数据"卡片自动出现利用率/显存/温度。
|
||||
7. Sunshine 切 NVENC(配置 encoder=nvenc 或按其自动探测),1080p60 复测帧率。
|
||||
|
||||
## GPU 数据看板(已上线)
|
||||
|
||||
- 地址:`http://192.168.1.51:8090/`(自动刷新 4 秒)
|
||||
- 源码:`tools/gpu_dashboard.py`(宿主机 `/root/gpu_dashboard.py`,
|
||||
systemd 单元 `gpu-dashboard.service` 已设开机自启)
|
||||
- 直通前显示:型号/驱动/直通阶段/IOMMU 状态与组内设备/VM 配置;
|
||||
直通后自动经 `qm guest exec 100` 采集 nvidia-smi:利用率、显存、温度、
|
||||
驱动版本、功耗、NVENC 编码会话数。
|
||||
- 手动重启:`systemctl restart gpu-dashboard`。
|
||||
- 后续:接管理平台后,该数据源可换成平台 GPU 池接口(Web 控制台监控模块复用)。
|
||||
|
||||
---
|
||||
|
||||
# 2026-10-30 实施记录:直通落地成功,卡在 vBIOS,已回退保环境
|
||||
|
||||
## 已完成并持久化
|
||||
|
||||
1. 宿主机:blacklist nouveau + vfio.conf(ids=10de:13d9,8086:0c01) + initramfs 更新 + 重启;
|
||||
重启后 `01:00.0` 由 vfio-pci 接管,nouveau 0。
|
||||
2. VM 100:hostpci0=0000:01:00.0,pcie=1 挂载成功;客户机出现 `3D Video Controller`。
|
||||
3. 驱动:582.66 WHQL 经"PC 下载 → 宿主机 8091 HTTP → VM 内网拉取"进入 VM
|
||||
(VM 直连 NVIDIA CDN 被 403,需走内网中转);安装器兼容性检测拒绝(桌面包 INF 无移动 ID),
|
||||
改用 `pnputil /add-driver Display.Driver/nvcvi.inf /install` 成功——
|
||||
nvcvi.inf 含我们的精确 ID `PCI\VEN_10DE&DEV_13D9&SUBSYS_65571558`,
|
||||
设备绑定成功、`C:\Windows\System32\nvidia-smi.exe` 出现。
|
||||
4. 全部 VM 已设 onboot 自启;宿主机新增 systemd 服务:`gpu-dashboard`(8090)、`dl-http`(8091)。
|
||||
|
||||
## 阻塞点:GM204M 缺少可用 vBIOS,驱动初始化失败(Code 43)
|
||||
|
||||
- 宿主机 dmesg:`Invalid PCI ROM header signature: expecting 0xaa55, got 0xffff` ——
|
||||
该 Optimus 移动卡的 ROM BAR 读不出 vBIOS;sysfs 转储 0 字节。
|
||||
- 已尝试无效:`cpu host,hidden=1`;`args -cpu host,-hypervisor,hv_vendor_id=...,kvm=off`;
|
||||
`rombar=0`(设备直接不枚举)。
|
||||
- 供体 ROM 实验:TechPowerUp 上两个 "GTX 965M" 条目实为 GM206(1427) 改版(芯片不符);
|
||||
同 GM204 的 Clevo GTX 970M(13D8) ROM 经 PCIR 补丁(vendor 10de + device 13d9 + 子系统 6557)
|
||||
后 guest 仍不枚举设备。
|
||||
- 结论:**需要这块卡(或同款 GM204M/13D9)的真 vBIOS 才能完成驱动初始化**。
|
||||
|
||||
## 已回退(保客户演示环境)
|
||||
|
||||
- VM 100 已摘除 hostpci 并重启,Sunshine 正常(x264 路线),47989 开放;
|
||||
失败安装无残留(无 NVIDIA 目录)。
|
||||
- 直通重新启用只需两条命令:`qm set 100 -hostpci0 0000:01:00.0,pcie=1[,romfile=...]` + 重启 VM。
|
||||
|
||||
## 后续选项(按代价排序)
|
||||
|
||||
1. **宿主机整机冷断电 30 秒以上**(现场 1 分钟操作;Maxwell 多次热重启后设备状态可能损坏,
|
||||
冷启动是社区最后一招)→ 开机后重挂 `romfile=gtx965m-fixed.rom`(已留存
|
||||
`/usr/share/kvm/gtx965m-fixed.rom`,即 970M ROM 补丁版)再试。
|
||||
2. **获取真 GM204M(13D9) vBIOS**:任何可进系统的 965M 机器用 GPU-Z 导出
|
||||
(同事/二手渠道),或从 CLEVO BIOS 刷写包提取;放到 `/usr/share/kvm/` 后 romfile 挂载。
|
||||
3. 业务备选:若 965M 直通确认不可行,NVENC 需换卡(桌面 GM204 及以上)或改用其他硬件方案。
|
||||
|
||||
## GPU 数据看板
|
||||
|
||||
`http://192.168.1.51:8090/`(systemd `gpu-dashboard`,源码 `tools/gpu_dashboard.py`)。
|
||||
驱动一旦可初始化,"VM 内实时数据"卡片自动显示利用率/显存/温度/NVENC 会话,无需改代码。
|
||||
39
docs/idd-install-helper.bat
Normal file
39
docs/idd-install-helper.bat
Normal file
@ -0,0 +1,39 @@
|
||||
@echo off
|
||||
REM ============================================================
|
||||
REM MttVDD 虚拟显示驱动 - 一键安装(管理员运行)
|
||||
REM 用法:右键本文件 -> 以管理员身份运行
|
||||
REM ============================================================
|
||||
setlocal
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo [1/4] 关闭可能占用驱动的程序...
|
||||
sc stop SunshineService >nul 2>&1
|
||||
taskkill /f /im sunshine.exe >nul 2>&1
|
||||
|
||||
echo [2/4] 安装驱动签名证书...
|
||||
certutil -addstore -f "Root" "Virtual_Display_Driver.cer"
|
||||
if errorlevel 1 (
|
||||
echo [错误] 证书安装失败,请确认以管理员身份运行
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [3/4] 安装 MttVDD 驱动...
|
||||
pnputil /add-driver "MttVDD.inf" /install
|
||||
if errorlevel 1 (
|
||||
echo [警告] 驱动安装返回错误,尝试另一种方式...
|
||||
rundll32.exe setupapi.dll,InstallHinfSection DefaultInstall 132 .\MttVDD.inf
|
||||
)
|
||||
|
||||
echo [4/4] 显示当前虚拟显示器状态...
|
||||
pnputil /enum-devices /class Display
|
||||
|
||||
echo.
|
||||
echo ============================================================
|
||||
echo 安装完成。请重启 Windows 后:
|
||||
echo 1) 设置 - 显示 - 找到 MttVDD 虚拟显示器
|
||||
echo 2) 分辨率选 1920x1080,刷新率选 60Hz
|
||||
echo 3) 重启 SunshineService 服务
|
||||
echo 重启方式:开始菜单搜索 services.msc -> SunshineService -> 重新启动
|
||||
echo ============================================================
|
||||
pause
|
||||
322
docs/moonlight-kiosk-20261028.md
Normal file
322
docs/moonlight-kiosk-20261028.md
Normal file
@ -0,0 +1,322 @@
|
||||
# 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/麒麟待分配。
|
||||
82
docs/rdp-vs-moonlight-comparison-20261028.md
Normal file
82
docs/rdp-vs-moonlight-comparison-20261028.md
Normal file
@ -0,0 +1,82 @@
|
||||
# RDP 与 Moonlight 实测对比(2026-10-28)
|
||||
|
||||
## 测试环境
|
||||
|
||||
```text
|
||||
终端:RK3528 PRO DDR4,Armbian 25.05,aarch64,LightDM + Xorg
|
||||
显示器:1920×1440 @ 75Hz
|
||||
云桌面:VM 100(win10-test),Proxmox VE 9.2.21,节点 starhe
|
||||
终端到云桌面:192.168.1.188 -> 192.168.1.70,同一局域网 WiFi
|
||||
```
|
||||
|
||||
服务端显示环境改造(本次关键前提):
|
||||
|
||||
```text
|
||||
改造前:Microsoft Basic Display Adapter,1920×1080 @ 1Hz
|
||||
改造后:Virtual Display Driver 8.35.11.365,1920×1080 @ 60Hz
|
||||
```
|
||||
|
||||
改造前 Sunshine 抓不到稳定帧(1Hz),改造后可正常编码。
|
||||
|
||||
## 对比数据
|
||||
|
||||
| 指标 | RDP(当前 kiosk-shell) | Moonlight 1080p30 | Moonlight 1080p60 |
|
||||
|---|---|---|---|
|
||||
| 协议/编码 | RDP GFX,CPU 软解 | H.264 / libx264 软编 | H.264 / libx264 软编 |
|
||||
| 分辨率 | 1920×1440 | 1920×1080 | 1920×1080 |
|
||||
| 终端 CPU | 约 111%(单核近满载) | 约 26% | 约 26% |
|
||||
| 终端内存 | 约 232 MB | 约 192 MB | 约 165 MB |
|
||||
| 终端温度 | 约 58~65℃ | 约 56℃ | 约 53℃ |
|
||||
| 实际帧率 | 无可靠统计(观感为低帧+卷帘) | 12.83 FPS | 22.79 FPS |
|
||||
| 网络丢包 | 未测 | 0.00% | 0.00% |
|
||||
| 网络抖动丢帧 | 未测 | 0.08% | 15.28% |
|
||||
| 网络延迟 | 未测 | 2 ms | 6 ms |
|
||||
| 主机处理延迟 | 未测 | 14.4 / 27.6 / 平均 18.1 ms | 18.8 / 78.3 / 平均 38.2 ms |
|
||||
| 平均渲染耗时 | 未测 | 3.34 ms | 46.26 ms |
|
||||
| 画面观感 | 卷帘、翻页 | 完整清晰,无撕裂 | 完整清晰,无撕裂 |
|
||||
|
||||
## 结论
|
||||
|
||||
1. **卷帘问题不是 RDP 协议的限制,是当前 RDP 客户端实现路径的问题。**
|
||||
Moonlight 在同一台盒子、同一台云桌面、同一网络下画面完整清晰,无卷帘无撕裂,
|
||||
抓屏确认 1080p 画面文字锐利、任务栏时间正确。
|
||||
|
||||
2. **Moonlight 的瓶颈在服务端,不在终端,也不<E4B99F><E4B88D><EFBFBD>网络。**
|
||||
1080p30 时网络丢包 0.00%、延迟 2 ms、渲染 3.34 ms、终端 CPU 26%,
|
||||
但实际帧率仅 12.83 FPS;1080p60 时主机处理延迟升到平均 38.2 ms、渲染 46.26 ms。
|
||||
服务端当前使用 libx264 软件编码(VM 内无 NVIDIA 直通,`nvidia-smi` 不存在),
|
||||
4 核 CPU 同时承担 1080p 编码与桌面捕获,是帧率上限的真正原因。
|
||||
|
||||
3. **RDP 路径的 CPU 代价约为 Moonlight 的 4 倍**(111% vs 26%),
|
||||
因为 RDP 走 `FreeRDP GDI -> CPU 内存复制 -> QImage/QPixmap -> QLabel -> Xorg`,
|
||||
而 Moonlight 只做一次解码和一次窗口呈现。
|
||||
|
||||
4. **RK3528 VPU 硬解在当前 moonlight-qt 6.2.0 下不可用。**
|
||||
`h264_v4l2m2m` / `hevc_v4l2m2m` / `hevc_v4l2request` 均报
|
||||
`Could not find a valid device`,即使盒子存在 /dev/mpp_service、/dev/rga 和
|
||||
/dev/dri/renderD128。需 Rockchip MPP FFmpeg 补丁或 V4L2 request 驱动。
|
||||
|
||||
## 后续建议
|
||||
|
||||
优先级从高到低:
|
||||
|
||||
1. **服务端 GPU 直通**:将宿主机 GTX 965M(PCI 01:00.0,IOMMU Group 2 独占)
|
||||
直通给 VM 100,使 Sunshine 可用 NVENC 硬编码。预期把 1080p 帧率从 13 FPS
|
||||
提升到 60 FPS,同时释放服务端 CPU。需先确认内核 `intel_iommu=on`、
|
||||
`vfio-pci` 已绑定,以及该笔记本 GPU 的 vBIOS ROM 提取。
|
||||
2. **消除帧率随请求升高而抖动**(60 FPS 时抖动丢帧 15.28%):服务端稳定后复测。
|
||||
3. **RK3528 硬解适配**:解决 V4L2 request/m2m 设备缺失,这是达成 4K 的前提。
|
||||
4. **协议选型建议**:
|
||||
- 若 GPU 直通可行,Moonlight 在 CAD/3D/视频场景优于 RDP,建议按产品方案采用双协议:
|
||||
普通办公用 RDP,GPU/CAD/3D 用 Moonlight;
|
||||
- 同时保留并优化 RDP 路径的 Weston + MPP/VPU + DMABUF/DRM 零拷贝改造,
|
||||
使 RDP 在办公场景达到可接受体验。
|
||||
5. 当前 Moonlight 未接 VPU,1080p 软件解码仅 26% CPU 但 4K 会超出能力,
|
||||
需在 4K 目标确定后优先解决硬解。
|
||||
|
||||
## 附:本次变更
|
||||
|
||||
- VM 100:安装 Virtual Display Driver(MttVDD),提供 1920×1080@60Hz 虚拟显示器。
|
||||
- VM 100:安装 Sunshine v2026.914.233613,Web UI 账号 admin / vdi12345。
|
||||
- 终端:安装 moonlight-qt 6.2.0,已与 Sunshine 完成配对。
|
||||
- 终端:kiosk-shell 自启动已恢复(~/.config/autostart/vdi-kiosk.desktop)。
|
||||
74
docs/sunshine-vm-setup-for-colleague.md
Normal file
74
docs/sunshine-vm-setup-for-colleague.md
Normal file
@ -0,0 +1,74 @@
|
||||
# Sunshine 显卡环境配置指引(转给操作 VM 的同事)
|
||||
|
||||
## 背景
|
||||
|
||||
我们在 VM 100(win10-test,192.168.1.70)里装了 Sunshine 用于 Moonlight 串流验证。当前卡在:VM 没有可用 GPU,Windows 只有「Microsoft 基本显示适配器」,分辨率 1024×768、刷新率 1Hz,Sunshine 无法按 30/60fps 捕获编码。
|
||||
|
||||
不需要动 Proxmox,只需要在 Windows 里完成两件事:装虚拟显示器驱动 + 确认 Sunshine 配置。
|
||||
|
||||
## 第 1 步:安装虚拟显示器驱动(IddSampleDriver)
|
||||
|
||||
让 Windows 出现一个虚拟显卡,可自定义分辨率和刷新率:
|
||||
|
||||
1. 下载虚拟显示驱动包(ParrotGeek 的 IddSampleDriver,或等效的「Virtual Display Driver」)。
|
||||
常用地址:https://github.com/itsmikethetech/Virtual-Display-Driver/releases
|
||||
(如果 GitHub 打不开,用镜像:把链接前面加 https://gh-proxy.com/ )
|
||||
2. 解压后右键 `IddSampleDriver.inf` →「安装」。
|
||||
3. 重启 Windows。
|
||||
4. 装好后设备管理器会出现「IddSampleDriver Device」,显示设置里会多出一个虚拟显示器。
|
||||
5. 把虚拟显示器设置为:
|
||||
- 分辨率:1920×1080(先用 1080p,稳定后再试 2K)
|
||||
- 刷新率:60Hz(关键!当前 1Hz 是主要问题)
|
||||
- 如果看到多显示器,把虚拟屏设为「扩展这些显示器」, Sunshine 默认抓主屏;也可以只保留虚拟屏。
|
||||
|
||||
## 第 2 步:确认 NVIDIA 独显是否可用(如果宿主已直通/有驱动)
|
||||
|
||||
打开 PowerShell 执行:
|
||||
|
||||
```powershell
|
||||
nvidia-smi
|
||||
```
|
||||
|
||||
- 如果能输出显卡信息(GTX 965M):很好,说明 VM 内能用 NVIDIA。
|
||||
- 请安装 GeForce 驱动(如未装),然后告知我们,我们把 Sunshine 切到 NVENC 硬编码。
|
||||
- 如果提示找不到命令或没有 NVIDIA 设备:跳过此步,走纯软件编码 + 虚拟显示器路线(也够验证流畅度)。
|
||||
|
||||
## 第 3 步:检查 Sunshine 服务状态
|
||||
|
||||
Sunshine 已装在 `C:\Program Files\Sunshine\`,服务名 `SunshineService`。
|
||||
|
||||
PowerShell 执行:
|
||||
|
||||
```powershell
|
||||
Get-Service SunshineService
|
||||
Restart-Service SunshineService
|
||||
```
|
||||
|
||||
重启服务后再看日志确认识别到新显示器:
|
||||
|
||||
```powershell
|
||||
Get-Content "C:\Program Files\Sunshine\config\sunshine.log" -Tail 60
|
||||
```
|
||||
|
||||
需要看到的关键行(类似):
|
||||
|
||||
```
|
||||
Desktop resolution [1920x1080]
|
||||
Display refresh rate [60 Hz]
|
||||
```
|
||||
|
||||
## 第 4 步:告知我们结果
|
||||
|
||||
只需回三条信息:
|
||||
|
||||
1. 虚拟显示器是否装好,分辨率/刷新率是多少;
|
||||
2. `nvidia-smi` 是否有输出;
|
||||
3. sunshine.log 最后几十行里的 Desktop resolution / Display refresh rate 数值。
|
||||
|
||||
我们这边会立刻重新发起 Moonlight 串流测试并采集性能数据。
|
||||
|
||||
## 备注
|
||||
|
||||
- Web UI(可选):https://192.168.1.70:47990 账号 admin / 密码 vdi12345
|
||||
- 装驱动需要重启 VM,重启后 SunshineService 会自启,无需其他操作。
|
||||
- Sunshine 的串流端口 47984/47989/47990/48010 已确认开放,防火墙无需改动。
|
||||
20
docs/vdd_settings.xml
Normal file
20
docs/vdd_settings.xml
Normal file
@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<display_config>
|
||||
<target_display>
|
||||
<target_x>0</target_x>
|
||||
<target_y>0</target_y>
|
||||
<target_width>1920</target_width>
|
||||
<target_height>1080</target_height>
|
||||
<target_refresh_rate>60</target_refresh_rate>
|
||||
</target_display>
|
||||
<adapter_index>0</adapter_index>
|
||||
<modes>
|
||||
<mode>
|
||||
<width>1920</width>
|
||||
<height>1080</height>
|
||||
<refresh_rate>60</refresh_rate>
|
||||
</mode>
|
||||
</modes>
|
||||
</display_config>
|
||||
</root>
|
||||
287
docs/web-console-mvp-plan-20261028.md
Normal file
287
docs/web-console-mvp-plan-20261028.md
Normal file
@ -0,0 +1,287 @@
|
||||
# VDI Web 管理控制台 MVP 实施方案
|
||||
|
||||
> 版本:v0.1(功能先行,UI 稿待项目经理提供)
|
||||
> 日期:2026-10-28
|
||||
> 依据:`VDI云桌面软件开发方案(1).docx` 第 2、4、5 节及 `VDI云桌面完整方案-云管端.docx` 管理平台模块表。
|
||||
|
||||
## 1. 先明确两个“登录”
|
||||
|
||||
本项目有两个不同的界面,必须分开:
|
||||
|
||||
| 界面 | 使用者 | 运行位置 | 职责 |
|
||||
|---|---|---|---|
|
||||
| 终端 kiosk-shell | 云桌面最终用户 | RK3528 盒子本地全屏 | 首配、设备绑定、用户登录、选择/进入被授权桌面、会话状态 |
|
||||
| Web 管理控制台 console | 超级管理员、运维、只读人员 | PC 浏览器 | 终端、桌面池、用户绑定、GPU、监控、OTA、审计管理 |
|
||||
|
||||
正式文档中的 `console/` 指第二项。它不应运行在盒子上,也不应直接发起 RDP/Moonlight;它通过平台 API 管理资源和下发策略。
|
||||
|
||||
“盒子开机直连 Moonlight”只能作为当前串流技术验证,不是最终产品登录流程。最终终端流程应恢复为:
|
||||
|
||||
```text
|
||||
盒子开机
|
||||
-> kiosk-shell 全屏终端登录/首配页
|
||||
-> vdt-agent 注册并获取终端身份
|
||||
-> 用户登录或终端绑定校验
|
||||
-> 平台返回该用户可用的桌面列表及短期连接信息
|
||||
-> 用户选择桌面
|
||||
-> kiosk-shell 按 protocol=rdp|moonlight 启动会话
|
||||
-> 断开后回桌面列表/登录页
|
||||
```
|
||||
|
||||
## 2. Web 控制台 MVP 目标
|
||||
|
||||
第一版目标不是做漂亮的大屏,而是跑通一个可演示、可替换 UI、可接真实后端的后台系统:
|
||||
|
||||
1. 用户能登录和退出;
|
||||
2. 根据角色控制菜单和按钮;
|
||||
3. 管理员能查看终端、桌面、用户及绑定关系;
|
||||
4. 能对终端下发命令、对桌面执行开关机/重置;
|
||||
5. 能查看基础状态、操作结果和审计记录;
|
||||
6. 后端未完成时使用 Mock API,后端接口上线后不改页面业务逻辑即可切换。
|
||||
|
||||
## 3. 技术栈与工程结构
|
||||
|
||||
按定版文档执行:Vue 3 + Vite + TypeScript 严格模式 + Element Plus。
|
||||
|
||||
建议补充:Pinia(状态)、Vue Router、Axios、Vitest、Playwright、MSW(Mock API)。
|
||||
|
||||
```text
|
||||
console/
|
||||
├─ src/
|
||||
│ ├─ api/ # OpenAPI 生成 SDK 的薄封装
|
||||
│ ├─ assets/
|
||||
│ ├─ components/ # 领域无关组件
|
||||
│ ├─ layouts/ # 登录布局、控制台布局
|
||||
│ ├─ mocks/ # MSW handlers 与演示数据
|
||||
│ ├─ router/ # 路由、鉴权守卫、RBAC meta
|
||||
│ ├─ stores/ # auth、app、notification
|
||||
│ ├─ types/ # 页面 ViewModel;API 类型由生成器管理
|
||||
│ ├─ views/
|
||||
│ │ ├─ login/
|
||||
│ │ ├─ dashboard/
|
||||
│ │ ├─ terminals/
|
||||
│ │ ├─ desktops/
|
||||
│ │ ├─ users/
|
||||
│ │ ├─ gpu/
|
||||
│ │ ├─ monitoring/
|
||||
│ │ ├─ ota/
|
||||
│ │ └─ audit/
|
||||
│ ├─ App.vue
|
||||
│ └─ main.ts
|
||||
├─ e2e/
|
||||
├─ public/
|
||||
├─ .env.development
|
||||
├─ .env.mock
|
||||
├─ package.json
|
||||
└─ vite.config.ts
|
||||
```
|
||||
|
||||
约束:页面不能直接拼 URL 或写 Axios;所有网络调用只经过 `api/`。这样 Mock 与真实后端可无感切换。
|
||||
|
||||
## 4. 页面与优先级
|
||||
|
||||
### M0:登录与应用框架
|
||||
|
||||
- 登录页:用户名、密码、登录按钮、错误状态、加载状态;
|
||||
- 控制台壳:侧边导航、顶部栏、面包屑、当前账号、退出;
|
||||
- 401 自动清理会话并回登录;
|
||||
- 403 权限提示;
|
||||
- 路由级 RBAC:`super_admin`、`operator`、`viewer`;
|
||||
- 开发环境使用 Mock 登录,生产环境只调用真实 API。
|
||||
|
||||
### M1:核心演示闭环
|
||||
|
||||
1. 仪表盘:在线终端、活动会话、运行桌面、告警数量;
|
||||
2. 终端管理:列表、搜索、状态筛选、详情、重启、重置会话;
|
||||
3. 桌面管理:列表、状态筛选、开机、关机、重启、重置;
|
||||
4. 用户与绑定:用户列表、用户详情、终端/桌面绑定与解绑;
|
||||
5. 审计日志:操作者、动作、目标、结果、时间、请求 ID。
|
||||
|
||||
M1 完成后已经可以给项目经理演示完整业务流程。
|
||||
|
||||
### M2:完整文档范围
|
||||
|
||||
- 桌面池:模板、容量、回收策略;
|
||||
- GPU 池:节点/PCI 地址/型号/状态/绑定桌面;
|
||||
- 监控:会话在线数、FPS、RTT、节点水位、告警;
|
||||
- OTA 中心:版本、批次、灰度比例、进度、失败终端;
|
||||
- CSV 用户/绑定批量导入向导;
|
||||
- 配置模板与配置版本下发。
|
||||
|
||||
## 5. 登录与权限方案
|
||||
|
||||
### 登录接口
|
||||
|
||||
```http
|
||||
POST /api/v1/auth/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "******"
|
||||
}
|
||||
```
|
||||
|
||||
建议响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"accessToken": "jwt",
|
||||
"expiresIn": 3600,
|
||||
"user": {
|
||||
"id": "usr_001",
|
||||
"username": "admin",
|
||||
"displayName": "系统管理员",
|
||||
"role": "super_admin"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
建议第一版 access token 仅存内存;刷新 token 使用 `HttpOnly + Secure + SameSite` Cookie。若后端尚未支持 refresh,MVP 可暂存 `sessionStorage`,但不要长期使用 `localStorage` 保存高权限令牌。
|
||||
|
||||
### 权限矩阵
|
||||
|
||||
| 能力 | super_admin | operator | viewer |
|
||||
|---|---:|---:|---:|
|
||||
| 查看所有页面 | 是 | 是 | 是 |
|
||||
| 终端远程命令 | 是 | 是 | 否 |
|
||||
| 桌面生命周期操作 | 是 | 是 | 否 |
|
||||
| 用户/绑定修改 | 是 | 否 | 否 |
|
||||
| GPU 绑定 | 是 | 否 | 否 |
|
||||
| OTA 发布/灰度 | 是 | 是 | 否 |
|
||||
| 只读监控/审计 | 是 | 是 | 是 |
|
||||
|
||||
前端 RBAC 只是交互约束,真正授权必须由 `api-gateway` 再验证,不能只隐藏按钮。
|
||||
|
||||
## 6. 第一版 API 契约
|
||||
|
||||
文档已明确的接口优先沿用:
|
||||
|
||||
```text
|
||||
POST /api/v1/auth/login
|
||||
GET /api/v1/users
|
||||
POST /api/v1/desktop/assign
|
||||
POST /api/v1/pool/{id}/action
|
||||
GET /api/v1/gpu/cards
|
||||
POST /api/v1/gpu/bind
|
||||
GET /api/v1/monitor/query
|
||||
```
|
||||
|
||||
为控制台 MVP 补齐建议接口(需后端确认并写入 OpenAPI):
|
||||
|
||||
```text
|
||||
GET /api/v1/auth/me
|
||||
POST /api/v1/auth/logout
|
||||
GET /api/v1/terminals
|
||||
GET /api/v1/terminals/{id}
|
||||
POST /api/v1/terminals/{id}/commands
|
||||
GET /api/v1/desktops
|
||||
GET /api/v1/desktops/{id}
|
||||
POST /api/v1/desktops/{id}/actions
|
||||
GET /api/v1/bindings
|
||||
DELETE /api/v1/bindings/{id}
|
||||
GET /api/v1/audit-logs
|
||||
```
|
||||
|
||||
所有列表统一查询参数:`page`、`pageSize`、`keyword`、`status`、`sort`;统一响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [],
|
||||
"page": 1,
|
||||
"pageSize": 20,
|
||||
"total": 0,
|
||||
"requestId": "req_xxx"
|
||||
}
|
||||
```
|
||||
|
||||
所有命令型接口必须返回 `commandId`,页面展示“已提交”,再轮询或通过 WebSocket/MQTT 网关显示最终结果,不能把 HTTP 200 当作设备已经执行成功。
|
||||
|
||||
## 7. Mock 先行策略
|
||||
|
||||
后端还没完成时,前端不应硬编码页面数据。使用 MSW 模拟真实 HTTP:
|
||||
|
||||
- 固定 3 个测试账号,对应三角色;
|
||||
- 终端包含在线/离线/会话中/升级中;
|
||||
- 桌面包含运行/停止/错误/任务中;
|
||||
- 所有命令先进入 `pending`,1–3 秒后变成 `success` 或 `failed`;
|
||||
- 登录失败、401、403、网络超时、空列表都要有测试场景;
|
||||
- `VITE_API_MODE=mock|real` 切换数据源。
|
||||
|
||||
项目经理 UI 稿到后,Mock 数据仍然可用于逐页视觉验收。
|
||||
|
||||
## 8. 与终端登录流程的接口边界
|
||||
|
||||
管理员 Web 控制台不直接把 VM 密码写进终端,也不让终端调用 Proxmox API。
|
||||
|
||||
建议终端用户登录流程由 `auth-svc` 单独提供:
|
||||
|
||||
```text
|
||||
POST /api/v1/terminal/session/login
|
||||
GET /api/v1/terminal/session/desktops
|
||||
POST /api/v1/terminal/session/connect-ticket
|
||||
```
|
||||
|
||||
`connect-ticket` 返回短期、一次性连接信息:
|
||||
|
||||
```json
|
||||
{
|
||||
"desktopId": "desk_001",
|
||||
"protocol": "moonlight",
|
||||
"endpoint": "192.168.1.70",
|
||||
"app": "Desktop",
|
||||
"display": { "width": 1920, "height": 1080, "fps": 60 },
|
||||
"ticket": "short-lived-token",
|
||||
"expiresAt": "2026-10-28T16:30:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
这部分不是 `console/` 页面,但必须和 Web 控制台的“用户/终端/桌面绑定”共享同一套后端数据。
|
||||
|
||||
## 9. UI 稿到来前的页面设计原则
|
||||
|
||||
第一版只做稳定工作台,不做营销式页面:
|
||||
|
||||
- 左侧导航 + 顶部工具栏 + 主内容区;
|
||||
- 列表优先,详情使用独立页或抽屉;
|
||||
- 状态使用图标/标签,不依赖单一颜色;
|
||||
- 危险操作二次确认;
|
||||
- 所有异步操作显示提交、执行中、成功、失败四态;
|
||||
- 页面级空态、错误态、无权限态必须完整;
|
||||
- 颜色、字号、间距集中为设计 token,避免 UI 稿到后逐页改 CSS;
|
||||
- 表格列、筛选区、表单字段以配置驱动,减少视觉改版成本。
|
||||
|
||||
## 10. 验收标准
|
||||
|
||||
M1 应达到:
|
||||
|
||||
1. 三种角色都能登录,菜单/按钮权限正确;
|
||||
2. 刷新页面仍能恢复会话,令牌过期自动回登录;
|
||||
3. 终端、桌面、用户/绑定、审计五条流程可演示;
|
||||
4. 命令有执行中与最终结果,不出现“点击无反馈”;
|
||||
5. API 全走统一 SDK/适配层,无页面直连 Axios;
|
||||
6. Vitest 覆盖 auth store、路由守卫、权限判断;
|
||||
7. Playwright 覆盖登录、终端命令、桌面操作、用户绑定四条主路径;
|
||||
8. `npm run lint`、`npm run typecheck`、`npm run test`、`npm run build` 全通过。
|
||||
|
||||
## 11. 推荐实施顺序
|
||||
|
||||
```text
|
||||
第 1 步:初始化 console 工程、路由、布局、设计 token
|
||||
第 2 步:OpenAPI 初稿 + API SDK 生成 + MSW Mock
|
||||
第 3 步:登录、会话恢复、RBAC、错误处理
|
||||
第 4 步:终端管理 + 桌面管理
|
||||
第 5 步:用户与绑定 + 审计日志
|
||||
第 6 步:仪表盘聚合数据
|
||||
第 7 步:Playwright E2E 与生产构建
|
||||
第 8 步:项目经理 UI 稿到后替换视觉层
|
||||
第 9 步:接真实 Go API,关闭 Mock
|
||||
```
|
||||
|
||||
## 12. 当前决策建议
|
||||
|
||||
- 现在先开发 `console/` 的 M0 + M1,不等 UI 稿;
|
||||
- 同时为 `terminal/kiosk-shell` 恢复“登录/桌面列表后连接”的产品流程,不再把开机直连 Moonlight 当最终形态;
|
||||
- Web 控制台第一版只接 Mock,API 契约先和后端负责人确认;
|
||||
- Moonlight/RDP 统一抽象成终端连接票据的 `protocol` 字段,Web 控制台只管理策略,不直接操控串流客户端;
|
||||
- UI 稿到后只调整布局组件与 design tokens,业务路由、权限、API、测试保留。
|
||||
@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.16)
|
||||
project(kiosk-shell CXX)
|
||||
|
||||
# 对应《VDI 云桌面软件开发方案》2.2 节技术栈定版:
|
||||
# kiosk 与 freerdp 插件用 C/C++(贴合 GTK/GStreamer 生态)
|
||||
# kiosk 用 C/C++/Qt;会话通道统一 Moonlight(RDP 渲染管线已移除,代码存于 git 历史)
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_AUTOMOC ON)
|
||||
@ -14,18 +14,20 @@ endif()
|
||||
|
||||
# ---- Qt5 ----
|
||||
find_package(Qt5 REQUIRED COMPONENTS Core Gui Widgets)
|
||||
|
||||
# ---- FreeRDP 3 ----
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(FREERDP REQUIRED IMPORTED_TARGET freerdp3)
|
||||
include(CTest)
|
||||
|
||||
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
|
||||
@ -36,7 +38,54 @@ target_link_libraries(kiosk-shell PRIVATE
|
||||
Qt5::Core
|
||||
Qt5::Gui
|
||||
Qt5::Widgets
|
||||
PkgConfig::FREERDP
|
||||
)
|
||||
|
||||
install(TARGETS kiosk-shell RUNTIME DESTINATION bin)
|
||||
# 看门狗 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)
|
||||
|
||||
40
terminal/kiosk-shell/etc/moonlight.conf
Normal file
40
terminal/kiosk-shell/etc/moonlight.conf
Normal file
@ -0,0 +1,40 @@
|
||||
# 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
|
||||
269
terminal/kiosk-shell/scripts/vdi-moonlight
Normal file
269
terminal/kiosk-shell/scripts/vdi-moonlight
Normal file
@ -0,0 +1,269 @@
|
||||
#!/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
|
||||
@ -92,6 +92,10 @@ 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);
|
||||
|
||||
@ -39,6 +39,9 @@ std::string configPath();
|
||||
// 读取配置;文件不存在时返回默认值
|
||||
TerminalConfig loadConfig();
|
||||
|
||||
// 判断是否已经有 Agent/API 下发的可用桌面连接配置。
|
||||
bool hasConnectionConfig(const TerminalConfig& cfg);
|
||||
|
||||
// 保存配置
|
||||
bool saveConfig(const TerminalConfig& cfg);
|
||||
|
||||
|
||||
196
terminal/kiosk-shell/src/desktop_list_page.cpp
Normal file
196
terminal/kiosk-shell/src/desktop_list_page.cpp
Normal file
@ -0,0 +1,196 @@
|
||||
#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
|
||||
57
terminal/kiosk-shell/src/desktop_list_page.h
Normal file
57
terminal/kiosk-shell/src/desktop_list_page.h
Normal file
@ -0,0 +1,57 @@
|
||||
// 桌面列表页
|
||||
//
|
||||
// 登录成功后展示当前用户可用的云桌面卡片;
|
||||
// 选择桌面后由 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
|
||||
75
terminal/kiosk-shell/src/desktop_service.cpp
Normal file
75
terminal/kiosk-shell/src/desktop_service.cpp
Normal file
@ -0,0 +1,75 @@
|
||||
#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
|
||||
45
terminal/kiosk-shell/src/desktop_service.h
Normal file
45
terminal/kiosk-shell/src/desktop_service.h
Normal file
@ -0,0 +1,45 @@
|
||||
// 桌面服务适配层
|
||||
//
|
||||
// 职责:登录认证、桌面列表、连接票据。
|
||||
// 当前为本地演示实现(账号非空即通过;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
|
||||
56
terminal/kiosk-shell/src/desktop_session.h
Normal file
56
terminal/kiosk-shell/src/desktop_session.h
Normal file
@ -0,0 +1,56 @@
|
||||
// 桌面与会话领域模型
|
||||
//
|
||||
// 产品流程:登录 -> 桌面列表 -> 选择桌面 -> 获取连接票据 -> 启动 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
|
||||
136
terminal/kiosk-shell/src/desktop_settings_dialog.cpp
Normal file
136
terminal/kiosk-shell/src/desktop_settings_dialog.cpp
Normal file
@ -0,0 +1,136 @@
|
||||
#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
|
||||
51
terminal/kiosk-shell/src/desktop_settings_dialog.h
Normal file
51
terminal/kiosk-shell/src/desktop_settings_dialog.h
Normal file
@ -0,0 +1,51 @@
|
||||
// 桌面连接设置对话框
|
||||
//
|
||||
// 现场环境每套部署的桌面地址不同:选择一张桌面卡,
|
||||
// 编辑地址(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
|
||||
152
terminal/kiosk-shell/src/desktop_store.cpp
Normal file
152
terminal/kiosk-shell/src/desktop_store.cpp
Normal file
@ -0,0 +1,152 @@
|
||||
#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
|
||||
37
terminal/kiosk-shell/src/desktop_store.h
Normal file
37
terminal/kiosk-shell/src/desktop_store.h
Normal file
@ -0,0 +1,37 @@
|
||||
// 桌面连接配置持久化(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
|
||||
@ -1,7 +1,6 @@
|
||||
#include "login_page.h"
|
||||
|
||||
#include <QCheckBox>
|
||||
#include <QComboBox>
|
||||
#include <QFile>
|
||||
#include <QFont>
|
||||
#include <QGridLayout>
|
||||
#include <QHBoxLayout>
|
||||
@ -9,35 +8,75 @@
|
||||
#include <QLineEdit>
|
||||
#include <QProcess>
|
||||
#include <QPushButton>
|
||||
#include <QRegularExpression>
|
||||
#include <QTextStream>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
namespace vdi {
|
||||
namespace {
|
||||
|
||||
const char* kResolutionIds[] = {"1920x1440", "2560x1440", "1920x1080", "1280x720"};
|
||||
QString defaultRouteInterface();
|
||||
QString interfaceIPv4(const QString& dev);
|
||||
|
||||
QString detectNetworkSummary() {
|
||||
// 取默认路由的出接口与该接口上的 IPv4 地址
|
||||
// 用 QFile 读 /proc,避免 shell 引号在不同终端下的转义问题
|
||||
const QString dev = defaultRouteInterface();
|
||||
if (dev.isEmpty()) {
|
||||
return QStringLiteral("网络:未连接");
|
||||
}
|
||||
const QString addr = interfaceIPv4(dev);
|
||||
if (addr.isEmpty()) {
|
||||
return QStringLiteral("网络:%1").arg(dev);
|
||||
}
|
||||
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();
|
||||
}
|
||||
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("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("网络:未连接");
|
||||
process.start(QStringLiteral("ip"),
|
||||
QStringList() << QStringLiteral("-4") << QStringLiteral("-o") << QStringLiteral("addr")
|
||||
<< QStringLiteral("show") << QStringLiteral("dev") << dev);
|
||||
if (!process.waitForFinished(2000)) {
|
||||
return QString();
|
||||
}
|
||||
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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
if (iface.isEmpty()) {
|
||||
return QStringLiteral("网络:未连接");
|
||||
}
|
||||
return QStringLiteral("网络:%1 (%2)").arg(iface, addr);
|
||||
return QString();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@ -57,7 +96,7 @@ LoginPage::LoginPage(const TerminalConfig& initial, QWidget* parent)
|
||||
title->setAlignment(Qt::AlignCenter);
|
||||
root->addWidget(title);
|
||||
|
||||
auto* subtitle = new QLabel(QStringLiteral("RK3528 远程桌面接入"), this);
|
||||
auto* subtitle = new QLabel(QStringLiteral("登录后进入您的云桌面"), this);
|
||||
QFont subFont = subtitle->font();
|
||||
subFont.setPointSize(13);
|
||||
subtitle->setFont(subFont);
|
||||
@ -76,39 +115,25 @@ 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);
|
||||
|
||||
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);
|
||||
editUser_ = new QLineEdit(this);
|
||||
editUser_->setPlaceholderText(QStringLiteral("用户名"));
|
||||
editPassword_ = new QLineEdit(this);
|
||||
editPassword_->setEchoMode(QLineEdit::Password);
|
||||
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);
|
||||
editPassword_->setPlaceholderText(QStringLiteral("密码"));
|
||||
|
||||
struct Row {
|
||||
QString label;
|
||||
QWidget* widget;
|
||||
};
|
||||
const Row rows[] = {
|
||||
{QStringLiteral("服务器地址"), editServer_},
|
||||
{QStringLiteral("登录账号"), editUser_},
|
||||
{QStringLiteral("登录密码"), editPassword_},
|
||||
{QStringLiteral("显示分辨率"), comboResolution_},
|
||||
{QStringLiteral("用户名"), editUser_},
|
||||
{QStringLiteral("密码"), editPassword_},
|
||||
};
|
||||
int row = 0;
|
||||
for (const Row& r : rows) {
|
||||
@ -118,84 +143,73 @@ 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);
|
||||
buttonConnect_ = new QPushButton(QStringLiteral("连接"), this);
|
||||
buttonConnect_->setMinimumSize(160, 46);
|
||||
buttonConnect_->setStyleSheet(
|
||||
buttonLogin_ = new QPushButton(QStringLiteral("登 录"), this);
|
||||
buttonLogin_->setMinimumSize(160, 46);
|
||||
buttonLogin_->setStyleSheet(
|
||||
QStringLiteral("QPushButton { background: #2f6feb; color: white; border: none; "
|
||||
"border-radius: 6px; font-weight: bold; }"
|
||||
"QPushButton:disabled { background: #3a4a63; }"));
|
||||
buttonConnect_->setDefault(true);
|
||||
buttonQuit_ = new QPushButton(QStringLiteral("退出"), this);
|
||||
buttonQuit_->setMinimumSize(110, 46);
|
||||
|
||||
buttonRow->addWidget(buttonConnect_);
|
||||
buttonRow->addWidget(buttonQuit_);
|
||||
buttonLogin_->setDefault(true);
|
||||
buttonRow->addWidget(buttonLogin_);
|
||||
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(buttonConnect_, &QPushButton::clicked, this, &LoginPage::onConnectClicked);
|
||||
connect(buttonQuit_, &QPushButton::clicked, this, [this]() { emit quitRequested(); });
|
||||
connect(buttonLogin_, &QPushButton::clicked, this, &LoginPage::onLoginClicked);
|
||||
// 密码框回车直接登录
|
||||
connect(editPassword_, &QLineEdit::returnPressed, this, &LoginPage::onLoginClicked);
|
||||
connect(editUser_, &QLineEdit::returnPressed, this, &LoginPage::onLoginClicked);
|
||||
}
|
||||
|
||||
void LoginPage::setConnectHandler(ConnectHandler handler) {
|
||||
connectHandler_ = std::move(handler);
|
||||
void LoginPage::setLoginHandler(LoginHandler handler) {
|
||||
loginHandler_ = 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) {
|
||||
buttonConnect_->setEnabled(!busy);
|
||||
buttonConnect_->setText(busy ? QStringLiteral("连接中…") : QStringLiteral("连接"));
|
||||
editServer_->setEnabled(!busy);
|
||||
buttonLogin_->setEnabled(!busy);
|
||||
buttonLogin_->setText(busy ? QStringLiteral("登录中…") : QStringLiteral("登 录"));
|
||||
editUser_->setEnabled(!busy);
|
||||
editPassword_->setEnabled(!busy);
|
||||
comboResolution_->setEnabled(!busy);
|
||||
}
|
||||
|
||||
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;
|
||||
TerminalConfig LoginPage::deviceConfig() const {
|
||||
return cfg_;
|
||||
}
|
||||
|
||||
void LoginPage::onConnectClicked() {
|
||||
TerminalConfig cfg = currentConfig();
|
||||
if (cfg.server.empty()) {
|
||||
setStatus(QStringLiteral("请填写服务器地址"), true);
|
||||
void LoginPage::onLoginClicked() {
|
||||
const QString username = editUser_->text().trimmed();
|
||||
const QString password = editPassword_->text();
|
||||
if (username.isEmpty() || password.isEmpty()) {
|
||||
setStatus(QStringLiteral("请输入用户名和密码"), true);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setStatus(QStringLiteral("正在连接 %1 …").arg(QString::fromStdString(cfg.server)));
|
||||
setStatus(QStringLiteral("正在认证…"));
|
||||
|
||||
if (cfg.remember) {
|
||||
saveConfig(cfg);
|
||||
if (loginHandler_) {
|
||||
loginHandler_(username, password);
|
||||
}
|
||||
if (connectHandler_) {
|
||||
connectHandler_(cfg);
|
||||
}
|
||||
emit connectRequested(cfg);
|
||||
emit loginRequested(username, password);
|
||||
}
|
||||
|
||||
} // namespace vdi
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
// 首配 / 登录页
|
||||
// 登录页
|
||||
//
|
||||
// 对应《VDI 云桌面软件开发方案》3.2 节:
|
||||
// 首配页(无配置时进入)——SN 绑定、WiFi 扫码、服务器地址
|
||||
// 状态条——网络 / 会话状态
|
||||
// 产品流程第一步:设备 SN/网络展示 + 用户认证。
|
||||
// 当前为本地演示认证(账号密码非空通过),由 DesktopService 承担;
|
||||
// 页面只负责输入采集与状态展示,不直接连接云桌面。
|
||||
|
||||
#ifndef KIOSK_SHELL_LOGIN_PAGE_H
|
||||
#define KIOSK_SHELL_LOGIN_PAGE_H
|
||||
@ -13,8 +13,6 @@
|
||||
#include "config.h"
|
||||
|
||||
class QLineEdit;
|
||||
class QComboBox;
|
||||
class QCheckBox;
|
||||
class QLabel;
|
||||
class QPushButton;
|
||||
class QGridLayout;
|
||||
@ -26,42 +24,36 @@ class LoginPage : public QWidget {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
using ConnectHandler = std::function<void(const TerminalConfig&)>;
|
||||
// 认证回调:由 MainWindow 接到 DesktopService
|
||||
using LoginHandler = std::function<void(const QString& username, const QString& password)>;
|
||||
|
||||
explicit LoginPage(const TerminalConfig& initial, QWidget* parent = nullptr);
|
||||
|
||||
void setConnectHandler(ConnectHandler handler);
|
||||
void setLoginHandler(LoginHandler handler);
|
||||
void setStatus(const QString& text, bool error = false);
|
||||
void setBusy(bool busy);
|
||||
|
||||
// 供外部回填
|
||||
TerminalConfig currentConfig() const;
|
||||
// 保留设备首配信息(SN/服务器地址),登录不再承载 Windows 直连参数
|
||||
TerminalConfig deviceConfig() const;
|
||||
|
||||
signals:
|
||||
void connectRequested(const vdi::TerminalConfig& cfg);
|
||||
void quitRequested();
|
||||
void loginRequested(const QString& username, const QString& password);
|
||||
|
||||
private slots:
|
||||
void onConnectClicked();
|
||||
void onLoginClicked();
|
||||
|
||||
private:
|
||||
TerminalConfig cfg_;
|
||||
ConnectHandler connectHandler_;
|
||||
LoginHandler loginHandler_;
|
||||
|
||||
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* buttonConnect_ = nullptr;
|
||||
QPushButton* buttonQuit_ = nullptr;
|
||||
QPushButton* buttonLogin_ = nullptr;
|
||||
};
|
||||
|
||||
} // namespace vdi
|
||||
|
||||
Q_DECLARE_METATYPE(vdi::TerminalConfig)
|
||||
|
||||
#endif // KIOSK_SHELL_LOGIN_PAGE_H
|
||||
|
||||
@ -1,69 +1,26 @@
|
||||
#include "main_window.h"
|
||||
|
||||
#include <QAction>
|
||||
#include <QApplication>
|
||||
#include <QCloseEvent>
|
||||
#include <QKeySequence>
|
||||
#include <QLabel>
|
||||
#include <QDateTime>
|
||||
#include <QDebug>
|
||||
#include <QFile>
|
||||
#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 {
|
||||
|
||||
// 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;
|
||||
constexpr int kTicketTimeoutMs = 10000;
|
||||
constexpr int kProcessStartTimeoutMs = 5000;
|
||||
constexpr int kStreamReadyTimeoutMs = 30000;
|
||||
|
||||
} // namespace
|
||||
|
||||
@ -73,15 +30,23 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
|
||||
});
|
||||
setupUi();
|
||||
setupHotkey();
|
||||
setupHeartbeat();
|
||||
|
||||
// BOOT -> CONFIG
|
||||
config_ = loadConfig();
|
||||
fsm_.transitionTo(State::kConfig, "启动完成");
|
||||
showLogin();
|
||||
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("请选择要进入的云桌面"));
|
||||
});
|
||||
}
|
||||
|
||||
MainWindow::~MainWindow() {
|
||||
stopSession();
|
||||
stopSessions();
|
||||
}
|
||||
|
||||
void MainWindow::setupUi() {
|
||||
@ -93,8 +58,6 @@ void MainWindow::setupUi() {
|
||||
stack_->setStyleSheet(QStringLiteral("background: #0f1420;"));
|
||||
setCentralWidget(stack_);
|
||||
|
||||
// 登录页
|
||||
config_ = loadConfig();
|
||||
auto* login = new LoginPage(config_, this);
|
||||
loginPage_ = login;
|
||||
login->setStyleSheet(
|
||||
@ -102,62 +65,83 @@ 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; }"
|
||||
"QCheckBox { color: #8a94a6; }"));
|
||||
"QPushButton:hover { background: #2b3654; }"));
|
||||
stack_->addWidget(login);
|
||||
connect(login, &LoginPage::loginRequested, this, &MainWindow::onLoginRequested);
|
||||
|
||||
connect(login, &LoginPage::connectRequested, this, &MainWindow::onConnectRequested);
|
||||
connect(login, &LoginPage::quitRequested, this, &MainWindow::onQuitRequested);
|
||||
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);
|
||||
|
||||
// 会话视图
|
||||
auto* sessionWidget = new QWidget(this);
|
||||
auto* sessionLayout = new QVBoxLayout(sessionWidget);
|
||||
sessionLayout->setContentsMargins(0, 0, 0, 0);
|
||||
overlay_ = new TransitionOverlay(this);
|
||||
|
||||
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_);
|
||||
|
||||
// 重连定时器
|
||||
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, "重连失败,请检查网络");
|
||||
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;
|
||||
}
|
||||
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::setupHotkey() {
|
||||
// 全局热键 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::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();
|
||||
}
|
||||
|
||||
void MainWindow::onStateChanged(State from, State to, const QString& detail) {
|
||||
@ -169,28 +153,24 @@ 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:
|
||||
if (stack_->currentWidget() != sessionView_->parentWidget()) {
|
||||
stack_->setCurrentWidget(sessionView_->parentWidget());
|
||||
}
|
||||
sessionOverlay_->setText(QStringLiteral("正在连接 %1 …")
|
||||
.arg(QString::fromStdString(config_.server)));
|
||||
sessionOverlay_->show();
|
||||
overlay_->start(QStringLiteral("正在进入云桌面"));
|
||||
break;
|
||||
case State::kSession:
|
||||
sessionOverlay_->hide();
|
||||
break;
|
||||
case State::kReconnect:
|
||||
sessionOverlay_->setText(QStringLiteral("连接已断开,正在重连…"));
|
||||
sessionOverlay_->show();
|
||||
reconnectTimer_->start(kReconnectDelayMs);
|
||||
break;
|
||||
case State::kError:
|
||||
sessionOverlay_->setText(QStringLiteral("连接失败:%1").arg(detail));
|
||||
sessionOverlay_->show();
|
||||
if (loginPage_) {
|
||||
loginPage_->setBusy(false);
|
||||
loginPage_->setStatus(QStringLiteral("连接失败:%1").arg(detail), true);
|
||||
stopSessions();
|
||||
fsm_.transitionTo(State::kDesktopList, QStringLiteral("连接失败"));
|
||||
if (desktopListPage_) {
|
||||
desktopListPage_->setBusy(false);
|
||||
desktopListPage_->setStatus(QStringLiteral("连接失败:%1").arg(detail), true);
|
||||
}
|
||||
break;
|
||||
case State::kBoot:
|
||||
@ -206,136 +186,289 @@ void MainWindow::showLogin() {
|
||||
stack_->setCurrentWidget(loginPage_);
|
||||
if (loginPage_) {
|
||||
loginPage_->setBusy(false);
|
||||
loginPage_->setStatus(QStringLiteral("就绪"));
|
||||
loginPage_->setStatus(QStringLiteral("请输入用户名和密码登录"));
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::onConnectRequested(const vdi::TerminalConfig& cfg) {
|
||||
config_ = cfg;
|
||||
reconnectAttempts_ = 0;
|
||||
startSession(cfg);
|
||||
void MainWindow::showDesktopList() {
|
||||
if (!desktopListPage_) {
|
||||
return;
|
||||
}
|
||||
stack_->setCurrentWidget(desktopListPage_);
|
||||
desktopListPage_->setCurrentUser(currentUser_);
|
||||
desktopListPage_->setBusy(false);
|
||||
}
|
||||
|
||||
void MainWindow::startSession(const TerminalConfig& cfg) {
|
||||
if (!fsm_.transitionTo(State::kConnecting, QString::fromStdString(cfg.server))) {
|
||||
// 已经在 CONNECTING 或 SESSION,忽略重复触发
|
||||
if (fsm_.current() != State::kConnecting) {
|
||||
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_) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
RdpOptions opts;
|
||||
opts.server = cfg.server;
|
||||
opts.username = cfg.username;
|
||||
opts.password = cfg.password;
|
||||
|
||||
// 解析分辨率
|
||||
int w = 1920;
|
||||
int h = 1440;
|
||||
if (sscanf(cfg.resolution.c_str(), "%dx%d", &w, &h) != 2) {
|
||||
w = 1920;
|
||||
h = 1440;
|
||||
}
|
||||
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 阶段;正式产品必须做证书校验
|
||||
|
||||
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, 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);
|
||||
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::stopSession() {
|
||||
if (session_) {
|
||||
session_->stop();
|
||||
session_.reset();
|
||||
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"));
|
||||
|
||||
if (!fsm_.transitionTo(State::kConnecting, QStringLiteral("正在获取连接信息"))) {
|
||||
failConnection(attempt, QStringLiteral("当前状态无法发起连接"));
|
||||
return;
|
||||
}
|
||||
armConnectionTimeout(attempt, ConnectionPhase::kTicket, kTicketTimeoutMs);
|
||||
|
||||
desktopService_.createTicket(desktopId, [this, attempt](const ConnectionTicket& ticket,
|
||||
const QString& error) {
|
||||
if (attempt != connectionAttempt_ || connectionPhase_ != ConnectionPhase::kTicket) {
|
||||
return;
|
||||
}
|
||||
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::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::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("连接配置已保存"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
// FreeRDP 主缓冲是 4 字节/像素,需转换后交给 Qt
|
||||
QImage image = convertFrame(data, width, height, stride, format);
|
||||
if (image.isNull()) {
|
||||
return;
|
||||
}
|
||||
lastFrame_ = image;
|
||||
frameDirty_ = true;
|
||||
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);
|
||||
|
||||
if (sessionView_) {
|
||||
sessionView_->setPixmap(QPixmap::fromImage(lastFrame_).scaled(
|
||||
sessionView_->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation));
|
||||
phaseClock_.restart();
|
||||
connectionPhase_ = ConnectionPhase::kProcessStart;
|
||||
logConnectionPhase(attempt, QStringLiteral("process_start"), QStringLiteral("started"));
|
||||
armConnectionTimeout(attempt, ConnectionPhase::kProcessStart, kProcessStartTimeoutMs);
|
||||
|
||||
return moonlightSession_->start(ticket, [this, attempt](const MoonlightSession::Event& event) {
|
||||
QMetaObject::invokeMethod(
|
||||
this, [this, attempt, event]() { onMoonlightEvent(attempt, event); },
|
||||
Qt::QueuedConnection);
|
||||
});
|
||||
}
|
||||
|
||||
void MainWindow::onMoonlightEvent(quint64 attempt, const MoonlightSession::Event& event) {
|
||||
if (attempt != connectionAttempt_) {
|
||||
return;
|
||||
}
|
||||
|
||||
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::waitForMoonlightWindow(quint64 attempt) {
|
||||
if (attempt != connectionAttempt_ || connectionPhase_ != ConnectionPhase::kStreamReady) {
|
||||
return;
|
||||
}
|
||||
if (!moonlightSession_->activateStreamWindow()) {
|
||||
QTimer::singleShot(100, this, [this, attempt]() { waitForMoonlightWindow(attempt); });
|
||||
return;
|
||||
}
|
||||
|
||||
connectionTimer_->stop();
|
||||
logConnectionPhase(attempt, QStringLiteral("stream_ready"), QStringLiteral("ready"));
|
||||
connectionPhase_ = ConnectionPhase::kSession;
|
||||
fsm_.transitionTo(State::kSession, QStringLiteral("云桌面画面已就绪"));
|
||||
overlay_->onFirstFrame();
|
||||
}
|
||||
|
||||
void MainWindow::failConnection(quint64 attempt, const QString& message) {
|
||||
if (attempt != connectionAttempt_) {
|
||||
return;
|
||||
}
|
||||
connectionTimer_->stop();
|
||||
logConnectionPhase(attempt, QStringLiteral("connection"), QStringLiteral("failed"), message);
|
||||
++connectionAttempt_;
|
||||
connectionPhase_ = ConnectionPhase::kIdle;
|
||||
if (moonlightSession_) {
|
||||
moonlightSession_->stop();
|
||||
}
|
||||
overlay_->finish();
|
||||
if (fsm_.isIn(State::kConnecting) || fsm_.isIn(State::kSession) ||
|
||||
fsm_.isIn(State::kReconnect)) {
|
||||
fsm_.transitionTo(State::kDesktopList, QStringLiteral("连接失败"));
|
||||
}
|
||||
if (desktopListPage_) {
|
||||
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() {
|
||||
stopSession();
|
||||
stopSessions();
|
||||
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: // 重新连接
|
||||
reconnectAttempts_ = 0;
|
||||
stopSession();
|
||||
startSession(config_);
|
||||
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("已有串流进程正在运行"));
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 2: // 断开回登录页
|
||||
stopSession();
|
||||
fsm_.transitionTo(State::kConfig, "已断开");
|
||||
case 2:
|
||||
stopSessions();
|
||||
if (fsm_.current() != State::kConfig) {
|
||||
fsm_.transitionTo(State::kDesktopList, QStringLiteral("已断开"));
|
||||
}
|
||||
break;
|
||||
case 3: // 退出终端
|
||||
case 3:
|
||||
onQuitRequested();
|
||||
break;
|
||||
default: {
|
||||
QMenu menu(this);
|
||||
QAction* reconnect = menu.addAction(QStringLiteral("重新连接"));
|
||||
QAction* disconnect = menu.addAction(QStringLiteral("断开并返回登录页"));
|
||||
QAction* settings = menu.addAction(QStringLiteral("桌面连接设置"));
|
||||
menu.addSeparator();
|
||||
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 == reconnect) {
|
||||
if (chosen == settings) {
|
||||
if (fsm_.current() == State::kDesktopList || fsm_.current() == State::kConfig) {
|
||||
onEditDesktopRequested(QString());
|
||||
}
|
||||
} else if (chosen == reconnect) {
|
||||
onMenuAction(1);
|
||||
} else if (chosen == disconnect) {
|
||||
onMenuAction(2);
|
||||
@ -347,8 +480,4 @@ void MainWindow::onMenuAction(int action) {
|
||||
}
|
||||
}
|
||||
|
||||
bool MainWindow::eventFilter(QObject* watched, QEvent* event) {
|
||||
return QMainWindow::eventFilter(watched, event);
|
||||
}
|
||||
|
||||
} // namespace vdi
|
||||
|
||||
@ -1,27 +1,21 @@
|
||||
// Kiosk 主窗口
|
||||
//
|
||||
// 对应《VDI 云桌面软件开发方案》3.2 节:
|
||||
// Weston(Wayland) + 全屏 Qt 壳进程
|
||||
// 状态机 BOOT -> CONFIG -> CONNECTING -> SESSION -> RECONNECT -> ERROR
|
||||
// 读取下发连接配置(地址/账号模板/显示档位)
|
||||
// 状态条(网络/会话/OTA 进度)
|
||||
// 首配页(SN 绑定、WiFi 扫码、服务器地址)
|
||||
// 以 libfreerdp3 API 内嵌(非拉起外部进程)
|
||||
// 全局热键 Ctrl+Alt+Shift+M 呼出菜单
|
||||
// 产品流程编排:开机 -> 桌面列表 -> 获取连接票据 -> Moonlight 会话 -> 返回列表。
|
||||
|
||||
#ifndef KIOSK_SHELL_MAIN_WINDOW_H
|
||||
#define KIOSK_SHELL_MAIN_WINDOW_H
|
||||
|
||||
#include <QImage>
|
||||
#include <QElapsedTimer>
|
||||
#include <QMainWindow>
|
||||
#include <QPointer>
|
||||
#include <memory>
|
||||
|
||||
#include "config.h"
|
||||
#include "rdp_session.h"
|
||||
#include "desktop_service.h"
|
||||
#include "desktop_session.h"
|
||||
#include "moonlight_session.h"
|
||||
#include "state_machine.h"
|
||||
|
||||
class QLabel;
|
||||
class QStackedWidget;
|
||||
class QStatusBar;
|
||||
class QTimer;
|
||||
@ -29,6 +23,8 @@ class QTimer;
|
||||
namespace vdi {
|
||||
|
||||
class LoginPage;
|
||||
class DesktopListPage;
|
||||
class TransitionOverlay;
|
||||
|
||||
class MainWindow : public QMainWindow {
|
||||
Q_OBJECT
|
||||
@ -37,38 +33,61 @@ public:
|
||||
explicit MainWindow(QWidget* parent = nullptr);
|
||||
~MainWindow() override;
|
||||
|
||||
protected:
|
||||
bool eventFilter(QObject* watched, QEvent* event) override;
|
||||
|
||||
private slots:
|
||||
void onConnectRequested(const vdi::TerminalConfig& cfg);
|
||||
void onLoginRequested(const QString& username, const QString& password);
|
||||
void onDesktopSelected(const QString& desktopId);
|
||||
void onEditDesktopRequested(const QString& desktopId);
|
||||
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 startSession(const TerminalConfig& cfg);
|
||||
void stopSession();
|
||||
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 showLogin();
|
||||
void showDesktopList();
|
||||
void updateStatusBar(const QString& text);
|
||||
|
||||
StateMachine fsm_;
|
||||
std::unique_ptr<RdpSession> session_;
|
||||
DesktopService desktopService_;
|
||||
std::unique_ptr<MoonlightSession> moonlightSession_;
|
||||
ConnectionTicket activeTicket_;
|
||||
QString currentUser_;
|
||||
QString connectingDesktopId_;
|
||||
std::vector<DesktopInfo> desktops_;
|
||||
|
||||
QPointer<LoginPage> loginPage_;
|
||||
QPointer<DesktopListPage> desktopListPage_;
|
||||
QStackedWidget* stack_ = nullptr;
|
||||
QLabel* sessionView_ = nullptr;
|
||||
QLabel* sessionOverlay_ = nullptr;
|
||||
QStatusBar* statusBar_ = nullptr;
|
||||
QTimer* reconnectTimer_ = nullptr;
|
||||
TransitionOverlay* overlay_ = nullptr;
|
||||
QTimer* connectionTimer_ = nullptr;
|
||||
QTimer* heartbeatTimer_ = nullptr;
|
||||
QElapsedTimer connectionClock_;
|
||||
QElapsedTimer phaseClock_;
|
||||
quint64 connectionAttempt_ = 0;
|
||||
ConnectionPhase connectionPhase_ = ConnectionPhase::kIdle;
|
||||
TerminalConfig config_;
|
||||
int reconnectAttempts_ = 0;
|
||||
QImage lastFrame_;
|
||||
bool frameDirty_ = false;
|
||||
};
|
||||
|
||||
} // namespace vdi
|
||||
|
||||
209
terminal/kiosk-shell/src/monitor.cpp
Normal file
209
terminal/kiosk-shell/src/monitor.cpp
Normal file
@ -0,0 +1,209 @@
|
||||
#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
|
||||
65
terminal/kiosk-shell/src/monitor.h
Normal file
65
terminal/kiosk-shell/src/monitor.h
Normal file
@ -0,0 +1,65 @@
|
||||
// 看门狗 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
|
||||
66
terminal/kiosk-shell/src/monitor_main.cpp
Normal file
66
terminal/kiosk-shell/src/monitor_main.cpp
Normal file
@ -0,0 +1,66 @@
|
||||
// 看门狗 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);
|
||||
}
|
||||
277
terminal/kiosk-shell/src/moonlight_session.cpp
Normal file
277
terminal/kiosk-shell/src/moonlight_session.cpp
Normal file
@ -0,0 +1,277 @@
|
||||
#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
|
||||
98
terminal/kiosk-shell/src/moonlight_session.h
Normal file
98
terminal/kiosk-shell/src/moonlight_session.h
Normal file
@ -0,0 +1,98 @@
|
||||
// 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
|
||||
@ -1,326 +0,0 @@
|
||||
#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
|
||||
@ -1,106 +0,0 @@
|
||||
// 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
|
||||
@ -4,12 +4,14 @@ namespace vdi {
|
||||
|
||||
const char* stateToString(State s) {
|
||||
switch (s) {
|
||||
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";
|
||||
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";
|
||||
}
|
||||
return "UNKNOWN";
|
||||
}
|
||||
@ -21,15 +23,24 @@ bool StateMachine::isLegalTransition(State from, State to) {
|
||||
case State::kBoot:
|
||||
return to == State::kConfig || to == State::kError;
|
||||
case State::kConfig:
|
||||
return to == State::kConnecting || to == State::kError;
|
||||
case State::kConnecting:
|
||||
return to == State::kSession || to == State::kReconnect || to == State::kError;
|
||||
case State::kSession:
|
||||
return to == State::kReconnect || to == State::kConfig || to == State::kError;
|
||||
case State::kReconnect:
|
||||
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;
|
||||
case State::kConnecting:
|
||||
return to == State::kSession || to == State::kReconnect ||
|
||||
to == State::kDesktopList || to == State::kError;
|
||||
case State::kSession:
|
||||
return to == State::kReconnect || to == State::kDesktopList ||
|
||||
to == State::kConfig || to == State::kError;
|
||||
case State::kReconnect:
|
||||
return to == State::kConnecting || to == State::kDesktopList ||
|
||||
to == State::kConfig || to == State::kError;
|
||||
case State::kError:
|
||||
return to == State::kConfig || to == State::kConnecting;
|
||||
return to == State::kConfig || to == State::kDesktopList ||
|
||||
to == State::kConnecting;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@ -47,7 +58,8 @@ bool StateMachine::transitionTo(State next, const QString& detail) {
|
||||
}
|
||||
|
||||
bool StateMachine::canConnect() const {
|
||||
return current_ == State::kConfig || current_ == State::kReconnect || current_ == State::kError;
|
||||
return current_ == State::kDesktopList || current_ == State::kConfig ||
|
||||
current_ == State::kReconnect || current_ == State::kError;
|
||||
}
|
||||
|
||||
bool StateMachine::canDisconnect() const {
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
// kiosk-shell 状态机
|
||||
//
|
||||
// 对应《VDI 云桌面软件开发方案》3.2 节:
|
||||
// 状态机:BOOT -> CONFIG(无配置)-> CONNECTING -> SESSION(嵌入 freerdp 窗口)
|
||||
// 状态机:BOOT -> CONFIG/LOGIN -> CONNECTING -> SESSION(Moonlight 串流)
|
||||
// -> RECONNECT -> ERROR
|
||||
//
|
||||
// 状态转换规则:
|
||||
// BOOT 进程启动,读取本地配置
|
||||
// CONFIG 无可用配置或用户停留在登录页,等待输入
|
||||
// CONNECTING 正在建立 RDP 连接
|
||||
// SESSION 远程会话进行中
|
||||
// CONNECTING 正在建立 Moonlight 串流连接
|
||||
// SESSION 远程会话进行中(moonlight-qt 独占全屏)
|
||||
// RECONNECT 会话意外断开,等待重连
|
||||
// ERROR 不可恢复错误,提示后回到 CONFIG
|
||||
|
||||
@ -22,12 +22,14 @@
|
||||
namespace vdi {
|
||||
|
||||
enum class State {
|
||||
kBoot, // 启动中
|
||||
kConfig, // 首配 / 登录页
|
||||
kConnecting, // 连接中
|
||||
kSession, // 会话中
|
||||
kReconnect, // 断线等待重连
|
||||
kError, // 错误
|
||||
kBoot, // 启动中
|
||||
kConfig, // 首配 / 登录页
|
||||
kAuthenticating, // 正在认证
|
||||
kDesktopList, // 桌面列表(登录成功后)
|
||||
kConnecting, // 连接中
|
||||
kSession, // 会话中
|
||||
kReconnect, // 断线等待重连
|
||||
kError, // 错误
|
||||
};
|
||||
|
||||
const char* stateToString(State s);
|
||||
|
||||
128
terminal/kiosk-shell/src/transition_overlay.cpp
Normal file
128
terminal/kiosk-shell/src/transition_overlay.cpp
Normal file
@ -0,0 +1,128 @@
|
||||
#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
|
||||
49
terminal/kiosk-shell/src/transition_overlay.h
Normal file
49
terminal/kiosk-shell/src/transition_overlay.h
Normal file
@ -0,0 +1,49 @@
|
||||
// 会话切换过场层
|
||||
//
|
||||
// 用途:用户点击"连接"后,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
|
||||
53
terminal/kiosk-shell/tests/test_desktop_list_page.cpp
Normal file
53
terminal/kiosk-shell/tests/test_desktop_list_page.cpp
Normal file
@ -0,0 +1,53 @@
|
||||
#include <QPushButton>
|
||||
#include <QSignalSpy>
|
||||
#include <QtTest>
|
||||
|
||||
#include "desktop_list_page.h"
|
||||
|
||||
using namespace vdi;
|
||||
|
||||
class DesktopListPageTest : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
void firstClickDisablesActionsAndBlocksDuplicate();
|
||||
};
|
||||
|
||||
void DesktopListPageTest::firstClickDisablesActionsAndBlocksDuplicate() {
|
||||
DesktopListPage page;
|
||||
DesktopInfo desktop;
|
||||
desktop.id = QStringLiteral("desktop-1");
|
||||
desktop.name = QStringLiteral("Windows 10");
|
||||
desktop.os = QStringLiteral("Windows");
|
||||
desktop.state = DesktopState::kAvailable;
|
||||
page.setDesktops({desktop});
|
||||
|
||||
QPushButton* connectButton = nullptr;
|
||||
QPushButton* editButton = nullptr;
|
||||
const auto buttons = page.findChildren<QPushButton*>();
|
||||
for (QPushButton* button : buttons) {
|
||||
if (button->text() == QStringLiteral("连接")) {
|
||||
connectButton = button;
|
||||
} else if (button->text() == QStringLiteral("设置")) {
|
||||
editButton = button;
|
||||
}
|
||||
}
|
||||
QVERIFY(connectButton);
|
||||
QVERIFY(editButton);
|
||||
|
||||
QSignalSpy spy(&page, &DesktopListPage::desktopSelected);
|
||||
connectButton->click();
|
||||
QCOMPARE(spy.count(), 1);
|
||||
QVERIFY(!connectButton->isEnabled());
|
||||
QVERIFY(!editButton->isEnabled());
|
||||
|
||||
connectButton->click();
|
||||
QCOMPARE(spy.count(), 1);
|
||||
|
||||
page.setBusy(false);
|
||||
QVERIFY(connectButton->isEnabled());
|
||||
QVERIFY(editButton->isEnabled());
|
||||
}
|
||||
|
||||
QTEST_MAIN(DesktopListPageTest)
|
||||
#include "test_desktop_list_page.moc"
|
||||
94
terminal/kiosk-shell/tests/test_moonlight_session.cpp
Normal file
94
terminal/kiosk-shell/tests/test_moonlight_session.cpp
Normal file
@ -0,0 +1,94 @@
|
||||
#include <QtTest>
|
||||
|
||||
#include "moonlight_session.h"
|
||||
|
||||
using namespace vdi;
|
||||
|
||||
class MoonlightSessionTest : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
void parsesSplitReadyMarkers();
|
||||
void requiresBothReadyMarkers();
|
||||
void rejectsSecondStart();
|
||||
};
|
||||
|
||||
void MoonlightSessionTest::parsesSplitReadyMarkers() {
|
||||
MoonlightOutputParser parser;
|
||||
parser.append("Test decode succ");
|
||||
QVERIFY(!parser.isReady());
|
||||
parser.append("essful\nRenderer 'SDL' with 'DRM' backend ");
|
||||
QVERIFY(parser.hasTestDecode());
|
||||
QVERIFY(!parser.hasRenderer());
|
||||
parser.append("chosen\n");
|
||||
QVERIFY(parser.isReady());
|
||||
}
|
||||
|
||||
void MoonlightSessionTest::requiresBothReadyMarkers() {
|
||||
MoonlightOutputParser parser;
|
||||
parser.append("Test decode successful\n");
|
||||
QVERIFY(!parser.isReady());
|
||||
|
||||
parser.reset();
|
||||
parser.append("Renderer 'SDL' chosen\n");
|
||||
QVERIFY(!parser.isReady());
|
||||
|
||||
parser.append("Connection terminated: -1\n");
|
||||
QVERIFY(parser.hasTerminalError());
|
||||
QVERIFY(!parser.isReady());
|
||||
}
|
||||
|
||||
void MoonlightSessionTest::rejectsSecondStart() {
|
||||
QTemporaryDir tempDir;
|
||||
QVERIFY(tempDir.isValid());
|
||||
|
||||
const QString executable = tempDir.filePath(QStringLiteral("moonlight-qt"));
|
||||
QFile script(executable);
|
||||
QVERIFY(script.open(QIODevice::WriteOnly | QIODevice::Text));
|
||||
script.write("#!/bin/sh\n"
|
||||
"printf 'Test decode successful\\n'\n"
|
||||
"printf \"Renderer 'SDL' with 'DRM' backend chosen\\n\"\n"
|
||||
"sleep 5\n");
|
||||
script.close();
|
||||
QVERIFY(script.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner |
|
||||
QFileDevice::ExeOwner | QFileDevice::ReadGroup |
|
||||
QFileDevice::ExeGroup | QFileDevice::ReadOther |
|
||||
QFileDevice::ExeOther));
|
||||
|
||||
const QByteArray oldPath = qgetenv("PATH");
|
||||
qputenv("PATH", tempDir.path().toUtf8() + ':' + oldPath);
|
||||
|
||||
ConnectionTicket ticket;
|
||||
ticket.endpoint = QStringLiteral("127.0.0.1");
|
||||
ticket.app = QStringLiteral("Desktop");
|
||||
|
||||
MoonlightSession session;
|
||||
int started = 0;
|
||||
int ready = 0;
|
||||
int terminal = 0;
|
||||
const auto callback = [&](const MoonlightSession::Event& event) {
|
||||
if (event.type == MoonlightSession::EventType::kStarted) {
|
||||
++started;
|
||||
} else if (event.type == MoonlightSession::EventType::kStreamReady) {
|
||||
++ready;
|
||||
} else if (event.type == MoonlightSession::EventType::kFailed ||
|
||||
event.type == MoonlightSession::EventType::kFinished) {
|
||||
++terminal;
|
||||
}
|
||||
};
|
||||
|
||||
QVERIFY(session.start(ticket, callback));
|
||||
QTRY_COMPARE_WITH_TIMEOUT(started, 1, 2000);
|
||||
QVERIFY(!session.start(ticket, callback));
|
||||
QTRY_COMPARE_WITH_TIMEOUT(ready, 1, 3000);
|
||||
|
||||
session.stop();
|
||||
QTRY_COMPARE_WITH_TIMEOUT(terminal, 1, 4000);
|
||||
QCOMPARE(ready, 1);
|
||||
QCOMPARE(terminal, 1);
|
||||
|
||||
qputenv("PATH", oldPath);
|
||||
}
|
||||
|
||||
QTEST_GUILESS_MAIN(MoonlightSessionTest)
|
||||
#include "test_moonlight_session.moc"
|
||||
8
terminal/kiosk-shell/vdi-kiosk.desktop
Normal file
8
terminal/kiosk-shell/vdi-kiosk.desktop
Normal file
@ -0,0 +1,8 @@
|
||||
[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
|
||||
9
terminal/kiosk-shell/vdi-moonlight.desktop
Normal file
9
terminal/kiosk-shell/vdi-moonlight.desktop
Normal file
@ -0,0 +1,9 @@
|
||||
[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
|
||||
41
tools/deploy_kiosk.sh
Normal file
41
tools/deploy_kiosk.sh
Normal file
@ -0,0 +1,41 @@
|
||||
#!/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"
|
||||
249
tools/gpu_dashboard.py
Normal file
249
tools/gpu_dashboard.py
Normal file
@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env python3
|
||||
"""服务器 GPU 状态看板(部署在 PVE 宿主机上,仅依赖 python3 标准库)。
|
||||
|
||||
数据源:
|
||||
- lspci / /sys / lsmod:GPU 型号、当前驱动(nouveau/vfio-pci/nvidia)、IOMMU 状态
|
||||
- qm config/status:VM 100 的直通配置(hostpci0)与运行状态
|
||||
- 直通生效后:qm guest exec 在 VM 100 内执行 nvidia-smi 采集实时数据
|
||||
(利用率 / 显存 / 温度 / 驱动版本 / NVENC 编码会话)
|
||||
|
||||
用法:python3 gpu_dashboard.py [端口] # 默认 8090,浏览器访问 http://<宿主机IP>:8090/
|
||||
"""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
VMID = 100
|
||||
CACHE_TTL = 2.0
|
||||
GUEST_TIMEOUT = 25
|
||||
|
||||
_lock = threading.Lock()
|
||||
_cache = {"t": 0.0, "data": None}
|
||||
|
||||
|
||||
def run(cmd, timeout=15):
|
||||
try:
|
||||
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
|
||||
return (r.stdout or "").strip()
|
||||
except Exception as e:
|
||||
return "ERROR: %s" % e
|
||||
|
||||
|
||||
def read(path):
|
||||
try:
|
||||
with open(path) as f:
|
||||
return f.read().strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def gpu_identity():
|
||||
out = run("lspci -nnk -s 01:00.0")
|
||||
name = ""
|
||||
driver = ""
|
||||
for line in out.splitlines():
|
||||
if "NVIDIA" in line and not name:
|
||||
idx = line.find("NVIDIA")
|
||||
name = line[idx:].strip()
|
||||
if "Kernel driver in use" in line:
|
||||
driver = line.split(":", 1)[-1].strip()
|
||||
audio = ""
|
||||
for line in run("lspci -nnk -s 01:00.1").splitlines():
|
||||
if "Kernel driver in use" in line:
|
||||
audio = line.split(":", 1)[-1].strip()
|
||||
return {
|
||||
"name": name or "NVIDIA GPU (0000:01:00.0)",
|
||||
"driver": driver or "(无,未绑定)",
|
||||
"audio_driver": audio or "(无)",
|
||||
}
|
||||
|
||||
|
||||
def iommu_state():
|
||||
# 新内核(6.8+)默认启用 Intel DMAR,无需显式 intel_iommu=on;
|
||||
# 以 iommu_groups 是否存在且 GPU 已入组为准,内核参数仅作展示。
|
||||
cmdline = read("/proc/cmdline")
|
||||
flag = "intel_iommu=on" in cmdline or "iommu=on" in cmdline
|
||||
groups = run("find /sys/kernel/iommu_groups -maxdepth 1 -mindepth 1 -type d 2>/dev/null | wc -l")
|
||||
n = int(groups) if groups.isdigit() else 0
|
||||
gpu_group = run("for d in /sys/kernel/iommu_groups/*/devices/0000:01:00.0; do "
|
||||
"[ -e \"$d\" ] && dirname \"$d\" | sed 's|.*iommu_groups/||;s|/devices||'; done")
|
||||
members = run("ls /sys/kernel/iommu_groups/%s/devices/ 2>/dev/null" % gpu_group) if gpu_group else ""
|
||||
if n == 0:
|
||||
state, ok = "未启用(iommu 组数为 0,需 BIOS 开 VT-d + 内核参数 intel_iommu=on)", False
|
||||
elif not gpu_group:
|
||||
state, ok = "已启用,但 GPU 不在任何组内(异常)", False
|
||||
else:
|
||||
state, ok = "已启用(DMAR 生效)", True
|
||||
return {"state": state, "ok": ok, "kernel_flag": flag, "groups": n,
|
||||
"gpu_group": gpu_group, "group_members": members}
|
||||
|
||||
|
||||
def passthrough_state():
|
||||
cfg = run("qm config %d 2>/dev/null" % VMID)
|
||||
hostpci = ""
|
||||
for line in cfg.splitlines():
|
||||
if line.startswith("hostpci"):
|
||||
hostpci = line
|
||||
break
|
||||
status = run("qm status %d 2>/dev/null" % VMID).replace("status: ", "").replace("status:", "").strip()
|
||||
return {"hostpci": hostpci or "(未配置)", "vm_status": status or "unknown"}
|
||||
|
||||
|
||||
def guest_gpu():
|
||||
"""直通后在 VM 内经 guest agent 执行 nvidia-smi;未直通/无驱动时返回 None。"""
|
||||
q = ("nvidia-smi --query-gpu=name,utilization.gpu,memory.used,memory.total,"
|
||||
"temperature.gpu,driver_version,power.draw --format=csv,noheader")
|
||||
out = run("qm guest exec %d -- powershell -NoProfile -Command \"%s\"" % (VMID, q),
|
||||
timeout=GUEST_TIMEOUT)
|
||||
try:
|
||||
d = json.loads(out)
|
||||
except Exception:
|
||||
return None
|
||||
if d.get("returncode") != 0:
|
||||
return None
|
||||
text = (d.get("out-data") or "").strip()
|
||||
if not text or "nvidia-smi" in (d.get("err-data") or ""):
|
||||
return None
|
||||
parts = [p.strip() for p in text.split(",")]
|
||||
if len(parts) < 6:
|
||||
return None
|
||||
info = {
|
||||
"name": parts[0],
|
||||
"utilization": parts[1],
|
||||
"vram_used": parts[2],
|
||||
"vram_total": parts[3],
|
||||
"temperature": parts[4],
|
||||
"driver": parts[5],
|
||||
"power": parts[6] if len(parts) > 6 else "",
|
||||
}
|
||||
enc = run("qm guest exec %d -- powershell -NoProfile -Command \""
|
||||
"nvidia-smi --query-gpu=encoder.stats.sessionCount --format=csv,noheader\"" % VMID,
|
||||
timeout=GUEST_TIMEOUT)
|
||||
try:
|
||||
d2 = json.loads(enc)
|
||||
v = (d2.get("out-data") or "").strip()
|
||||
if d2.get("returncode") == 0 and v:
|
||||
info["enc_sessions"] = v
|
||||
except Exception:
|
||||
pass
|
||||
return info
|
||||
|
||||
|
||||
def collect():
|
||||
ident = gpu_identity()
|
||||
driver = ident["driver"]
|
||||
if "vfio-pci" in driver:
|
||||
stage = "vfio 已接管(可直通/已解绑 nouveau)"
|
||||
elif "nouveau" in driver:
|
||||
stage = "未直通(GPU 由宿主 nouveau 占用,直通前需解绑)"
|
||||
elif "nvidia" in driver:
|
||||
stage = "未直通(GPU 由宿主 nvidia 驱动占用)"
|
||||
else:
|
||||
stage = "GPU 未绑定驱动"
|
||||
data = {
|
||||
"time": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"gpu": ident,
|
||||
"stage": stage,
|
||||
"iommu": iommu_state(),
|
||||
"pt": passthrough_state(),
|
||||
"guest": guest_gpu() if "hostpci" in passthrough_state()["hostpci"] else None,
|
||||
}
|
||||
return data
|
||||
|
||||
|
||||
def get_data():
|
||||
with _lock:
|
||||
now = time.time()
|
||||
if _cache["data"] is not None and now - _cache["t"] < CACHE_TTL:
|
||||
return _cache["data"]
|
||||
data = collect()
|
||||
_cache["data"] = data
|
||||
_cache["t"] = now
|
||||
return data
|
||||
|
||||
|
||||
HTML = """<!DOCTYPE html>
|
||||
<html lang="zh-CN"><head><meta charset="utf-8">
|
||||
<title>服务器 GPU 状态</title>
|
||||
<style>
|
||||
body { background:#0f1420; color:#e6e9ef; font-family:"Microsoft YaHei",sans-serif;
|
||||
margin:0; padding:32px; }
|
||||
h1 { font-size:22px; margin:0 0 4px; }
|
||||
.sub { color:#8a94a6; font-size:12px; margin-bottom:24px; }
|
||||
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(280px,1fr)); gap:14px; max-width:1100px; }
|
||||
.card { background:#1a2233; border:1px solid #2b3654; border-radius:10px; padding:16px 18px; }
|
||||
.card h3 { margin:0 0 10px; font-size:13px; color:#8a94a6; font-weight:normal; }
|
||||
.card .v { font-size:15px; line-height:1.7; word-break:break-all; }
|
||||
.ok { color:#3fb950; } .bad { color:#e05561; } .warn { color:#d29922; }
|
||||
.big { font-size:30px; font-weight:bold; }
|
||||
</style></head><body>
|
||||
<h1>服务器 GPU 状态</h1>
|
||||
<div class="sub" id="time">加载中…(每 4 秒自动刷新)</div>
|
||||
<div class="grid">
|
||||
<div class="card"><h3>显卡</h3><div class="v" id="gpu">-</div></div>
|
||||
<div class="card"><h3>直通阶段</h3><div class="v" id="stage">-</div></div>
|
||||
<div class="card"><h3>IOMMU / VT-d</h3><div class="v" id="iommu">-</div></div>
|
||||
<div class="card"><h3>VM %d 直通配置</h3><div class="v" id="pt">-</div></div>
|
||||
<div class="card"><h3>VM 内实时数据(nvidia-smi)</h3><div class="v" id="guest">直通生效后显示</div></div>
|
||||
</div>
|
||||
<script>
|
||||
const $ = id => document.getElementById(id);
|
||||
function esc(s){ return String(s).replace(/</g,"<"); }
|
||||
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()
|
||||
29
tools/overnight-monitor.sh
Normal file
29
tools/overnight-monitor.sh
Normal file
@ -0,0 +1,29 @@
|
||||
#!/bin/sh
|
||||
set -u
|
||||
LOG_DIR="$HOME/vdi-terminal/logs"
|
||||
LOG_FILE="$LOG_DIR/overnight-$(date +%Y%m%d).log"
|
||||
mkdir -p "$LOG_DIR"
|
||||
log() { printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S %z')" "$*" >> "$LOG_FILE"; }
|
||||
log "monitor_start pid=$$ interval=60s"
|
||||
while :; do
|
||||
now=$(date '+%Y-%m-%d %H:%M:%S %z')
|
||||
shell_pid=$(pgrep -f '^/home/armbian/vdi-terminal/bin/kiosk-shell$' | head -n 1 || true)
|
||||
monitor_pid=$(pgrep -f '^/home/armbian/vdi-terminal/bin/vdi-kiosk-monitor' | head -n 1 || true)
|
||||
if [ -n "$shell_pid" ]; then
|
||||
ps_line=$(ps -o pcpu=,pmem=,rss=,etime= -p "$shell_pid" | tr '\n' ' ')
|
||||
else
|
||||
ps_line="missing"
|
||||
fi
|
||||
temp=$(awk '{printf "%.1f", $1/1000}' /sys/class/thermal/thermal_zone0/temp 2>/dev/null || printf "unknown")
|
||||
mem=$(awk '/MemTotal:|MemAvailable:|SwapFree:/{printf "%s=%sKB ",$1,$2}' /proc/meminfo 2>/dev/null || true)
|
||||
heartbeat="missing"
|
||||
if [ -f /tmp/vdi-kiosk.heartbeat ]; then
|
||||
heartbeat=$(stat -c '%Y' /tmp/vdi-kiosk.heartbeat 2>/dev/null || printf 'unknown')
|
||||
fi
|
||||
rdp=$(ss -tnp 2>/dev/null | grep '192.168.1.70:3389' | tr '\n' ';' || true)
|
||||
starts=$(grep -c '主进程已启动' /tmp/vdi-kiosk-monitor.log 2>/dev/null || printf 0)
|
||||
exits=$(grep -c '主进程已退出' /tmp/vdi-kiosk-monitor.log 2>/dev/null || printf 0)
|
||||
crashes=$(grep -ciE 'segmentation fault|bus error|double free|invalid pointer|aborted|terminate called' /tmp/vdi-kiosk-monitor.log 2>/dev/null || printf 0)
|
||||
log "sample now=$now shell_pid=${shell_pid:-missing} monitor_pid=${monitor_pid:-missing} ps=[$ps_line] temp_c=$temp heartbeat_epoch=$heartbeat rdp=[$rdp] starts=$starts exits=$exits crashes=$crashes $mem"
|
||||
sleep 60
|
||||
done
|
||||
89
tools/pve_term_exec.py
Normal file
89
tools/pve_term_exec.py
Normal file
@ -0,0 +1,89 @@
|
||||
"""Run a command on the PVE node via the same VNC term channel the web console uses.
|
||||
|
||||
Usage: python3 pve_term_exec.py "<command>"
|
||||
Reads ticket from /tmp/pve_t.txt (refreshed automatically if expired).
|
||||
Prints the command output captured from the terminal stream.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import ssl
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
HOST = "https://192.168.1.51:8006"
|
||||
NODE = "starhe"
|
||||
CTX = ssl.create_default_context()
|
||||
CTX.check_hostname = False
|
||||
CTX.verify_mode = ssl.CERT_NONE
|
||||
|
||||
|
||||
def http(path, data=None, ticket=None, csrf=None):
|
||||
body = urllib.parse.urlencode(data).encode() if data is not None else None
|
||||
req = urllib.request.Request(HOST + path, body)
|
||||
if ticket:
|
||||
req.add_header("Cookie", "PVEAuthCookie=" + ticket)
|
||||
if csrf:
|
||||
req.add_header("CSRFPreventionToken", csrf)
|
||||
with urllib.request.urlopen(req, context=CTX, timeout=30) as r:
|
||||
raw = r.read()
|
||||
return json.loads(raw)["data"] if raw else None
|
||||
|
||||
|
||||
def login():
|
||||
d = http("/api2/json/access/ticket",
|
||||
{"username": "root@pam", "password": "yx66668888"})
|
||||
return d["ticket"], d["CSRFPreventionToken"]
|
||||
|
||||
|
||||
def run_cmd(command, timeout=60):
|
||||
ticket, csrf = login()
|
||||
# 1. open a term proxy (VNC channel to node shell)
|
||||
r = http("/api2/json/nodes/%s/termproxy" % NODE, {}, ticket, csrf)
|
||||
port, ticket_vnc = r["port"], r["ticket"]
|
||||
user, realm_sep, _ = "root", "@", "pam"
|
||||
|
||||
import socket
|
||||
sock = socket.create_connection(("192.168.1.51", port), timeout=10)
|
||||
# PVE handshake: username@realm\n ticket\n binary handshake
|
||||
sock.sendall(b"root@pam\n" + ticket_vnc.encode() + b"\n")
|
||||
gh = sock.recv(16)
|
||||
maj, mino = struct.unpack("BB", gh[0:2])
|
||||
said = sock.recv(16 * 4)
|
||||
# client sends its own handshake
|
||||
ch = struct.pack("BB", maj, mino) + b"PVEVNC Client 00" + b"\x00" * 0
|
||||
sock.sendall(ch)
|
||||
|
||||
# 2. run the command in the shell by typing it and reading output
|
||||
marker = "PVEDONE%s" % int(time.time())
|
||||
payload = command + " ; echo " + marker + "\n"
|
||||
time.sleep(0.5)
|
||||
sock.sendall(payload.encode())
|
||||
|
||||
buf = b""
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
sock.settimeout(2)
|
||||
chunk = sock.recv(65536)
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
if marker.encode() in buf.replace(b"\x1b", b"").replace(b"[?2004l", b""):
|
||||
break
|
||||
except socket.timeout:
|
||||
if marker.encode() in buf:
|
||||
break
|
||||
continue
|
||||
sock.close()
|
||||
# strip terminal control chars roughly and the echoed command
|
||||
text = buf.decode("utf-8", "replace")
|
||||
text = text.replace(marker, "")
|
||||
lines = [l for l in text.split("\r\n")]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(run_cmd(sys.argv[1]))
|
||||
55
tools/pve_vm.py
Normal file
55
tools/pve_vm.py
Normal file
@ -0,0 +1,55 @@
|
||||
import json
|
||||
import ssl
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
HOST = "https://192.168.1.51:8006"
|
||||
CTX = ssl.create_default_context()
|
||||
CTX.check_hostname = False
|
||||
CTX.verify_mode = ssl.CERT_NONE
|
||||
|
||||
|
||||
def api(path, data=None, ticket=None, csrf=None, json_body=None):
|
||||
body = None
|
||||
headers = {}
|
||||
if json_body is not None:
|
||||
body = json.dumps(json_body).encode()
|
||||
headers["Content-Type"] = "application/json"
|
||||
elif data is not None:
|
||||
body = urllib.parse.urlencode(data).encode()
|
||||
req = urllib.request.Request(HOST + path, body, headers)
|
||||
if ticket:
|
||||
req.add_header("Cookie", "PVEAuthCookie=" + ticket)
|
||||
if csrf:
|
||||
req.add_header("CSRFPreventionToken", csrf)
|
||||
with urllib.request.urlopen(req, context=CTX, timeout=40) as r:
|
||||
raw = r.read()
|
||||
return json.loads(raw)["data"] if raw else None
|
||||
|
||||
|
||||
def agent(ticket, csrf, command, timeout=90):
|
||||
r = api("/api2/json/nodes/starhe/qemu/100/agent/exec",
|
||||
json_body={"command": command}, ticket=ticket, csrf=csrf)
|
||||
pid = r["pid"]
|
||||
for _ in range(timeout // 2):
|
||||
import time
|
||||
time.sleep(2)
|
||||
st = api("/api2/json/nodes/starhe/qemu/100/agent/exec-status?pid=%d" % pid,
|
||||
ticket=ticket)
|
||||
if st.get("exited"):
|
||||
return st.get("out-data") or "", st.get("err-data") or ""
|
||||
return "TIMEOUT", ""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
d = api("/api2/json/access/ticket",
|
||||
{"username": "root@pam", "password": "yx66668888"})
|
||||
ticket, csrf = d["ticket"], d["CSRFPreventionToken"]
|
||||
ps = sys.argv[1]
|
||||
out, err = agent(ticket, csrf, [
|
||||
"C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe",
|
||||
"-NoProfile", "-Command", ps])
|
||||
print(out)
|
||||
if err:
|
||||
print("ERR:", err[:300])
|
||||
44
tools/syntax_sanity.py
Normal file
44
tools/syntax_sanity.py
Normal file
@ -0,0 +1,44 @@
|
||||
#!/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)
|
||||
84
tools/xwd2png.py
Normal file
84
tools/xwd2png.py
Normal file
@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Convert an XWD dump (from xwd) into a PNG, without external image libraries.
|
||||
|
||||
Usage: xwd -root -silent | python3 xwd2png.py out.png
|
||||
"""
|
||||
import struct
|
||||
import sys
|
||||
import zlib
|
||||
|
||||
|
||||
def read_header(data):
|
||||
fields = struct.unpack(">25I", data[:100])
|
||||
names = [
|
||||
"header_size", "file_version", "pixmap_format", "pixmap_depth",
|
||||
"pixmap_width", "pixmap_height", "xoffset", "byte_order",
|
||||
"bitmap_unit", "bitmap_bit_order", "bitmap_pad", "bits_per_pixel",
|
||||
"bytes_per_line", "visual_class", "red_mask", "green_mask",
|
||||
"blue_mask", "bits_per_rgb", "colormap_entries", "ncolors",
|
||||
"window_width", "window_height", "window_x", "window_y",
|
||||
"window_bdrwidth",
|
||||
]
|
||||
return dict(zip(names, fields))
|
||||
|
||||
|
||||
def write_png(path, width, height, rgb_rows):
|
||||
raw = b"".join(b"\x00" + row for row in rgb_rows)
|
||||
|
||||
def chunk(tag, payload):
|
||||
return (struct.pack(">I", len(payload)) + tag + payload +
|
||||
struct.pack(">I", zlib.crc32(tag + payload) & 0xFFFFFFFF))
|
||||
|
||||
header = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
|
||||
png = (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) +
|
||||
chunk(b"IDAT", zlib.compress(raw, 6)) + chunk(b"IEND", b""))
|
||||
with open(path, "wb") as handle:
|
||||
handle.write(png)
|
||||
|
||||
|
||||
def main():
|
||||
out_path = sys.argv[1] if len(sys.argv) > 1 else "out.png"
|
||||
data = sys.stdin.buffer.read()
|
||||
head = read_header(data)
|
||||
width = head["pixmap_width"]
|
||||
height = head["pixmap_height"]
|
||||
bpp = head["bits_per_pixel"]
|
||||
stride = head["bytes_per_line"]
|
||||
offset = head["header_size"] + head["ncolors"] * 12
|
||||
pixels = data[offset:]
|
||||
|
||||
if bpp == 32:
|
||||
rows = []
|
||||
for y in range(height):
|
||||
line = pixels[y * stride:y * stride + width * 4]
|
||||
out = bytearray(width * 3)
|
||||
for x in range(width):
|
||||
src = x * 4
|
||||
dst = x * 3
|
||||
out[dst] = line[src + 2]
|
||||
out[dst + 1] = line[src + 1]
|
||||
out[dst + 2] = line[src]
|
||||
rows.append(bytes(out))
|
||||
elif bpp == 24:
|
||||
rows = []
|
||||
for y in range(height):
|
||||
line = pixels[y * stride:y * stride + width * 3]
|
||||
out = bytearray(width * 3)
|
||||
for x in range(width):
|
||||
src = x * 3
|
||||
dst = x * 3
|
||||
out[dst] = line[src + 2]
|
||||
out[dst + 1] = line[src + 1]
|
||||
out[dst + 2] = line[src]
|
||||
rows.append(bytes(out))
|
||||
else:
|
||||
sys.stderr.write("unsupported bits_per_pixel=%s\n" % bpp)
|
||||
return 2
|
||||
|
||||
write_png(out_path, width, height, rows)
|
||||
print("%s %dx%d" % (out_path, width, height))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Reference in New Issue
Block a user