From 9f1824ff21daa41d6092cf27283e1a6f48e5c129 Mon Sep 17 00:00:00 2001 From: songyiming <1000000008@qq.com> Date: Thu, 29 Oct 2026 17:37:38 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A=E5=88=A0=E9=99=A4?= =?UTF-8?q?=20RDP=20=E6=B8=B2=E6=9F=93=E7=AE=A1=E7=BA=BF=EF=BC=8C=E5=8D=8F?= =?UTF-8?q?=E8=AE=AE=E7=BB=9F=E4=B8=80=20Moonlight=EF=BC=9B=E9=80=80?= =?UTF-8?q?=E5=87=BA=E4=B8=89=E5=B1=82=E8=AE=BE=E8=AE=A1=E4=B8=8E=E4=B8=B2?= =?UTF-8?q?=E6=B5=81=E8=BF=87=E5=9C=BA=E5=B1=82=E8=90=BD=E5=9C=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 rdp_session.*、FreeRDP 构建依赖与代码内协议概念,Moonlight 为唯一会话通道 - Ctrl+Alt+Shift+Q 退串流回列表(kiosk 兜底 + moonlight-qt 主路径);登录页去退出按钮 - 新增 TransitionOverlay:全屏过场层盖住串流启动闪烁,进程 CPU 首帧检测后淡出 - Sunshine 写入 sw_preset=ultrafast;实测推翻 x264 瓶颈结论(21.6FPS 帽子在虚拟显示/捕获节奏,CPU≈0%) - 文档:麒麟(101/.71)/Ubuntu(102/.72) 接入信息与四件事落地记录;MEMORY 根因修正 --- MEMORY.md | 25 +- docs/moonlight-kiosk-20261028.md | 56 +++ terminal/kiosk-shell/CMakeLists.txt | 9 +- .../kiosk-shell/src/desktop_list_page.cpp | 5 +- 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 | 416 +++--------------- terminal/kiosk-shell/src/main_window.h | 44 +- .../kiosk-shell/src/moonlight_session.cpp | 4 + terminal/kiosk-shell/src/moonlight_session.h | 3 + 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 | 131 ++++++ terminal/kiosk-shell/src/transition_overlay.h | 49 +++ tools/syntax_sanity.py | 6 + 21 files changed, 364 insertions(+), 923 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 diff --git a/MEMORY.md b/MEMORY.md index 75e0740..3335035 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -6,13 +6,14 @@ - 项目目录:`D:\DAIMA\vdi-cloud` - 终端盒子:RK3528 PRO DDR4,Armbian Ubuntu Noble,aarch64,IP `192.168.1.188` - 当前图形:LightDM + Xorg;正式文档要求 Weston + Wayland + Qt,当前只是 POC -- Proxmox:9.2.2,Windows 10 22H2 VM `192.168.1.70:3389`,用户 `test1` -- FreeRDP 3.32.1 已安装,命令 `xfreerdp3` -- `kiosk-shell` 已按 Qt/C++ + libfreerdp3 内嵌实现,已真实显示 Windows 10 远程桌面 -- **当前开机默认走 Moonlight 云桌面**(2026-10-28 起): - `vdi-kiosk-monitor -- vdi-moonlight` → `moonlight-qt stream 192.168.1.70 Desktop`, - 满屏 1920x1080 与屏幕 1:1,崩溃/被杀均自动恢复。 - RDP 路径保留但自启已禁用,可切回。详见 `docs/moonlight-kiosk-20261028.md` +- Proxmox:9.2.2 节点 `starhe`。VM 100 win10-test `192.168.1.70`(Sunshine/Moonlight 云桌面); + VM 101 kylin-test `192.168.1.71`(test1)、VM 102 ubuntu-test `192.168.1.72`(test2), + 密码均 `yx66668888`,SSH 可登、图形会话在跑,**尚未装 Sunshine**,暂不能串流 +- `kiosk-shell`(Qt/C++):登录页 → 桌面列表 → Moonlight 会话;**RDP 代码已于 2026-10-29 + 全部删除**(git 历史可查),Moonlight 是唯一会话通道,无 freerdp 构建依赖 +- 新增退出三层与会话过场层:`Ctrl+Alt+Shift+Q` 退串流回列表;列表页"退出登录"; + 登录页无退出按钮(退出终端只在管理菜单);点连接有全屏过场层,首帧后淡出。 + 详见 `docs/moonlight-kiosk-20261028.md` 2026-10-29 下午节 - 代码位置:`terminal/kiosk-shell` - 远程安装路径:`~/vdi-terminal/bin/`(`kiosk-shell`、`vdi-kiosk-monitor`、`vdi-moonlight`) @@ -20,7 +21,7 @@ - 所有实现严格参考用户提供的两个正式 Word 和会议纪要;文档未确定的技术只能标记待调研,不得擅自定版。 - 终端先做,后接自研管理平台 API;终端不直接调用 Proxmox API。 - 正式状态机:`BOOT -> CONFIG -> CONNECTING -> SESSION -> RECONNECT -> ERROR` -- 正式 Kiosk 选型:Weston + Wayland + Qt;FreeRDP 以 libfreerdp3 API 内嵌,不拉外部进程。 +- 正式 Kiosk 选型:Weston + Wayland + Qt;会话通道统一 Moonlight(RDP 已删,勿在代码里复活协议分支)。 - `/cert:ignore`、空密码仅限局域网 POC。 - 项目经理新增待调研:RDP 是否适合 GPU/CAD/3D;研究 Moonlight/Sunshine,但不要在调研前删除 RDP、确定 Moonlight 参数或定版双协议。 @@ -34,8 +35,12 @@ 可达性判断必须用 TCP 探测;`moonlight-qt` 的 app id 会随服务端重装失效, 命令行要用应用名 `Desktop`;RK3528 上 `--video-decoder auto` 会选到不可用的 `h264_v4l2m2m`,必须显式 `software`。 -- 服务端软编码(Sunshine libx264,无 GPU)是 1080p 只有 ~21.6 FPS、抖动丢帧 13.64% 的根因; - 客户端解码 15 ms、网络丢包 0%、延迟 3 ms,链路健康。 +- **21.6 FPS 上限根因(2026-10-29 修正)**:不是 x264 编码算力——ultrafast 前后帧率分毫不差, + 串流中 VM CPU 仅 0.5%、sunshine 累计 14 秒 CPU/3 分钟会话,鼠标运动也不变; + 真正瓶颈在虚拟显示/捕获节奏(MttVDD 呈现 + Desktop Duplication 脏帧 + QEMU 显示栈, + VM CPU 型号 `QEMU Virtual CPU version 2.5+` 未透传宿主 AVX2)。 + 根治 = GPU 直通(NVENC + 真实显示栈);备选 = VirtIO-GPU / cpu=host 排除 QEMU 显示栈因素。 + 客户端解码 10~15 ms、网络丢包 0%、延迟 3 ms,链路健康。 ## Web 控制台(2026-10-28 方案确认) - `console/` 是管理员 Web 管理控制台,不是 RK3528 盒子的用户登录页。 diff --git a/docs/moonlight-kiosk-20261028.md b/docs/moonlight-kiosk-20261028.md index bd2c0c0..9e7113f 100644 --- a/docs/moonlight-kiosk-20261028.md +++ b/docs/moonlight-kiosk-20261028.md @@ -194,3 +194,59 @@ Moonlight 两次失败(38 秒断流、第二次连不上)均为该丢包所 决定:保持 vsync + frame-pacing 开启(C++ 默认), `etc/moonlight.conf` 默认值已同步改回 `1`。 鼠标卡顿的根治手段仍是服务端 GPU 直通 + NVENC。 + +--- + +# 2026-10-29 下午追加:四件事落地(删 RDP / 退出设计 / 过场层 / ultrafast 实测) + +## 已交付(全部编译部署到盒子并实测) + +1. **删 RDP**:`rdp_session.*`、FreeRDP 渲染管线、CMake 构建依赖全部移除, + 协议概念(`Protocol` 枚举)从代码删除,Moonlight 是唯一会话通道; + RDP 代码只存在于 git 历史(网关备用)。部署后 `ldd kiosk-shell` 无任何 freerdp 依赖。 +2. **退出设计三层**: + - 会话内 `Ctrl+Alt+Shift+Q` 退出串流回桌面列表——moonlight-qt 自带快捷键为主路径, + kiosk 侧保留同一快捷键兜底(串流未建立/焦点仍在 kiosk 时生效); + - 桌面列表右上角"退出登录"→ 登录页(已有); + - 登录页删除"退出"按钮(退出终端是维护功能,只留在管理菜单 Ctrl+Alt+Shift+M)。 +3. **加载过场层**:新增 `src/transition_overlay.*`,点连接后全屏置顶黑层 + ("正在进入云桌面…"动态省略号 + 旋轮),盖住 moonlight-qt 启动期左上角小窗闪烁; + 首帧检测用 moonlight 进程 CPU 时间近似(软解 CPU 远高于握手期,阈值 120 tick / 15s 兜底), + 检测到后 1 秒淡出。活动期间吞键盘鼠标、周期 raise 保持置顶。 +4. **Sunshine x264 预设 ultrafast**:VM 100 的 `C:\Program Files\Sunshine\config\sunshine.conf` + 写入 `sw_preset = ultrafast`(该版本支持 `sw_preset`/`sw_tune` 键),服务已重启生效。 + +## 关键实测结论:21.6 FPS 帽子不在 x264,旧根因判定被推翻 + +ultrafast 前后对照(1080p60 / H.264 / 盒子软解,同参数同网络): + +| 指标 | medium(基线 10-28) | ultrafast(本次) | +|---|---|---| +| Video stream FPS | 21.59~21.64 | 21.57~21.61 | +| 抖动丢帧 | 13.64%~15.91% | 13.64%(分毫不差) | +| 客户端解码 | 15.04 ms | 10.51~11.14 ms | + +决定性证据: + +- 串流进行中 VM 内 `Processor(_Total)` CPU 仅 0.2%~0.5%, + sunshine 进程自服务重启起累计 CPU 时间仅 14 秒(含三次共约 3 分钟会话); +- 用 xdotool 持续晃鼠标制造画面运动,帧率与 CPU 都不变; +- libx264 只有 `MMX2 SSE2Fast SSSE3 SSE4.2`——VM CPU 型号是 + `QEMU Virtual CPU version 2.5+`(kvm64 类),宿主指令集(含 AVX2)没有透传。 + +结论:**21.6 FPS 的上限来自虚拟显示/捕获节奏(IddSampleDriver/MttVDD 呈现与 +Desktop Duplication 的脏帧节奏,叠加 QEMU 显示栈),不是编码器算力**—— +编码器近乎空转,预设怎么调都不会变。ultrafast 保留(无害,客户端解码也略降)。 +根治路径按优先级:GPU 直通(NVENC + 真实显示栈)> 换 VM 显示方案 +(VirtIO-GPU / cpu=host 先排除 QEMU 显示栈因素)> 都不是调 Sunshine 参数能解决的。 + +## 运维新装 Linux 云桌面 VM(2026-10-29 接入信息) + +| VM | Proxmox | 地址 | 账号 | 实测 | +|---|---|---|---|---| +| kylin-test | 101 | 192.168.1.71 | test1 / yx66668888 | SSH 可登,银河麒麟 V10 SP1 桌面版,UKUI + Xorg,图形会话运行中 | +| ubuntu-test | 102 | 192.168.1.72 | test2 / yx66668888 | SSH 可登,Ubuntu 24.04.4 LTS,GNOME + Xorg,图形会话运行中(4 核 / 5.8GiB) | + +两台均未装 Sunshine(47989 等端口关闭),还不能被 Moonlight 连接。 +接入桌面列表的步骤:装 Sunshine(Linux 版)→ 配对 → 在盒子"桌面连接设置"里 +把 Ubuntu/麒麟卡片的地址填上即可(kiosk 代码已支持,无需改代码)。 diff --git a/terminal/kiosk-shell/CMakeLists.txt b/terminal/kiosk-shell/CMakeLists.txt index 838a2ba..8c21fb2 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) @@ -15,10 +15,6 @@ endif() # ---- Qt5 ---- find_package(Qt5 REQUIRED COMPONENTS Core Gui Widgets) -# ---- FreeRDP 3 ---- -find_package(PkgConfig REQUIRED) -pkg_check_modules(FREERDP REQUIRED IMPORTED_TARGET freerdp3) - add_executable(kiosk-shell src/main.cpp src/main_window.cpp @@ -28,9 +24,9 @@ add_executable(kiosk-shell src/desktop_service.cpp src/desktop_store.cpp src/moonlight_session.cpp + src/transition_overlay.cpp src/state_machine.cpp src/config.cpp - src/rdp_session.cpp ) target_include_directories(kiosk-shell PRIVATE @@ -41,7 +37,6 @@ target_link_libraries(kiosk-shell PRIVATE Qt5::Core Qt5::Gui Qt5::Widgets - PkgConfig::FREERDP ) # 看门狗 Monitor:独立进程,监控主进程假死/崩溃 diff --git a/terminal/kiosk-shell/src/desktop_list_page.cpp b/terminal/kiosk-shell/src/desktop_list_page.cpp index d9092a3..2d1b2c0 100644 --- a/terminal/kiosk-shell/src/desktop_list_page.cpp +++ b/terminal/kiosk-shell/src/desktop_list_page.cpp @@ -110,13 +110,12 @@ void DesktopListPage::rebuildCards() { textCol->addWidget(osLine); row->addLayout(textCol, 1); - // 状态/协议标签 + // 状态标签(会话通道统一 Moonlight,无需按桌面标注协议) QString stateText; QString stateColor; switch (d.state) { case DesktopState::kAvailable: - stateText = QStringLiteral("%1 · 可连接").arg( - QString::fromLatin1(protocolToString(d.protocol)).toUpper()); + stateText = QStringLiteral("可连接"); stateColor = QStringLiteral("#3fb950"); break; case DesktopState::kPending: 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..213b881 100644 --- a/terminal/kiosk-shell/src/main_window.cpp +++ b/terminal/kiosk-shell/src/main_window.cpp @@ -2,77 +2,27 @@ #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; +// 首帧检测:moonlight-qt 是独立进程,kiosk 拿不到解码回调。 +// 软解 1080p60 的 CPU 占用远高于启动握手期,因此轮询进程 CPU 时间, +// 累计超过阈值即认为串流已出画;超时则强制淡出兜底(时钟频率 100 tick/秒)。 +constexpr int kFirstFramePollMs = 500; +constexpr qint64 kFirstFrameCpuTicks = 120; +constexpr qint64 kFirstFrameTimeoutMs = 15000; } // namespace @@ -83,12 +33,10 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { setupUi(); setupHotkey(); setupHeartbeat(); - keyboardLayout_ = freerdp_keyboard_init(0); config_ = loadConfig(); - // 开机永远进入登录页;不再根据本地配置自动连接 RDP, - // 会话只能由用户在桌面列表选择后启动。 + // 开机永远进入登录页;会话只能由用户在桌面列表选择后启动。 fsm_.transitionTo(State::kConfig, QStringLiteral("等待登录")); showLogin(); } @@ -120,7 +68,6 @@ void MainWindow::setupUi() { stack_->addWidget(login); connect(login, &LoginPage::loginRequested, this, &MainWindow::onLoginRequested); - connect(login, &LoginPage::quitRequested, this, &MainWindow::onQuitRequested); // 桌面列表页 auto* list = new DesktopListPage(this); @@ -136,64 +83,44 @@ void MainWindow::setupUi() { connect(list, &DesktopListPage::editRequested, this, &MainWindow::onEditDesktopRequested); connect(list, &DesktopListPage::logoutRequested, this, &MainWindow::onLogoutRequested); - // 会话视图 - auto* sessionWidget = new QWidget(this); - auto* sessionLayout = new QVBoxLayout(sessionWidget); - sessionLayout->setContentsMargins(0, 0, 0, 0); - - sessionView_ = new QLabel(sessionWidget); - sessionView_->setFocusPolicy(Qt::StrongFocus); - sessionView_->setMouseTracking(true); - sessionView_->installEventFilter(this); - sessionView_->setAlignment(Qt::AlignCenter); - sessionView_->setStyleSheet(QStringLiteral("background: #000000; color: #e6e9ef;")); - sessionView_->setText(QStringLiteral("正在建立远程会话…")); - - sessionOverlay_ = new QLabel(sessionWidget); - sessionOverlay_->setAlignment(Qt::AlignCenter); - sessionOverlay_->setStyleSheet( - QStringLiteral("background: rgba(15,20,32,0.82); color: #e6e9ef; " - "font-size: 20px; padding: 40px;")); - sessionOverlay_->setText(QStringLiteral("正在连接…")); - sessionOverlay_->hide(); - - sessionLayout->addWidget(sessionView_); - sessionLayout->addWidget(sessionOverlay_); - stack_->addWidget(sessionWidget); + // 过场层:独立置顶窗口,盖住 moonlight-qt 启动期的小窗口闪烁与分辨率切换, + // 首帧检测到后淡出。不属于 stack_(串流期间整个 kiosk 都被 moonlight 覆盖)。 + overlay_ = new TransitionOverlay(this); // 状态条 statusBar_ = new QStatusBar(this); statusBar_->setStyleSheet(QStringLiteral("QStatusBar { background: #161d2e; color: #8a94a6; }")); setStatusBar(statusBar_); - // RDP 重连定时器 - reconnectTimer_ = new QTimer(this); - reconnectTimer_->setSingleShot(true); - connect(reconnectTimer_, &QTimer::timeout, this, [this]() { - if (fsm_.current() == State::kReconnect && reconnectAttempts_ < kMaxReconnectAttempts) { - ++reconnectAttempts_; - if (activeTicket_.protocol == Protocol::kMoonlight) { - startMoonlightSession(activeTicket_); - } else { - startRdpSession(activeTicket_); - } - } else if (fsm_.current() == State::kReconnect) { - stopSessions(); - fsm_.transitionTo(State::kDesktopList, QStringLiteral("重连失败,已返回桌面列表")); - } - }); - - frameRefreshTimer_ = new QTimer(this); - frameRefreshTimer_->setInterval(33); - connect(frameRefreshTimer_, &QTimer::timeout, this, &MainWindow::onFrameRefresh); - frameRefreshTimer_->start(); + // 串流首帧检测 + firstFrameTimer_ = new QTimer(this); + firstFrameTimer_->setInterval(kFirstFramePollMs); + connect(firstFrameTimer_, &QTimer::timeout, this, &MainWindow::watchFirstFrame); } void MainWindow::setupHotkey() { // 全局热键 Ctrl+Alt+Shift+M 呼出管理菜单(方案 3.2 节) - auto* shortcut = new QShortcut(QKeySequence(QStringLiteral("Ctrl+Alt+Shift+M")), this); - shortcut->setContext(Qt::ApplicationShortcut); - connect(shortcut, &QShortcut::activated, this, [this]() { onMenuAction(0); }); + auto* menuShortcut = new QShortcut(QKeySequence(QStringLiteral("Ctrl+Alt+Shift+M")), this); + menuShortcut->setContext(Qt::ApplicationShortcut); + connect(menuShortcut, &QShortcut::activated, this, [this]() { onMenuAction(0); }); + + // Ctrl+Alt+Shift+Q 退出串流回桌面列表。 + // 主路径是 moonlight-qt 自带的同一快捷键(串流独占键盘时由它退出, + // 进程退出后 onMoonlightState 自动回列表);这里兜底处理 + // 串流尚未建立、假死或焦点仍在 kiosk 的情况。 + auto* quitShortcut = new QShortcut(QKeySequence(QStringLiteral("Ctrl+Alt+Shift+Q")), this); + quitShortcut->setContext(Qt::ApplicationShortcut); + connect(quitShortcut, &QShortcut::activated, this, [this]() { + if (fsm_.isIn(State::kConnecting) || fsm_.isIn(State::kSession) || + fsm_.isIn(State::kReconnect)) { + stopSessions(); + fsm_.transitionTo(State::kDesktopList, QStringLiteral("已退出串流")); + if (desktopListPage_) { + desktopListPage_->setBusy(false); + desktopListPage_->setStatus(QStringLiteral("已退出串流")); + } + } + }); } void MainWindow::setupHeartbeat() { @@ -228,21 +155,14 @@ void MainWindow::onStateChanged(State from, State to, const QString& detail) { refreshDesktopList(); break; case State::kConnecting: - if (stack_->currentWidget() != sessionView_->parentWidget()) { - stack_->setCurrentWidget(sessionView_->parentWidget()); - } - sessionView_->setFocus(Qt::OtherFocusReason); - sessionOverlay_->setText(QStringLiteral("正在连接 %1 …").arg(activeTicket_.endpoint)); - sessionOverlay_->show(); + firstFrameClock_.restart(); + overlay_->start(QStringLiteral("正在进入云桌面")); + firstFrameTimer_->start(); break; case State::kSession: - sessionView_->setFocus(Qt::OtherFocusReason); - sessionOverlay_->hide(); + // 串流画面由 moonlight-qt 全屏呈现;过场层由首帧检测负责淡出 break; case State::kReconnect: - sessionOverlay_->setText(QStringLiteral("连接已断开,正在重连…")); - sessionOverlay_->show(); - reconnectTimer_->start(kReconnectDelayMs); break; case State::kError: stopSessions(); @@ -322,18 +242,12 @@ void MainWindow::onDesktopSelected(const QString& desktopId) { return; } activeTicket_ = ticket; - reconnectAttempts_ = 0; - - if (ticket.protocol == Protocol::kMoonlight) { - startMoonlightSession(ticket); - } else { - startRdpSession(ticket); - } + startMoonlightSession(ticket); }); } void MainWindow::onEditDesktopRequested(const QString& desktopId) { - // 桌面连接设置:编辑协议/地址/应用名/分辨率/帧率,保存后立即刷新列表。 + // 桌面连接设置:编辑地址/应用名/分辨率/帧率,保存后立即刷新列表。 // 现场环境地址不同或走 VPN 隧道时,在这里改地址即可,无需改代码。 DesktopSettingsDialog dialog(desktops_, desktopId, this); dialog.setStyleSheet( @@ -361,81 +275,10 @@ void MainWindow::onLogoutRequested() { fsm_.transitionTo(State::kConfig, QStringLiteral("已退出登录")); } -void MainWindow::startRdpSession(const ConnectionTicket& ticket) { - ++sessionEpoch_; - { - QMutexLocker locker(&frameMutex_); - latestFrame_ = QImage(); - frameEpoch_ = sessionEpoch_; - frameWakePending_ = false; - } - if (fsm_.current() == State::kSession) { - fsm_.transitionTo(State::kReconnect, QStringLiteral("手动重新连接")); - } - if (!fsm_.transitionTo(State::kConnecting, ticket.endpoint)) { - if (fsm_.current() != State::kConnecting) { - return; - } - } - - RdpOptions opts; - opts.server = ticket.endpoint.toStdString(); - opts.username = ticket.username.toStdString(); - opts.password = ticket.password.toStdString(); - opts.keyboardLayout = keyboardLayout_; - opts.width = ticket.width; - opts.height = ticket.height; - opts.bpp = 32; - opts.fullscreen = true; - // 与 xfreerdp3 相同的 GFX 能力组合语义: - // FreeRDP 3.32.1 不接受单个管道名,必须成组。 - opts.gfx = "progressive|RFX|AVC420|AVC444"; - opts.ignoreCertificate = true; // POC 阶段;正式产品必须做证书校验 - - RdpCallbacks cb; - cb.onFrame = [this](const uint8_t* data, uint32_t width, uint32_t height, uint32_t stride, - uint32_t format) { - // 在 FreeRDP 线程中复制帧,不能把其内部缓冲区裸指针排队到 GUI 线程。 - const QImage image = convertFrame(data, width, height, stride, format); - if (image.isNull()) { - return; - } - QMutexLocker locker(&frameMutex_); - latestFrame_ = image; - frameEpoch_ = sessionEpoch_; - if (frameWakePending_) { - return; - } - frameWakePending_ = true; - locker.unlock(); - QMetaObject::invokeMethod(this, &MainWindow::onFrameWakeup, Qt::QueuedConnection); - }; - cb.onState = [this](bool connected, const std::string& message) { - QMetaObject::invokeMethod( - this, - [this, connected, message]() { - onSessionState(connected, QString::fromStdString(message)); - }, - Qt::QueuedConnection); - }; - - rdpSession_ = std::make_unique(); - rdpSession_->start(opts, cb); -} - void MainWindow::startMoonlightSession(const ConnectionTicket& ticket) { if (!fsm_.transitionTo(State::kConnecting, ticket.endpoint)) { - if (fsm_.current() != State::kConnecting) { - return; - } + return; } - // Moonlight 是外部全屏进程:Kiosk 画面停在"正在连接"覆盖层, - // 串流窗口由 moonlight-qt 自行置顶显示。 - if (stack_->currentWidget() != sessionView_->parentWidget()) { - stack_->setCurrentWidget(sessionView_->parentWidget()); - } - sessionOverlay_->setText(QStringLiteral("正在连接 %1 …").arg(ticket.endpoint)); - sessionOverlay_->show(); MoonlightSession::Options opts; opts.decoder = QStringLiteral("software"); // RK3528 无可用 V4L2 硬解设备 @@ -454,7 +297,8 @@ void MainWindow::startMoonlightSession(const ConnectionTicket& ticket) { void MainWindow::onMoonlightState(bool running, const QString& message) { if (running) { - // 进程已拉起;串流窗口是否成功由 moonlight 自身展示 + // 进程已拉起:转入会话态;过场层等首帧检测淡出 + fsm_.transitionTo(State::kSession, message); updateStatusBar(QStringLiteral("SESSION %1").arg(message)); return; } @@ -470,59 +314,31 @@ void MainWindow::onMoonlightState(bool running, const QString& message) { } } -void MainWindow::onSessionState(bool connected, const QString& message) { - if (connected) { - reconnectAttempts_ = 0; - fsm_.transitionTo(State::kSession, message); - } else { - if (fsm_.current() == State::kConnecting) { - // 首次连接失败:回桌面列表并提示 - stopSessions(); - fsm_.transitionTo(State::kDesktopList, message); - if (desktopListPage_) { - desktopListPage_->setBusy(false); - desktopListPage_->setStatus(QStringLiteral("连接失败:%1").arg(message), true); - } - } else if (fsm_.current() == State::kSession) { - fsm_.transitionTo(State::kReconnect, message); +void MainWindow::watchFirstFrame() { + if (!moonlightSession_ || !moonlightSession_->isRunning()) { + return; // 进程退出路径由 onMoonlightState 收尾 + } + qint64 cpuTicks = -1; + // /proc//stat 的 comm 字段可含空格,从最后一个 ')' 后取字段: + // 第 0 个是 state(整行第 3 字段),utime/stime 是第 11/12 个。 + QFile statFile(QStringLiteral("/proc/%1/stat").arg(moonlightSession_->processId())); + if (statFile.open(QIODevice::ReadOnly | QIODevice::Text)) { + const QString content = QString::fromLatin1(statFile.readAll()); + const QStringList fields = + content.mid(content.lastIndexOf(')') + 1).split(' ', Qt::SkipEmptyParts); + if (fields.size() > 12) { + cpuTicks = fields.value(11).toLongLong() + fields.value(12).toLongLong(); } } -} - -void MainWindow::onFrameWakeup() { - QMutexLocker locker(&frameMutex_); - frameWakePending_ = false; -} - -void MainWindow::onFrameRefresh() { - QImage image; - uint64_t epoch = 0; - { - QMutexLocker locker(&frameMutex_); - image = latestFrame_; - epoch = frameEpoch_; - latestFrame_ = QImage(); - frameWakePending_ = false; - } - if (epoch == sessionEpoch_) { - onFrameImage(image); + if (cpuTicks >= kFirstFrameCpuTicks || firstFrameClock_.elapsed() >= kFirstFrameTimeoutMs) { + onFirstFrameDetected(); } } -void MainWindow::onFrameImage(const QImage& image) { - if (image.isNull()) { - return; - } - lastFrame_ = image; - - if (sessionView_) { - const QSize targetSize = sessionView_->size(); - if (lastFrame_.size() == targetSize) { - sessionView_->setPixmap(QPixmap::fromImage(lastFrame_)); - } else { - sessionView_->setPixmap(QPixmap::fromImage(lastFrame_).scaled( - targetSize, Qt::KeepAspectRatio, Qt::FastTransformation)); - } +void MainWindow::onFirstFrameDetected() { + firstFrameTimer_->stop(); + if (overlay_) { + overlay_->onFirstFrame(); } } @@ -532,31 +348,24 @@ void MainWindow::onQuitRequested() { } void MainWindow::stopSessions() { - if (rdpSession_) { - rdpSession_->stop(); - rdpSession_.reset(); + if (firstFrameTimer_) { + firstFrameTimer_->stop(); } if (moonlightSession_) { moonlightSession_->stop(); moonlightSession_.reset(); } - QMutexLocker locker(&frameMutex_); - latestFrame_ = QImage(); - lastFrame_ = QImage(); - frameWakePending_ = false; + if (overlay_) { + overlay_->finish(); + } } void MainWindow::onMenuAction(int action) { switch (action) { case 1: // 重新连接当前桌面 if (fsm_.canConnect() && !activeTicket_.desktopId.isEmpty()) { - reconnectAttempts_ = 0; stopSessions(); - if (activeTicket_.protocol == Protocol::kMoonlight) { - startMoonlightSession(activeTicket_); - } else { - startRdpSession(activeTicket_); - } + startMoonlightSession(activeTicket_); } break; case 2: // 断开回桌面列表 @@ -565,7 +374,7 @@ void MainWindow::onMenuAction(int action) { fsm_.transitionTo(State::kDesktopList, "已断开"); } break; - case 3: // 退出终端 + case 3: // 退出终端(维护功能,只保留在管理菜单) onQuitRequested(); break; default: { @@ -588,90 +397,9 @@ void MainWindow::onMenuAction(int action) { } else if (chosen == quit) { onMenuAction(3); } - if (stack_->currentWidget() == sessionView_->parentWidget()) { - sessionView_->setFocus(Qt::OtherFocusReason); - } break; } } } -bool MainWindow::eventFilter(QObject* watched, QEvent* event) { - if (watched == sessionView_ && rdpSession_) { - if (event->type() == QEvent::KeyPress || event->type() == QEvent::KeyRelease) { - auto* keyEvent = static_cast(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..7aece35 100644 --- a/terminal/kiosk-shell/src/main_window.h +++ b/terminal/kiosk-shell/src/main_window.h @@ -2,42 +2,39 @@ // // 产品流程编排: // 开机 -> 登录页 -> 认证 -> 桌面列表 -> 选择桌面 -// -> 获取连接票据 -> RDP(内嵌)或 Moonlight(外部进程)会话 +// -> 获取连接票据 -> Moonlight 串流会话(外部进程 moonlight-qt) // -> 会话结束回桌面列表;退出登录回登录页 // // 对应《VDI 云桌面软件开发方案》3.2 节的状态机, // 并按产品流程扩展 AUTHENTICATING / DESKTOP_LIST 两个状态。 -// 全局热键 Ctrl+Alt+Shift+M 呼出管理菜单。 +// 全局热键 Ctrl+Alt+Shift+M 呼出管理菜单(含退出终端); +// Ctrl+Alt+Shift+Q 退出串流:moonlight-qt 自带同名快捷键, +// kiosk 侧保留一份兜底,串流未建立或焦点仍在 kiosk 时也能退出。 #ifndef KIOSK_SHELL_MAIN_WINDOW_H #define KIOSK_SHELL_MAIN_WINDOW_H -#include +#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,19 +43,12 @@ public: explicit MainWindow(QWidget* parent = nullptr); ~MainWindow() override; -protected: - bool eventFilter(QObject* watched, QEvent* event) override; - private slots: void onLoginRequested(const QString& username, const QString& password); void onDesktopSelected(const QString& desktopId); void onEditDesktopRequested(const QString& desktopId); void onLogoutRequested(); void onQuitRequested(); - void onFrameImage(const QImage& image); - void onFrameWakeup(); - void onFrameRefresh(); - void onSessionState(bool connected, const QString& message); void onMoonlightState(bool running, const QString& message); void onMenuAction(int action); @@ -68,42 +58,30 @@ private: void setupHeartbeat(); void onStateChanged(vdi::State from, vdi::State to, const QString& detail); void refreshDesktopList(); - void startRdpSession(const ConnectionTicket& ticket); void startMoonlightSession(const ConnectionTicket& ticket); void stopSessions(); void showLogin(); void showDesktopList(); void updateStatusBar(const QString& text); - bool forwardKeyEvent(QKeyEvent* event, bool pressed); - bool forwardMouseEvent(QEvent* event); + void watchFirstFrame(); + void onFirstFrameDetected(); StateMachine fsm_; DesktopService desktopService_; - std::unique_ptr rdpSession_; std::unique_ptr moonlightSession_; ConnectionTicket activeTicket_; QString currentUser_; 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* firstFrameTimer_ = nullptr; QTimer* heartbeatTimer_ = nullptr; - QTimer* frameRefreshTimer_ = nullptr; + QElapsedTimer firstFrameClock_; TerminalConfig config_; - QImage lastFrame_; - QImage latestFrame_; - QMutex frameMutex_; - uint64_t frameEpoch_ = 0; - uint64_t sessionEpoch_ = 0; - bool frameWakePending_ = false; - QPoint lastMousePosition_; - uint32_t keyboardLayout_ = 0; }; } // namespace vdi diff --git a/terminal/kiosk-shell/src/moonlight_session.cpp b/terminal/kiosk-shell/src/moonlight_session.cpp index a9fd323..069f03c 100644 --- a/terminal/kiosk-shell/src/moonlight_session.cpp +++ b/terminal/kiosk-shell/src/moonlight_session.cpp @@ -22,6 +22,10 @@ bool MoonlightSession::isRunning() const { return process_ && process_->state() != QProcess::NotRunning; } +qint64 MoonlightSession::processId() const { + return isRunning() ? process_->processId() : 0; +} + bool MoonlightSession::start(const ConnectionTicket& ticket, StateCallback callback) { if (isRunning()) { return false; diff --git a/terminal/kiosk-shell/src/moonlight_session.h b/terminal/kiosk-shell/src/moonlight_session.h index f49288e..0f8688f 100644 --- a/terminal/kiosk-shell/src/moonlight_session.h +++ b/terminal/kiosk-shell/src/moonlight_session.h @@ -44,6 +44,9 @@ public: bool isRunning() const; + // 串流进程 PID(未运行返回 0),供首帧检测读 /proc 统计 + qint64 processId() const; + private slots: void onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus); void onProcessError(QProcess::ProcessError error); 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..e2aec76 --- /dev/null +++ b/terminal/kiosk-shell/src/transition_overlay.cpp @@ -0,0 +1,131 @@ +#include "transition_overlay.h" + +#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-qt 会盖住 kiosk 主窗口, + // 过场层必须压在它之上才起作用(onTick 周期性 raise 兜底)。 + setWindowFlags(Qt::Window | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint); + setStyleSheet(QStringLiteral("TransitionOverlay { background-color: #000000; }")); + + auto* layout = new QVBoxLayout(this); + layout->setSpacing(28); + + labelSpinner_ = new QLabel(this); + QFont spinFont = labelSpinner_->font(); + spinFont.setPointSize(44); + labelSpinner_->setFont(spinFont); + labelSpinner_->setAlignment(Qt::AlignCenter); + labelSpinner_->setStyleSheet(QStringLiteral("color: #2f6feb; background: transparent;")); + layout->addStretch(1); + layout->addWidget(labelSpinner_); + + labelTitle_ = new QLabel(this); + QFont titleFont = labelTitle_->font(); + titleFont.setPointSize(20); + labelTitle_->setFont(titleFont); + labelTitle_->setAlignment(Qt::AlignCenter); + labelTitle_->setStyleSheet(QStringLiteral("color: #e6e9ef; background: transparent;")); + layout->addWidget(labelTitle_); + layout->addStretch(2); + + tickTimer_ = new QTimer(this); + connect(tickTimer_, &QTimer::timeout, this, &TransitionOverlay::onTick); + fadeTimer_ = new QTimer(this); + connect(fadeTimer_, &QTimer::timeout, this, &TransitionOverlay::onFadeStep); + + // 活动期间吞掉本窗口的键盘/鼠标事件,避免误触下层页面 + installEventFilter(this); +} + +void TransitionOverlay::start(const QString& title) { + tickTimer_->stop(); + fadeTimer_->stop(); + dots_ = 0; + setWindowOpacity(1.0); + setProperty("baseTitle", title); + labelTitle_->setText(title); + labelSpinner_->setText(QString::fromUtf8(kSpinnerFrames[0])); + + showFullScreen(); + raise(); + activateWindow(); + grabKeyboard(); + grabMouse(); + tickTimer_->start(kTickMs); +} + +void TransitionOverlay::finish() { + tickTimer_->stop(); + fadeTimer_->stop(); + releaseKeyboard(); + releaseMouse(); + hide(); + setWindowOpacity(1.0); +} + +void TransitionOverlay::onFirstFrame() { + // 首帧已出画:停止动画并淡出;若尚未显示/已在淡出则忽略 + if (!isVisible() || fadeTimer_->isActive()) { + return; + } + tickTimer_->stop(); + releaseKeyboard(); + releaseMouse(); + fadeTimer_->start(kFadeStepMs); +} + +void TransitionOverlay::onTick() { + dots_ = (dots_ + 1) % 4; + const QString base = property("baseTitle").toString(); + if (!base.isEmpty()) { + labelTitle_->setText(base + QString(dots_, QLatin1Char('.'))); + } + labelSpinner_->setText(QString::fromUtf8(kSpinnerFrames[dots_])); + // X11 下 moonlight 的全屏窗口可能后映射,周期性抬回最上层 + raise(); +} + +void TransitionOverlay::onFadeStep() { + const double opacity = windowOpacity() - 1.0 / kFadeSteps; + if (opacity <= 0.0) { + finish(); + return; + } + setWindowOpacity(opacity); +} + +bool TransitionOverlay::eventFilter(QObject* watched, QEvent* event) { + if (watched == this && isVisible()) { + switch (event->type()) { + case QEvent::KeyPress: + case QEvent::KeyRelease: + case QEvent::MouseButtonPress: + case QEvent::MouseButtonRelease: + case QEvent::MouseMove: + case QEvent::Wheel: + return true; + default: + break; + } + } + return QWidget::eventFilter(watched, event); +} + +} // namespace vdi 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/tools/syntax_sanity.py b/tools/syntax_sanity.py index cf12fc6..aa99c94 100644 --- a/tools/syntax_sanity.py +++ b/tools/syntax_sanity.py @@ -8,6 +8,10 @@ os.chdir(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "terminal", "kiosk-shell")) FILES = [ + "src/desktop_settings_dialog.h", + "src/desktop_settings_dialog.cpp", + "src/desktop_store.h", + "src/desktop_store.cpp", "src/main_window.cpp", "src/main_window.h", "src/desktop_list_page.cpp", @@ -17,6 +21,8 @@ FILES = [ "src/desktop_session.h", "src/moonlight_session.cpp", "src/moonlight_session.h", + "src/transition_overlay.h", + "src/transition_overlay.cpp", "src/login_page.cpp", "src/login_page.h", "src/state_machine.cpp",