功能:实现终端登录页与状态机骨架
变更详情: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:
commit
c25bc2908a
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
build/
|
||||||
|
*.o
|
||||||
|
*.so
|
||||||
33
docs/api-free-rdp3.md
Normal file
33
docs/api-free-rdp3.md
Normal 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` 能力位设置。
|
||||||
42
terminal/kiosk-shell/CMakeLists.txt
Normal file
42
terminal/kiosk-shell/CMakeLists.txt
Normal 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)
|
||||||
118
terminal/kiosk-shell/src/config.cpp
Normal file
118
terminal/kiosk-shell/src/config.cpp
Normal 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
|
||||||
50
terminal/kiosk-shell/src/config.h
Normal file
50
terminal/kiosk-shell/src/config.h
Normal 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
|
||||||
202
terminal/kiosk-shell/src/login_page.cpp
Normal file
202
terminal/kiosk-shell/src/login_page.cpp
Normal 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
|
||||||
68
terminal/kiosk-shell/src/login_page.h
Normal file
68
terminal/kiosk-shell/src/login_page.h
Normal 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
|
||||||
22
terminal/kiosk-shell/src/main.cpp
Normal file
22
terminal/kiosk-shell/src/main.cpp
Normal 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();
|
||||||
|
}
|
||||||
324
terminal/kiosk-shell/src/main_window.cpp
Normal file
324
terminal/kiosk-shell/src/main_window.cpp
Normal 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
|
||||||
76
terminal/kiosk-shell/src/main_window.h
Normal file
76
terminal/kiosk-shell/src/main_window.h
Normal 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
|
||||||
225
terminal/kiosk-shell/src/rdp_session.cpp
Normal file
225
terminal/kiosk-shell/src/rdp_session.cpp
Normal 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
|
||||||
90
terminal/kiosk-shell/src/rdp_session.h
Normal file
90
terminal/kiosk-shell/src/rdp_session.h
Normal 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
|
||||||
61
terminal/kiosk-shell/src/state_machine.cpp
Normal file
61
terminal/kiosk-shell/src/state_machine.cpp
Normal 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
|
||||||
60
terminal/kiosk-shell/src/state_machine.h
Normal file
60
terminal/kiosk-shell/src/state_machine.h
Normal 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
|
||||||
Loading…
Reference in New Issue
Block a user