重构:完善串流就绪检测并补充 GPU 联调记录
变更详情:kiosk 会话就绪检测重构(Moonlight 输出解析器/事件化回调/单元测试);GPU 直通实操记录(vfio Subsystem 注入、sysfs vBIOS、7zr+pnputil 安装、566.36 驱动、Sunshine 配对断链修复)
This commit is contained in:
parent
b08486046b
commit
13f645a503
11
MEMORY.md
11
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。
|
||||
|
||||
@ -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":"<GET /api/pin 里拿>","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 登录页,直接进入"选择云桌面"列表页:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -69,6 +69,8 @@ void DesktopListPage::setDesktops(const std::vector<DesktopInfo>& desktops) {
|
||||
}
|
||||
|
||||
void DesktopListPage::rebuildCards() {
|
||||
actionButtons_.clear();
|
||||
|
||||
// 清空旧卡片(最后一项是 stretch)
|
||||
while (cardsLayout_->count() > 1) {
|
||||
QLayoutItem* item = cardsLayout_->takeAt(0);
|
||||
@ -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 {
|
||||
|
||||
@ -48,6 +48,7 @@ private:
|
||||
QLabel* labelUser_ = nullptr;
|
||||
QLabel* labelStatus_ = nullptr;
|
||||
QVBoxLayout* cardsLayout_ = nullptr;
|
||||
std::vector<QPushButton*> actionButtons_;
|
||||
bool busy_ = false;
|
||||
};
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
#include <QApplication>
|
||||
#include <QDateTime>
|
||||
#include <QDebug>
|
||||
#include <QFile>
|
||||
#include <QMenu>
|
||||
#include <QShortcut>
|
||||
@ -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<MoonlightSession>();
|
||||
}
|
||||
|
||||
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<MoonlightSession>(/*parent=*/nullptr);
|
||||
moonlightSession_->setOptions(opts);
|
||||
moonlightSession_->start(ticket, [this](bool running, const QString& message) {
|
||||
|
||||
phaseClock_.restart();
|
||||
connectionPhase_ = ConnectionPhase::kProcessStart;
|
||||
logConnectionPhase(attempt, QStringLiteral("process_start"), QStringLiteral("started"));
|
||||
armConnectionTimeout(attempt, ConnectionPhase::kProcessStart, kProcessStartTimeoutMs);
|
||||
|
||||
return moonlightSession_->start(ticket, [this, attempt](const MoonlightSession::Event& event) {
|
||||
QMetaObject::invokeMethod(
|
||||
this,
|
||||
[this, running, message]() { onMoonlightState(running, message); },
|
||||
this, [this, attempt, event]() { onMoonlightEvent(attempt, event); },
|
||||
Qt::QueuedConnection);
|
||||
});
|
||||
}
|
||||
|
||||
void MainWindow::onMoonlightState(bool running, const QString& message) {
|
||||
if (running) {
|
||||
// 进程已拉起:转入会话态;过场层等首帧检测淡出
|
||||
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/<pid>/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: {
|
||||
|
||||
@ -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> moonlightSession_;
|
||||
ConnectionTicket activeTicket_;
|
||||
QString currentUser_;
|
||||
QString connectingDesktopId_;
|
||||
std::vector<DesktopInfo> desktops_;
|
||||
|
||||
QPointer<LoginPage> 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_;
|
||||
};
|
||||
|
||||
|
||||
@ -159,6 +159,12 @@ int Monitor::run(const std::string& command) {
|
||||
log("主进程已退出, code=" + std::to_string(exitCode) +
|
||||
(signalNum ? (", signal=" + std::to_string(signalNum)) : ""));
|
||||
|
||||
// shell 主进程退出时 Moonlight 等后代可能仍留在原进程组中。
|
||||
// 重启前清理整个组,避免旧串流窗口与新实例并存。
|
||||
killTree(child, SIGTERM);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(300));
|
||||
killTree(child, SIGKILL);
|
||||
|
||||
if (++restartAttempts_ > config_.maxRestartAttempts) {
|
||||
log("连续重启超过 " + std::to_string(config_.maxRestartAttempts) +
|
||||
" 次,放弃重启(熔断)");
|
||||
|
||||
@ -1,17 +1,104 @@
|
||||
#include "moonlight_session.h"
|
||||
|
||||
#include <QDate>
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QProcessEnvironment>
|
||||
#include <QTextStream>
|
||||
#include <QTimer>
|
||||
|
||||
namespace vdi {
|
||||
namespace {
|
||||
|
||||
MoonlightSession::MoonlightSession(QObject* parent) : QObject(parent) {}
|
||||
constexpr int kReadyStableMs = 750;
|
||||
constexpr int kTerminateGraceMs = 2000;
|
||||
|
||||
bool isTestDecodeLine(const QByteArray& line) {
|
||||
return line.toLower().contains("test decode successful");
|
||||
}
|
||||
|
||||
bool isRendererLine(const QByteArray& line) {
|
||||
const QByteArray lower = line.toLower();
|
||||
return lower.contains("renderer '") && lower.contains(" chosen");
|
||||
}
|
||||
|
||||
bool isTerminalErrorLine(const QByteArray& line) {
|
||||
const QByteArray lower = line.toLower();
|
||||
if (lower.contains("会话已终止")) {
|
||||
return true;
|
||||
}
|
||||
const QByteArray marker("connection terminated:");
|
||||
const int markerPos = lower.indexOf(marker);
|
||||
if (markerPos < 0) {
|
||||
return false;
|
||||
}
|
||||
bool ok = false;
|
||||
const int code = lower.mid(markerPos + marker.size()).trimmed().toInt(&ok);
|
||||
return !ok || code != 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void MoonlightOutputParser::reset() {
|
||||
buffer_.clear();
|
||||
hasTestDecode_ = false;
|
||||
hasRenderer_ = false;
|
||||
hasTerminalError_ = false;
|
||||
}
|
||||
|
||||
void MoonlightOutputParser::append(const QByteArray& output) {
|
||||
buffer_.append(output);
|
||||
int newline = -1;
|
||||
while ((newline = buffer_.indexOf('\n')) >= 0) {
|
||||
const QByteArray line = buffer_.left(newline).trimmed();
|
||||
buffer_.remove(0, newline + 1);
|
||||
hasTestDecode_ = hasTestDecode_ || isTestDecodeLine(line);
|
||||
hasRenderer_ = hasRenderer_ || isRendererLine(line);
|
||||
hasTerminalError_ = hasTerminalError_ || isTerminalErrorLine(line);
|
||||
}
|
||||
}
|
||||
|
||||
bool MoonlightOutputParser::isReady() const {
|
||||
return hasTestDecode_ && hasRenderer_ && !hasTerminalError_;
|
||||
}
|
||||
|
||||
MoonlightSession::MoonlightSession(QObject* parent) : QObject(parent) {
|
||||
process_ = new QProcess(this);
|
||||
process_->setProcessChannelMode(QProcess::MergedChannels);
|
||||
connect(process_, &QProcess::started, this, &MoonlightSession::onProcessStarted);
|
||||
connect(process_,
|
||||
QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished), this,
|
||||
&MoonlightSession::onProcessFinished);
|
||||
connect(process_, &QProcess::errorOccurred, this, &MoonlightSession::onProcessError);
|
||||
connect(process_, &QProcess::readyReadStandardOutput, this,
|
||||
&MoonlightSession::onProcessOutput);
|
||||
|
||||
readyTimer_ = new QTimer(this);
|
||||
readyTimer_->setSingleShot(true);
|
||||
connect(readyTimer_, &QTimer::timeout, this, [this]() {
|
||||
if (!readyEventDelivered_ && !terminalEventDelivered_ && isRunning()) {
|
||||
readyEventDelivered_ = true;
|
||||
emitEvent(EventType::kStreamReady, QStringLiteral("串流画面已就绪"));
|
||||
}
|
||||
});
|
||||
|
||||
killTimer_ = new QTimer(this);
|
||||
killTimer_->setSingleShot(true);
|
||||
connect(killTimer_, &QTimer::timeout, this, [this]() {
|
||||
if (isRunning()) {
|
||||
process_->kill();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
MoonlightSession::~MoonlightSession() {
|
||||
stop();
|
||||
callback_ = nullptr;
|
||||
readyTimer_->stop();
|
||||
killTimer_->stop();
|
||||
if (isRunning()) {
|
||||
process_->kill();
|
||||
process_->waitForFinished(1000);
|
||||
}
|
||||
}
|
||||
|
||||
void MoonlightSession::setOptions(const Options& options) {
|
||||
@ -19,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<int, QProcess::ExitStatus>::of(&QProcess::finished), this,
|
||||
&MoonlightSession::onProcessFinished);
|
||||
connect(process_, &QProcess::errorOccurred, this, &MoonlightSession::onProcessError);
|
||||
connect(process_, &QProcess::readyReadStandardOutput, this, [this, logPath]() {
|
||||
QFile log(logPath);
|
||||
if (log.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text)) {
|
||||
QTextStream(&log) << QString::fromUtf8(process_->readAllStandardOutput());
|
||||
}
|
||||
});
|
||||
|
||||
if (callback_) {
|
||||
callback_(true, QStringLiteral("串流进程已启动"));
|
||||
}
|
||||
process_->start();
|
||||
return true;
|
||||
}
|
||||
|
||||
void MoonlightSession::onProcessStarted() {
|
||||
emitEvent(EventType::kStarted, QStringLiteral("串流进程已启动"));
|
||||
}
|
||||
|
||||
void MoonlightSession::onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus) {
|
||||
QString message;
|
||||
if (exitStatus == QProcess::CrashExit) {
|
||||
message = QStringLiteral("串流异常退出 (code=%1)").arg(exitCode);
|
||||
} else {
|
||||
message = QStringLiteral("串流已结束 (code=%1)").arg(exitCode);
|
||||
}
|
||||
process_->deleteLater();
|
||||
process_ = nullptr;
|
||||
if (callback_) {
|
||||
callback_(false, message);
|
||||
readyTimer_->stop();
|
||||
killTimer_->stop();
|
||||
|
||||
if (stopRequested_) {
|
||||
emitTerminalEvent(EventType::kFinished, QStringLiteral("串流已结束"));
|
||||
return;
|
||||
}
|
||||
|
||||
const QString message = exitStatus == QProcess::CrashExit
|
||||
? QStringLiteral("串流异常退出 (code=%1)").arg(exitCode)
|
||||
: QStringLiteral("串流已结束 (code=%1)").arg(exitCode);
|
||||
emitTerminalEvent(exitStatus == QProcess::CrashExit ? EventType::kFailed
|
||||
: EventType::kFinished,
|
||||
message);
|
||||
}
|
||||
|
||||
void MoonlightSession::onProcessError(QProcess::ProcessError error) {
|
||||
QString message;
|
||||
switch (error) {
|
||||
case QProcess::FailedToStart:
|
||||
message = QStringLiteral("无法启动 moonlight-qt,请检查安装");
|
||||
break;
|
||||
case QProcess::Crashed:
|
||||
return; // 由 finished(CrashExit) 统一上报
|
||||
default:
|
||||
message = QStringLiteral("串流进程错误 (%1)").arg(static_cast<int>(error));
|
||||
break;
|
||||
if (error == QProcess::Crashed) {
|
||||
return;
|
||||
}
|
||||
if (callback_) {
|
||||
callback_(false, message);
|
||||
|
||||
const QString message = error == QProcess::FailedToStart
|
||||
? QStringLiteral("无法启动 moonlight-qt,请检查安装")
|
||||
: QStringLiteral("串流进程错误 (%1)").arg(static_cast<int>(error));
|
||||
emitTerminalEvent(EventType::kFailed, message);
|
||||
}
|
||||
|
||||
void MoonlightSession::onProcessOutput() {
|
||||
const QByteArray output = process_->readAllStandardOutput();
|
||||
if (output.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
QFile log(logPath_);
|
||||
if (log.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text)) {
|
||||
log.write(output);
|
||||
}
|
||||
|
||||
const bool wasReady = outputParser_.isReady();
|
||||
outputParser_.append(output);
|
||||
if (outputParser_.hasTerminalError()) {
|
||||
readyTimer_->stop();
|
||||
emitTerminalEvent(EventType::kFailed, QStringLiteral("云桌面连接已中断"));
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
if (!wasReady && outputParser_.isReady()) {
|
||||
scheduleReadyEvent();
|
||||
}
|
||||
}
|
||||
|
||||
void MoonlightSession::stop() {
|
||||
if (!process_) {
|
||||
if (!isRunning()) {
|
||||
return;
|
||||
}
|
||||
QProcess* proc = process_;
|
||||
process_ = nullptr; // 先断开自身引用,避免 finished 回调再触发状态迁移
|
||||
disconnect(proc, nullptr, this, nullptr);
|
||||
proc->terminate();
|
||||
if (!proc->waitForFinished(2000)) {
|
||||
proc->kill();
|
||||
proc->waitForFinished(1000);
|
||||
stopRequested_ = true;
|
||||
readyTimer_->stop();
|
||||
process_->terminate();
|
||||
killTimer_->start(kTerminateGraceMs);
|
||||
}
|
||||
|
||||
void MoonlightSession::emitEvent(EventType type, const QString& message) {
|
||||
if (callback_) {
|
||||
callback_(Event{type, message});
|
||||
}
|
||||
}
|
||||
|
||||
void MoonlightSession::emitTerminalEvent(EventType type, const QString& message) {
|
||||
if (terminalEventDelivered_) {
|
||||
return;
|
||||
}
|
||||
terminalEventDelivered_ = true;
|
||||
emitEvent(type, message);
|
||||
}
|
||||
|
||||
void MoonlightSession::scheduleReadyEvent() {
|
||||
if (!readyEventDelivered_ && !terminalEventDelivered_ && isRunning()) {
|
||||
readyTimer_->start(kReadyStableMs);
|
||||
}
|
||||
proc->deleteLater();
|
||||
}
|
||||
|
||||
} // namespace vdi
|
||||
|
||||
@ -1,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 <QByteArray>
|
||||
#include <QProcess>
|
||||
#include <QString>
|
||||
#include <functional>
|
||||
|
||||
#include "desktop_session.h"
|
||||
|
||||
class QTimer;
|
||||
|
||||
namespace vdi {
|
||||
|
||||
class MoonlightOutputParser {
|
||||
public:
|
||||
void reset();
|
||||
void append(const QByteArray& output);
|
||||
bool isReady() const;
|
||||
bool hasTestDecode() const { return hasTestDecode_; }
|
||||
bool hasRenderer() const { return hasRenderer_; }
|
||||
bool hasTerminalError() const { return hasTerminalError_; }
|
||||
|
||||
private:
|
||||
QByteArray buffer_;
|
||||
bool hasTestDecode_ = false;
|
||||
bool hasRenderer_ = false;
|
||||
bool hasTerminalError_ = false;
|
||||
};
|
||||
|
||||
class MoonlightSession : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
struct Options {
|
||||
// 低延迟开关(实测 A/B 后定版)
|
||||
bool vsync = true;
|
||||
bool framePacing = true;
|
||||
bool performanceOverlay = false;
|
||||
bool audioOnHost = true;
|
||||
QString videoCodec = QStringLiteral("H.264");
|
||||
QString decoder = QStringLiteral("software"); // RK3528 无可用 V4L2 硬解设备
|
||||
QString decoder = QStringLiteral("software");
|
||||
};
|
||||
|
||||
using StateCallback = std::function<void(bool running, const QString& message)>;
|
||||
enum class EventType {
|
||||
kStarted,
|
||||
kStreamReady,
|
||||
kFailed,
|
||||
kFinished,
|
||||
};
|
||||
|
||||
struct Event {
|
||||
EventType type;
|
||||
QString message;
|
||||
};
|
||||
|
||||
using EventCallback = std::function<void(const Event& event)>;
|
||||
|
||||
explicit MoonlightSession(QObject* parent = nullptr);
|
||||
~MoonlightSession() override;
|
||||
|
||||
void setOptions(const Options& options);
|
||||
|
||||
// 启动串流;成功返回 true(不代表串流已建立,仅代表进程已拉起)
|
||||
bool start(const ConnectionTicket& ticket, StateCallback callback);
|
||||
|
||||
// 终止串流(管理菜单/退出登录时调用)
|
||||
bool start(const ConnectionTicket& ticket, EventCallback callback);
|
||||
void stop();
|
||||
|
||||
bool isRunning() const;
|
||||
|
||||
// 串流进程 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
|
||||
|
||||
@ -2,7 +2,9 @@
|
||||
|
||||
#include <QEvent>
|
||||
#include <QFont>
|
||||
#include <QGuiApplication>
|
||||
#include <QLabel>
|
||||
#include <QScreen>
|
||||
#include <QTimer>
|
||||
#include <QVBoxLayout>
|
||||
#include <QVariant>
|
||||
@ -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() {
|
||||
|
||||
53
terminal/kiosk-shell/tests/test_desktop_list_page.cpp
Normal file
53
terminal/kiosk-shell/tests/test_desktop_list_page.cpp
Normal file
@ -0,0 +1,53 @@
|
||||
#include <QPushButton>
|
||||
#include <QSignalSpy>
|
||||
#include <QtTest>
|
||||
|
||||
#include "desktop_list_page.h"
|
||||
|
||||
using namespace vdi;
|
||||
|
||||
class DesktopListPageTest : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
void firstClickDisablesActionsAndBlocksDuplicate();
|
||||
};
|
||||
|
||||
void DesktopListPageTest::firstClickDisablesActionsAndBlocksDuplicate() {
|
||||
DesktopListPage page;
|
||||
DesktopInfo desktop;
|
||||
desktop.id = QStringLiteral("desktop-1");
|
||||
desktop.name = QStringLiteral("Windows 10");
|
||||
desktop.os = QStringLiteral("Windows");
|
||||
desktop.state = DesktopState::kAvailable;
|
||||
page.setDesktops({desktop});
|
||||
|
||||
QPushButton* connectButton = nullptr;
|
||||
QPushButton* editButton = nullptr;
|
||||
const auto buttons = page.findChildren<QPushButton*>();
|
||||
for (QPushButton* button : buttons) {
|
||||
if (button->text() == QStringLiteral("连接")) {
|
||||
connectButton = button;
|
||||
} else if (button->text() == QStringLiteral("设置")) {
|
||||
editButton = button;
|
||||
}
|
||||
}
|
||||
QVERIFY(connectButton);
|
||||
QVERIFY(editButton);
|
||||
|
||||
QSignalSpy spy(&page, &DesktopListPage::desktopSelected);
|
||||
connectButton->click();
|
||||
QCOMPARE(spy.count(), 1);
|
||||
QVERIFY(!connectButton->isEnabled());
|
||||
QVERIFY(!editButton->isEnabled());
|
||||
|
||||
connectButton->click();
|
||||
QCOMPARE(spy.count(), 1);
|
||||
|
||||
page.setBusy(false);
|
||||
QVERIFY(connectButton->isEnabled());
|
||||
QVERIFY(editButton->isEnabled());
|
||||
}
|
||||
|
||||
QTEST_MAIN(DesktopListPageTest)
|
||||
#include "test_desktop_list_page.moc"
|
||||
94
terminal/kiosk-shell/tests/test_moonlight_session.cpp
Normal file
94
terminal/kiosk-shell/tests/test_moonlight_session.cpp
Normal file
@ -0,0 +1,94 @@
|
||||
#include <QtTest>
|
||||
|
||||
#include "moonlight_session.h"
|
||||
|
||||
using namespace vdi;
|
||||
|
||||
class MoonlightSessionTest : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
void parsesSplitReadyMarkers();
|
||||
void requiresBothReadyMarkers();
|
||||
void rejectsSecondStart();
|
||||
};
|
||||
|
||||
void MoonlightSessionTest::parsesSplitReadyMarkers() {
|
||||
MoonlightOutputParser parser;
|
||||
parser.append("Test decode succ");
|
||||
QVERIFY(!parser.isReady());
|
||||
parser.append("essful\nRenderer 'SDL' with 'DRM' backend ");
|
||||
QVERIFY(parser.hasTestDecode());
|
||||
QVERIFY(!parser.hasRenderer());
|
||||
parser.append("chosen\n");
|
||||
QVERIFY(parser.isReady());
|
||||
}
|
||||
|
||||
void MoonlightSessionTest::requiresBothReadyMarkers() {
|
||||
MoonlightOutputParser parser;
|
||||
parser.append("Test decode successful\n");
|
||||
QVERIFY(!parser.isReady());
|
||||
|
||||
parser.reset();
|
||||
parser.append("Renderer 'SDL' chosen\n");
|
||||
QVERIFY(!parser.isReady());
|
||||
|
||||
parser.append("Connection terminated: -1\n");
|
||||
QVERIFY(parser.hasTerminalError());
|
||||
QVERIFY(!parser.isReady());
|
||||
}
|
||||
|
||||
void MoonlightSessionTest::rejectsSecondStart() {
|
||||
QTemporaryDir tempDir;
|
||||
QVERIFY(tempDir.isValid());
|
||||
|
||||
const QString executable = tempDir.filePath(QStringLiteral("moonlight-qt"));
|
||||
QFile script(executable);
|
||||
QVERIFY(script.open(QIODevice::WriteOnly | QIODevice::Text));
|
||||
script.write("#!/bin/sh\n"
|
||||
"printf 'Test decode successful\\n'\n"
|
||||
"printf \"Renderer 'SDL' with 'DRM' backend chosen\\n\"\n"
|
||||
"sleep 5\n");
|
||||
script.close();
|
||||
QVERIFY(script.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner |
|
||||
QFileDevice::ExeOwner | QFileDevice::ReadGroup |
|
||||
QFileDevice::ExeGroup | QFileDevice::ReadOther |
|
||||
QFileDevice::ExeOther));
|
||||
|
||||
const QByteArray oldPath = qgetenv("PATH");
|
||||
qputenv("PATH", tempDir.path().toUtf8() + ':' + oldPath);
|
||||
|
||||
ConnectionTicket ticket;
|
||||
ticket.endpoint = QStringLiteral("127.0.0.1");
|
||||
ticket.app = QStringLiteral("Desktop");
|
||||
|
||||
MoonlightSession session;
|
||||
int started = 0;
|
||||
int ready = 0;
|
||||
int terminal = 0;
|
||||
const auto callback = [&](const MoonlightSession::Event& event) {
|
||||
if (event.type == MoonlightSession::EventType::kStarted) {
|
||||
++started;
|
||||
} else if (event.type == MoonlightSession::EventType::kStreamReady) {
|
||||
++ready;
|
||||
} else if (event.type == MoonlightSession::EventType::kFailed ||
|
||||
event.type == MoonlightSession::EventType::kFinished) {
|
||||
++terminal;
|
||||
}
|
||||
};
|
||||
|
||||
QVERIFY(session.start(ticket, callback));
|
||||
QTRY_COMPARE_WITH_TIMEOUT(started, 1, 2000);
|
||||
QVERIFY(!session.start(ticket, callback));
|
||||
QTRY_COMPARE_WITH_TIMEOUT(ready, 1, 3000);
|
||||
|
||||
session.stop();
|
||||
QTRY_COMPARE_WITH_TIMEOUT(terminal, 1, 4000);
|
||||
QCOMPARE(ready, 1);
|
||||
QCOMPARE(terminal, 1);
|
||||
|
||||
qputenv("PATH", oldPath);
|
||||
}
|
||||
|
||||
QTEST_GUILESS_MAIN(MoonlightSessionTest)
|
||||
#include "test_moonlight_session.moc"
|
||||
@ -15,25 +15,27 @@ echo "==> 检查盒子连通性"
|
||||
ssh -o ConnectTimeout=8 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'echo "box ok: $(hostname)"'
|
||||
|
||||
echo "==> 同步源码到盒子"
|
||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'rm -rf ~/vdi-build/repo/kiosk-shell/src && mkdir -p ~/vdi-build/repo/kiosk-shell/src'
|
||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'rm -rf ~/vdi-build/repo/kiosk-shell/src ~/vdi-build/repo/kiosk-shell/tests && mkdir -p ~/vdi-build/repo/kiosk-shell/src ~/vdi-build/repo/kiosk-shell/tests'
|
||||
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/CMakeLists.txt" "$BOX":~/vdi-build/repo/kiosk-shell/
|
||||
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC"/src/*.cpp "$SRC"/src/*.h "$BOX":~/vdi-build/repo/kiosk-shell/src/
|
||||
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC"/tests/*.cpp "$BOX":~/vdi-build/repo/kiosk-shell/tests/
|
||||
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/scripts/vdi-moonlight" "$BOX":~/vdi-terminal/bin/vdi-moonlight
|
||||
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/etc/moonlight.conf" "$BOX":~/.config/vdi-terminal/moonlight.conf
|
||||
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/etc/moonlight.conf" "$BOX":/tmp/vdi-moonlight.conf
|
||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'mkdir -p ~/.config/vdi-terminal; test -f ~/.config/vdi-terminal/moonlight.conf || cp /tmp/vdi-moonlight.conf ~/.config/vdi-terminal/moonlight.conf; rm -f /tmp/vdi-moonlight.conf'
|
||||
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/vdi-kiosk.desktop" "$BOX":~/.config/autostart/vdi-kiosk.desktop
|
||||
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/vdi-moonlight.desktop" "$BOX":~/.config/autostart/vdi-moonlight.desktop
|
||||
|
||||
echo "==> 在盒子上编译"
|
||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'sed -i "s/\r$//" ~/vdi-build/repo/kiosk-shell/src/*.cpp ~/vdi-build/repo/kiosk-shell/src/*.h ~/vdi-terminal/bin/vdi-moonlight; chmod +x ~/vdi-terminal/bin/vdi-moonlight; cd ~/vdi-build/repo/kiosk-shell && (cmake -G Ninja -B build -DCMAKE_BUILD_TYPE=Release > /tmp/cmake.log 2>&1 || (tail -30 /tmp/cmake.log; exit 1)) && cmake --build build 2>&1 | tail -8'
|
||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'sed -i "s/\r$//" ~/vdi-build/repo/kiosk-shell/src/*.cpp ~/vdi-build/repo/kiosk-shell/src/*.h ~/vdi-build/repo/kiosk-shell/tests/*.cpp ~/vdi-terminal/bin/vdi-moonlight; chmod +x ~/vdi-terminal/bin/vdi-moonlight; cd ~/vdi-build/repo/kiosk-shell && (cmake -G Ninja -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON > /tmp/cmake.log 2>&1 || (tail -30 /tmp/cmake.log; exit 1)) && cmake --build build && QT_QPA_PLATFORM=offscreen ctest --test-dir build --output-on-failure'
|
||||
|
||||
echo "==> 安装二进制"
|
||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'pkill -f "/vdi-kiosk-monitor " 2>/dev/null || true; pkill -f "/bin/sh /home/armbian/vdi-terminal/bin/vdi-moonlight" 2>/dev/null || true; pkill -f "^moonlight-qt stream" 2>/dev/null || true; sleep 2; cp ~/vdi-build/repo/kiosk-shell/build/kiosk-shell ~/vdi-terminal/bin/kiosk-shell && cp ~/vdi-build/repo/kiosk-shell/build/vdi-kiosk-monitor ~/vdi-terminal/bin/vdi-kiosk-monitor && chmod +x ~/vdi-terminal/bin/kiosk-shell ~/vdi-terminal/bin/vdi-kiosk-monitor && ls -la ~/vdi-terminal/bin/'
|
||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'monitor_pids=$(pgrep -f "^/home/armbian/vdi-terminal/bin/vdi-kiosk-monitor -- " 2>/dev/null || true); test -z "$monitor_pids" || kill $monitor_pids 2>/dev/null || true; pair_pids=$(pgrep -f "^bash -c tail -f .*(pin|pair)_fifo" 2>/dev/null || true); test -z "$pair_pids" || kill $pair_pids 2>/dev/null || true; tail_pids=$(pgrep -f "^tail -f .*(pin|pair)_fifo" 2>/dev/null || true); test -z "$tail_pids" || kill $tail_pids 2>/dev/null || true; rm -f ~/pair_fifo /tmp/pin_fifo; for name in vdi-moonlight moonlight-qt kiosk-shell; do pids=$(pidof "$name" 2>/dev/null || true); test -z "$pids" || kill $pids 2>/dev/null || true; done; for i in $(seq 1 40); do pgrep -f "^/home/armbian/vdi-terminal/bin/vdi-kiosk-monitor -- " >/dev/null 2>&1 || break; sleep 0.2; done; monitor_pids=$(pgrep -f "^/home/armbian/vdi-terminal/bin/vdi-kiosk-monitor -- " 2>/dev/null || true); test -z "$monitor_pids" || kill -9 $monitor_pids 2>/dev/null || true; cp ~/vdi-build/repo/kiosk-shell/build/kiosk-shell ~/vdi-terminal/bin/kiosk-shell && cp ~/vdi-build/repo/kiosk-shell/build/vdi-kiosk-monitor ~/vdi-terminal/bin/vdi-kiosk-monitor && chmod +x ~/vdi-terminal/bin/kiosk-shell ~/vdi-terminal/bin/vdi-kiosk-monitor && ls -la ~/vdi-terminal/bin/'
|
||||
|
||||
echo "==> 收敛自启项(只保留 kiosk-shell 一条链)"
|
||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'mkdir -p ~/.config/autostart; rm -f ~/.config/autostart/vdi-kiosk.desktop.bak; sed -i "s/^Hidden=.*/Hidden=true/;s/^X-GNOME-Autostart-enabled=.*/X-GNOME-Autostart-enabled=false/" ~/.config/autostart/vdi-moonlight.desktop 2>/dev/null || true; grep -H . ~/.config/autostart/*.desktop'
|
||||
|
||||
echo "==> 启动受管栈并验证"
|
||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'rm -f /tmp/vdi-kiosk.heartbeat; nohup /home/armbian/vdi-terminal/bin/vdi-kiosk-monitor -- /home/armbian/vdi-terminal/bin/kiosk-shell > /tmp/vdi-monitor.log 2>&1 & sleep 8; echo "--- 进程 ---"; pgrep -af "vdi-kiosk-monitor|kiosk-shell|moonlight-qt" | grep -v pgrep; echo "--- 看门狗日志 ---"; tail -5 /tmp/vdi-monitor.log; echo "--- 心跳 ---"; cat /tmp/vdi-kiosk.heartbeat 2>/dev/null; echo'
|
||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'rm -f /tmp/vdi-kiosk.heartbeat; nohup env DISPLAY=:0 XAUTHORITY=/home/armbian/.Xauthority /home/armbian/vdi-terminal/bin/vdi-kiosk-monitor -- /home/armbian/vdi-terminal/bin/kiosk-shell > /tmp/vdi-monitor.log 2>&1 & sleep 8; echo "--- 进程 ---"; pgrep -af "vdi-kiosk-monitor|kiosk-shell|moonlight-qt" | grep -v pgrep; echo "--- 看门狗日志 ---"; tail -5 /tmp/vdi-monitor.log; echo "--- 心跳 ---"; cat /tmp/vdi-kiosk.heartbeat 2>/dev/null; echo'
|
||||
|
||||
echo "==> 抓屏确认当前界面(应为登录页)"
|
||||
echo "==> 抓屏确认当前界面(应为桌面列表)"
|
||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'DISPLAY=:0 xwd -root -silent' 2>/dev/null | python "$REPO/tools/xwd2png.py" /tmp/kiosk-login.png && echo "captured /tmp/kiosk-login.png"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user