From 415388ab0619998fcbbc938a17cc30a847b422eb Mon Sep 17 00:00:00 2001 From: songyiming <1000000008@qq.com> Date: Fri, 30 Oct 2026 15:11:21 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=20Moonlight=20=E4=BC=9A=E8=AF=9D=E5=B9=B6=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E4=BA=91=E6=A1=8C=E9=9D=A2=E5=90=AF=E5=8A=A8=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 8 + README.md | 75 +-- terminal/kiosk-shell/CMakeLists.txt | 45 +- .../kiosk-shell/src/desktop_list_page.cpp | 30 +- terminal/kiosk-shell/src/desktop_list_page.h | 4 +- terminal/kiosk-shell/src/desktop_service.cpp | 1 - terminal/kiosk-shell/src/desktop_session.h | 24 +- .../src/desktop_settings_dialog.cpp | 3 +- .../kiosk-shell/src/desktop_settings_dialog.h | 4 +- terminal/kiosk-shell/src/desktop_store.cpp | 8 - terminal/kiosk-shell/src/login_page.cpp | 7 +- terminal/kiosk-shell/src/login_page.h | 2 - terminal/kiosk-shell/src/main_window.cpp | 596 ++++++------------ terminal/kiosk-shell/src/main_window.h | 70 +- terminal/kiosk-shell/src/monitor.cpp | 6 + .../kiosk-shell/src/moonlight_session.cpp | 286 ++++++--- terminal/kiosk-shell/src/moonlight_session.h | 65 +- terminal/kiosk-shell/src/rdp_session.cpp | 368 ----------- terminal/kiosk-shell/src/rdp_session.h | 116 ---- terminal/kiosk-shell/src/state_machine.h | 6 +- .../kiosk-shell/src/transition_overlay.cpp | 128 ++++ terminal/kiosk-shell/src/transition_overlay.h | 49 ++ .../tests/test_desktop_list_page.cpp | 53 ++ .../tests/test_moonlight_session.cpp | 94 +++ tools/deploy_kiosk.sh | 14 +- 25 files changed, 933 insertions(+), 1129 deletions(-) delete mode 100644 terminal/kiosk-shell/src/rdp_session.cpp delete mode 100644 terminal/kiosk-shell/src/rdp_session.h create mode 100644 terminal/kiosk-shell/src/transition_overlay.cpp create mode 100644 terminal/kiosk-shell/src/transition_overlay.h create mode 100644 terminal/kiosk-shell/tests/test_desktop_list_page.cpp create mode 100644 terminal/kiosk-shell/tests/test_moonlight_session.cpp diff --git a/.gitignore b/.gitignore index 5d7d74c..f0a64ed 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,11 @@ build/ +CMakeFiles/ +CMakeCache.txt *.o *.so +*.log +*.tmp +.zcode/ +__pycache__/ +test-output/ +screenshots/ diff --git a/README.md b/README.md index 1375b21..c349a2b 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,11 @@ ```text 盒子开机 - -> kiosk-shell 登录/首配 - -> 用户认证 - -> 获取可用云桌面列表 + -> 设备身份自动登录 + -> 获取并展示可用云桌面列表 -> 用户选择 Windows / Ubuntu / 麒麟等桌面 -> 获取一次性连接票据 - -> 根据协议启动 RDP 或 Moonlight 会话 + -> 启动 Moonlight 会话 -> 会话结束后返回桌面列表 ``` @@ -38,8 +37,7 @@ RK3528 云终端 vdi-cloud/ ├─ terminal/ # 终端嵌入式软件 │ ├─ meta-vdi/ # Yocto 层:BSP、内核、设备树、U-Boot、配方 -│ ├─ kiosk-shell/ # Qt 全屏壳:登录、桌面列表、远程会话 -│ ├─ freerdp-rk/ # FreeRDP 3 与 Rockchip MPP/VPU 适配 +│ ├─ kiosk-shell/ # Qt 全屏壳:桌面列表、Moonlight 会话、看门狗 │ ├─ vdt-agent/ # Rust:注册、心跳、命令、日志 │ ├─ ota-updater/ # Rust:OTA 签名校验、A/B 切换、回滚 │ └─ usb-monitor/ # Rust:USB 监听、挂载与重定向策略 @@ -58,39 +56,21 @@ vdi-cloud/ 已实现: -- Qt 全屏登录页; -- 设备 SN 和网络状态显示; -- 本地演示认证(当前账号和密码非空即可通过); +- 开机后使用设备 SN 作为当前身份,自动进入桌面列表; - Windows、Ubuntu、麒麟三类桌面卡片; -- **桌面连接可配置**:每张卡片有“设置”按钮,可配置协议(Moonlight/RDP)、 - 服务器地址(支持 IP、域名或 VPN 隧道地址)、应用名、分辨率和帧率; +- **桌面连接可配置**:每张卡片有“设置”按钮,可配置服务器地址(支持 IP、域名或 VPN 隧道地址)、Sunshine 应用名、分辨率和帧率; - 连接配置持久化在 `~/.config/vdi-terminal/desktops.json`, 地址为空的桌面显示“待分配”且不可连接; - 老环境自动迁移:首次运行会读取 `client.conf` 里的 server 地址填充 Windows 桌面; - `Ctrl+Alt+Shift+M` 管理菜单提供“桌面连接设置”统一入口; -- 退出登录后返回登录页;会话退出后返回桌面列表。 +- 会话退出后返回桌面列表。 不同客户部署环境的桌面地址不同:换环境时在“设置”里改地址即可,不需要改代码。 VPN 属于网络层能力(盒子上的 VPN 客户端建立隧道后),地址字段直接填隧道地址即可。 -本地演示认证是后端 API 上线前的临时实现。后续通过 `DesktopService` 适配层接入真实的用户认证、桌面列表和一次性连接票据接口,页面流程不需要重写。 +设备身份自动登录是正式认证 API 上线前的临时实现。后续通过 `DesktopService` 适配层接入真实的用户认证、桌面列表和一次性连接票据接口,页面流程不需要重写。 -### 2. RDP 会话 - -已实现: - -- 基于 `libfreerdp3` API 内嵌,不拉起外部 `xfreerdp` 进程; -- FreeRDP GDI/GFX 画面接入 Qt; -- 键盘、鼠标输入转发; -- X11 键盘码到 RDP 扫描码映射; -- 鼠标缩放坐标转换; -- 会话状态与断线重连; -- `Ctrl+Alt+Shift+M` 管理菜单; -- RDP 后端代码保留,可由连接票据选择启用。 - -当前 RDP 路径仍处于 POC 阶段,证书忽略、测试账号等配置不能直接用于正式环境。 - -### 3. Moonlight/Sunshine 会话 +### 2. Moonlight/Sunshine 会话 已实现: @@ -98,13 +78,17 @@ VPN 属于网络层能力(盒子上的 VPN 客户端建立隧道后),地 - 不通过 Shell 拼接用户输入,降低命令注入风险; - H.264 软件解码; - 绝对鼠标模式; +- 点击连接后立即锁定操作,保证任何时刻最多一个 Moonlight 会话; +- 解析 Moonlight 输出,只有测试解码成功、渲染器选定且全屏串流窗口已激活后才淡出过场; +- ARM/X11 环境通过 `GL_IS_SLOW=0` 缩短不可用 DRM 路径的探测时间; +- 连接阶段有结构化耗时日志和 30 秒就绪超时,失败时自动关闭错误窗口并返回列表; - 会话进程退出后自动返回桌面列表; - `vsync`、`frame-pacing`、性能叠图等低延迟参数可配置; - 独立 Moonlight 启动脚本保留作调试用途,但不再作为产品开机入口。 当前联调环境中,Sunshine 服务端仍使用 `libx264` 软件编码。1080p 实际帧率约 13~22 FPS,鼠标体感延迟主要来自服务端软编码和客户端帧队列/VSync。后续需要完成 GPU 直通并启用 NVENC。 -### 4. 状态机 +### 3. 状态机 当前终端状态机: @@ -119,7 +103,7 @@ BOOT -> DESKTOP_LIST / ERROR ``` -### 5. Monitor 看门狗 +### 4. Monitor 看门狗 已实现独立 `vdi-kiosk-monitor` 进程: @@ -134,8 +118,7 @@ BOOT ### 终端 - C++17 -- Qt 5 Widgets -- FreeRDP 3 +- Qt 5 Widgets / Qt Test - Moonlight Qt - 当前开发环境:Armbian + LightDM + Xorg - 正式目标环境:Weston + Wayland @@ -164,8 +147,7 @@ BOOT ```bash sudo apt install \ build-essential cmake ninja-build pkg-config \ - qtbase5-dev libqt5widgets5 \ - freerdp3-dev + qtbase5-dev libqt5widgets5 libqt5test5 ``` Moonlight 会话还需要安装: @@ -178,8 +160,9 @@ sudo apt install moonlight-qt ```bash cd terminal/kiosk-shell -cmake -G Ninja -B build -DCMAKE_BUILD_TYPE=Release +cmake -G Ninja -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON cmake --build build +QT_QPA_PLATFORM=offscreen ctest --test-dir build --output-on-failure ``` 编译产物: @@ -255,27 +238,25 @@ Moonlight 调试配置路径: 3. 当前 Moonlight 使用软件解码,RK3528 的 VPU/MPP 硬解尚未打通; 4. Sunshine 当前使用软件编码,未启用 NVENC; 5. 当前仍是 X11 POC,尚未完成 Weston/Wayland/DRM 零拷贝路径; -6. RDP 证书校验、短期凭据和正式安全策略尚未完成; -7. USB 重定向、OTA、Agent、管理平台和 Web 控制台尚未开发完成。 +6. USB 重定向、OTA、Agent、管理平台和 Web 控制台尚未开发完成。 ## 九、下一步计划 按优先级: -1. 恢复 RK3528 盒子网络并部署登录/桌面列表新版本; -2. 实测 Moonlight `no-vsync` / `no-frame-pacing` 对鼠标延迟的改善; -3. 将 GTX 965M 直通 Windows VM,使 Sunshine 使用 NVENC; -4. 定义并联调终端用户认证、桌面列表和一次性连接票据 API; -5. 实现 Ubuntu、麒麟桌面连接配置; -6. 适配 RK3528 MPP/VPU 硬解; -7. 切换到 Weston/Wayland 正式显示路径; -8. 开发 `vdt-agent`、OTA、USB、云端服务和 Web 控制台。 +1. 保持终端、Sunshine 和虚拟显示器三端分辨率及色彩空间一致; +2. 将 GTX 965M 直通 Windows VM,使 Sunshine 使用 NVENC; +3. 定义并联调终端用户认证、桌面列表和一次性连接票据 API; +4. 实现 Ubuntu、麒麟桌面连接配置; +5. 适配 RK3528 MPP/VPU 硬解; +6. 切换到 Weston/Wayland 正式显示路径; +7. 开发 `vdt-agent`、OTA、USB、云端服务和 Web 控制台。 ## 十、开发规范 - 已验证、已实现、待验证和规划中必须明确区分; - 终端不能直接调用 Proxmox API; -- RDP 和 Moonlight 协议选择由管理平台连接票据决定; +- Moonlight 是当前唯一远程会话协议,连接参数由管理平台连接票据决定; - 所有密钥和密码只能通过安全配置注入,不得提交到 Git; - 正式代码变更应通过编译、真机测试和会话恢复测试; - 本 README 随功能、接口、目录和验证结论持续更新。 diff --git a/terminal/kiosk-shell/CMakeLists.txt b/terminal/kiosk-shell/CMakeLists.txt index 838a2ba..56eabf9 100644 --- a/terminal/kiosk-shell/CMakeLists.txt +++ b/terminal/kiosk-shell/CMakeLists.txt @@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.16) project(kiosk-shell CXX) # 对应《VDI 云桌面软件开发方案》2.2 节技术栈定版: -# kiosk 与 freerdp 插件用 C/C++(贴合 GTK/GStreamer 生态) +# kiosk 用 C/C++/Qt;会话通道统一 Moonlight(RDP 渲染管线已移除,代码存于 git 历史) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) @@ -14,10 +14,7 @@ endif() # ---- Qt5 ---- find_package(Qt5 REQUIRED COMPONENTS Core Gui Widgets) - -# ---- FreeRDP 3 ---- -find_package(PkgConfig REQUIRED) -pkg_check_modules(FREERDP REQUIRED IMPORTED_TARGET freerdp3) +include(CTest) add_executable(kiosk-shell src/main.cpp @@ -28,9 +25,9 @@ add_executable(kiosk-shell src/desktop_service.cpp src/desktop_store.cpp src/moonlight_session.cpp + src/transition_overlay.cpp src/state_machine.cpp src/config.cpp - src/rdp_session.cpp ) target_include_directories(kiosk-shell PRIVATE @@ -41,7 +38,6 @@ target_link_libraries(kiosk-shell PRIVATE Qt5::Core Qt5::Gui Qt5::Widgets - PkgConfig::FREERDP ) # 看门狗 Monitor:独立进程,监控主进程假死/崩溃 @@ -57,4 +53,39 @@ target_include_directories(vdi-kiosk-monitor PRIVATE find_package(Threads REQUIRED) target_link_libraries(vdi-kiosk-monitor PRIVATE Threads::Threads) +if(BUILD_TESTING) + find_package(Qt5 REQUIRED COMPONENTS Test) + + add_executable(test-moonlight-session + tests/test_moonlight_session.cpp + src/moonlight_session.cpp + ) + target_include_directories(test-moonlight-session PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src + ) + target_link_libraries(test-moonlight-session PRIVATE + Qt5::Core + Qt5::Test + ) + add_test(NAME moonlight-session COMMAND test-moonlight-session) + + add_executable(test-desktop-list-page + tests/test_desktop_list_page.cpp + src/desktop_list_page.cpp + ) + target_include_directories(test-desktop-list-page PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src + ) + target_link_libraries(test-desktop-list-page PRIVATE + Qt5::Core + Qt5::Gui + Qt5::Widgets + Qt5::Test + ) + add_test(NAME desktop-list-page COMMAND test-desktop-list-page) + set_tests_properties(desktop-list-page PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen" + ) +endif() + install(TARGETS kiosk-shell vdi-kiosk-monitor RUNTIME DESTINATION bin) diff --git a/terminal/kiosk-shell/src/desktop_list_page.cpp b/terminal/kiosk-shell/src/desktop_list_page.cpp index d9092a3..a74f071 100644 --- a/terminal/kiosk-shell/src/desktop_list_page.cpp +++ b/terminal/kiosk-shell/src/desktop_list_page.cpp @@ -35,11 +35,10 @@ DesktopListPage::DesktopListPage(QWidget* parent) : QWidget(parent) { auto* infoRow = new QHBoxLayout(); labelUser_ = new QLabel(this); labelUser_->setStyleSheet(QStringLiteral("color: #8a94a6;")); - buttonLogout_ = new QPushButton(QStringLiteral("退出登录"), this); - buttonLogout_->setMinimumSize(110, 38); + // 无登录页演示模式下不设"退出登录"(用户身份由设备/平台承担, + // 正式认证接入后由 DesktopService 驱动恢复登录流程) infoRow->addWidget(labelUser_); addStretchToRow(infoRow); - infoRow->addWidget(buttonLogout_); root->addLayout(infoRow); // ---- 卡片区 ---- @@ -62,12 +61,6 @@ DesktopListPage::DesktopListPage(QWidget* parent) : QWidget(parent) { labelStatus_->setAlignment(Qt::AlignCenter); labelStatus_->setStyleSheet(QStringLiteral("color: #8a94a6;")); root->addWidget(labelStatus_); - - connect(buttonLogout_, &QPushButton::clicked, this, [this]() { - if (!busy_) { - emit logoutRequested(); - } - }); } void DesktopListPage::setDesktops(const std::vector& desktops) { @@ -76,6 +69,8 @@ void DesktopListPage::setDesktops(const std::vector& desktops) { } void DesktopListPage::rebuildCards() { + actionButtons_.clear(); + // 清空旧卡片(最后一项是 stretch) while (cardsLayout_->count() > 1) { QLayoutItem* item = cardsLayout_->takeAt(0); @@ -110,13 +105,12 @@ void DesktopListPage::rebuildCards() { textCol->addWidget(osLine); row->addLayout(textCol, 1); - // 状态/协议标签 + // 状态标签(会话通道统一 Moonlight,无需按桌面标注协议) QString stateText; QString stateColor; switch (d.state) { case DesktopState::kAvailable: - stateText = QStringLiteral("%1 · 可连接").arg( - QString::fromLatin1(protocolToString(d.protocol)).toUpper()); + stateText = QStringLiteral("可连接"); stateColor = QStringLiteral("#3fb950"); break; case DesktopState::kPending: @@ -135,6 +129,8 @@ void DesktopListPage::rebuildCards() { // “设置”:每张卡都可编辑连接参数(协议/地址/应用名/分辨率/帧率) auto* editBtn = new QPushButton(QStringLiteral("设置"), card); editBtn->setMinimumSize(88, 42); + editBtn->setEnabled(!busy_); + actionButtons_.push_back(editBtn); connect(editBtn, &QPushButton::clicked, this, [this, id = d.id]() { if (!busy_) { emit editRequested(id); @@ -149,8 +145,11 @@ void DesktopListPage::rebuildCards() { "QPushButton { background: #2f6feb; color: white; border: none; " "border-radius: 6px; font-weight: bold; }" "QPushButton:disabled { background: #3a4a63; }")); + connectBtn->setEnabled(!busy_); + actionButtons_.push_back(connectBtn); connect(connectBtn, &QPushButton::clicked, this, [this, id = d.id]() { if (!busy_) { + setBusy(true); setStatus(QStringLiteral("正在连接,请稍候…")); emit desktopSelected(id); } @@ -176,8 +175,11 @@ void DesktopListPage::setStatus(const QString& text, bool error) { void DesktopListPage::setBusy(bool busy) { busy_ = busy; - buttonLogout_->setEnabled(!busy); - // 连接按钮在 rebuildCards 时根据 busy_ 生成;这里统一刷新点击行为由 busy_ 拦截 + for (QPushButton* button : actionButtons_) { + if (button) { + button->setEnabled(!busy_); + } + } } QString DesktopListPage::currentUser() const { diff --git a/terminal/kiosk-shell/src/desktop_list_page.h b/terminal/kiosk-shell/src/desktop_list_page.h index 01fbeb1..ea453d2 100644 --- a/terminal/kiosk-shell/src/desktop_list_page.h +++ b/terminal/kiosk-shell/src/desktop_list_page.h @@ -38,8 +38,6 @@ signals: void desktopSelected(const QString& desktopId); // 用户点击某张卡片的“设置”按钮 void editRequested(const QString& desktopId); - // 用户点击退出登录 - void logoutRequested(); private: void rebuildCards(); @@ -50,7 +48,7 @@ private: QLabel* labelUser_ = nullptr; QLabel* labelStatus_ = nullptr; QVBoxLayout* cardsLayout_ = nullptr; - QPushButton* buttonLogout_ = nullptr; + std::vector actionButtons_; bool busy_ = false; }; diff --git a/terminal/kiosk-shell/src/desktop_service.cpp b/terminal/kiosk-shell/src/desktop_service.cpp index 2c9687f..74007a2 100644 --- a/terminal/kiosk-shell/src/desktop_service.cpp +++ b/terminal/kiosk-shell/src/desktop_service.cpp @@ -51,7 +51,6 @@ void DesktopService::createTicket(const QString& desktopId, TicketCallback callb .arg(d.name); return; } - ticket.protocol = d.protocol; ticket.endpoint = d.endpoint; ticket.app = d.app; ticket.width = d.width; diff --git a/terminal/kiosk-shell/src/desktop_session.h b/terminal/kiosk-shell/src/desktop_session.h index d8285a9..7b5bc2c 100644 --- a/terminal/kiosk-shell/src/desktop_session.h +++ b/terminal/kiosk-shell/src/desktop_session.h @@ -1,8 +1,8 @@ // 桌面与会话领域模型 // -// 对应产品流程: -// 登录 -> 桌面列表 -> 选择桌面 -> 获取连接票据 -> 启动 RDP/Moonlight 会话 +// 产品流程:登录 -> 桌面列表 -> 选择桌面 -> 获取连接票据 -> 启动 Moonlight 会话 // +// 协议统一 Moonlight(RDP 渲染路径已移除,内嵌 RDP 代码保留在 git 历史作网关备用)。 // 当前阶段由本地 DesktopService 提供演示数据;后续接入管理平台后, // 由 API 适配器返回同样的模型,页面与状态机不感知数据来源。 @@ -14,32 +14,22 @@ namespace vdi { -enum class Protocol { - kRdp, - kMoonlight, -}; - -inline const char* protocolToString(Protocol p) { - return p == Protocol::kRdp ? "rdp" : "moonlight"; -} - // 桌面卡片的运行状态 enum class DesktopState { kAvailable, // 可连接 kPending, // 任务进行中(开机/重置等) - kUnassigned, // 尚未分配连接地址/协议,暂不可连接 + kUnassigned, // 尚未配置连接地址,暂不可连接 }; struct DesktopInfo { QString id; QString name; // 显示名,如 "Windows 10" QString os; // 操作系统描述 - Protocol protocol = Protocol::kMoonlight; DesktopState state = DesktopState::kUnassigned; // 连接参数(kUnassigned 时为空) - QString endpoint; // 主机地址 - QString app; // Moonlight 应用名 + QString endpoint; // 主机地址(IP / 域名 / VPN 隧道地址) + QString app; // Sunshine 应用名 int width = 1920; int height = 1080; int fps = 60; @@ -48,15 +38,11 @@ struct DesktopInfo { // 一次会话的连接凭据;当前为本地构造,后续由平台 connect-ticket 接口下发 struct ConnectionTicket { QString desktopId; - Protocol protocol = Protocol::kMoonlight; QString endpoint; QString app; int width = 1920; int height = 1080; int fps = 60; - QString username; - QString password; - int rdpPort = 3389; }; struct AuthResult { diff --git a/terminal/kiosk-shell/src/desktop_settings_dialog.cpp b/terminal/kiosk-shell/src/desktop_settings_dialog.cpp index a74d4cd..bb53357 100644 --- a/terminal/kiosk-shell/src/desktop_settings_dialog.cpp +++ b/terminal/kiosk-shell/src/desktop_settings_dialog.cpp @@ -43,7 +43,7 @@ DesktopSettingsDialog::DesktopSettingsDialog(std::vector desktops, } form->addRow(QStringLiteral("桌面"), comboDesktop_); - // 协议统一 Moonlight(RDP 渲染实测差,已不提供选项;代码保留作网关备用) + // 会话通道统一 Moonlight,无协议选项(地址/应用名即连接参数) editEndpoint_ = new QLineEdit(this); editEndpoint_->setPlaceholderText(QStringLiteral("例如 192.168.1.70 或 VPN 隧道地址")); @@ -121,7 +121,6 @@ void DesktopSettingsDialog::onAccepted() { if (d.id != id) { continue; } - d.protocol = Protocol::kMoonlight; d.endpoint = editEndpoint_->text().trimmed(); d.app = editApp_->text().trimmed(); const QString res = comboResolution_->currentData().toString(); diff --git a/terminal/kiosk-shell/src/desktop_settings_dialog.h b/terminal/kiosk-shell/src/desktop_settings_dialog.h index eb22565..18d3070 100644 --- a/terminal/kiosk-shell/src/desktop_settings_dialog.h +++ b/terminal/kiosk-shell/src/desktop_settings_dialog.h @@ -1,8 +1,8 @@ // 桌面连接设置对话框 // // 现场环境每套部署的桌面地址不同:选择一张桌面卡, -// 编辑协议(Moonlight/RDP)、地址(IP / 域名 / VPN 隧道地址)、 -// 应用名、分辨率和帧率,保存后立即生效。 +// 编辑地址(IP / 域名 / VPN 隧道地址)、应用名、分辨率和帧率, +// 保存后立即生效。会话通道统一 Moonlight,无协议选项。 // 地址为空 = 待分配,卡片不可连接。 #ifndef KIOSK_SHELL_DESKTOP_SETTINGS_DIALOG_H diff --git a/terminal/kiosk-shell/src/desktop_store.cpp b/terminal/kiosk-shell/src/desktop_store.cpp index 2e2c248..e53c830 100644 --- a/terminal/kiosk-shell/src/desktop_store.cpp +++ b/terminal/kiosk-shell/src/desktop_store.cpp @@ -21,7 +21,6 @@ std::vector defaultDesktops(const QString& migratedServer) { win.id = QStringLiteral("desk-win10-01"); win.name = QStringLiteral("Windows 10"); win.os = QStringLiteral("Windows 桌面"); - win.protocol = Protocol::kMoonlight; win.endpoint = migratedServer; // 兼容老盒子:迁移 client.conf 的 server win.app = QStringLiteral("Desktop"); win.width = 1920; @@ -33,14 +32,12 @@ std::vector defaultDesktops(const QString& migratedServer) { ubuntu.id = QStringLiteral("desk-ubuntu-01"); ubuntu.name = QStringLiteral("Ubuntu"); ubuntu.os = QStringLiteral("Ubuntu 桌面"); - ubuntu.protocol = Protocol::kMoonlight; list.push_back(ubuntu); DesktopInfo kylin; kylin.id = QStringLiteral("desk-kylin-01"); kylin.name = QStringLiteral("麒麟"); kylin.os = QStringLiteral("银河麒麟桌面"); - kylin.protocol = Protocol::kMoonlight; list.push_back(kylin); return list; @@ -51,9 +48,6 @@ DesktopInfo fromJson(const QJsonObject& obj) { d.id = obj.value(QStringLiteral("id")).toString(); d.name = obj.value(QStringLiteral("name")).toString(); d.os = obj.value(QStringLiteral("os")).toString(); - // 协议统一 Moonlight:RDP 渲染路径实测差(CPU 高、卷帘),本地配置不再提供 - // RDP 选项;RDP 代码保留作网关备用,协议字段仅在平台票据层区分。 - d.protocol = Protocol::kMoonlight; d.endpoint = obj.value(QStringLiteral("endpoint")).toString(); d.app = obj.value(QStringLiteral("app")).toString(); d.width = obj.value(QStringLiteral("width")).toInt(1920); @@ -68,8 +62,6 @@ QJsonObject toJson(const DesktopInfo& d) { obj.insert(QStringLiteral("id"), d.id); obj.insert(QStringLiteral("name"), d.name); obj.insert(QStringLiteral("os"), d.os); - obj.insert(QStringLiteral("protocol"), - d.protocol == Protocol::kRdp ? QStringLiteral("rdp") : QStringLiteral("moonlight")); obj.insert(QStringLiteral("endpoint"), d.endpoint); obj.insert(QStringLiteral("app"), d.app); obj.insert(QStringLiteral("width"), d.width); diff --git a/terminal/kiosk-shell/src/login_page.cpp b/terminal/kiosk-shell/src/login_page.cpp index 60bdf04..aaa632a 100644 --- a/terminal/kiosk-shell/src/login_page.cpp +++ b/terminal/kiosk-shell/src/login_page.cpp @@ -146,6 +146,8 @@ LoginPage::LoginPage(const TerminalConfig& initial, QWidget* parent) root->addLayout(grid); // ---- 按钮 ---- + // 登录页不提供"退出"按钮:点击退出会被看门狗拉起,属于维护功能, + // 只保留在管理菜单(Ctrl+Alt+Shift+M -> 退出终端) auto* buttonRow = new QHBoxLayout(); buttonRow->addStretch(1); buttonLogin_ = new QPushButton(QStringLiteral("登 录"), this); @@ -155,11 +157,7 @@ LoginPage::LoginPage(const TerminalConfig& initial, QWidget* parent) "border-radius: 6px; font-weight: bold; }" "QPushButton:disabled { background: #3a4a63; }")); buttonLogin_->setDefault(true); - buttonQuit_ = new QPushButton(QStringLiteral("退出"), this); - buttonQuit_->setMinimumSize(110, 46); - buttonRow->addWidget(buttonLogin_); - buttonRow->addWidget(buttonQuit_); buttonRow->addStretch(1); root->addLayout(buttonRow); @@ -171,7 +169,6 @@ LoginPage::LoginPage(const TerminalConfig& initial, QWidget* parent) root->addStretch(1); connect(buttonLogin_, &QPushButton::clicked, this, &LoginPage::onLoginClicked); - connect(buttonQuit_, &QPushButton::clicked, this, [this]() { emit quitRequested(); }); // 密码框回车直接登录 connect(editPassword_, &QLineEdit::returnPressed, this, &LoginPage::onLoginClicked); connect(editUser_, &QLineEdit::returnPressed, this, &LoginPage::onLoginClicked); diff --git a/terminal/kiosk-shell/src/login_page.h b/terminal/kiosk-shell/src/login_page.h index f212ca4..d327d94 100644 --- a/terminal/kiosk-shell/src/login_page.h +++ b/terminal/kiosk-shell/src/login_page.h @@ -38,7 +38,6 @@ public: signals: void loginRequested(const QString& username, const QString& password); - void quitRequested(); private slots: void onLoginClicked(); @@ -53,7 +52,6 @@ private: QLabel* labelSn_ = nullptr; QLabel* labelNetwork_ = nullptr; QPushButton* buttonLogin_ = nullptr; - QPushButton* buttonQuit_ = nullptr; }; } // namespace vdi diff --git a/terminal/kiosk-shell/src/main_window.cpp b/terminal/kiosk-shell/src/main_window.cpp index 2042622..b93a55c 100644 --- a/terminal/kiosk-shell/src/main_window.cpp +++ b/terminal/kiosk-shell/src/main_window.cpp @@ -2,77 +2,25 @@ #include #include -#include +#include #include -#include -#include -#include #include -#include -#include -#include -#include #include #include #include #include -#include - -#include -#include -#include -#include #include "desktop_list_page.h" #include "desktop_settings_dialog.h" #include "login_page.h" +#include "transition_overlay.h" namespace vdi { namespace { -// FreeRDP 像素格式 -> QImage::Format -// 注意:FreeRDP 的像素格式值是计算宏(FREERDP_PIXEL_FORMAT), -// 不能硬编码数值,必须用宏直接比较。 -// FreeRDP 主缓冲是 4 字节/像素(BGRX / BGRA / RGBA), -// QImage 没有对应的 4 字节 BGRA 格式,因此统一转成 RGB888 输出。 -QImage::Format toQImageFormat(uint32_t freerdpFormat) { - (void)freerdpFormat; - return QImage::Format_RGB888; -} - -// FreeRDP BGRX32 在当前 ARM64 小端目标上与 QImage::Format_RGB32 的内存布局一致。 -QImage convertFrame(const uint8_t* data, uint32_t width, uint32_t height, uint32_t bytesPerLine, - uint32_t freerdpFormat) { - if (!data || width == 0 || height == 0) { - return QImage(); - } - if (freerdpFormat == PIXEL_FORMAT_BGRX32 || freerdpFormat == PIXEL_FORMAT_BGRA32) { - QImage out(static_cast(width), static_cast(height), QImage::Format_RGB32); - const size_t rowBytes = static_cast(width) * 4; - for (uint32_t y = 0; y < height; ++y) { - std::memcpy(out.scanLine(static_cast(y)), - data + static_cast(y) * bytesPerLine, rowBytes); - } - return out; - } - - QImage out(static_cast(width), static_cast(height), QImage::Format_RGB888); - for (uint32_t y = 0; y < height; ++y) { - const uint8_t* src = data + static_cast(y) * bytesPerLine; - uint8_t* dst = out.scanLine(static_cast(y)); - for (uint32_t x = 0; x < width; ++x) { - const uint8_t* p = src + static_cast(x) * 4; - dst[0] = p[0]; - dst[1] = p[1]; - dst[2] = p[2]; - dst += 3; - } - } - return out; -} - -constexpr int kReconnectDelayMs = 3000; -constexpr int kMaxReconnectAttempts = 5; +constexpr int kTicketTimeoutMs = 10000; +constexpr int kProcessStartTimeoutMs = 5000; +constexpr int kStreamReadyTimeoutMs = 30000; } // namespace @@ -83,14 +31,18 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { setupUi(); setupHotkey(); setupHeartbeat(); - keyboardLayout_ = freerdp_keyboard_init(0); config_ = loadConfig(); - - // 开机永远进入登录页;不再根据本地配置自动连接 RDP, - // 会话只能由用户在桌面列表选择后启动。 - fsm_.transitionTo(State::kConfig, QStringLiteral("等待登录")); - showLogin(); + fsm_.transitionTo(State::kConfig, QStringLiteral("就绪")); + const QString deviceUser = QString::fromStdString(config_.sn).trimmed(); + desktopService_.login(deviceUser.isEmpty() ? QStringLiteral("terminal") : deviceUser, + QStringLiteral("kiosk"), [this](const AuthResult& result) { + if (result.ok) { + currentUser_ = result.displayName; + } + fsm_.transitionTo(State::kAuthenticating, QStringLiteral("自动登录")); + fsm_.transitionTo(State::kDesktopList, QStringLiteral("请选择要进入的云桌面")); + }); } MainWindow::~MainWindow() { @@ -106,7 +58,6 @@ void MainWindow::setupUi() { stack_->setStyleSheet(QStringLiteral("background: #0f1420;")); setCentralWidget(stack_); - // 登录页 auto* login = new LoginPage(config_, this); loginPage_ = login; login->setStyleSheet( @@ -118,11 +69,8 @@ void MainWindow::setupUi() { "border-radius: 6px; color: #e6e9ef; }" "QPushButton:hover { background: #2b3654; }")); stack_->addWidget(login); - connect(login, &LoginPage::loginRequested, this, &MainWindow::onLoginRequested); - connect(login, &LoginPage::quitRequested, this, &MainWindow::onQuitRequested); - // 桌面列表页 auto* list = new DesktopListPage(this); desktopListPage_ = list; list->setStyleSheet( @@ -131,74 +79,59 @@ void MainWindow::setupUi() { "border-radius: 6px; color: #e6e9ef; }" "QPushButton:hover { background: #2b3654; }")); stack_->addWidget(list); - connect(list, &DesktopListPage::desktopSelected, this, &MainWindow::onDesktopSelected); connect(list, &DesktopListPage::editRequested, this, &MainWindow::onEditDesktopRequested); - connect(list, &DesktopListPage::logoutRequested, this, &MainWindow::onLogoutRequested); - // 会话视图 - auto* sessionWidget = new QWidget(this); - auto* sessionLayout = new QVBoxLayout(sessionWidget); - sessionLayout->setContentsMargins(0, 0, 0, 0); + overlay_ = new TransitionOverlay(this); - sessionView_ = new QLabel(sessionWidget); - sessionView_->setFocusPolicy(Qt::StrongFocus); - sessionView_->setMouseTracking(true); - sessionView_->installEventFilter(this); - sessionView_->setAlignment(Qt::AlignCenter); - sessionView_->setStyleSheet(QStringLiteral("background: #000000; color: #e6e9ef;")); - sessionView_->setText(QStringLiteral("正在建立远程会话…")); - - sessionOverlay_ = new QLabel(sessionWidget); - sessionOverlay_->setAlignment(Qt::AlignCenter); - sessionOverlay_->setStyleSheet( - QStringLiteral("background: rgba(15,20,32,0.82); color: #e6e9ef; " - "font-size: 20px; padding: 40px;")); - sessionOverlay_->setText(QStringLiteral("正在连接…")); - sessionOverlay_->hide(); - - sessionLayout->addWidget(sessionView_); - sessionLayout->addWidget(sessionOverlay_); - stack_->addWidget(sessionWidget); - - // 状态条 statusBar_ = new QStatusBar(this); statusBar_->setStyleSheet(QStringLiteral("QStatusBar { background: #161d2e; color: #8a94a6; }")); setStatusBar(statusBar_); - // RDP 重连定时器 - reconnectTimer_ = new QTimer(this); - reconnectTimer_->setSingleShot(true); - connect(reconnectTimer_, &QTimer::timeout, this, [this]() { - if (fsm_.current() == State::kReconnect && reconnectAttempts_ < kMaxReconnectAttempts) { - ++reconnectAttempts_; - if (activeTicket_.protocol == Protocol::kMoonlight) { - startMoonlightSession(activeTicket_); - } else { - startRdpSession(activeTicket_); - } - } else if (fsm_.current() == State::kReconnect) { - stopSessions(); - fsm_.transitionTo(State::kDesktopList, QStringLiteral("重连失败,已返回桌面列表")); + connectionTimer_ = new QTimer(this); + connectionTimer_->setSingleShot(true); + connect(connectionTimer_, &QTimer::timeout, this, [this]() { + QString message; + switch (connectionPhase_) { + case ConnectionPhase::kTicket: + message = QStringLiteral("获取连接信息超时"); + break; + case ConnectionPhase::kProcessStart: + message = QStringLiteral("Moonlight 启动超时"); + break; + case ConnectionPhase::kStreamReady: + message = QStringLiteral("云桌面画面就绪超时"); + break; + default: + return; } + failConnection(connectionAttempt_, message); }); - frameRefreshTimer_ = new QTimer(this); - frameRefreshTimer_->setInterval(33); - connect(frameRefreshTimer_, &QTimer::timeout, this, &MainWindow::onFrameRefresh); - frameRefreshTimer_->start(); + moonlightSession_ = std::make_unique(); } void MainWindow::setupHotkey() { - // 全局热键 Ctrl+Alt+Shift+M 呼出管理菜单(方案 3.2 节) - auto* shortcut = new QShortcut(QKeySequence(QStringLiteral("Ctrl+Alt+Shift+M")), this); - shortcut->setContext(Qt::ApplicationShortcut); - connect(shortcut, &QShortcut::activated, this, [this]() { onMenuAction(0); }); + auto* menuShortcut = new QShortcut(QKeySequence(QStringLiteral("Ctrl+Alt+Shift+M")), this); + menuShortcut->setContext(Qt::ApplicationShortcut); + connect(menuShortcut, &QShortcut::activated, this, [this]() { onMenuAction(0); }); + + auto* quitShortcut = new QShortcut(QKeySequence(QStringLiteral("Ctrl+Alt+Shift+Q")), this); + quitShortcut->setContext(Qt::ApplicationShortcut); + connect(quitShortcut, &QShortcut::activated, this, [this]() { + if (fsm_.isIn(State::kConnecting) || fsm_.isIn(State::kSession) || + fsm_.isIn(State::kReconnect)) { + stopSessions(); + fsm_.transitionTo(State::kDesktopList, QStringLiteral("已退出串流")); + if (desktopListPage_) { + desktopListPage_->setBusy(false); + desktopListPage_->setStatus(QStringLiteral("已退出串流")); + } + } + }); } void MainWindow::setupHeartbeat() { - // 定期写心跳文件,供看门狗 Monitor 判断主进程是否假死 - // 对应会议纪要要求的双进程架构 const QString path = QStringLiteral("/tmp/vdi-kiosk.heartbeat"); heartbeatTimer_ = new QTimer(this); heartbeatTimer_->setInterval(5000); @@ -206,7 +139,6 @@ void MainWindow::setupHeartbeat() { QFile file(path); if (file.open(QIODevice::WriteOnly | QIODevice::Text)) { file.write(QByteArray::number(QDateTime::currentSecsSinceEpoch())); - file.close(); } }); heartbeatTimer_->start(); @@ -228,21 +160,10 @@ void MainWindow::onStateChanged(State from, State to, const QString& detail) { refreshDesktopList(); break; case State::kConnecting: - if (stack_->currentWidget() != sessionView_->parentWidget()) { - stack_->setCurrentWidget(sessionView_->parentWidget()); - } - sessionView_->setFocus(Qt::OtherFocusReason); - sessionOverlay_->setText(QStringLiteral("正在连接 %1 …").arg(activeTicket_.endpoint)); - sessionOverlay_->show(); + overlay_->start(QStringLiteral("正在进入云桌面")); break; case State::kSession: - sessionView_->setFocus(Qt::OtherFocusReason); - sessionOverlay_->hide(); - break; case State::kReconnect: - sessionOverlay_->setText(QStringLiteral("连接已断开,正在重连…")); - sessionOverlay_->show(); - reconnectTimer_->start(kReconnectDelayMs); break; case State::kError: stopSessions(); @@ -310,31 +231,44 @@ void MainWindow::onLoginRequested(const QString& username, const QString& passwo void MainWindow::onDesktopSelected(const QString& desktopId) { if (!fsm_.canConnect()) { + if (desktopListPage_) { + desktopListPage_->setBusy(false); + } return; } - desktopService_.createTicket(desktopId, [this](const ConnectionTicket& ticket, - const QString& error) { - if (!error.isEmpty()) { - if (desktopListPage_) { - desktopListPage_->setBusy(false); - desktopListPage_->setStatus(error, true); - } + + const quint64 attempt = ++connectionAttempt_; + connectingDesktopId_ = desktopId; + connectionClock_.restart(); + phaseClock_.restart(); + connectionPhase_ = ConnectionPhase::kTicket; + logConnectionPhase(attempt, QStringLiteral("ticket"), QStringLiteral("started")); + + if (!fsm_.transitionTo(State::kConnecting, QStringLiteral("正在获取连接信息"))) { + failConnection(attempt, QStringLiteral("当前状态无法发起连接")); + return; + } + armConnectionTimeout(attempt, ConnectionPhase::kTicket, kTicketTimeoutMs); + + desktopService_.createTicket(desktopId, [this, attempt](const ConnectionTicket& ticket, + const QString& error) { + if (attempt != connectionAttempt_ || connectionPhase_ != ConnectionPhase::kTicket) { return; } + connectionTimer_->stop(); + if (!error.isEmpty()) { + failConnection(attempt, error); + return; + } + logConnectionPhase(attempt, QStringLiteral("ticket"), QStringLiteral("ready")); activeTicket_ = ticket; - reconnectAttempts_ = 0; - - if (ticket.protocol == Protocol::kMoonlight) { - startMoonlightSession(ticket); - } else { - startRdpSession(ticket); + if (!startMoonlightSession(ticket, attempt)) { + failConnection(attempt, QStringLiteral("已有串流进程正在运行")); } }); } void MainWindow::onEditDesktopRequested(const QString& desktopId) { - // 桌面连接设置:编辑协议/地址/应用名/分辨率/帧率,保存后立即刷新列表。 - // 现场环境地址不同或走 VPN 隧道时,在这里改地址即可,无需改代码。 DesktopSettingsDialog dialog(desktops_, desktopId, this); dialog.setStyleSheet( QStringLiteral("QWidget { background: #0f1420; color: #e6e9ef; }" @@ -354,176 +288,126 @@ void MainWindow::onEditDesktopRequested(const QString& desktopId) { } } -void MainWindow::onLogoutRequested() { - stopSessions(); - desktopService_.logout(); - currentUser_.clear(); - fsm_.transitionTo(State::kConfig, QStringLiteral("已退出登录")); -} - -void MainWindow::startRdpSession(const ConnectionTicket& ticket) { - ++sessionEpoch_; - { - QMutexLocker locker(&frameMutex_); - latestFrame_ = QImage(); - frameEpoch_ = sessionEpoch_; - frameWakePending_ = false; - } - if (fsm_.current() == State::kSession) { - fsm_.transitionTo(State::kReconnect, QStringLiteral("手动重新连接")); - } - if (!fsm_.transitionTo(State::kConnecting, ticket.endpoint)) { - if (fsm_.current() != State::kConnecting) { - return; - } - } - - RdpOptions opts; - opts.server = ticket.endpoint.toStdString(); - opts.username = ticket.username.toStdString(); - opts.password = ticket.password.toStdString(); - opts.keyboardLayout = keyboardLayout_; - opts.width = ticket.width; - opts.height = ticket.height; - opts.bpp = 32; - opts.fullscreen = true; - // 与 xfreerdp3 相同的 GFX 能力组合语义: - // FreeRDP 3.32.1 不接受单个管道名,必须成组。 - opts.gfx = "progressive|RFX|AVC420|AVC444"; - opts.ignoreCertificate = true; // POC 阶段;正式产品必须做证书校验 - - RdpCallbacks cb; - cb.onFrame = [this](const uint8_t* data, uint32_t width, uint32_t height, uint32_t stride, - uint32_t format) { - // 在 FreeRDP 线程中复制帧,不能把其内部缓冲区裸指针排队到 GUI 线程。 - const QImage image = convertFrame(data, width, height, stride, format); - if (image.isNull()) { - return; - } - QMutexLocker locker(&frameMutex_); - latestFrame_ = image; - frameEpoch_ = sessionEpoch_; - if (frameWakePending_) { - return; - } - frameWakePending_ = true; - locker.unlock(); - QMetaObject::invokeMethod(this, &MainWindow::onFrameWakeup, Qt::QueuedConnection); - }; - cb.onState = [this](bool connected, const std::string& message) { - QMetaObject::invokeMethod( - this, - [this, connected, message]() { - onSessionState(connected, QString::fromStdString(message)); - }, - Qt::QueuedConnection); - }; - - rdpSession_ = std::make_unique(); - rdpSession_->start(opts, cb); -} - -void MainWindow::startMoonlightSession(const ConnectionTicket& ticket) { - if (!fsm_.transitionTo(State::kConnecting, ticket.endpoint)) { - if (fsm_.current() != State::kConnecting) { - return; - } - } - // Moonlight 是外部全屏进程:Kiosk 画面停在"正在连接"覆盖层, - // 串流窗口由 moonlight-qt 自行置顶显示。 - if (stack_->currentWidget() != sessionView_->parentWidget()) { - stack_->setCurrentWidget(sessionView_->parentWidget()); - } - sessionOverlay_->setText(QStringLiteral("正在连接 %1 …").arg(ticket.endpoint)); - sessionOverlay_->show(); - +bool MainWindow::startMoonlightSession(const ConnectionTicket& ticket, quint64 attempt) { MoonlightSession::Options opts; - opts.decoder = QStringLiteral("software"); // RK3528 无可用 V4L2 硬解设备 + opts.decoder = QStringLiteral("software"); opts.videoCodec = QStringLiteral("H.264"); opts.audioOnHost = true; - - moonlightSession_ = std::make_unique(/*parent=*/nullptr); moonlightSession_->setOptions(opts); - moonlightSession_->start(ticket, [this](bool running, const QString& message) { + + phaseClock_.restart(); + connectionPhase_ = ConnectionPhase::kProcessStart; + logConnectionPhase(attempt, QStringLiteral("process_start"), QStringLiteral("started")); + armConnectionTimeout(attempt, ConnectionPhase::kProcessStart, kProcessStartTimeoutMs); + + return moonlightSession_->start(ticket, [this, attempt](const MoonlightSession::Event& event) { QMetaObject::invokeMethod( - this, - [this, running, message]() { onMoonlightState(running, message); }, + this, [this, attempt, event]() { onMoonlightEvent(attempt, event); }, Qt::QueuedConnection); }); } -void MainWindow::onMoonlightState(bool running, const QString& message) { - if (running) { - // 进程已拉起;串流窗口是否成功由 moonlight 自身展示 - updateStatusBar(QStringLiteral("SESSION %1").arg(message)); +void MainWindow::onMoonlightEvent(quint64 attempt, const MoonlightSession::Event& event) { + if (attempt != connectionAttempt_) { return; } - // 进程退出:回桌面列表 - moonlightSession_.reset(); - stopSessions(); - if (fsm_.current() == State::kConnecting || fsm_.current() == State::kSession) { - fsm_.transitionTo(State::kDesktopList, message); + + switch (event.type) { + case MoonlightSession::EventType::kStarted: + if (connectionPhase_ != ConnectionPhase::kProcessStart) { + return; + } + connectionTimer_->stop(); + logConnectionPhase(attempt, QStringLiteral("process_start"), QStringLiteral("ready")); + phaseClock_.restart(); + armConnectionTimeout(attempt, ConnectionPhase::kStreamReady, kStreamReadyTimeoutMs); + updateStatusBar(QStringLiteral("CONNECTING 正在初始化云桌面画面")); + break; + case MoonlightSession::EventType::kStreamReady: + if (connectionPhase_ != ConnectionPhase::kStreamReady) { + return; + } + waitForMoonlightWindow(attempt); + break; + case MoonlightSession::EventType::kFailed: + failConnection(attempt, event.message); + break; + case MoonlightSession::EventType::kFinished: { + connectionTimer_->stop(); + logConnectionPhase(attempt, QStringLiteral("session"), QStringLiteral("finished")); + ++connectionAttempt_; + connectionPhase_ = ConnectionPhase::kIdle; + overlay_->finish(); + if (fsm_.isIn(State::kConnecting) || fsm_.isIn(State::kSession) || + fsm_.isIn(State::kReconnect)) { + fsm_.transitionTo(State::kDesktopList, event.message); + } + if (desktopListPage_) { + desktopListPage_->setBusy(false); + desktopListPage_->setStatus(event.message); + } + break; + } + } +} + +void MainWindow::waitForMoonlightWindow(quint64 attempt) { + if (attempt != connectionAttempt_ || connectionPhase_ != ConnectionPhase::kStreamReady) { + return; + } + if (!moonlightSession_->activateStreamWindow()) { + QTimer::singleShot(100, this, [this, attempt]() { waitForMoonlightWindow(attempt); }); + return; + } + + connectionTimer_->stop(); + logConnectionPhase(attempt, QStringLiteral("stream_ready"), QStringLiteral("ready")); + connectionPhase_ = ConnectionPhase::kSession; + fsm_.transitionTo(State::kSession, QStringLiteral("云桌面画面已就绪")); + overlay_->onFirstFrame(); +} + +void MainWindow::failConnection(quint64 attempt, const QString& message) { + if (attempt != connectionAttempt_) { + return; + } + connectionTimer_->stop(); + logConnectionPhase(attempt, QStringLiteral("connection"), QStringLiteral("failed"), message); + ++connectionAttempt_; + connectionPhase_ = ConnectionPhase::kIdle; + if (moonlightSession_) { + moonlightSession_->stop(); + } + overlay_->finish(); + if (fsm_.isIn(State::kConnecting) || fsm_.isIn(State::kSession) || + fsm_.isIn(State::kReconnect)) { + fsm_.transitionTo(State::kDesktopList, QStringLiteral("连接失败")); } if (desktopListPage_) { desktopListPage_->setBusy(false); - desktopListPage_->setStatus(message); + desktopListPage_->setStatus(QStringLiteral("连接失败:%1").arg(message), true); } } -void MainWindow::onSessionState(bool connected, const QString& message) { - if (connected) { - reconnectAttempts_ = 0; - fsm_.transitionTo(State::kSession, message); - } else { - if (fsm_.current() == State::kConnecting) { - // 首次连接失败:回桌面列表并提示 - stopSessions(); - fsm_.transitionTo(State::kDesktopList, message); - if (desktopListPage_) { - desktopListPage_->setBusy(false); - desktopListPage_->setStatus(QStringLiteral("连接失败:%1").arg(message), true); - } - } else if (fsm_.current() == State::kSession) { - fsm_.transitionTo(State::kReconnect, message); - } - } -} - -void MainWindow::onFrameWakeup() { - QMutexLocker locker(&frameMutex_); - frameWakePending_ = false; -} - -void MainWindow::onFrameRefresh() { - QImage image; - uint64_t epoch = 0; - { - QMutexLocker locker(&frameMutex_); - image = latestFrame_; - epoch = frameEpoch_; - latestFrame_ = QImage(); - frameWakePending_ = false; - } - if (epoch == sessionEpoch_) { - onFrameImage(image); - } -} - -void MainWindow::onFrameImage(const QImage& image) { - if (image.isNull()) { +void MainWindow::armConnectionTimeout(quint64 attempt, ConnectionPhase phase, int timeoutMs) { + if (attempt != connectionAttempt_) { return; } - lastFrame_ = image; + connectionPhase_ = phase; + connectionTimer_->start(timeoutMs); +} - if (sessionView_) { - const QSize targetSize = sessionView_->size(); - if (lastFrame_.size() == targetSize) { - sessionView_->setPixmap(QPixmap::fromImage(lastFrame_)); - } else { - sessionView_->setPixmap(QPixmap::fromImage(lastFrame_).scaled( - targetSize, Qt::KeepAspectRatio, Qt::FastTransformation)); - } - } +void MainWindow::logConnectionPhase(quint64 attempt, const QString& phase, + const QString& result, const QString& error) { + const qint64 totalMs = connectionClock_.isValid() ? connectionClock_.elapsed() : 0; + const qint64 phaseMs = phaseClock_.isValid() ? phaseClock_.elapsed() : 0; + qInfo().noquote() + << QStringLiteral("vdi_connection attempt=%1 desktop=%2 phase=%3 result=%4 phase_ms=%5 total_ms=%6 error=%7") + .arg(attempt) + .arg(connectingDesktopId_, phase, result) + .arg(phaseMs) + .arg(totalMs) + .arg(error); } void MainWindow::onQuitRequested() { @@ -532,40 +416,43 @@ void MainWindow::onQuitRequested() { } void MainWindow::stopSessions() { - if (rdpSession_) { - rdpSession_->stop(); - rdpSession_.reset(); + stopSessions(true); +} + +void MainWindow::stopSessions(bool invalidateAttempt) { + connectionTimer_->stop(); + if (invalidateAttempt) { + ++connectionAttempt_; } + connectionPhase_ = ConnectionPhase::kIdle; if (moonlightSession_) { moonlightSession_->stop(); - moonlightSession_.reset(); } - QMutexLocker locker(&frameMutex_); - latestFrame_ = QImage(); - lastFrame_ = QImage(); - frameWakePending_ = false; + if (overlay_) { + overlay_->finish(); + } } void MainWindow::onMenuAction(int action) { switch (action) { - case 1: // 重新连接当前桌面 + case 1: if (fsm_.canConnect() && !activeTicket_.desktopId.isEmpty()) { - reconnectAttempts_ = 0; - stopSessions(); - if (activeTicket_.protocol == Protocol::kMoonlight) { - startMoonlightSession(activeTicket_); - } else { - startRdpSession(activeTicket_); + const quint64 attempt = ++connectionAttempt_; + connectingDesktopId_ = activeTicket_.desktopId; + connectionClock_.restart(); + if (fsm_.transitionTo(State::kConnecting, activeTicket_.endpoint) && + !startMoonlightSession(activeTicket_, attempt)) { + failConnection(attempt, QStringLiteral("已有串流进程正在运行")); } } break; - case 2: // 断开回桌面列表 + case 2: stopSessions(); if (fsm_.current() != State::kConfig) { - fsm_.transitionTo(State::kDesktopList, "已断开"); + fsm_.transitionTo(State::kDesktopList, QStringLiteral("已断开")); } break; - case 3: // 退出终端 + case 3: onQuitRequested(); break; default: { @@ -588,90 +475,9 @@ void MainWindow::onMenuAction(int action) { } else if (chosen == quit) { onMenuAction(3); } - if (stack_->currentWidget() == sessionView_->parentWidget()) { - sessionView_->setFocus(Qt::OtherFocusReason); - } break; } } } -bool MainWindow::eventFilter(QObject* watched, QEvent* event) { - if (watched == sessionView_ && rdpSession_) { - if (event->type() == QEvent::KeyPress || event->type() == QEvent::KeyRelease) { - auto* keyEvent = static_cast(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(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(event); - pos = mouse->pos(); - flags = PTR_FLAGS_MOVE; - } else if (event->type() == QEvent::MouseButtonPress || - event->type() == QEvent::MouseButtonRelease) { - auto* mouse = static_cast(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(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(remoteX), - static_cast(remoteY)); -} - } // namespace vdi diff --git a/terminal/kiosk-shell/src/main_window.h b/terminal/kiosk-shell/src/main_window.h index a701a5f..8eba3a7 100644 --- a/terminal/kiosk-shell/src/main_window.h +++ b/terminal/kiosk-shell/src/main_window.h @@ -1,43 +1,30 @@ // Kiosk 主窗口 // -// 产品流程编排: -// 开机 -> 登录页 -> 认证 -> 桌面列表 -> 选择桌面 -// -> 获取连接票据 -> RDP(内嵌)或 Moonlight(外部进程)会话 -// -> 会话结束回桌面列表;退出登录回登录页 -// -// 对应《VDI 云桌面软件开发方案》3.2 节的状态机, -// 并按产品流程扩展 AUTHENTICATING / DESKTOP_LIST 两个状态。 -// 全局热键 Ctrl+Alt+Shift+M 呼出管理菜单。 +// 产品流程编排:开机 -> 桌面列表 -> 获取连接票据 -> Moonlight 会话 -> 返回列表。 #ifndef KIOSK_SHELL_MAIN_WINDOW_H #define KIOSK_SHELL_MAIN_WINDOW_H -#include +#include #include -#include #include -#include #include #include "config.h" #include "desktop_service.h" #include "desktop_session.h" #include "moonlight_session.h" -#include "rdp_session.h" #include "state_machine.h" -class QLabel; class QStackedWidget; class QStatusBar; class QTimer; -class QEvent; -class QKeyEvent; -class QMouseEvent; namespace vdi { class LoginPage; class DesktopListPage; +class TransitionOverlay; class MainWindow : public QMainWindow { Q_OBJECT @@ -46,64 +33,61 @@ public: explicit MainWindow(QWidget* parent = nullptr); ~MainWindow() override; -protected: - bool eventFilter(QObject* watched, QEvent* event) override; - private slots: void onLoginRequested(const QString& username, const QString& password); void onDesktopSelected(const QString& desktopId); void onEditDesktopRequested(const QString& desktopId); - void onLogoutRequested(); void onQuitRequested(); - void onFrameImage(const QImage& image); - void onFrameWakeup(); - void onFrameRefresh(); - void onSessionState(bool connected, const QString& message); - void onMoonlightState(bool running, const QString& message); void onMenuAction(int action); private: + enum class ConnectionPhase { + kIdle, + kTicket, + kProcessStart, + kStreamReady, + kSession, + }; + void setupUi(); void setupHotkey(); void setupHeartbeat(); void onStateChanged(vdi::State from, vdi::State to, const QString& detail); void refreshDesktopList(); - void startRdpSession(const ConnectionTicket& ticket); - void startMoonlightSession(const ConnectionTicket& ticket); + bool startMoonlightSession(const ConnectionTicket& ticket, quint64 attempt); + void onMoonlightEvent(quint64 attempt, const MoonlightSession::Event& event); + void waitForMoonlightWindow(quint64 attempt); void stopSessions(); + void stopSessions(bool invalidateAttempt); + void failConnection(quint64 attempt, const QString& message); + void armConnectionTimeout(quint64 attempt, ConnectionPhase phase, int timeoutMs); + void logConnectionPhase(quint64 attempt, const QString& phase, + const QString& result = QString(), + const QString& error = QString()); void showLogin(); void showDesktopList(); void updateStatusBar(const QString& text); - bool forwardKeyEvent(QKeyEvent* event, bool pressed); - bool forwardMouseEvent(QEvent* event); StateMachine fsm_; DesktopService desktopService_; - std::unique_ptr rdpSession_; std::unique_ptr moonlightSession_; ConnectionTicket activeTicket_; QString currentUser_; + QString connectingDesktopId_; std::vector desktops_; - int reconnectAttempts_ = 0; QPointer loginPage_; QPointer desktopListPage_; QStackedWidget* stack_ = nullptr; - QLabel* sessionView_ = nullptr; - QLabel* sessionOverlay_ = nullptr; QStatusBar* statusBar_ = nullptr; - QTimer* reconnectTimer_ = nullptr; + TransitionOverlay* overlay_ = nullptr; + QTimer* connectionTimer_ = nullptr; QTimer* heartbeatTimer_ = nullptr; - QTimer* frameRefreshTimer_ = nullptr; + QElapsedTimer connectionClock_; + QElapsedTimer phaseClock_; + quint64 connectionAttempt_ = 0; + ConnectionPhase connectionPhase_ = ConnectionPhase::kIdle; TerminalConfig config_; - QImage lastFrame_; - QImage latestFrame_; - QMutex frameMutex_; - uint64_t frameEpoch_ = 0; - uint64_t sessionEpoch_ = 0; - bool frameWakePending_ = false; - QPoint lastMousePosition_; - uint32_t keyboardLayout_ = 0; }; } // namespace vdi diff --git a/terminal/kiosk-shell/src/monitor.cpp b/terminal/kiosk-shell/src/monitor.cpp index d9a1f50..b6c3cc4 100644 --- a/terminal/kiosk-shell/src/monitor.cpp +++ b/terminal/kiosk-shell/src/monitor.cpp @@ -159,6 +159,12 @@ int Monitor::run(const std::string& command) { log("主进程已退出, code=" + std::to_string(exitCode) + (signalNum ? (", signal=" + std::to_string(signalNum)) : "")); + // shell 主进程退出时 Moonlight 等后代可能仍留在原进程组中。 + // 重启前清理整个组,避免旧串流窗口与新实例并存。 + killTree(child, SIGTERM); + std::this_thread::sleep_for(std::chrono::milliseconds(300)); + killTree(child, SIGKILL); + if (++restartAttempts_ > config_.maxRestartAttempts) { log("连续重启超过 " + std::to_string(config_.maxRestartAttempts) + " 次,放弃重启(熔断)"); diff --git a/terminal/kiosk-shell/src/moonlight_session.cpp b/terminal/kiosk-shell/src/moonlight_session.cpp index a9fd323..9d1c3dc 100644 --- a/terminal/kiosk-shell/src/moonlight_session.cpp +++ b/terminal/kiosk-shell/src/moonlight_session.cpp @@ -1,17 +1,104 @@ #include "moonlight_session.h" #include -#include #include #include +#include #include +#include 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::of(&QProcess::finished), this, + &MoonlightSession::onProcessFinished); + connect(process_, &QProcess::errorOccurred, this, &MoonlightSession::onProcessError); + connect(process_, &QProcess::readyReadStandardOutput, this, + &MoonlightSession::onProcessOutput); + + readyTimer_ = new QTimer(this); + readyTimer_->setSingleShot(true); + connect(readyTimer_, &QTimer::timeout, this, [this]() { + if (!readyEventDelivered_ && !terminalEventDelivered_ && isRunning()) { + readyEventDelivered_ = true; + emitEvent(EventType::kStreamReady, QStringLiteral("串流画面已就绪")); + } + }); + + killTimer_ = new QTimer(this); + killTimer_->setSingleShot(true); + connect(killTimer_, &QTimer::timeout, this, [this]() { + if (isRunning()) { + process_->kill(); + } + }); +} MoonlightSession::~MoonlightSession() { - stop(); + callback_ = nullptr; + readyTimer_->stop(); + killTimer_->stop(); + if (isRunning()) { + process_->kill(); + process_->waitForFinished(1000); + } } void MoonlightSession::setOptions(const Options& options) { @@ -19,19 +106,55 @@ void MoonlightSession::setOptions(const Options& options) { } bool MoonlightSession::isRunning() const { - return process_ && process_->state() != QProcess::NotRunning; + return process_->state() != QProcess::NotRunning; } -bool MoonlightSession::start(const ConnectionTicket& ticket, StateCallback callback) { +qint64 MoonlightSession::processId() const { + return isRunning() ? process_->processId() : 0; +} + +bool MoonlightSession::activateStreamWindow() { + if (!isRunning()) { + return false; + } + + QProcess finder; + finder.start(QStringLiteral("xdotool"), + {QStringLiteral("search"), QStringLiteral("--onlyvisible"), + QStringLiteral("--pid"), QString::number(process_->processId()), + QStringLiteral("--class"), QStringLiteral("com.moonlight_stream.Moonlight")}); + if (!finder.waitForFinished(500) || finder.exitCode() != 0) { + return false; + } + const QString windowId = QString::fromUtf8(finder.readAllStandardOutput()) + .split(QLatin1Char('\n'), Qt::SkipEmptyParts) + .value(0).trimmed(); + if (windowId.isEmpty()) { + return false; + } + + return QProcess::execute(QStringLiteral("xdotool"), + {QStringLiteral("windowactivate"), QStringLiteral("--sync"), + windowId}) == 0; +} + +bool MoonlightSession::start(const ConnectionTicket& ticket, EventCallback callback) { if (isRunning()) { return false; } - callback_ = std::move(callback); - // moonlight-qt 需要 X11 会话环境;由 kiosk-shell 继承当前 DISPLAY/XAUTHORITY - process_ = new QProcess(this); + callback_ = std::move(callback); + stopRequested_ = false; + terminalEventDelivered_ = false; + readyEventDelivered_ = false; + outputParser_.reset(); + readyTimer_->stop(); + killTimer_->stop(); + process_->setProgram(QStringLiteral("moonlight-qt")); - process_->setProcessChannelMode(QProcess::MergedChannels); + QProcessEnvironment environment = QProcessEnvironment::systemEnvironment(); + environment.insert(QStringLiteral("GL_IS_SLOW"), QStringLiteral("0")); + process_->setProcessEnvironment(environment); QStringList args; args << QStringLiteral("stream") @@ -46,96 +169,109 @@ bool MoonlightSession::start(const ConnectionTicket& ticket, StateCallback callb << QStringLiteral("--video-decoder") << options_.decoder << QStringLiteral("--absolute-mouse") << QStringLiteral("--keep-awake"); - if (options_.vsync) { - args << QStringLiteral("--vsync"); - } else { - args << QStringLiteral("--no-vsync"); - } - if (options_.framePacing) { - args << QStringLiteral("--frame-pacing"); - } else { - args << QStringLiteral("--no-frame-pacing"); - } + args << (options_.vsync ? QStringLiteral("--vsync") : QStringLiteral("--no-vsync")); + args << (options_.framePacing ? QStringLiteral("--frame-pacing") + : QStringLiteral("--no-frame-pacing")); if (options_.performanceOverlay) { args << QStringLiteral("--performance-overlay"); } - if (options_.audioOnHost) { - args << QStringLiteral("--audio-on-host"); - } else { - args << QStringLiteral("--no-audio-on-host"); - } + args << (options_.audioOnHost ? QStringLiteral("--audio-on-host") + : QStringLiteral("--no-audio-on-host")); process_->setArguments(args); - // 日志追加到 moonlight-yyyyMMdd.log,便于现场排查 - const QString logDir = - QDir::homePath() + QStringLiteral("/vdi-terminal/logs"); + const QString logDir = QDir::homePath() + QStringLiteral("/vdi-terminal/logs"); QDir().mkpath(logDir); - const QString logPath = logDir + QStringLiteral("/moonlight-%1.log") - .arg(QDate::currentDate().toString(QStringLiteral("yyyyMMdd"))); + logPath_ = logDir + QStringLiteral("/moonlight-%1.log") + .arg(QDate::currentDate().toString(QStringLiteral("yyyyMMdd"))); - // QProcess::finished 在 Qt5 里有两个重载((int) 已废弃),必须用 QOverload 消歧 - connect(process_, - QOverload::of(&QProcess::finished), this, - &MoonlightSession::onProcessFinished); - connect(process_, &QProcess::errorOccurred, this, &MoonlightSession::onProcessError); - connect(process_, &QProcess::readyReadStandardOutput, this, [this, logPath]() { - QFile log(logPath); - if (log.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text)) { - QTextStream(&log) << QString::fromUtf8(process_->readAllStandardOutput()); - } - }); - - if (callback_) { - callback_(true, QStringLiteral("串流进程已启动")); - } process_->start(); return true; } +void MoonlightSession::onProcessStarted() { + emitEvent(EventType::kStarted, QStringLiteral("串流进程已启动")); +} + void MoonlightSession::onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus) { - QString message; - if (exitStatus == QProcess::CrashExit) { - message = QStringLiteral("串流异常退出 (code=%1)").arg(exitCode); - } else { - message = QStringLiteral("串流已结束 (code=%1)").arg(exitCode); - } - process_->deleteLater(); - process_ = nullptr; - if (callback_) { - callback_(false, message); + readyTimer_->stop(); + killTimer_->stop(); + + if (stopRequested_) { + emitTerminalEvent(EventType::kFinished, QStringLiteral("串流已结束")); + return; } + + const QString message = exitStatus == QProcess::CrashExit + ? QStringLiteral("串流异常退出 (code=%1)").arg(exitCode) + : QStringLiteral("串流已结束 (code=%1)").arg(exitCode); + emitTerminalEvent(exitStatus == QProcess::CrashExit ? EventType::kFailed + : EventType::kFinished, + message); } void MoonlightSession::onProcessError(QProcess::ProcessError error) { - QString message; - switch (error) { - case QProcess::FailedToStart: - message = QStringLiteral("无法启动 moonlight-qt,请检查安装"); - break; - case QProcess::Crashed: - return; // 由 finished(CrashExit) 统一上报 - default: - message = QStringLiteral("串流进程错误 (%1)").arg(static_cast(error)); - break; + if (error == QProcess::Crashed) { + return; } - if (callback_) { - callback_(false, message); + + const QString message = error == QProcess::FailedToStart + ? QStringLiteral("无法启动 moonlight-qt,请检查安装") + : QStringLiteral("串流进程错误 (%1)").arg(static_cast(error)); + emitTerminalEvent(EventType::kFailed, message); +} + +void MoonlightSession::onProcessOutput() { + const QByteArray output = process_->readAllStandardOutput(); + if (output.isEmpty()) { + return; + } + + QFile log(logPath_); + if (log.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text)) { + log.write(output); + } + + const bool wasReady = outputParser_.isReady(); + outputParser_.append(output); + if (outputParser_.hasTerminalError()) { + readyTimer_->stop(); + emitTerminalEvent(EventType::kFailed, QStringLiteral("云桌面连接已中断")); + stop(); + return; + } + if (!wasReady && outputParser_.isReady()) { + scheduleReadyEvent(); } } void MoonlightSession::stop() { - if (!process_) { + if (!isRunning()) { return; } - QProcess* proc = process_; - process_ = nullptr; // 先断开自身引用,避免 finished 回调再触发状态迁移 - disconnect(proc, nullptr, this, nullptr); - proc->terminate(); - if (!proc->waitForFinished(2000)) { - proc->kill(); - proc->waitForFinished(1000); + stopRequested_ = true; + readyTimer_->stop(); + process_->terminate(); + killTimer_->start(kTerminateGraceMs); +} + +void MoonlightSession::emitEvent(EventType type, const QString& message) { + if (callback_) { + callback_(Event{type, message}); + } +} + +void MoonlightSession::emitTerminalEvent(EventType type, const QString& message) { + if (terminalEventDelivered_) { + return; + } + terminalEventDelivered_ = true; + emitEvent(type, message); +} + +void MoonlightSession::scheduleReadyEvent() { + if (!readyEventDelivered_ && !terminalEventDelivered_ && isRunning()) { + readyTimer_->start(kReadyStableMs); } - proc->deleteLater(); } } // namespace vdi diff --git a/terminal/kiosk-shell/src/moonlight_session.h b/terminal/kiosk-shell/src/moonlight_session.h index f49288e..d9f29d1 100644 --- a/terminal/kiosk-shell/src/moonlight_session.h +++ b/terminal/kiosk-shell/src/moonlight_session.h @@ -1,57 +1,96 @@ // Moonlight 会话(外部进程) // -// 以 QProcess 参数数组直启 moonlight-qt(不拼 shell 命令), -// 串流期间 moonlight-qt 独占全屏显示;进程退出后回调 MainWindow 返回桌面列表。 -// 参数来自 ConnectionTicket,不再读取全局静态配置。 +// 以 QProcess 参数数组直启 moonlight-qt(不拼 shell 命令)。 +// 进程启动、串流就绪和终止是不同事件,调用方据此控制过场和状态机。 #ifndef KIOSK_SHELL_MOONLIGHT_SESSION_H #define KIOSK_SHELL_MOONLIGHT_SESSION_H +#include #include #include #include #include "desktop_session.h" +class QTimer; + namespace vdi { +class MoonlightOutputParser { +public: + void reset(); + void append(const QByteArray& output); + bool isReady() const; + bool hasTestDecode() const { return hasTestDecode_; } + bool hasRenderer() const { return hasRenderer_; } + bool hasTerminalError() const { return hasTerminalError_; } + +private: + QByteArray buffer_; + bool hasTestDecode_ = false; + bool hasRenderer_ = false; + bool hasTerminalError_ = false; +}; + class MoonlightSession : public QObject { Q_OBJECT public: struct Options { - // 低延迟开关(实测 A/B 后定版) bool vsync = true; bool framePacing = true; bool performanceOverlay = false; bool audioOnHost = true; QString videoCodec = QStringLiteral("H.264"); - QString decoder = QStringLiteral("software"); // RK3528 无可用 V4L2 硬解设备 + QString decoder = QStringLiteral("software"); }; - using StateCallback = std::function; + enum class EventType { + kStarted, + kStreamReady, + kFailed, + kFinished, + }; + + struct Event { + EventType type; + QString message; + }; + + using EventCallback = std::function; explicit MoonlightSession(QObject* parent = nullptr); ~MoonlightSession() override; void setOptions(const Options& options); - - // 启动串流;成功返回 true(不代表串流已建立,仅代表进程已拉起) - bool start(const ConnectionTicket& ticket, StateCallback callback); - - // 终止串流(管理菜单/退出登录时调用) + bool start(const ConnectionTicket& ticket, EventCallback callback); void stop(); - bool isRunning() const; + qint64 processId() const; + bool activateStreamWindow(); private slots: + void onProcessStarted(); void onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus); void onProcessError(QProcess::ProcessError error); + void onProcessOutput(); private: + void emitEvent(EventType type, const QString& message); + void emitTerminalEvent(EventType type, const QString& message); + void scheduleReadyEvent(); + QProcess* process_ = nullptr; + QTimer* readyTimer_ = nullptr; + QTimer* killTimer_ = nullptr; Options options_; - StateCallback callback_; + EventCallback callback_; + MoonlightOutputParser outputParser_; + QString logPath_; + bool stopRequested_ = false; + bool terminalEventDelivered_ = false; + bool readyEventDelivered_ = false; }; } // namespace vdi diff --git a/terminal/kiosk-shell/src/rdp_session.cpp b/terminal/kiosk-shell/src/rdp_session.cpp deleted file mode 100644 index 725919a..0000000 --- a/terminal/kiosk-shell/src/rdp_session.cpp +++ /dev/null @@ -1,368 +0,0 @@ -#include - -#include "rdp_session.h" - -#include -#include -#include -#include - -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 lock(mutex()); - table()[instance] = session; - } - static void remove(freerdp* instance) { - std::lock_guard lock(mutex()); - table().erase(instance); - } - static RdpSession* get(freerdp* instance) { - if (!instance) return nullptr; - std::lock_guard 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& table() { - static std::map 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 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 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(options_.width)); - freerdp_settings_set_uint32(settings, FreeRDP_DesktopHeight, - static_cast(options_.height)); - freerdp_settings_set_uint32(settings, FreeRDP_ColorDepth, static_cast(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(gdi->width); - frameHeight_ = static_cast(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(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 diff --git a/terminal/kiosk-shell/src/rdp_session.h b/terminal/kiosk-shell/src/rdp_session.h deleted file mode 100644 index 894fc9b..0000000 --- a/terminal/kiosk-shell/src/rdp_session.h +++ /dev/null @@ -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 = ; // 内部结构,公开 -// 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 -#include -#include -#include -#include - -#include -#include -#include -#include - -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 - onFrame; - // 会话状态变化 - std::function 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 running_{false}; - std::atomic 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 diff --git a/terminal/kiosk-shell/src/state_machine.h b/terminal/kiosk-shell/src/state_machine.h index 9c8d1ba..c1969dc 100644 --- a/terminal/kiosk-shell/src/state_machine.h +++ b/terminal/kiosk-shell/src/state_machine.h @@ -1,14 +1,14 @@ // kiosk-shell 状态机 // // 对应《VDI 云桌面软件开发方案》3.2 节: -// 状态机:BOOT -> CONFIG(无配置)-> CONNECTING -> SESSION(嵌入 freerdp 窗口) +// 状态机:BOOT -> CONFIG/LOGIN -> CONNECTING -> SESSION(Moonlight 串流) // -> RECONNECT -> ERROR // // 状态转换规则: // BOOT 进程启动,读取本地配置 // CONFIG 无可用配置或用户停留在登录页,等待输入 -// CONNECTING 正在建立 RDP 连接 -// SESSION 远程会话进行中 +// CONNECTING 正在建立 Moonlight 串流连接 +// SESSION 远程会话进行中(moonlight-qt 独占全屏) // RECONNECT 会话意外断开,等待重连 // ERROR 不可恢复错误,提示后回到 CONFIG diff --git a/terminal/kiosk-shell/src/transition_overlay.cpp b/terminal/kiosk-shell/src/transition_overlay.cpp new file mode 100644 index 0000000..e2694de --- /dev/null +++ b/terminal/kiosk-shell/src/transition_overlay.cpp @@ -0,0 +1,128 @@ +#include "transition_overlay.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +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 diff --git a/terminal/kiosk-shell/src/transition_overlay.h b/terminal/kiosk-shell/src/transition_overlay.h new file mode 100644 index 0000000..5a8ea84 --- /dev/null +++ b/terminal/kiosk-shell/src/transition_overlay.h @@ -0,0 +1,49 @@ +// 会话切换过场层 +// +// 用途:用户点击"连接"后,Moonlight 客户端会先在屏幕左上角创建一个小窗口, +// 再扩展到全屏并解码首帧,这段时间画面闪烁、体验割裂。 +// 本控件是覆盖在 kiosk 全屏窗口之上的不透明过场层(置顶), +// 盖住这个过程,直到收到首帧信号后淡出,让用户感知为"一次平滑的转场"。 + +#ifndef KIOSK_SHELL_TRANSITION_OVERLAY_H +#define KIOSK_SHELL_TRANSITION_OVERLAY_H + +#include + +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 diff --git a/terminal/kiosk-shell/tests/test_desktop_list_page.cpp b/terminal/kiosk-shell/tests/test_desktop_list_page.cpp new file mode 100644 index 0000000..574b15c --- /dev/null +++ b/terminal/kiosk-shell/tests/test_desktop_list_page.cpp @@ -0,0 +1,53 @@ +#include +#include +#include + +#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(); + 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" diff --git a/terminal/kiosk-shell/tests/test_moonlight_session.cpp b/terminal/kiosk-shell/tests/test_moonlight_session.cpp new file mode 100644 index 0000000..498d936 --- /dev/null +++ b/terminal/kiosk-shell/tests/test_moonlight_session.cpp @@ -0,0 +1,94 @@ +#include + +#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" diff --git a/tools/deploy_kiosk.sh b/tools/deploy_kiosk.sh index 7168b4c..c3f355e 100644 --- a/tools/deploy_kiosk.sh +++ b/tools/deploy_kiosk.sh @@ -15,25 +15,27 @@ echo "==> 检查盒子连通性" ssh -o ConnectTimeout=8 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'echo "box ok: $(hostname)"' echo "==> 同步源码到盒子" -ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'rm -rf ~/vdi-build/repo/kiosk-shell/src && mkdir -p ~/vdi-build/repo/kiosk-shell/src' +ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'rm -rf ~/vdi-build/repo/kiosk-shell/src ~/vdi-build/repo/kiosk-shell/tests && mkdir -p ~/vdi-build/repo/kiosk-shell/src ~/vdi-build/repo/kiosk-shell/tests' scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/CMakeLists.txt" "$BOX":~/vdi-build/repo/kiosk-shell/ scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC"/src/*.cpp "$SRC"/src/*.h "$BOX":~/vdi-build/repo/kiosk-shell/src/ +scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC"/tests/*.cpp "$BOX":~/vdi-build/repo/kiosk-shell/tests/ scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/scripts/vdi-moonlight" "$BOX":~/vdi-terminal/bin/vdi-moonlight -scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/etc/moonlight.conf" "$BOX":~/.config/vdi-terminal/moonlight.conf +scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/etc/moonlight.conf" "$BOX":/tmp/vdi-moonlight.conf +ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'mkdir -p ~/.config/vdi-terminal; test -f ~/.config/vdi-terminal/moonlight.conf || cp /tmp/vdi-moonlight.conf ~/.config/vdi-terminal/moonlight.conf; rm -f /tmp/vdi-moonlight.conf' scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/vdi-kiosk.desktop" "$BOX":~/.config/autostart/vdi-kiosk.desktop scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/vdi-moonlight.desktop" "$BOX":~/.config/autostart/vdi-moonlight.desktop echo "==> 在盒子上编译" -ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'sed -i "s/\r$//" ~/vdi-build/repo/kiosk-shell/src/*.cpp ~/vdi-build/repo/kiosk-shell/src/*.h ~/vdi-terminal/bin/vdi-moonlight; chmod +x ~/vdi-terminal/bin/vdi-moonlight; cd ~/vdi-build/repo/kiosk-shell && (cmake -G Ninja -B build -DCMAKE_BUILD_TYPE=Release > /tmp/cmake.log 2>&1 || (tail -30 /tmp/cmake.log; exit 1)) && cmake --build build 2>&1 | tail -8' +ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'sed -i "s/\r$//" ~/vdi-build/repo/kiosk-shell/src/*.cpp ~/vdi-build/repo/kiosk-shell/src/*.h ~/vdi-build/repo/kiosk-shell/tests/*.cpp ~/vdi-terminal/bin/vdi-moonlight; chmod +x ~/vdi-terminal/bin/vdi-moonlight; cd ~/vdi-build/repo/kiosk-shell && (cmake -G Ninja -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON > /tmp/cmake.log 2>&1 || (tail -30 /tmp/cmake.log; exit 1)) && cmake --build build && QT_QPA_PLATFORM=offscreen ctest --test-dir build --output-on-failure' echo "==> 安装二进制" -ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'pkill -f "/vdi-kiosk-monitor " 2>/dev/null || true; pkill -f "/bin/sh /home/armbian/vdi-terminal/bin/vdi-moonlight" 2>/dev/null || true; pkill -f "^moonlight-qt stream" 2>/dev/null || true; sleep 2; cp ~/vdi-build/repo/kiosk-shell/build/kiosk-shell ~/vdi-terminal/bin/kiosk-shell && cp ~/vdi-build/repo/kiosk-shell/build/vdi-kiosk-monitor ~/vdi-terminal/bin/vdi-kiosk-monitor && chmod +x ~/vdi-terminal/bin/kiosk-shell ~/vdi-terminal/bin/vdi-kiosk-monitor && ls -la ~/vdi-terminal/bin/' +ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'monitor_pids=$(pgrep -f "^/home/armbian/vdi-terminal/bin/vdi-kiosk-monitor -- " 2>/dev/null || true); test -z "$monitor_pids" || kill $monitor_pids 2>/dev/null || true; pair_pids=$(pgrep -f "^bash -c tail -f .*(pin|pair)_fifo" 2>/dev/null || true); test -z "$pair_pids" || kill $pair_pids 2>/dev/null || true; tail_pids=$(pgrep -f "^tail -f .*(pin|pair)_fifo" 2>/dev/null || true); test -z "$tail_pids" || kill $tail_pids 2>/dev/null || true; rm -f ~/pair_fifo /tmp/pin_fifo; for name in vdi-moonlight moonlight-qt kiosk-shell; do pids=$(pidof "$name" 2>/dev/null || true); test -z "$pids" || kill $pids 2>/dev/null || true; done; for i in $(seq 1 40); do pgrep -f "^/home/armbian/vdi-terminal/bin/vdi-kiosk-monitor -- " >/dev/null 2>&1 || break; sleep 0.2; done; monitor_pids=$(pgrep -f "^/home/armbian/vdi-terminal/bin/vdi-kiosk-monitor -- " 2>/dev/null || true); test -z "$monitor_pids" || kill -9 $monitor_pids 2>/dev/null || true; cp ~/vdi-build/repo/kiosk-shell/build/kiosk-shell ~/vdi-terminal/bin/kiosk-shell && cp ~/vdi-build/repo/kiosk-shell/build/vdi-kiosk-monitor ~/vdi-terminal/bin/vdi-kiosk-monitor && chmod +x ~/vdi-terminal/bin/kiosk-shell ~/vdi-terminal/bin/vdi-kiosk-monitor && ls -la ~/vdi-terminal/bin/' echo "==> 收敛自启项(只保留 kiosk-shell 一条链)" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'mkdir -p ~/.config/autostart; rm -f ~/.config/autostart/vdi-kiosk.desktop.bak; sed -i "s/^Hidden=.*/Hidden=true/;s/^X-GNOME-Autostart-enabled=.*/X-GNOME-Autostart-enabled=false/" ~/.config/autostart/vdi-moonlight.desktop 2>/dev/null || true; grep -H . ~/.config/autostart/*.desktop' echo "==> 启动受管栈并验证" -ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'rm -f /tmp/vdi-kiosk.heartbeat; nohup /home/armbian/vdi-terminal/bin/vdi-kiosk-monitor -- /home/armbian/vdi-terminal/bin/kiosk-shell > /tmp/vdi-monitor.log 2>&1 & sleep 8; echo "--- 进程 ---"; pgrep -af "vdi-kiosk-monitor|kiosk-shell|moonlight-qt" | grep -v pgrep; echo "--- 看门狗日志 ---"; tail -5 /tmp/vdi-monitor.log; echo "--- 心跳 ---"; cat /tmp/vdi-kiosk.heartbeat 2>/dev/null; echo' +ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'rm -f /tmp/vdi-kiosk.heartbeat; nohup env DISPLAY=:0 XAUTHORITY=/home/armbian/.Xauthority /home/armbian/vdi-terminal/bin/vdi-kiosk-monitor -- /home/armbian/vdi-terminal/bin/kiosk-shell > /tmp/vdi-monitor.log 2>&1 & sleep 8; echo "--- 进程 ---"; pgrep -af "vdi-kiosk-monitor|kiosk-shell|moonlight-qt" | grep -v pgrep; echo "--- 看门狗日志 ---"; tail -5 /tmp/vdi-monitor.log; echo "--- 心跳 ---"; cat /tmp/vdi-kiosk.heartbeat 2>/dev/null; echo' -echo "==> 抓屏确认当前界面(应为登录页)" +echo "==> 抓屏确认当前界面(应为桌面列表)" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'DISPLAY=:0 xwd -root -silent' 2>/dev/null | python "$REPO/tools/xwd2png.py" /tmp/kiosk-login.png && echo "captured /tmp/kiosk-login.png"