重构:移除 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:
宋一鸣 2026-10-29 17:37:38 +08:00
parent 56b9f1faf3
commit 26fde0369e
21 changed files with 364 additions and 923 deletions

View File

@ -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 盒子的用户登录页。

View File

@ -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 代码已支持,无需改代码)。

View File

@ -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:独立进程,监控主进程假死/崩溃

View File

@ -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:

View File

@ -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;

View File

@ -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 {

View File

@ -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();

View File

@ -1,8 +1,8 @@
// 桌面连接设置对话框
//
// 现场环境每套部署的桌面地址不同:选择一张桌面卡,
// 编辑协议(Moonlight/RDP)、地址(IP / 域名 / VPN 隧道地址)、
// 应用名、分辨率和帧率,保存后立即生效。
// 编辑地址(IP / 域名 / VPN 隧道地址)、应用名、分辨率和帧率,
// 保存后立即生效。会话通道统一 Moonlight,无协议选项。
// 地址为空 = 待分配,卡片不可连接。
#ifndef KIOSK_SHELL_DESKTOP_SETTINGS_DIALOG_H

View File

@ -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);

View File

@ -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);

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -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);

View File

@ -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

View File

@ -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

View File

@ -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

View 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

View 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

View File

@ -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",