feat(kiosk-shell): 首版登录页与状态机骨架

按《VDI 云桌面软件开发方案》3.2 节实现:
- 状态机 BOOT/CONFIG/CONNECTING/SESSION/RECONNECT/ERROR
- 首配页(SN 绑定、服务器地址、账号、密码、显示档位)
- 全局热键 Ctrl+Alt+Shift+M 呼出管理菜单
- FreeRDP 3 以 libfreerdp3 API 内嵌,非拉起外部进程
This commit is contained in:
dev 2026-10-23 17:22:55 +08:00
commit 419630fcd7
14 changed files with 1374 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
build/
*.o
*.so

33
docs/api-free-rdp3.md Normal file
View File

@ -0,0 +1,33 @@
# FreeRDP 3 客户端 API 参考(Ubuntu 24.04 / freerdp3-dev)
> 用途:kiosk-shell 内嵌 FreeRDP 客户端时使用,避免拉起外部进程(开发方案 3.2 节)。
> 记录时间:2026-10-23
## 包名
- 运行时:`libfreerdp-client3-3`、`libfreerdp3-3`
- 开发:`freerdp3-dev`(Ubuntu 24.04 提供 `freerdp3-dev` 与 `libfreerdp-client3-3-dev`)
- pkg-config 名称:`freerdp3`、`freerdp3-client`(安装后以 `pkg-config --list-all | grep freerdp` 实测为准)
## 关键头文件
| 头文件 | 用途 |
|---|---|
| `freerdp/client.h` | 客户端主入口、freerdp_client_context_new / freerdp_client_context_free |
| `freerdp/freerdp.h` | 核心上下文 |
| `freerdp/settings.h` | 连接参数设置(freerdp_settings_new / set) |
| `freerdp/channels/cliprdr.h` | 剪贴板通道 |
| `freerdp/gdi/gfx.h` | GFX 图形管线(AVC420/AVC444/RFX 能力) |
| `freerdp/crypto/tls.h` | TLS |
## 最小连接流程(伪代码)
```c
freerdp_client_context* ctx = freerdp_client_context_new();
RdpClientContext* cctx = ...;
freerdp_settings_new(0); // 释放: freerdp_settings_free
freerdp_set_pointer / freerdp_set_keyboard ... // 输入回调
ctx->Connect = ...; // 事件表
freerdp_client_context_new / connect
```
## 注意事项
- `/gfx` 命令行语法在 3.32.1 上必须用 `|` 分隔的能力组合(如 `progressive|RFX|AVC420|AVC444`),
单独 `AVC420` 会解析失败。API 侧对应 `gfx` 能力位设置。

View File

@ -0,0 +1,42 @@
cmake_minimum_required(VERSION 3.16)
project(kiosk-shell CXX)
# 对应《VDI 云桌面软件开发方案》2.2 节技术栈定版:
# kiosk 与 freerdp 插件用 C/C++(贴合 GTK/GStreamer 生态)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
set(CMAKE_INCLUDE_CURRENT_DIR ON)
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release)
endif()
# ---- Qt5 ----
find_package(Qt5 REQUIRED COMPONENTS Core Gui Widgets)
# ---- FreeRDP 3 ----
find_package(PkgConfig REQUIRED)
pkg_check_modules(FREERDP REQUIRED IMPORTED_TARGET freerdp3)
add_executable(kiosk-shell
src/main.cpp
src/main_window.cpp
src/login_page.cpp
src/state_machine.cpp
src/config.cpp
src/rdp_session.cpp
)
target_include_directories(kiosk-shell PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src
)
target_link_libraries(kiosk-shell PRIVATE
Qt5::Core
Qt5::Gui
Qt5::Widgets
PkgConfig::FREERDP
)
install(TARGETS kiosk-shell RUNTIME DESTINATION bin)

View File

@ -0,0 +1,118 @@
#include "config.h"
#include <sys/stat.h>
#include <unistd.h>
#include <cstdio>
#include <cstdlib>
#include <fstream>
#include <sstream>
namespace vdi {
namespace {
std::string trim(const std::string& s) {
const char* ws = " \t\r\n";
size_t b = s.find_first_not_of(ws);
if (b == std::string::npos) return std::string();
size_t e = s.find_last_not_of(ws);
return s.substr(b, e - b + 1);
}
std::string readFirstLine(const char* path) {
std::ifstream f(path);
std::string line;
if (f && std::getline(f, line)) {
return trim(line);
}
return std::string();
}
} // namespace
std::string configDir() {
const char* home = getenv("HOME");
return std::string(home ? home : "/root") + "/.config/vdi-terminal";
}
std::string configPath() {
return configDir() + "/client.conf";
}
std::string readSerialNumber() {
// 优先设备树序列号(RK3528 ODM 通常提供)
std::string sn = readFirstLine("/proc/device-tree/serial-number");
if (!sn.empty()) return sn;
// 其次 machine-id
sn = readFirstLine("/etc/machine-id");
if (!sn.empty()) return sn;
// 兜底:用 MAC 派生
std::ifstream f("/sys/class/net/wlan0/address");
std::string mac;
if (f && std::getline(f, mac)) {
mac = trim(mac);
for (auto& c : mac) {
if (c == ':') c = '-';
}
return mac;
}
return "VDI-UNKNOWN";
}
TerminalConfig loadConfig() {
TerminalConfig cfg;
cfg.sn = readSerialNumber();
std::ifstream f(configPath());
if (!f) {
return cfg;
}
std::string line;
while (std::getline(f, line)) {
line = trim(line);
if (line.empty() || line[0] == '#') continue;
size_t eq = line.find('=');
if (eq == std::string::npos) continue;
std::string key = trim(line.substr(0, eq));
std::string val = trim(line.substr(eq + 1));
if (key == "sn") cfg.sn = val;
else if (key == "deviceKey") cfg.deviceKey = val;
else if (key == "server") cfg.server = val;
else if (key == "username") cfg.username = val;
else if (key == "password") cfg.password = val;
else if (key == "wifiSsid") cfg.wifiSsid = val;
else if (key == "wifiPassword") cfg.wifiPassword = val;
else if (key == "resolution") cfg.resolution = val;
else if (key == "remember") cfg.remember = (val == "1" || val == "true");
else if (key == "version") cfg.version = atoi(val.c_str());
}
return cfg;
}
bool saveConfig(const TerminalConfig& cfg) {
std::string dir = configDir();
mkdir(dir.c_str(), 0755);
std::string tmp = configPath() + ".tmp";
{
std::ofstream f(tmp, std::ios::trunc);
if (!f) return false;
f << "# VDI 云终端本地配置(由 kiosk-shell 维护)\n";
f << "version=" << (cfg.version + 1) << "\n";
f << "sn=" << cfg.sn << "\n";
f << "deviceKey=" << cfg.deviceKey << "\n";
f << "server=" << cfg.server << "\n";
f << "username=" << cfg.username << "\n";
f << "password=" << cfg.password << "\n";
f << "wifiSsid=" << cfg.wifiSsid << "\n";
f << "wifiPassword=" << cfg.wifiPassword << "\n";
f << "resolution=" << cfg.resolution << "\n";
f << "remember=" << (cfg.remember ? "1" : "0") << "\n";
}
return rename(tmp.c_str(), configPath().c_str()) == 0;
}
} // namespace vdi

View File

@ -0,0 +1,50 @@
// 终端本地配置
//
// 对应《VDI 云桌面软件开发方案》4.2 节 config_template 的终端侧镜像:
// 终端保存一份本地的配置版本,API 下发后覆盖。
// 当前阶段先做本地文件版本,后续由 vdt-agent 接管与 API 同步。
#ifndef KIOSK_SHELL_CONFIG_H
#define KIOSK_SHELL_CONFIG_H
#include <string>
namespace vdi {
struct TerminalConfig {
// 设备身份
std::string sn; // 序列号
std::string deviceKey; // 预置密钥(注册用)
// 首配
std::string server; // 服务器地址
std::string username; // 登录账号
std::string password; // 登录密码
std::string wifiSsid; // WiFi SSID
std::string wifiPassword;
// 显示档位:1920x1440 / 2560x1440 / 1920x1080 / 1280x720
std::string resolution = "1920x1440";
// 是否记住连接参数
bool remember = true;
int version = 0;
};
// 配置路径:~/.config/vdi-terminal/client.conf
std::string configDir();
std::string configPath();
// 读取配置;文件不存在时返回默认值
TerminalConfig loadConfig();
// 保存配置
bool saveConfig(const TerminalConfig& cfg);
// 读取设备序列号(优先取 /etc/machine-id,其次 /proc/device-tree/serial-number)
std::string readSerialNumber();
} // namespace vdi
#endif // KIOSK_SHELL_CONFIG_H

View File

@ -0,0 +1,202 @@
#include "login_page.h"
#include <QCheckBox>
#include <QComboBox>
#include <QFont>
#include <QGridLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QVBoxLayout>
namespace vdi {
namespace {
const char* kResolutionIds[] = {"1920x1440", "2560x1440", "1920x1080", "1280x720"};
QString detectNetworkSummary() {
// 简单读取默认路由与第一块非回环网卡地址
QProcess process;
process.start("sh", QStringList() << "-c" <<
"ip -4 route show default | awk '{print $NF}'; "
"ip -4 -o addr show scope global | awk '{print $4}' | head -n 1");
process.waitForFinished(2000);
const QString out = QString::fromUtf8(process.readAllStandardOutput()).trimmed();
if (out.isEmpty()) {
return QStringLiteral("网络:未连接");
}
QStringList lines = out.split('\n', Qt::SkipEmptyParts);
QString iface = lines.value(0);
QString addr = lines.value(1);
if (addr.contains('/')) {
addr = addr.split('/').value(0);
}
return QStringLiteral("网络:%1 (%2)").arg(iface, addr);
}
} // namespace
LoginPage::LoginPage(const TerminalConfig& initial, QWidget* parent)
: QWidget(parent), cfg_(initial) {
auto* root = new QVBoxLayout(this);
root->setContentsMargins(80, 60, 80, 40);
root->setSpacing(18);
// ---- 标题区 ----
auto* title = new QLabel(QStringLiteral("VDI 云终端"), this);
QFont titleFont = title->font();
titleFont.setPointSize(28);
titleFont.setBold(true);
title->setFont(titleFont);
title->setAlignment(Qt::AlignCenter);
root->addWidget(title);
auto* subtitle = new QLabel(QStringLiteral("RK3528 远程桌面接入"), this);
QFont subFont = subtitle->font();
subFont.setPointSize(13);
subtitle->setFont(subFont);
subtitle->setAlignment(Qt::AlignCenter);
subtitle->setStyleSheet(QStringLiteral("color: #8a94a6;"));
root->addWidget(subtitle);
// ---- 设备信息条 ----
auto* infoBox = new QHBoxLayout();
labelSn_ = new QLabel(QStringLiteral("设备 SN:%1").arg(cfg_.sn), this);
labelNetwork_ = new QLabel(detectNetworkSummary(), this);
labelSn_->setStyleSheet(QStringLiteral("color: #8a94a6;"));
labelNetwork_->setStyleSheet(QStringLiteral("color: #8a94a6;"));
infoBox->addWidget(labelSn_);
infoBox->addStretch(1);
infoBox->addWidget(labelNetwork_);
root->addLayout(infoBox);
// ---- 表单 ----
auto* grid = new QGridLayout();
grid->setHorizontalSpacing(14);
grid->setVerticalSpacing(12);
grid->setColumnStretch(1, 1);
editServer_ = new QLineEdit(cfg_.server, this);
editServer_->setPlaceholderText(QStringLiteral("服务器地址,例如 192.168.1.70"));
editUser_ = new QLineEdit(cfg_.username, this);
editUser_->setPlaceholderText(QStringLiteral("登录账号"));
editPassword_ = new QLineEdit(cfg_.password, this);
editPassword_->setEchoMode(QLineEdit::Password);
editPassword_->setPlaceholderText(QStringLiteral("登录密码(可留空)"));
comboResolution_ = new QComboBox(this);
for (const char* id : kResolutionIds) {
comboResolution_->addItem(QString::fromLatin1(id), QString::fromLatin1(id));
}
int resIndex = comboResolution_->findData(QString::fromStdString(cfg_.resolution));
comboResolution_->setCurrentIndex(resIndex >= 0 ? resIndex : 0);
checkRemember_ = new QCheckBox(QStringLiteral("记住连接参数"), this);
checkRemember_->setChecked(cfg_.remember);
struct Row {
QString label;
QWidget* widget;
};
const Row rows[] = {
{QStringLiteral("服务器地址"), editServer_},
{QStringLiteral("登录账号"), editUser_},
{QStringLiteral("登录密码"), editPassword_},
{QStringLiteral("显示分辨率"), comboResolution_},
};
int row = 0;
for (const Row& r : rows) {
auto* label = new QLabel(r.label, this);
label->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
grid->addWidget(label, row, 0);
grid->addWidget(r.widget, row, 1);
++row;
}
grid->addWidget(checkRemember_, row, 1);
root->addLayout(grid);
// ---- 按钮 ----
auto* buttonRow = new QHBoxLayout();
buttonRow->addStretch(1);
buttonConnect_ = new QPushButton(QStringLiteral("连接"), this);
buttonConnect_->setMinimumSize(160, 46);
buttonConnect_->setStyleSheet(
QStringLiteral("QPushButton { background: #2f6feb; color: white; border: none; "
"border-radius: 6px; font-weight: bold; }"
"QPushButton:disabled { background: #3a4a63; }"));
buttonConnect_->setDefault(true);
buttonQuit_ = new QPushButton(QStringLiteral("退出"), this);
buttonQuit_->setMinimumSize(110, 46);
buttonRow->addWidget(buttonConnect_);
buttonRow->addWidget(buttonQuit_);
buttonRow->addStretch(1);
root->addLayout(buttonRow);
// ---- 状态条 ----
labelStatus_ = new QLabel(QStringLiteral("就绪"), this);
labelStatus_->setAlignment(Qt::AlignCenter);
labelStatus_->setStyleSheet(QStringLiteral("color: #8a94a6;"));
root->addWidget(labelStatus_);
root->addStretch(1);
connect(buttonConnect_, &QPushButton::clicked, this, &LoginPage::onConnectClicked);
connect(buttonQuit_, &QPushButton::clicked, this, [this]() { emit quitRequested(); });
connect(comboResolution_, QOverload<int>::of(&LoginPage::onResolutionChanged), this,
&LoginPage::onResolutionChanged);
}
void LoginPage::setConnectHandler(ConnectHandler handler) {
connectHandler_ = std::move(handler);
}
void LoginPage::setStatus(const QString& text, bool error) {
labelStatus_->setText(text);
labelStatus_->setStyleSheet(
QStringLiteral("color: %1;").arg(error ? QStringLiteral("#e05561")
: QStringLiteral("#8a94a6")));
}
void LoginPage::setBusy(bool busy) {
buttonConnect_->setEnabled(!busy);
buttonConnect_->setText(busy ? QStringLiteral("连接中…") : QStringLiteral("连接"));
editServer_->setEnabled(!busy);
editUser_->setEnabled(!busy);
editPassword_->setEnabled(!busy);
comboResolution_->setEnabled(!busy);
}
TerminalConfig LoginPage::currentConfig() const {
TerminalConfig out = cfg_;
out.server = editServer_->text().trimmed().toStdString();
out.username = editUser_->text().trimmed().toStdString();
out.password = editPassword_->text().toStdString();
out.resolution = comboResolution_->currentData().toString().toStdString();
out.remember = checkRemember_->isChecked();
return out;
}
void LoginPage::onConnectClicked() {
TerminalConfig cfg = currentConfig();
if (cfg.server.empty()) {
setStatus(QStringLiteral("请填写服务器地址"), true);
return;
}
setBusy(true);
setStatus(QStringLiteral("正在连接 %1 …").arg(QString::fromStdString(cfg.server)));
if (cfg.remember) {
saveConfig(cfg);
}
if (connectHandler_) {
connectHandler_(cfg);
}
emit connectRequested(cfg);
}
void LoginPage::onResolutionChanged(int) {
// 分辨率变更即时生效,实际尺寸在建立连接时读取
}
} // namespace vdi

View File

@ -0,0 +1,68 @@
// 首配 / 登录页
//
// 对应《VDI 云桌面软件开发方案》3.2 节:
// 首配页(无配置时进入)——SN 绑定、WiFi 扫码、服务器地址
// 状态条——网络 / 会话状态
#ifndef KIOSK_SHELL_LOGIN_PAGE_H
#define KIOSK_SHELL_LOGIN_PAGE_H
#include <QWidget>
#include <functional>
#include "config.h"
class QLineEdit;
class QComboBox;
class QCheckBox;
class QLabel;
class QPushButton;
class QGridLayout;
class QVBoxLayout;
namespace vdi {
class LoginPage : public QWidget {
Q_OBJECT
public:
using ConnectHandler = std::function<void(const TerminalConfig&)>;
explicit LoginPage(const TerminalConfig& initial, QWidget* parent = nullptr);
void setConnectHandler(ConnectHandler handler);
void setStatus(const QString& text, bool error = false);
void setBusy(bool busy);
// 供外部回填
TerminalConfig currentConfig() const;
signals:
void connectRequested(const vdi::TerminalConfig& cfg);
void quitRequested();
private slots:
void onConnectClicked();
void onResolutionChanged(int index);
private:
TerminalConfig cfg_;
ConnectHandler connectHandler_;
QLineEdit* editServer_ = nullptr;
QLineEdit* editUser_ = nullptr;
QLineEdit* editPassword_ = nullptr;
QComboBox* comboResolution_ = nullptr;
QCheckBox* checkRemember_ = nullptr;
QLabel* labelStatus_ = nullptr;
QLabel* labelSn_ = nullptr;
QLabel* labelNetwork_ = nullptr;
QPushButton* buttonConnect_ = nullptr;
QPushButton* buttonQuit_ = nullptr;
};
} // namespace vdi
Q_DECLARE_METATYPE(vdi::TerminalConfig)
#endif // KIOSK_SHELL_LOGIN_PAGE_H

View File

@ -0,0 +1,22 @@
// kiosk-shell 入口
//
// 对应《VDI 云桌面软件开发方案》3.2 节:
// Weston(Wayland) + 全屏 Qt 壳进程
// 开机自动登录后由本进程接管显示,进入 Kiosk 状态
#include <QApplication>
#include <QTimer>
#include "main_window.h"
int main(int argc, char** argv) {
QApplication app(argc, argv);
app.setApplicationName(QStringLiteral("vdi-kiosk-shell"));
app.setApplicationVersion(QStringLiteral("0.1.0"));
app.setOrganizationName(QStringLiteral("vdi"));
auto* window = new vdi::MainWindow();
window->show();
return app.exec();
}

View File

@ -0,0 +1,324 @@
#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 "login_page.h"
namespace vdi {
namespace {
// FreeRDP 像素格式 -> QImage::Format
QImage::Format toQImageFormat(uint32_t freerdpFormat) {
switch (freerdpFormat) {
case 0x00000020: // PIXEL_FORMAT_BGRX8888
return QImage::Format_RGB888;
case 0x00000030: // PIXEL_FORMAT_BGRA8888
return QImage::Format_RGBA8888;
case 0x00000000: // PIXEL_FORMAT_RGBX8888
return QImage::Format_RGBX8888;
default:
return QImage::Format_RGBA8888;
}
}
constexpr int kReconnectDelayMs = 3000;
constexpr int kMaxReconnectAttempts = 5;
} // namespace
MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
fsm_ = StateMachine([this](State from, State to, const std::string& detail) {
onStateChanged(from, to, QString::fromStdString(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;
}
const QImage::Format fmt = toQImageFormat(format);
// FreeRDP 的 primary_buffer 是 BGRA/BGRX 排列,QImage 需要拷贝
QImage image(reinterpret_cast<const uchar*>(data), static_cast<int>(width),
static_cast<int>(height), static_cast<int>(stride), fmt);
lastFrame_ = image.copy(); // 拷贝,脱离 FreeRDP 缓冲生命周期
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

View File

@ -0,0 +1,76 @@
// Kiosk 主窗口
//
// 对应《VDI 云桌面软件开发方案》3.2 节:
// Weston(Wayland) + 全屏 Qt 壳进程
// 状态机 BOOT -> CONFIG -> CONNECTING -> SESSION -> RECONNECT -> ERROR
// 读取下发连接配置(地址/账号模板/显示档位)
// 状态条(网络/会话/OTA 进度)
// 首配页(SN 绑定、WiFi 扫码、服务器地址)
// 以 libfreerdp3 API 内嵌(非拉起外部进程)
// 全局热键 Ctrl+Alt+Shift+M 呼出菜单
#ifndef KIOSK_SHELL_MAIN_WINDOW_H
#define KIOSK_SHELL_MAIN_WINDOW_H
#include <QImage>
#include <QMainWindow>
#include <QPointer>
#include <memory>
#include "config.h"
#include "rdp_session.h"
#include "state_machine.h"
class QLabel;
class QStackedWidget;
class QStatusBar;
class QTimer;
namespace vdi {
class LoginPage;
class MainWindow : public QMainWindow {
Q_OBJECT
public:
explicit MainWindow(QWidget* parent = nullptr);
~MainWindow() override;
protected:
bool eventFilter(QObject* watched, QEvent* event) override;
private slots:
void onConnectRequested(const vdi::TerminalConfig& cfg);
void onQuitRequested();
void onFrame(const uint8_t* data, uint32_t width, uint32_t height, uint32_t stride,
uint32_t format);
void onSessionState(bool connected, const QString& message);
void onMenuAction(int action);
private:
void setupUi();
void setupHotkey();
void onStateChanged(vdi::State from, vdi::State to, const QString& detail);
void startSession(const TerminalConfig& cfg);
void stopSession();
void showLogin();
void updateStatusBar(const QString& text);
StateMachine fsm_;
std::unique_ptr<RdpSession> session_;
QPointer<LoginPage> loginPage_;
QStackedWidget* stack_ = nullptr;
QLabel* sessionView_ = nullptr;
QLabel* sessionOverlay_ = nullptr;
QStatusBar* statusBar_ = nullptr;
QTimer* reconnectTimer_ = nullptr;
TerminalConfig config_;
int reconnectAttempts_ = 0;
QImage lastFrame_;
bool frameDirty_ = false;
};
} // namespace vdi
#endif // KIOSK_SHELL_MAIN_WINDOW_H

View File

@ -0,0 +1,225 @@
#include "rdp_session.h"
#include <cstring>
#include <mutex>
namespace vdi {
namespace {
// 会话实例指针表:通过 rdpContext->handle 取回 C++ 对象
struct SessionSlot {
RdpSession* session = nullptr;
};
SessionSlot* slotOf(rdpContext* context) {
if (!context) return nullptr;
auto* slot = static_cast<SessionSlot*>(context->handle);
return slot;
}
} // namespace
RdpSession::RdpSession() = default;
RdpSession::~RdpSession() {
stop();
}
bool RdpSession::start(const RdpOptions& options, RdpCallbacks callbacks) {
if (running_.load()) {
return false;
}
options_ = std::move(options);
callbacks_ = std::move(callbacks);
running_.store(true);
worker_ = std::thread(&RdpSession::run, this);
return true;
}
void RdpSession::stop() {
if (!running_.load()) {
return;
}
running_.store(false);
if (context_) {
// 请求断开;连接线程会在 freerdp_disconnect 后返回
freerdp_disconnect(context_);
}
if (worker_.joinable()) {
worker_.join();
}
context_ = nullptr;
}
void RdpSession::run() {
// 1. 创建设置
rdpSettings* settings = freerdp_settings_new(0);
if (!settings) {
running_.store(false);
if (callbacks_.onState) {
callbacks_.onState(false, "无法创建设置");
}
return;
}
// 2. 写入连接参数
// 注意 FreeRDP 3 的键名与 2.x 不同:
// FreeRDP_ServerHostname / FreeRDP_Username / FreeRDP_DesktopWidth ...
freerdp_settings_set_string(settings, FreeRDP_ServerHostname, options_.server.c_str());
if (!options_.username.empty()) {
freerdp_settings_set_string(settings, FreeRDP_Username, options_.username.c_str());
}
if (!options_.password.empty()) {
freerdp_settings_set_string(settings, FreeRDP_Password, options_.password.c_str());
}
if (!options_.domain.empty()) {
freerdp_settings_set_string(settings, FreeRDP_Domain, options_.domain.c_str());
}
freerdp_settings_set_uint32(settings, FreeRDP_DesktopWidth,
static_cast<UINT32>(options_.width));
freerdp_settings_set_uint32(settings, FreeRDP_DesktopHeight,
static_cast<UINT32>(options_.height));
freerdp_settings_set_uint32(settings, FreeRDP_ColorDepth,
static_cast<UINT32>(options_.bpp));
freerdp_settings_set_bool(settings, FreeRDP_Fullscreen,
options_.fullscreen ? TRUE : FALSE);
freerdp_settings_set_bool(settings, FreeRDP_IgnoreCertificate,
options_.ignoreCertificate ? TRUE : FALSE);
freerdp_settings_set_bool(settings, FreeRDP_NetworkAutoDetect, TRUE);
freerdp_settings_set_bool(settings, FreeRDP_UseRdpSecurityLayer, TRUE);
freerdp_settings_set_bool(settings, FreeRDP_SupportDynamicChannels, TRUE);
// 3. GFX 能力开关
// 对应命令行 /gfx 的能力组合语义:
// progressive|RFX|AVC420|AVC444
// FreeRDP 3.32.1 单独指定 AVC420 会解析失败,必须成组出现,
// 因此这里按能力逐项开启并保证 progressive 一起生效。
freerdp_settings_set_bool(settings, FreeRDP_GfxProgressive, TRUE);
freerdp_settings_set_bool(settings, FreeRDP_GfxH264, TRUE);
freerdp_settings_set_bool(settings, FreeRDP_GfxAVC444, TRUE);
freerdp_settings_set_bool(settings, FreeRDP_GfxAVC444v2, TRUE);
freerdp_settings_set_bool(settings, FreeRDP_GfxThinClient, TRUE);
freerdp_settings_set_bool(settings, FreeRDP_GfxSmallCache, TRUE);
// 4. 创<><E5889B>上下文
rdpContext* context = freerdp_context_new(settings);
if (!context) {
freerdp_settings_free(settings);
running_.store(false);
if (callbacks_.onState) {
callbacks_.onState(false, "无法创建 RDP 上下文");
}
return;
}
context_ = context;
// 5. 会话槽位(用于 C 回调找回 C++ 对象)
auto* slot = new SessionSlot();
slot->session = this;
context->handle = slot;
// 6. 注册事件
context->PostConnect = onPostConnect;
context->PostDisconnect = onPostDisconnect;
// 7. 挂载 GFX 渲染管线:把解码后的帧通过回调交给 Qt
rdpGdi* gdi = context->gdi;
if (gdi && gdi->gfx) {
gdi->gfx->UpdateSurfaceArea = onUpdateSurfaceArea;
}
// 8. 连接(阻塞)
if (!freerdp_connect(context)) {
if (callbacks_.onState) {
callbacks_.onState(false, "连接失败,请检查地址与网络");
}
delete slot;
context->handle = nullptr;
freerdp_disconnect(context);
freerdp_context_free(context);
freerdp_settings_free(settings);
context_ = nullptr;
running_.store(false);
return;
}
// 9. 事件循环:FreeRDP 3 需要手动泵事件
while (running_.load() && freerdp_get_state(context) != CONNECTION_STATE_FINAL) {
if (!freerdp_check_event_handles(context, 100)) {
continue;
}
if (context->rdp->AutoReconnect) {
if (!freerdp_reconnect(context)) {
break;
}
}
}
connected_.store(false);
if (callbacks_.onState) {
callbacks_.onState(false, "会话已结束");
}
delete slot;
context->handle = nullptr;
freerdp_disconnect(context);
freerdp_context_free(context);
freerdp_settings_free(settings);
context_ = nullptr;
running_.store(false);
}
BOOL RdpSession::onPostConnect(rdpContext* context) {
auto* slot = slotOf(context);
if (slot && slot->session) {
slot->session->connected_.store(true);
if (slot->session->callbacks_.onState) {
slot->session->callbacks_.onState(true, "已连接");
}
}
return TRUE;
}
BOOL RdpSession::onPostDisconnect(rdpContext* context) {
auto* slot = slotOf(context);
if (slot && slot->session) {
slot->session->connected_.store(false);
}
return TRUE;
}
UINT RdpSession::onUpdateSurfaceArea(RdpgfxClientContext* context, const RECTANGLE_16* rects,
UINT16 surfaceId, UINT32 codecId, UINT32 width, UINT32 height,
const BYTE* data, UINT32 dataLength, UINT64 frameId) {
(void)rects;
(void)surfaceId;
(void)codecId;
(void)dataLength;
(void)frameId;
// 通过 gfx 上下文回溯到 rdpContext
auto* slot = slotOf(context->context);
if (!slot || !slot->session) {
return ERROR_SUCCESS;
}
RdpSession* self = slot->session;
// gdi->primary_buffer 保存的是解码后的完整帧
rdpGdi* gdi = context->context->gdi;
if (!gdi || !gdi->primary_buffer) {
return ERROR_SUCCESS;
}
self->frameWidth_ = static_cast<uint32_t>(gdi->width);
self->frameHeight_ = static_cast<uint32_t>(gdi->height);
self->frameStride_ = gdi->stride;
self->frameFormat_ = gdi->dstFormat;
if (self->callbacks_.onFrame) {
self->callbacks_.onFrame(gdi->primary_buffer, self->frameWidth_, self->frameHeight_,
self->frameStride_, self->frameFormat_);
}
return ERROR_SUCCESS;
}
} // namespace vdi

View File

@ -0,0 +1,90 @@
// RDP 会话封装(内嵌 FreeRDP 3 客户端 API)
//
// 对应《VDI 云桌面软件开发方案》3.2 / 3.3 节:
// "以 libfreerdp3 API 内嵌(非拉起外部进程),保证会话内无缝切换与全局热键"
// 协议层 FreeRDP 3;图形管线走 GFX;4K 档切 H.265 解码管线
//
// 实现路径:
// libfreerdp3 核心库提供 freerdp_context_new / gdi_graphics_pipeline_init_ex,
// 通过 pcRdpgfxUpdateSurfaceArea 回调拿到解码后的帧缓冲,交由 Qt 绘制。
// 后续 freerdp-rk 分支把这里替换为 GStreamer + mppvideodec 零拷贝管线。
#ifndef KIOSK_SHELL_RDP_SESSION_H
#define KIOSK_SHELL_RDP_SESSION_H
#include <atomic>
#include <functional>
#include <string>
#include <thread>
#include <vector>
#include <freerdp/freerdp.h>
#include <freerdp/gdi/gdi.h>
#include <freerdp/gdi/gfx.h>
#include <freerdp/settings.h>
namespace vdi {
struct RdpOptions {
std::string server; // 主机或 host:port
std::string username;
std::string password;
std::string domain;
int width = 1920;
int height = 1440;
int bpp = 32;
bool fullscreen = true;
// 图形管线能力组合。FreeRDP 3.32.1 的 /gfx 不接受单个管道名,
// 必须是 "|" 分隔的组合(实测 AVC420 单独使用会解析失败)。
std::string gfx = "progressive|RFX|AVC420|AVC444";
bool ignoreCertificate = true; // POC 阶段;正式产品必须做证书校验
};
struct RdpCallbacks {
// 每帧解码完成,参数:像素数据、宽、高、每行字节数、像素格式
std::function<void(const uint8_t* data, uint32_t width, uint32_t height, uint32_t stride,
uint32_t format)>
onFrame;
// 会话状态:connected 表示当前是否已连上
std::function<void(bool connected, const std::string& message)> onState;
};
class RdpSession {
public:
RdpSession();
~RdpSession();
RdpSession(const RdpSession&) = delete;
RdpSession& operator=(const RdpSession&) = delete;
// 启动会话(内部线程执行阻塞式连接)
bool start(const RdpOptions& options, RdpCallbacks callbacks);
// 主动断开
void stop();
bool isRunning() const { return running_.load(); }
private:
void run();
// FreeRDP 回调(静态,回调内通过 context 取回 this)
static BOOL onPostConnect(rdpContext* context);
static BOOL onPostDisconnect(rdpContext* context);
static UINT onUpdateSurfaceArea(RdpgfxClientContext* context, const RDPGFX_RECT16* rect,
UINT16 surfaceId, UINT32 codecId, UINT32 width, UINT32 height,
const BYTE* data, UINT32 dataLength, UINT64 frameId);
RdpOptions options_;
RdpCallbacks callbacks_;
std::atomic<bool> running_{false};
std::atomic<bool> connected_{false};
rdpContext* context_ = nullptr;
std::thread worker_;
uint8_t* frameBuffer_ = nullptr;
uint32_t frameWidth_ = 0;
uint32_t frameHeight_ = 0;
uint32_t frameStride_ = 0;
uint32_t frameFormat_ = 0;
};
} // namespace vdi
#endif // KIOSK_SHELL_RDP_SESSION_H

View File

@ -0,0 +1,61 @@
#include "state_machine.h"
namespace vdi {
const char* stateToString(State s) {
switch (s) {
case State::kBoot: return "BOOT";
case State::kConfig: return "CONFIG";
case State::kConnecting: return "CONNECTING";
case State::kSession: return "SESSION";
case State::kReconnect: return "RECONNECT";
case State::kError: return "ERROR";
}
return "UNKNOWN";
}
StateMachine::StateMachine(StateListener listener) : listener_(std::move(listener)) {}
bool StateMachine::isLegalTransition(State from, State to) {
switch (from) {
case State::kBoot:
return to == State::kConfig || to == State::kError;
case State::kConfig:
return to == State::kConnecting || to == State::kError;
case State::kConnecting:
return to == State::kSession || to == State::kReconnect || to == State::kError;
case State::kSession:
return to == State::kReconnect || to == State::kConfig || to == State::kError;
case State::kReconnect:
return to == State::kConnecting || to == State::kConfig || to == State::kError;
case State::kError:
return to == State::kConfig || to == State::kConnecting;
}
return false;
}
bool StateMachine::transitionTo(State next, const std::string& detail) {
if (!isLegalTransition(current_, next)) {
return false;
}
State from = current_;
current_ = next;
if (listener_) {
listener_(from, next, detail);
}
return true;
}
bool StateMachine::canConnect() const {
return current_ == State::kConfig || current_ == State::kReconnect || current_ == State::kError;
}
bool StateMachine::canDisconnect() const {
return current_ == State::kSession || current_ == State::kConnecting;
}
bool StateMachine::canRetry() const {
return current_ == State::kReconnect || current_ == State::kError;
}
} // namespace vdi

View File

@ -0,0 +1,60 @@
// kiosk-shell 状态机
//
// 对应《VDI 云桌面软件开发方案》3.2 节:
// 状态机:BOOT -> CONFIG(无配置)-> CONNECTING -> SESSION(嵌入 freerdp 窗口)
// -> RECONNECT -> ERROR
//
// 状态转换规则:
// BOOT 进程启动,读取本地配置
// CONFIG 无可用配置或用户停留在登录页,等待输入
// CONNECTING 正在建立 RDP 连接
// SESSION 远程会话进行中
// RECONNECT 会话意外断开,等待重连
// ERROR 不可恢复错误,提示后回到 CONFIG
#ifndef KIOSK_SHELL_STATE_MACHINE_H
#define KIOSK_SHELL_STATE_MACHINE_H
#include <functional>
#include <string>
namespace vdi {
enum class State {
kBoot, // 启动中
kConfig, // 首配 / 登录页
kConnecting, // 连接中
kSession, // 会话中
kReconnect, // 断线等待重连
kError, // 错误
};
const char* stateToString(State s);
class StateMachine {
public:
using StateListener = std::function<void(State from, State to, const std::string& detail)>;
explicit StateMachine(StateListener listener = nullptr);
State current() const { return current_; }
bool isIn(State s) const { return current_ == s; }
// 尝试切换到目标状态;非法转换会被拒绝并返回 false
bool transitionTo(State next, const std::string& detail = std::string());
// 当前状态下允许执行的动作
bool canConnect() const;
bool canDisconnect() const;
bool canRetry() const;
private:
static bool isLegalTransition(State from, State to);
State current_ = State::kBoot;
StateListener listener_;
};
} // namespace vdi
#endif // KIOSK_SHELL_STATE_MACHINE_H