diff --git a/terminal/kiosk-shell/CMakeLists.txt b/terminal/kiosk-shell/CMakeLists.txt
index 6b37a08..a6c5762 100644
--- a/terminal/kiosk-shell/CMakeLists.txt
+++ b/terminal/kiosk-shell/CMakeLists.txt
@@ -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)
diff --git a/terminal/kiosk-shell/resources/README.md b/terminal/kiosk-shell/resources/README.md
new file mode 100644
index 0000000..71e27bb
--- /dev/null
+++ b/terminal/kiosk-shell/resources/README.md
@@ -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.
diff --git a/terminal/kiosk-shell/resources/brand.qrc b/terminal/kiosk-shell/resources/brand.qrc
new file mode 100644
index 0000000..1f6ba7d
--- /dev/null
+++ b/terminal/kiosk-shell/resources/brand.qrc
@@ -0,0 +1,7 @@
+
+
+ logo.png
+ mark.png
+ kylinsoft.png
+
+
diff --git a/terminal/kiosk-shell/resources/kylinsoft.png b/terminal/kiosk-shell/resources/kylinsoft.png
new file mode 100644
index 0000000..4bb25d9
Binary files /dev/null and b/terminal/kiosk-shell/resources/kylinsoft.png differ
diff --git a/terminal/kiosk-shell/resources/logo.png b/terminal/kiosk-shell/resources/logo.png
new file mode 100644
index 0000000..b415d5a
Binary files /dev/null and b/terminal/kiosk-shell/resources/logo.png differ
diff --git a/terminal/kiosk-shell/resources/mark.png b/terminal/kiosk-shell/resources/mark.png
new file mode 100644
index 0000000..cca318f
Binary files /dev/null and b/terminal/kiosk-shell/resources/mark.png differ
diff --git a/terminal/kiosk-shell/src/desktop_list_page.cpp b/terminal/kiosk-shell/src/desktop_list_page.cpp
index f279329..b6764c6 100644
--- a/terminal/kiosk-shell/src/desktop_list_page.cpp
+++ b/terminal/kiosk-shell/src/desktop_list_page.cpp
@@ -1,66 +1,163 @@
#include "desktop_list_page.h"
-#include
#include
#include
#include
+#include
#include
+#include
#include
+#include
#include
+#include
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& desktops) {
@@ -70,127 +167,161 @@ void DesktopListPage::setDesktops(const std::vector& 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(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
diff --git a/terminal/kiosk-shell/src/desktop_list_page.h b/terminal/kiosk-shell/src/desktop_list_page.h
index ea453d2..d67d6b0 100644
--- a/terminal/kiosk-shell/src/desktop_list_page.h
+++ b/terminal/kiosk-shell/src/desktop_list_page.h
@@ -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 desktops_;
QString currentUser_;
- QLabel* labelUser_ = nullptr;
QLabel* labelStatus_ = nullptr;
QVBoxLayout* cardsLayout_ = nullptr;
std::vector 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
diff --git a/terminal/kiosk-shell/src/desktop_service.h b/terminal/kiosk-shell/src/desktop_service.h
index b97ecb2..249f05c 100644
--- a/terminal/kiosk-shell/src/desktop_service.h
+++ b/terminal/kiosk-shell/src/desktop_service.h
@@ -36,6 +36,7 @@ public:
QString currentUser() const { return currentUser_; }
bool platformManaged() const { return platform_.configured(); }
+ void setPolicyHandler(std::function 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)> reset, std::function state) { platform_.resetSession=std::move(reset); platform_.kioskState=std::move(state); }
diff --git a/terminal/kiosk-shell/src/main_window.cpp b/terminal/kiosk-shell/src/main_window.cpp
index 0474308..2166a61 100644
--- a/terminal/kiosk-shell/src/main_window.cpp
+++ b/terminal/kiosk-shell/src/main_window.cpp
@@ -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 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);
diff --git a/terminal/kiosk-shell/src/main_window.h b/terminal/kiosk-shell/src/main_window.h
index f0fd057..8a6d25b 100644
--- a/terminal/kiosk-shell/src/main_window.h
+++ b/terminal/kiosk-shell/src/main_window.h
@@ -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;
diff --git a/terminal/kiosk-shell/src/platform_client.cpp b/terminal/kiosk-shell/src/platform_client.cpp
index 47e2b2d..bdbed38 100644
--- a/terminal/kiosk-shell/src/platform_client.cpp
+++ b/terminal/kiosk-shell/src/platform_client.cpp
@@ -1,5 +1,6 @@
#include "platform_client.h"
#include "config.h"
+#include "terminal_policy.h"
#include
#include
#include
@@ -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(reply->error());
+ }
reply->deleteLater(); callback(object,error);
});
}
void PlatformClient::authenticate(std::function 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 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;
diff --git a/terminal/kiosk-shell/src/platform_client.h b/terminal/kiosk-shell/src/platform_client.h
index a681c62..5335f7f 100644
--- a/terminal/kiosk-shell/src/platform_client.h
+++ b/terminal/kiosk-shell/src/platform_client.h
@@ -8,6 +8,7 @@
#include
#include
#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)> resetSession;
std::function kioskState;
+ std::function 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 ending_, ackInFlight_;
bool configured_=false, authenticated_=false, polling_=false, connected_=false;
diff --git a/terminal/kiosk-shell/src/system_metrics.cpp b/terminal/kiosk-shell/src/system_metrics.cpp
new file mode 100644
index 0000000..637d87a
--- /dev/null
+++ b/terminal/kiosk-shell/src/system_metrics.cpp
@@ -0,0 +1,70 @@
+#include "system_metrics.h"
+#include
+#include
+#include
+#include
+#include
+#include
+
+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 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]std::numeric_limits::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 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;
+}
+}
diff --git a/terminal/kiosk-shell/src/system_metrics.h b/terminal/kiosk-shell/src/system_metrics.h
new file mode 100644
index 0000000..d5482fd
--- /dev/null
+++ b/terminal/kiosk-shell/src/system_metrics.h
@@ -0,0 +1,19 @@
+#ifndef VDI_SYSTEM_METRICS_H
+#define VDI_SYSTEM_METRICS_H
+#include
+#include
+#include
+
+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 previous_{};
+ bool hasPrevious_ = false;
+};
+}
+#endif
diff --git a/terminal/kiosk-shell/src/terminal_policy.cpp b/terminal/kiosk-shell/src/terminal_policy.cpp
new file mode 100644
index 0000000..9a0b53a
--- /dev/null
+++ b/terminal/kiosk-shell/src/terminal_policy.cpp
@@ -0,0 +1,31 @@
+#include "terminal_policy.h"
+#include
+#include
+#include
+namespace vdi {
+QString applyTerminalPolicy(const QJsonObject& config, const QString& path,
+ const std::function& 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 {};
+}
+}
diff --git a/terminal/kiosk-shell/src/terminal_policy.h b/terminal/kiosk-shell/src/terminal_policy.h
new file mode 100644
index 0000000..384da32
--- /dev/null
+++ b/terminal/kiosk-shell/src/terminal_policy.h
@@ -0,0 +1,11 @@
+#ifndef KIOSK_SHELL_TERMINAL_POLICY_H
+#define KIOSK_SHELL_TERMINAL_POLICY_H
+#include
+#include
+#include
+namespace vdi {
+// Writes only the management policy. Identity and stream configuration are separate.
+QString applyTerminalPolicy(const QJsonObject& config, const QString& path,
+ const std::function& apply);
+}
+#endif
diff --git a/terminal/kiosk-shell/tests/preview_desktop_list.cpp b/terminal/kiosk-shell/tests/preview_desktop_list.cpp
new file mode 100644
index 0000000..becb2dd
--- /dev/null
+++ b/terminal/kiosk-shell/tests/preview_desktop_list.cpp
@@ -0,0 +1,35 @@
+#include
+#include
+#include
+#include
+#include
+#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 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();
+}
diff --git a/terminal/kiosk-shell/tests/test_main_window.cpp b/terminal/kiosk-shell/tests/test_main_window.cpp
new file mode 100644
index 0000000..2e69099
--- /dev/null
+++ b/terminal/kiosk-shell/tests/test_main_window.cpp
@@ -0,0 +1,218 @@
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#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();
+ 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 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();
+ 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"
diff --git a/terminal/kiosk-shell/tests/test_system_metrics.cpp b/terminal/kiosk-shell/tests/test_system_metrics.cpp
new file mode 100644
index 0000000..2c66ff5
--- /dev/null
+++ b/terminal/kiosk-shell/tests/test_system_metrics.cpp
@@ -0,0 +1,47 @@
+#include "system_metrics.h"
+#include
+#include
+#include
+#include
+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"
diff --git a/terminal/kiosk-shell/tests/test_terminal_policy.cpp b/terminal/kiosk-shell/tests/test_terminal_policy.cpp
new file mode 100644
index 0000000..0929827
--- /dev/null
+++ b/terminal/kiosk-shell/tests/test_terminal_policy.cpp
@@ -0,0 +1,33 @@
+#include
+#include
+#include
+#include
+#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"
diff --git a/tools/deploy_kiosk.sh b/tools/deploy_kiosk.sh
index 1bc5233..9cb89fa 100644
--- a/tools/deploy_kiosk.sh
+++ b/tools/deploy_kiosk.sh
@@ -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/