功能:统一 Moonlight 会话并优化云桌面启动体验

This commit is contained in:
宋一鸣 2026-10-30 15:11:21 +08:00
parent 01c2a0c336
commit b2f6a6abcd
25 changed files with 933 additions and 1129 deletions

8
.gitignore vendored
View File

@ -1,3 +1,11 @@
build/ build/
CMakeFiles/
CMakeCache.txt
*.o *.o
*.so *.so
*.log
*.tmp
.zcode/
__pycache__/
test-output/
screenshots/

View File

@ -12,12 +12,11 @@
```text ```text
盒子开机 盒子开机
-> kiosk-shell 登录/首配 -> 设备身份自动登录
-> 用户认证 -> 获取并展示可用云桌面列表
-> 获取可用云桌面列表
-> 用户选择 Windows / Ubuntu / 麒麟等桌面 -> 用户选择 Windows / Ubuntu / 麒麟等桌面
-> 获取一次性连接票据 -> 获取一次性连接票据
-> 根据协议启动 RDP 或 Moonlight 会话 -> 启动 Moonlight 会话
-> 会话结束后返回桌面列表 -> 会话结束后返回桌面列表
``` ```
@ -38,8 +37,7 @@ RK3528 云终端
vdi-cloud/ vdi-cloud/
├─ terminal/ # 终端嵌入式软件 ├─ terminal/ # 终端嵌入式软件
│ ├─ meta-vdi/ # Yocto 层:BSP、内核、设备树、U-Boot、配方 │ ├─ meta-vdi/ # Yocto 层:BSP、内核、设备树、U-Boot、配方
│ ├─ kiosk-shell/ # Qt 全屏壳:登录、桌面列表、远程会话 │ ├─ kiosk-shell/ # Qt 全屏壳:桌面列表、Moonlight 会话、看门狗
│ ├─ freerdp-rk/ # FreeRDP 3 与 Rockchip MPP/VPU 适配
│ ├─ vdt-agent/ # Rust:注册、心跳、命令、日志 │ ├─ vdt-agent/ # Rust:注册、心跳、命令、日志
│ ├─ ota-updater/ # Rust:OTA 签名校验、A/B 切换、回滚 │ ├─ ota-updater/ # Rust:OTA 签名校验、A/B 切换、回滚
│ └─ usb-monitor/ # Rust:USB 监听、挂载与重定向策略 │ └─ usb-monitor/ # Rust:USB 监听、挂载与重定向策略
@ -58,39 +56,21 @@ vdi-cloud/
已实现: 已实现:
- Qt 全屏登录页; - 开机后使用设备 SN 作为当前身份,自动进入桌面列表;
- 设备 SN 和网络状态显示;
- 本地演示认证(当前账号和密码非空即可通过);
- Windows、Ubuntu、麒麟三类桌面卡片; - Windows、Ubuntu、麒麟三类桌面卡片;
- **桌面连接可配置**:每张卡片有“设置”按钮,可配置协议(Moonlight/RDP)、 - **桌面连接可配置**:每张卡片有“设置”按钮,可配置服务器地址(支持 IP、域名或 VPN 隧道地址)、Sunshine 应用名、分辨率和帧率;
服务器地址(支持 IP、域名或 VPN 隧道地址)、应用名、分辨率和帧率;
- 连接配置持久化在 `~/.config/vdi-terminal/desktops.json`, - 连接配置持久化在 `~/.config/vdi-terminal/desktops.json`,
地址为空的桌面显示“待分配”且不可连接; 地址为空的桌面显示“待分配”且不可连接;
- 老环境自动迁移:首次运行会读取 `client.conf` 里的 server 地址填充 Windows 桌面; - 老环境自动迁移:首次运行会读取 `client.conf` 里的 server 地址填充 Windows 桌面;
- `Ctrl+Alt+Shift+M` 管理菜单提供“桌面连接设置”统一入口; - `Ctrl+Alt+Shift+M` 管理菜单提供“桌面连接设置”统一入口;
- 退出登录后返回登录页;会话退出后返回桌面列表。 - 会话退出后返回桌面列表。
不同客户部署环境的桌面地址不同:换环境时在“设置”里改地址即可,不需要改代码。 不同客户部署环境的桌面地址不同:换环境时在“设置”里改地址即可,不需要改代码。
VPN 属于网络层能力(盒子上的 VPN 客户端建立隧道后),地址字段直接填隧道地址即可。 VPN 属于网络层能力(盒子上的 VPN 客户端建立隧道后),地址字段直接填隧道地址即可。
本地演示认证是后端 API 上线前的临时实现。后续通过 `DesktopService` 适配层接入真实的用户认证、桌面列表和一次性连接票据接口,页面流程不需要重写。 设备身份自动登录是正式认证 API 上线前的临时实现。后续通过 `DesktopService` 适配层接入真实的用户认证、桌面列表和一次性连接票据接口,页面流程不需要重写。
### 2. RDP 会话 ### 2. Moonlight/Sunshine 会话
已实现:
- 基于 `libfreerdp3` API 内嵌,不拉起外部 `xfreerdp` 进程;
- FreeRDP GDI/GFX 画面接入 Qt;
- 键盘、鼠标输入转发;
- X11 键盘码到 RDP 扫描码映射;
- 鼠标缩放坐标转换;
- 会话状态与断线重连;
- `Ctrl+Alt+Shift+M` 管理菜单;
- RDP 后端代码保留,可由连接票据选择启用。
当前 RDP 路径仍处于 POC 阶段,证书忽略、测试账号等配置不能直接用于正式环境。
### 3. Moonlight/Sunshine 会话
已实现: 已实现:
@ -98,13 +78,17 @@ VPN 属于网络层能力(盒子上的 VPN 客户端建立隧道后),地
- 不通过 Shell 拼接用户输入,降低命令注入风险; - 不通过 Shell 拼接用户输入,降低命令注入风险;
- H.264 软件解码; - H.264 软件解码;
- 绝对鼠标模式; - 绝对鼠标模式;
- 点击连接后立即锁定操作,保证任何时刻最多一个 Moonlight 会话;
- 解析 Moonlight 输出,只有测试解码成功、渲染器选定且全屏串流窗口已激活后才淡出过场;
- ARM/X11 环境通过 `GL_IS_SLOW=0` 缩短不可用 DRM 路径的探测时间;
- 连接阶段有结构化耗时日志和 30 秒就绪超时,失败时自动关闭错误窗口并返回列表;
- 会话进程退出后自动返回桌面列表; - 会话进程退出后自动返回桌面列表;
- `vsync`、`frame-pacing`、性能叠图等低延迟参数可配置; - `vsync`、`frame-pacing`、性能叠图等低延迟参数可配置;
- 独立 Moonlight 启动脚本保留作调试用途,但不再作为产品开机入口。 - 独立 Moonlight 启动脚本保留作调试用途,但不再作为产品开机入口。
当前联调环境中,Sunshine 服务端仍使用 `libx264` 软件编码。1080p 实际帧率约 13~22 FPS,鼠标体感延迟主要来自服务端软编码和客户端帧队列/VSync。后续需要完成 GPU 直通并启用 NVENC。 当前联调环境中,Sunshine 服务端仍使用 `libx264` 软件编码。1080p 实际帧率约 13~22 FPS,鼠标体感延迟主要来自服务端软编码和客户端帧队列/VSync。后续需要完成 GPU 直通并启用 NVENC。
### 4. 状态机 ### 3. 状态机
当前终端状态机: 当前终端状态机:
@ -119,7 +103,7 @@ BOOT
-> DESKTOP_LIST / ERROR -> DESKTOP_LIST / ERROR
``` ```
### 5. Monitor 看门狗 ### 4. Monitor 看门狗
已实现独立 `vdi-kiosk-monitor` 进程: 已实现独立 `vdi-kiosk-monitor` 进程:
@ -134,8 +118,7 @@ BOOT
### 终端 ### 终端
- C++17 - C++17
- Qt 5 Widgets - Qt 5 Widgets / Qt Test
- FreeRDP 3
- Moonlight Qt - Moonlight Qt
- 当前开发环境:Armbian + LightDM + Xorg - 当前开发环境:Armbian + LightDM + Xorg
- 正式目标环境:Weston + Wayland - 正式目标环境:Weston + Wayland
@ -164,8 +147,7 @@ BOOT
```bash ```bash
sudo apt install \ sudo apt install \
build-essential cmake ninja-build pkg-config \ build-essential cmake ninja-build pkg-config \
qtbase5-dev libqt5widgets5 \ qtbase5-dev libqt5widgets5 libqt5test5
freerdp3-dev
``` ```
Moonlight 会话还需要安装: Moonlight 会话还需要安装:
@ -178,8 +160,9 @@ sudo apt install moonlight-qt
```bash ```bash
cd terminal/kiosk-shell 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 cmake --build build
QT_QPA_PLATFORM=offscreen ctest --test-dir build --output-on-failure
``` ```
编译产物: 编译产物:
@ -255,27 +238,25 @@ Moonlight 调试配置路径:
3. 当前 Moonlight 使用软件解码,RK3528 的 VPU/MPP 硬解尚未打通; 3. 当前 Moonlight 使用软件解码,RK3528 的 VPU/MPP 硬解尚未打通;
4. Sunshine 当前使用软件编码,未启用 NVENC; 4. Sunshine 当前使用软件编码,未启用 NVENC;
5. 当前仍是 X11 POC,尚未完成 Weston/Wayland/DRM 零拷贝路径; 5. 当前仍是 X11 POC,尚未完成 Weston/Wayland/DRM 零拷贝路径;
6. RDP 证书校验、短期凭据和正式安全策略尚未完成; 6. USB 重定向、OTA、Agent、管理平台和 Web 控制台尚未开发完成。
7. USB 重定向、OTA、Agent、管理平台和 Web 控制台尚未开发完成。
## 九、下一步计划 ## 九、下一步计划
按优先级: 按优先级:
1. 恢复 RK3528 盒子网络并部署登录/桌面列表新版本; 1. 保持终端、Sunshine 和虚拟显示器三端分辨率及色彩空间一致;
2. 实测 Moonlight `no-vsync` / `no-frame-pacing` 对鼠标延迟的改善; 2. 将 GTX 965M 直通 Windows VM,使 Sunshine 使用 NVENC;
3. 将 GTX 965M 直通 Windows VM,使 Sunshine 使用 NVENC; 3. 定义并联调终端用户认证、桌面列表和一次性连接票据 API;
4. 定义并联调终端用户认证、桌面列表和一次性连接票据 API; 4. 实现 Ubuntu、麒麟桌面连接配置;
5. 实现 Ubuntu、麒麟桌面连接配置; 5. 适配 RK3528 MPP/VPU 硬解;
6. 适配 RK3528 MPP/VPU 硬解; 6. 切换到 Weston/Wayland 正式显示路径;
7. 切换到 Weston/Wayland 正式显示路径; 7. 开发 `vdt-agent`、OTA、USB、云端服务和 Web 控制台。
8. 开发 `vdt-agent`、OTA、USB、云端服务和 Web 控制台。
## 十、开发规范 ## 十、开发规范
- 已验证、已实现、待验证和规划中必须明确区分; - 已验证、已实现、待验证和规划中必须明确区分;
- 终端不能直接调用 Proxmox API; - 终端不能直接调用 Proxmox API;
- RDP 和 Moonlight 协议选择由管理平台连接票据决定; - Moonlight 是当前唯一远程会话协议,连接参数由管理平台连接票据决定;
- 所有密钥和密码只能通过安全配置注入,不得提交到 Git; - 所有密钥和密码只能通过安全配置注入,不得提交到 Git;
- 正式代码变更应通过编译、真机测试和会话恢复测试; - 正式代码变更应通过编译、真机测试和会话恢复测试;
- 本 README 随功能、接口、目录和验证结论持续更新。 - 本 README 随功能、接口、目录和验证结论持续更新。

View File

@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.16)
project(kiosk-shell CXX) project(kiosk-shell CXX)
# 对应《VDI 云桌面软件开发方案》2.2 节技术栈定版: # 对应《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 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON) set(CMAKE_AUTOMOC ON)
@ -14,10 +14,7 @@ endif()
# ---- Qt5 ---- # ---- Qt5 ----
find_package(Qt5 REQUIRED COMPONENTS Core Gui Widgets) find_package(Qt5 REQUIRED COMPONENTS Core Gui Widgets)
include(CTest)
# ---- FreeRDP 3 ----
find_package(PkgConfig REQUIRED)
pkg_check_modules(FREERDP REQUIRED IMPORTED_TARGET freerdp3)
add_executable(kiosk-shell add_executable(kiosk-shell
src/main.cpp src/main.cpp
@ -28,9 +25,9 @@ add_executable(kiosk-shell
src/desktop_service.cpp src/desktop_service.cpp
src/desktop_store.cpp src/desktop_store.cpp
src/moonlight_session.cpp src/moonlight_session.cpp
src/transition_overlay.cpp
src/state_machine.cpp src/state_machine.cpp
src/config.cpp src/config.cpp
src/rdp_session.cpp
) )
target_include_directories(kiosk-shell PRIVATE target_include_directories(kiosk-shell PRIVATE
@ -41,7 +38,6 @@ target_link_libraries(kiosk-shell PRIVATE
Qt5::Core Qt5::Core
Qt5::Gui Qt5::Gui
Qt5::Widgets Qt5::Widgets
PkgConfig::FREERDP
) )
# 看门狗 Monitor:独立进程,监控主进程假死/崩溃 # 看门狗 Monitor:独立进程,监控主进程假死/崩溃
@ -57,4 +53,39 @@ target_include_directories(vdi-kiosk-monitor PRIVATE
find_package(Threads REQUIRED) find_package(Threads REQUIRED)
target_link_libraries(vdi-kiosk-monitor PRIVATE Threads::Threads) target_link_libraries(vdi-kiosk-monitor PRIVATE Threads::Threads)
if(BUILD_TESTING)
find_package(Qt5 REQUIRED COMPONENTS Test)
add_executable(test-moonlight-session
tests/test_moonlight_session.cpp
src/moonlight_session.cpp
)
target_include_directories(test-moonlight-session PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src
)
target_link_libraries(test-moonlight-session PRIVATE
Qt5::Core
Qt5::Test
)
add_test(NAME moonlight-session COMMAND test-moonlight-session)
add_executable(test-desktop-list-page
tests/test_desktop_list_page.cpp
src/desktop_list_page.cpp
)
target_include_directories(test-desktop-list-page PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src
)
target_link_libraries(test-desktop-list-page PRIVATE
Qt5::Core
Qt5::Gui
Qt5::Widgets
Qt5::Test
)
add_test(NAME desktop-list-page COMMAND test-desktop-list-page)
set_tests_properties(desktop-list-page PROPERTIES
ENVIRONMENT "QT_QPA_PLATFORM=offscreen"
)
endif()
install(TARGETS kiosk-shell vdi-kiosk-monitor RUNTIME DESTINATION bin) install(TARGETS kiosk-shell vdi-kiosk-monitor RUNTIME DESTINATION bin)

View File

@ -35,11 +35,10 @@ DesktopListPage::DesktopListPage(QWidget* parent) : QWidget(parent) {
auto* infoRow = new QHBoxLayout(); auto* infoRow = new QHBoxLayout();
labelUser_ = new QLabel(this); labelUser_ = new QLabel(this);
labelUser_->setStyleSheet(QStringLiteral("color: #8a94a6;")); labelUser_->setStyleSheet(QStringLiteral("color: #8a94a6;"));
buttonLogout_ = new QPushButton(QStringLiteral("退出登录"), this); // 无登录页演示模式下不设"退出登录"(用户身份由设备/平台承担,
buttonLogout_->setMinimumSize(110, 38); // 正式认证接入后由 DesktopService 驱动恢复登录流程)
infoRow->addWidget(labelUser_); infoRow->addWidget(labelUser_);
addStretchToRow(infoRow); addStretchToRow(infoRow);
infoRow->addWidget(buttonLogout_);
root->addLayout(infoRow); root->addLayout(infoRow);
// ---- 卡片区 ---- // ---- 卡片区 ----
@ -62,12 +61,6 @@ DesktopListPage::DesktopListPage(QWidget* parent) : QWidget(parent) {
labelStatus_->setAlignment(Qt::AlignCenter); labelStatus_->setAlignment(Qt::AlignCenter);
labelStatus_->setStyleSheet(QStringLiteral("color: #8a94a6;")); labelStatus_->setStyleSheet(QStringLiteral("color: #8a94a6;"));
root->addWidget(labelStatus_); root->addWidget(labelStatus_);
connect(buttonLogout_, &QPushButton::clicked, this, [this]() {
if (!busy_) {
emit logoutRequested();
}
});
} }
void DesktopListPage::setDesktops(const std::vector<DesktopInfo>& desktops) { void DesktopListPage::setDesktops(const std::vector<DesktopInfo>& desktops) {
@ -76,6 +69,8 @@ void DesktopListPage::setDesktops(const std::vector<DesktopInfo>& desktops) {
} }
void DesktopListPage::rebuildCards() { void DesktopListPage::rebuildCards() {
actionButtons_.clear();
// 清空旧卡片(最后一项是 stretch) // 清空旧卡片(最后一项是 stretch)
while (cardsLayout_->count() > 1) { while (cardsLayout_->count() > 1) {
QLayoutItem* item = cardsLayout_->takeAt(0); QLayoutItem* item = cardsLayout_->takeAt(0);
@ -110,13 +105,12 @@ void DesktopListPage::rebuildCards() {
textCol->addWidget(osLine); textCol->addWidget(osLine);
row->addLayout(textCol, 1); row->addLayout(textCol, 1);
// 状态/协议标签 // 状态标签(会话通道统一 Moonlight,无需按桌面标注协议)
QString stateText; QString stateText;
QString stateColor; QString stateColor;
switch (d.state) { switch (d.state) {
case DesktopState::kAvailable: case DesktopState::kAvailable:
stateText = QStringLiteral("%1 · 可连接").arg( stateText = QStringLiteral("可连接");
QString::fromLatin1(protocolToString(d.protocol)).toUpper());
stateColor = QStringLiteral("#3fb950"); stateColor = QStringLiteral("#3fb950");
break; break;
case DesktopState::kPending: case DesktopState::kPending:
@ -135,6 +129,8 @@ void DesktopListPage::rebuildCards() {
// “设置”:每张卡都可编辑连接参数(协议/地址/应用名/分辨率/帧率) // “设置”:每张卡都可编辑连接参数(协议/地址/应用名/分辨率/帧率)
auto* editBtn = new QPushButton(QStringLiteral("设置"), card); auto* editBtn = new QPushButton(QStringLiteral("设置"), card);
editBtn->setMinimumSize(88, 42); editBtn->setMinimumSize(88, 42);
editBtn->setEnabled(!busy_);
actionButtons_.push_back(editBtn);
connect(editBtn, &QPushButton::clicked, this, [this, id = d.id]() { connect(editBtn, &QPushButton::clicked, this, [this, id = d.id]() {
if (!busy_) { if (!busy_) {
emit editRequested(id); emit editRequested(id);
@ -149,8 +145,11 @@ void DesktopListPage::rebuildCards() {
"QPushButton { background: #2f6feb; color: white; border: none; " "QPushButton { background: #2f6feb; color: white; border: none; "
"border-radius: 6px; font-weight: bold; }" "border-radius: 6px; font-weight: bold; }"
"QPushButton:disabled { background: #3a4a63; }")); "QPushButton:disabled { background: #3a4a63; }"));
connectBtn->setEnabled(!busy_);
actionButtons_.push_back(connectBtn);
connect(connectBtn, &QPushButton::clicked, this, [this, id = d.id]() { connect(connectBtn, &QPushButton::clicked, this, [this, id = d.id]() {
if (!busy_) { if (!busy_) {
setBusy(true);
setStatus(QStringLiteral("正在连接,请稍候…")); setStatus(QStringLiteral("正在连接,请稍候…"));
emit desktopSelected(id); emit desktopSelected(id);
} }
@ -176,8 +175,11 @@ void DesktopListPage::setStatus(const QString& text, bool error) {
void DesktopListPage::setBusy(bool busy) { void DesktopListPage::setBusy(bool busy) {
busy_ = busy; busy_ = busy;
buttonLogout_->setEnabled(!busy); for (QPushButton* button : actionButtons_) {
// 连接按钮在 rebuildCards 时根据 busy_ 生成;这里统一刷新点击行为由 busy_ 拦截 if (button) {
button->setEnabled(!busy_);
}
}
} }
QString DesktopListPage::currentUser() const { QString DesktopListPage::currentUser() const {

View File

@ -38,8 +38,6 @@ signals:
void desktopSelected(const QString& desktopId); void desktopSelected(const QString& desktopId);
// 用户点击某张卡片的“设置”按钮 // 用户点击某张卡片的“设置”按钮
void editRequested(const QString& desktopId); void editRequested(const QString& desktopId);
// 用户点击退出登录
void logoutRequested();
private: private:
void rebuildCards(); void rebuildCards();
@ -50,7 +48,7 @@ private:
QLabel* labelUser_ = nullptr; QLabel* labelUser_ = nullptr;
QLabel* labelStatus_ = nullptr; QLabel* labelStatus_ = nullptr;
QVBoxLayout* cardsLayout_ = nullptr; QVBoxLayout* cardsLayout_ = nullptr;
QPushButton* buttonLogout_ = nullptr; std::vector<QPushButton*> actionButtons_;
bool busy_ = false; bool busy_ = false;
}; };

View File

@ -51,7 +51,6 @@ void DesktopService::createTicket(const QString& desktopId, TicketCallback callb
.arg(d.name); .arg(d.name);
return; return;
} }
ticket.protocol = d.protocol;
ticket.endpoint = d.endpoint; ticket.endpoint = d.endpoint;
ticket.app = d.app; ticket.app = d.app;
ticket.width = d.width; ticket.width = d.width;

View File

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

View File

@ -43,7 +43,7 @@ DesktopSettingsDialog::DesktopSettingsDialog(std::vector<DesktopInfo> desktops,
} }
form->addRow(QStringLiteral("桌面"), comboDesktop_); form->addRow(QStringLiteral("桌面"), comboDesktop_);
// 协议统一 Moonlight(RDP 渲染实测差,已不提供选项;代码保留作网关备用) // 会话通道统一 Moonlight,无协议选项(地址/应用名即连接参数)
editEndpoint_ = new QLineEdit(this); editEndpoint_ = new QLineEdit(this);
editEndpoint_->setPlaceholderText(QStringLiteral("例如 192.168.1.70 或 VPN 隧道地址")); editEndpoint_->setPlaceholderText(QStringLiteral("例如 192.168.1.70 或 VPN 隧道地址"));
@ -121,7 +121,6 @@ void DesktopSettingsDialog::onAccepted() {
if (d.id != id) { if (d.id != id) {
continue; continue;
} }
d.protocol = Protocol::kMoonlight;
d.endpoint = editEndpoint_->text().trimmed(); d.endpoint = editEndpoint_->text().trimmed();
d.app = editApp_->text().trimmed(); d.app = editApp_->text().trimmed();
const QString res = comboResolution_->currentData().toString(); const QString res = comboResolution_->currentData().toString();

View File

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

View File

@ -21,7 +21,6 @@ std::vector<DesktopInfo> defaultDesktops(const QString& migratedServer) {
win.id = QStringLiteral("desk-win10-01"); win.id = QStringLiteral("desk-win10-01");
win.name = QStringLiteral("Windows 10"); win.name = QStringLiteral("Windows 10");
win.os = QStringLiteral("Windows 桌面"); win.os = QStringLiteral("Windows 桌面");
win.protocol = Protocol::kMoonlight;
win.endpoint = migratedServer; // 兼容老盒子:迁移 client.conf 的 server win.endpoint = migratedServer; // 兼容老盒子:迁移 client.conf 的 server
win.app = QStringLiteral("Desktop"); win.app = QStringLiteral("Desktop");
win.width = 1920; win.width = 1920;
@ -33,14 +32,12 @@ std::vector<DesktopInfo> defaultDesktops(const QString& migratedServer) {
ubuntu.id = QStringLiteral("desk-ubuntu-01"); ubuntu.id = QStringLiteral("desk-ubuntu-01");
ubuntu.name = QStringLiteral("Ubuntu"); ubuntu.name = QStringLiteral("Ubuntu");
ubuntu.os = QStringLiteral("Ubuntu 桌面"); ubuntu.os = QStringLiteral("Ubuntu 桌面");
ubuntu.protocol = Protocol::kMoonlight;
list.push_back(ubuntu); list.push_back(ubuntu);
DesktopInfo kylin; DesktopInfo kylin;
kylin.id = QStringLiteral("desk-kylin-01"); kylin.id = QStringLiteral("desk-kylin-01");
kylin.name = QStringLiteral("麒麟"); kylin.name = QStringLiteral("麒麟");
kylin.os = QStringLiteral("银河麒麟桌面"); kylin.os = QStringLiteral("银河麒麟桌面");
kylin.protocol = Protocol::kMoonlight;
list.push_back(kylin); list.push_back(kylin);
return list; return list;
@ -51,9 +48,6 @@ DesktopInfo fromJson(const QJsonObject& obj) {
d.id = obj.value(QStringLiteral("id")).toString(); d.id = obj.value(QStringLiteral("id")).toString();
d.name = obj.value(QStringLiteral("name")).toString(); d.name = obj.value(QStringLiteral("name")).toString();
d.os = obj.value(QStringLiteral("os")).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.endpoint = obj.value(QStringLiteral("endpoint")).toString();
d.app = obj.value(QStringLiteral("app")).toString(); d.app = obj.value(QStringLiteral("app")).toString();
d.width = obj.value(QStringLiteral("width")).toInt(1920); 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("id"), d.id);
obj.insert(QStringLiteral("name"), d.name); obj.insert(QStringLiteral("name"), d.name);
obj.insert(QStringLiteral("os"), d.os); 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("endpoint"), d.endpoint);
obj.insert(QStringLiteral("app"), d.app); obj.insert(QStringLiteral("app"), d.app);
obj.insert(QStringLiteral("width"), d.width); obj.insert(QStringLiteral("width"), d.width);

View File

@ -146,6 +146,8 @@ LoginPage::LoginPage(const TerminalConfig& initial, QWidget* parent)
root->addLayout(grid); root->addLayout(grid);
// ---- 按钮 ---- // ---- 按钮 ----
// 登录页不提供"退出"按钮:点击退出会被看门狗拉起,属于维护功能,
// 只保留在管理菜单(Ctrl+Alt+Shift+M -> 退出终端)
auto* buttonRow = new QHBoxLayout(); auto* buttonRow = new QHBoxLayout();
buttonRow->addStretch(1); buttonRow->addStretch(1);
buttonLogin_ = new QPushButton(QStringLiteral("登 录"), this); buttonLogin_ = new QPushButton(QStringLiteral("登 录"), this);
@ -155,11 +157,7 @@ LoginPage::LoginPage(const TerminalConfig& initial, QWidget* parent)
"border-radius: 6px; font-weight: bold; }" "border-radius: 6px; font-weight: bold; }"
"QPushButton:disabled { background: #3a4a63; }")); "QPushButton:disabled { background: #3a4a63; }"));
buttonLogin_->setDefault(true); buttonLogin_->setDefault(true);
buttonQuit_ = new QPushButton(QStringLiteral("退出"), this);
buttonQuit_->setMinimumSize(110, 46);
buttonRow->addWidget(buttonLogin_); buttonRow->addWidget(buttonLogin_);
buttonRow->addWidget(buttonQuit_);
buttonRow->addStretch(1); buttonRow->addStretch(1);
root->addLayout(buttonRow); root->addLayout(buttonRow);
@ -171,7 +169,6 @@ LoginPage::LoginPage(const TerminalConfig& initial, QWidget* parent)
root->addStretch(1); root->addStretch(1);
connect(buttonLogin_, &QPushButton::clicked, this, &LoginPage::onLoginClicked); connect(buttonLogin_, &QPushButton::clicked, this, &LoginPage::onLoginClicked);
connect(buttonQuit_, &QPushButton::clicked, this, [this]() { emit quitRequested(); });
// 密码框回车直接登录 // 密码框回车直接登录
connect(editPassword_, &QLineEdit::returnPressed, this, &LoginPage::onLoginClicked); connect(editPassword_, &QLineEdit::returnPressed, this, &LoginPage::onLoginClicked);
connect(editUser_, &QLineEdit::returnPressed, this, &LoginPage::onLoginClicked); connect(editUser_, &QLineEdit::returnPressed, this, &LoginPage::onLoginClicked);

View File

@ -38,7 +38,6 @@ public:
signals: signals:
void loginRequested(const QString& username, const QString& password); void loginRequested(const QString& username, const QString& password);
void quitRequested();
private slots: private slots:
void onLoginClicked(); void onLoginClicked();
@ -53,7 +52,6 @@ private:
QLabel* labelSn_ = nullptr; QLabel* labelSn_ = nullptr;
QLabel* labelNetwork_ = nullptr; QLabel* labelNetwork_ = nullptr;
QPushButton* buttonLogin_ = nullptr; QPushButton* buttonLogin_ = nullptr;
QPushButton* buttonQuit_ = nullptr;
}; };
} // namespace vdi } // namespace vdi

View File

@ -2,77 +2,25 @@
#include <QApplication> #include <QApplication>
#include <QDateTime> #include <QDateTime>
#include <QEvent> #include <QDebug>
#include <QFile> #include <QFile>
#include <QKeyEvent>
#include <QKeySequence>
#include <QLabel>
#include <QMenu> #include <QMenu>
#include <QMessageBox>
#include <QMouseEvent>
#include <QPoint>
#include <QScreen>
#include <QShortcut> #include <QShortcut>
#include <QStackedWidget> #include <QStackedWidget>
#include <QStatusBar> #include <QStatusBar>
#include <QTimer> #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_list_page.h"
#include "desktop_settings_dialog.h" #include "desktop_settings_dialog.h"
#include "login_page.h" #include "login_page.h"
#include "transition_overlay.h"
namespace vdi { namespace vdi {
namespace { namespace {
// FreeRDP 像素格式 -> QImage::Format constexpr int kTicketTimeoutMs = 10000;
// 注意:FreeRDP 的像素格式值是计算宏(FREERDP_PIXEL_FORMAT), constexpr int kProcessStartTimeoutMs = 5000;
// 不能硬编码数值,必须用宏直接比较。 constexpr int kStreamReadyTimeoutMs = 30000;
// 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;
} // namespace } // namespace
@ -83,14 +31,18 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
setupUi(); setupUi();
setupHotkey(); setupHotkey();
setupHeartbeat(); setupHeartbeat();
keyboardLayout_ = freerdp_keyboard_init(0);
config_ = loadConfig(); config_ = loadConfig();
fsm_.transitionTo(State::kConfig, QStringLiteral("就绪"));
// 开机永远进入登录页;不再根据本地配置自动连接 RDP, const QString deviceUser = QString::fromStdString(config_.sn).trimmed();
// 会话只能由用户在桌面列表选择后启动。 desktopService_.login(deviceUser.isEmpty() ? QStringLiteral("terminal") : deviceUser,
fsm_.transitionTo(State::kConfig, QStringLiteral("等待登录")); QStringLiteral("kiosk"), [this](const AuthResult& result) {
showLogin(); if (result.ok) {
currentUser_ = result.displayName;
}
fsm_.transitionTo(State::kAuthenticating, QStringLiteral("自动登录"));
fsm_.transitionTo(State::kDesktopList, QStringLiteral("请选择要进入的云桌面"));
});
} }
MainWindow::~MainWindow() { MainWindow::~MainWindow() {
@ -106,7 +58,6 @@ void MainWindow::setupUi() {
stack_->setStyleSheet(QStringLiteral("background: #0f1420;")); stack_->setStyleSheet(QStringLiteral("background: #0f1420;"));
setCentralWidget(stack_); setCentralWidget(stack_);
// 登录页
auto* login = new LoginPage(config_, this); auto* login = new LoginPage(config_, this);
loginPage_ = login; loginPage_ = login;
login->setStyleSheet( login->setStyleSheet(
@ -118,11 +69,8 @@ void MainWindow::setupUi() {
"border-radius: 6px; color: #e6e9ef; }" "border-radius: 6px; color: #e6e9ef; }"
"QPushButton:hover { background: #2b3654; }")); "QPushButton:hover { background: #2b3654; }"));
stack_->addWidget(login); stack_->addWidget(login);
connect(login, &LoginPage::loginRequested, this, &MainWindow::onLoginRequested); connect(login, &LoginPage::loginRequested, this, &MainWindow::onLoginRequested);
connect(login, &LoginPage::quitRequested, this, &MainWindow::onQuitRequested);
// 桌面列表页
auto* list = new DesktopListPage(this); auto* list = new DesktopListPage(this);
desktopListPage_ = list; desktopListPage_ = list;
list->setStyleSheet( list->setStyleSheet(
@ -131,74 +79,59 @@ void MainWindow::setupUi() {
"border-radius: 6px; color: #e6e9ef; }" "border-radius: 6px; color: #e6e9ef; }"
"QPushButton:hover { background: #2b3654; }")); "QPushButton:hover { background: #2b3654; }"));
stack_->addWidget(list); stack_->addWidget(list);
connect(list, &DesktopListPage::desktopSelected, this, &MainWindow::onDesktopSelected); connect(list, &DesktopListPage::desktopSelected, this, &MainWindow::onDesktopSelected);
connect(list, &DesktopListPage::editRequested, this, &MainWindow::onEditDesktopRequested); connect(list, &DesktopListPage::editRequested, this, &MainWindow::onEditDesktopRequested);
connect(list, &DesktopListPage::logoutRequested, this, &MainWindow::onLogoutRequested);
// 会话视图 overlay_ = new TransitionOverlay(this);
auto* sessionWidget = new QWidget(this);
auto* sessionLayout = new QVBoxLayout(sessionWidget);
sessionLayout->setContentsMargins(0, 0, 0, 0);
sessionView_ = new QLabel(sessionWidget);
sessionView_->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_ = new QStatusBar(this);
statusBar_->setStyleSheet(QStringLiteral("QStatusBar { background: #161d2e; color: #8a94a6; }")); statusBar_->setStyleSheet(QStringLiteral("QStatusBar { background: #161d2e; color: #8a94a6; }"));
setStatusBar(statusBar_); setStatusBar(statusBar_);
// RDP 重连定时器 connectionTimer_ = new QTimer(this);
reconnectTimer_ = new QTimer(this); connectionTimer_->setSingleShot(true);
reconnectTimer_->setSingleShot(true); connect(connectionTimer_, &QTimer::timeout, this, [this]() {
connect(reconnectTimer_, &QTimer::timeout, this, [this]() { QString message;
if (fsm_.current() == State::kReconnect && reconnectAttempts_ < kMaxReconnectAttempts) { switch (connectionPhase_) {
++reconnectAttempts_; case ConnectionPhase::kTicket:
if (activeTicket_.protocol == Protocol::kMoonlight) { message = QStringLiteral("获取连接信息超时");
startMoonlightSession(activeTicket_); break;
} else { case ConnectionPhase::kProcessStart:
startRdpSession(activeTicket_); message = QStringLiteral("Moonlight 启动超时");
} break;
} else if (fsm_.current() == State::kReconnect) { case ConnectionPhase::kStreamReady:
stopSessions(); message = QStringLiteral("云桌面画面就绪超时");
fsm_.transitionTo(State::kDesktopList, QStringLiteral("重连失败,已返回桌面列表")); break;
default:
return;
} }
failConnection(connectionAttempt_, message);
}); });
frameRefreshTimer_ = new QTimer(this); moonlightSession_ = std::make_unique<MoonlightSession>();
frameRefreshTimer_->setInterval(33);
connect(frameRefreshTimer_, &QTimer::timeout, this, &MainWindow::onFrameRefresh);
frameRefreshTimer_->start();
} }
void MainWindow::setupHotkey() { void MainWindow::setupHotkey() {
// 全局热键 Ctrl+Alt+Shift+M 呼出管理菜单(方案 3.2 节) auto* menuShortcut = new QShortcut(QKeySequence(QStringLiteral("Ctrl+Alt+Shift+M")), this);
auto* shortcut = new QShortcut(QKeySequence(QStringLiteral("Ctrl+Alt+Shift+M")), this); menuShortcut->setContext(Qt::ApplicationShortcut);
shortcut->setContext(Qt::ApplicationShortcut); connect(menuShortcut, &QShortcut::activated, this, [this]() { onMenuAction(0); });
connect(shortcut, &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() { void MainWindow::setupHeartbeat() {
// 定期写心跳文件,供看门狗 Monitor 判断主进程是否假死
// 对应会议纪要要求的双进程架构
const QString path = QStringLiteral("/tmp/vdi-kiosk.heartbeat"); const QString path = QStringLiteral("/tmp/vdi-kiosk.heartbeat");
heartbeatTimer_ = new QTimer(this); heartbeatTimer_ = new QTimer(this);
heartbeatTimer_->setInterval(5000); heartbeatTimer_->setInterval(5000);
@ -206,7 +139,6 @@ void MainWindow::setupHeartbeat() {
QFile file(path); QFile file(path);
if (file.open(QIODevice::WriteOnly | QIODevice::Text)) { if (file.open(QIODevice::WriteOnly | QIODevice::Text)) {
file.write(QByteArray::number(QDateTime::currentSecsSinceEpoch())); file.write(QByteArray::number(QDateTime::currentSecsSinceEpoch()));
file.close();
} }
}); });
heartbeatTimer_->start(); heartbeatTimer_->start();
@ -228,21 +160,10 @@ void MainWindow::onStateChanged(State from, State to, const QString& detail) {
refreshDesktopList(); refreshDesktopList();
break; break;
case State::kConnecting: case State::kConnecting:
if (stack_->currentWidget() != sessionView_->parentWidget()) { overlay_->start(QStringLiteral("正在进入云桌面"));
stack_->setCurrentWidget(sessionView_->parentWidget());
}
sessionView_->setFocus(Qt::OtherFocusReason);
sessionOverlay_->setText(QStringLiteral("正在连接 %1 …").arg(activeTicket_.endpoint));
sessionOverlay_->show();
break; break;
case State::kSession: case State::kSession:
sessionView_->setFocus(Qt::OtherFocusReason);
sessionOverlay_->hide();
break;
case State::kReconnect: case State::kReconnect:
sessionOverlay_->setText(QStringLiteral("连接已断开,正在重连…"));
sessionOverlay_->show();
reconnectTimer_->start(kReconnectDelayMs);
break; break;
case State::kError: case State::kError:
stopSessions(); stopSessions();
@ -310,31 +231,44 @@ void MainWindow::onLoginRequested(const QString& username, const QString& passwo
void MainWindow::onDesktopSelected(const QString& desktopId) { void MainWindow::onDesktopSelected(const QString& desktopId) {
if (!fsm_.canConnect()) { if (!fsm_.canConnect()) {
if (desktopListPage_) {
desktopListPage_->setBusy(false);
}
return; return;
} }
desktopService_.createTicket(desktopId, [this](const ConnectionTicket& ticket,
const QString& error) { const quint64 attempt = ++connectionAttempt_;
if (!error.isEmpty()) { connectingDesktopId_ = desktopId;
if (desktopListPage_) { connectionClock_.restart();
desktopListPage_->setBusy(false); phaseClock_.restart();
desktopListPage_->setStatus(error, true); 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; return;
} }
connectionTimer_->stop();
if (!error.isEmpty()) {
failConnection(attempt, error);
return;
}
logConnectionPhase(attempt, QStringLiteral("ticket"), QStringLiteral("ready"));
activeTicket_ = ticket; activeTicket_ = ticket;
reconnectAttempts_ = 0; if (!startMoonlightSession(ticket, attempt)) {
failConnection(attempt, QStringLiteral("已有串流进程正在运行"));
if (ticket.protocol == Protocol::kMoonlight) {
startMoonlightSession(ticket);
} else {
startRdpSession(ticket);
} }
}); });
} }
void MainWindow::onEditDesktopRequested(const QString& desktopId) { void MainWindow::onEditDesktopRequested(const QString& desktopId) {
// 桌面连接设置:编辑协议/地址/应用名/分辨率/帧率,保存后立即刷新列表。
// 现场环境地址不同或走 VPN 隧道时,在这里改地址即可,无需改代码。
DesktopSettingsDialog dialog(desktops_, desktopId, this); DesktopSettingsDialog dialog(desktops_, desktopId, this);
dialog.setStyleSheet( dialog.setStyleSheet(
QStringLiteral("QWidget { background: #0f1420; color: #e6e9ef; }" QStringLiteral("QWidget { background: #0f1420; color: #e6e9ef; }"
@ -354,176 +288,126 @@ void MainWindow::onEditDesktopRequested(const QString& desktopId) {
} }
} }
void MainWindow::onLogoutRequested() { bool MainWindow::startMoonlightSession(const ConnectionTicket& ticket, quint64 attempt) {
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();
MoonlightSession::Options opts; MoonlightSession::Options opts;
opts.decoder = QStringLiteral("software"); // RK3528 无可用 V4L2 硬解设备 opts.decoder = QStringLiteral("software");
opts.videoCodec = QStringLiteral("H.264"); opts.videoCodec = QStringLiteral("H.264");
opts.audioOnHost = true; opts.audioOnHost = true;
moonlightSession_ = std::make_unique<MoonlightSession>(/*parent=*/nullptr);
moonlightSession_->setOptions(opts); 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( QMetaObject::invokeMethod(
this, this, [this, attempt, event]() { onMoonlightEvent(attempt, event); },
[this, running, message]() { onMoonlightState(running, message); },
Qt::QueuedConnection); Qt::QueuedConnection);
}); });
} }
void MainWindow::onMoonlightState(bool running, const QString& message) { void MainWindow::onMoonlightEvent(quint64 attempt, const MoonlightSession::Event& event) {
if (running) { if (attempt != connectionAttempt_) {
// 进程已拉起;串流窗口是否成功由 moonlight 自身展示
updateStatusBar(QStringLiteral("SESSION %1").arg(message));
return; return;
} }
// 进程退出:回桌面列表
moonlightSession_.reset(); switch (event.type) {
stopSessions(); case MoonlightSession::EventType::kStarted:
if (fsm_.current() == State::kConnecting || fsm_.current() == State::kSession) { if (connectionPhase_ != ConnectionPhase::kProcessStart) {
fsm_.transitionTo(State::kDesktopList, message); 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_) { if (desktopListPage_) {
desktopListPage_->setBusy(false); desktopListPage_->setBusy(false);
desktopListPage_->setStatus(message); desktopListPage_->setStatus(QStringLiteral("连接失败:%1").arg(message), true);
} }
} }
void MainWindow::onSessionState(bool connected, const QString& message) { void MainWindow::armConnectionTimeout(quint64 attempt, ConnectionPhase phase, int timeoutMs) {
if (connected) { if (attempt != connectionAttempt_) {
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()) {
return; return;
} }
lastFrame_ = image; connectionPhase_ = phase;
connectionTimer_->start(timeoutMs);
}
if (sessionView_) { void MainWindow::logConnectionPhase(quint64 attempt, const QString& phase,
const QSize targetSize = sessionView_->size(); const QString& result, const QString& error) {
if (lastFrame_.size() == targetSize) { const qint64 totalMs = connectionClock_.isValid() ? connectionClock_.elapsed() : 0;
sessionView_->setPixmap(QPixmap::fromImage(lastFrame_)); const qint64 phaseMs = phaseClock_.isValid() ? phaseClock_.elapsed() : 0;
} else { qInfo().noquote()
sessionView_->setPixmap(QPixmap::fromImage(lastFrame_).scaled( << QStringLiteral("vdi_connection attempt=%1 desktop=%2 phase=%3 result=%4 phase_ms=%5 total_ms=%6 error=%7")
targetSize, Qt::KeepAspectRatio, Qt::FastTransformation)); .arg(attempt)
} .arg(connectingDesktopId_, phase, result)
} .arg(phaseMs)
.arg(totalMs)
.arg(error);
} }
void MainWindow::onQuitRequested() { void MainWindow::onQuitRequested() {
@ -532,40 +416,43 @@ void MainWindow::onQuitRequested() {
} }
void MainWindow::stopSessions() { void MainWindow::stopSessions() {
if (rdpSession_) { stopSessions(true);
rdpSession_->stop(); }
rdpSession_.reset();
void MainWindow::stopSessions(bool invalidateAttempt) {
connectionTimer_->stop();
if (invalidateAttempt) {
++connectionAttempt_;
} }
connectionPhase_ = ConnectionPhase::kIdle;
if (moonlightSession_) { if (moonlightSession_) {
moonlightSession_->stop(); moonlightSession_->stop();
moonlightSession_.reset();
} }
QMutexLocker locker(&frameMutex_); if (overlay_) {
latestFrame_ = QImage(); overlay_->finish();
lastFrame_ = QImage(); }
frameWakePending_ = false;
} }
void MainWindow::onMenuAction(int action) { void MainWindow::onMenuAction(int action) {
switch (action) { switch (action) {
case 1: // 重新连接当前桌面 case 1:
if (fsm_.canConnect() && !activeTicket_.desktopId.isEmpty()) { if (fsm_.canConnect() && !activeTicket_.desktopId.isEmpty()) {
reconnectAttempts_ = 0; const quint64 attempt = ++connectionAttempt_;
stopSessions(); connectingDesktopId_ = activeTicket_.desktopId;
if (activeTicket_.protocol == Protocol::kMoonlight) { connectionClock_.restart();
startMoonlightSession(activeTicket_); if (fsm_.transitionTo(State::kConnecting, activeTicket_.endpoint) &&
} else { !startMoonlightSession(activeTicket_, attempt)) {
startRdpSession(activeTicket_); failConnection(attempt, QStringLiteral("已有串流进程正在运行"));
} }
} }
break; break;
case 2: // 断开回桌面列表 case 2:
stopSessions(); stopSessions();
if (fsm_.current() != State::kConfig) { if (fsm_.current() != State::kConfig) {
fsm_.transitionTo(State::kDesktopList, "已断开"); fsm_.transitionTo(State::kDesktopList, QStringLiteral("已断开"));
} }
break; break;
case 3: // 退出终端 case 3:
onQuitRequested(); onQuitRequested();
break; break;
default: { default: {
@ -588,90 +475,9 @@ void MainWindow::onMenuAction(int action) {
} else if (chosen == quit) { } else if (chosen == quit) {
onMenuAction(3); onMenuAction(3);
} }
if (stack_->currentWidget() == sessionView_->parentWidget()) {
sessionView_->setFocus(Qt::OtherFocusReason);
}
break; 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 } // namespace vdi

View File

@ -1,43 +1,30 @@
// Kiosk 主窗口 // Kiosk 主窗口
// //
// 产品流程编排: // 产品流程编排:开机 -> 桌面列表 -> 获取连接票据 -> Moonlight 会话 -> 返回列表。
// 开机 -> 登录页 -> 认证 -> 桌面列表 -> 选择桌面
// -> 获取连接票据 -> RDP(内嵌)或 Moonlight(外部进程)会话
// -> 会话结束回桌面列表;退出登录回登录页
//
// 对应《VDI 云桌面软件开发方案》3.2 节的状态机,
// 并按产品流程扩展 AUTHENTICATING / DESKTOP_LIST 两个状态。
// 全局热键 Ctrl+Alt+Shift+M 呼出管理菜单。
#ifndef KIOSK_SHELL_MAIN_WINDOW_H #ifndef KIOSK_SHELL_MAIN_WINDOW_H
#define KIOSK_SHELL_MAIN_WINDOW_H #define KIOSK_SHELL_MAIN_WINDOW_H
#include <QImage> #include <QElapsedTimer>
#include <QMainWindow> #include <QMainWindow>
#include <QMutex>
#include <QPointer> #include <QPointer>
#include <QPoint>
#include <memory> #include <memory>
#include "config.h" #include "config.h"
#include "desktop_service.h" #include "desktop_service.h"
#include "desktop_session.h" #include "desktop_session.h"
#include "moonlight_session.h" #include "moonlight_session.h"
#include "rdp_session.h"
#include "state_machine.h" #include "state_machine.h"
class QLabel;
class QStackedWidget; class QStackedWidget;
class QStatusBar; class QStatusBar;
class QTimer; class QTimer;
class QEvent;
class QKeyEvent;
class QMouseEvent;
namespace vdi { namespace vdi {
class LoginPage; class LoginPage;
class DesktopListPage; class DesktopListPage;
class TransitionOverlay;
class MainWindow : public QMainWindow { class MainWindow : public QMainWindow {
Q_OBJECT Q_OBJECT
@ -46,64 +33,61 @@ public:
explicit MainWindow(QWidget* parent = nullptr); explicit MainWindow(QWidget* parent = nullptr);
~MainWindow() override; ~MainWindow() override;
protected:
bool eventFilter(QObject* watched, QEvent* event) override;
private slots: private slots:
void onLoginRequested(const QString& username, const QString& password); void onLoginRequested(const QString& username, const QString& password);
void onDesktopSelected(const QString& desktopId); void onDesktopSelected(const QString& desktopId);
void onEditDesktopRequested(const QString& desktopId); void onEditDesktopRequested(const QString& desktopId);
void onLogoutRequested();
void onQuitRequested(); 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); void onMenuAction(int action);
private: private:
enum class ConnectionPhase {
kIdle,
kTicket,
kProcessStart,
kStreamReady,
kSession,
};
void setupUi(); void setupUi();
void setupHotkey(); void setupHotkey();
void setupHeartbeat(); void setupHeartbeat();
void onStateChanged(vdi::State from, vdi::State to, const QString& detail); void onStateChanged(vdi::State from, vdi::State to, const QString& detail);
void refreshDesktopList(); void refreshDesktopList();
void startRdpSession(const ConnectionTicket& ticket); bool startMoonlightSession(const ConnectionTicket& ticket, quint64 attempt);
void startMoonlightSession(const ConnectionTicket& ticket); void onMoonlightEvent(quint64 attempt, const MoonlightSession::Event& event);
void waitForMoonlightWindow(quint64 attempt);
void stopSessions(); 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 showLogin();
void showDesktopList(); void showDesktopList();
void updateStatusBar(const QString& text); void updateStatusBar(const QString& text);
bool forwardKeyEvent(QKeyEvent* event, bool pressed);
bool forwardMouseEvent(QEvent* event);
StateMachine fsm_; StateMachine fsm_;
DesktopService desktopService_; DesktopService desktopService_;
std::unique_ptr<RdpSession> rdpSession_;
std::unique_ptr<MoonlightSession> moonlightSession_; std::unique_ptr<MoonlightSession> moonlightSession_;
ConnectionTicket activeTicket_; ConnectionTicket activeTicket_;
QString currentUser_; QString currentUser_;
QString connectingDesktopId_;
std::vector<DesktopInfo> desktops_; std::vector<DesktopInfo> desktops_;
int reconnectAttempts_ = 0;
QPointer<LoginPage> loginPage_; QPointer<LoginPage> loginPage_;
QPointer<DesktopListPage> desktopListPage_; QPointer<DesktopListPage> desktopListPage_;
QStackedWidget* stack_ = nullptr; QStackedWidget* stack_ = nullptr;
QLabel* sessionView_ = nullptr;
QLabel* sessionOverlay_ = nullptr;
QStatusBar* statusBar_ = nullptr; QStatusBar* statusBar_ = nullptr;
QTimer* reconnectTimer_ = nullptr; TransitionOverlay* overlay_ = nullptr;
QTimer* connectionTimer_ = nullptr;
QTimer* heartbeatTimer_ = nullptr; QTimer* heartbeatTimer_ = nullptr;
QTimer* frameRefreshTimer_ = nullptr; QElapsedTimer connectionClock_;
QElapsedTimer phaseClock_;
quint64 connectionAttempt_ = 0;
ConnectionPhase connectionPhase_ = ConnectionPhase::kIdle;
TerminalConfig config_; 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 } // namespace vdi

View File

@ -159,6 +159,12 @@ int Monitor::run(const std::string& command) {
log("主进程已退出, code=" + std::to_string(exitCode) + log("主进程已退出, code=" + std::to_string(exitCode) +
(signalNum ? (", signal=" + std::to_string(signalNum)) : "")); (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) { if (++restartAttempts_ > config_.maxRestartAttempts) {
log("连续重启超过 " + std::to_string(config_.maxRestartAttempts) + log("连续重启超过 " + std::to_string(config_.maxRestartAttempts) +
" 次,放弃重启(熔断)"); " 次,放弃重启(熔断)");

View File

@ -1,17 +1,104 @@
#include "moonlight_session.h" #include "moonlight_session.h"
#include <QDate> #include <QDate>
#include <QDateTime>
#include <QDir> #include <QDir>
#include <QFile> #include <QFile>
#include <QProcessEnvironment>
#include <QTextStream> #include <QTextStream>
#include <QTimer>
namespace vdi { 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() { MoonlightSession::~MoonlightSession() {
stop(); callback_ = nullptr;
readyTimer_->stop();
killTimer_->stop();
if (isRunning()) {
process_->kill();
process_->waitForFinished(1000);
}
} }
void MoonlightSession::setOptions(const Options& options) { void MoonlightSession::setOptions(const Options& options) {
@ -19,19 +106,55 @@ void MoonlightSession::setOptions(const Options& options) {
} }
bool MoonlightSession::isRunning() const { 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()) { if (isRunning()) {
return false; return false;
} }
callback_ = std::move(callback);
// moonlight-qt 需要 X11 会话环境;由 kiosk-shell 继承当前 DISPLAY/XAUTHORITY callback_ = std::move(callback);
process_ = new QProcess(this); stopRequested_ = false;
terminalEventDelivered_ = false;
readyEventDelivered_ = false;
outputParser_.reset();
readyTimer_->stop();
killTimer_->stop();
process_->setProgram(QStringLiteral("moonlight-qt")); 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; QStringList args;
args << QStringLiteral("stream") args << QStringLiteral("stream")
@ -46,96 +169,109 @@ bool MoonlightSession::start(const ConnectionTicket& ticket, StateCallback callb
<< QStringLiteral("--video-decoder") << options_.decoder << QStringLiteral("--video-decoder") << options_.decoder
<< QStringLiteral("--absolute-mouse") << QStringLiteral("--absolute-mouse")
<< QStringLiteral("--keep-awake"); << QStringLiteral("--keep-awake");
if (options_.vsync) { args << (options_.vsync ? QStringLiteral("--vsync") : QStringLiteral("--no-vsync"));
args << QStringLiteral("--vsync"); args << (options_.framePacing ? QStringLiteral("--frame-pacing")
} else { : QStringLiteral("--no-frame-pacing"));
args << QStringLiteral("--no-vsync");
}
if (options_.framePacing) {
args << QStringLiteral("--frame-pacing");
} else {
args << QStringLiteral("--no-frame-pacing");
}
if (options_.performanceOverlay) { if (options_.performanceOverlay) {
args << QStringLiteral("--performance-overlay"); args << QStringLiteral("--performance-overlay");
} }
if (options_.audioOnHost) { args << (options_.audioOnHost ? QStringLiteral("--audio-on-host")
args << QStringLiteral("--audio-on-host"); : QStringLiteral("--no-audio-on-host"));
} else {
args << QStringLiteral("--no-audio-on-host");
}
process_->setArguments(args); 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); QDir().mkpath(logDir);
const QString logPath = logDir + QStringLiteral("/moonlight-%1.log") logPath_ = logDir + QStringLiteral("/moonlight-%1.log")
.arg(QDate::currentDate().toString(QStringLiteral("yyyyMMdd"))); .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(); process_->start();
return true; return true;
} }
void MoonlightSession::onProcessStarted() {
emitEvent(EventType::kStarted, QStringLiteral("串流进程已启动"));
}
void MoonlightSession::onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus) { void MoonlightSession::onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus) {
QString message; readyTimer_->stop();
if (exitStatus == QProcess::CrashExit) { killTimer_->stop();
message = QStringLiteral("串流异常退出 (code=%1)").arg(exitCode);
} else { if (stopRequested_) {
message = QStringLiteral("串流已结束 (code=%1)").arg(exitCode); emitTerminalEvent(EventType::kFinished, QStringLiteral("串流已结束"));
} return;
process_->deleteLater();
process_ = nullptr;
if (callback_) {
callback_(false, message);
} }
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) { void MoonlightSession::onProcessError(QProcess::ProcessError error) {
QString message; if (error == QProcess::Crashed) {
switch (error) { return;
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 (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() { void MoonlightSession::stop() {
if (!process_) { if (!isRunning()) {
return; return;
} }
QProcess* proc = process_; stopRequested_ = true;
process_ = nullptr; // 先断开自身引用,避免 finished 回调再触发状态迁移 readyTimer_->stop();
disconnect(proc, nullptr, this, nullptr); process_->terminate();
proc->terminate(); killTimer_->start(kTerminateGraceMs);
if (!proc->waitForFinished(2000)) { }
proc->kill();
proc->waitForFinished(1000); 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 } // namespace vdi

View File

@ -1,57 +1,96 @@
// Moonlight 会话(外部进程) // Moonlight 会话(外部进程)
// //
// 以 QProcess 参数数组直启 moonlight-qt(不拼 shell 命令), // 以 QProcess 参数数组直启 moonlight-qt(不拼 shell 命令)。
// 串流期间 moonlight-qt 独占全屏显示;进程退出后回调 MainWindow 返回桌面列表。 // 进程启动、串流就绪和终止是不同事件,调用方据此控制过场和状态机。
// 参数来自 ConnectionTicket,不再读取全局静态配置。
#ifndef KIOSK_SHELL_MOONLIGHT_SESSION_H #ifndef KIOSK_SHELL_MOONLIGHT_SESSION_H
#define KIOSK_SHELL_MOONLIGHT_SESSION_H #define KIOSK_SHELL_MOONLIGHT_SESSION_H
#include <QByteArray>
#include <QProcess> #include <QProcess>
#include <QString> #include <QString>
#include <functional> #include <functional>
#include "desktop_session.h" #include "desktop_session.h"
class QTimer;
namespace vdi { 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 { class MoonlightSession : public QObject {
Q_OBJECT Q_OBJECT
public: public:
struct Options { struct Options {
// 低延迟开关(实测 A/B 后定版)
bool vsync = true; bool vsync = true;
bool framePacing = true; bool framePacing = true;
bool performanceOverlay = false; bool performanceOverlay = false;
bool audioOnHost = true; bool audioOnHost = true;
QString videoCodec = QStringLiteral("H.264"); 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); explicit MoonlightSession(QObject* parent = nullptr);
~MoonlightSession() override; ~MoonlightSession() override;
void setOptions(const Options& options); void setOptions(const Options& options);
bool start(const ConnectionTicket& ticket, EventCallback callback);
// 启动串流;成功返回 true(不代表串流已建立,仅代表进程已拉起)
bool start(const ConnectionTicket& ticket, StateCallback callback);
// 终止串流(管理菜单/退出登录时调用)
void stop(); void stop();
bool isRunning() const; bool isRunning() const;
qint64 processId() const;
bool activateStreamWindow();
private slots: private slots:
void onProcessStarted();
void onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus); void onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus);
void onProcessError(QProcess::ProcessError error); void onProcessError(QProcess::ProcessError error);
void onProcessOutput();
private: private:
void emitEvent(EventType type, const QString& message);
void emitTerminalEvent(EventType type, const QString& message);
void scheduleReadyEvent();
QProcess* process_ = nullptr; QProcess* process_ = nullptr;
QTimer* readyTimer_ = nullptr;
QTimer* killTimer_ = nullptr;
Options options_; Options options_;
StateCallback callback_; EventCallback callback_;
MoonlightOutputParser outputParser_;
QString logPath_;
bool stopRequested_ = false;
bool terminalEventDelivered_ = false;
bool readyEventDelivered_ = false;
}; };
} // namespace vdi } // namespace vdi

View File

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

View File

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

View File

@ -1,14 +1,14 @@
// kiosk-shell 状态机 // kiosk-shell 状态机
// //
// 对应《VDI 云桌面软件开发方案》3.2 节: // 对应《VDI 云桌面软件开发方案》3.2 节:
// 状态机:BOOT -> CONFIG(无配置)-> CONNECTING -> SESSION(嵌入 freerdp 窗口) // 状态机:BOOT -> CONFIG/LOGIN -> CONNECTING -> SESSION(Moonlight 串流)
// -> RECONNECT -> ERROR // -> RECONNECT -> ERROR
// //
// 状态转换规则: // 状态转换规则:
// BOOT 进程启动,读取本地配置 // BOOT 进程启动,读取本地配置
// CONFIG 无可用配置或用户停留在登录页,等待输入 // CONFIG 无可用配置或用户停留在登录页,等待输入
// CONNECTING 正在建立 RDP 连接 // CONNECTING 正在建立 Moonlight 串流连接
// SESSION 远程会话进行中 // SESSION 远程会话进行中(moonlight-qt 独占全屏)
// RECONNECT 会话意外断开,等待重连 // RECONNECT 会话意外断开,等待重连
// ERROR 不可恢复错误,提示后回到 CONFIG // ERROR 不可恢复错误,提示后回到 CONFIG

View 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

View File

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

View File

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

View 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"

View File

@ -15,25 +15,27 @@ echo "==> 检查盒子连通性"
ssh -o ConnectTimeout=8 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'echo "box ok: $(hostname)"' ssh -o ConnectTimeout=8 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'echo "box ok: $(hostname)"'
echo "==> 同步源码到盒子" 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/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"/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/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-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 scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/vdi-moonlight.desktop" "$BOX":~/.config/autostart/vdi-moonlight.desktop
echo "==> 在盒子上编译" 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 "==> 安装二进制" 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 一条链)" 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' 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 "==> 启动受管栈并验证" 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" 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"