vdi-cloud/terminal/kiosk-shell/src/main_window.cpp

648 lines
23 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#include "main_window.h"
#include <QApplication>
#include <QDateTime>
#include <QEvent>
#include <QFile>
#include <QKeyEvent>
#include <QKeySequence>
#include <QLabel>
#include <QMenu>
#include <QMessageBox>
#include <QMouseEvent>
#include <QPoint>
#include <QScreen>
#include <QShortcut>
#include <QStackedWidget>
#include <QStatusBar>
#include <QTimer>
#include <QVBoxLayout>
#include <cstring>
#include <freerdp/codec/color.h>
#include <freerdp/locale/keyboard.h>
#include <freerdp/scancode.h>
#include "desktop_list_page.h"
#include "login_page.h"
namespace vdi {
namespace {
// FreeRDP 像素格式 -> QImage::Format
// 注意:FreeRDP 的像素格式值是计算宏(FREERDP_PIXEL_FORMAT),
// 不能硬编码数值,必须用宏直接比较。
// FreeRDP 主缓冲是 4 字节/像素(BGRX / BGRA / RGBA),
// QImage 没有对应的 4 字节 BGRA 格式,因此统一转成 RGB888 输出。
QImage::Format toQImageFormat(uint32_t freerdpFormat) {
(void)freerdpFormat;
return QImage::Format_RGB888;
}
// FreeRDP BGRX32 在当前 ARM64 小端目标上与 QImage::Format_RGB32 的内存布局一致。
QImage convertFrame(const uint8_t* data, uint32_t width, uint32_t height, uint32_t bytesPerLine,
uint32_t freerdpFormat) {
if (!data || width == 0 || height == 0) {
return QImage();
}
if (freerdpFormat == PIXEL_FORMAT_BGRX32 || freerdpFormat == PIXEL_FORMAT_BGRA32) {
QImage out(static_cast<int>(width), static_cast<int>(height), QImage::Format_RGB32);
const size_t rowBytes = static_cast<size_t>(width) * 4;
for (uint32_t y = 0; y < height; ++y) {
std::memcpy(out.scanLine(static_cast<int>(y)),
data + static_cast<size_t>(y) * bytesPerLine, rowBytes);
}
return out;
}
QImage out(static_cast<int>(width), static_cast<int>(height), QImage::Format_RGB888);
for (uint32_t y = 0; y < height; ++y) {
const uint8_t* src = data + static_cast<size_t>(y) * bytesPerLine;
uint8_t* dst = out.scanLine(static_cast<int>(y));
for (uint32_t x = 0; x < width; ++x) {
const uint8_t* p = src + static_cast<size_t>(x) * 4;
dst[0] = p[0];
dst[1] = p[1];
dst[2] = p[2];
dst += 3;
}
}
return out;
}
constexpr int kReconnectDelayMs = 3000;
constexpr int kMaxReconnectAttempts = 5;
} // namespace
MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
fsm_ = StateMachine([this](State from, State to, const QString& detail) {
onStateChanged(from, to, detail);
});
setupUi();
setupHotkey();
setupHeartbeat();
keyboardLayout_ = freerdp_keyboard_init(0);
config_ = loadConfig();
// 开机永远进入登录页;不再根据本地配置自动连接 RDP,
// 会话只能由用户在桌面列表选择后启动。
fsm_.transitionTo(State::kConfig, QStringLiteral("等待登录"));
showLogin();
}
MainWindow::~MainWindow() {
stopSessions();
}
void MainWindow::setupUi() {
setWindowTitle(QStringLiteral("VDI 云终端"));
setWindowState(Qt::WindowFullScreen);
setStyleSheet(QStringLiteral("QMainWindow { background: #0f1420; }"));
stack_ = new QStackedWidget(this);
stack_->setStyleSheet(QStringLiteral("background: #0f1420;"));
setCentralWidget(stack_);
// 登录页
auto* login = new LoginPage(config_, this);
loginPage_ = login;
login->setStyleSheet(
QStringLiteral("QWidget { background: #0f1420; color: #e6e9ef; }"
"QLineEdit { background: #1a2233; border: 1px solid #2b3654; "
"border-radius: 4px; padding: 8px; color: #e6e9ef; }"
"QLineEdit:focus { border: 1px solid #2f6feb; }"
"QPushButton { background: #232d44; border: 1px solid #2b3654; "
"border-radius: 6px; color: #e6e9ef; }"
"QPushButton:hover { background: #2b3654; }"));
stack_->addWidget(login);
connect(login, &LoginPage::loginRequested, this, &MainWindow::onLoginRequested);
connect(login, &LoginPage::quitRequested, this, &MainWindow::onQuitRequested);
// 桌面列表页
auto* list = new DesktopListPage(this);
desktopListPage_ = list;
list->setStyleSheet(
QStringLiteral("QWidget { background: #0f1420; color: #e6e9ef; }"
"QPushButton { background: #232d44; border: 1px solid #2b3654; "
"border-radius: 6px; color: #e6e9ef; }"
"QPushButton:hover { background: #2b3654; }"));
stack_->addWidget(list);
connect(list, &DesktopListPage::desktopSelected, this, &MainWindow::onDesktopSelected);
connect(list, &DesktopListPage::logoutRequested, this, &MainWindow::onLogoutRequested);
// 会话视图
auto* sessionWidget = new QWidget(this);
auto* sessionLayout = new QVBoxLayout(sessionWidget);
sessionLayout->setContentsMargins(0, 0, 0, 0);
sessionView_ = new QLabel(sessionWidget);
sessionView_->setFocusPolicy(Qt::StrongFocus);
sessionView_->setMouseTracking(true);
sessionView_->installEventFilter(this);
sessionView_->setAlignment(Qt::AlignCenter);
sessionView_->setStyleSheet(QStringLiteral("background: #000000; color: #e6e9ef;"));
sessionView_->setText(QStringLiteral("正在建立远程会话…"));
sessionOverlay_ = new QLabel(sessionWidget);
sessionOverlay_->setAlignment(Qt::AlignCenter);
sessionOverlay_->setStyleSheet(
QStringLiteral("background: rgba(15,20,32,0.82); color: #e6e9ef; "
"font-size: 20px; padding: 40px;"));
sessionOverlay_->setText(QStringLiteral("正在连接…"));
sessionOverlay_->hide();
sessionLayout->addWidget(sessionView_);
sessionLayout->addWidget(sessionOverlay_);
stack_->addWidget(sessionWidget);
// 状态条
statusBar_ = new QStatusBar(this);
statusBar_->setStyleSheet(QStringLiteral("QStatusBar { background: #161d2e; color: #8a94a6; }"));
setStatusBar(statusBar_);
// RDP 重连定时器
reconnectTimer_ = new QTimer(this);
reconnectTimer_->setSingleShot(true);
connect(reconnectTimer_, &QTimer::timeout, this, [this]() {
if (fsm_.current() == State::kReconnect && reconnectAttempts_ < kMaxReconnectAttempts) {
++reconnectAttempts_;
if (activeTicket_.protocol == Protocol::kMoonlight) {
startMoonlightSession(activeTicket_);
} else {
startRdpSession(activeTicket_);
}
} else if (fsm_.current() == State::kReconnect) {
stopSessions();
fsm_.transitionTo(State::kDesktopList, QStringLiteral("重连失败,已返回桌面列表"));
}
});
frameRefreshTimer_ = new QTimer(this);
frameRefreshTimer_->setInterval(33);
connect(frameRefreshTimer_, &QTimer::timeout, this, &MainWindow::onFrameRefresh);
frameRefreshTimer_->start();
}
void MainWindow::setupHotkey() {
// 全局热键 Ctrl+Alt+Shift+M 呼出管理菜单(方案 3.2 节)
auto* shortcut = new QShortcut(QKeySequence(QStringLiteral("Ctrl+Alt+Shift+M")), this);
shortcut->setContext(Qt::ApplicationShortcut);
connect(shortcut, &QShortcut::activated, this, [this]() { onMenuAction(0); });
}
void MainWindow::setupHeartbeat() {
// 定期写心跳文件,供看门狗 Monitor 判断主进程是否假死
// 对应会议纪要要求的双进程架构
const QString path = QStringLiteral("/tmp/vdi-kiosk.heartbeat");
heartbeatTimer_ = new QTimer(this);
heartbeatTimer_->setInterval(5000);
connect(heartbeatTimer_, &QTimer::timeout, this, [path]() {
QFile file(path);
if (file.open(QIODevice::WriteOnly | QIODevice::Text)) {
file.write(QByteArray::number(QDateTime::currentSecsSinceEpoch()));
file.close();
}
});
heartbeatTimer_->start();
}
void MainWindow::onStateChanged(State from, State to, const QString& detail) {
Q_UNUSED(from);
const QString stateName = QString::fromLatin1(stateToString(to));
updateStatusBar(detail.isEmpty() ? stateName : QStringLiteral("%1 %2").arg(stateName, detail));
switch (to) {
case State::kConfig:
showLogin();
break;
case State::kAuthenticating:
break;
case State::kDesktopList:
showDesktopList();
refreshDesktopList();
break;
case State::kConnecting:
if (stack_->currentWidget() != sessionView_->parentWidget()) {
stack_->setCurrentWidget(sessionView_->parentWidget());
}
sessionView_->setFocus(Qt::OtherFocusReason);
sessionOverlay_->setText(QStringLiteral("正在连接 %1 …").arg(activeTicket_.endpoint));
sessionOverlay_->show();
break;
case State::kSession:
sessionView_->setFocus(Qt::OtherFocusReason);
sessionOverlay_->hide();
break;
case State::kReconnect:
sessionOverlay_->setText(QStringLiteral("连接已断开,正在重连…"));
sessionOverlay_->show();
reconnectTimer_->start(kReconnectDelayMs);
break;
case State::kError:
stopSessions();
fsm_.transitionTo(State::kDesktopList, QStringLiteral("连接失败"));
if (desktopListPage_) {
desktopListPage_->setBusy(false);
desktopListPage_->setStatus(QStringLiteral("连接失败:%1").arg(detail), true);
}
break;
case State::kBoot:
break;
}
}
void MainWindow::updateStatusBar(const QString& text) {
statusBar_->showMessage(text);
}
void MainWindow::showLogin() {
stack_->setCurrentWidget(loginPage_);
if (loginPage_) {
loginPage_->setBusy(false);
loginPage_->setStatus(QStringLiteral("请输入用户名和密码登录"));
}
}
void MainWindow::showDesktopList() {
if (!desktopListPage_) {
return;
}
stack_->setCurrentWidget(desktopListPage_);
desktopListPage_->setCurrentUser(currentUser_);
desktopListPage_->setBusy(false);
}
void MainWindow::refreshDesktopList() {
desktopService_.listDesktops([this](std::vector<DesktopInfo> list, const QString& error) {
if (error.isEmpty()) {
desktops_ = list;
}
if (desktopListPage_) {
desktopListPage_->setDesktops(desktops_);
if (!error.isEmpty()) {
desktopListPage_->setStatus(error, true);
}
}
});
}
void MainWindow::onLoginRequested(const QString& username, const QString& password) {
desktopService_.login(username, password, [this](const AuthResult& result) {
if (!loginPage_) {
return;
}
if (!result.ok) {
loginPage_->setBusy(false);
loginPage_->setStatus(result.message, true);
return;
}
currentUser_ = result.displayName;
fsm_.transitionTo(State::kAuthenticating, QStringLiteral("认证成功"));
fsm_.transitionTo(State::kDesktopList, QStringLiteral("欢迎 %1").arg(currentUser_));
});
}
void MainWindow::onDesktopSelected(const QString& desktopId) {
if (!fsm_.canConnect()) {
return;
}
desktopService_.createTicket(desktopId, [this](const ConnectionTicket& ticket,
const QString& error) {
if (!error.isEmpty()) {
if (desktopListPage_) {
desktopListPage_->setBusy(false);
desktopListPage_->setStatus(error, true);
}
return;
}
activeTicket_ = ticket;
reconnectAttempts_ = 0;
if (ticket.protocol == Protocol::kMoonlight) {
startMoonlightSession(ticket);
} else {
startRdpSession(ticket);
}
});
}
void MainWindow::onLogoutRequested() {
stopSessions();
desktopService_.logout();
currentUser_.clear();
fsm_.transitionTo(State::kConfig, QStringLiteral("已退出登录"));
}
void MainWindow::startRdpSession(const ConnectionTicket& ticket) {
++sessionEpoch_;
{
QMutexLocker locker(&frameMutex_);
latestFrame_ = QImage();
frameEpoch_ = sessionEpoch_;
frameWakePending_ = false;
}
if (fsm_.current() == State::kSession) {
fsm_.transitionTo(State::kReconnect, QStringLiteral("手动重新连接"));
}
if (!fsm_.transitionTo(State::kConnecting, ticket.endpoint)) {
if (fsm_.current() != State::kConnecting) {
return;
}
}
RdpOptions opts;
opts.server = ticket.endpoint.toStdString();
opts.username = ticket.username.toStdString();
opts.password = ticket.password.toStdString();
opts.keyboardLayout = keyboardLayout_;
opts.width = ticket.width;
opts.height = ticket.height;
opts.bpp = 32;
opts.fullscreen = true;
// 与 xfreerdp3 相同的 GFX 能力组合语义:
// FreeRDP 3.32.1 不接受单个管道名,必须成组。
opts.gfx = "progressive|RFX|AVC420|AVC444";
opts.ignoreCertificate = true; // POC 阶段;正式产品必须做证书校验
RdpCallbacks cb;
cb.onFrame = [this](const uint8_t* data, uint32_t width, uint32_t height, uint32_t stride,
uint32_t format) {
// 在 FreeRDP 线程中复制帧,不能把其内部缓冲区裸指针排队到 GUI 线程。
const QImage image = convertFrame(data, width, height, stride, format);
if (image.isNull()) {
return;
}
QMutexLocker locker(&frameMutex_);
latestFrame_ = image;
frameEpoch_ = sessionEpoch_;
if (frameWakePending_) {
return;
}
frameWakePending_ = true;
locker.unlock();
QMetaObject::invokeMethod(this, &MainWindow::onFrameWakeup, Qt::QueuedConnection);
};
cb.onState = [this](bool connected, const std::string& message) {
QMetaObject::invokeMethod(
this,
[this, connected, message]() {
onSessionState(connected, QString::fromStdString(message));
},
Qt::QueuedConnection);
};
rdpSession_ = std::make_unique<RdpSession>();
rdpSession_->start(opts, cb);
}
void MainWindow::startMoonlightSession(const ConnectionTicket& ticket) {
if (!fsm_.transitionTo(State::kConnecting, ticket.endpoint)) {
if (fsm_.current() != State::kConnecting) {
return;
}
}
// Moonlight 是外部全屏进程:Kiosk 画面停在"正在连接"覆盖层,
// 串流窗口由 moonlight-qt 自行置顶显示。
if (stack_->currentWidget() != sessionView_->parentWidget()) {
stack_->setCurrentWidget(sessionView_->parentWidget());
}
sessionOverlay_->setText(QStringLiteral("正在连接 %1 …").arg(ticket.endpoint));
sessionOverlay_->show();
MoonlightSession::Options opts;
opts.decoder = QStringLiteral("software"); // RK3528 无可用 V4L2 硬解设备
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) {
QMetaObject::invokeMethod(
this,
[this, running, message]() { onMoonlightState(running, message); },
Qt::QueuedConnection);
});
}
void MainWindow::onMoonlightState(bool running, const QString& message) {
if (running) {
// 进程已拉起;串流窗口是否成功由 moonlight 自身展示
updateStatusBar(QStringLiteral("SESSION %1").arg(message));
return;
}
// 进程退出:回桌面列表
moonlightSession_.reset();
stopSessions();
if (fsm_.current() == State::kConnecting || fsm_.current() == State::kSession) {
fsm_.transitionTo(State::kDesktopList, message);
}
if (desktopListPage_) {
desktopListPage_->setBusy(false);
desktopListPage_->setStatus(message);
}
}
void MainWindow::onSessionState(bool connected, const QString& message) {
if (connected) {
reconnectAttempts_ = 0;
fsm_.transitionTo(State::kSession, message);
} else {
if (fsm_.current() == State::kConnecting) {
// 首次连接失败:回桌面列表并提示
stopSessions();
fsm_.transitionTo(State::kDesktopList, message);
if (desktopListPage_) {
desktopListPage_->setBusy(false);
desktopListPage_->setStatus(QStringLiteral("连接失败:%1").arg(message), true);
}
} else if (fsm_.current() == State::kSession) {
fsm_.transitionTo(State::kReconnect, message);
}
}
}
void MainWindow::onFrameWakeup() {
QMutexLocker locker(&frameMutex_);
frameWakePending_ = false;
}
void MainWindow::onFrameRefresh() {
QImage image;
uint64_t epoch = 0;
{
QMutexLocker locker(&frameMutex_);
image = latestFrame_;
epoch = frameEpoch_;
latestFrame_ = QImage();
frameWakePending_ = false;
}
if (epoch == sessionEpoch_) {
onFrameImage(image);
}
}
void MainWindow::onFrameImage(const QImage& image) {
if (image.isNull()) {
return;
}
lastFrame_ = image;
if (sessionView_) {
const QSize targetSize = sessionView_->size();
if (lastFrame_.size() == targetSize) {
sessionView_->setPixmap(QPixmap::fromImage(lastFrame_));
} else {
sessionView_->setPixmap(QPixmap::fromImage(lastFrame_).scaled(
targetSize, Qt::KeepAspectRatio, Qt::FastTransformation));
}
}
}
void MainWindow::onQuitRequested() {
stopSessions();
QApplication::quit();
}
void MainWindow::stopSessions() {
if (rdpSession_) {
rdpSession_->stop();
rdpSession_.reset();
}
if (moonlightSession_) {
moonlightSession_->stop();
moonlightSession_.reset();
}
QMutexLocker locker(&frameMutex_);
latestFrame_ = QImage();
lastFrame_ = QImage();
frameWakePending_ = false;
}
void MainWindow::onMenuAction(int action) {
switch (action) {
case 1: // 重新连接当前桌面
if (fsm_.canConnect() && !activeTicket_.desktopId.isEmpty()) {
reconnectAttempts_ = 0;
stopSessions();
if (activeTicket_.protocol == Protocol::kMoonlight) {
startMoonlightSession(activeTicket_);
} else {
startRdpSession(activeTicket_);
}
}
break;
case 2: // 断开回桌面列表
stopSessions();
if (fsm_.current() != State::kConfig) {
fsm_.transitionTo(State::kDesktopList, "已断开");
}
break;
case 3: // 退出终端
onQuitRequested();
break;
default: {
QMenu menu(this);
QAction* reconnect = menu.addAction(QStringLiteral("重新连接当前桌面"));
QAction* disconnect = menu.addAction(QStringLiteral("断开并返回桌面列表"));
menu.addSeparator();
QAction* quit = menu.addAction(QStringLiteral("退出终端"));
QAction* chosen = menu.exec(QCursor::pos());
if (chosen == reconnect) {
onMenuAction(1);
} else if (chosen == disconnect) {
onMenuAction(2);
} else if (chosen == quit) {
onMenuAction(3);
}
if (stack_->currentWidget() == sessionView_->parentWidget()) {
sessionView_->setFocus(Qt::OtherFocusReason);
}
break;
}
}
}
bool MainWindow::eventFilter(QObject* watched, QEvent* event) {
if (watched == sessionView_ && rdpSession_) {
if (event->type() == QEvent::KeyPress || event->type() == QEvent::KeyRelease) {
auto* keyEvent = static_cast<QKeyEvent*>(event);
return forwardKeyEvent(keyEvent, event->type() == QEvent::KeyPress);
}
if (event->type() == QEvent::MouseMove || event->type() == QEvent::MouseButtonPress ||
event->type() == QEvent::MouseButtonRelease) {
return forwardMouseEvent(static_cast<QMouseEvent*>(event));
}
if (event->type() == QEvent::Wheel) {
return forwardMouseEvent(event);
}
}
return QMainWindow::eventFilter(watched, event);
}
bool MainWindow::forwardKeyEvent(QKeyEvent* event, bool pressed) {
if (!rdpSession_) {
return false;
}
const DWORD rdpScanCode =
freerdp_keyboard_get_rdp_scancode_from_x11_keycode(event->nativeScanCode());
if (rdpScanCode == RDP_SCANCODE_UNKNOWN) {
return false;
}
return rdpSession_->sendKeyEvent(pressed, event->isAutoRepeat(), rdpScanCode);
}
bool MainWindow::forwardMouseEvent(QEvent* event) {
if (!rdpSession_) {
return false;
}
uint16_t flags = 0;
QPoint pos = lastMousePosition_;
if (event->type() == QEvent::MouseMove) {
auto* mouse = static_cast<QMouseEvent*>(event);
pos = mouse->pos();
flags = PTR_FLAGS_MOVE;
} else if (event->type() == QEvent::MouseButtonPress ||
event->type() == QEvent::MouseButtonRelease) {
auto* mouse = static_cast<QMouseEvent*>(event);
pos = mouse->pos();
if (mouse->button() == Qt::LeftButton) flags |= PTR_FLAGS_BUTTON1;
if (mouse->button() == Qt::RightButton) flags |= PTR_FLAGS_BUTTON2;
if (mouse->button() == Qt::MiddleButton) flags |= PTR_FLAGS_BUTTON3;
if (event->type() == QEvent::MouseButtonPress) flags |= PTR_FLAGS_DOWN;
} else if (event->type() == QEvent::Wheel) {
auto* wheel = static_cast<QWheelEvent*>(event);
pos = wheel->position().toPoint();
flags = PTR_FLAGS_WHEEL;
if (wheel->angleDelta().y() < 0) flags |= PTR_FLAGS_WHEEL_NEGATIVE;
}
lastMousePosition_ = pos;
if (lastFrame_.isNull() || !sessionView_) {
return false;
}
const QSize sourceSize = lastFrame_.size();
const QSize viewSize = sessionView_->size();
const QSize displayedSize = sourceSize.scaled(viewSize, Qt::KeepAspectRatio);
const int offsetX = (viewSize.width() - displayedSize.width()) / 2;
const int offsetY = (viewSize.height() - displayedSize.height()) / 2;
if (pos.x() < offsetX || pos.y() < offsetY ||
pos.x() >= offsetX + displayedSize.width() ||
pos.y() >= offsetY + displayedSize.height()) {
return true;
}
const int remoteX = qBound(0,
(pos.x() - offsetX) * sourceSize.width() / displayedSize.width(),
sourceSize.width() - 1);
const int remoteY = qBound(0,
(pos.y() - offsetY) * sourceSize.height() / displayedSize.height(),
sourceSize.height() - 1);
return rdpSession_->sendMouseEvent(flags, static_cast<uint16_t>(remoteX),
static_cast<uint16_t>(remoteY));
}
} // namespace vdi