功能:完善终端认证恢复、选择页及策略和指标采集

This commit is contained in:
宋一鸣 2026-11-10 17:51:39 +08:00
parent d87fa47e24
commit eef485306f
22 changed files with 904 additions and 151 deletions

View File

@ -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)

View 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.

View File

@ -0,0 +1,7 @@
<RCC>
<qresource prefix="/brand">
<file>logo.png</file>
<file>mark.png</file>
<file>kylinsoft.png</file>
</qresource>
</RCC>

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

View File

@ -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

View File

@ -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

View File

@ -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); }

View File

@ -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);

View File

@ -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;

View File

@ -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;

View File

@ -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;

View 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;
}
}

View 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

View 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 {};
}
}

View 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

View 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();
}

View 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"

View 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"

View 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"

View File

@ -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/