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/