From 13f645a503ef7d3679a87859e3c9178aa8acc05b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=8B=E4=B8=80=E9=B8=A3?= <1000000008@qq.com> Date: Fri, 30 Oct 2026 14:42:11 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=EF=BC=9A=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E4=B8=B2=E6=B5=81=E5=B0=B1=E7=BB=AA=E6=A3=80=E6=B5=8B=E5=B9=B6?= =?UTF-8?q?=E8=A1=A5=E5=85=85=20GPU=20=E8=81=94=E8=B0=83=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 变更详情:kiosk 会话就绪检测重构(Moonlight 输出解析器/事件化回调/单元测试);GPU 直通实操记录(vfio Subsystem 注入、sysfs vBIOS、7zr+pnputil 安装、566.36 驱动、Sunshine 配对断链修复) --- MEMORY.md | 11 + docs/moonlight-kiosk-20261028.md | 56 ++++ terminal/kiosk-shell/CMakeLists.txt | 36 +++ .../kiosk-shell/src/desktop_list_page.cpp | 13 +- terminal/kiosk-shell/src/desktop_list_page.h | 1 + terminal/kiosk-shell/src/main_window.cpp | 270 +++++++++++------ terminal/kiosk-shell/src/main_window.h | 41 +-- terminal/kiosk-shell/src/monitor.cpp | 6 + .../kiosk-shell/src/moonlight_session.cpp | 282 +++++++++++++----- terminal/kiosk-shell/src/moonlight_session.h | 66 +++- .../kiosk-shell/src/transition_overlay.cpp | 25 +- .../tests/test_desktop_list_page.cpp | 53 ++++ .../tests/test_moonlight_session.cpp | 94 ++++++ tools/deploy_kiosk.sh | 14 +- 14 files changed, 743 insertions(+), 225 deletions(-) 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/MEMORY.md b/MEMORY.md index 13a9208..96ffc2a 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -18,6 +18,17 @@ - 代码位置:`terminal/kiosk-shell` - 远程安装路径:`~/vdi-terminal/bin/`(`kiosk-shell`、`vdi-kiosk-monitor`、`vdi-moonlight`) +## GPU 直通进行中(2026-10-30 下午,见 moonlight-kiosk 文档当日追加节) +- 宿主侧已就绪:nouveau 已黑名单、GPU 在 vfio-pci、IOMMU 组 2(root port+GPU) +- 关键坑:vfio 清零 Subsystem ID(必须 `x-pci-sub-vendor-id/device-id` 注入 1558:6557); + 移动卡 vBIOS 需 sysfs 抽取(/usr/share/kvm/gtx965m.rom);NVIDIA 安装器在 SYSTEM 会话 + 挂起(用 7zr 解包 + pnputil);582.78 绑定后 D3D 全灭已换 566.36(D3D 待验证) +- **当前 VM 100 未挂 GPU**:另一并行会话在改配置(hv_vendor_id 方案),本会话已停手避撞, + 两个会话不要同时改 VM 100 配置! +- Sunshine 配对 2026.914 改为"Web UI 批准式":`moonlight pair --pin NNNN` + + `GET /api/csrf-token` + `POST /api/pin {pairing_id,pin}`;Web 凭据 vdiadmin/VdiPass2026; + 删 sunshine_state.json = 全部失配;盒子 Moonlight 配置目录缓存旧 uniqueid 会卡死配对 + ## 必须遵守 - 所有实现严格参考用户提供的两个正式 Word 和会议纪要;文档未确定的技术只能标记待调研,不得擅自定版。 - 终端先做,后接自研管理平台 API;终端不直接调用 Proxmox API。 diff --git a/docs/moonlight-kiosk-20261028.md b/docs/moonlight-kiosk-20261028.md index 4dc0bce..ed95309 100644 --- a/docs/moonlight-kiosk-20261028.md +++ b/docs/moonlight-kiosk-20261028.md @@ -253,6 +253,62 @@ Desktop Duplication 的脏帧节奏,叠加 QEMU 显示栈),不是编码器 --- +# 2026-10-30 下午追加:GPU 直通实操 + 配对断链修复(大量新坑位) + +## 直通实操结论(VM 100 / GTX 965M CLEVO 1558:6557) + +1. **vfio reset 会把 GPU 的 Subsystem ID 清零**(Windows 里实例变成 + `SUBSYS_00000000`),而 NVIDIA 移动卡驱动全部按 SUBSYS 匹配 + (本卡在 `nvcvi.inf` 的 `SUBSYS_65571558`),不注入就永远"无法安装"。 + 解法 = QEMU 设备参数 `x-pci-sub-vendor-id=0x1558,x-pci-sub-device-id=0x6557`。 +2. **移动卡 vBIOS 不在卡上**(ROM BAR 为空,宿主 dmesg 反复 + "No more images in PCI ROM")。宿主开机时系统 BIOS 已 shadow 过 vBIOS, + 可从 sysfs 抽取:停 VM → `echo 0000:01:00.0 > /sys/bus/pci/drivers/vfio-pci/unbind` + → `echo 1 > rom; cat rom > gtx965m.rom`(108KB,55AA 开头有效)→ 重绑 vfio。 +3. **NVIDIA 安装器(-s/-x)在 qemu-ga SYSTEM 会话下静默挂起**(CPU 时间 ~0s, + 无解包无日志)。VM 内装驱动的可行路径:PC 端 7zr 解包 → 取 Display.Driver → + zip 传入 VM → `pnputil /add-driver nvcvi.inf /install`。 + 国内线路被 NVIDIA CDN 403,直链需 PC 中转(PC→scp 宿主机→宿主机 HTTP→VM 拉)。 +4. **582.78 绑定成功但 D3D 全灭**:PnP Status OK(需手动 + `pnputil /restart-device` 踢一次,开机自启是 Code 43),但 dxdiag 看不到适配器、 + NVML 初始化失败、所有硬编编码器 D3D11 建设备报 0x887A0004 (DEVICE_REMOVED)。 + 已换 **566.36**(2024-12,Maxwell+VM 社区验证充分),包已装、设备已绑定; + D3D 是否健康**尚未验证**(GPU 在替换期间被移出 VM,见下)。 +5. **可用挂载参数模板**(未来重启 VM 用): + `-device vfio-pci,host=0000:01:00.0,bus=pcie.0,addr=0x10,x-pci-sub-vendor-id=0x1558,x-pci-sub-device-id=0x6557,romfile=gtx965m.rom` + (rom 放 /usr/share/kvm/;另一会话在测 `hv_vendor_id=NVidiaFIX,kvm=off` 方案)。 +6. **local-lvm thin 池曾被驱动文件写满**(100% → error IO → VM 被 QEMU 暂停), + 已 `lvextend -L +13G pve/data`(49→62GB)。VM 内 C:\nvsetup 有 ~12GB 驱动 + 中间产物可清理;池仍超配(160GB 卷/62GB 池),建议后续扩存储。 + +## 配对断链与修复(重要运维知识) + +- `sunshine_state.json` 存服务器 uniqueid 与已配对客户端。**删它 = 所有客户端失配**。 + 修复后的新流程(2026.914 的"Web UI 批准式"配对): + 1. 盒子:`moonlight-qt pair 192.168.1.70 --pin 4321`(**PIN 由客户端生成**,可 `--pin` 指定); + 2. 宿主/PC 侧调 Sunshine Web API 批准: + `GET /api/csrf-token`(Basic Auth)→ + `POST /api/pin` body `{"pairing_id":"","pin":"4321"}`, + 头带 `X-CSRF-Token` + Basic Auth(无 /api/login;POST 必须 CSRF 头)。 +- Web 凭据已重置为 `vdiadmin / VdiPass2026`(写在本页,勿提交正式环境)。 +- **旧版缓存 uniqueid 会让 pair 卡 "Found unexpected PC" 死循环**: + 盒子 `~/.config/Moonlight Game Streaming Project/` 整目录删除后重配即可。 +- Sunshine 重启不再换 uniqueid(持久化已恢复);**若再出现"每次重启换 uniqueid", + 先查 sunshine_state.json 是否能写**。 +- NVIDIA 设备半坏时 Sunshine 捕获初始化会崩循环(服务恢复 5-6 分钟拉起一次), + 表象是"每几分钟换一次 UUID + 配对全部失败"。根因仍是驱动 D3D 问题。 + +## 当前现场(2026-10-30 14:50) + +- VM 100:**GPU 未挂载**(另一并行会话用 `hv_vendor_id=NVidiaFIX` 方案接手了配置, + 为避免两个会话互相覆盖,本会话已停止改 VM 配置);VM 内 566.36 驱动已装好待用。 +- 盒子:kiosk 单链已恢复、配对已修复、串流实测 ~23 FPS(软编基线)可正常进出。 +- 宿主机 /root 下留有 nv582.78.exe、DisplayDriver.zip、DD566.zip、gtx965m.rom; + 8018 端口有临时 HTTP 服务(http8018svc)供 VM 拉文件,用完可 + `systemctl stop http8018svc`。 + +--- + # 2026-10-30 追加:开机直进桌面列表 按产品决定,盒子开机不再显示 kiosk 登录页,直接进入"选择云桌面"列表页: diff --git a/terminal/kiosk-shell/CMakeLists.txt b/terminal/kiosk-shell/CMakeLists.txt index 8c21fb2..56eabf9 100644 --- a/terminal/kiosk-shell/CMakeLists.txt +++ b/terminal/kiosk-shell/CMakeLists.txt @@ -14,6 +14,7 @@ endif() # ---- Qt5 ---- find_package(Qt5 REQUIRED COMPONENTS Core Gui Widgets) +include(CTest) add_executable(kiosk-shell src/main.cpp @@ -52,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 5a45074..a74f071 100644 --- a/terminal/kiosk-shell/src/desktop_list_page.cpp +++ b/terminal/kiosk-shell/src/desktop_list_page.cpp @@ -69,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); @@ -127,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); @@ -141,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); } @@ -168,7 +175,11 @@ void DesktopListPage::setStatus(const QString& text, bool error) { void DesktopListPage::setBusy(bool busy) { busy_ = 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 cea10f5..ea453d2 100644 --- a/terminal/kiosk-shell/src/desktop_list_page.h +++ b/terminal/kiosk-shell/src/desktop_list_page.h @@ -48,6 +48,7 @@ private: QLabel* labelUser_ = nullptr; QLabel* labelStatus_ = nullptr; QVBoxLayout* cardsLayout_ = nullptr; + std::vector actionButtons_; bool busy_ = false; }; diff --git a/terminal/kiosk-shell/src/main_window.cpp b/terminal/kiosk-shell/src/main_window.cpp index 9418491..b93a55c 100644 --- a/terminal/kiosk-shell/src/main_window.cpp +++ b/terminal/kiosk-shell/src/main_window.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -17,12 +18,9 @@ namespace vdi { namespace { -// 首帧检测:moonlight-qt 是独立进程,kiosk 拿不到解码回调。 -// 软解 1080p60 的 CPU 占用远高于启动握手期,因此轮询进程 CPU 时间, -// 累计超过阈值即认为串流已出画;超时则强制淡出兜底(时钟频率 100 tick/秒)。 -constexpr int kFirstFramePollMs = 500; -constexpr qint64 kFirstFrameCpuTicks = 120; -constexpr qint64 kFirstFrameTimeoutMs = 15000; +constexpr int kTicketTimeoutMs = 10000; +constexpr int kProcessStartTimeoutMs = 5000; +constexpr int kStreamReadyTimeoutMs = 30000; } // namespace @@ -35,10 +33,6 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { setupHeartbeat(); config_ = loadConfig(); - - // 开机直进桌面列表:当前无正式账号体系,设备身份(SN)即用户,自动登录。 - // 登录页代码保留——接入管理平台认证后,由 DesktopService 决定是否回到登录流程; - // 会话只能由用户在桌面列表选择后启动。 fsm_.transitionTo(State::kConfig, QStringLiteral("就绪")); const QString deviceUser = QString::fromStdString(config_.sn).trimmed(); desktopService_.login(deviceUser.isEmpty() ? QStringLiteral("terminal") : deviceUser, @@ -64,7 +58,6 @@ void MainWindow::setupUi() { stack_->setStyleSheet(QStringLiteral("background: #0f1420;")); setCentralWidget(stack_); - // 登录页 auto* login = new LoginPage(config_, this); loginPage_ = login; login->setStyleSheet( @@ -76,10 +69,8 @@ void MainWindow::setupUi() { "border-radius: 6px; color: #e6e9ef; }" "QPushButton:hover { background: #2b3654; }")); stack_->addWidget(login); - connect(login, &LoginPage::loginRequested, this, &MainWindow::onLoginRequested); - // 桌面列表页 auto* list = new DesktopListPage(this); desktopListPage_ = list; list->setStyleSheet( @@ -88,35 +79,43 @@ 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); - // 过场层:独立置顶窗口,盖住 moonlight-qt 启动期的小窗口闪烁与分辨率切换, - // 首帧检测到后淡出。不属于 stack_(串流期间整个 kiosk 都被 moonlight 覆盖)。 overlay_ = new TransitionOverlay(this); - // 状态条 statusBar_ = new QStatusBar(this); statusBar_->setStyleSheet(QStringLiteral("QStatusBar { background: #161d2e; color: #8a94a6; }")); setStatusBar(statusBar_); - // 串流首帧检测 - firstFrameTimer_ = new QTimer(this); - firstFrameTimer_->setInterval(kFirstFramePollMs); - connect(firstFrameTimer_, &QTimer::timeout, this, &MainWindow::watchFirstFrame); + 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); + }); + + moonlightSession_ = std::make_unique(); } void MainWindow::setupHotkey() { - // 全局热键 Ctrl+Alt+Shift+M 呼出管理菜单(方案 3.2 节) 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]() { @@ -133,8 +132,6 @@ void MainWindow::setupHotkey() { } void MainWindow::setupHeartbeat() { - // 定期写心跳文件,供看门狗 Monitor 判断主进程是否假死 - // 对应会议纪要要求的双进程架构 const QString path = QStringLiteral("/tmp/vdi-kiosk.heartbeat"); heartbeatTimer_ = new QTimer(this); heartbeatTimer_->setInterval(5000); @@ -142,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(); @@ -164,13 +160,9 @@ void MainWindow::onStateChanged(State from, State to, const QString& detail) { refreshDesktopList(); break; case State::kConnecting: - firstFrameClock_.restart(); overlay_->start(QStringLiteral("正在进入云桌面")); - firstFrameTimer_->start(); break; case State::kSession: - // 串流画面由 moonlight-qt 全屏呈现;过场层由首帧检测负责淡出 - break; case State::kReconnect: break; case State::kError: @@ -239,25 +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; - startMoonlightSession(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; }" @@ -277,71 +288,126 @@ void MainWindow::onEditDesktopRequested(const QString& desktopId) { } } -void MainWindow::startMoonlightSession(const ConnectionTicket& ticket) { - if (!fsm_.transitionTo(State::kConnecting, ticket.endpoint)) { - return; - } - +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) { - // 进程已拉起:转入会话态;过场层等首帧检测淡出 - fsm_.transitionTo(State::kSession, message); - 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::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(); - } - } - if (cpuTicks >= kFirstFrameCpuTicks || firstFrameClock_.elapsed() >= kFirstFrameTimeoutMs) { - onFirstFrameDetected(); +void MainWindow::armConnectionTimeout(quint64 attempt, ConnectionPhase phase, int timeoutMs) { + if (attempt != connectionAttempt_) { + return; } + connectionPhase_ = phase; + connectionTimer_->start(timeoutMs); } -void MainWindow::onFirstFrameDetected() { - firstFrameTimer_->stop(); - if (overlay_) { - overlay_->onFirstFrame(); - } +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() { @@ -350,12 +416,17 @@ void MainWindow::onQuitRequested() { } void MainWindow::stopSessions() { - if (firstFrameTimer_) { - firstFrameTimer_->stop(); + stopSessions(true); +} + +void MainWindow::stopSessions(bool invalidateAttempt) { + connectionTimer_->stop(); + if (invalidateAttempt) { + ++connectionAttempt_; } + connectionPhase_ = ConnectionPhase::kIdle; if (moonlightSession_) { moonlightSession_->stop(); - moonlightSession_.reset(); } if (overlay_) { overlay_->finish(); @@ -364,19 +435,24 @@ void MainWindow::stopSessions() { void MainWindow::onMenuAction(int action) { switch (action) { - case 1: // 重新连接当前桌面 + case 1: if (fsm_.canConnect() && !activeTicket_.desktopId.isEmpty()) { - stopSessions(); - startMoonlightSession(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: { diff --git a/terminal/kiosk-shell/src/main_window.h b/terminal/kiosk-shell/src/main_window.h index e75fbf6..8eba3a7 100644 --- a/terminal/kiosk-shell/src/main_window.h +++ b/terminal/kiosk-shell/src/main_window.h @@ -1,15 +1,6 @@ // Kiosk 主窗口 // -// 产品流程编排: -// 开机 -> 登录页 -> 认证 -> 桌面列表 -> 选择桌面 -// -> 获取连接票据 -> Moonlight 串流会话(外部进程 moonlight-qt) -// -> 会话结束回桌面列表;退出登录回登录页 -// -// 对应《VDI 云桌面软件开发方案》3.2 节的状态机, -// 并按产品流程扩展 AUTHENTICATING / DESKTOP_LIST 两个状态。 -// 全局热键 Ctrl+Alt+Shift+M 呼出管理菜单(含退出终端); -// Ctrl+Alt+Shift+Q 退出串流:moonlight-qt 自带同名快捷键, -// kiosk 侧保留一份兜底,串流未建立或焦点仍在 kiosk 时也能退出。 +// 产品流程编排:开机 -> 桌面列表 -> 获取连接票据 -> Moonlight 会话 -> 返回列表。 #ifndef KIOSK_SHELL_MAIN_WINDOW_H #define KIOSK_SHELL_MAIN_WINDOW_H @@ -28,7 +19,6 @@ class QStackedWidget; class QStatusBar; class QTimer; -class QEvent; namespace vdi { @@ -48,28 +38,42 @@ private slots: void onDesktopSelected(const QString& desktopId); void onEditDesktopRequested(const QString& desktopId); void onQuitRequested(); - 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 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); - void watchFirstFrame(); - void onFirstFrameDetected(); StateMachine fsm_; DesktopService desktopService_; std::unique_ptr moonlightSession_; ConnectionTicket activeTicket_; QString currentUser_; + QString connectingDesktopId_; std::vector desktops_; QPointer loginPage_; @@ -77,9 +81,12 @@ private: QStackedWidget* stack_ = nullptr; QStatusBar* statusBar_ = nullptr; TransitionOverlay* overlay_ = nullptr; - QTimer* firstFrameTimer_ = nullptr; + QTimer* connectionTimer_ = nullptr; QTimer* heartbeatTimer_ = nullptr; - QElapsedTimer firstFrameClock_; + QElapsedTimer connectionClock_; + QElapsedTimer phaseClock_; + quint64 connectionAttempt_ = 0; + ConnectionPhase connectionPhase_ = ConnectionPhase::kIdle; TerminalConfig config_; }; 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 069f03c..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,23 +106,55 @@ void MoonlightSession::setOptions(const Options& options) { } bool MoonlightSession::isRunning() const { - return process_ && process_->state() != QProcess::NotRunning; + return process_->state() != QProcess::NotRunning; } qint64 MoonlightSession::processId() const { return isRunning() ? process_->processId() : 0; } -bool MoonlightSession::start(const ConnectionTicket& ticket, StateCallback callback) { +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") @@ -50,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 0f8688f..d9f29d1 100644 --- a/terminal/kiosk-shell/src/moonlight_session.h +++ b/terminal/kiosk-shell/src/moonlight_session.h @@ -1,60 +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; - - // 串流进程 PID(未运行返回 0),供首帧检测读 /proc 统计 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/transition_overlay.cpp b/terminal/kiosk-shell/src/transition_overlay.cpp index e2aec76..e2694de 100644 --- a/terminal/kiosk-shell/src/transition_overlay.cpp +++ b/terminal/kiosk-shell/src/transition_overlay.cpp @@ -2,7 +2,9 @@ #include #include +#include #include +#include #include #include #include @@ -10,7 +12,7 @@ namespace vdi { namespace { -constexpr int kTickMs = 400; // 省略号/旋轮动画与置顶保活周期 +constexpr int kTickMs = 400; // 省略号/旋轮动画周期 constexpr int kFadeStepMs = 40; // 淡出步进周期 constexpr int kFadeSteps = 25; // 全程约 1 秒淡出 @@ -19,9 +21,10 @@ const char* kSpinnerFrames[] = {"◐", "◓", "◑", "◒"}; } // namespace TransitionOverlay::TransitionOverlay(QWidget* parent) : QWidget(parent) { - // 独立置顶无边框窗口:串流启动后 moonlight-qt 会盖住 kiosk 主窗口, - // 过场层必须压在它之上才起作用(onTick 周期性 raise 兜底)。 - setWindowFlags(Qt::Window | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint); + // 独立置顶无边框窗口只在启动时映射一次,后续不再与 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); @@ -63,19 +66,17 @@ void TransitionOverlay::start(const QString& title) { labelTitle_->setText(title); labelSpinner_->setText(QString::fromUtf8(kSpinnerFrames[0])); - showFullScreen(); + if (QScreen* targetScreen = QGuiApplication::primaryScreen()) { + setGeometry(targetScreen->geometry()); + } + show(); raise(); - activateWindow(); - grabKeyboard(); - grabMouse(); tickTimer_->start(kTickMs); } void TransitionOverlay::finish() { tickTimer_->stop(); fadeTimer_->stop(); - releaseKeyboard(); - releaseMouse(); hide(); setWindowOpacity(1.0); } @@ -86,8 +87,6 @@ void TransitionOverlay::onFirstFrame() { return; } tickTimer_->stop(); - releaseKeyboard(); - releaseMouse(); fadeTimer_->start(kFadeStepMs); } @@ -98,8 +97,6 @@ void TransitionOverlay::onTick() { labelTitle_->setText(base + QString(dots_, QLatin1Char('.'))); } labelSpinner_->setText(QString::fromUtf8(kSpinnerFrames[dots_])); - // X11 下 moonlight 的全屏窗口可能后映射,周期性抬回最上层 - raise(); } void TransitionOverlay::onFadeStep() { 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"