功能:统一 Moonlight 会话并优化云桌面启动体验
This commit is contained in:
parent
9ae099d02d
commit
415388ab06
8
.gitignore
vendored
8
.gitignore
vendored
@ -1,3 +1,11 @@
|
||||
build/
|
||||
CMakeFiles/
|
||||
CMakeCache.txt
|
||||
*.o
|
||||
*.so
|
||||
*.log
|
||||
*.tmp
|
||||
.zcode/
|
||||
__pycache__/
|
||||
test-output/
|
||||
screenshots/
|
||||
|
||||
75
README.md
75
README.md
@ -12,12 +12,11 @@
|
||||
|
||||
```text
|
||||
盒子开机
|
||||
-> kiosk-shell 登录/首配
|
||||
-> 用户认证
|
||||
-> 获取可用云桌面列表
|
||||
-> 设备身份自动登录
|
||||
-> 获取并展示可用云桌面列表
|
||||
-> 用户选择 Windows / Ubuntu / 麒麟等桌面
|
||||
-> 获取一次性连接票据
|
||||
-> 根据协议启动 RDP 或 Moonlight 会话
|
||||
-> 启动 Moonlight 会话
|
||||
-> 会话结束后返回桌面列表
|
||||
```
|
||||
|
||||
@ -38,8 +37,7 @@ RK3528 云终端
|
||||
vdi-cloud/
|
||||
├─ terminal/ # 终端嵌入式软件
|
||||
│ ├─ meta-vdi/ # Yocto 层:BSP、内核、设备树、U-Boot、配方
|
||||
│ ├─ kiosk-shell/ # Qt 全屏壳:登录、桌面列表、远程会话
|
||||
│ ├─ freerdp-rk/ # FreeRDP 3 与 Rockchip MPP/VPU 适配
|
||||
│ ├─ kiosk-shell/ # Qt 全屏壳:桌面列表、Moonlight 会话、看门狗
|
||||
│ ├─ vdt-agent/ # Rust:注册、心跳、命令、日志
|
||||
│ ├─ ota-updater/ # Rust:OTA 签名校验、A/B 切换、回滚
|
||||
│ └─ usb-monitor/ # Rust:USB 监听、挂载与重定向策略
|
||||
@ -58,39 +56,21 @@ vdi-cloud/
|
||||
|
||||
已实现:
|
||||
|
||||
- Qt 全屏登录页;
|
||||
- 设备 SN 和网络状态显示;
|
||||
- 本地演示认证(当前账号和密码非空即可通过);
|
||||
- 开机后使用设备 SN 作为当前身份,自动进入桌面列表;
|
||||
- Windows、Ubuntu、麒麟三类桌面卡片;
|
||||
- **桌面连接可配置**:每张卡片有“设置”按钮,可配置协议(Moonlight/RDP)、
|
||||
服务器地址(支持 IP、域名或 VPN 隧道地址)、应用名、分辨率和帧率;
|
||||
- **桌面连接可配置**:每张卡片有“设置”按钮,可配置服务器地址(支持 IP、域名或 VPN 隧道地址)、Sunshine 应用名、分辨率和帧率;
|
||||
- 连接配置持久化在 `~/.config/vdi-terminal/desktops.json`,
|
||||
地址为空的桌面显示“待分配”且不可连接;
|
||||
- 老环境自动迁移:首次运行会读取 `client.conf` 里的 server 地址填充 Windows 桌面;
|
||||
- `Ctrl+Alt+Shift+M` 管理菜单提供“桌面连接设置”统一入口;
|
||||
- 退出登录后返回登录页;会话退出后返回桌面列表。
|
||||
- 会话退出后返回桌面列表。
|
||||
|
||||
不同客户部署环境的桌面地址不同:换环境时在“设置”里改地址即可,不需要改代码。
|
||||
VPN 属于网络层能力(盒子上的 VPN 客户端建立隧道后),地址字段直接填隧道地址即可。
|
||||
|
||||
本地演示认证是后端 API 上线前的临时实现。后续通过 `DesktopService` 适配层接入真实的用户认证、桌面列表和一次性连接票据接口,页面流程不需要重写。
|
||||
设备身份自动登录是正式认证 API 上线前的临时实现。后续通过 `DesktopService` 适配层接入真实的用户认证、桌面列表和一次性连接票据接口,页面流程不需要重写。
|
||||
|
||||
### 2. RDP 会话
|
||||
|
||||
已实现:
|
||||
|
||||
- 基于 `libfreerdp3` API 内嵌,不拉起外部 `xfreerdp` 进程;
|
||||
- FreeRDP GDI/GFX 画面接入 Qt;
|
||||
- 键盘、鼠标输入转发;
|
||||
- X11 键盘码到 RDP 扫描码映射;
|
||||
- 鼠标缩放坐标转换;
|
||||
- 会话状态与断线重连;
|
||||
- `Ctrl+Alt+Shift+M` 管理菜单;
|
||||
- RDP 后端代码保留,可由连接票据选择启用。
|
||||
|
||||
当前 RDP 路径仍处于 POC 阶段,证书忽略、测试账号等配置不能直接用于正式环境。
|
||||
|
||||
### 3. Moonlight/Sunshine 会话
|
||||
### 2. Moonlight/Sunshine 会话
|
||||
|
||||
已实现:
|
||||
|
||||
@ -98,13 +78,17 @@ VPN 属于网络层能力(盒子上的 VPN 客户端建立隧道后),地
|
||||
- 不通过 Shell 拼接用户输入,降低命令注入风险;
|
||||
- H.264 软件解码;
|
||||
- 绝对鼠标模式;
|
||||
- 点击连接后立即锁定操作,保证任何时刻最多一个 Moonlight 会话;
|
||||
- 解析 Moonlight 输出,只有测试解码成功、渲染器选定且全屏串流窗口已激活后才淡出过场;
|
||||
- ARM/X11 环境通过 `GL_IS_SLOW=0` 缩短不可用 DRM 路径的探测时间;
|
||||
- 连接阶段有结构化耗时日志和 30 秒就绪超时,失败时自动关闭错误窗口并返回列表;
|
||||
- 会话进程退出后自动返回桌面列表;
|
||||
- `vsync`、`frame-pacing`、性能叠图等低延迟参数可配置;
|
||||
- 独立 Moonlight 启动脚本保留作调试用途,但不再作为产品开机入口。
|
||||
|
||||
当前联调环境中,Sunshine 服务端仍使用 `libx264` 软件编码。1080p 实际帧率约 13~22 FPS,鼠标体感延迟主要来自服务端软编码和客户端帧队列/VSync。后续需要完成 GPU 直通并启用 NVENC。
|
||||
|
||||
### 4. 状态机
|
||||
### 3. 状态机
|
||||
|
||||
当前终端状态机:
|
||||
|
||||
@ -119,7 +103,7 @@ BOOT
|
||||
-> DESKTOP_LIST / ERROR
|
||||
```
|
||||
|
||||
### 5. Monitor 看门狗
|
||||
### 4. Monitor 看门狗
|
||||
|
||||
已实现独立 `vdi-kiosk-monitor` 进程:
|
||||
|
||||
@ -134,8 +118,7 @@ BOOT
|
||||
### 终端
|
||||
|
||||
- C++17
|
||||
- Qt 5 Widgets
|
||||
- FreeRDP 3
|
||||
- Qt 5 Widgets / Qt Test
|
||||
- Moonlight Qt
|
||||
- 当前开发环境:Armbian + LightDM + Xorg
|
||||
- 正式目标环境:Weston + Wayland
|
||||
@ -164,8 +147,7 @@ BOOT
|
||||
```bash
|
||||
sudo apt install \
|
||||
build-essential cmake ninja-build pkg-config \
|
||||
qtbase5-dev libqt5widgets5 \
|
||||
freerdp3-dev
|
||||
qtbase5-dev libqt5widgets5 libqt5test5
|
||||
```
|
||||
|
||||
Moonlight 会话还需要安装:
|
||||
@ -178,8 +160,9 @@ sudo apt install moonlight-qt
|
||||
|
||||
```bash
|
||||
cd terminal/kiosk-shell
|
||||
cmake -G Ninja -B build -DCMAKE_BUILD_TYPE=Release
|
||||
cmake -G Ninja -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON
|
||||
cmake --build build
|
||||
QT_QPA_PLATFORM=offscreen ctest --test-dir build --output-on-failure
|
||||
```
|
||||
|
||||
编译产物:
|
||||
@ -255,27 +238,25 @@ Moonlight 调试配置路径:
|
||||
3. 当前 Moonlight 使用软件解码,RK3528 的 VPU/MPP 硬解尚未打通;
|
||||
4. Sunshine 当前使用软件编码,未启用 NVENC;
|
||||
5. 当前仍是 X11 POC,尚未完成 Weston/Wayland/DRM 零拷贝路径;
|
||||
6. RDP 证书校验、短期凭据和正式安全策略尚未完成;
|
||||
7. USB 重定向、OTA、Agent、管理平台和 Web 控制台尚未开发完成。
|
||||
6. USB 重定向、OTA、Agent、管理平台和 Web 控制台尚未开发完成。
|
||||
|
||||
## 九、下一步计划
|
||||
|
||||
按优先级:
|
||||
|
||||
1. 恢复 RK3528 盒子网络并部署登录/桌面列表新版本;
|
||||
2. 实测 Moonlight `no-vsync` / `no-frame-pacing` 对鼠标延迟的改善;
|
||||
3. 将 GTX 965M 直通 Windows VM,使 Sunshine 使用 NVENC;
|
||||
4. 定义并联调终端用户认证、桌面列表和一次性连接票据 API;
|
||||
5. 实现 Ubuntu、麒麟桌面连接配置;
|
||||
6. 适配 RK3528 MPP/VPU 硬解;
|
||||
7. 切换到 Weston/Wayland 正式显示路径;
|
||||
8. 开发 `vdt-agent`、OTA、USB、云端服务和 Web 控制台。
|
||||
1. 保持终端、Sunshine 和虚拟显示器三端分辨率及色彩空间一致;
|
||||
2. 将 GTX 965M 直通 Windows VM,使 Sunshine 使用 NVENC;
|
||||
3. 定义并联调终端用户认证、桌面列表和一次性连接票据 API;
|
||||
4. 实现 Ubuntu、麒麟桌面连接配置;
|
||||
5. 适配 RK3528 MPP/VPU 硬解;
|
||||
6. 切换到 Weston/Wayland 正式显示路径;
|
||||
7. 开发 `vdt-agent`、OTA、USB、云端服务和 Web 控制台。
|
||||
|
||||
## 十、开发规范
|
||||
|
||||
- 已验证、已实现、待验证和规划中必须明确区分;
|
||||
- 终端不能直接调用 Proxmox API;
|
||||
- RDP 和 Moonlight 协议选择由管理平台连接票据决定;
|
||||
- Moonlight 是当前唯一远程会话协议,连接参数由管理平台连接票据决定;
|
||||
- 所有密钥和密码只能通过安全配置注入,不得提交到 Git;
|
||||
- 正式代码变更应通过编译、真机测试和会话恢复测试;
|
||||
- 本 README 随功能、接口、目录和验证结论持续更新。
|
||||
|
||||
@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.16)
|
||||
project(kiosk-shell CXX)
|
||||
|
||||
# 对应《VDI 云桌面软件开发方案》2.2 节技术栈定版:
|
||||
# kiosk 与 freerdp 插件用 C/C++(贴合 GTK/GStreamer 生态)
|
||||
# kiosk 用 C/C++/Qt;会话通道统一 Moonlight(RDP 渲染管线已移除,代码存于 git 历史)
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_AUTOMOC ON)
|
||||
@ -14,10 +14,7 @@ endif()
|
||||
|
||||
# ---- Qt5 ----
|
||||
find_package(Qt5 REQUIRED COMPONENTS Core Gui Widgets)
|
||||
|
||||
# ---- FreeRDP 3 ----
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(FREERDP REQUIRED IMPORTED_TARGET freerdp3)
|
||||
include(CTest)
|
||||
|
||||
add_executable(kiosk-shell
|
||||
src/main.cpp
|
||||
@ -28,9 +25,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 +38,6 @@ target_link_libraries(kiosk-shell PRIVATE
|
||||
Qt5::Core
|
||||
Qt5::Gui
|
||||
Qt5::Widgets
|
||||
PkgConfig::FREERDP
|
||||
)
|
||||
|
||||
# 看门狗 Monitor:独立进程,监控主进程假死/崩溃
|
||||
@ -57,4 +53,39 @@ target_include_directories(vdi-kiosk-monitor PRIVATE
|
||||
find_package(Threads REQUIRED)
|
||||
target_link_libraries(vdi-kiosk-monitor PRIVATE Threads::Threads)
|
||||
|
||||
if(BUILD_TESTING)
|
||||
find_package(Qt5 REQUIRED COMPONENTS Test)
|
||||
|
||||
add_executable(test-moonlight-session
|
||||
tests/test_moonlight_session.cpp
|
||||
src/moonlight_session.cpp
|
||||
)
|
||||
target_include_directories(test-moonlight-session PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src
|
||||
)
|
||||
target_link_libraries(test-moonlight-session PRIVATE
|
||||
Qt5::Core
|
||||
Qt5::Test
|
||||
)
|
||||
add_test(NAME moonlight-session COMMAND test-moonlight-session)
|
||||
|
||||
add_executable(test-desktop-list-page
|
||||
tests/test_desktop_list_page.cpp
|
||||
src/desktop_list_page.cpp
|
||||
)
|
||||
target_include_directories(test-desktop-list-page PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src
|
||||
)
|
||||
target_link_libraries(test-desktop-list-page PRIVATE
|
||||
Qt5::Core
|
||||
Qt5::Gui
|
||||
Qt5::Widgets
|
||||
Qt5::Test
|
||||
)
|
||||
add_test(NAME desktop-list-page COMMAND test-desktop-list-page)
|
||||
set_tests_properties(desktop-list-page PROPERTIES
|
||||
ENVIRONMENT "QT_QPA_PLATFORM=offscreen"
|
||||
)
|
||||
endif()
|
||||
|
||||
install(TARGETS kiosk-shell vdi-kiosk-monitor RUNTIME DESTINATION bin)
|
||||
|
||||
@ -35,11 +35,10 @@ DesktopListPage::DesktopListPage(QWidget* parent) : QWidget(parent) {
|
||||
auto* infoRow = new QHBoxLayout();
|
||||
labelUser_ = new QLabel(this);
|
||||
labelUser_->setStyleSheet(QStringLiteral("color: #8a94a6;"));
|
||||
buttonLogout_ = new QPushButton(QStringLiteral("退出登录"), this);
|
||||
buttonLogout_->setMinimumSize(110, 38);
|
||||
// 无登录页演示模式下不设"退出登录"(用户身份由设备/平台承担,
|
||||
// 正式认证接入后由 DesktopService 驱动恢复登录流程)
|
||||
infoRow->addWidget(labelUser_);
|
||||
addStretchToRow(infoRow);
|
||||
infoRow->addWidget(buttonLogout_);
|
||||
root->addLayout(infoRow);
|
||||
|
||||
// ---- 卡片区 ----
|
||||
@ -62,12 +61,6 @@ DesktopListPage::DesktopListPage(QWidget* parent) : QWidget(parent) {
|
||||
labelStatus_->setAlignment(Qt::AlignCenter);
|
||||
labelStatus_->setStyleSheet(QStringLiteral("color: #8a94a6;"));
|
||||
root->addWidget(labelStatus_);
|
||||
|
||||
connect(buttonLogout_, &QPushButton::clicked, this, [this]() {
|
||||
if (!busy_) {
|
||||
emit logoutRequested();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void DesktopListPage::setDesktops(const std::vector<DesktopInfo>& desktops) {
|
||||
@ -76,6 +69,8 @@ void DesktopListPage::setDesktops(const std::vector<DesktopInfo>& desktops) {
|
||||
}
|
||||
|
||||
void DesktopListPage::rebuildCards() {
|
||||
actionButtons_.clear();
|
||||
|
||||
// 清空旧卡片(最后一项是 stretch)
|
||||
while (cardsLayout_->count() > 1) {
|
||||
QLayoutItem* item = cardsLayout_->takeAt(0);
|
||||
@ -110,13 +105,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:
|
||||
@ -135,6 +129,8 @@ void DesktopListPage::rebuildCards() {
|
||||
// “设置”:每张卡都可编辑连接参数(协议/地址/应用名/分辨率/帧率)
|
||||
auto* editBtn = new QPushButton(QStringLiteral("设置"), card);
|
||||
editBtn->setMinimumSize(88, 42);
|
||||
editBtn->setEnabled(!busy_);
|
||||
actionButtons_.push_back(editBtn);
|
||||
connect(editBtn, &QPushButton::clicked, this, [this, id = d.id]() {
|
||||
if (!busy_) {
|
||||
emit editRequested(id);
|
||||
@ -149,8 +145,11 @@ void DesktopListPage::rebuildCards() {
|
||||
"QPushButton { background: #2f6feb; color: white; border: none; "
|
||||
"border-radius: 6px; font-weight: bold; }"
|
||||
"QPushButton:disabled { background: #3a4a63; }"));
|
||||
connectBtn->setEnabled(!busy_);
|
||||
actionButtons_.push_back(connectBtn);
|
||||
connect(connectBtn, &QPushButton::clicked, this, [this, id = d.id]() {
|
||||
if (!busy_) {
|
||||
setBusy(true);
|
||||
setStatus(QStringLiteral("正在连接,请稍候…"));
|
||||
emit desktopSelected(id);
|
||||
}
|
||||
@ -176,8 +175,11 @@ void DesktopListPage::setStatus(const QString& text, bool error) {
|
||||
|
||||
void DesktopListPage::setBusy(bool busy) {
|
||||
busy_ = busy;
|
||||
buttonLogout_->setEnabled(!busy);
|
||||
// 连接按钮在 rebuildCards 时根据 busy_ 生成;这里统一刷新点击行为由 busy_ 拦截
|
||||
for (QPushButton* button : actionButtons_) {
|
||||
if (button) {
|
||||
button->setEnabled(!busy_);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QString DesktopListPage::currentUser() const {
|
||||
|
||||
@ -38,8 +38,6 @@ signals:
|
||||
void desktopSelected(const QString& desktopId);
|
||||
// 用户点击某张卡片的“设置”按钮
|
||||
void editRequested(const QString& desktopId);
|
||||
// 用户点击退出登录
|
||||
void logoutRequested();
|
||||
|
||||
private:
|
||||
void rebuildCards();
|
||||
@ -50,7 +48,7 @@ private:
|
||||
QLabel* labelUser_ = nullptr;
|
||||
QLabel* labelStatus_ = nullptr;
|
||||
QVBoxLayout* cardsLayout_ = nullptr;
|
||||
QPushButton* buttonLogout_ = nullptr;
|
||||
std::vector<QPushButton*> actionButtons_;
|
||||
bool busy_ = false;
|
||||
};
|
||||
|
||||
|
||||
@ -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,25 @@
|
||||
|
||||
#include <QApplication>
|
||||
#include <QDateTime>
|
||||
#include <QEvent>
|
||||
#include <QDebug>
|
||||
#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;
|
||||
constexpr int kTicketTimeoutMs = 10000;
|
||||
constexpr int kProcessStartTimeoutMs = 5000;
|
||||
constexpr int kStreamReadyTimeoutMs = 30000;
|
||||
|
||||
} // namespace
|
||||
|
||||
@ -83,14 +31,18 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
|
||||
setupUi();
|
||||
setupHotkey();
|
||||
setupHeartbeat();
|
||||
keyboardLayout_ = freerdp_keyboard_init(0);
|
||||
|
||||
config_ = loadConfig();
|
||||
|
||||
// 开机永远进入登录页;不再根据本地配置自动连接 RDP,
|
||||
// 会话只能由用户在桌面列表选择后启动。
|
||||
fsm_.transitionTo(State::kConfig, QStringLiteral("等待登录"));
|
||||
showLogin();
|
||||
fsm_.transitionTo(State::kConfig, QStringLiteral("就绪"));
|
||||
const QString deviceUser = QString::fromStdString(config_.sn).trimmed();
|
||||
desktopService_.login(deviceUser.isEmpty() ? QStringLiteral("terminal") : deviceUser,
|
||||
QStringLiteral("kiosk"), [this](const AuthResult& result) {
|
||||
if (result.ok) {
|
||||
currentUser_ = result.displayName;
|
||||
}
|
||||
fsm_.transitionTo(State::kAuthenticating, QStringLiteral("自动登录"));
|
||||
fsm_.transitionTo(State::kDesktopList, QStringLiteral("请选择要进入的云桌面"));
|
||||
});
|
||||
}
|
||||
|
||||
MainWindow::~MainWindow() {
|
||||
@ -106,7 +58,6 @@ void MainWindow::setupUi() {
|
||||
stack_->setStyleSheet(QStringLiteral("background: #0f1420;"));
|
||||
setCentralWidget(stack_);
|
||||
|
||||
// 登录页
|
||||
auto* login = new LoginPage(config_, this);
|
||||
loginPage_ = login;
|
||||
login->setStyleSheet(
|
||||
@ -118,11 +69,8 @@ void MainWindow::setupUi() {
|
||||
"border-radius: 6px; color: #e6e9ef; }"
|
||||
"QPushButton:hover { background: #2b3654; }"));
|
||||
stack_->addWidget(login);
|
||||
|
||||
connect(login, &LoginPage::loginRequested, this, &MainWindow::onLoginRequested);
|
||||
connect(login, &LoginPage::quitRequested, this, &MainWindow::onQuitRequested);
|
||||
|
||||
// 桌面列表页
|
||||
auto* list = new DesktopListPage(this);
|
||||
desktopListPage_ = list;
|
||||
list->setStyleSheet(
|
||||
@ -131,74 +79,59 @@ void MainWindow::setupUi() {
|
||||
"border-radius: 6px; color: #e6e9ef; }"
|
||||
"QPushButton:hover { background: #2b3654; }"));
|
||||
stack_->addWidget(list);
|
||||
|
||||
connect(list, &DesktopListPage::desktopSelected, this, &MainWindow::onDesktopSelected);
|
||||
connect(list, &DesktopListPage::editRequested, this, &MainWindow::onEditDesktopRequested);
|
||||
connect(list, &DesktopListPage::logoutRequested, this, &MainWindow::onLogoutRequested);
|
||||
|
||||
// 会话视图
|
||||
auto* sessionWidget = new QWidget(this);
|
||||
auto* sessionLayout = new QVBoxLayout(sessionWidget);
|
||||
sessionLayout->setContentsMargins(0, 0, 0, 0);
|
||||
overlay_ = new TransitionOverlay(this);
|
||||
|
||||
sessionView_ = new QLabel(sessionWidget);
|
||||
sessionView_->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);
|
||||
|
||||
// 状态条
|
||||
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("重连失败,已返回桌面列表"));
|
||||
connectionTimer_ = new QTimer(this);
|
||||
connectionTimer_->setSingleShot(true);
|
||||
connect(connectionTimer_, &QTimer::timeout, this, [this]() {
|
||||
QString message;
|
||||
switch (connectionPhase_) {
|
||||
case ConnectionPhase::kTicket:
|
||||
message = QStringLiteral("获取连接信息超时");
|
||||
break;
|
||||
case ConnectionPhase::kProcessStart:
|
||||
message = QStringLiteral("Moonlight 启动超时");
|
||||
break;
|
||||
case ConnectionPhase::kStreamReady:
|
||||
message = QStringLiteral("云桌面画面就绪超时");
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
failConnection(connectionAttempt_, message);
|
||||
});
|
||||
|
||||
frameRefreshTimer_ = new QTimer(this);
|
||||
frameRefreshTimer_->setInterval(33);
|
||||
connect(frameRefreshTimer_, &QTimer::timeout, this, &MainWindow::onFrameRefresh);
|
||||
frameRefreshTimer_->start();
|
||||
moonlightSession_ = std::make_unique<MoonlightSession>();
|
||||
}
|
||||
|
||||
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); });
|
||||
|
||||
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() {
|
||||
// 定期写心跳文件,供看门狗 Monitor 判断主进程是否假死
|
||||
// 对应会议纪要要求的双进程架构
|
||||
const QString path = QStringLiteral("/tmp/vdi-kiosk.heartbeat");
|
||||
heartbeatTimer_ = new QTimer(this);
|
||||
heartbeatTimer_->setInterval(5000);
|
||||
@ -206,7 +139,6 @@ void MainWindow::setupHeartbeat() {
|
||||
QFile file(path);
|
||||
if (file.open(QIODevice::WriteOnly | QIODevice::Text)) {
|
||||
file.write(QByteArray::number(QDateTime::currentSecsSinceEpoch()));
|
||||
file.close();
|
||||
}
|
||||
});
|
||||
heartbeatTimer_->start();
|
||||
@ -228,21 +160,10 @@ 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();
|
||||
overlay_->start(QStringLiteral("正在进入云桌面"));
|
||||
break;
|
||||
case State::kSession:
|
||||
sessionView_->setFocus(Qt::OtherFocusReason);
|
||||
sessionOverlay_->hide();
|
||||
break;
|
||||
case State::kReconnect:
|
||||
sessionOverlay_->setText(QStringLiteral("连接已断开,正在重连…"));
|
||||
sessionOverlay_->show();
|
||||
reconnectTimer_->start(kReconnectDelayMs);
|
||||
break;
|
||||
case State::kError:
|
||||
stopSessions();
|
||||
@ -310,31 +231,44 @@ void MainWindow::onLoginRequested(const QString& username, const QString& passwo
|
||||
|
||||
void MainWindow::onDesktopSelected(const QString& desktopId) {
|
||||
if (!fsm_.canConnect()) {
|
||||
if (desktopListPage_) {
|
||||
desktopListPage_->setBusy(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
desktopService_.createTicket(desktopId, [this](const ConnectionTicket& ticket,
|
||||
const QString& error) {
|
||||
if (!error.isEmpty()) {
|
||||
if (desktopListPage_) {
|
||||
desktopListPage_->setBusy(false);
|
||||
desktopListPage_->setStatus(error, true);
|
||||
}
|
||||
|
||||
const quint64 attempt = ++connectionAttempt_;
|
||||
connectingDesktopId_ = desktopId;
|
||||
connectionClock_.restart();
|
||||
phaseClock_.restart();
|
||||
connectionPhase_ = ConnectionPhase::kTicket;
|
||||
logConnectionPhase(attempt, QStringLiteral("ticket"), QStringLiteral("started"));
|
||||
|
||||
if (!fsm_.transitionTo(State::kConnecting, QStringLiteral("正在获取连接信息"))) {
|
||||
failConnection(attempt, QStringLiteral("当前状态无法发起连接"));
|
||||
return;
|
||||
}
|
||||
armConnectionTimeout(attempt, ConnectionPhase::kTicket, kTicketTimeoutMs);
|
||||
|
||||
desktopService_.createTicket(desktopId, [this, attempt](const ConnectionTicket& ticket,
|
||||
const QString& error) {
|
||||
if (attempt != connectionAttempt_ || connectionPhase_ != ConnectionPhase::kTicket) {
|
||||
return;
|
||||
}
|
||||
connectionTimer_->stop();
|
||||
if (!error.isEmpty()) {
|
||||
failConnection(attempt, error);
|
||||
return;
|
||||
}
|
||||
logConnectionPhase(attempt, QStringLiteral("ticket"), QStringLiteral("ready"));
|
||||
activeTicket_ = ticket;
|
||||
reconnectAttempts_ = 0;
|
||||
|
||||
if (ticket.protocol == Protocol::kMoonlight) {
|
||||
startMoonlightSession(ticket);
|
||||
} else {
|
||||
startRdpSession(ticket);
|
||||
if (!startMoonlightSession(ticket, attempt)) {
|
||||
failConnection(attempt, QStringLiteral("已有串流进程正在运行"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void MainWindow::onEditDesktopRequested(const QString& desktopId) {
|
||||
// 桌面连接设置:编辑协议/地址/应用名/分辨率/帧率,保存后立即刷新列表。
|
||||
// 现场环境地址不同或走 VPN 隧道时,在这里改地址即可,无需改代码。
|
||||
DesktopSettingsDialog dialog(desktops_, desktopId, this);
|
||||
dialog.setStyleSheet(
|
||||
QStringLiteral("QWidget { background: #0f1420; color: #e6e9ef; }"
|
||||
@ -354,176 +288,126 @@ void MainWindow::onEditDesktopRequested(const QString& desktopId) {
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::onLogoutRequested() {
|
||||
stopSessions();
|
||||
desktopService_.logout();
|
||||
currentUser_.clear();
|
||||
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;
|
||||
}
|
||||
}
|
||||
// Moonlight 是外部全屏进程:Kiosk 画面停在"正在连接"覆盖层,
|
||||
// 串流窗口由 moonlight-qt 自行置顶显示。
|
||||
if (stack_->currentWidget() != sessionView_->parentWidget()) {
|
||||
stack_->setCurrentWidget(sessionView_->parentWidget());
|
||||
}
|
||||
sessionOverlay_->setText(QStringLiteral("正在连接 %1 …").arg(ticket.endpoint));
|
||||
sessionOverlay_->show();
|
||||
|
||||
bool MainWindow::startMoonlightSession(const ConnectionTicket& ticket, quint64 attempt) {
|
||||
MoonlightSession::Options opts;
|
||||
opts.decoder = QStringLiteral("software"); // RK3528 无可用 V4L2 硬解设备
|
||||
opts.decoder = QStringLiteral("software");
|
||||
opts.videoCodec = QStringLiteral("H.264");
|
||||
opts.audioOnHost = true;
|
||||
|
||||
moonlightSession_ = std::make_unique<MoonlightSession>(/*parent=*/nullptr);
|
||||
moonlightSession_->setOptions(opts);
|
||||
moonlightSession_->start(ticket, [this](bool running, const QString& message) {
|
||||
|
||||
phaseClock_.restart();
|
||||
connectionPhase_ = ConnectionPhase::kProcessStart;
|
||||
logConnectionPhase(attempt, QStringLiteral("process_start"), QStringLiteral("started"));
|
||||
armConnectionTimeout(attempt, ConnectionPhase::kProcessStart, kProcessStartTimeoutMs);
|
||||
|
||||
return moonlightSession_->start(ticket, [this, attempt](const MoonlightSession::Event& event) {
|
||||
QMetaObject::invokeMethod(
|
||||
this,
|
||||
[this, running, message]() { onMoonlightState(running, message); },
|
||||
this, [this, attempt, event]() { onMoonlightEvent(attempt, event); },
|
||||
Qt::QueuedConnection);
|
||||
});
|
||||
}
|
||||
|
||||
void MainWindow::onMoonlightState(bool running, const QString& message) {
|
||||
if (running) {
|
||||
// 进程已拉起;串流窗口是否成功由 moonlight 自身展示
|
||||
updateStatusBar(QStringLiteral("SESSION %1").arg(message));
|
||||
void MainWindow::onMoonlightEvent(quint64 attempt, const MoonlightSession::Event& event) {
|
||||
if (attempt != connectionAttempt_) {
|
||||
return;
|
||||
}
|
||||
// 进程退出:回桌面列表
|
||||
moonlightSession_.reset();
|
||||
stopSessions();
|
||||
if (fsm_.current() == State::kConnecting || fsm_.current() == State::kSession) {
|
||||
fsm_.transitionTo(State::kDesktopList, message);
|
||||
|
||||
switch (event.type) {
|
||||
case MoonlightSession::EventType::kStarted:
|
||||
if (connectionPhase_ != ConnectionPhase::kProcessStart) {
|
||||
return;
|
||||
}
|
||||
connectionTimer_->stop();
|
||||
logConnectionPhase(attempt, QStringLiteral("process_start"), QStringLiteral("ready"));
|
||||
phaseClock_.restart();
|
||||
armConnectionTimeout(attempt, ConnectionPhase::kStreamReady, kStreamReadyTimeoutMs);
|
||||
updateStatusBar(QStringLiteral("CONNECTING 正在初始化云桌面画面"));
|
||||
break;
|
||||
case MoonlightSession::EventType::kStreamReady:
|
||||
if (connectionPhase_ != ConnectionPhase::kStreamReady) {
|
||||
return;
|
||||
}
|
||||
waitForMoonlightWindow(attempt);
|
||||
break;
|
||||
case MoonlightSession::EventType::kFailed:
|
||||
failConnection(attempt, event.message);
|
||||
break;
|
||||
case MoonlightSession::EventType::kFinished: {
|
||||
connectionTimer_->stop();
|
||||
logConnectionPhase(attempt, QStringLiteral("session"), QStringLiteral("finished"));
|
||||
++connectionAttempt_;
|
||||
connectionPhase_ = ConnectionPhase::kIdle;
|
||||
overlay_->finish();
|
||||
if (fsm_.isIn(State::kConnecting) || fsm_.isIn(State::kSession) ||
|
||||
fsm_.isIn(State::kReconnect)) {
|
||||
fsm_.transitionTo(State::kDesktopList, event.message);
|
||||
}
|
||||
if (desktopListPage_) {
|
||||
desktopListPage_->setBusy(false);
|
||||
desktopListPage_->setStatus(event.message);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::waitForMoonlightWindow(quint64 attempt) {
|
||||
if (attempt != connectionAttempt_ || connectionPhase_ != ConnectionPhase::kStreamReady) {
|
||||
return;
|
||||
}
|
||||
if (!moonlightSession_->activateStreamWindow()) {
|
||||
QTimer::singleShot(100, this, [this, attempt]() { waitForMoonlightWindow(attempt); });
|
||||
return;
|
||||
}
|
||||
|
||||
connectionTimer_->stop();
|
||||
logConnectionPhase(attempt, QStringLiteral("stream_ready"), QStringLiteral("ready"));
|
||||
connectionPhase_ = ConnectionPhase::kSession;
|
||||
fsm_.transitionTo(State::kSession, QStringLiteral("云桌面画面已就绪"));
|
||||
overlay_->onFirstFrame();
|
||||
}
|
||||
|
||||
void MainWindow::failConnection(quint64 attempt, const QString& message) {
|
||||
if (attempt != connectionAttempt_) {
|
||||
return;
|
||||
}
|
||||
connectionTimer_->stop();
|
||||
logConnectionPhase(attempt, QStringLiteral("connection"), QStringLiteral("failed"), message);
|
||||
++connectionAttempt_;
|
||||
connectionPhase_ = ConnectionPhase::kIdle;
|
||||
if (moonlightSession_) {
|
||||
moonlightSession_->stop();
|
||||
}
|
||||
overlay_->finish();
|
||||
if (fsm_.isIn(State::kConnecting) || fsm_.isIn(State::kSession) ||
|
||||
fsm_.isIn(State::kReconnect)) {
|
||||
fsm_.transitionTo(State::kDesktopList, QStringLiteral("连接失败"));
|
||||
}
|
||||
if (desktopListPage_) {
|
||||
desktopListPage_->setBusy(false);
|
||||
desktopListPage_->setStatus(message);
|
||||
desktopListPage_->setStatus(QStringLiteral("连接失败:%1").arg(message), true);
|
||||
}
|
||||
}
|
||||
|
||||
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::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);
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::onFrameImage(const QImage& image) {
|
||||
if (image.isNull()) {
|
||||
void MainWindow::armConnectionTimeout(quint64 attempt, ConnectionPhase phase, int timeoutMs) {
|
||||
if (attempt != connectionAttempt_) {
|
||||
return;
|
||||
}
|
||||
lastFrame_ = image;
|
||||
connectionPhase_ = phase;
|
||||
connectionTimer_->start(timeoutMs);
|
||||
}
|
||||
|
||||
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::logConnectionPhase(quint64 attempt, const QString& phase,
|
||||
const QString& result, const QString& error) {
|
||||
const qint64 totalMs = connectionClock_.isValid() ? connectionClock_.elapsed() : 0;
|
||||
const qint64 phaseMs = phaseClock_.isValid() ? phaseClock_.elapsed() : 0;
|
||||
qInfo().noquote()
|
||||
<< QStringLiteral("vdi_connection attempt=%1 desktop=%2 phase=%3 result=%4 phase_ms=%5 total_ms=%6 error=%7")
|
||||
.arg(attempt)
|
||||
.arg(connectingDesktopId_, phase, result)
|
||||
.arg(phaseMs)
|
||||
.arg(totalMs)
|
||||
.arg(error);
|
||||
}
|
||||
|
||||
void MainWindow::onQuitRequested() {
|
||||
@ -532,40 +416,43 @@ void MainWindow::onQuitRequested() {
|
||||
}
|
||||
|
||||
void MainWindow::stopSessions() {
|
||||
if (rdpSession_) {
|
||||
rdpSession_->stop();
|
||||
rdpSession_.reset();
|
||||
stopSessions(true);
|
||||
}
|
||||
|
||||
void MainWindow::stopSessions(bool invalidateAttempt) {
|
||||
connectionTimer_->stop();
|
||||
if (invalidateAttempt) {
|
||||
++connectionAttempt_;
|
||||
}
|
||||
connectionPhase_ = ConnectionPhase::kIdle;
|
||||
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: // 重新连接当前桌面
|
||||
case 1:
|
||||
if (fsm_.canConnect() && !activeTicket_.desktopId.isEmpty()) {
|
||||
reconnectAttempts_ = 0;
|
||||
stopSessions();
|
||||
if (activeTicket_.protocol == Protocol::kMoonlight) {
|
||||
startMoonlightSession(activeTicket_);
|
||||
} else {
|
||||
startRdpSession(activeTicket_);
|
||||
const quint64 attempt = ++connectionAttempt_;
|
||||
connectingDesktopId_ = activeTicket_.desktopId;
|
||||
connectionClock_.restart();
|
||||
if (fsm_.transitionTo(State::kConnecting, activeTicket_.endpoint) &&
|
||||
!startMoonlightSession(activeTicket_, attempt)) {
|
||||
failConnection(attempt, QStringLiteral("已有串流进程正在运行"));
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 2: // 断开回桌面列表
|
||||
case 2:
|
||||
stopSessions();
|
||||
if (fsm_.current() != State::kConfig) {
|
||||
fsm_.transitionTo(State::kDesktopList, "已断开");
|
||||
fsm_.transitionTo(State::kDesktopList, QStringLiteral("已断开"));
|
||||
}
|
||||
break;
|
||||
case 3: // 退出终端
|
||||
case 3:
|
||||
onQuitRequested();
|
||||
break;
|
||||
default: {
|
||||
@ -588,90 +475,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
|
||||
|
||||
@ -1,43 +1,30 @@
|
||||
// Kiosk 主窗口
|
||||
//
|
||||
// 产品流程编排:
|
||||
// 开机 -> 登录页 -> 认证 -> 桌面列表 -> 选择桌面
|
||||
// -> 获取连接票据 -> RDP(内嵌)或 Moonlight(外部进程)会话
|
||||
// -> 会话结束回桌面列表;退出登录回登录页
|
||||
//
|
||||
// 对应《VDI 云桌面软件开发方案》3.2 节的状态机,
|
||||
// 并按产品流程扩展 AUTHENTICATING / DESKTOP_LIST 两个状态。
|
||||
// 全局热键 Ctrl+Alt+Shift+M 呼出管理菜单。
|
||||
// 产品流程编排:开机 -> 桌面列表 -> 获取连接票据 -> Moonlight 会话 -> 返回列表。
|
||||
|
||||
#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,64 +33,61 @@ 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);
|
||||
|
||||
private:
|
||||
enum class ConnectionPhase {
|
||||
kIdle,
|
||||
kTicket,
|
||||
kProcessStart,
|
||||
kStreamReady,
|
||||
kSession,
|
||||
};
|
||||
|
||||
void setupUi();
|
||||
void setupHotkey();
|
||||
void setupHeartbeat();
|
||||
void onStateChanged(vdi::State from, vdi::State to, const QString& detail);
|
||||
void refreshDesktopList();
|
||||
void startRdpSession(const ConnectionTicket& ticket);
|
||||
void startMoonlightSession(const ConnectionTicket& ticket);
|
||||
bool startMoonlightSession(const ConnectionTicket& ticket, quint64 attempt);
|
||||
void onMoonlightEvent(quint64 attempt, const MoonlightSession::Event& event);
|
||||
void waitForMoonlightWindow(quint64 attempt);
|
||||
void stopSessions();
|
||||
void stopSessions(bool invalidateAttempt);
|
||||
void failConnection(quint64 attempt, const QString& message);
|
||||
void armConnectionTimeout(quint64 attempt, ConnectionPhase phase, int timeoutMs);
|
||||
void logConnectionPhase(quint64 attempt, const QString& phase,
|
||||
const QString& result = QString(),
|
||||
const QString& error = QString());
|
||||
void showLogin();
|
||||
void showDesktopList();
|
||||
void updateStatusBar(const QString& text);
|
||||
bool forwardKeyEvent(QKeyEvent* event, bool pressed);
|
||||
bool forwardMouseEvent(QEvent* event);
|
||||
|
||||
StateMachine fsm_;
|
||||
DesktopService desktopService_;
|
||||
std::unique_ptr<RdpSession> rdpSession_;
|
||||
std::unique_ptr<MoonlightSession> moonlightSession_;
|
||||
ConnectionTicket activeTicket_;
|
||||
QString currentUser_;
|
||||
QString connectingDesktopId_;
|
||||
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* connectionTimer_ = nullptr;
|
||||
QTimer* heartbeatTimer_ = nullptr;
|
||||
QTimer* frameRefreshTimer_ = nullptr;
|
||||
QElapsedTimer connectionClock_;
|
||||
QElapsedTimer phaseClock_;
|
||||
quint64 connectionAttempt_ = 0;
|
||||
ConnectionPhase connectionPhase_ = ConnectionPhase::kIdle;
|
||||
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
|
||||
|
||||
@ -159,6 +159,12 @@ int Monitor::run(const std::string& command) {
|
||||
log("主进程已退出, code=" + std::to_string(exitCode) +
|
||||
(signalNum ? (", signal=" + std::to_string(signalNum)) : ""));
|
||||
|
||||
// shell 主进程退出时 Moonlight 等后代可能仍留在原进程组中。
|
||||
// 重启前清理整个组,避免旧串流窗口与新实例并存。
|
||||
killTree(child, SIGTERM);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(300));
|
||||
killTree(child, SIGKILL);
|
||||
|
||||
if (++restartAttempts_ > config_.maxRestartAttempts) {
|
||||
log("连续重启超过 " + std::to_string(config_.maxRestartAttempts) +
|
||||
" 次,放弃重启(熔断)");
|
||||
|
||||
@ -1,17 +1,104 @@
|
||||
#include "moonlight_session.h"
|
||||
|
||||
#include <QDate>
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QProcessEnvironment>
|
||||
#include <QTextStream>
|
||||
#include <QTimer>
|
||||
|
||||
namespace vdi {
|
||||
namespace {
|
||||
|
||||
MoonlightSession::MoonlightSession(QObject* parent) : QObject(parent) {}
|
||||
constexpr int kReadyStableMs = 750;
|
||||
constexpr int kTerminateGraceMs = 2000;
|
||||
|
||||
bool isTestDecodeLine(const QByteArray& line) {
|
||||
return line.toLower().contains("test decode successful");
|
||||
}
|
||||
|
||||
bool isRendererLine(const QByteArray& line) {
|
||||
const QByteArray lower = line.toLower();
|
||||
return lower.contains("renderer '") && lower.contains(" chosen");
|
||||
}
|
||||
|
||||
bool isTerminalErrorLine(const QByteArray& line) {
|
||||
const QByteArray lower = line.toLower();
|
||||
if (lower.contains("会话已终止")) {
|
||||
return true;
|
||||
}
|
||||
const QByteArray marker("connection terminated:");
|
||||
const int markerPos = lower.indexOf(marker);
|
||||
if (markerPos < 0) {
|
||||
return false;
|
||||
}
|
||||
bool ok = false;
|
||||
const int code = lower.mid(markerPos + marker.size()).trimmed().toInt(&ok);
|
||||
return !ok || code != 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void MoonlightOutputParser::reset() {
|
||||
buffer_.clear();
|
||||
hasTestDecode_ = false;
|
||||
hasRenderer_ = false;
|
||||
hasTerminalError_ = false;
|
||||
}
|
||||
|
||||
void MoonlightOutputParser::append(const QByteArray& output) {
|
||||
buffer_.append(output);
|
||||
int newline = -1;
|
||||
while ((newline = buffer_.indexOf('\n')) >= 0) {
|
||||
const QByteArray line = buffer_.left(newline).trimmed();
|
||||
buffer_.remove(0, newline + 1);
|
||||
hasTestDecode_ = hasTestDecode_ || isTestDecodeLine(line);
|
||||
hasRenderer_ = hasRenderer_ || isRendererLine(line);
|
||||
hasTerminalError_ = hasTerminalError_ || isTerminalErrorLine(line);
|
||||
}
|
||||
}
|
||||
|
||||
bool MoonlightOutputParser::isReady() const {
|
||||
return hasTestDecode_ && hasRenderer_ && !hasTerminalError_;
|
||||
}
|
||||
|
||||
MoonlightSession::MoonlightSession(QObject* parent) : QObject(parent) {
|
||||
process_ = new QProcess(this);
|
||||
process_->setProcessChannelMode(QProcess::MergedChannels);
|
||||
connect(process_, &QProcess::started, this, &MoonlightSession::onProcessStarted);
|
||||
connect(process_,
|
||||
QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished), this,
|
||||
&MoonlightSession::onProcessFinished);
|
||||
connect(process_, &QProcess::errorOccurred, this, &MoonlightSession::onProcessError);
|
||||
connect(process_, &QProcess::readyReadStandardOutput, this,
|
||||
&MoonlightSession::onProcessOutput);
|
||||
|
||||
readyTimer_ = new QTimer(this);
|
||||
readyTimer_->setSingleShot(true);
|
||||
connect(readyTimer_, &QTimer::timeout, this, [this]() {
|
||||
if (!readyEventDelivered_ && !terminalEventDelivered_ && isRunning()) {
|
||||
readyEventDelivered_ = true;
|
||||
emitEvent(EventType::kStreamReady, QStringLiteral("串流画面已就绪"));
|
||||
}
|
||||
});
|
||||
|
||||
killTimer_ = new QTimer(this);
|
||||
killTimer_->setSingleShot(true);
|
||||
connect(killTimer_, &QTimer::timeout, this, [this]() {
|
||||
if (isRunning()) {
|
||||
process_->kill();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
MoonlightSession::~MoonlightSession() {
|
||||
stop();
|
||||
callback_ = nullptr;
|
||||
readyTimer_->stop();
|
||||
killTimer_->stop();
|
||||
if (isRunning()) {
|
||||
process_->kill();
|
||||
process_->waitForFinished(1000);
|
||||
}
|
||||
}
|
||||
|
||||
void MoonlightSession::setOptions(const Options& options) {
|
||||
@ -19,19 +106,55 @@ void MoonlightSession::setOptions(const Options& options) {
|
||||
}
|
||||
|
||||
bool MoonlightSession::isRunning() const {
|
||||
return process_ && process_->state() != QProcess::NotRunning;
|
||||
return process_->state() != QProcess::NotRunning;
|
||||
}
|
||||
|
||||
bool MoonlightSession::start(const ConnectionTicket& ticket, StateCallback callback) {
|
||||
qint64 MoonlightSession::processId() const {
|
||||
return isRunning() ? process_->processId() : 0;
|
||||
}
|
||||
|
||||
bool MoonlightSession::activateStreamWindow() {
|
||||
if (!isRunning()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
QProcess finder;
|
||||
finder.start(QStringLiteral("xdotool"),
|
||||
{QStringLiteral("search"), QStringLiteral("--onlyvisible"),
|
||||
QStringLiteral("--pid"), QString::number(process_->processId()),
|
||||
QStringLiteral("--class"), QStringLiteral("com.moonlight_stream.Moonlight")});
|
||||
if (!finder.waitForFinished(500) || finder.exitCode() != 0) {
|
||||
return false;
|
||||
}
|
||||
const QString windowId = QString::fromUtf8(finder.readAllStandardOutput())
|
||||
.split(QLatin1Char('\n'), Qt::SkipEmptyParts)
|
||||
.value(0).trimmed();
|
||||
if (windowId.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return QProcess::execute(QStringLiteral("xdotool"),
|
||||
{QStringLiteral("windowactivate"), QStringLiteral("--sync"),
|
||||
windowId}) == 0;
|
||||
}
|
||||
|
||||
bool MoonlightSession::start(const ConnectionTicket& ticket, EventCallback callback) {
|
||||
if (isRunning()) {
|
||||
return false;
|
||||
}
|
||||
callback_ = std::move(callback);
|
||||
|
||||
// moonlight-qt 需要 X11 会话环境;由 kiosk-shell 继承当前 DISPLAY/XAUTHORITY
|
||||
process_ = new QProcess(this);
|
||||
callback_ = std::move(callback);
|
||||
stopRequested_ = false;
|
||||
terminalEventDelivered_ = false;
|
||||
readyEventDelivered_ = false;
|
||||
outputParser_.reset();
|
||||
readyTimer_->stop();
|
||||
killTimer_->stop();
|
||||
|
||||
process_->setProgram(QStringLiteral("moonlight-qt"));
|
||||
process_->setProcessChannelMode(QProcess::MergedChannels);
|
||||
QProcessEnvironment environment = QProcessEnvironment::systemEnvironment();
|
||||
environment.insert(QStringLiteral("GL_IS_SLOW"), QStringLiteral("0"));
|
||||
process_->setProcessEnvironment(environment);
|
||||
|
||||
QStringList args;
|
||||
args << QStringLiteral("stream")
|
||||
@ -46,96 +169,109 @@ bool MoonlightSession::start(const ConnectionTicket& ticket, StateCallback callb
|
||||
<< QStringLiteral("--video-decoder") << options_.decoder
|
||||
<< QStringLiteral("--absolute-mouse")
|
||||
<< QStringLiteral("--keep-awake");
|
||||
if (options_.vsync) {
|
||||
args << QStringLiteral("--vsync");
|
||||
} else {
|
||||
args << QStringLiteral("--no-vsync");
|
||||
}
|
||||
if (options_.framePacing) {
|
||||
args << QStringLiteral("--frame-pacing");
|
||||
} else {
|
||||
args << QStringLiteral("--no-frame-pacing");
|
||||
}
|
||||
args << (options_.vsync ? QStringLiteral("--vsync") : QStringLiteral("--no-vsync"));
|
||||
args << (options_.framePacing ? QStringLiteral("--frame-pacing")
|
||||
: QStringLiteral("--no-frame-pacing"));
|
||||
if (options_.performanceOverlay) {
|
||||
args << QStringLiteral("--performance-overlay");
|
||||
}
|
||||
if (options_.audioOnHost) {
|
||||
args << QStringLiteral("--audio-on-host");
|
||||
} else {
|
||||
args << QStringLiteral("--no-audio-on-host");
|
||||
}
|
||||
args << (options_.audioOnHost ? QStringLiteral("--audio-on-host")
|
||||
: QStringLiteral("--no-audio-on-host"));
|
||||
process_->setArguments(args);
|
||||
|
||||
// 日志追加到 moonlight-yyyyMMdd.log,便于现场排查
|
||||
const QString logDir =
|
||||
QDir::homePath() + QStringLiteral("/vdi-terminal/logs");
|
||||
const QString logDir = QDir::homePath() + QStringLiteral("/vdi-terminal/logs");
|
||||
QDir().mkpath(logDir);
|
||||
const QString logPath = logDir + QStringLiteral("/moonlight-%1.log")
|
||||
.arg(QDate::currentDate().toString(QStringLiteral("yyyyMMdd")));
|
||||
logPath_ = logDir + QStringLiteral("/moonlight-%1.log")
|
||||
.arg(QDate::currentDate().toString(QStringLiteral("yyyyMMdd")));
|
||||
|
||||
// QProcess::finished 在 Qt5 里有两个重载((int) 已废弃),必须用 QOverload 消歧
|
||||
connect(process_,
|
||||
QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished), this,
|
||||
&MoonlightSession::onProcessFinished);
|
||||
connect(process_, &QProcess::errorOccurred, this, &MoonlightSession::onProcessError);
|
||||
connect(process_, &QProcess::readyReadStandardOutput, this, [this, logPath]() {
|
||||
QFile log(logPath);
|
||||
if (log.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text)) {
|
||||
QTextStream(&log) << QString::fromUtf8(process_->readAllStandardOutput());
|
||||
}
|
||||
});
|
||||
|
||||
if (callback_) {
|
||||
callback_(true, QStringLiteral("串流进程已启动"));
|
||||
}
|
||||
process_->start();
|
||||
return true;
|
||||
}
|
||||
|
||||
void MoonlightSession::onProcessStarted() {
|
||||
emitEvent(EventType::kStarted, QStringLiteral("串流进程已启动"));
|
||||
}
|
||||
|
||||
void MoonlightSession::onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus) {
|
||||
QString message;
|
||||
if (exitStatus == QProcess::CrashExit) {
|
||||
message = QStringLiteral("串流异常退出 (code=%1)").arg(exitCode);
|
||||
} else {
|
||||
message = QStringLiteral("串流已结束 (code=%1)").arg(exitCode);
|
||||
}
|
||||
process_->deleteLater();
|
||||
process_ = nullptr;
|
||||
if (callback_) {
|
||||
callback_(false, message);
|
||||
readyTimer_->stop();
|
||||
killTimer_->stop();
|
||||
|
||||
if (stopRequested_) {
|
||||
emitTerminalEvent(EventType::kFinished, QStringLiteral("串流已结束"));
|
||||
return;
|
||||
}
|
||||
|
||||
const QString message = exitStatus == QProcess::CrashExit
|
||||
? QStringLiteral("串流异常退出 (code=%1)").arg(exitCode)
|
||||
: QStringLiteral("串流已结束 (code=%1)").arg(exitCode);
|
||||
emitTerminalEvent(exitStatus == QProcess::CrashExit ? EventType::kFailed
|
||||
: EventType::kFinished,
|
||||
message);
|
||||
}
|
||||
|
||||
void MoonlightSession::onProcessError(QProcess::ProcessError error) {
|
||||
QString message;
|
||||
switch (error) {
|
||||
case QProcess::FailedToStart:
|
||||
message = QStringLiteral("无法启动 moonlight-qt,请检查安装");
|
||||
break;
|
||||
case QProcess::Crashed:
|
||||
return; // 由 finished(CrashExit) 统一上报
|
||||
default:
|
||||
message = QStringLiteral("串流进程错误 (%1)").arg(static_cast<int>(error));
|
||||
break;
|
||||
if (error == QProcess::Crashed) {
|
||||
return;
|
||||
}
|
||||
if (callback_) {
|
||||
callback_(false, message);
|
||||
|
||||
const QString message = error == QProcess::FailedToStart
|
||||
? QStringLiteral("无法启动 moonlight-qt,请检查安装")
|
||||
: QStringLiteral("串流进程错误 (%1)").arg(static_cast<int>(error));
|
||||
emitTerminalEvent(EventType::kFailed, message);
|
||||
}
|
||||
|
||||
void MoonlightSession::onProcessOutput() {
|
||||
const QByteArray output = process_->readAllStandardOutput();
|
||||
if (output.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
QFile log(logPath_);
|
||||
if (log.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text)) {
|
||||
log.write(output);
|
||||
}
|
||||
|
||||
const bool wasReady = outputParser_.isReady();
|
||||
outputParser_.append(output);
|
||||
if (outputParser_.hasTerminalError()) {
|
||||
readyTimer_->stop();
|
||||
emitTerminalEvent(EventType::kFailed, QStringLiteral("云桌面连接已中断"));
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
if (!wasReady && outputParser_.isReady()) {
|
||||
scheduleReadyEvent();
|
||||
}
|
||||
}
|
||||
|
||||
void MoonlightSession::stop() {
|
||||
if (!process_) {
|
||||
if (!isRunning()) {
|
||||
return;
|
||||
}
|
||||
QProcess* proc = process_;
|
||||
process_ = nullptr; // 先断开自身引用,避免 finished 回调再触发状态迁移
|
||||
disconnect(proc, nullptr, this, nullptr);
|
||||
proc->terminate();
|
||||
if (!proc->waitForFinished(2000)) {
|
||||
proc->kill();
|
||||
proc->waitForFinished(1000);
|
||||
stopRequested_ = true;
|
||||
readyTimer_->stop();
|
||||
process_->terminate();
|
||||
killTimer_->start(kTerminateGraceMs);
|
||||
}
|
||||
|
||||
void MoonlightSession::emitEvent(EventType type, const QString& message) {
|
||||
if (callback_) {
|
||||
callback_(Event{type, message});
|
||||
}
|
||||
}
|
||||
|
||||
void MoonlightSession::emitTerminalEvent(EventType type, const QString& message) {
|
||||
if (terminalEventDelivered_) {
|
||||
return;
|
||||
}
|
||||
terminalEventDelivered_ = true;
|
||||
emitEvent(type, message);
|
||||
}
|
||||
|
||||
void MoonlightSession::scheduleReadyEvent() {
|
||||
if (!readyEventDelivered_ && !terminalEventDelivered_ && isRunning()) {
|
||||
readyTimer_->start(kReadyStableMs);
|
||||
}
|
||||
proc->deleteLater();
|
||||
}
|
||||
|
||||
} // namespace vdi
|
||||
|
||||
@ -1,57 +1,96 @@
|
||||
// Moonlight 会话(外部进程)
|
||||
//
|
||||
// 以 QProcess 参数数组直启 moonlight-qt(不拼 shell 命令),
|
||||
// 串流期间 moonlight-qt 独占全屏显示;进程退出后回调 MainWindow 返回桌面列表。
|
||||
// 参数来自 ConnectionTicket,不再读取全局静态配置。
|
||||
// 以 QProcess 参数数组直启 moonlight-qt(不拼 shell 命令)。
|
||||
// 进程启动、串流就绪和终止是不同事件,调用方据此控制过场和状态机。
|
||||
|
||||
#ifndef KIOSK_SHELL_MOONLIGHT_SESSION_H
|
||||
#define KIOSK_SHELL_MOONLIGHT_SESSION_H
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QProcess>
|
||||
#include <QString>
|
||||
#include <functional>
|
||||
|
||||
#include "desktop_session.h"
|
||||
|
||||
class QTimer;
|
||||
|
||||
namespace vdi {
|
||||
|
||||
class MoonlightOutputParser {
|
||||
public:
|
||||
void reset();
|
||||
void append(const QByteArray& output);
|
||||
bool isReady() const;
|
||||
bool hasTestDecode() const { return hasTestDecode_; }
|
||||
bool hasRenderer() const { return hasRenderer_; }
|
||||
bool hasTerminalError() const { return hasTerminalError_; }
|
||||
|
||||
private:
|
||||
QByteArray buffer_;
|
||||
bool hasTestDecode_ = false;
|
||||
bool hasRenderer_ = false;
|
||||
bool hasTerminalError_ = false;
|
||||
};
|
||||
|
||||
class MoonlightSession : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
struct Options {
|
||||
// 低延迟开关(实测 A/B 后定版)
|
||||
bool vsync = true;
|
||||
bool framePacing = true;
|
||||
bool performanceOverlay = false;
|
||||
bool audioOnHost = true;
|
||||
QString videoCodec = QStringLiteral("H.264");
|
||||
QString decoder = QStringLiteral("software"); // RK3528 无可用 V4L2 硬解设备
|
||||
QString decoder = QStringLiteral("software");
|
||||
};
|
||||
|
||||
using StateCallback = std::function<void(bool running, const QString& message)>;
|
||||
enum class EventType {
|
||||
kStarted,
|
||||
kStreamReady,
|
||||
kFailed,
|
||||
kFinished,
|
||||
};
|
||||
|
||||
struct Event {
|
||||
EventType type;
|
||||
QString message;
|
||||
};
|
||||
|
||||
using EventCallback = std::function<void(const Event& event)>;
|
||||
|
||||
explicit MoonlightSession(QObject* parent = nullptr);
|
||||
~MoonlightSession() override;
|
||||
|
||||
void setOptions(const Options& options);
|
||||
|
||||
// 启动串流;成功返回 true(不代表串流已建立,仅代表进程已拉起)
|
||||
bool start(const ConnectionTicket& ticket, StateCallback callback);
|
||||
|
||||
// 终止串流(管理菜单/退出登录时调用)
|
||||
bool start(const ConnectionTicket& ticket, EventCallback callback);
|
||||
void stop();
|
||||
|
||||
bool isRunning() const;
|
||||
qint64 processId() const;
|
||||
bool activateStreamWindow();
|
||||
|
||||
private slots:
|
||||
void onProcessStarted();
|
||||
void onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus);
|
||||
void onProcessError(QProcess::ProcessError error);
|
||||
void onProcessOutput();
|
||||
|
||||
private:
|
||||
void emitEvent(EventType type, const QString& message);
|
||||
void emitTerminalEvent(EventType type, const QString& message);
|
||||
void scheduleReadyEvent();
|
||||
|
||||
QProcess* process_ = nullptr;
|
||||
QTimer* readyTimer_ = nullptr;
|
||||
QTimer* killTimer_ = nullptr;
|
||||
Options options_;
|
||||
StateCallback callback_;
|
||||
EventCallback callback_;
|
||||
MoonlightOutputParser outputParser_;
|
||||
QString logPath_;
|
||||
bool stopRequested_ = false;
|
||||
bool terminalEventDelivered_ = false;
|
||||
bool readyEventDelivered_ = false;
|
||||
};
|
||||
|
||||
} // namespace vdi
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
128
terminal/kiosk-shell/src/transition_overlay.cpp
Normal file
128
terminal/kiosk-shell/src/transition_overlay.cpp
Normal file
@ -0,0 +1,128 @@
|
||||
#include "transition_overlay.h"
|
||||
|
||||
#include <QEvent>
|
||||
#include <QFont>
|
||||
#include <QGuiApplication>
|
||||
#include <QLabel>
|
||||
#include <QScreen>
|
||||
#include <QTimer>
|
||||
#include <QVBoxLayout>
|
||||
#include <QVariant>
|
||||
|
||||
namespace vdi {
|
||||
namespace {
|
||||
|
||||
constexpr int kTickMs = 400; // 省略号/旋轮动画周期
|
||||
constexpr int kFadeStepMs = 40; // 淡出步进周期
|
||||
constexpr int kFadeSteps = 25; // 全程约 1 秒淡出
|
||||
|
||||
const char* kSpinnerFrames[] = {"◐", "◓", "◑", "◒"};
|
||||
|
||||
} // namespace
|
||||
|
||||
TransitionOverlay::TransitionOverlay(QWidget* parent) : QWidget(parent) {
|
||||
// 独立置顶无边框窗口只在启动时映射一次,后续不再与 Moonlight 抢焦点。
|
||||
setWindowFlags(Qt::Window | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint |
|
||||
Qt::WindowDoesNotAcceptFocus);
|
||||
setAttribute(Qt::WA_ShowWithoutActivating);
|
||||
setStyleSheet(QStringLiteral("TransitionOverlay { background-color: #000000; }"));
|
||||
|
||||
auto* layout = new QVBoxLayout(this);
|
||||
layout->setSpacing(28);
|
||||
|
||||
labelSpinner_ = new QLabel(this);
|
||||
QFont spinFont = labelSpinner_->font();
|
||||
spinFont.setPointSize(44);
|
||||
labelSpinner_->setFont(spinFont);
|
||||
labelSpinner_->setAlignment(Qt::AlignCenter);
|
||||
labelSpinner_->setStyleSheet(QStringLiteral("color: #2f6feb; background: transparent;"));
|
||||
layout->addStretch(1);
|
||||
layout->addWidget(labelSpinner_);
|
||||
|
||||
labelTitle_ = new QLabel(this);
|
||||
QFont titleFont = labelTitle_->font();
|
||||
titleFont.setPointSize(20);
|
||||
labelTitle_->setFont(titleFont);
|
||||
labelTitle_->setAlignment(Qt::AlignCenter);
|
||||
labelTitle_->setStyleSheet(QStringLiteral("color: #e6e9ef; background: transparent;"));
|
||||
layout->addWidget(labelTitle_);
|
||||
layout->addStretch(2);
|
||||
|
||||
tickTimer_ = new QTimer(this);
|
||||
connect(tickTimer_, &QTimer::timeout, this, &TransitionOverlay::onTick);
|
||||
fadeTimer_ = new QTimer(this);
|
||||
connect(fadeTimer_, &QTimer::timeout, this, &TransitionOverlay::onFadeStep);
|
||||
|
||||
// 活动期间吞掉本窗口的键盘/鼠标事件,避免误触下层页面
|
||||
installEventFilter(this);
|
||||
}
|
||||
|
||||
void TransitionOverlay::start(const QString& title) {
|
||||
tickTimer_->stop();
|
||||
fadeTimer_->stop();
|
||||
dots_ = 0;
|
||||
setWindowOpacity(1.0);
|
||||
setProperty("baseTitle", title);
|
||||
labelTitle_->setText(title);
|
||||
labelSpinner_->setText(QString::fromUtf8(kSpinnerFrames[0]));
|
||||
|
||||
if (QScreen* targetScreen = QGuiApplication::primaryScreen()) {
|
||||
setGeometry(targetScreen->geometry());
|
||||
}
|
||||
show();
|
||||
raise();
|
||||
tickTimer_->start(kTickMs);
|
||||
}
|
||||
|
||||
void TransitionOverlay::finish() {
|
||||
tickTimer_->stop();
|
||||
fadeTimer_->stop();
|
||||
hide();
|
||||
setWindowOpacity(1.0);
|
||||
}
|
||||
|
||||
void TransitionOverlay::onFirstFrame() {
|
||||
// 首帧已出画:停止动画并淡出;若尚未显示/已在淡出则忽略
|
||||
if (!isVisible() || fadeTimer_->isActive()) {
|
||||
return;
|
||||
}
|
||||
tickTimer_->stop();
|
||||
fadeTimer_->start(kFadeStepMs);
|
||||
}
|
||||
|
||||
void TransitionOverlay::onTick() {
|
||||
dots_ = (dots_ + 1) % 4;
|
||||
const QString base = property("baseTitle").toString();
|
||||
if (!base.isEmpty()) {
|
||||
labelTitle_->setText(base + QString(dots_, QLatin1Char('.')));
|
||||
}
|
||||
labelSpinner_->setText(QString::fromUtf8(kSpinnerFrames[dots_]));
|
||||
}
|
||||
|
||||
void TransitionOverlay::onFadeStep() {
|
||||
const double opacity = windowOpacity() - 1.0 / kFadeSteps;
|
||||
if (opacity <= 0.0) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
setWindowOpacity(opacity);
|
||||
}
|
||||
|
||||
bool TransitionOverlay::eventFilter(QObject* watched, QEvent* event) {
|
||||
if (watched == this && isVisible()) {
|
||||
switch (event->type()) {
|
||||
case QEvent::KeyPress:
|
||||
case QEvent::KeyRelease:
|
||||
case QEvent::MouseButtonPress:
|
||||
case QEvent::MouseButtonRelease:
|
||||
case QEvent::MouseMove:
|
||||
case QEvent::Wheel:
|
||||
return true;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return QWidget::eventFilter(watched, event);
|
||||
}
|
||||
|
||||
} // namespace vdi
|
||||
49
terminal/kiosk-shell/src/transition_overlay.h
Normal file
49
terminal/kiosk-shell/src/transition_overlay.h
Normal file
@ -0,0 +1,49 @@
|
||||
// 会话切换过场层
|
||||
//
|
||||
// 用途:用户点击"连接"后,Moonlight 客户端会先在屏幕左上角创建一个小窗口,
|
||||
// 再扩展到全屏并解码首帧,这段时间画面闪烁、体验割裂。
|
||||
// 本控件是覆盖在 kiosk 全屏窗口之上的不透明过场层(置顶),
|
||||
// 盖住这个过程,直到收到首帧信号后淡出,让用户感知为"一次平滑的转场"。
|
||||
|
||||
#ifndef KIOSK_SHELL_TRANSITION_OVERLAY_H
|
||||
#define KIOSK_SHELL_TRANSITION_OVERLAY_H
|
||||
|
||||
#include <QWidget>
|
||||
|
||||
class QLabel;
|
||||
class QTimer;
|
||||
|
||||
namespace vdi {
|
||||
|
||||
class TransitionOverlay : public QWidget {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit TransitionOverlay(QWidget* parent = nullptr);
|
||||
|
||||
// 显示过场层并开始动画;title 为提示文字(如"正在进入 Windows 10")
|
||||
void start(const QString& title);
|
||||
// 隐藏过场层(带淡出)
|
||||
void finish();
|
||||
|
||||
// 首次视频帧已到达
|
||||
void onFirstFrame();
|
||||
|
||||
protected:
|
||||
bool eventFilter(QObject* watched, QEvent* event) override;
|
||||
|
||||
private slots:
|
||||
void onTick();
|
||||
void onFadeStep();
|
||||
|
||||
private:
|
||||
QLabel* labelTitle_ = nullptr;
|
||||
QLabel* labelSpinner_ = nullptr;
|
||||
QTimer* tickTimer_ = nullptr;
|
||||
QTimer* fadeTimer_ = nullptr;
|
||||
int dots_ = 0;
|
||||
};
|
||||
|
||||
} // namespace vdi
|
||||
|
||||
#endif // KIOSK_SHELL_TRANSITION_OVERLAY_H
|
||||
53
terminal/kiosk-shell/tests/test_desktop_list_page.cpp
Normal file
53
terminal/kiosk-shell/tests/test_desktop_list_page.cpp
Normal file
@ -0,0 +1,53 @@
|
||||
#include <QPushButton>
|
||||
#include <QSignalSpy>
|
||||
#include <QtTest>
|
||||
|
||||
#include "desktop_list_page.h"
|
||||
|
||||
using namespace vdi;
|
||||
|
||||
class DesktopListPageTest : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
void firstClickDisablesActionsAndBlocksDuplicate();
|
||||
};
|
||||
|
||||
void DesktopListPageTest::firstClickDisablesActionsAndBlocksDuplicate() {
|
||||
DesktopListPage page;
|
||||
DesktopInfo desktop;
|
||||
desktop.id = QStringLiteral("desktop-1");
|
||||
desktop.name = QStringLiteral("Windows 10");
|
||||
desktop.os = QStringLiteral("Windows");
|
||||
desktop.state = DesktopState::kAvailable;
|
||||
page.setDesktops({desktop});
|
||||
|
||||
QPushButton* connectButton = nullptr;
|
||||
QPushButton* editButton = nullptr;
|
||||
const auto buttons = page.findChildren<QPushButton*>();
|
||||
for (QPushButton* button : buttons) {
|
||||
if (button->text() == QStringLiteral("连接")) {
|
||||
connectButton = button;
|
||||
} else if (button->text() == QStringLiteral("设置")) {
|
||||
editButton = button;
|
||||
}
|
||||
}
|
||||
QVERIFY(connectButton);
|
||||
QVERIFY(editButton);
|
||||
|
||||
QSignalSpy spy(&page, &DesktopListPage::desktopSelected);
|
||||
connectButton->click();
|
||||
QCOMPARE(spy.count(), 1);
|
||||
QVERIFY(!connectButton->isEnabled());
|
||||
QVERIFY(!editButton->isEnabled());
|
||||
|
||||
connectButton->click();
|
||||
QCOMPARE(spy.count(), 1);
|
||||
|
||||
page.setBusy(false);
|
||||
QVERIFY(connectButton->isEnabled());
|
||||
QVERIFY(editButton->isEnabled());
|
||||
}
|
||||
|
||||
QTEST_MAIN(DesktopListPageTest)
|
||||
#include "test_desktop_list_page.moc"
|
||||
94
terminal/kiosk-shell/tests/test_moonlight_session.cpp
Normal file
94
terminal/kiosk-shell/tests/test_moonlight_session.cpp
Normal file
@ -0,0 +1,94 @@
|
||||
#include <QtTest>
|
||||
|
||||
#include "moonlight_session.h"
|
||||
|
||||
using namespace vdi;
|
||||
|
||||
class MoonlightSessionTest : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
void parsesSplitReadyMarkers();
|
||||
void requiresBothReadyMarkers();
|
||||
void rejectsSecondStart();
|
||||
};
|
||||
|
||||
void MoonlightSessionTest::parsesSplitReadyMarkers() {
|
||||
MoonlightOutputParser parser;
|
||||
parser.append("Test decode succ");
|
||||
QVERIFY(!parser.isReady());
|
||||
parser.append("essful\nRenderer 'SDL' with 'DRM' backend ");
|
||||
QVERIFY(parser.hasTestDecode());
|
||||
QVERIFY(!parser.hasRenderer());
|
||||
parser.append("chosen\n");
|
||||
QVERIFY(parser.isReady());
|
||||
}
|
||||
|
||||
void MoonlightSessionTest::requiresBothReadyMarkers() {
|
||||
MoonlightOutputParser parser;
|
||||
parser.append("Test decode successful\n");
|
||||
QVERIFY(!parser.isReady());
|
||||
|
||||
parser.reset();
|
||||
parser.append("Renderer 'SDL' chosen\n");
|
||||
QVERIFY(!parser.isReady());
|
||||
|
||||
parser.append("Connection terminated: -1\n");
|
||||
QVERIFY(parser.hasTerminalError());
|
||||
QVERIFY(!parser.isReady());
|
||||
}
|
||||
|
||||
void MoonlightSessionTest::rejectsSecondStart() {
|
||||
QTemporaryDir tempDir;
|
||||
QVERIFY(tempDir.isValid());
|
||||
|
||||
const QString executable = tempDir.filePath(QStringLiteral("moonlight-qt"));
|
||||
QFile script(executable);
|
||||
QVERIFY(script.open(QIODevice::WriteOnly | QIODevice::Text));
|
||||
script.write("#!/bin/sh\n"
|
||||
"printf 'Test decode successful\\n'\n"
|
||||
"printf \"Renderer 'SDL' with 'DRM' backend chosen\\n\"\n"
|
||||
"sleep 5\n");
|
||||
script.close();
|
||||
QVERIFY(script.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner |
|
||||
QFileDevice::ExeOwner | QFileDevice::ReadGroup |
|
||||
QFileDevice::ExeGroup | QFileDevice::ReadOther |
|
||||
QFileDevice::ExeOther));
|
||||
|
||||
const QByteArray oldPath = qgetenv("PATH");
|
||||
qputenv("PATH", tempDir.path().toUtf8() + ':' + oldPath);
|
||||
|
||||
ConnectionTicket ticket;
|
||||
ticket.endpoint = QStringLiteral("127.0.0.1");
|
||||
ticket.app = QStringLiteral("Desktop");
|
||||
|
||||
MoonlightSession session;
|
||||
int started = 0;
|
||||
int ready = 0;
|
||||
int terminal = 0;
|
||||
const auto callback = [&](const MoonlightSession::Event& event) {
|
||||
if (event.type == MoonlightSession::EventType::kStarted) {
|
||||
++started;
|
||||
} else if (event.type == MoonlightSession::EventType::kStreamReady) {
|
||||
++ready;
|
||||
} else if (event.type == MoonlightSession::EventType::kFailed ||
|
||||
event.type == MoonlightSession::EventType::kFinished) {
|
||||
++terminal;
|
||||
}
|
||||
};
|
||||
|
||||
QVERIFY(session.start(ticket, callback));
|
||||
QTRY_COMPARE_WITH_TIMEOUT(started, 1, 2000);
|
||||
QVERIFY(!session.start(ticket, callback));
|
||||
QTRY_COMPARE_WITH_TIMEOUT(ready, 1, 3000);
|
||||
|
||||
session.stop();
|
||||
QTRY_COMPARE_WITH_TIMEOUT(terminal, 1, 4000);
|
||||
QCOMPARE(ready, 1);
|
||||
QCOMPARE(terminal, 1);
|
||||
|
||||
qputenv("PATH", oldPath);
|
||||
}
|
||||
|
||||
QTEST_GUILESS_MAIN(MoonlightSessionTest)
|
||||
#include "test_moonlight_session.moc"
|
||||
@ -15,25 +15,27 @@ echo "==> 检查盒子连通性"
|
||||
ssh -o ConnectTimeout=8 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'echo "box ok: $(hostname)"'
|
||||
|
||||
echo "==> 同步源码到盒子"
|
||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'rm -rf ~/vdi-build/repo/kiosk-shell/src && mkdir -p ~/vdi-build/repo/kiosk-shell/src'
|
||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'rm -rf ~/vdi-build/repo/kiosk-shell/src ~/vdi-build/repo/kiosk-shell/tests && mkdir -p ~/vdi-build/repo/kiosk-shell/src ~/vdi-build/repo/kiosk-shell/tests'
|
||||
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/CMakeLists.txt" "$BOX":~/vdi-build/repo/kiosk-shell/
|
||||
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC"/src/*.cpp "$SRC"/src/*.h "$BOX":~/vdi-build/repo/kiosk-shell/src/
|
||||
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC"/tests/*.cpp "$BOX":~/vdi-build/repo/kiosk-shell/tests/
|
||||
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/scripts/vdi-moonlight" "$BOX":~/vdi-terminal/bin/vdi-moonlight
|
||||
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/etc/moonlight.conf" "$BOX":~/.config/vdi-terminal/moonlight.conf
|
||||
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/etc/moonlight.conf" "$BOX":/tmp/vdi-moonlight.conf
|
||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'mkdir -p ~/.config/vdi-terminal; test -f ~/.config/vdi-terminal/moonlight.conf || cp /tmp/vdi-moonlight.conf ~/.config/vdi-terminal/moonlight.conf; rm -f /tmp/vdi-moonlight.conf'
|
||||
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/vdi-kiosk.desktop" "$BOX":~/.config/autostart/vdi-kiosk.desktop
|
||||
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/vdi-moonlight.desktop" "$BOX":~/.config/autostart/vdi-moonlight.desktop
|
||||
|
||||
echo "==> 在盒子上编译"
|
||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'sed -i "s/\r$//" ~/vdi-build/repo/kiosk-shell/src/*.cpp ~/vdi-build/repo/kiosk-shell/src/*.h ~/vdi-terminal/bin/vdi-moonlight; chmod +x ~/vdi-terminal/bin/vdi-moonlight; cd ~/vdi-build/repo/kiosk-shell && (cmake -G Ninja -B build -DCMAKE_BUILD_TYPE=Release > /tmp/cmake.log 2>&1 || (tail -30 /tmp/cmake.log; exit 1)) && cmake --build build 2>&1 | tail -8'
|
||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'sed -i "s/\r$//" ~/vdi-build/repo/kiosk-shell/src/*.cpp ~/vdi-build/repo/kiosk-shell/src/*.h ~/vdi-build/repo/kiosk-shell/tests/*.cpp ~/vdi-terminal/bin/vdi-moonlight; chmod +x ~/vdi-terminal/bin/vdi-moonlight; cd ~/vdi-build/repo/kiosk-shell && (cmake -G Ninja -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON > /tmp/cmake.log 2>&1 || (tail -30 /tmp/cmake.log; exit 1)) && cmake --build build && QT_QPA_PLATFORM=offscreen ctest --test-dir build --output-on-failure'
|
||||
|
||||
echo "==> 安装二进制"
|
||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'pkill -f "/vdi-kiosk-monitor " 2>/dev/null || true; pkill -f "/bin/sh /home/armbian/vdi-terminal/bin/vdi-moonlight" 2>/dev/null || true; pkill -f "^moonlight-qt stream" 2>/dev/null || true; sleep 2; cp ~/vdi-build/repo/kiosk-shell/build/kiosk-shell ~/vdi-terminal/bin/kiosk-shell && cp ~/vdi-build/repo/kiosk-shell/build/vdi-kiosk-monitor ~/vdi-terminal/bin/vdi-kiosk-monitor && chmod +x ~/vdi-terminal/bin/kiosk-shell ~/vdi-terminal/bin/vdi-kiosk-monitor && ls -la ~/vdi-terminal/bin/'
|
||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'monitor_pids=$(pgrep -f "^/home/armbian/vdi-terminal/bin/vdi-kiosk-monitor -- " 2>/dev/null || true); test -z "$monitor_pids" || kill $monitor_pids 2>/dev/null || true; pair_pids=$(pgrep -f "^bash -c tail -f .*(pin|pair)_fifo" 2>/dev/null || true); test -z "$pair_pids" || kill $pair_pids 2>/dev/null || true; tail_pids=$(pgrep -f "^tail -f .*(pin|pair)_fifo" 2>/dev/null || true); test -z "$tail_pids" || kill $tail_pids 2>/dev/null || true; rm -f ~/pair_fifo /tmp/pin_fifo; for name in vdi-moonlight moonlight-qt kiosk-shell; do pids=$(pidof "$name" 2>/dev/null || true); test -z "$pids" || kill $pids 2>/dev/null || true; done; for i in $(seq 1 40); do pgrep -f "^/home/armbian/vdi-terminal/bin/vdi-kiosk-monitor -- " >/dev/null 2>&1 || break; sleep 0.2; done; monitor_pids=$(pgrep -f "^/home/armbian/vdi-terminal/bin/vdi-kiosk-monitor -- " 2>/dev/null || true); test -z "$monitor_pids" || kill -9 $monitor_pids 2>/dev/null || true; cp ~/vdi-build/repo/kiosk-shell/build/kiosk-shell ~/vdi-terminal/bin/kiosk-shell && cp ~/vdi-build/repo/kiosk-shell/build/vdi-kiosk-monitor ~/vdi-terminal/bin/vdi-kiosk-monitor && chmod +x ~/vdi-terminal/bin/kiosk-shell ~/vdi-terminal/bin/vdi-kiosk-monitor && ls -la ~/vdi-terminal/bin/'
|
||||
|
||||
echo "==> 收敛自启项(只保留 kiosk-shell 一条链)"
|
||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'mkdir -p ~/.config/autostart; rm -f ~/.config/autostart/vdi-kiosk.desktop.bak; sed -i "s/^Hidden=.*/Hidden=true/;s/^X-GNOME-Autostart-enabled=.*/X-GNOME-Autostart-enabled=false/" ~/.config/autostart/vdi-moonlight.desktop 2>/dev/null || true; grep -H . ~/.config/autostart/*.desktop'
|
||||
|
||||
echo "==> 启动受管栈并验证"
|
||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'rm -f /tmp/vdi-kiosk.heartbeat; nohup /home/armbian/vdi-terminal/bin/vdi-kiosk-monitor -- /home/armbian/vdi-terminal/bin/kiosk-shell > /tmp/vdi-monitor.log 2>&1 & sleep 8; echo "--- 进程 ---"; pgrep -af "vdi-kiosk-monitor|kiosk-shell|moonlight-qt" | grep -v pgrep; echo "--- 看门狗日志 ---"; tail -5 /tmp/vdi-monitor.log; echo "--- 心跳 ---"; cat /tmp/vdi-kiosk.heartbeat 2>/dev/null; echo'
|
||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'rm -f /tmp/vdi-kiosk.heartbeat; nohup env DISPLAY=:0 XAUTHORITY=/home/armbian/.Xauthority /home/armbian/vdi-terminal/bin/vdi-kiosk-monitor -- /home/armbian/vdi-terminal/bin/kiosk-shell > /tmp/vdi-monitor.log 2>&1 & sleep 8; echo "--- 进程 ---"; pgrep -af "vdi-kiosk-monitor|kiosk-shell|moonlight-qt" | grep -v pgrep; echo "--- 看门狗日志 ---"; tail -5 /tmp/vdi-monitor.log; echo "--- 心跳 ---"; cat /tmp/vdi-kiosk.heartbeat 2>/dev/null; echo'
|
||||
|
||||
echo "==> 抓屏确认当前界面(应为登录页)"
|
||||
echo "==> 抓屏确认当前界面(应为桌面列表)"
|
||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'DISPLAY=:0 xwd -root -silent' 2>/dev/null | python "$REPO/tools/xwd2png.py" /tmp/kiosk-login.png && echo "captured /tmp/kiosk-login.png"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user