vdi-cloud/terminal/kiosk-shell/src/main_window.cpp
宋一鸣 24529b80b0 功能:接入 FreeRDP 3 内嵌客户端与远程桌面
变更详情:feat(kiosk-shell): FreeRDP 3 内嵌客户端跑通,登录页可进入远程桌面

- 按方案 3.2/3.3 节以 libfreerdp3 API 内嵌,不拉起外部进程
- gdi_init 移到 PostConnect(提前调用会段错误)
- 去掉 ContextSize 扩展 rdpContext,改用 freerdp* 注册表 + gfx->custom
- NLA 认证传空字符串而非 nullptr,修复 SEC_E_NO_CREDENTIALS
- 补充 FreeRDP3 内嵌实现笔记文档
2026-10-23 18:57:05 +08:00

355 lines
12 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 <QAction>
#include <QApplication>
#include <QCloseEvent>
#include <QKeySequence>
#include <QLabel>
#include <QMenu>
#include <QMessageBox>
#include <QScreen>
#include <QShortcut>
#include <QStackedWidget>
#include <QStatusBar>
#include <QTimer>
#include <QVBoxLayout>
#include <freerdp/codec/color.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 的 4 字节/像素缓冲转成 QImage 可用的 RGB888
// bytesPerLine 是 FreeRDP 的每行字节数(width*4)
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();
}
const bool bgr = (freerdpFormat == PIXEL_FORMAT_BGRX32) ||
(freerdpFormat == PIXEL_FORMAT_BGRA32);
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;
if (bgr) {
dst[0] = p[2];
dst[1] = p[1];
dst[2] = p[0];
} else {
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();
// BOOT -> CONFIG
config_ = loadConfig();
fsm_.transitionTo(State::kConfig, "启动完成");
showLogin();
}
MainWindow::~MainWindow() {
stopSession();
}
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_);
// 登录页
config_ = loadConfig();
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; }"
"QComboBox { background: #1a2233; border: 1px solid #2b3654; "
"border-radius: 4px; padding: 8px; color: #e6e9ef; }"
"QPushButton { background: #232d44; border: 1px solid #2b3654; "
"border-radius: 6px; color: #e6e9ef; }"
"QPushButton:hover { background: #2b3654; }"
"QCheckBox { color: #8a94a6; }"));
stack_->addWidget(login);
connect(login, &LoginPage::connectRequested, this, &MainWindow::onConnectRequested);
connect(login, &LoginPage::quitRequested, this, &MainWindow::onQuitRequested);
// 会话视图
auto* sessionWidget = new QWidget(this);
auto* sessionLayout = new QVBoxLayout(sessionWidget);
sessionLayout->setContentsMargins(0, 0, 0, 0);
sessionView_ = new QLabel(sessionWidget);
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_);
// 重连定时器
reconnectTimer_ = new QTimer(this);
reconnectTimer_->setSingleShot(true);
connect(reconnectTimer_, &QTimer::timeout, this, [this]() {
if (fsm_.current() == State::kReconnect && reconnectAttempts_ < kMaxReconnectAttempts) {
++reconnectAttempts_;
startSession(config_);
} else if (fsm_.current() == State::kReconnect) {
fsm_.transitionTo(State::kError, "重连失败,请检查网络");
}
});
}
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::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::kConnecting:
if (stack_->currentWidget() != sessionView_->parentWidget()) {
stack_->setCurrentWidget(sessionView_->parentWidget());
}
sessionOverlay_->setText(QStringLiteral("正在连接 %1 …")
.arg(QString::fromStdString(config_.server)));
sessionOverlay_->show();
break;
case State::kSession:
sessionOverlay_->hide();
break;
case State::kReconnect:
sessionOverlay_->setText(QStringLiteral("连接已断开,正在重连…"));
sessionOverlay_->show();
reconnectTimer_->start(kReconnectDelayMs);
break;
case State::kError:
sessionOverlay_->setText(QStringLiteral("连接失败:%1").arg(detail));
sessionOverlay_->show();
if (loginPage_) {
loginPage_->setBusy(false);
loginPage_->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::onConnectRequested(const vdi::TerminalConfig& cfg) {
config_ = cfg;
reconnectAttempts_ = 0;
startSession(cfg);
}
void MainWindow::startSession(const TerminalConfig& cfg) {
if (!fsm_.transitionTo(State::kConnecting, QString::fromStdString(cfg.server))) {
// 已经在 CONNECTING 或 SESSION,忽略重复触发
if (fsm_.current() != State::kConnecting) {
return;
}
}
RdpOptions opts;
opts.server = cfg.server;
opts.username = cfg.username;
opts.password = cfg.password;
// 解析分辨率
int w = 1920;
int h = 1440;
if (sscanf(cfg.resolution.c_str(), "%dx%d", &w, &h) != 2) {
w = 1920;
h = 1440;
}
opts.width = w;
opts.height = h;
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 线程更新
QMetaObject::invokeMethod(
this, [this, data, width, height, stride, format]() {
onFrame(data, width, height, stride, format);
},
Qt::QueuedConnection);
};
cb.onState = [this](bool connected, const std::string& message) {
QMetaObject::invokeMethod(
this,
[this, connected, message]() {
onSessionState(connected, QString::fromStdString(message));
},
Qt::QueuedConnection);
};
stopSession();
session_ = std::make_unique<RdpSession>();
session_->start(opts, cb);
}
void MainWindow::stopSession() {
if (session_) {
session_->stop();
session_.reset();
}
}
void MainWindow::onSessionState(bool connected, const QString& message) {
if (connected) {
reconnectAttempts_ = 0;
fsm_.transitionTo(State::kSession, message);
} else {
if (fsm_.current() == State::kConnecting) {
// 首次连接失败
fsm_.transitionTo(State::kError, message);
} else if (fsm_.current() == State::kSession) {
fsm_.transitionTo(State::kReconnect, message);
}
}
}
void MainWindow::onFrame(const uint8_t* data, uint32_t width, uint32_t height, uint32_t stride,
uint32_t format) {
if (!data || width == 0 || height == 0) {
return;
}
// FreeRDP 主缓冲是 4 字节/像素,需转换后交给 Qt
QImage image = convertFrame(data, width, height, stride, format);
if (image.isNull()) {
return;
}
lastFrame_ = image;
frameDirty_ = true;
if (sessionView_) {
sessionView_->setPixmap(QPixmap::fromImage(lastFrame_).scaled(
sessionView_->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation));
}
}
void MainWindow::onQuitRequested() {
stopSession();
QApplication::quit();
}
void MainWindow::onMenuAction(int action) {
switch (action) {
case 1: // 重新连接
reconnectAttempts_ = 0;
stopSession();
startSession(config_);
break;
case 2: // 断开回登录页
stopSession();
fsm_.transitionTo(State::kConfig, "已断开");
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);
}
break;
}
}
}
bool MainWindow::eventFilter(QObject* watched, QEvent* event) {
return QMainWindow::eventFilter(watched, event);
}
} // namespace vdi