功能:完善终端认证恢复、选择页及策略和指标采集
This commit is contained in:
parent
d87fa47e24
commit
eef485306f
@ -6,6 +6,7 @@ project(kiosk-shell CXX)
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_AUTOMOC ON)
|
||||
set(CMAKE_AUTORCC ON)
|
||||
set(CMAKE_INCLUDE_CURRENT_DIR ON)
|
||||
|
||||
if(NOT CMAKE_BUILD_TYPE)
|
||||
@ -16,14 +17,16 @@ endif()
|
||||
find_package(Qt5 REQUIRED COMPONENTS Core Gui Widgets Network)
|
||||
include(CTest)
|
||||
|
||||
add_executable(kiosk-shell
|
||||
src/main.cpp
|
||||
set(KIOSK_SOURCES
|
||||
resources/brand.qrc
|
||||
src/main_window.cpp
|
||||
src/login_page.cpp
|
||||
src/desktop_list_page.cpp
|
||||
src/desktop_settings_dialog.cpp
|
||||
src/desktop_service.cpp
|
||||
src/platform_client.cpp
|
||||
src/system_metrics.cpp
|
||||
src/terminal_policy.cpp
|
||||
src/desktop_store.cpp
|
||||
src/moonlight_session.cpp
|
||||
src/transition_overlay.cpp
|
||||
@ -31,6 +34,8 @@ add_executable(kiosk-shell
|
||||
src/config.cpp
|
||||
)
|
||||
|
||||
add_executable(kiosk-shell src/main.cpp ${KIOSK_SOURCES})
|
||||
|
||||
target_include_directories(kiosk-shell PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src
|
||||
)
|
||||
@ -62,6 +67,14 @@ target_link_libraries(vdi-kiosk-monitor PRIVATE Threads::Threads)
|
||||
|
||||
if(BUILD_TESTING)
|
||||
find_package(Qt5 REQUIRED COMPONENTS Test)
|
||||
add_executable(test-system-metrics tests/test_system_metrics.cpp src/system_metrics.cpp)
|
||||
target_include_directories(test-system-metrics PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)
|
||||
target_link_libraries(test-system-metrics PRIVATE Qt5::Core Qt5::Test)
|
||||
add_test(NAME system-metrics COMMAND test-system-metrics)
|
||||
add_executable(test-terminal-policy tests/test_terminal_policy.cpp src/terminal_policy.cpp)
|
||||
target_include_directories(test-terminal-policy PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)
|
||||
target_link_libraries(test-terminal-policy PRIVATE Qt5::Core Qt5::Test)
|
||||
add_test(NAME terminal-policy COMMAND test-terminal-policy)
|
||||
if(UNIX)
|
||||
find_package(Python3 REQUIRED COMPONENTS Interpreter)
|
||||
add_test(NAME display-guard COMMAND ${Python3_EXECUTABLE}
|
||||
@ -84,6 +97,7 @@ if(BUILD_TESTING)
|
||||
add_executable(test-desktop-list-page
|
||||
tests/test_desktop_list_page.cpp
|
||||
src/desktop_list_page.cpp
|
||||
resources/brand.qrc
|
||||
)
|
||||
target_include_directories(test-desktop-list-page PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src
|
||||
@ -98,6 +112,24 @@ if(BUILD_TESTING)
|
||||
set_tests_properties(desktop-list-page PROPERTIES
|
||||
ENVIRONMENT "QT_QPA_PLATFORM=offscreen"
|
||||
)
|
||||
|
||||
# Render the native page at target resolutions without occupying the display.
|
||||
add_executable(preview-desktop-list tests/preview_desktop_list.cpp
|
||||
src/desktop_list_page.cpp resources/brand.qrc)
|
||||
target_include_directories(preview-desktop-list PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)
|
||||
target_link_libraries(preview-desktop-list PRIVATE Qt5::Core Qt5::Gui Qt5::Widgets)
|
||||
|
||||
add_executable(test-main-window tests/test_main_window.cpp ${KIOSK_SOURCES})
|
||||
target_include_directories(test-main-window PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)
|
||||
target_link_libraries(test-main-window PRIVATE
|
||||
Qt5::Core Qt5::Gui Qt5::Widgets Qt5::Network Qt5::Test)
|
||||
if(UNIX AND NOT APPLE)
|
||||
target_link_libraries(test-main-window PRIVATE X11::X11)
|
||||
endif()
|
||||
add_test(NAME main-window-boot COMMAND test-main-window)
|
||||
set_tests_properties(main-window-boot PROPERTIES
|
||||
ENVIRONMENT "QT_QPA_PLATFORM=offscreen;DISPLAY="
|
||||
)
|
||||
endif()
|
||||
|
||||
install(TARGETS kiosk-shell vdi-kiosk-monitor RUNTIME DESTINATION bin)
|
||||
|
||||
9
terminal/kiosk-shell/resources/README.md
Normal file
9
terminal/kiosk-shell/resources/README.md
Normal file
@ -0,0 +1,9 @@
|
||||
# Terminal brand assets
|
||||
|
||||
`logo.png` and `mark.png` are rasterized from `ui/terminal/assets/logo.svg`
|
||||
and `mark.svg`, preserving their view boxes and the embedded SVG color matrix
|
||||
(copper RGB 189/113/59; alpha = clamp(1.4 × (R − B))). The mark uses the design's
|
||||
gold tones. PNG avoids depending on SVG filter support on the ARM terminal.
|
||||
`kylinsoft.png` is copied from `ui/terminal/assets/systems/kylinsoft.png`.
|
||||
|
||||
Resources are embedded in the executable; no browser or runtime asset path is needed.
|
||||
7
terminal/kiosk-shell/resources/brand.qrc
Normal file
7
terminal/kiosk-shell/resources/brand.qrc
Normal file
@ -0,0 +1,7 @@
|
||||
<RCC>
|
||||
<qresource prefix="/brand">
|
||||
<file>logo.png</file>
|
||||
<file>mark.png</file>
|
||||
<file>kylinsoft.png</file>
|
||||
</qresource>
|
||||
</RCC>
|
||||
BIN
terminal/kiosk-shell/resources/kylinsoft.png
Normal file
BIN
terminal/kiosk-shell/resources/kylinsoft.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.8 KiB |
BIN
terminal/kiosk-shell/resources/logo.png
Normal file
BIN
terminal/kiosk-shell/resources/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 132 KiB |
BIN
terminal/kiosk-shell/resources/mark.png
Normal file
BIN
terminal/kiosk-shell/resources/mark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
@ -1,66 +1,163 @@
|
||||
#include "desktop_list_page.h"
|
||||
|
||||
#include <QFont>
|
||||
#include <QFrame>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QPainter>
|
||||
#include <QPushButton>
|
||||
#include <QResizeEvent>
|
||||
#include <QScrollArea>
|
||||
#include <QSettings>
|
||||
#include <QVBoxLayout>
|
||||
#include <cmath>
|
||||
|
||||
namespace vdi {
|
||||
namespace {
|
||||
|
||||
// 在信息行里加一个拉伸占位(独立函数避免在类外重复声明)
|
||||
void addStretchToRow(QHBoxLayout* row) {
|
||||
row->addStretch(1);
|
||||
QPixmap systemIcon(const QString& os) {
|
||||
QPixmap icon(64, 64);
|
||||
icon.fill(Qt::transparent);
|
||||
QPainter p(&icon);
|
||||
p.setRenderHint(QPainter::Antialiasing);
|
||||
if (os.contains(QStringLiteral("kylin"), Qt::CaseInsensitive) ||
|
||||
os.contains(QStringLiteral("麒麟"))) {
|
||||
const QPixmap kylin(QStringLiteral(":/brand/kylinsoft.png"));
|
||||
p.drawPixmap(QRect(5, 5, 54, 54), kylin);
|
||||
} else if (os.contains(QStringLiteral("ubuntu"), Qt::CaseInsensitive)) {
|
||||
p.setPen(Qt::NoPen);
|
||||
p.setBrush(QColor("#e95420"));
|
||||
p.drawEllipse(4, 4, 56, 56);
|
||||
p.setPen(QPen(Qt::white, 4));
|
||||
p.setBrush(Qt::NoBrush);
|
||||
p.drawEllipse(QPointF(32, 32), 13, 13);
|
||||
for (int a : {0, 120, 240}) {
|
||||
const double angle = a * 3.14159265 / 180;
|
||||
const QPointF at(32 + 18 * std::cos(angle), 32 + 18 * std::sin(angle));
|
||||
p.setPen(QPen(QColor("#e95420"), 4));
|
||||
p.setBrush(Qt::white);
|
||||
p.drawEllipse(at, 5, 5);
|
||||
}
|
||||
} else if (os.contains(QStringLiteral("windows"), Qt::CaseInsensitive)) {
|
||||
p.setPen(Qt::NoPen);
|
||||
p.setBrush(QColor("#69b7ef"));
|
||||
for (int y : {12, 34}) for (int x : {12, 34}) p.drawRect(x, y, 19, 19);
|
||||
} else {
|
||||
p.setPen(QPen(QColor("#bd713b"), 3));
|
||||
p.drawRoundedRect(QRect(8, 11, 48, 34), 4, 4);
|
||||
p.drawLine(32, 45, 32, 54);
|
||||
p.drawLine(21, 54, 43, 54);
|
||||
}
|
||||
return icon;
|
||||
}
|
||||
|
||||
QLabel* textLabel(const QString& text, const char* name, QWidget* parent) {
|
||||
auto* label = new QLabel(text, parent);
|
||||
label->setObjectName(QString::fromLatin1(name));
|
||||
label->setTextFormat(Qt::PlainText);
|
||||
return label;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
DesktopListPage::DesktopListPage(QWidget* parent) : QWidget(parent) {
|
||||
auto* root = new QVBoxLayout(this);
|
||||
root->setContentsMargins(80, 50, 80, 40);
|
||||
root->setSpacing(16);
|
||||
setObjectName(QStringLiteral("desktopSelection"));
|
||||
light_ = QSettings().value(QStringLiteral("appearance/light"), false).toBool();
|
||||
|
||||
// ---- 标题区 ----
|
||||
auto* title = new QLabel(QStringLiteral("选择云桌面"), this);
|
||||
QFont titleFont = title->font();
|
||||
titleFont.setPointSize(26);
|
||||
titleFont.setBold(true);
|
||||
title->setFont(titleFont);
|
||||
title->setAlignment(Qt::AlignCenter);
|
||||
root->addWidget(title);
|
||||
themeButton_ = new QPushButton(this);
|
||||
themeButton_->setObjectName(QStringLiteral("themeButton"));
|
||||
themeButton_->setCursor(Qt::PointingHandCursor);
|
||||
connect(themeButton_, &QPushButton::clicked, this, [this] {
|
||||
light_ = !light_;
|
||||
QSettings().setValue(QStringLiteral("appearance/light"), light_);
|
||||
applyTheme();
|
||||
});
|
||||
|
||||
auto* infoRow = new QHBoxLayout();
|
||||
labelUser_ = new QLabel(this);
|
||||
labelUser_->setStyleSheet(QStringLiteral("color: #8a94a6;"));
|
||||
// 无登录页演示模式下不设"退出登录"(用户身份由设备/平台承担,
|
||||
// 正式认证接入后由 DesktopService 驱动恢复登录流程)
|
||||
infoRow->addWidget(labelUser_);
|
||||
addStretchToRow(infoRow);
|
||||
root->addLayout(infoRow);
|
||||
panel_ = new QFrame(this);
|
||||
panel_->setObjectName(QStringLiteral("selectionPanel"));
|
||||
auto* content = new QVBoxLayout(panel_);
|
||||
content->setContentsMargins(28, 24, 28, 20);
|
||||
content->setSpacing(8);
|
||||
content->addWidget(textLabel(QStringLiteral("你的云端工作空间"), "eyebrow", panel_));
|
||||
auto* titleRow = new QHBoxLayout;
|
||||
titleRow->addWidget(textLabel(QStringLiteral("选择云桌面"), "pageTitle", panel_));
|
||||
titleRow->addStretch();
|
||||
labelCount_ = textLabel(QString(), "desktopCount", panel_);
|
||||
titleRow->addWidget(labelCount_);
|
||||
content->addLayout(titleRow);
|
||||
content->addWidget(textLabel(QStringLiteral("选择工作环境,继续你的工作。"), "subtitle", panel_));
|
||||
content->addSpacing(8);
|
||||
|
||||
// ---- 卡片区 ----
|
||||
auto* scroll = new QScrollArea(this);
|
||||
scroll->setWidgetResizable(true);
|
||||
scroll->setFrameShape(QFrame::NoFrame);
|
||||
scroll->setStyleSheet(QStringLiteral(
|
||||
"QScrollArea { background: transparent; }"
|
||||
"QWidget { background: transparent; }"));
|
||||
|
||||
auto* cardsHost = new QWidget(scroll);
|
||||
scroll_ = new QScrollArea(panel_);
|
||||
scroll_->setWidgetResizable(true);
|
||||
scroll_->setFrameShape(QFrame::NoFrame);
|
||||
scroll_->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
auto* cardsHost = new QWidget(scroll_);
|
||||
cardsHost->setObjectName(QStringLiteral("cardsHost"));
|
||||
cardsLayout_ = new QVBoxLayout(cardsHost);
|
||||
cardsLayout_->setSpacing(14);
|
||||
cardsLayout_->setContentsMargins(0, 0, 8, 0);
|
||||
cardsLayout_->setSpacing(12);
|
||||
cardsLayout_->addStretch(1);
|
||||
scroll->setWidget(cardsHost);
|
||||
root->addWidget(scroll, 1);
|
||||
scroll_->setWidget(cardsHost);
|
||||
content->addWidget(scroll_, 1);
|
||||
|
||||
// ---- 状态条 ----
|
||||
labelStatus_ = new QLabel(this);
|
||||
labelStatus_->setAlignment(Qt::AlignCenter);
|
||||
labelStatus_->setStyleSheet(QStringLiteral("color: #8a94a6;"));
|
||||
root->addWidget(labelStatus_);
|
||||
labelEmpty_ = textLabel(QStringLiteral("正在获取可用云桌面…\n桌面列表将在连接完成后自动显示"), "emptyState", panel_);
|
||||
labelEmpty_->setAlignment(Qt::AlignCenter);
|
||||
labelEmpty_->setWordWrap(true);
|
||||
content->addWidget(labelEmpty_, 1);
|
||||
auto* separator = new QFrame(panel_);
|
||||
separator->setObjectName(QStringLiteral("separator"));
|
||||
separator->setFixedHeight(1);
|
||||
content->addWidget(separator);
|
||||
labelStatus_ = textLabel(QString(), "selectionStatus", panel_);
|
||||
labelStatus_->setWordWrap(true);
|
||||
labelStatus_->setMinimumHeight(24);
|
||||
content->addWidget(labelStatus_);
|
||||
applyTheme();
|
||||
rebuildCards();
|
||||
}
|
||||
|
||||
void DesktopListPage::applyTheme() {
|
||||
const QString text = light_ ? "#1f2937" : "#e4e8ed";
|
||||
const QString muted = light_ ? "#596575" : "#a5b1bf";
|
||||
const QString line = light_ ? "#e5ddd6" : "#394452";
|
||||
const QString card = light_ ? "#ffffff" : "#242d38";
|
||||
const QString field = light_ ? "#f6f7f9" : "#29333f";
|
||||
setStyleSheet(QStringLiteral(
|
||||
"QWidget#desktopSelection { background: transparent; color: %1; font-family: 'Noto Sans CJK SC', 'Microsoft YaHei', sans-serif; font-size: 14px; }"
|
||||
"QLabel { background: transparent; border: none; color: %1; font-size: 14px; }"
|
||||
"QFrame#selectionPanel { background: %4; border: 1px solid %3; border-radius: 24px; }"
|
||||
"QLabel#eyebrow { color: #bd713b; font-size: 12px; font-weight: 600; }"
|
||||
"QLabel#pageTitle { font-size: 30px; font-weight: 700; }"
|
||||
"QLabel#subtitle, QLabel#desktopCount, QLabel#desktopOs, QLabel#emptyState { color: %2; }"
|
||||
"QLabel#emptyState { line-height: 180%; }"
|
||||
"QLabel#desktopName { font-size: 17px; font-weight: 600; }"
|
||||
"QLabel#desktopOs { font-size: 12px; }"
|
||||
"QLabel#desktopState { font-size: 12px; color: %2; }"
|
||||
"QLabel#desktopState[tone=available] { color: %6; }"
|
||||
"QLabel#desktopState[tone=pending] { color: %7; }"
|
||||
"QLabel#systemIcon { background: %4; border-radius: 12px; }"
|
||||
"QFrame#separator { background: %3; border: none; }"
|
||||
"QScrollArea, QWidget#cardsHost { background: transparent; border: none; }"
|
||||
"QFrame#desktopCard { background: %5; border: 1px solid %3; border-radius: 14px; }"
|
||||
"QFrame#desktopCard:hover { border-color: #bd713b; }"
|
||||
"QPushButton { background: transparent; color: %1; border: 1px solid %3; border-radius: 9px; padding: 7px 14px; font-size: 14px; }"
|
||||
"QPushButton:hover { background: %5; border-color: #bd713b; }"
|
||||
"QPushButton:focus { border: 2px solid #d99a6c; }"
|
||||
"QPushButton#connectButton { background: #bd713b; border: 1px solid #bd713b; color: #ffffff; font-weight: 600; }"
|
||||
"QPushButton#connectButton:hover { background: #ce834b; border-color: #ce834b; }"
|
||||
"QPushButton#connectButton:pressed { background: #a35e30; }"
|
||||
"QPushButton:disabled, QPushButton#connectButton:disabled { background: %3; color: %2; border-color: %3; }"
|
||||
"QPushButton#themeButton { background: %4; font-size: 12px; }"
|
||||
"QScrollBar:vertical { background: transparent; width: 6px; margin: 0; }"
|
||||
"QScrollBar::handle:vertical { background: %3; border-radius: 3px; min-height: 24px; }"
|
||||
"QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }"
|
||||
"QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: transparent; }"
|
||||
).arg(text, muted, line, card, field,
|
||||
light_ ? "#327657" : "#8bc9a5", light_ ? "#8e6224" : "#d6ad72"));
|
||||
themeButton_->setText(light_ ? QStringLiteral("☼ 浅色外观") : QStringLiteral("◐ 深色外观"));
|
||||
themeButton_->setAccessibleName(light_ ? QStringLiteral("切换为深色外观") : QStringLiteral("切换为浅色外观"));
|
||||
setStatus(labelStatus_->text(), statusError_);
|
||||
update();
|
||||
}
|
||||
|
||||
void DesktopListPage::setDesktops(const std::vector<DesktopInfo>& desktops) {
|
||||
@ -70,127 +167,161 @@ void DesktopListPage::setDesktops(const std::vector<DesktopInfo>& desktops) {
|
||||
|
||||
void DesktopListPage::rebuildCards() {
|
||||
actionButtons_.clear();
|
||||
|
||||
// 清空旧卡片(最后一项是 stretch)
|
||||
while (cardsLayout_->count() > 1) {
|
||||
QLayoutItem* item = cardsLayout_->takeAt(0);
|
||||
if (item->widget()) {
|
||||
item->widget()->deleteLater();
|
||||
}
|
||||
if (item->widget()) { item->widget()->hide(); item->widget()->deleteLater(); }
|
||||
delete item;
|
||||
}
|
||||
|
||||
labelCount_->setText(QStringLiteral("%1 个桌面").arg(desktops_.size()));
|
||||
labelEmpty_->setVisible(desktops_.empty());
|
||||
scroll_->setVisible(!desktops_.empty());
|
||||
for (const DesktopInfo& d : desktops_) {
|
||||
auto* card = new QFrame(this);
|
||||
auto* card = new QFrame;
|
||||
card->setObjectName(QStringLiteral("desktopCard"));
|
||||
card->setMinimumHeight(96);
|
||||
card->setStyleSheet(QStringLiteral(
|
||||
"QFrame#desktopCard { background: #1a2233; border: 1px solid #2b3654; border-radius: 10px; }"
|
||||
"QLabel { border: none; background: transparent; }"));
|
||||
|
||||
card->setMinimumHeight(height() >= 900 ? 120 : 86);
|
||||
auto* row = new QHBoxLayout(card);
|
||||
row->setContentsMargins(24, 16, 24, 16);
|
||||
row->setSpacing(18);
|
||||
|
||||
auto* textCol = new QVBoxLayout();
|
||||
textCol->setSpacing(4);
|
||||
auto* name = new QLabel(d.name, card);
|
||||
QFont nameFont = name->font();
|
||||
nameFont.setPointSize(15);
|
||||
nameFont.setBold(true);
|
||||
name->setFont(nameFont);
|
||||
auto* osLine = new QLabel(d.os, card);
|
||||
osLine->setStyleSheet(QStringLiteral("color: #8a94a6;"));
|
||||
row->setContentsMargins(16, 12, 16, 12);
|
||||
row->setSpacing(14);
|
||||
auto* icon = textLabel(QString(), "systemIcon", card);
|
||||
icon->setFixedSize(56, 56);
|
||||
icon->setAlignment(Qt::AlignCenter);
|
||||
icon->setPixmap(systemIcon(d.os).scaled(44, 44, Qt::KeepAspectRatio, Qt::SmoothTransformation));
|
||||
row->addWidget(icon);
|
||||
auto* textCol = new QVBoxLayout;
|
||||
textCol->setSpacing(6);
|
||||
auto* name = textLabel(d.name, "desktopName", card);
|
||||
name->setWordWrap(true);
|
||||
textCol->addWidget(name);
|
||||
textCol->addWidget(osLine);
|
||||
textCol->addWidget(textLabel(d.os, "desktopOs", card));
|
||||
row->addLayout(textCol, 1);
|
||||
|
||||
// 状态标签(会话通道统一 Moonlight,无需按桌面标注协议)
|
||||
QString stateText;
|
||||
QString stateColor;
|
||||
const char* tone = "unassigned";
|
||||
switch (d.state) {
|
||||
case DesktopState::kAvailable:
|
||||
stateText = QStringLiteral("可连接");
|
||||
stateColor = QStringLiteral("#3fb950");
|
||||
break;
|
||||
case DesktopState::kPending:
|
||||
stateText = QStringLiteral("任务进行中");
|
||||
stateColor = QStringLiteral("#d29922");
|
||||
break;
|
||||
case DesktopState::kUnassigned:
|
||||
stateText = QStringLiteral("待分配");
|
||||
stateColor = QStringLiteral("#8a94a6");
|
||||
break;
|
||||
case DesktopState::kAvailable:
|
||||
stateText = QStringLiteral("可连接"); tone = "available"; break;
|
||||
case DesktopState::kPending:
|
||||
stateText = QStringLiteral("任务进行中"); tone = "pending"; break;
|
||||
case DesktopState::kUnassigned:
|
||||
stateText = QStringLiteral("待分配"); break;
|
||||
}
|
||||
auto* state = new QLabel(stateText, card);
|
||||
state->setStyleSheet(QStringLiteral("color: %1; font-weight: bold;").arg(stateColor));
|
||||
row->addWidget(state);
|
||||
|
||||
// “设置”:每张卡都可编辑连接参数(协议/地址/应用名/分辨率/帧率)
|
||||
auto* editBtn = new QPushButton(QStringLiteral("设置"), card);
|
||||
editBtn->setMinimumSize(88, 42);
|
||||
editBtn->setEnabled(!busy_);
|
||||
actionButtons_.push_back(editBtn);
|
||||
connect(editBtn, &QPushButton::clicked, this, [this, id = d.id]() {
|
||||
if (!busy_) {
|
||||
emit editRequested(id);
|
||||
}
|
||||
});
|
||||
row->addWidget(editBtn);
|
||||
|
||||
auto* actions = new QVBoxLayout;
|
||||
actions->setSpacing(8);
|
||||
auto* state = textLabel(stateText, "desktopState", card);
|
||||
state->setAlignment(Qt::AlignRight);
|
||||
state->setProperty("tone", tone);
|
||||
actions->addWidget(state);
|
||||
auto* buttons = new QHBoxLayout;
|
||||
buttons->setSpacing(8);
|
||||
auto* edit = new QPushButton(QStringLiteral("设置"), card);
|
||||
edit->setMinimumSize(64, 36);
|
||||
edit->setCursor(Qt::PointingHandCursor);
|
||||
edit->setAccessibleName(QStringLiteral("设置 %1").arg(d.name));
|
||||
edit->setEnabled(!busy_);
|
||||
actionButtons_.push_back(edit);
|
||||
connect(edit, &QPushButton::clicked, this, [this, id = d.id] { if (!busy_) emit editRequested(id); });
|
||||
buttons->addWidget(edit);
|
||||
auto* enter = new QPushButton(QStringLiteral("连接"), card);
|
||||
enter->setObjectName(QStringLiteral("connectButton"));
|
||||
enter->setMinimumSize(82, 36);
|
||||
enter->setCursor(Qt::PointingHandCursor);
|
||||
enter->setAccessibleName(QStringLiteral("连接 %1").arg(d.name));
|
||||
enter->setEnabled(!busy_ && d.state == DesktopState::kAvailable);
|
||||
if (d.state == DesktopState::kAvailable) {
|
||||
auto* connectBtn = new QPushButton(QStringLiteral("连接"), card);
|
||||
connectBtn->setMinimumSize(110, 42);
|
||||
connectBtn->setStyleSheet(QStringLiteral(
|
||||
"QPushButton { background: #2f6feb; color: white; border: none; "
|
||||
"border-radius: 6px; font-weight: bold; }"
|
||||
"QPushButton:disabled { background: #3a4a63; }"));
|
||||
connectBtn->setEnabled(!busy_);
|
||||
actionButtons_.push_back(connectBtn);
|
||||
connect(connectBtn, &QPushButton::clicked, this, [this, id = d.id]() {
|
||||
if (!busy_) {
|
||||
setBusy(true);
|
||||
setStatus(QStringLiteral("正在连接,请稍候…"));
|
||||
emit desktopSelected(id);
|
||||
}
|
||||
actionButtons_.push_back(enter);
|
||||
connect(enter, &QPushButton::clicked, this, [this, id = d.id] {
|
||||
if (busy_) return;
|
||||
setBusy(true);
|
||||
setStatus(QStringLiteral("正在连接,请稍候…"));
|
||||
emit desktopSelected(id);
|
||||
});
|
||||
row->addWidget(connectBtn);
|
||||
} else {
|
||||
auto* btn = new QPushButton(QStringLiteral("连接"), card);
|
||||
btn->setMinimumSize(110, 42);
|
||||
btn->setEnabled(false);
|
||||
row->addWidget(btn);
|
||||
}
|
||||
|
||||
buttons->addWidget(enter);
|
||||
actions->addLayout(buttons);
|
||||
row->addLayout(actions);
|
||||
cardsLayout_->insertWidget(cardsLayout_->count() - 1, card);
|
||||
}
|
||||
}
|
||||
|
||||
void DesktopListPage::setStatus(const QString& text, bool error) {
|
||||
statusError_ = error;
|
||||
labelStatus_->setText(text);
|
||||
labelStatus_->setStyleSheet(
|
||||
QStringLiteral("color: %1;").arg(error ? QStringLiteral("#e05561")
|
||||
: QStringLiteral("#8a94a6")));
|
||||
labelStatus_->setStyleSheet(QStringLiteral("color: %1; font-size: 13px;")
|
||||
.arg(error ? (light_ ? "#b33243" : "#ff9ca6") : (light_ ? "#596575" : "#a5b1bf")));
|
||||
if (desktops_.empty()) labelEmpty_->setText(text.isEmpty()
|
||||
? QStringLiteral("暂无可用桌面\n请联系管理员分配云桌面")
|
||||
: QStringLiteral("正在等待云桌面\n连接状态将自动更新"));
|
||||
}
|
||||
|
||||
void DesktopListPage::setBusy(bool busy) {
|
||||
busy_ = busy;
|
||||
for (QPushButton* button : actionButtons_) {
|
||||
if (button) {
|
||||
button->setEnabled(!busy_);
|
||||
}
|
||||
}
|
||||
for (QPushButton* button : actionButtons_) button->setEnabled(!busy_);
|
||||
}
|
||||
|
||||
QString DesktopListPage::currentUser() const {
|
||||
return currentUser_;
|
||||
}
|
||||
QString DesktopListPage::currentUser() const { return currentUser_; }
|
||||
|
||||
void DesktopListPage::setCurrentUser(const QString& user) {
|
||||
currentUser_ = user;
|
||||
labelUser_->setText(user.isEmpty()
|
||||
? QStringLiteral("当前用户:未登录")
|
||||
: QStringLiteral("当前用户:%1").arg(user));
|
||||
}
|
||||
|
||||
void DesktopListPage::arrangePage() {
|
||||
// Keep native text at a readable size; the list scrolls on short displays.
|
||||
const bool compact = width() < 1000;
|
||||
const int left = compact ? 24 : qRound(width() * .465);
|
||||
const int right = compact ? 24 : qMax(28, qRound(width() * .055));
|
||||
const int top = compact ? 112 : qMax(100, qRound(height() * .155));
|
||||
int panelHeight = height() - top - (compact ? 24 : qRound(height() * .10));
|
||||
if (height() >= 900) panelHeight = qMin(panelHeight, qRound(height() * .64));
|
||||
panel_->setGeometry(left, top, qMax(320, width() - left - right), qMax(300, panelHeight));
|
||||
for (QFrame* card : panel_->findChildren<QFrame*>(QStringLiteral("desktopCard"))) {
|
||||
card->setMinimumHeight(height() >= 900 ? 120 : 86);
|
||||
}
|
||||
themeButton_->setGeometry(width() - 166, 28, 132, 40);
|
||||
}
|
||||
|
||||
void DesktopListPage::resizeEvent(QResizeEvent* event) {
|
||||
QWidget::resizeEvent(event);
|
||||
arrangePage();
|
||||
}
|
||||
|
||||
void DesktopListPage::paintEvent(QPaintEvent*) {
|
||||
QPainter p(this);
|
||||
p.setRenderHint(QPainter::Antialiasing);
|
||||
p.fillRect(rect(), QColor(light_ ? "#ffffff" : "#181f27"));
|
||||
const bool compact = width() < 1000;
|
||||
const double s = qMin(width() / 1920.0, height() / 1080.0);
|
||||
const double divide = width() * .52;
|
||||
QLinearGradient background(0, 0, divide, height());
|
||||
background.setColorAt(0, QColor(light_ ? "#fcf8f5" : "#1b222b"));
|
||||
background.setColorAt(1, QColor(light_ ? "#f5e6da" : "#262930"));
|
||||
p.fillRect(QRectF(0, 0, compact ? width() : divide, height()), background);
|
||||
static const QPixmap logo(QStringLiteral(":/brand/logo.png"));
|
||||
p.setRenderHint(QPainter::SmoothPixmapTransform);
|
||||
p.drawPixmap(QRectF(44 * s + 12, compact ? 25 : 42 * s, compact ? 200 : 240 * s, compact ? 66 : 79 * s), logo, logo.rect());
|
||||
if (compact) return;
|
||||
p.save();
|
||||
p.scale(s, s);
|
||||
QFont font(QStringLiteral("Noto Sans CJK SC"));
|
||||
font.setPixelSize(46); font.setBold(true); p.setFont(font);
|
||||
p.setPen(QColor(light_ ? "#1f2937" : "#e4e8ed"));
|
||||
p.drawText(QRectF(56, 157, 740, 130), Qt::AlignLeft, QStringLiteral("百工协作,\n万象更新。"));
|
||||
font.setPixelSize(15); font.setBold(false); p.setFont(font);
|
||||
p.setPen(QColor(light_ ? "#596575" : "#a5b1bf"));
|
||||
p.drawText(QRectF(56, 306, 760, 36), Qt::AlignLeft, QStringLiteral("连接你的云端桌面,让工作自然延续。"));
|
||||
const double cx = 420, cy = 665;
|
||||
QRadialGradient halo(cx, cy, 355);
|
||||
halo.setColorAt(0, light_ ? QColor(255, 255, 255, 210) : QColor(230, 161, 94, 26));
|
||||
halo.setColorAt(1, QColor(230, 161, 94, 0));
|
||||
p.setPen(Qt::NoPen); p.setBrush(halo); p.drawEllipse(QPointF(cx, cy), 355, 335);
|
||||
static const QPixmap mark(QStringLiteral(":/brand/mark.png"));
|
||||
p.drawPixmap(QRectF(110, 400, 620, 525), mark, mark.rect());
|
||||
p.setBrush(QColor(light_ ? "#bd865b" : "#c6a06c"));
|
||||
for (int i = 0; i < 28; ++i) {
|
||||
double a = i * 2.399, r = 330 + (i % 4) * 13;
|
||||
p.drawEllipse(QPointF(cx + std::cos(a) * r, cy + std::sin(a) * r * .82), i % 4 ? 1 : 2, i % 4 ? 1 : 2);
|
||||
}
|
||||
font.setPixelSize(13); p.setFont(font); p.setPen(QColor(light_ ? "#596575" : "#a5b1bf"));
|
||||
p.drawText(QRectF(56, 1007, 710, 32), Qt::AlignLeft, QStringLiteral("百工万象 / 云桌面工作台"));
|
||||
p.restore();
|
||||
}
|
||||
|
||||
} // namespace vdi
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
// 桌面列表页
|
||||
//
|
||||
// 登录成功后展示当前用户可用的云桌面卡片;
|
||||
// 开机设备认证后展示平台授权的云桌面卡片;
|
||||
// 选择桌面后由 MainWindow 获取连接票据并启动对应协议会话;
|
||||
// 会话退出后返回本页。
|
||||
|
||||
@ -15,6 +15,8 @@
|
||||
class QLabel;
|
||||
class QPushButton;
|
||||
class QVBoxLayout;
|
||||
class QFrame;
|
||||
class QScrollArea;
|
||||
|
||||
namespace vdi {
|
||||
|
||||
@ -41,15 +43,29 @@ signals:
|
||||
|
||||
private:
|
||||
void rebuildCards();
|
||||
void applyTheme();
|
||||
void arrangePage();
|
||||
|
||||
protected:
|
||||
void paintEvent(QPaintEvent* event) override;
|
||||
void resizeEvent(QResizeEvent* event) override;
|
||||
|
||||
private:
|
||||
|
||||
std::vector<DesktopInfo> desktops_;
|
||||
QString currentUser_;
|
||||
|
||||
QLabel* labelUser_ = nullptr;
|
||||
QLabel* labelStatus_ = nullptr;
|
||||
QVBoxLayout* cardsLayout_ = nullptr;
|
||||
std::vector<QPushButton*> actionButtons_;
|
||||
bool busy_ = false;
|
||||
bool light_ = false;
|
||||
bool statusError_ = false;
|
||||
QFrame* panel_ = nullptr;
|
||||
QScrollArea* scroll_ = nullptr;
|
||||
QLabel* labelCount_ = nullptr;
|
||||
QLabel* labelEmpty_ = nullptr;
|
||||
QPushButton* themeButton_ = nullptr;
|
||||
};
|
||||
|
||||
} // namespace vdi
|
||||
|
||||
@ -36,6 +36,7 @@ public:
|
||||
|
||||
QString currentUser() const { return currentUser_; }
|
||||
bool platformManaged() const { return platform_.configured(); }
|
||||
void setPolicyHandler(std::function<void(int)> handler) { platform_.desktopRefreshInterval=std::move(handler); }
|
||||
void sessionConnected(const QString& id) { if (platformManaged()) platform_.connected(id); }
|
||||
void sessionEnded(const QString& id) { if (platformManaged()) platform_.ended(id); }
|
||||
void setRuntimeHandlers(std::function<void(std::function<void(bool)>)> reset, std::function<QString()> state) { platform_.resetSession=std::move(reset); platform_.kioskState=std::move(state); }
|
||||
|
||||
@ -65,11 +65,30 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
|
||||
auto* refresh=new QTimer(this); refresh->setInterval(15000);
|
||||
connect(refresh,&QTimer::timeout,this,[this]{ if (fsm_.isIn(State::kDesktopList)) refreshDesktopList(); });
|
||||
refresh->start();
|
||||
desktopService_.setPolicyHandler([refresh](int seconds){ refresh->setInterval(seconds*1000); });
|
||||
}
|
||||
deviceAuthTimer_ = new QTimer(this);
|
||||
deviceAuthTimer_->setSingleShot(true);
|
||||
connect(deviceAuthTimer_, &QTimer::timeout, this, &MainWindow::authenticateDevice);
|
||||
deviceAuthMessage_ = QStringLiteral("正在连接管理平台,请稍候…");
|
||||
// Paint the product page before even a synchronous configuration failure.
|
||||
// Device authentication has no username/password recovery flow.
|
||||
fsm_.transitionTo(State::kDesktopList, QStringLiteral("请选择要进入的云桌面"));
|
||||
QTimer::singleShot(0, this, &MainWindow::authenticateDevice);
|
||||
}
|
||||
|
||||
void MainWindow::authenticateDevice() {
|
||||
if (deviceAuthenticated_ || deviceAuthInFlight_) return;
|
||||
deviceAuthTimer_->stop();
|
||||
deviceAuthInFlight_ = true;
|
||||
const QString deviceUser = QString::fromStdString(config_.sn).trimmed();
|
||||
desktopService_.login(deviceUser.isEmpty() ? QStringLiteral("terminal") : deviceUser,
|
||||
QStringLiteral("kiosk"), [this](const AuthResult& result) {
|
||||
deviceAuthInFlight_ = false;
|
||||
deviceAuthenticated_ = result.ok;
|
||||
if (result.ok) {
|
||||
deviceAuthRetryMs_ = 2000;
|
||||
qInfo() << "vdi_device_auth result=ready";
|
||||
currentUser_ = result.displayName;
|
||||
if (desktopListPage_) {
|
||||
desktopListPage_->setCurrentUser(currentUser_);
|
||||
@ -77,14 +96,19 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
|
||||
if (fsm_.isIn(State::kDesktopList)) refreshDesktopList();
|
||||
return;
|
||||
}
|
||||
// A real platform authentication failure must still return to the
|
||||
// login page. The normal device-authenticated boot path never shows
|
||||
// that page, so the first painted kiosk frame is the desktop list.
|
||||
// A cold boot may precede Wi-Fi, clock synchronization or the API.
|
||||
// Stay on the selection page and never substitute local authorization.
|
||||
deviceAuthMessage_ = QStringLiteral("%1;正在自动重试,无需重启盒子")
|
||||
.arg(result.message.isEmpty()
|
||||
? QStringLiteral("管理平台暂不可用")
|
||||
: result.message);
|
||||
qWarning() << "vdi_device_auth result=waiting retry_ms=" << deviceAuthRetryMs_;
|
||||
if (fsm_.isIn(State::kDesktopList)) {
|
||||
fsm_.transitionTo(State::kConfig, result.message);
|
||||
refreshDesktopList();
|
||||
}
|
||||
deviceAuthTimer_->start(deviceAuthRetryMs_);
|
||||
deviceAuthRetryMs_ = qMin(deviceAuthRetryMs_ * 2, 30000);
|
||||
});
|
||||
fsm_.transitionTo(State::kDesktopList, QStringLiteral("请选择要进入的云桌面"));
|
||||
}
|
||||
|
||||
MainWindow::~MainWindow() {
|
||||
@ -155,11 +179,6 @@ void MainWindow::setupUi() {
|
||||
|
||||
auto* list = new DesktopListPage(this);
|
||||
desktopListPage_ = list;
|
||||
list->setStyleSheet(
|
||||
QStringLiteral("QWidget { background: #0f1420; color: #e6e9ef; }"
|
||||
"QPushButton { background: #232d44; border: 1px solid #2b3654; "
|
||||
"border-radius: 6px; color: #e6e9ef; }"
|
||||
"QPushButton:hover { background: #2b3654; }"));
|
||||
stack_->addWidget(list);
|
||||
connect(list, &DesktopListPage::desktopSelected, this, &MainWindow::onDesktopSelected);
|
||||
connect(list, &DesktopListPage::editRequested, this, &MainWindow::onEditDesktopRequested);
|
||||
@ -169,6 +188,9 @@ void MainWindow::setupUi() {
|
||||
statusBar_ = new QStatusBar(this);
|
||||
statusBar_->setStyleSheet(QStringLiteral("QStatusBar { background: #161d2e; color: #8a94a6; }"));
|
||||
setStatusBar(statusBar_);
|
||||
// Product feedback is shown in the page/overlay; internal FSM names are
|
||||
// retained in diagnostics rather than displayed along the screen edge.
|
||||
statusBar_->hide();
|
||||
|
||||
connectionTimer_ = new QTimer(this);
|
||||
connectionTimer_->setSingleShot(true);
|
||||
@ -502,6 +524,15 @@ void MainWindow::showDesktopList() {
|
||||
}
|
||||
|
||||
void MainWindow::refreshDesktopList() {
|
||||
if (!deviceAuthenticated_) {
|
||||
desktops_.clear();
|
||||
if (desktopListPage_) {
|
||||
desktopListPage_->setDesktops(desktops_);
|
||||
desktopListPage_->setBusy(true);
|
||||
desktopListPage_->setStatus(deviceAuthMessage_);
|
||||
}
|
||||
return;
|
||||
}
|
||||
desktopService_.listDesktops([this](std::vector<DesktopInfo> list, const QString& error) {
|
||||
if (error.isEmpty()) {
|
||||
desktops_ = list;
|
||||
@ -510,6 +541,7 @@ void MainWindow::refreshDesktopList() {
|
||||
}
|
||||
if (desktopListPage_) {
|
||||
desktopListPage_->setDesktops(desktops_);
|
||||
desktopListPage_->setBusy(!fsm_.isIn(State::kDesktopList));
|
||||
if (!error.isEmpty()) {
|
||||
desktopListPage_->setStatus(error, true);
|
||||
} else {
|
||||
@ -536,6 +568,7 @@ void MainWindow::onLoginRequested(const QString& username, const QString& passwo
|
||||
}
|
||||
|
||||
void MainWindow::onDesktopSelected(const QString& desktopId) {
|
||||
if (!deviceAuthenticated_) return;
|
||||
if (!fsm_.canConnect()) {
|
||||
if (desktopListPage_) {
|
||||
desktopListPage_->setBusy(false);
|
||||
|
||||
@ -48,6 +48,7 @@ private slots:
|
||||
void onMenuAction(int action);
|
||||
|
||||
private:
|
||||
friend class MainWindowTest;
|
||||
enum class ConnectionPhase {
|
||||
kIdle,
|
||||
kTicket,
|
||||
@ -61,6 +62,7 @@ private:
|
||||
void setupHeartbeat();
|
||||
void onStateChanged(vdi::State from, vdi::State to, const QString& detail);
|
||||
void refreshDesktopList();
|
||||
void authenticateDevice();
|
||||
bool startMoonlightSession(const ConnectionTicket& ticket, quint64 attempt);
|
||||
void onMoonlightEvent(quint64 attempt, const MoonlightSession::Event& event);
|
||||
void waitForMoonlightWindow(quint64 attempt);
|
||||
@ -98,6 +100,11 @@ private:
|
||||
TransitionOverlay* overlay_ = nullptr;
|
||||
QTimer* connectionTimer_ = nullptr;
|
||||
QTimer* heartbeatTimer_ = nullptr;
|
||||
QTimer* deviceAuthTimer_ = nullptr;
|
||||
bool deviceAuthenticated_ = false;
|
||||
bool deviceAuthInFlight_ = false;
|
||||
int deviceAuthRetryMs_ = 2000;
|
||||
QString deviceAuthMessage_;
|
||||
QElapsedTimer connectionClock_;
|
||||
QElapsedTimer phaseClock_;
|
||||
quint64 connectionAttempt_ = 0;
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
#include "platform_client.h"
|
||||
#include "config.h"
|
||||
#include "terminal_policy.h"
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
@ -14,7 +15,7 @@
|
||||
|
||||
namespace vdi {
|
||||
namespace {
|
||||
constexpr const char* kVersion="qt-platform/0.2.0";
|
||||
constexpr const char* kVersion="qt-platform/0.4.0";
|
||||
QString readText(const QString& path) { QFile f(path); return f.open(QIODevice::ReadOnly) ? QString::fromUtf8(f.readAll()).trimmed() : QString(); }
|
||||
}
|
||||
PlatformClient::PlatformClient() {
|
||||
@ -46,6 +47,8 @@ PlatformClient::PlatformClient() {
|
||||
heartbeat_.setInterval(30000); commands_.setInterval(3000);
|
||||
connect(&heartbeat_,&QTimer::timeout,this,[this]{ heartbeat(); });
|
||||
connect(&commands_,&QTimer::timeout,this,[this]{ poll(); });
|
||||
policies_.setInterval(15000);
|
||||
connect(&policies_,&QTimer::timeout,this,[this]{ fetchPolicy(); });
|
||||
}
|
||||
bool PlatformClient::save() {
|
||||
config_["deviceToken"]=token_;
|
||||
@ -67,22 +70,27 @@ void PlatformClient::request(const QString& method,const QString& path,const QJs
|
||||
QNetworkReply* reply=method=="GET" ? network_.get(req) : network_.post(req,QJsonDocument(body).toJson(QJsonDocument::Compact));
|
||||
QTimer::singleShot(8000,reply,[reply]{ if (reply->isRunning()) reply->abort(); });
|
||||
connect(reply,&QNetworkReply::readyRead,this,[reply]{ if (reply->bytesAvailable()>1024*1024) reply->abort(); });
|
||||
connect(reply,&QNetworkReply::finished,this,[this,reply,callback]{
|
||||
connect(reply,&QNetworkReply::finished,this,[this,reply,callback,path]{
|
||||
const int status=reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||
const auto object=QJsonDocument::fromJson(reply->read(1024*1024)).object();
|
||||
QString error;
|
||||
if (reply->error()!=QNetworkReply::NoError || status<200 || status>=300) error=object.value("message").toString(QStringLiteral("平台请求失败,请检查网络、证书或设备授权"));
|
||||
if (status==409) error=QStringLiteral("409: ")+error;
|
||||
if (!error.isEmpty() && (path=="/terminal/config" || path=="/terminal/register")) {
|
||||
// Diagnose cold-boot ordering without logging identities or bodies.
|
||||
qWarning() << "vdi_device_auth_request http_status=" << status
|
||||
<< "network_error=" << static_cast<int>(reply->error());
|
||||
}
|
||||
reply->deleteLater(); callback(object,error);
|
||||
});
|
||||
}
|
||||
void PlatformClient::authenticate(std::function<void(const AuthResult&)> callback) {
|
||||
const auto ready=[this,callback](const QJsonObject&,const QString& error){
|
||||
const auto ready=[this,callback](const QJsonObject& config,const QString& error){
|
||||
AuthResult result; result.ok=error.isEmpty(); result.message=error; result.displayName=QString::fromStdString(loadConfig().sn);
|
||||
authenticated_=result.ok;
|
||||
if (result.ok) {
|
||||
if (!save()) { result.ok=false; result.message=QStringLiteral("无法安全保存设备身份"); authenticated_=false; }
|
||||
else { heartbeat_.start(); commands_.start(); heartbeat(); }
|
||||
else { applyPolicy(config); heartbeat_.start(); commands_.start(); policies_.start(); heartbeat(); fetchPolicy(); }
|
||||
}
|
||||
callback(result);
|
||||
};
|
||||
@ -145,7 +153,7 @@ void PlatformClient::flushEnds() {
|
||||
QString PlatformClient::bootID() const { return readText("/proc/sys/kernel/random/boot_id"); }
|
||||
void PlatformClient::heartbeat() {
|
||||
if (!authenticated_) return;
|
||||
request("POST","/terminal/heartbeat",{{"agentVersion",kVersion}},[](const QJsonObject&,const QString&){});
|
||||
request("POST","/terminal/heartbeat",{{"agentVersion",kVersion},{"policyProtocol",1},{"systemMetrics",metrics_.sample()}},[](const QJsonObject&,const QString&){});
|
||||
if (connected_) connected(activeSession_);
|
||||
flushEnds();
|
||||
const QString pending=config_.value("pendingRebootTask").toString();
|
||||
@ -154,6 +162,42 @@ void PlatformClient::heartbeat() {
|
||||
}
|
||||
flushAcks();
|
||||
}
|
||||
void PlatformClient::fetchPolicy() {
|
||||
if (!authenticated_ || policyInFlight_) return;
|
||||
policyInFlight_=true;
|
||||
request("GET","/terminal/config",{},[this](const QJsonObject& reply,const QString& error){
|
||||
policyInFlight_=false;
|
||||
if (error.isEmpty()) applyPolicy(reply);
|
||||
else flushPolicyAck();
|
||||
});
|
||||
}
|
||||
void PlatformClient::applyPolicy(const QJsonObject& reply) {
|
||||
// Remain compatible with servers predating policy support.
|
||||
if (!reply.contains("policyProtocol")) return;
|
||||
if (reply==appliedPolicy_) { flushPolicyAck(); return; }
|
||||
std::function<void(int,int,int)> handler;
|
||||
if (desktopRefreshInterval) handler=[this](int heartbeat,int commands,int refresh){
|
||||
heartbeat_.setInterval(heartbeat*1000);commands_.setInterval(commands*1000);desktopRefreshInterval(refresh);
|
||||
};
|
||||
const QString error=applyTerminalPolicy(reply,QString::fromStdString(configDir())+"/policy.json",handler);
|
||||
if (error.isEmpty()) appliedPolicy_=reply;
|
||||
const QString id=reply.value("deploymentId").toString();
|
||||
const int version=reply.value("configVersion").toInt();
|
||||
if (!id.isEmpty() && version>=2) {
|
||||
pendingPolicyAck_={{"deploymentId",id},{"configVersion",version},{"status",error.isEmpty()?"applied":"failed"}};
|
||||
if (!error.isEmpty()) pendingPolicyAck_["errorCode"]=error;
|
||||
}
|
||||
flushPolicyAck();
|
||||
}
|
||||
void PlatformClient::flushPolicyAck() {
|
||||
if (!authenticated_ || pendingPolicyAck_.isEmpty() || policyAckInFlight_) return;
|
||||
policyAckInFlight_=true;
|
||||
const auto sent=pendingPolicyAck_;
|
||||
request("POST","/terminal/config/ack",sent,[this,sent](const QJsonObject&,const QString& error){
|
||||
policyAckInFlight_=false;
|
||||
if ((error.isEmpty() || error.startsWith("409:")) && pendingPolicyAck_==sent) pendingPolicyAck_={};
|
||||
});
|
||||
}
|
||||
QJsonObject PlatformClient::diagnostics() const {
|
||||
const double uptime=readText("/proc/uptime").section(' ',0,0).toDouble();
|
||||
QFile log(QDir::homePath()+"/.xsession-errors"); QString tail;
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
#include <QTimer>
|
||||
#include <functional>
|
||||
#include "desktop_session.h"
|
||||
#include "system_metrics.h"
|
||||
|
||||
namespace vdi {
|
||||
// Optional HTTPS device adapter. A configured platform never falls back to local authorization.
|
||||
@ -23,7 +24,11 @@ public:
|
||||
void ended(const QString& sessionId);
|
||||
std::function<void(std::function<void(bool)>)> resetSession;
|
||||
std::function<QString()> kioskState;
|
||||
std::function<void(int)> desktopRefreshInterval;
|
||||
private:
|
||||
void fetchPolicy();
|
||||
void applyPolicy(const QJsonObject& reply);
|
||||
void flushPolicyAck();
|
||||
void request(const QString& method, const QString& path, const QJsonObject& body, Reply callback);
|
||||
void heartbeat();
|
||||
void poll();
|
||||
@ -35,9 +40,12 @@ private:
|
||||
QString bootID() const;
|
||||
QString configPath_;
|
||||
QJsonObject config_;
|
||||
SystemMetrics metrics_;
|
||||
QNetworkAccessManager network_;
|
||||
QSslConfiguration tls_;
|
||||
QTimer heartbeat_, commands_;
|
||||
QTimer heartbeat_, commands_, policies_;
|
||||
QJsonObject appliedPolicy_, pendingPolicyAck_;
|
||||
bool policyInFlight_=false, policyAckInFlight_=false;
|
||||
QString origin_, token_, error_, activeSession_;
|
||||
QSet<QString> ending_, ackInFlight_;
|
||||
bool configured_=false, authenticated_=false, polling_=false, connected_=false;
|
||||
|
||||
70
terminal/kiosk-shell/src/system_metrics.cpp
Normal file
70
terminal/kiosk-shell/src/system_metrics.cpp
Normal file
@ -0,0 +1,70 @@
|
||||
#include "system_metrics.h"
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QMap>
|
||||
#include <QRegularExpression>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
|
||||
namespace {
|
||||
QByteArray read(const QString& path) {
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::ReadOnly)) return {};
|
||||
return file.read(65536);
|
||||
}
|
||||
}
|
||||
namespace vdi {
|
||||
SystemMetrics::SystemMetrics(QString procRoot, QString thermalRoot)
|
||||
: procRoot_(std::move(procRoot)), thermalRoot_(std::move(thermalRoot)) {}
|
||||
QJsonObject SystemMetrics::sample() {
|
||||
QJsonObject result{{"cpuPercent", QJsonValue::Null}, {"memoryPercent", QJsonValue::Null},
|
||||
{"socTemperatureC", QJsonValue::Null}, {"uptimeSeconds", QJsonValue::Null}};
|
||||
const auto fields = QString::fromLatin1(read(procRoot_ + "/stat")).section('\n',0,0)
|
||||
.simplified().split(' ', Qt::SkipEmptyParts);
|
||||
std::array<quint64, 8> counters{};
|
||||
bool valid = fields.size() >= 9 && fields[0] == "cpu";
|
||||
for (int i=0; valid && i<8; ++i) {
|
||||
bool ok=false; counters[i]=fields[i+1].toULongLong(&ok); valid=ok;
|
||||
}
|
||||
if (valid && hasPrevious_) {
|
||||
quint64 total=0, idle=0;
|
||||
for (int i=0; valid && i<8; ++i) {
|
||||
if (counters[i]<previous_[i]) { valid=false; break; }
|
||||
const quint64 delta=counters[i]-previous_[i];
|
||||
if (delta>std::numeric_limits<quint64>::max()-total) { valid=false; break; }
|
||||
total+=delta;
|
||||
if (i==3 || i==4) idle+=delta;
|
||||
}
|
||||
if (valid && total>0) result["cpuPercent"]=100.0*(total-idle)/total;
|
||||
}
|
||||
// Rebaseline after a reset; never reuse an earlier sample after a read failure.
|
||||
hasPrevious_=fields.size()>=9 && fields[0]=="cpu";
|
||||
for (int i=0; hasPrevious_ && i<8; ++i) {
|
||||
bool ok=false; counters[i]=fields[i+1].toULongLong(&ok); hasPrevious_=ok;
|
||||
}
|
||||
previous_=counters;
|
||||
QMap<QString,quint64> memory;
|
||||
for (const auto& line : read(procRoot_+"/meminfo").split('\n')) {
|
||||
const auto parts=QString::fromLatin1(line).simplified().split(' ');
|
||||
if (parts.size()!=3 || parts[2]!="kB") continue;
|
||||
bool ok=false; const auto value=parts[1].toULongLong(&ok);
|
||||
if (ok) memory.insert(parts[0],value);
|
||||
}
|
||||
const auto total=memory.value("MemTotal:");
|
||||
if (total && memory.contains("MemAvailable:") && memory.value("MemAvailable:")<=total)
|
||||
result["memoryPercent"]=100.0*(total-memory.value("MemAvailable:"))/total;
|
||||
bool ok=false;
|
||||
const double uptime=QString::fromLatin1(read(procRoot_+"/uptime")).simplified().section(' ',0,0).toDouble(&ok);
|
||||
if (ok && std::isfinite(uptime) && uptime>=0 && uptime<=3155760000.0) result["uptimeSeconds"]=uptime;
|
||||
// This metric names its source. Do not relabel an arbitrary thermal zone as CPU/GPU.
|
||||
for (const auto& zone : QDir(thermalRoot_).entryList({"thermal_zone*"},QDir::Dirs|QDir::NoDotAndDotDot,QDir::Name)) {
|
||||
const QString base=thermalRoot_+"/"+zone;
|
||||
if (read(base+"/type").trimmed()!="soc-thermal") continue;
|
||||
const double temperature=QString::fromLatin1(read(base+"/temp")).trimmed().toDouble(&ok)/1000.0;
|
||||
if (ok && std::isfinite(temperature) && temperature>=-40 && temperature<=150) {
|
||||
result["socTemperatureC"]=temperature; break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
19
terminal/kiosk-shell/src/system_metrics.h
Normal file
19
terminal/kiosk-shell/src/system_metrics.h
Normal file
@ -0,0 +1,19 @@
|
||||
#ifndef VDI_SYSTEM_METRICS_H
|
||||
#define VDI_SYSTEM_METRICS_H
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
#include <array>
|
||||
|
||||
namespace vdi {
|
||||
// Read-only Linux counters. Missing or reset counters never become zero usage.
|
||||
class SystemMetrics {
|
||||
public:
|
||||
explicit SystemMetrics(QString procRoot = "/proc", QString thermalRoot = "/sys/class/thermal");
|
||||
QJsonObject sample();
|
||||
private:
|
||||
QString procRoot_, thermalRoot_;
|
||||
std::array<quint64, 8> previous_{};
|
||||
bool hasPrevious_ = false;
|
||||
};
|
||||
}
|
||||
#endif
|
||||
31
terminal/kiosk-shell/src/terminal_policy.cpp
Normal file
31
terminal/kiosk-shell/src/terminal_policy.cpp
Normal file
@ -0,0 +1,31 @@
|
||||
#include "terminal_policy.h"
|
||||
#include <QJsonDocument>
|
||||
#include <QSaveFile>
|
||||
#include <cmath>
|
||||
namespace vdi {
|
||||
QString applyTerminalPolicy(const QJsonObject& config, const QString& path,
|
||||
const std::function<void(int,int,int)>& apply) {
|
||||
auto integer=[&config](const char* key,int min,int max) {
|
||||
const auto v=config.value(key);
|
||||
return v.isDouble() && std::isfinite(v.toDouble()) && std::floor(v.toDouble())==v.toDouble()
|
||||
&& v.toDouble()>=min && v.toDouble()<=max;
|
||||
};
|
||||
if (!integer("policyProtocol",1,1) || !integer("configVersion",1,2147483647)
|
||||
|| !integer("heartbeatInterval",15,60) || !integer("commandPollInterval",3,30)
|
||||
|| !integer("desktopRefreshInterval",5,120) || !config.value("deploymentId").isString()
|
||||
|| config.value("deploymentId").toString().size()>100
|
||||
|| (config.value("deploymentId").toString().isEmpty() != (config.value("configVersion").toInt()==1)))
|
||||
return QStringLiteral("invalid_policy");
|
||||
if (!apply) return QStringLiteral("runtime_unavailable");
|
||||
QJsonObject safe;
|
||||
for (const auto* key:{"policyProtocol","configVersion","deploymentId","heartbeatInterval","commandPollInterval","desktopRefreshInterval"})
|
||||
safe[key]=config.value(key);
|
||||
QSaveFile file(path);
|
||||
if (!file.open(QIODevice::WriteOnly) || !file.setPermissions(QFileDevice::ReadOwner|QFileDevice::WriteOwner))
|
||||
return QStringLiteral("persist_failed");
|
||||
const auto bytes=QJsonDocument(safe).toJson();
|
||||
if (file.write(bytes)!=bytes.size() || !file.commit()) return QStringLiteral("persist_failed");
|
||||
apply(config.value("heartbeatInterval").toInt(),config.value("commandPollInterval").toInt(),config.value("desktopRefreshInterval").toInt());
|
||||
return {};
|
||||
}
|
||||
}
|
||||
11
terminal/kiosk-shell/src/terminal_policy.h
Normal file
11
terminal/kiosk-shell/src/terminal_policy.h
Normal file
@ -0,0 +1,11 @@
|
||||
#ifndef KIOSK_SHELL_TERMINAL_POLICY_H
|
||||
#define KIOSK_SHELL_TERMINAL_POLICY_H
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
#include <functional>
|
||||
namespace vdi {
|
||||
// Writes only the management policy. Identity and stream configuration are separate.
|
||||
QString applyTerminalPolicy(const QJsonObject& config, const QString& path,
|
||||
const std::function<void(int,int,int)>& apply);
|
||||
}
|
||||
#endif
|
||||
35
terminal/kiosk-shell/tests/preview_desktop_list.cpp
Normal file
35
terminal/kiosk-shell/tests/preview_desktop_list.cpp
Normal file
@ -0,0 +1,35 @@
|
||||
#include <QApplication>
|
||||
#include <QDir>
|
||||
#include <QPushButton>
|
||||
#include <QSettings>
|
||||
#include <QTimer>
|
||||
#include "desktop_list_page.h"
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
QApplication app(argc, argv);
|
||||
app.setOrganizationName("vdi-preview");
|
||||
app.setApplicationName("desktop-selection");
|
||||
const QStringList args = app.arguments();
|
||||
if (args.size() < 5) return 2;
|
||||
QSettings().setValue("appearance/light", args[4] == "light");
|
||||
vdi::DesktopListPage page;
|
||||
page.resize(args[2].toInt(), args[3].toInt());
|
||||
page.setCurrentUser(QStringLiteral("云终端"));
|
||||
if (!args.contains("empty")) {
|
||||
std::vector<vdi::DesktopInfo> desktops;
|
||||
for (const QString& os : {QStringLiteral("Windows"), QStringLiteral("Ubuntu"), QStringLiteral("麒麟")}) {
|
||||
vdi::DesktopInfo d;
|
||||
d.id = os; d.name = os + QStringLiteral(" 云桌面"); d.os = os;
|
||||
d.state = vdi::DesktopState::kAvailable;
|
||||
if (args.contains("states") && os == "Ubuntu") d.state = vdi::DesktopState::kPending;
|
||||
if (args.contains("states") && os == QStringLiteral("麒麟")) d.state = vdi::DesktopState::kUnassigned;
|
||||
desktops.push_back(d);
|
||||
}
|
||||
page.setDesktops(desktops);
|
||||
}
|
||||
if (args.contains("error")) page.setStatus(QStringLiteral("连接失败:管理平台暂不可用;正在自动重试,无需重启盒子"), true);
|
||||
if (args.contains("busy")) { page.setBusy(true); page.setStatus(QStringLiteral("正在连接,请稍候…")); }
|
||||
page.show();
|
||||
QTimer::singleShot(150, [&] { app.exit(page.grab().save(args[1]) ? 0 : 1); });
|
||||
return app.exec();
|
||||
}
|
||||
218
terminal/kiosk-shell/tests/test_main_window.cpp
Normal file
218
terminal/kiosk-shell/tests/test_main_window.cpp
Normal file
@ -0,0 +1,218 @@
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QLabel>
|
||||
#include <QProcess>
|
||||
#include <QSslCertificate>
|
||||
#include <QSslKey>
|
||||
#include <QSslSocket>
|
||||
#include <QStackedWidget>
|
||||
#include <QTcpServer>
|
||||
#include <QTemporaryDir>
|
||||
#include <QtTest>
|
||||
|
||||
#include "desktop_list_page.h"
|
||||
#include "main_window.h"
|
||||
|
||||
namespace vdi {
|
||||
|
||||
// Real HTTPS requests exercise the device adapter without production identities.
|
||||
class TestPlatform : public QTcpServer {
|
||||
public:
|
||||
QSslCertificate certificate;
|
||||
QSslKey key;
|
||||
bool deny = false;
|
||||
int delayMs = 0;
|
||||
int authRequests = 0;
|
||||
int listRequests = 0;
|
||||
|
||||
protected:
|
||||
void incomingConnection(qintptr descriptor) override {
|
||||
auto* socket = new QSslSocket(this);
|
||||
socket->setLocalCertificate(certificate);
|
||||
socket->setPrivateKey(key);
|
||||
socket->setPeerVerifyMode(QSslSocket::VerifyNone);
|
||||
if (!socket->setSocketDescriptor(descriptor)) {
|
||||
socket->deleteLater();
|
||||
return;
|
||||
}
|
||||
auto buffer = std::make_shared<QByteArray>();
|
||||
connect(socket, &QSslSocket::readyRead, this, [this, socket, buffer] {
|
||||
*buffer += socket->readAll();
|
||||
if (!buffer->contains("\r\n\r\n") || socket->property("answered").toBool()) return;
|
||||
socket->setProperty("answered", true);
|
||||
const QByteArray path = buffer->split(' ').value(1);
|
||||
QJsonObject body;
|
||||
int status = 200;
|
||||
if (path == "/api/v1/terminal/config") {
|
||||
++authRequests;
|
||||
if (deny) {
|
||||
status = 403;
|
||||
body["message"] = QStringLiteral("设备授权不可用");
|
||||
}
|
||||
} else if (path.startsWith("/api/v1/terminal/desktops")) {
|
||||
++listRequests;
|
||||
body["items"] = QJsonArray{QJsonObject{
|
||||
{"id", "authorized-desktop"}, {"name", "Authorized Windows"},
|
||||
{"os", "Windows"}, {"powerState", "running"},
|
||||
{"endpoint", "127.0.0.1"}, {"app", "Desktop"}}};
|
||||
} else {
|
||||
body["items"] = QJsonArray{};
|
||||
}
|
||||
const QByteArray data = QJsonDocument(body).toJson(QJsonDocument::Compact);
|
||||
const QByteArray response = "HTTP/1.1 " + QByteArray::number(status) +
|
||||
" Result\r\nContent-Type: application/json\r\nContent-Length: " +
|
||||
QByteArray::number(data.size()) + "\r\nConnection: close\r\n\r\n" + data;
|
||||
QTimer::singleShot(delayMs, socket, [socket, response] {
|
||||
socket->write(response);
|
||||
socket->disconnectFromHost();
|
||||
});
|
||||
});
|
||||
connect(socket, &QSslSocket::disconnected, socket, &QObject::deleteLater);
|
||||
socket->startServerEncryption();
|
||||
}
|
||||
};
|
||||
|
||||
class MainWindowTest : public QObject {
|
||||
Q_OBJECT
|
||||
private:
|
||||
QTemporaryDir certificates_;
|
||||
std::unique_ptr<QTemporaryDir> homeDirectory_;
|
||||
QByteArray originalHome_;
|
||||
QString certificatePath_;
|
||||
QString keyPath_;
|
||||
|
||||
void configure(TestPlatform& server, quint16 port, bool invalidCA = false) {
|
||||
const QString directory = homeDirectory_->path() + "/.config/vdi-terminal";
|
||||
QVERIFY(QDir().mkpath(directory));
|
||||
QFile certificateFile(certificatePath_);
|
||||
QVERIFY(certificateFile.open(QIODevice::ReadOnly));
|
||||
server.certificate = QSslCertificate(certificateFile.readAll());
|
||||
QFile keyFile(keyPath_);
|
||||
QVERIFY(keyFile.open(QIODevice::ReadOnly));
|
||||
server.key = QSslKey(keyFile.readAll(), QSsl::Rsa);
|
||||
QFile config(directory + "/platform.json");
|
||||
QVERIFY(config.open(QIODevice::WriteOnly));
|
||||
config.write(QJsonDocument(QJsonObject{
|
||||
{"url", QString("https://127.0.0.1:%1").arg(port)},
|
||||
{"deviceToken", "isolated-test-identity"},
|
||||
{"caFile", invalidCA ? directory + "/missing.pem" : certificatePath_}
|
||||
}).toJson());
|
||||
config.close();
|
||||
// A previously saved local desktop must not bypass device authentication.
|
||||
QFile local(directory + "/desktops.json");
|
||||
QVERIFY(local.open(QIODevice::WriteOnly));
|
||||
local.write("[{\"id\":\"local-only\",\"name\":\"Unauthorized Local\","
|
||||
"\"os\":\"Windows\",\"endpoint\":\"127.0.0.2\"}]");
|
||||
}
|
||||
|
||||
void prepare(MainWindow& window) {
|
||||
// Tests neither update the real watchdog heartbeat nor touch X11 focus.
|
||||
window.heartbeatTimer_->stop();
|
||||
window.deviceAuthRetryMs_ = 30;
|
||||
}
|
||||
|
||||
bool selectionVisible(MainWindow& window) {
|
||||
return window.fsm_.isIn(State::kDesktopList) &&
|
||||
window.stack_->currentWidget() == window.desktopListPage_;
|
||||
}
|
||||
|
||||
private slots:
|
||||
void initTestCase() {
|
||||
QVERIFY(certificates_.isValid());
|
||||
certificatePath_ = certificates_.path() + "/server.pem";
|
||||
keyPath_ = certificates_.path() + "/server.key";
|
||||
QProcess openssl;
|
||||
openssl.start("openssl", {"req", "-x509", "-newkey", "rsa:2048", "-nodes",
|
||||
"-keyout", keyPath_, "-out", certificatePath_, "-days", "1",
|
||||
"-subj", "/CN=127.0.0.1", "-addext", "subjectAltName=IP:127.0.0.1"});
|
||||
QVERIFY(openssl.waitForFinished(10000));
|
||||
QCOMPARE(openssl.exitCode(), 0);
|
||||
}
|
||||
void init() {
|
||||
originalHome_ = qgetenv("HOME");
|
||||
homeDirectory_ = std::make_unique<QTemporaryDir>();
|
||||
QVERIFY(homeDirectory_->isValid());
|
||||
qputenv("HOME", homeDirectory_->path().toUtf8());
|
||||
}
|
||||
void cleanup() {
|
||||
if (originalHome_.isNull()) qunsetenv("HOME");
|
||||
else qputenv("HOME", originalHome_);
|
||||
homeDirectory_.reset();
|
||||
}
|
||||
void offlineBootRecoversWithoutRestart() {
|
||||
TestPlatform server;
|
||||
QVERIFY(server.listen(QHostAddress::LocalHost, 0));
|
||||
const quint16 port = server.serverPort();
|
||||
server.close();
|
||||
configure(server, port);
|
||||
MainWindow window;
|
||||
prepare(window);
|
||||
QTRY_VERIFY(window.deviceAuthTimer_->isActive());
|
||||
QVERIFY(selectionVisible(window));
|
||||
QVERIFY(!window.deviceAuthenticated_);
|
||||
QVERIFY(window.desktops_.empty());
|
||||
QCOMPARE(server.listRequests, 0);
|
||||
QVERIFY(server.listen(QHostAddress::LocalHost, port));
|
||||
QTRY_VERIFY(window.deviceAuthenticated_);
|
||||
QTRY_COMPARE(window.desktops_.size(), size_t(1));
|
||||
QCOMPARE(window.desktops_[0].id, QString("authorized-desktop"));
|
||||
QVERIFY(selectionVisible(window));
|
||||
QVERIFY(!window.deviceAuthTimer_->isActive());
|
||||
}
|
||||
void rejectedIdentityStaysOnSelectionAndRetries() {
|
||||
TestPlatform server;
|
||||
QVERIFY(server.listen(QHostAddress::LocalHost, 0));
|
||||
configure(server, server.serverPort());
|
||||
server.deny = true;
|
||||
MainWindow window;
|
||||
prepare(window);
|
||||
QTRY_VERIFY(server.authRequests >= 3);
|
||||
QVERIFY(selectionVisible(window));
|
||||
QVERIFY(!window.deviceAuthenticated_);
|
||||
QVERIFY(window.desktops_.empty());
|
||||
QCOMPARE(server.listRequests, 0);
|
||||
server.deny = false;
|
||||
QTRY_VERIFY(window.deviceAuthenticated_);
|
||||
QTRY_COMPARE(window.desktops_.size(), size_t(1));
|
||||
QVERIFY(selectionVisible(window));
|
||||
}
|
||||
void slowAuthenticationDoesNotOverlap() {
|
||||
TestPlatform server;
|
||||
QVERIFY(server.listen(QHostAddress::LocalHost, 0));
|
||||
configure(server, server.serverPort());
|
||||
server.delayMs = 200;
|
||||
MainWindow window;
|
||||
prepare(window);
|
||||
QTRY_COMPARE(server.authRequests, 1);
|
||||
window.authenticateDevice();
|
||||
window.authenticateDevice();
|
||||
QVERIFY(window.deviceAuthInFlight_);
|
||||
QTRY_VERIFY(window.deviceAuthenticated_);
|
||||
QCOMPARE(server.authRequests, 1);
|
||||
QVERIFY(selectionVisible(window));
|
||||
}
|
||||
void invalidConfigurationNeverShowsLogin() {
|
||||
TestPlatform server;
|
||||
configure(server, 1, true);
|
||||
MainWindow window;
|
||||
prepare(window);
|
||||
QTRY_VERIFY(window.deviceAuthTimer_->isActive());
|
||||
QVERIFY(selectionVisible(window));
|
||||
QVERIFY(!window.deviceAuthenticated_);
|
||||
QCOMPARE(server.authRequests, 0);
|
||||
}
|
||||
void localModeStillLoadsDesktops() {
|
||||
MainWindow window;
|
||||
prepare(window);
|
||||
QTRY_VERIFY(window.deviceAuthenticated_);
|
||||
QVERIFY(selectionVisible(window));
|
||||
QVERIFY(!window.deviceAuthTimer_->isActive());
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace vdi
|
||||
|
||||
QTEST_MAIN(vdi::MainWindowTest)
|
||||
#include "test_main_window.moc"
|
||||
47
terminal/kiosk-shell/tests/test_system_metrics.cpp
Normal file
47
terminal/kiosk-shell/tests/test_system_metrics.cpp
Normal file
@ -0,0 +1,47 @@
|
||||
#include "system_metrics.h"
|
||||
#include <QtTest>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QTemporaryDir>
|
||||
class SystemMetricsTest : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void countersAndMissing() {
|
||||
QTemporaryDir root;
|
||||
auto put=[&](const QString& path,const QByteArray& value) { QFile f(root.path()+path); QVERIFY(f.open(QIODevice::WriteOnly)); f.write(value); };
|
||||
QDir().mkpath(root.path()+"/thermal/thermal_zone0");
|
||||
put("/stat","cpu 100 0 50 800 50 0 0 0 100 0\n");
|
||||
put("/meminfo","MemTotal: 1000 kB\nMemAvailable: 700 kB\n");
|
||||
put("/uptime","120.5 80\n");
|
||||
put("/thermal/thermal_zone0/type","soc-thermal\n");
|
||||
put("/thermal/thermal_zone0/temp","44370\n");
|
||||
vdi::SystemMetrics collector(root.path(),root.path()+"/thermal");
|
||||
auto sample=collector.sample();
|
||||
QVERIFY(sample["cpuPercent"].isNull());
|
||||
QCOMPARE(sample["memoryPercent"].toDouble(),30.0);
|
||||
QCOMPARE(sample["socTemperatureC"].toDouble(),44.37);
|
||||
QCOMPARE(sample["uptimeSeconds"].toDouble(),120.5);
|
||||
put("/stat","cpu 120 0 60 860 60 0 0 0 500 0\n");
|
||||
QCOMPARE(collector.sample()["cpuPercent"].toDouble(),30.0); // guest time is not counted twice
|
||||
QVERIFY(collector.sample()["cpuPercent"].isNull()); // no counter delta
|
||||
put("/stat","cpu 1 0 0 1 0 0 0 0\n");
|
||||
QVERIFY(collector.sample()["cpuPercent"].isNull()); // reboot/reset
|
||||
put("/stat","broken\n"); collector.sample();
|
||||
put("/stat","cpu 2 0 0 5 0 0 0 0\n");
|
||||
QVERIFY(collector.sample()["cpuPercent"].isNull());
|
||||
put("/meminfo","MemTotal: 100 kB\nMemAvailable: 101 kB\n");
|
||||
put("/uptime","nan\n");
|
||||
put("/thermal/thermal_zone0/type","gpu-thermal\n");
|
||||
sample=collector.sample();
|
||||
QVERIFY(sample["memoryPercent"].isNull());
|
||||
QVERIFY(sample["uptimeSeconds"].isNull());
|
||||
QVERIFY(sample["socTemperatureC"].isNull());
|
||||
}
|
||||
void unavailable() {
|
||||
vdi::SystemMetrics collector("/nonexistent/proc","/nonexistent/thermal");
|
||||
const auto sample=collector.sample();
|
||||
for (auto value : sample) QVERIFY(value.isNull());
|
||||
}
|
||||
};
|
||||
QTEST_GUILESS_MAIN(SystemMetricsTest)
|
||||
#include "test_system_metrics.moc"
|
||||
33
terminal/kiosk-shell/tests/test_terminal_policy.cpp
Normal file
33
terminal/kiosk-shell/tests/test_terminal_policy.cpp
Normal file
@ -0,0 +1,33 @@
|
||||
#include <QtTest>
|
||||
#include <QTemporaryDir>
|
||||
#include <QFile>
|
||||
#include <QJsonDocument>
|
||||
#include "terminal_policy.h"
|
||||
class TerminalPolicyTest : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void durableBeforeRuntime() {
|
||||
QTemporaryDir dir; const QString path=dir.path()+"/policy.json";int calls=0;
|
||||
QJsonObject p{{"policyProtocol",1},{"configVersion",2},{"deploymentId","dispatch_1"},
|
||||
{"heartbeatInterval",30},{"commandPollInterval",5},{"desktopRefreshInterval",20},{"deviceToken","must-not-persist"}};
|
||||
auto apply=[&](int h,int c,int r){
|
||||
QFile file(path); QVERIFY(file.open(QIODevice::ReadOnly));
|
||||
const auto disk=QJsonDocument::fromJson(file.readAll()).object();
|
||||
QCOMPARE(disk.value("configVersion").toInt(),2);QVERIFY(!disk.contains("deviceToken"));
|
||||
QCOMPARE(h,30);QCOMPARE(c,5);QCOMPARE(r,20);++calls;
|
||||
};
|
||||
QCOMPARE(vdi::applyTerminalPolicy(p,path,apply),QString());QCOMPARE(calls,1);
|
||||
const auto original=QFileInfo(path).size();
|
||||
for (const auto key:{"heartbeatInterval","commandPollInterval","desktopRefreshInterval","policyProtocol","configVersion"}) {
|
||||
auto bad=p;bad[key]=1.5;
|
||||
QCOMPARE(vdi::applyTerminalPolicy(bad,path,apply),QString("invalid_policy"));
|
||||
}
|
||||
auto bad=p;bad["heartbeatInterval"]=90;
|
||||
QCOMPARE(vdi::applyTerminalPolicy(bad,path,apply),QString("invalid_policy"));
|
||||
QCOMPARE(QFileInfo(path).size(),original);QCOMPARE(calls,1);
|
||||
QCOMPARE(vdi::applyTerminalPolicy(p,dir.path()+"/missing/policy.json",apply),QString("persist_failed"));
|
||||
QCOMPARE(vdi::applyTerminalPolicy(p,path,{}),QString("runtime_unavailable"));QCOMPARE(calls,1);
|
||||
}
|
||||
};
|
||||
QTEST_GUILESS_MAIN(TerminalPolicyTest)
|
||||
#include "test_terminal_policy.moc"
|
||||
@ -17,6 +17,7 @@ ssh -o ConnectTimeout=8 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/n
|
||||
echo "==> 同步源码到盒子"
|
||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'rm -rf ~/vdi-build/repo/kiosk-shell/src ~/vdi-build/repo/kiosk-shell/tests && mkdir -p ~/vdi-build/repo/kiosk-shell/src ~/vdi-build/repo/kiosk-shell/tests ~/vdi-build/repo/kiosk-shell/scripts'
|
||||
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/CMakeLists.txt" "$BOX":~/vdi-build/repo/kiosk-shell/
|
||||
scp -q -r "$SRC/resources" "$BOX":~/vdi-build/repo/kiosk-shell/
|
||||
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC"/src/*.cpp "$SRC"/src/*.h "$BOX":~/vdi-build/repo/kiosk-shell/src/
|
||||
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC"/tests/*.cpp "$BOX":~/vdi-build/repo/kiosk-shell/tests/
|
||||
scp -q "$SRC/tests/test_display_guard.py" "$BOX":~/vdi-build/repo/kiosk-shell/tests/
|
||||
|
||||
Loading…
Reference in New Issue
Block a user