From 419630fcd7ef1296f35e16358482d870d56cd0e6 Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 23 Oct 2026 17:22:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(kiosk-shell):=20=E9=A6=96=E7=89=88?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E9=A1=B5=E4=B8=8E=E7=8A=B6=E6=80=81=E6=9C=BA?= =?UTF-8?q?=E9=AA=A8=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按《VDI 云桌面软件开发方案》3.2 节实现: - 状态机 BOOT/CONFIG/CONNECTING/SESSION/RECONNECT/ERROR - 首配页(SN 绑定、服务器地址、账号、密码、显示档位) - 全局热键 Ctrl+Alt+Shift+M 呼出管理菜单 - FreeRDP 3 以 libfreerdp3 API 内嵌,非拉起外部进程 --- .gitignore | 3 + docs/api-free-rdp3.md | 33 +++ terminal/kiosk-shell/CMakeLists.txt | 42 +++ terminal/kiosk-shell/src/config.cpp | 118 ++++++++ terminal/kiosk-shell/src/config.h | 50 ++++ terminal/kiosk-shell/src/login_page.cpp | 202 +++++++++++++ terminal/kiosk-shell/src/login_page.h | 68 +++++ terminal/kiosk-shell/src/main.cpp | 22 ++ terminal/kiosk-shell/src/main_window.cpp | 324 +++++++++++++++++++++ terminal/kiosk-shell/src/main_window.h | 76 +++++ terminal/kiosk-shell/src/rdp_session.cpp | 225 ++++++++++++++ terminal/kiosk-shell/src/rdp_session.h | 90 ++++++ terminal/kiosk-shell/src/state_machine.cpp | 61 ++++ terminal/kiosk-shell/src/state_machine.h | 60 ++++ 14 files changed, 1374 insertions(+) create mode 100644 .gitignore create mode 100644 docs/api-free-rdp3.md create mode 100644 terminal/kiosk-shell/CMakeLists.txt create mode 100644 terminal/kiosk-shell/src/config.cpp create mode 100644 terminal/kiosk-shell/src/config.h create mode 100644 terminal/kiosk-shell/src/login_page.cpp create mode 100644 terminal/kiosk-shell/src/login_page.h create mode 100644 terminal/kiosk-shell/src/main.cpp create mode 100644 terminal/kiosk-shell/src/main_window.cpp create mode 100644 terminal/kiosk-shell/src/main_window.h create mode 100644 terminal/kiosk-shell/src/rdp_session.cpp create mode 100644 terminal/kiosk-shell/src/rdp_session.h create mode 100644 terminal/kiosk-shell/src/state_machine.cpp create mode 100644 terminal/kiosk-shell/src/state_machine.h diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5d7d74c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +build/ +*.o +*.so diff --git a/docs/api-free-rdp3.md b/docs/api-free-rdp3.md new file mode 100644 index 0000000..6011043 --- /dev/null +++ b/docs/api-free-rdp3.md @@ -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` 能力位设置。 diff --git a/terminal/kiosk-shell/CMakeLists.txt b/terminal/kiosk-shell/CMakeLists.txt new file mode 100644 index 0000000..7f4f5b2 --- /dev/null +++ b/terminal/kiosk-shell/CMakeLists.txt @@ -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) diff --git a/terminal/kiosk-shell/src/config.cpp b/terminal/kiosk-shell/src/config.cpp new file mode 100644 index 0000000..b6f8665 --- /dev/null +++ b/terminal/kiosk-shell/src/config.cpp @@ -0,0 +1,118 @@ +#include "config.h" + +#include +#include + +#include +#include +#include +#include + +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 diff --git a/terminal/kiosk-shell/src/config.h b/terminal/kiosk-shell/src/config.h new file mode 100644 index 0000000..b670676 --- /dev/null +++ b/terminal/kiosk-shell/src/config.h @@ -0,0 +1,50 @@ +// 终端本地配置 +// +// 对应《VDI 云桌面软件开发方案》4.2 节 config_template 的终端侧镜像: +// 终端保存一份本地的配置版本,API 下发后覆盖。 +// 当前阶段先做本地文件版本,后续由 vdt-agent 接管与 API 同步。 + +#ifndef KIOSK_SHELL_CONFIG_H +#define KIOSK_SHELL_CONFIG_H + +#include + +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 diff --git a/terminal/kiosk-shell/src/login_page.cpp b/terminal/kiosk-shell/src/login_page.cpp new file mode 100644 index 0000000..f6e3f20 --- /dev/null +++ b/terminal/kiosk-shell/src/login_page.cpp @@ -0,0 +1,202 @@ +#include "login_page.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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::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 diff --git a/terminal/kiosk-shell/src/login_page.h b/terminal/kiosk-shell/src/login_page.h new file mode 100644 index 0000000..4d9fb37 --- /dev/null +++ b/terminal/kiosk-shell/src/login_page.h @@ -0,0 +1,68 @@ +// 首配 / 登录页 +// +// 对应《VDI 云桌面软件开发方案》3.2 节: +// 首配页(无配置时进入)——SN 绑定、WiFi 扫码、服务器地址 +// 状态条——网络 / 会话状态 + +#ifndef KIOSK_SHELL_LOGIN_PAGE_H +#define KIOSK_SHELL_LOGIN_PAGE_H + +#include +#include + +#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; + + 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 diff --git a/terminal/kiosk-shell/src/main.cpp b/terminal/kiosk-shell/src/main.cpp new file mode 100644 index 0000000..b3fccb0 --- /dev/null +++ b/terminal/kiosk-shell/src/main.cpp @@ -0,0 +1,22 @@ +// kiosk-shell 入口 +// +// 对应《VDI 云桌面软件开发方案》3.2 节: +// Weston(Wayland) + 全屏 Qt 壳进程 +// 开机自动登录后由本进程接管显示,进入 Kiosk 状态 + +#include +#include + +#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(); +} diff --git a/terminal/kiosk-shell/src/main_window.cpp b/terminal/kiosk-shell/src/main_window.cpp new file mode 100644 index 0000000..85f4b04 --- /dev/null +++ b/terminal/kiosk-shell/src/main_window.cpp @@ -0,0 +1,324 @@ +#include "main_window.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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(); + 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(data), static_cast(width), + static_cast(height), static_cast(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 diff --git a/terminal/kiosk-shell/src/main_window.h b/terminal/kiosk-shell/src/main_window.h new file mode 100644 index 0000000..ebba350 --- /dev/null +++ b/terminal/kiosk-shell/src/main_window.h @@ -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 +#include +#include +#include + +#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 session_; + QPointer 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 diff --git a/terminal/kiosk-shell/src/rdp_session.cpp b/terminal/kiosk-shell/src/rdp_session.cpp new file mode 100644 index 0000000..918d0c3 --- /dev/null +++ b/terminal/kiosk-shell/src/rdp_session.cpp @@ -0,0 +1,225 @@ +#include "rdp_session.h" + +#include +#include + +namespace vdi { +namespace { + +// 会话实例指针表:通过 rdpContext->handle 取回 C++ 对象 +struct SessionSlot { + RdpSession* session = nullptr; +}; + +SessionSlot* slotOf(rdpContext* context) { + if (!context) return nullptr; + auto* slot = static_cast(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(options_.width)); + freerdp_settings_set_uint32(settings, FreeRDP_DesktopHeight, + static_cast(options_.height)); + freerdp_settings_set_uint32(settings, FreeRDP_ColorDepth, + static_cast(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. 创��上下文 + 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(gdi->width); + self->frameHeight_ = static_cast(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 diff --git a/terminal/kiosk-shell/src/rdp_session.h b/terminal/kiosk-shell/src/rdp_session.h new file mode 100644 index 0000000..1890361 --- /dev/null +++ b/terminal/kiosk-shell/src/rdp_session.h @@ -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 +#include +#include +#include +#include + +#include +#include +#include +#include + +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 + onFrame; + // 会话状态:connected 表示当前是否已连上 + std::function 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 running_{false}; + std::atomic 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 diff --git a/terminal/kiosk-shell/src/state_machine.cpp b/terminal/kiosk-shell/src/state_machine.cpp new file mode 100644 index 0000000..bab70d5 --- /dev/null +++ b/terminal/kiosk-shell/src/state_machine.cpp @@ -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 diff --git a/terminal/kiosk-shell/src/state_machine.h b/terminal/kiosk-shell/src/state_machine.h new file mode 100644 index 0000000..c54e700 --- /dev/null +++ b/terminal/kiosk-shell/src/state_machine.h @@ -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 +#include + +namespace vdi { + +enum class State { + kBoot, // 启动中 + kConfig, // 首配 / 登录页 + kConnecting, // 连接中 + kSession, // 会话中 + kReconnect, // 断线等待重连 + kError, // 错误 +}; + +const char* stateToString(State s); + +class StateMachine { +public: + using StateListener = std::function; + + 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