重构:移除 RDP 管线并完善会话退出与串流过场
变更详情:删除 RDP 渲染管线,协议统一 Moonlight;退出三层设计与串流过场层落地 - 移除 rdp_session.*、FreeRDP 构建依赖与代码内协议概念,Moonlight 为唯一会话通道 - Ctrl+Alt+Shift+Q 退串流回列表(kiosk 兜底 + moonlight-qt 主路径);登录页去退出按钮 - 新增 TransitionOverlay:全屏过场层盖住串流启动闪烁,进程 CPU 首帧检测后淡出 - Sunshine 写入 sw_preset=ultrafast;实测推翻 x264 瓶颈结论(21.6FPS 帽子在虚拟显示/捕获节奏,CPU≈0%) - 文档:麒麟(101/.71)/Ubuntu(102/.72) 接入信息与四件事落地记录;MEMORY 根因修正
This commit is contained in:
parent
56b9f1faf3
commit
26fde0369e
25
MEMORY.md
25
MEMORY.md
@ -6,13 +6,14 @@
|
||||
- 项目目录:`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 远程桌面
|
||||
- **当前开机默认走 Moonlight 云桌面**(2026-10-28 起):
|
||||
`vdi-kiosk-monitor -- vdi-moonlight` → `moonlight-qt stream 192.168.1.70 Desktop`,
|
||||
满屏 1920x1080 与屏幕 1:1,崩溃/被杀均自动恢复。
|
||||
RDP 路径保留但自启已禁用,可切回。详见 `docs/moonlight-kiosk-20261028.md`
|
||||
- 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++):登录页 → 桌面列表 → 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-kiosk-monitor`、`vdi-moonlight`)
|
||||
|
||||
@ -20,7 +21,7 @@
|
||||
- 所有实现严格参考用户提供的两个正式 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 参数或定版双协议。
|
||||
|
||||
@ -34,8 +35,12 @@
|
||||
可达性判断必须用 TCP 探测;`moonlight-qt` 的 app id 会随服务端重装失效,
|
||||
命令行要用应用名 `Desktop`;RK3528 上 `--video-decoder auto` 会选到不可用的
|
||||
`h264_v4l2m2m`,必须显式 `software`。
|
||||
- 服务端软编码(Sunshine libx264,无 GPU)是 1080p 只有 ~21.6 FPS、抖动丢帧 13.64% 的根因;
|
||||
客户端解码 15 ms、网络丢包 0%、延迟 3 ms,链路健康。
|
||||
- **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 盒子的用户登录页。
|
||||
|
||||
@ -194,3 +194,59 @@ Moonlight 两次失败(38 秒断流、第二次连不上)均为该丢包所
|
||||
决定:保持 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 代码已支持,无需改代码)。
|
||||
|
||||
@ -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)
|
||||
@ -15,10 +15,6 @@ endif()
|
||||
# ---- Qt5 ----
|
||||
find_package(Qt5 REQUIRED COMPONENTS Core Gui Widgets)
|
||||
|
||||
# ---- FreeRDP 3 ----
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(FREERDP REQUIRED IMPORTED_TARGET freerdp3)
|
||||
|
||||
add_executable(kiosk-shell
|
||||
src/main.cpp
|
||||
src/main_window.cpp
|
||||
@ -28,9 +24,9 @@ add_executable(kiosk-shell
|
||||
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
|
||||
@ -41,7 +37,6 @@ target_link_libraries(kiosk-shell PRIVATE
|
||||
Qt5::Core
|
||||
Qt5::Gui
|
||||
Qt5::Widgets
|
||||
PkgConfig::FREERDP
|
||||
)
|
||||
|
||||
# 看门狗 Monitor:独立进程,监控主进程假死/崩溃
|
||||
|
||||
@ -110,13 +110,12 @@ void DesktopListPage::rebuildCards() {
|
||||
textCol->addWidget(osLine);
|
||||
row->addLayout(textCol, 1);
|
||||
|
||||
// 状态/协议标签
|
||||
// 状态标签(会话通道统一 Moonlight,无需按桌面标注协议)
|
||||
QString stateText;
|
||||
QString stateColor;
|
||||
switch (d.state) {
|
||||
case DesktopState::kAvailable:
|
||||
stateText = QStringLiteral("%1 · 可连接").arg(
|
||||
QString::fromLatin1(protocolToString(d.protocol)).toUpper());
|
||||
stateText = QStringLiteral("可连接");
|
||||
stateColor = QStringLiteral("#3fb950");
|
||||
break;
|
||||
case DesktopState::kPending:
|
||||
|
||||
@ -51,7 +51,6 @@ void DesktopService::createTicket(const QString& desktopId, TicketCallback callb
|
||||
.arg(d.name);
|
||||
return;
|
||||
}
|
||||
ticket.protocol = d.protocol;
|
||||
ticket.endpoint = d.endpoint;
|
||||
ticket.app = d.app;
|
||||
ticket.width = d.width;
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
// 桌面与会话领域模型
|
||||
//
|
||||
// 对应产品流程:
|
||||
// 登录 -> 桌面列表 -> 选择桌面 -> 获取连接票据 -> 启动 RDP/Moonlight 会话
|
||||
// 产品流程:登录 -> 桌面列表 -> 选择桌面 -> 获取连接票据 -> 启动 Moonlight 会话
|
||||
//
|
||||
// 协议统一 Moonlight(RDP 渲染路径已移除,内嵌 RDP 代码保留在 git 历史作网关备用)。
|
||||
// 当前阶段由本地 DesktopService 提供演示数据;后续接入管理平台后,
|
||||
// 由 API 适配器返回同样的模型,页面与状态机不感知数据来源。
|
||||
|
||||
@ -14,32 +14,22 @@
|
||||
|
||||
namespace vdi {
|
||||
|
||||
enum class Protocol {
|
||||
kRdp,
|
||||
kMoonlight,
|
||||
};
|
||||
|
||||
inline const char* protocolToString(Protocol p) {
|
||||
return p == Protocol::kRdp ? "rdp" : "moonlight";
|
||||
}
|
||||
|
||||
// 桌面卡片的运行状态
|
||||
enum class DesktopState {
|
||||
kAvailable, // 可连接
|
||||
kPending, // 任务进行中(开机/重置等)
|
||||
kUnassigned, // 尚未分配连接地址/协议,暂不可连接
|
||||
kUnassigned, // 尚未配置连接地址,暂不可连接
|
||||
};
|
||||
|
||||
struct DesktopInfo {
|
||||
QString id;
|
||||
QString name; // 显示名,如 "Windows 10"
|
||||
QString os; // 操作系统描述
|
||||
Protocol protocol = Protocol::kMoonlight;
|
||||
DesktopState state = DesktopState::kUnassigned;
|
||||
|
||||
// 连接参数(kUnassigned 时为空)
|
||||
QString endpoint; // 主机地址
|
||||
QString app; // Moonlight 应用名
|
||||
QString endpoint; // 主机地址(IP / 域名 / VPN 隧道地址)
|
||||
QString app; // Sunshine 应用名
|
||||
int width = 1920;
|
||||
int height = 1080;
|
||||
int fps = 60;
|
||||
@ -48,15 +38,11 @@ struct DesktopInfo {
|
||||
// 一次会话的连接凭据;当前为本地构造,后续由平台 connect-ticket 接口下发
|
||||
struct ConnectionTicket {
|
||||
QString desktopId;
|
||||
Protocol protocol = Protocol::kMoonlight;
|
||||
QString endpoint;
|
||||
QString app;
|
||||
int width = 1920;
|
||||
int height = 1080;
|
||||
int fps = 60;
|
||||
QString username;
|
||||
QString password;
|
||||
int rdpPort = 3389;
|
||||
};
|
||||
|
||||
struct AuthResult {
|
||||
|
||||
@ -43,7 +43,7 @@ DesktopSettingsDialog::DesktopSettingsDialog(std::vector<DesktopInfo> desktops,
|
||||
}
|
||||
form->addRow(QStringLiteral("桌面"), comboDesktop_);
|
||||
|
||||
// 协议统一 Moonlight(RDP 渲染实测差,已不提供选项;代码保留作网关备用)
|
||||
// 会话通道统一 Moonlight,无协议选项(地址/应用名即连接参数)
|
||||
|
||||
editEndpoint_ = new QLineEdit(this);
|
||||
editEndpoint_->setPlaceholderText(QStringLiteral("例如 192.168.1.70 或 VPN 隧道地址"));
|
||||
@ -121,7 +121,6 @@ void DesktopSettingsDialog::onAccepted() {
|
||||
if (d.id != id) {
|
||||
continue;
|
||||
}
|
||||
d.protocol = Protocol::kMoonlight;
|
||||
d.endpoint = editEndpoint_->text().trimmed();
|
||||
d.app = editApp_->text().trimmed();
|
||||
const QString res = comboResolution_->currentData().toString();
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
// 桌面连接设置对话框
|
||||
//
|
||||
// 现场环境每套部署的桌面地址不同:选择一张桌面卡,
|
||||
// 编辑协议(Moonlight/RDP)、地址(IP / 域名 / VPN 隧道地址)、
|
||||
// 应用名、分辨率和帧率,保存后立即生效。
|
||||
// 编辑地址(IP / 域名 / VPN 隧道地址)、应用名、分辨率和帧率,
|
||||
// 保存后立即生效。会话通道统一 Moonlight,无协议选项。
|
||||
// 地址为空 = 待分配,卡片不可连接。
|
||||
|
||||
#ifndef KIOSK_SHELL_DESKTOP_SETTINGS_DIALOG_H
|
||||
|
||||
@ -21,7 +21,6 @@ std::vector<DesktopInfo> defaultDesktops(const QString& migratedServer) {
|
||||
win.id = QStringLiteral("desk-win10-01");
|
||||
win.name = QStringLiteral("Windows 10");
|
||||
win.os = QStringLiteral("Windows 桌面");
|
||||
win.protocol = Protocol::kMoonlight;
|
||||
win.endpoint = migratedServer; // 兼容老盒子:迁移 client.conf 的 server
|
||||
win.app = QStringLiteral("Desktop");
|
||||
win.width = 1920;
|
||||
@ -33,14 +32,12 @@ std::vector<DesktopInfo> defaultDesktops(const QString& migratedServer) {
|
||||
ubuntu.id = QStringLiteral("desk-ubuntu-01");
|
||||
ubuntu.name = QStringLiteral("Ubuntu");
|
||||
ubuntu.os = QStringLiteral("Ubuntu 桌面");
|
||||
ubuntu.protocol = Protocol::kMoonlight;
|
||||
list.push_back(ubuntu);
|
||||
|
||||
DesktopInfo kylin;
|
||||
kylin.id = QStringLiteral("desk-kylin-01");
|
||||
kylin.name = QStringLiteral("麒麟");
|
||||
kylin.os = QStringLiteral("银河麒麟桌面");
|
||||
kylin.protocol = Protocol::kMoonlight;
|
||||
list.push_back(kylin);
|
||||
|
||||
return list;
|
||||
@ -51,9 +48,6 @@ DesktopInfo fromJson(const QJsonObject& obj) {
|
||||
d.id = obj.value(QStringLiteral("id")).toString();
|
||||
d.name = obj.value(QStringLiteral("name")).toString();
|
||||
d.os = obj.value(QStringLiteral("os")).toString();
|
||||
// 协议统一 Moonlight:RDP 渲染路径实测差(CPU 高、卷帘),本地配置不再提供
|
||||
// RDP 选项;RDP 代码保留作网关备用,协议字段仅在平台票据层区分。
|
||||
d.protocol = Protocol::kMoonlight;
|
||||
d.endpoint = obj.value(QStringLiteral("endpoint")).toString();
|
||||
d.app = obj.value(QStringLiteral("app")).toString();
|
||||
d.width = obj.value(QStringLiteral("width")).toInt(1920);
|
||||
@ -68,8 +62,6 @@ QJsonObject toJson(const DesktopInfo& d) {
|
||||
obj.insert(QStringLiteral("id"), d.id);
|
||||
obj.insert(QStringLiteral("name"), d.name);
|
||||
obj.insert(QStringLiteral("os"), d.os);
|
||||
obj.insert(QStringLiteral("protocol"),
|
||||
d.protocol == Protocol::kRdp ? QStringLiteral("rdp") : QStringLiteral("moonlight"));
|
||||
obj.insert(QStringLiteral("endpoint"), d.endpoint);
|
||||
obj.insert(QStringLiteral("app"), d.app);
|
||||
obj.insert(QStringLiteral("width"), d.width);
|
||||
|
||||
@ -146,6 +146,8 @@ LoginPage::LoginPage(const TerminalConfig& initial, QWidget* parent)
|
||||
root->addLayout(grid);
|
||||
|
||||
// ---- 按钮 ----
|
||||
// 登录页不提供"退出"按钮:点击退出会被看门狗拉起,属于维护功能,
|
||||
// 只保留在管理菜单(Ctrl+Alt+Shift+M -> 退出终端)
|
||||
auto* buttonRow = new QHBoxLayout();
|
||||
buttonRow->addStretch(1);
|
||||
buttonLogin_ = new QPushButton(QStringLiteral("登 录"), this);
|
||||
@ -155,11 +157,7 @@ LoginPage::LoginPage(const TerminalConfig& initial, QWidget* parent)
|
||||
"border-radius: 6px; font-weight: bold; }"
|
||||
"QPushButton:disabled { background: #3a4a63; }"));
|
||||
buttonLogin_->setDefault(true);
|
||||
buttonQuit_ = new QPushButton(QStringLiteral("退出"), this);
|
||||
buttonQuit_->setMinimumSize(110, 46);
|
||||
|
||||
buttonRow->addWidget(buttonLogin_);
|
||||
buttonRow->addWidget(buttonQuit_);
|
||||
buttonRow->addStretch(1);
|
||||
root->addLayout(buttonRow);
|
||||
|
||||
@ -171,7 +169,6 @@ LoginPage::LoginPage(const TerminalConfig& initial, QWidget* parent)
|
||||
root->addStretch(1);
|
||||
|
||||
connect(buttonLogin_, &QPushButton::clicked, this, &LoginPage::onLoginClicked);
|
||||
connect(buttonQuit_, &QPushButton::clicked, this, [this]() { emit quitRequested(); });
|
||||
// 密码框回车直接登录
|
||||
connect(editPassword_, &QLineEdit::returnPressed, this, &LoginPage::onLoginClicked);
|
||||
connect(editUser_, &QLineEdit::returnPressed, this, &LoginPage::onLoginClicked);
|
||||
|
||||
@ -38,7 +38,6 @@ public:
|
||||
|
||||
signals:
|
||||
void loginRequested(const QString& username, const QString& password);
|
||||
void quitRequested();
|
||||
|
||||
private slots:
|
||||
void onLoginClicked();
|
||||
@ -53,7 +52,6 @@ private:
|
||||
QLabel* labelSn_ = nullptr;
|
||||
QLabel* labelNetwork_ = nullptr;
|
||||
QPushButton* buttonLogin_ = nullptr;
|
||||
QPushButton* buttonQuit_ = nullptr;
|
||||
};
|
||||
|
||||
} // namespace vdi
|
||||
|
||||
@ -2,77 +2,27 @@
|
||||
|
||||
#include <QApplication>
|
||||
#include <QDateTime>
|
||||
#include <QEvent>
|
||||
#include <QFile>
|
||||
#include <QKeyEvent>
|
||||
#include <QKeySequence>
|
||||
#include <QLabel>
|
||||
#include <QMenu>
|
||||
#include <QMessageBox>
|
||||
#include <QMouseEvent>
|
||||
#include <QPoint>
|
||||
#include <QScreen>
|
||||
#include <QShortcut>
|
||||
#include <QStackedWidget>
|
||||
#include <QStatusBar>
|
||||
#include <QTimer>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include <cstring>
|
||||
#include <freerdp/codec/color.h>
|
||||
#include <freerdp/locale/keyboard.h>
|
||||
#include <freerdp/scancode.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 BGRX32 在当前 ARM64 小端目标上与 QImage::Format_RGB32 的内存布局一致。
|
||||
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();
|
||||
}
|
||||
if (freerdpFormat == PIXEL_FORMAT_BGRX32 || freerdpFormat == PIXEL_FORMAT_BGRA32) {
|
||||
QImage out(static_cast<int>(width), static_cast<int>(height), QImage::Format_RGB32);
|
||||
const size_t rowBytes = static_cast<size_t>(width) * 4;
|
||||
for (uint32_t y = 0; y < height; ++y) {
|
||||
std::memcpy(out.scanLine(static_cast<int>(y)),
|
||||
data + static_cast<size_t>(y) * bytesPerLine, rowBytes);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
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;
|
||||
dst[0] = p[0];
|
||||
dst[1] = p[1];
|
||||
dst[2] = p[2];
|
||||
dst += 3;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
constexpr int kReconnectDelayMs = 3000;
|
||||
constexpr int kMaxReconnectAttempts = 5;
|
||||
// 首帧检测:moonlight-qt 是独立进程,kiosk 拿不到解码回调。
|
||||
// 软解 1080p60 的 CPU 占用远高于启动握手期,因此轮询进程 CPU 时间,
|
||||
// 累计超过阈值即认为串流已出画;超时则强制淡出兜底(时钟频率 100 tick/秒)。
|
||||
constexpr int kFirstFramePollMs = 500;
|
||||
constexpr qint64 kFirstFrameCpuTicks = 120;
|
||||
constexpr qint64 kFirstFrameTimeoutMs = 15000;
|
||||
|
||||
} // namespace
|
||||
|
||||
@ -83,12 +33,10 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
|
||||
setupUi();
|
||||
setupHotkey();
|
||||
setupHeartbeat();
|
||||
keyboardLayout_ = freerdp_keyboard_init(0);
|
||||
|
||||
config_ = loadConfig();
|
||||
|
||||
// 开机永远进入登录页;不再根据本地配置自动连接 RDP,
|
||||
// 会话只能由用户在桌面列表选择后启动。
|
||||
// 开机永远进入登录页;会话只能由用户在桌面列表选择后启动。
|
||||
fsm_.transitionTo(State::kConfig, QStringLiteral("等待登录"));
|
||||
showLogin();
|
||||
}
|
||||
@ -120,7 +68,6 @@ void MainWindow::setupUi() {
|
||||
stack_->addWidget(login);
|
||||
|
||||
connect(login, &LoginPage::loginRequested, this, &MainWindow::onLoginRequested);
|
||||
connect(login, &LoginPage::quitRequested, this, &MainWindow::onQuitRequested);
|
||||
|
||||
// 桌面列表页
|
||||
auto* list = new DesktopListPage(this);
|
||||
@ -136,64 +83,44 @@ void MainWindow::setupUi() {
|
||||
connect(list, &DesktopListPage::editRequested, this, &MainWindow::onEditDesktopRequested);
|
||||
connect(list, &DesktopListPage::logoutRequested, this, &MainWindow::onLogoutRequested);
|
||||
|
||||
// 会话视图
|
||||
auto* sessionWidget = new QWidget(this);
|
||||
auto* sessionLayout = new QVBoxLayout(sessionWidget);
|
||||
sessionLayout->setContentsMargins(0, 0, 0, 0);
|
||||
|
||||
sessionView_ = new QLabel(sessionWidget);
|
||||
sessionView_->setFocusPolicy(Qt::StrongFocus);
|
||||
sessionView_->setMouseTracking(true);
|
||||
sessionView_->installEventFilter(this);
|
||||
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);
|
||||
// 过场层:独立置顶窗口,盖住 moonlight-qt 启动期的小窗口闪烁与分辨率切换,
|
||||
// 首帧检测到后淡出。不属于 stack_(串流期间整个 kiosk 都被 moonlight 覆盖)。
|
||||
overlay_ = new TransitionOverlay(this);
|
||||
|
||||
// 状态条
|
||||
statusBar_ = new QStatusBar(this);
|
||||
statusBar_->setStyleSheet(QStringLiteral("QStatusBar { background: #161d2e; color: #8a94a6; }"));
|
||||
setStatusBar(statusBar_);
|
||||
|
||||
// RDP 重连定时器
|
||||
reconnectTimer_ = new QTimer(this);
|
||||
reconnectTimer_->setSingleShot(true);
|
||||
connect(reconnectTimer_, &QTimer::timeout, this, [this]() {
|
||||
if (fsm_.current() == State::kReconnect && reconnectAttempts_ < kMaxReconnectAttempts) {
|
||||
++reconnectAttempts_;
|
||||
if (activeTicket_.protocol == Protocol::kMoonlight) {
|
||||
startMoonlightSession(activeTicket_);
|
||||
} else {
|
||||
startRdpSession(activeTicket_);
|
||||
}
|
||||
} else if (fsm_.current() == State::kReconnect) {
|
||||
stopSessions();
|
||||
fsm_.transitionTo(State::kDesktopList, QStringLiteral("重连失败,已返回桌面列表"));
|
||||
}
|
||||
});
|
||||
|
||||
frameRefreshTimer_ = new QTimer(this);
|
||||
frameRefreshTimer_->setInterval(33);
|
||||
connect(frameRefreshTimer_, &QTimer::timeout, this, &MainWindow::onFrameRefresh);
|
||||
frameRefreshTimer_->start();
|
||||
// 串流首帧检测
|
||||
firstFrameTimer_ = new QTimer(this);
|
||||
firstFrameTimer_->setInterval(kFirstFramePollMs);
|
||||
connect(firstFrameTimer_, &QTimer::timeout, this, &MainWindow::watchFirstFrame);
|
||||
}
|
||||
|
||||
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); });
|
||||
auto* menuShortcut = new QShortcut(QKeySequence(QStringLiteral("Ctrl+Alt+Shift+M")), this);
|
||||
menuShortcut->setContext(Qt::ApplicationShortcut);
|
||||
connect(menuShortcut, &QShortcut::activated, this, [this]() { onMenuAction(0); });
|
||||
|
||||
// Ctrl+Alt+Shift+Q 退出串流回桌面列表。
|
||||
// 主路径是 moonlight-qt 自带的同一快捷键(串流独占键盘时由它退出,
|
||||
// 进程退出后 onMoonlightState 自动回列表);这里兜底处理
|
||||
// 串流尚未建立、假死或焦点仍在 kiosk 的情况。
|
||||
auto* quitShortcut = new QShortcut(QKeySequence(QStringLiteral("Ctrl+Alt+Shift+Q")), this);
|
||||
quitShortcut->setContext(Qt::ApplicationShortcut);
|
||||
connect(quitShortcut, &QShortcut::activated, this, [this]() {
|
||||
if (fsm_.isIn(State::kConnecting) || fsm_.isIn(State::kSession) ||
|
||||
fsm_.isIn(State::kReconnect)) {
|
||||
stopSessions();
|
||||
fsm_.transitionTo(State::kDesktopList, QStringLiteral("已退出串流"));
|
||||
if (desktopListPage_) {
|
||||
desktopListPage_->setBusy(false);
|
||||
desktopListPage_->setStatus(QStringLiteral("已退出串流"));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void MainWindow::setupHeartbeat() {
|
||||
@ -228,21 +155,14 @@ void MainWindow::onStateChanged(State from, State to, const QString& detail) {
|
||||
refreshDesktopList();
|
||||
break;
|
||||
case State::kConnecting:
|
||||
if (stack_->currentWidget() != sessionView_->parentWidget()) {
|
||||
stack_->setCurrentWidget(sessionView_->parentWidget());
|
||||
}
|
||||
sessionView_->setFocus(Qt::OtherFocusReason);
|
||||
sessionOverlay_->setText(QStringLiteral("正在连接 %1 …").arg(activeTicket_.endpoint));
|
||||
sessionOverlay_->show();
|
||||
firstFrameClock_.restart();
|
||||
overlay_->start(QStringLiteral("正在进入云桌面"));
|
||||
firstFrameTimer_->start();
|
||||
break;
|
||||
case State::kSession:
|
||||
sessionView_->setFocus(Qt::OtherFocusReason);
|
||||
sessionOverlay_->hide();
|
||||
// 串流画面由 moonlight-qt 全屏呈现;过场层由首帧检测负责淡出
|
||||
break;
|
||||
case State::kReconnect:
|
||||
sessionOverlay_->setText(QStringLiteral("连接已断开,正在重连…"));
|
||||
sessionOverlay_->show();
|
||||
reconnectTimer_->start(kReconnectDelayMs);
|
||||
break;
|
||||
case State::kError:
|
||||
stopSessions();
|
||||
@ -322,18 +242,12 @@ void MainWindow::onDesktopSelected(const QString& desktopId) {
|
||||
return;
|
||||
}
|
||||
activeTicket_ = ticket;
|
||||
reconnectAttempts_ = 0;
|
||||
|
||||
if (ticket.protocol == Protocol::kMoonlight) {
|
||||
startMoonlightSession(ticket);
|
||||
} else {
|
||||
startRdpSession(ticket);
|
||||
}
|
||||
startMoonlightSession(ticket);
|
||||
});
|
||||
}
|
||||
|
||||
void MainWindow::onEditDesktopRequested(const QString& desktopId) {
|
||||
// 桌面连接设置:编辑协议/地址/应用名/分辨率/帧率,保存后立即刷新列表。
|
||||
// 桌面连接设置:编辑地址/应用名/分辨率/帧率,保存后立即刷新列表。
|
||||
// 现场环境地址不同或走 VPN 隧道时,在这里改地址即可,无需改代码。
|
||||
DesktopSettingsDialog dialog(desktops_, desktopId, this);
|
||||
dialog.setStyleSheet(
|
||||
@ -361,81 +275,10 @@ void MainWindow::onLogoutRequested() {
|
||||
fsm_.transitionTo(State::kConfig, QStringLiteral("已退出登录"));
|
||||
}
|
||||
|
||||
void MainWindow::startRdpSession(const ConnectionTicket& ticket) {
|
||||
++sessionEpoch_;
|
||||
{
|
||||
QMutexLocker locker(&frameMutex_);
|
||||
latestFrame_ = QImage();
|
||||
frameEpoch_ = sessionEpoch_;
|
||||
frameWakePending_ = false;
|
||||
}
|
||||
if (fsm_.current() == State::kSession) {
|
||||
fsm_.transitionTo(State::kReconnect, QStringLiteral("手动重新连接"));
|
||||
}
|
||||
if (!fsm_.transitionTo(State::kConnecting, ticket.endpoint)) {
|
||||
if (fsm_.current() != State::kConnecting) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
RdpOptions opts;
|
||||
opts.server = ticket.endpoint.toStdString();
|
||||
opts.username = ticket.username.toStdString();
|
||||
opts.password = ticket.password.toStdString();
|
||||
opts.keyboardLayout = keyboardLayout_;
|
||||
opts.width = ticket.width;
|
||||
opts.height = ticket.height;
|
||||
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 线程。
|
||||
const QImage image = convertFrame(data, width, height, stride, format);
|
||||
if (image.isNull()) {
|
||||
return;
|
||||
}
|
||||
QMutexLocker locker(&frameMutex_);
|
||||
latestFrame_ = image;
|
||||
frameEpoch_ = sessionEpoch_;
|
||||
if (frameWakePending_) {
|
||||
return;
|
||||
}
|
||||
frameWakePending_ = true;
|
||||
locker.unlock();
|
||||
QMetaObject::invokeMethod(this, &MainWindow::onFrameWakeup, Qt::QueuedConnection);
|
||||
};
|
||||
cb.onState = [this](bool connected, const std::string& message) {
|
||||
QMetaObject::invokeMethod(
|
||||
this,
|
||||
[this, connected, message]() {
|
||||
onSessionState(connected, QString::fromStdString(message));
|
||||
},
|
||||
Qt::QueuedConnection);
|
||||
};
|
||||
|
||||
rdpSession_ = std::make_unique<RdpSession>();
|
||||
rdpSession_->start(opts, cb);
|
||||
}
|
||||
|
||||
void MainWindow::startMoonlightSession(const ConnectionTicket& ticket) {
|
||||
if (!fsm_.transitionTo(State::kConnecting, ticket.endpoint)) {
|
||||
if (fsm_.current() != State::kConnecting) {
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Moonlight 是外部全屏进程:Kiosk 画面停在"正在连接"覆盖层,
|
||||
// 串流窗口由 moonlight-qt 自行置顶显示。
|
||||
if (stack_->currentWidget() != sessionView_->parentWidget()) {
|
||||
stack_->setCurrentWidget(sessionView_->parentWidget());
|
||||
}
|
||||
sessionOverlay_->setText(QStringLiteral("正在连接 %1 …").arg(ticket.endpoint));
|
||||
sessionOverlay_->show();
|
||||
|
||||
MoonlightSession::Options opts;
|
||||
opts.decoder = QStringLiteral("software"); // RK3528 无可用 V4L2 硬解设备
|
||||
@ -454,7 +297,8 @@ void MainWindow::startMoonlightSession(const ConnectionTicket& ticket) {
|
||||
|
||||
void MainWindow::onMoonlightState(bool running, const QString& message) {
|
||||
if (running) {
|
||||
// 进程已拉起;串流窗口是否成功由 moonlight 自身展示
|
||||
// 进程已拉起:转入会话态;过场层等首帧检测淡出
|
||||
fsm_.transitionTo(State::kSession, message);
|
||||
updateStatusBar(QStringLiteral("SESSION %1").arg(message));
|
||||
return;
|
||||
}
|
||||
@ -470,59 +314,31 @@ void MainWindow::onMoonlightState(bool running, const QString& message) {
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::onSessionState(bool connected, const QString& message) {
|
||||
if (connected) {
|
||||
reconnectAttempts_ = 0;
|
||||
fsm_.transitionTo(State::kSession, message);
|
||||
} else {
|
||||
if (fsm_.current() == State::kConnecting) {
|
||||
// 首次连接失败:回桌面列表并提示
|
||||
stopSessions();
|
||||
fsm_.transitionTo(State::kDesktopList, message);
|
||||
if (desktopListPage_) {
|
||||
desktopListPage_->setBusy(false);
|
||||
desktopListPage_->setStatus(QStringLiteral("连接失败:%1").arg(message), true);
|
||||
}
|
||||
} else if (fsm_.current() == State::kSession) {
|
||||
fsm_.transitionTo(State::kReconnect, message);
|
||||
void MainWindow::watchFirstFrame() {
|
||||
if (!moonlightSession_ || !moonlightSession_->isRunning()) {
|
||||
return; // 进程退出路径由 onMoonlightState 收尾
|
||||
}
|
||||
qint64 cpuTicks = -1;
|
||||
// /proc/<pid>/stat 的 comm 字段可含空格,从最后一个 ')' 后取字段:
|
||||
// 第 0 个是 state(整行第 3 字段),utime/stime 是第 11/12 个。
|
||||
QFile statFile(QStringLiteral("/proc/%1/stat").arg(moonlightSession_->processId()));
|
||||
if (statFile.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
const QString content = QString::fromLatin1(statFile.readAll());
|
||||
const QStringList fields =
|
||||
content.mid(content.lastIndexOf(')') + 1).split(' ', Qt::SkipEmptyParts);
|
||||
if (fields.size() > 12) {
|
||||
cpuTicks = fields.value(11).toLongLong() + fields.value(12).toLongLong();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::onFrameWakeup() {
|
||||
QMutexLocker locker(&frameMutex_);
|
||||
frameWakePending_ = false;
|
||||
}
|
||||
|
||||
void MainWindow::onFrameRefresh() {
|
||||
QImage image;
|
||||
uint64_t epoch = 0;
|
||||
{
|
||||
QMutexLocker locker(&frameMutex_);
|
||||
image = latestFrame_;
|
||||
epoch = frameEpoch_;
|
||||
latestFrame_ = QImage();
|
||||
frameWakePending_ = false;
|
||||
}
|
||||
if (epoch == sessionEpoch_) {
|
||||
onFrameImage(image);
|
||||
if (cpuTicks >= kFirstFrameCpuTicks || firstFrameClock_.elapsed() >= kFirstFrameTimeoutMs) {
|
||||
onFirstFrameDetected();
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::onFrameImage(const QImage& image) {
|
||||
if (image.isNull()) {
|
||||
return;
|
||||
}
|
||||
lastFrame_ = image;
|
||||
|
||||
if (sessionView_) {
|
||||
const QSize targetSize = sessionView_->size();
|
||||
if (lastFrame_.size() == targetSize) {
|
||||
sessionView_->setPixmap(QPixmap::fromImage(lastFrame_));
|
||||
} else {
|
||||
sessionView_->setPixmap(QPixmap::fromImage(lastFrame_).scaled(
|
||||
targetSize, Qt::KeepAspectRatio, Qt::FastTransformation));
|
||||
}
|
||||
void MainWindow::onFirstFrameDetected() {
|
||||
firstFrameTimer_->stop();
|
||||
if (overlay_) {
|
||||
overlay_->onFirstFrame();
|
||||
}
|
||||
}
|
||||
|
||||
@ -532,31 +348,24 @@ void MainWindow::onQuitRequested() {
|
||||
}
|
||||
|
||||
void MainWindow::stopSessions() {
|
||||
if (rdpSession_) {
|
||||
rdpSession_->stop();
|
||||
rdpSession_.reset();
|
||||
if (firstFrameTimer_) {
|
||||
firstFrameTimer_->stop();
|
||||
}
|
||||
if (moonlightSession_) {
|
||||
moonlightSession_->stop();
|
||||
moonlightSession_.reset();
|
||||
}
|
||||
QMutexLocker locker(&frameMutex_);
|
||||
latestFrame_ = QImage();
|
||||
lastFrame_ = QImage();
|
||||
frameWakePending_ = false;
|
||||
if (overlay_) {
|
||||
overlay_->finish();
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::onMenuAction(int action) {
|
||||
switch (action) {
|
||||
case 1: // 重新连接当前桌面
|
||||
if (fsm_.canConnect() && !activeTicket_.desktopId.isEmpty()) {
|
||||
reconnectAttempts_ = 0;
|
||||
stopSessions();
|
||||
if (activeTicket_.protocol == Protocol::kMoonlight) {
|
||||
startMoonlightSession(activeTicket_);
|
||||
} else {
|
||||
startRdpSession(activeTicket_);
|
||||
}
|
||||
startMoonlightSession(activeTicket_);
|
||||
}
|
||||
break;
|
||||
case 2: // 断开回桌面列表
|
||||
@ -565,7 +374,7 @@ void MainWindow::onMenuAction(int action) {
|
||||
fsm_.transitionTo(State::kDesktopList, "已断开");
|
||||
}
|
||||
break;
|
||||
case 3: // 退出终端
|
||||
case 3: // 退出终端(维护功能,只保留在管理菜单)
|
||||
onQuitRequested();
|
||||
break;
|
||||
default: {
|
||||
@ -588,90 +397,9 @@ void MainWindow::onMenuAction(int action) {
|
||||
} else if (chosen == quit) {
|
||||
onMenuAction(3);
|
||||
}
|
||||
if (stack_->currentWidget() == sessionView_->parentWidget()) {
|
||||
sessionView_->setFocus(Qt::OtherFocusReason);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool MainWindow::eventFilter(QObject* watched, QEvent* event) {
|
||||
if (watched == sessionView_ && rdpSession_) {
|
||||
if (event->type() == QEvent::KeyPress || event->type() == QEvent::KeyRelease) {
|
||||
auto* keyEvent = static_cast<QKeyEvent*>(event);
|
||||
return forwardKeyEvent(keyEvent, event->type() == QEvent::KeyPress);
|
||||
}
|
||||
if (event->type() == QEvent::MouseMove || event->type() == QEvent::MouseButtonPress ||
|
||||
event->type() == QEvent::MouseButtonRelease) {
|
||||
return forwardMouseEvent(static_cast<QMouseEvent*>(event));
|
||||
}
|
||||
if (event->type() == QEvent::Wheel) {
|
||||
return forwardMouseEvent(event);
|
||||
}
|
||||
}
|
||||
return QMainWindow::eventFilter(watched, event);
|
||||
}
|
||||
|
||||
bool MainWindow::forwardKeyEvent(QKeyEvent* event, bool pressed) {
|
||||
if (!rdpSession_) {
|
||||
return false;
|
||||
}
|
||||
const DWORD rdpScanCode =
|
||||
freerdp_keyboard_get_rdp_scancode_from_x11_keycode(event->nativeScanCode());
|
||||
if (rdpScanCode == RDP_SCANCODE_UNKNOWN) {
|
||||
return false;
|
||||
}
|
||||
return rdpSession_->sendKeyEvent(pressed, event->isAutoRepeat(), rdpScanCode);
|
||||
}
|
||||
|
||||
bool MainWindow::forwardMouseEvent(QEvent* event) {
|
||||
if (!rdpSession_) {
|
||||
return false;
|
||||
}
|
||||
uint16_t flags = 0;
|
||||
QPoint pos = lastMousePosition_;
|
||||
if (event->type() == QEvent::MouseMove) {
|
||||
auto* mouse = static_cast<QMouseEvent*>(event);
|
||||
pos = mouse->pos();
|
||||
flags = PTR_FLAGS_MOVE;
|
||||
} else if (event->type() == QEvent::MouseButtonPress ||
|
||||
event->type() == QEvent::MouseButtonRelease) {
|
||||
auto* mouse = static_cast<QMouseEvent*>(event);
|
||||
pos = mouse->pos();
|
||||
if (mouse->button() == Qt::LeftButton) flags |= PTR_FLAGS_BUTTON1;
|
||||
if (mouse->button() == Qt::RightButton) flags |= PTR_FLAGS_BUTTON2;
|
||||
if (mouse->button() == Qt::MiddleButton) flags |= PTR_FLAGS_BUTTON3;
|
||||
if (event->type() == QEvent::MouseButtonPress) flags |= PTR_FLAGS_DOWN;
|
||||
} else if (event->type() == QEvent::Wheel) {
|
||||
auto* wheel = static_cast<QWheelEvent*>(event);
|
||||
pos = wheel->position().toPoint();
|
||||
flags = PTR_FLAGS_WHEEL;
|
||||
if (wheel->angleDelta().y() < 0) flags |= PTR_FLAGS_WHEEL_NEGATIVE;
|
||||
}
|
||||
lastMousePosition_ = pos;
|
||||
|
||||
if (lastFrame_.isNull() || !sessionView_) {
|
||||
return false;
|
||||
}
|
||||
const QSize sourceSize = lastFrame_.size();
|
||||
const QSize viewSize = sessionView_->size();
|
||||
const QSize displayedSize = sourceSize.scaled(viewSize, Qt::KeepAspectRatio);
|
||||
const int offsetX = (viewSize.width() - displayedSize.width()) / 2;
|
||||
const int offsetY = (viewSize.height() - displayedSize.height()) / 2;
|
||||
if (pos.x() < offsetX || pos.y() < offsetY ||
|
||||
pos.x() >= offsetX + displayedSize.width() ||
|
||||
pos.y() >= offsetY + displayedSize.height()) {
|
||||
return true;
|
||||
}
|
||||
const int remoteX = qBound(0,
|
||||
(pos.x() - offsetX) * sourceSize.width() / displayedSize.width(),
|
||||
sourceSize.width() - 1);
|
||||
const int remoteY = qBound(0,
|
||||
(pos.y() - offsetY) * sourceSize.height() / displayedSize.height(),
|
||||
sourceSize.height() - 1);
|
||||
return rdpSession_->sendMouseEvent(flags, static_cast<uint16_t>(remoteX),
|
||||
static_cast<uint16_t>(remoteY));
|
||||
}
|
||||
|
||||
} // namespace vdi
|
||||
|
||||
@ -2,42 +2,39 @@
|
||||
//
|
||||
// 产品流程编排:
|
||||
// 开机 -> 登录页 -> 认证 -> 桌面列表 -> 选择桌面
|
||||
// -> 获取连接票据 -> RDP(内嵌)或 Moonlight(外部进程)会话
|
||||
// -> 获取连接票据 -> Moonlight 串流会话(外部进程 moonlight-qt)
|
||||
// -> 会话结束回桌面列表;退出登录回登录页
|
||||
//
|
||||
// 对应《VDI 云桌面软件开发方案》3.2 节的状态机,
|
||||
// 并按产品流程扩展 AUTHENTICATING / DESKTOP_LIST 两个状态。
|
||||
// 全局热键 Ctrl+Alt+Shift+M 呼出管理菜单。
|
||||
// 全局热键 Ctrl+Alt+Shift+M 呼出管理菜单(含退出终端);
|
||||
// Ctrl+Alt+Shift+Q 退出串流:moonlight-qt 自带同名快捷键,
|
||||
// kiosk 侧保留一份兜底,串流未建立或焦点仍在 kiosk 时也能退出。
|
||||
|
||||
#ifndef KIOSK_SHELL_MAIN_WINDOW_H
|
||||
#define KIOSK_SHELL_MAIN_WINDOW_H
|
||||
|
||||
#include <QImage>
|
||||
#include <QElapsedTimer>
|
||||
#include <QMainWindow>
|
||||
#include <QMutex>
|
||||
#include <QPointer>
|
||||
#include <QPoint>
|
||||
#include <memory>
|
||||
|
||||
#include "config.h"
|
||||
#include "desktop_service.h"
|
||||
#include "desktop_session.h"
|
||||
#include "moonlight_session.h"
|
||||
#include "rdp_session.h"
|
||||
#include "state_machine.h"
|
||||
|
||||
class QLabel;
|
||||
class QStackedWidget;
|
||||
class QStatusBar;
|
||||
class QTimer;
|
||||
class QEvent;
|
||||
class QKeyEvent;
|
||||
class QMouseEvent;
|
||||
|
||||
namespace vdi {
|
||||
|
||||
class LoginPage;
|
||||
class DesktopListPage;
|
||||
class TransitionOverlay;
|
||||
|
||||
class MainWindow : public QMainWindow {
|
||||
Q_OBJECT
|
||||
@ -46,19 +43,12 @@ public:
|
||||
explicit MainWindow(QWidget* parent = nullptr);
|
||||
~MainWindow() override;
|
||||
|
||||
protected:
|
||||
bool eventFilter(QObject* watched, QEvent* event) override;
|
||||
|
||||
private slots:
|
||||
void onLoginRequested(const QString& username, const QString& password);
|
||||
void onDesktopSelected(const QString& desktopId);
|
||||
void onEditDesktopRequested(const QString& desktopId);
|
||||
void onLogoutRequested();
|
||||
void onQuitRequested();
|
||||
void onFrameImage(const QImage& image);
|
||||
void onFrameWakeup();
|
||||
void onFrameRefresh();
|
||||
void onSessionState(bool connected, const QString& message);
|
||||
void onMoonlightState(bool running, const QString& message);
|
||||
void onMenuAction(int action);
|
||||
|
||||
@ -68,42 +58,30 @@ private:
|
||||
void setupHeartbeat();
|
||||
void onStateChanged(vdi::State from, vdi::State to, const QString& detail);
|
||||
void refreshDesktopList();
|
||||
void startRdpSession(const ConnectionTicket& ticket);
|
||||
void startMoonlightSession(const ConnectionTicket& ticket);
|
||||
void stopSessions();
|
||||
void showLogin();
|
||||
void showDesktopList();
|
||||
void updateStatusBar(const QString& text);
|
||||
bool forwardKeyEvent(QKeyEvent* event, bool pressed);
|
||||
bool forwardMouseEvent(QEvent* event);
|
||||
void watchFirstFrame();
|
||||
void onFirstFrameDetected();
|
||||
|
||||
StateMachine fsm_;
|
||||
DesktopService desktopService_;
|
||||
std::unique_ptr<RdpSession> rdpSession_;
|
||||
std::unique_ptr<MoonlightSession> moonlightSession_;
|
||||
ConnectionTicket activeTicket_;
|
||||
QString currentUser_;
|
||||
std::vector<DesktopInfo> desktops_;
|
||||
int reconnectAttempts_ = 0;
|
||||
|
||||
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* firstFrameTimer_ = nullptr;
|
||||
QTimer* heartbeatTimer_ = nullptr;
|
||||
QTimer* frameRefreshTimer_ = nullptr;
|
||||
QElapsedTimer firstFrameClock_;
|
||||
TerminalConfig config_;
|
||||
QImage lastFrame_;
|
||||
QImage latestFrame_;
|
||||
QMutex frameMutex_;
|
||||
uint64_t frameEpoch_ = 0;
|
||||
uint64_t sessionEpoch_ = 0;
|
||||
bool frameWakePending_ = false;
|
||||
QPoint lastMousePosition_;
|
||||
uint32_t keyboardLayout_ = 0;
|
||||
};
|
||||
|
||||
} // namespace vdi
|
||||
|
||||
@ -22,6 +22,10 @@ bool MoonlightSession::isRunning() const {
|
||||
return process_ && process_->state() != QProcess::NotRunning;
|
||||
}
|
||||
|
||||
qint64 MoonlightSession::processId() const {
|
||||
return isRunning() ? process_->processId() : 0;
|
||||
}
|
||||
|
||||
bool MoonlightSession::start(const ConnectionTicket& ticket, StateCallback callback) {
|
||||
if (isRunning()) {
|
||||
return false;
|
||||
|
||||
@ -44,6 +44,9 @@ public:
|
||||
|
||||
bool isRunning() const;
|
||||
|
||||
// 串流进程 PID(未运行返回 0),供首帧检测读 /proc 统计
|
||||
qint64 processId() const;
|
||||
|
||||
private slots:
|
||||
void onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus);
|
||||
void onProcessError(QProcess::ProcessError error);
|
||||
|
||||
@ -1,368 +0,0 @@
|
||||
#include <freerdp/input.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
|
||||
bool RdpSession::sendKeyEvent(bool pressed, bool repeat, uint32_t rdpScanCode) {
|
||||
std::lock_guard<std::mutex> lock(inputMutex_);
|
||||
if (!running_.load() || !connected_.load() || !context_ || !context_->input) {
|
||||
return false;
|
||||
}
|
||||
return freerdp_input_send_keyboard_event_ex(context_->input, pressed ? TRUE : FALSE,
|
||||
repeat ? TRUE : FALSE, rdpScanCode) == TRUE;
|
||||
}
|
||||
|
||||
bool RdpSession::sendMouseEvent(uint16_t flags, uint16_t x, uint16_t y) {
|
||||
std::lock_guard<std::mutex> lock(inputMutex_);
|
||||
if (!running_.load() || !connected_.load() || !context_ || !context_->input) {
|
||||
return false;
|
||||
}
|
||||
return freerdp_input_send_mouse_event(context_->input, flags, x, y) == TRUE;
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
if (options_.keyboardLayout != 0) {
|
||||
freerdp_settings_set_uint32(settings, FreeRDP_KeyboardLayout, options_.keyboardLayout);
|
||||
}
|
||||
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);
|
||||
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);
|
||||
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 渲染管线。
|
||||
//
|
||||
// 重要:必须使用默认的 gdi_graphics_pipeline_init(等价于
|
||||
// init_ex(gdi, gfx, nullptr, nullptr, nullptr)),让 FreeRDP 自己把
|
||||
// GFX surface 合成进 gdi->primary_buffer。
|
||||
//
|
||||
// 若传入自定义 UpdateSurfaceArea 回调,FreeRDP 会认为"输出已由客户端更新"
|
||||
// (见 gdi_UpdateSurfaces 中对 handleInUpdateSurfaceArea 的判断),
|
||||
// 由客户端承担合成责任。此时我们若只发帧不合成,取到的就是尚未合成的
|
||||
// 中间画面,表现为画面分块填充、横向白带扫过。
|
||||
//
|
||||
// 同时不能写 gfx->custom:gdi_graphics_pipeline_init 内部会把它设为 gdi。
|
||||
if (instance->context && instance->context->gdi && instance->context->gdi->gfx) {
|
||||
RdpgfxClientContext* gfx = instance->context->gdi->gfx;
|
||||
if (!gdi_graphics_pipeline_init(instance->context->gdi, gfx)) {
|
||||
if (self->callbacks_.onState) {
|
||||
self->callbacks_.onState(false, "GFX 管线初始化失败");
|
||||
}
|
||||
return FALSE;
|
||||
}
|
||||
self->originalEndFrame_ = gfx->EndFrame;
|
||||
self->gfxPipelineActive_ = self->originalEndFrame_ != nullptr;
|
||||
gfx->EndFrame = onEndFrame;
|
||||
}
|
||||
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) {
|
||||
if (context && context->instance) {
|
||||
RdpSession* self = sessionOf(context->instance);
|
||||
if (self && !self->gfxPipelineActive_) {
|
||||
// 传统 Bitmap/RDP 路径没有 GFX EndFrame,在 EndPaint 提交。
|
||||
self->emitFrame();
|
||||
}
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
UINT RdpSession::onEndFrame(RdpgfxClientContext* gfxContext,
|
||||
const RDPGFX_END_FRAME_PDU* endFrame) {
|
||||
if (!gfxContext || !gfxContext->custom) {
|
||||
return ERROR_INVALID_DATA;
|
||||
}
|
||||
auto* gdi = static_cast<rdpGdi*>(gfxContext->custom);
|
||||
if (!gdi->context || !gdi->context->instance) {
|
||||
return ERROR_INVALID_DATA;
|
||||
}
|
||||
RdpSession* self = sessionOf(gdi->context->instance);
|
||||
if (!self || !self->originalEndFrame_) {
|
||||
return ERROR_INVALID_DATA;
|
||||
}
|
||||
|
||||
const UINT status = self->originalEndFrame_(gfxContext, endFrame);
|
||||
if (status == CHANNEL_RC_OK) {
|
||||
// originalEndFrame_ 已完成 gdi_UpdateSurfaces,primary_buffer 此刻才是完整帧。
|
||||
self->emitFrame();
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
} // namespace vdi
|
||||
@ -1,116 +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 <mutex>
|
||||
#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;
|
||||
uint32_t keyboardLayout = 0;
|
||||
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(); }
|
||||
|
||||
// 向远程会话注入键盘和鼠标事件;由 GUI 线程调用。
|
||||
bool sendKeyEvent(bool pressed, bool repeat, uint32_t rdpScanCode);
|
||||
bool sendMouseEvent(uint16_t flags, uint16_t x, uint16_t y);
|
||||
|
||||
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);
|
||||
// GFX 完整帧边界:先让 FreeRDP 完成所有 surface 合成,再提交一次。
|
||||
static UINT onEndFrame(RdpgfxClientContext* context,
|
||||
const RDPGFX_END_FRAME_PDU* endFrame);
|
||||
|
||||
// 把当前 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_;
|
||||
mutable std::mutex inputMutex_;
|
||||
uint32_t frameWidth_ = 0;
|
||||
uint32_t frameHeight_ = 0;
|
||||
uint32_t frameStride_ = 0;
|
||||
uint32_t frameFormat_ = 0;
|
||||
bool frameDirty_ = false;
|
||||
pcRdpgfxEndFrame originalEndFrame_ = nullptr;
|
||||
bool gfxPipelineActive_ = false;
|
||||
};
|
||||
|
||||
} // namespace vdi
|
||||
|
||||
#endif // KIOSK_SHELL_RDP_SESSION_H
|
||||
@ -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
|
||||
|
||||
|
||||
131
terminal/kiosk-shell/src/transition_overlay.cpp
Normal file
131
terminal/kiosk-shell/src/transition_overlay.cpp
Normal file
@ -0,0 +1,131 @@
|
||||
#include "transition_overlay.h"
|
||||
|
||||
#include <QEvent>
|
||||
#include <QFont>
|
||||
#include <QLabel>
|
||||
#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-qt 会盖住 kiosk 主窗口,
|
||||
// 过场层必须压在它之上才起作用(onTick 周期性 raise 兜底)。
|
||||
setWindowFlags(Qt::Window | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint);
|
||||
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]));
|
||||
|
||||
showFullScreen();
|
||||
raise();
|
||||
activateWindow();
|
||||
grabKeyboard();
|
||||
grabMouse();
|
||||
tickTimer_->start(kTickMs);
|
||||
}
|
||||
|
||||
void TransitionOverlay::finish() {
|
||||
tickTimer_->stop();
|
||||
fadeTimer_->stop();
|
||||
releaseKeyboard();
|
||||
releaseMouse();
|
||||
hide();
|
||||
setWindowOpacity(1.0);
|
||||
}
|
||||
|
||||
void TransitionOverlay::onFirstFrame() {
|
||||
// 首帧已出画:停止动画并淡出;若尚未显示/已在淡出则忽略
|
||||
if (!isVisible() || fadeTimer_->isActive()) {
|
||||
return;
|
||||
}
|
||||
tickTimer_->stop();
|
||||
releaseKeyboard();
|
||||
releaseMouse();
|
||||
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_]));
|
||||
// X11 下 moonlight 的全屏窗口可能后映射,周期性抬回最上层
|
||||
raise();
|
||||
}
|
||||
|
||||
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
|
||||
@ -8,6 +8,10 @@ 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",
|
||||
@ -17,6 +21,8 @@ FILES = [
|
||||
"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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user