修复:完善终端开机及串流交接并接入云管平台

This commit is contained in:
songyiming committed 2026-11-09 17:54:18 +08:00
1 parent 8276da0d45
commit 18a9ce995d
25 files changed
+922 -48

No files matched your search

+3 -1
View File
@@ -13,7 +13,7 @@ if(NOT CMAKE_BUILD_TYPE)
endif()
# ---- Qt5 ----
find_package(Qt5 REQUIRED COMPONENTS Core Gui Widgets)
find_package(Qt5 REQUIRED COMPONENTS Core Gui Widgets Network)
include(CTest)
add_executable(kiosk-shell
@@ -23,6 +23,7 @@ add_executable(kiosk-shell
src/desktop_list_page.cpp
src/desktop_settings_dialog.cpp
src/desktop_service.cpp
src/platform_client.cpp
src/desktop_store.cpp
src/moonlight_session.cpp
src/transition_overlay.cpp
@@ -38,6 +39,7 @@ target_link_libraries(kiosk-shell PRIVATE
Qt5::Core
Qt5::Gui
Qt5::Widgets
Qt5::Network
)
if(UNIX AND NOT APPLE)
@@ -0,0 +1,5 @@
[Seat:*]
autologin-user=armbian
autologin-user-timeout=0
user-session=vdi-kiosk
autologin-session=vdi-kiosk
+1 -1
View File
@@ -15,7 +15,7 @@
# vsync 1=开启垂直同步(--vsync),0=关闭(--no-vsync)
# 同步开关 A/B 未见明确延迟收益,保留开启以避免撕裂。
# 连续动画确认服务端可发送约59FPS;当前2K软件呈现选择20FPS档,
# NVENC重启故障及各档性能记录见 docs/moonlight-kiosk-20261028.md。
# NVENC重启故障及各档性能记录见 docs/terminal-guide.md#session。
# frame-pacing 1=开启帧率匀速(--frame-pacing),0=关闭(--no-frame-pacing)
# performance-overlay 1=显示性能叠图(调试用)
# profile smooth / low-latency / diagnostic;为空时保留下方显式开关
@@ -5,6 +5,7 @@
namespace vdi {
void DesktopService::login(const QString& username, const QString& password, AuthCallback callback) {
if (platformManaged()) { platform_.authenticate(std::move(callback)); return; }
// 本地演示认证:账号与密码非空即通过。
// 正式版本替换为 /api/v1/terminal/session/login(SN+用户身份校验)。
AuthResult result;
@@ -22,6 +23,7 @@ void DesktopService::login(const QString& username, const QString& password, Aut
}
void DesktopService::listDesktops(DesktopListCallback callback) {
if (platformManaged()) { platform_.list(std::move(callback)); return; }
// 桌面与连接参数来自本地连接配置(现场可配);
// 正式版本替换为 /api/v1/terminal/session/desktops(按用户绑定返回)。
std::vector<DesktopInfo> desktops = DesktopStore::load();
@@ -31,6 +33,7 @@ void DesktopService::listDesktops(DesktopListCallback callback) {
}
void DesktopService::createTicket(const QString& desktopId, TicketCallback callback) {
if (platformManaged()) { platform_.ticket(desktopId,std::move(callback)); return; }
// 本地演示票据:由连接配置构造。
// 正式版本替换为 /api/v1/terminal/session/connect-ticket(短期一次性票据)。
ConnectionTicket ticket;
+8 -3
View File
@@ -1,9 +1,8 @@
// 桌面服务适配层
//
// 职责:登录认证、桌面列表、连接票据。
// 当前为本地演示实现(账号非空即通过;Windows 桌面走 Moonlight,
// Ubuntu/麒麟未分配连接信息)。后续接入管理平台时替换/扩展本类为
// API 适配器(/api/v1/terminal/session/login|desktops|connect-ticket),
// 配置 platform.json 时通过 HTTPS 使用真实设备身份、授权列表和单次票据;
// 未配置平台时保留本地 POC。平台认证失败不会回退本地授权。
// 页面与状态机不感知数据来源变化。
#ifndef KIOSK_SHELL_DESKTOP_SERVICE_H
@@ -14,6 +13,7 @@
#include <vector>
#include "desktop_session.h"
#include "platform_client.h"
namespace vdi {
@@ -35,9 +35,14 @@ public:
void logout();
QString currentUser() const { return currentUser_; }
bool platformManaged() const { return platform_.configured(); }
void sessionConnected(const QString& id) { if (platformManaged()) platform_.connected(id); }
void sessionEnded(const QString& id) { if (platformManaged()) platform_.ended(id); }
void setRuntimeHandlers(std::function<void(std::function<void(bool)>)> reset, std::function<QString()> state) { platform_.resetSession=std::move(reset); platform_.kioskState=std::move(state); }
private:
QString currentUser_;
PlatformClient platform_;
};
} // namespace vdi
@@ -37,6 +37,7 @@ struct DesktopInfo {
// 一次会话的连接凭据;当前为本地构造,后续由平台 connect-ticket 接口下发
struct ConnectionTicket {
QString sessionId;
QString desktopId;
QString endpoint;
QString app;
+111 -20
View File
@@ -46,16 +46,45 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
setupHeartbeat();
config_ = loadConfig();
fsm_.transitionTo(State::kConfig, QStringLiteral("就绪"));
desktopService_.setRuntimeHandlers([this](std::function<void(bool)> done) {
showReturnCover(); stopSessions();
if (!fsm_.isIn(State::kDesktopList)) fsm_.transitionTo(State::kDesktopList,QStringLiteral("管理员已结束会话"));
presentKioskSurface(); releaseReturnCoverWhenReady();
if (desktopListPage_) desktopListPage_->setBusy(true);
auto* wait=new QTimer(this); wait->setInterval(100);
connect(wait,&QTimer::timeout,this,[this,wait,done,attempts=0]() mutable {
const bool stopped=!moonlightSession_ || !moonlightSession_->isRunning();
if (!stopped && ++attempts<100) return;
wait->stop(); wait->deleteLater();
if (desktopListPage_) desktopListPage_->setBusy(false);
done(stopped);
});
wait->start();
},[this]() { return QString::fromLatin1(stateToString(fsm_.current())); });
if (desktopService_.platformManaged()) {
auto* refresh=new QTimer(this); refresh->setInterval(15000);
connect(refresh,&QTimer::timeout,this,[this]{ if (fsm_.isIn(State::kDesktopList)) refreshDesktopList(); });
refresh->start();
}
const QString deviceUser = QString::fromStdString(config_.sn).trimmed();
desktopService_.login(deviceUser.isEmpty() ? QStringLiteral("terminal") : deviceUser,
QStringLiteral("kiosk"), [this](const AuthResult& result) {
if (result.ok) {
currentUser_ = result.displayName;
if (desktopListPage_) {
desktopListPage_->setCurrentUser(currentUser_);
}
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.
if (fsm_.isIn(State::kDesktopList)) {
fsm_.transitionTo(State::kConfig, result.message);
}
fsm_.transitionTo(State::kAuthenticating, QStringLiteral("自动登录"));
fsm_.transitionTo(State::kDesktopList, QStringLiteral("请选择要进入的云桌面"));
});
fsm_.transitionTo(State::kDesktopList, QStringLiteral("请选择要进入的云桌面"));
}
MainWindow::~MainWindow() {
@@ -87,6 +116,18 @@ void MainWindow::setupUi() {
setStyleSheet(QStringLiteral("QMainWindow { background: #0f1420; }"));
qApp->installEventFilter(this);
// The display-guard service may restore the physical output mode after
// this process has already mapped its window. Track screen geometry so
// the opaque kiosk surface immediately follows that mode change instead
// of leaving an exposed root-desktop strip at the old size.
for (QScreen* screen : QApplication::screens()) {
connect(screen, &QScreen::geometryChanged, this, [this](const QRect&) {
if (isVisible()) {
applyKioskSurface();
}
});
}
Qt::WindowFlags coverFlags = Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint | Qt::Tool;
#ifdef Q_OS_UNIX
coverFlags |= Qt::X11BypassWindowManagerHint;
@@ -200,15 +241,13 @@ void MainWindow::onStateChanged(State from, State to, const QString& detail) {
// state keeps the kiosk keyboard grab, including the connecting overlay.
const bool streamSurface = to == State::kSession || to == State::kReconnect;
if (streamSurface) {
if (isVisible()) {
hide();
}
// Moonlight owns Ctrl+Alt+Shift+Q while streaming and can destroy its
// window before QProcess notifies us. Keep our painted, full-screen
// list mapped underneath it: restoring a hidden window here is too
// late to prevent a frame of the host desktop from being scanned out.
setKioskInputGuard(false);
} else {
setKioskInputGuard(true);
if (to != State::kBoot) {
presentKioskSurface();
}
}
switch (to) {
@@ -234,15 +273,33 @@ void MainWindow::onStateChanged(State from, State to, const QString& detail) {
desktopListPage_->setBusy(false);
desktopListPage_->setStatus(QStringLiteral("连接失败:%1").arg(detail), true);
}
break;
return;
case State::kBoot:
break;
}
// Select the destination page before mapping/painting the window. In
// particular, the first boot frame must never paint the old login page.
if (!streamSurface && to != State::kBoot) {
presentKioskSurface();
}
}
void MainWindow::showEvent(QShowEvent* event) {
QMainWindow::showEvent(event);
applyKioskSurface();
// With the production X11 kiosk session there is no window manager to
// perform the initial activation handshake. Merely raising the Qt
// surface leaves _NET_ACTIVE_WINDOW unset and pointer/keyboard events can
// keep going to the desktop root. Use the same direct X11 map/focus path
// as the return-from-stream transition on the first paint as well.
presentKioskSurface();
// HDMI mode restoration can happen shortly after kiosk-shell maps. A
// delayed pass covers that race even on X servers that do not emit a
// geometryChanged signal for the mode switch.
QTimer::singleShot(1200, this, [this]() {
if (isVisible()) {
applyKioskSurface();
}
});
if (kioskInputGuard_) {
retryKeyboardGrab();
}
@@ -252,8 +309,15 @@ void MainWindow::applyKioskSurface() {
if (!isVisible()) {
return;
}
raise();
activateWindow();
// Geometry updates and delayed show callbacks must not steal the stream's
// focus or raise this persistent backing surface over Moonlight.
if (kioskInputGuard_) {
raise();
activateWindow();
if (overlay_ && overlay_->isVisible()) {
overlay_->raise();
}
}
if (QScreen* screen = windowHandle() ? windowHandle()->screen() : QApplication::primaryScreen()) {
// An override-redirect window does not receive the WM fullscreen
// negotiation; explicitly cover the complete output geometry.
@@ -441,11 +505,15 @@ void MainWindow::refreshDesktopList() {
desktopService_.listDesktops([this](std::vector<DesktopInfo> list, const QString& error) {
if (error.isEmpty()) {
desktops_ = list;
} else if (desktopService_.platformManaged()) {
desktops_.clear();
}
if (desktopListPage_) {
desktopListPage_->setDesktops(desktops_);
if (!error.isEmpty()) {
desktopListPage_->setStatus(error, true);
} else {
desktopListPage_->setStatus(QString());
}
}
});
@@ -491,6 +559,7 @@ void MainWindow::onDesktopSelected(const QString& desktopId) {
desktopService_.createTicket(desktopId, [this, attempt](const ConnectionTicket& ticket,
const QString& error) {
if (attempt != connectionAttempt_ || connectionPhase_ != ConnectionPhase::kTicket) {
desktopService_.sessionEnded(ticket.sessionId);
return;
}
connectionTimer_->stop();
@@ -507,6 +576,10 @@ void MainWindow::onDesktopSelected(const QString& desktopId) {
}
void MainWindow::onEditDesktopRequested(const QString& desktopId) {
if (desktopService_.platformManaged()) {
if (desktopListPage_) desktopListPage_->setStatus(QStringLiteral("桌面与连接参数由管理平台维护"));
return;
}
DesktopSettingsDialog dialog(desktops_, desktopId, this);
dialog.setStyleSheet(
QStringLiteral("QWidget { background: #0f1420; color: #e6e9ef; }"
@@ -568,6 +641,7 @@ void MainWindow::onMoonlightEvent(quint64 attempt, const MoonlightSession::Event
failConnection(attempt, event.message);
break;
case MoonlightSession::EventType::kFinished: {
desktopService_.sessionEnded(activeTicket_.sessionId);
connectionTimer_->stop();
logConnectionPhase(attempt, QStringLiteral("session"), QStringLiteral("finished"));
++connectionAttempt_;
@@ -590,6 +664,14 @@ void MainWindow::waitForMoonlightWindow(quint64 attempt) {
if (attempt != connectionAttempt_ || connectionPhase_ != ConnectionPhase::kStreamReady) {
return;
}
// Paint the return destination while our surface is still mapped, before
// handing the top of the X11 stack to Moonlight. A native client shortcut,
// normal disconnect, or client crash then reveals this page immediately.
showDesktopList();
desktopListPage_->setBusy(true);
desktopListPage_->setStatus(QString());
repaint();
QGuiApplication::sync();
if (!moonlightSession_->activateStreamWindow()) {
QTimer::singleShot(100, this, [this, attempt]() { waitForMoonlightWindow(attempt); });
return;
@@ -598,6 +680,7 @@ void MainWindow::waitForMoonlightWindow(quint64 attempt) {
connectionTimer_->stop();
logConnectionPhase(attempt, QStringLiteral("stream_ready"), QStringLiteral("ready"));
connectionPhase_ = ConnectionPhase::kSession;
desktopService_.sessionConnected(activeTicket_.sessionId);
fsm_.transitionTo(State::kSession, QStringLiteral("云桌面画面已就绪"));
overlay_->onFirstFrame();
}
@@ -613,6 +696,7 @@ void MainWindow::failConnection(quint64 attempt, const QString& message) {
if (moonlightSession_) {
moonlightSession_->stop();
}
releasePlatformSessionAfterStop(activeTicket_.sessionId);
overlay_->finish();
if (fsm_.isIn(State::kConnecting) || fsm_.isIn(State::kSession) ||
fsm_.isIn(State::kReconnect)) {
@@ -663,22 +747,29 @@ void MainWindow::stopSessions(bool invalidateAttempt) {
if (moonlightSession_) {
moonlightSession_->stop();
}
releasePlatformSessionAfterStop(activeTicket_.sessionId);
if (overlay_) {
overlay_->finish();
}
}
void MainWindow::releasePlatformSessionAfterStop(const QString& id) {
if (id.isEmpty()) return;
const qint64 pid=moonlightSession_?moonlightSession_->processId():0;
if (!pid) { desktopService_.sessionEnded(id); return; }
auto* wait=new QTimer(this); wait->setInterval(100);
connect(wait,&QTimer::timeout,this,[this,wait,id,pid] {
if (moonlightSession_ && moonlightSession_->isRunning() && moonlightSession_->processId()==pid) return;
wait->stop(); wait->deleteLater(); desktopService_.sessionEnded(id);
});
wait->start();
}
void MainWindow::onMenuAction(int action) {
switch (action) {
case 1:
if (fsm_.canConnect() && !activeTicket_.desktopId.isEmpty()) {
const quint64 attempt = ++connectionAttempt_;
connectingDesktopId_ = activeTicket_.desktopId;
connectionClock_.restart();
if (fsm_.transitionTo(State::kConnecting, activeTicket_.endpoint) &&
!startMoonlightSession(activeTicket_, attempt)) {
failConnection(attempt, QStringLiteral("已有串流进程正在运行"));
}
onDesktopSelected(activeTicket_.desktopId);
}
break;
case 2:
+1
View File
@@ -66,6 +66,7 @@ private:
void waitForMoonlightWindow(quint64 attempt);
void stopSessions();
void stopSessions(bool invalidateAttempt);
void releasePlatformSessionAfterStop(const QString& id);
void failConnection(quint64 attempt, const QString& message);
void armConnectionTimeout(quint64 attempt, ConnectionPhase phase, int timeoutMs);
void logConnectionPhase(quint64 attempt, const QString& phase,
+47 -5
View File
@@ -207,9 +207,50 @@ bool MoonlightSession::activateStreamWindow() {
return false;
}
// The production kiosk X11 session intentionally runs without an EWMH
// window manager. In that mode windowactivate always fails with
// "Your windowmanager claims not to support _NET_ACTIVE_WINDOW", even
// though the Moonlight window is already mapped and usable. Treating
// that failure as a stream failure leaves the opaque transition overlay
// up until the 30s timeout and then kills a healthy session.
if (QProcess::execute(QStringLiteral("xdotool"),
{QStringLiteral("windowactivate"), QStringLiteral("--sync"),
windowId}) == 0) {
return true;
}
// Fallback for the no-window-manager kiosk: map/raise the window and
// enforce the physical output geometry directly. SDL's fullscreen hint
// cannot be honoured by a WM-less X11 session, so geometry is the reliable
// handoff signal here.
if (QProcess::execute(QStringLiteral("xdotool"),
{QStringLiteral("windowmap"), windowId}) != 0) {
return false;
}
QProcess::execute(QStringLiteral("xdotool"), {QStringLiteral("windowraise"), windowId});
QProcess geometry;
geometry.start(QStringLiteral("xdotool"), {QStringLiteral("getdisplaygeometry" )});
if (!geometry.waitForFinished(500)) {
geometry.kill();
geometry.waitForFinished();
return false;
}
const QStringList size = QString::fromUtf8(geometry.readAllStandardOutput())
.simplified()
.split(QLatin1Char(' '), Qt::SkipEmptyParts);
if (size.size() != 2) {
return false;
}
const QString width = size.at(0);
const QString height = size.at(1);
if (QProcess::execute(QStringLiteral("xdotool"),
{QStringLiteral("windowmove"), windowId, QStringLiteral("0"),
QStringLiteral("0")}) != 0) {
return false;
}
return QProcess::execute(QStringLiteral("xdotool"),
{QStringLiteral("windowactivate"), QStringLiteral("--sync"),
windowId}) == 0;
{QStringLiteral("windowsize"), windowId, width, height}) == 0;
}
bool MoonlightSession::start(const ConnectionTicket& ticket, EventCallback callback) {
@@ -227,6 +268,7 @@ bool MoonlightSession::start(const ConnectionTicket& ticket, EventCallback callb
process_->setProgram(options_.program);
QProcessEnvironment environment = QProcessEnvironment::systemEnvironment();
environment.insert(QStringLiteral("VDI_KIOSK_STREAM"), QStringLiteral("1"));
environment.insert(QStringLiteral("GL_IS_SLOW"), QStringLiteral("0"));
environment.insert(QStringLiteral("SDL_RENDER_DRIVER"),
environment.value(QStringLiteral("VDI_SDL_RENDER_DRIVER"), QStringLiteral("opengles2")));
@@ -286,11 +328,11 @@ void MoonlightSession::onProcessFinished(int exitCode, QProcess::ExitStatus exit
return;
}
const QString message = exitStatus == QProcess::CrashExit
const bool failed = exitStatus == QProcess::CrashExit || exitCode != 0;
const QString message = failed
? QStringLiteral("串流异常退出 (code=%1)").arg(exitCode)
: QStringLiteral("串流已结束 (code=%1)").arg(exitCode);
emitTerminalEvent(exitStatus == QProcess::CrashExit ? EventType::kFailed
: EventType::kFinished,
emitTerminalEvent(failed ? EventType::kFailed : EventType::kFinished,
message);
}
@@ -0,0 +1,210 @@
#include "platform_client.h"
#include "config.h"
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QNetworkReply>
#include <QProcess>
#include <QSaveFile>
#include <QSslCertificate>
#include <QUrl>
#include <QDebug>
#include <QDir>
namespace vdi {
namespace {
constexpr const char* kVersion="qt-platform/0.2.0";
QString readText(const QString& path) { QFile f(path); return f.open(QIODevice::ReadOnly) ? QString::fromUtf8(f.readAll()).trimmed() : QString(); }
}
PlatformClient::PlatformClient() {
configPath_=QString::fromStdString(configDir())+QStringLiteral("/platform.json");
configured_=QFileInfo::exists(configPath_);
if (!configured_) return;
QFile file(configPath_);
if (!file.open(QIODevice::ReadOnly)) { error_=QStringLiteral("无法读取平台设备配置"); return; }
config_=QJsonDocument::fromJson(file.readAll()).object();
origin_=config_.value("url").toString();
QUrl url(origin_);
if (url.scheme()!="https" || url.host().isEmpty() || !url.userInfo().isEmpty() || url.hasQuery() || url.hasFragment() || (!url.path().isEmpty() && url.path()!="/")) {
error_=QStringLiteral("平台必须配置有效 HTTPS origin"); return;
}
while (origin_.endsWith('/')) origin_.chop(1);
tls_=QSslConfiguration::defaultConfiguration();
tls_.setProtocol(QSsl::TlsV1_2OrLater);
const QString ca=config_.value("caFile").toString();
if (!ca.isEmpty()) {
const auto certs=QSslCertificate::fromPath(ca);
if (certs.isEmpty()) { error_=QStringLiteral("平台 CA 证书不可用"); return; }
auto authorities=tls_.caCertificates(); authorities.append(certs); tls_.setCaCertificates(authorities);
}
token_=config_.value("deviceToken").toString();
for (const auto& value:config_.value("endingSessions").toArray()) ending_.insert(value.toString());
const QString old=config_.value("activeSessionId").toString();
if (!old.isEmpty()) ending_.insert(old);
config_.remove("activeSessionId");
heartbeat_.setInterval(30000); commands_.setInterval(3000);
connect(&heartbeat_,&QTimer::timeout,this,[this]{ heartbeat(); });
connect(&commands_,&QTimer::timeout,this,[this]{ poll(); });
}
bool PlatformClient::save() {
config_["deviceToken"]=token_;
QJsonArray ends; for (const auto& id:ending_) ends.append(id); config_["endingSessions"]=ends;
if (activeSession_.isEmpty()) config_.remove("activeSessionId"); else config_["activeSessionId"]=activeSession_;
QSaveFile file(configPath_);
if (!file.open(QIODevice::WriteOnly)) return false;
file.setPermissions(QFileDevice::ReadOwner|QFileDevice::WriteOwner);
file.write(QJsonDocument(config_).toJson());
return file.commit();
}
void PlatformClient::request(const QString& method,const QString& path,const QJsonObject& body,Reply callback) {
if (!error_.isEmpty()) { callback({},error_); return; }
QNetworkRequest req(QUrl(origin_+"/api/v1"+path));
req.setSslConfiguration(tls_);
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute,QNetworkRequest::ManualRedirectPolicy);
req.setHeader(QNetworkRequest::ContentTypeHeader,QStringLiteral("application/json"));
if (!token_.isEmpty()) req.setRawHeader("Authorization",("Bearer "+token_).toUtf8());
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]{
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;
reply->deleteLater(); callback(object,error);
});
}
void PlatformClient::authenticate(std::function<void(const AuthResult&)> callback) {
const auto ready=[this,callback](const QJsonObject&,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(); }
}
callback(result);
};
if (!token_.isEmpty()) { request("GET","/terminal/config",{},ready); return; }
const QString activation=config_.value("activationSecret").toString();
if (activation.isEmpty()) { ready({},QStringLiteral("设备尚未激活,请由管理员配置激活信息")); return; }
request("POST","/terminal/register",{{"sn",QString::fromStdString(loadConfig().sn)},{"activationSecret",activation},{"agentVersion",kVersion}},[this,ready](const QJsonObject& reply,const QString& error){
if (!error.isEmpty()) { ready({},error); return; }
token_=reply.value("accessToken").toString();
config_.remove("activationSecret");
ready(reply,token_.isEmpty()?QStringLiteral("平台未返回设备身份"):QString());
});
}
void PlatformClient::list(std::function<void(std::vector<DesktopInfo>,const QString&)> callback) {
if (!authenticated_) { callback({},QStringLiteral("正在连接管理平台")); return; }
request("GET","/terminal/desktops?pageSize=100",{},[callback](const QJsonObject& reply,const QString& error){
std::vector<DesktopInfo> list;
for (const auto& item:reply.value("items").toArray()) {
const auto obj=item.toObject(); DesktopInfo d;
d.id=obj.value("id").toString(); d.name=obj.value("name").toString(); d.os=obj.value("os").toString();
d.endpoint=obj.value("endpoint").toString(); d.app=obj.value("app").toString("Desktop");
d.width=obj.value("width").toInt(2560); d.height=obj.value("height").toInt(1440); d.fps=obj.value("fps").toInt(30);
d.state=obj.value("powerState").toString()=="running" && !d.endpoint.isEmpty()?DesktopState::kAvailable:DesktopState::kUnassigned;
list.push_back(d);
}
callback(std::move(list),error);
});
}
void PlatformClient::ticket(const QString& id,std::function<void(const ConnectionTicket&,const QString&)> callback) {
request("POST","/terminal/connect-ticket",{{"desktopId",id}},[this,callback](const QJsonObject& reply,const QString& error){
if (!error.isEmpty()) { callback({},error); return; }
request("POST","/terminal/connect-ticket/consume",{{"ticket",reply.value("ticket")}},[this,callback](const QJsonObject& result,const QString& error){
ConnectionTicket ticket;
if (error.isEmpty()) {
ticket.desktopId=result.value("desktopId").toString(); ticket.sessionId=result.value("sessionId").toString();
ticket.endpoint=result.value("endpoint").toString(); ticket.app=result.value("app").toString("Desktop");
ticket.width=result.value("width").toInt(2560); ticket.height=result.value("height").toInt(1440); ticket.fps=result.value("fps").toInt(30);
activeSession_=ticket.sessionId; connected_=false;
if (!save()) { ended(ticket.sessionId); callback({},QStringLiteral("连接预约无法持久化")); return; }
}
callback(ticket,error);
});
});
}
void PlatformClient::connected(const QString& id) {
if (id.isEmpty() || activeSession_!=id) return;
connected_=true;
request("POST","/terminal/session",{{"sessionId",id},{"state","connected"}},[](const QJsonObject&,const QString&){});
}
void PlatformClient::ended(const QString& id) {
if (id.isEmpty()) return;
if (activeSession_==id) { activeSession_.clear(); connected_=false; }
ending_.insert(id); save(); flushEnds();
}
void PlatformClient::flushEnds() {
for (const auto& id:ending_) request("POST","/terminal/session",{{"sessionId",id},{"state","ended"}},[this,id](const QJsonObject&,const QString& error){
if (error.isEmpty() || error.startsWith("409:")) { ending_.remove(id); save(); }
});
}
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&){});
if (connected_) connected(activeSession_);
flushEnds();
const QString pending=config_.value("pendingRebootTask").toString();
if (!pending.isEmpty() && bootID()!=config_.value("pendingRebootBootID").toString()) {
ack(pending,true); config_.remove("pendingRebootTask"); config_.remove("pendingRebootBootID"); save();
}
flushAcks();
}
QJsonObject PlatformClient::diagnostics() const {
const double uptime=readText("/proc/uptime").section(' ',0,0).toDouble();
QFile log(QDir::homePath()+"/.xsession-errors"); QString tail;
if (log.open(QIODevice::ReadOnly)) { log.seek(qMax<qint64>(0,log.size()-12000)); for (const auto& line:QString::fromUtf8(log.readAll()).split('\n')) {
const QString lower=line.toLower();
if (!lower.contains("password") && !lower.contains("token") && !lower.contains("secret") && !lower.contains("bearer")) tail+=line+'\n';
}}
return {{"agentVersion",kVersion},{"bootId",bootID()},{"uptimeSeconds",uptime},{"kioskState",kioskState?kioskState():QStringLiteral("unknown")},{"logTail",tail.left(16000)}};
}
void PlatformClient::ack(const QString& id,bool success,const QJsonObject& diagnostics) {
auto acks=config_.value("pendingAcks").toObject();
QJsonObject body{{"status",success?"success":"failed"}};
if (!diagnostics.isEmpty()) body["diagnostics"]=diagnostics;
acks[id]=body; config_["pendingAcks"]=acks; save(); flushAcks();
}
void PlatformClient::flushAcks() {
const auto acks=config_.value("pendingAcks").toObject();
for (auto it=acks.begin();it!=acks.end();++it) {
const QString id=it.key(); if (ackInFlight_.contains(id)) continue;
ackInFlight_.insert(id);
request("POST","/terminal/commands/"+id+"/ack",it.value().toObject(),[this,id](const QJsonObject&,const QString& error){
ackInFlight_.remove(id);
if (error.isEmpty() || error.startsWith("409:")) { auto pending=config_.value("pendingAcks").toObject(); pending.remove(id); config_["pendingAcks"]=pending; save(); }
});
}
}
void PlatformClient::poll() {
if (!authenticated_ || polling_) return;
flushAcks(); polling_=true;
request("GET","/terminal/commands",{},[this](const QJsonObject& reply,const QString& error){
polling_=false; if (!error.isEmpty()) return;
for (const auto& value:reply.value("items").toArray()) {
const auto task=value.toObject(); const QString id=task.value("id").toString(), action=task.value("action").toString();
if (action=="collect-logs") ack(id,true,diagnostics());
else if (action=="reset-session") {
if (resetSession) resetSession([this,id](bool ok){ ack(id,ok); });
else ack(id,false);
}
else if (action=="reboot") {
if (!config_.value("pendingRebootTask").toString().isEmpty()) { ack(id,false); continue; }
config_["pendingRebootTask"]=id; config_["pendingRebootBootID"]=bootID();
if (!save()) { ack(id,false); continue; }
auto* process=new QProcess(this);
connect(process,QOverload<int,QProcess::ExitStatus>::of(&QProcess::finished),this,[this,process,id](int code,QProcess::ExitStatus){
if (code!=0) { config_.remove("pendingRebootTask"); config_.remove("pendingRebootBootID"); ack(id,false); }
process->deleteLater();
});
connect(process,&QProcess::errorOccurred,this,[this,id](QProcess::ProcessError){ config_.remove("pendingRebootTask"); config_.remove("pendingRebootBootID"); ack(id,false); });
process->start("/usr/bin/sudo",{"-n","/usr/bin/systemctl","reboot"});
} else ack(id,false);
}
});
}
}
@@ -0,0 +1,46 @@
#ifndef KIOSK_SHELL_PLATFORM_CLIENT_H
#define KIOSK_SHELL_PLATFORM_CLIENT_H
#include <QObject>
#include <QJsonObject>
#include <QNetworkAccessManager>
#include <QSet>
#include <QSslConfiguration>
#include <QTimer>
#include <functional>
#include "desktop_session.h"
namespace vdi {
// Optional HTTPS device adapter. A configured platform never falls back to local authorization.
class PlatformClient : public QObject {
public:
using Reply = std::function<void(const QJsonObject&, const QString&)>;
PlatformClient();
bool configured() const { return configured_; }
void authenticate(std::function<void(const AuthResult&)> callback);
void list(std::function<void(std::vector<DesktopInfo>, const QString&)> callback);
void ticket(const QString& desktopId, std::function<void(const ConnectionTicket&, const QString&)> callback);
void connected(const QString& sessionId);
void ended(const QString& sessionId);
std::function<void(std::function<void(bool)>)> resetSession;
std::function<QString()> kioskState;
private:
void request(const QString& method, const QString& path, const QJsonObject& body, Reply callback);
void heartbeat();
void poll();
void ack(const QString& id, bool success, const QJsonObject& diagnostics = {});
void flushAcks();
bool save();
void flushEnds();
QJsonObject diagnostics() const;
QString bootID() const;
QString configPath_;
QJsonObject config_;
QNetworkAccessManager network_;
QSslConfiguration tls_;
QTimer heartbeat_, commands_;
QString origin_, token_, error_, activeSession_;
QSet<QString> ending_, ackInFlight_;
bool configured_=false, authenticated_=false, polling_=false, connected_=false;
};
}
#endif
+1 -1
View File
@@ -21,7 +21,7 @@ StateMachine::StateMachine(StateListener listener) : listener_(std::move(listene
bool StateMachine::isLegalTransition(State from, State to) {
switch (from) {
case State::kBoot:
return to == State::kConfig || to == State::kError;
return to == State::kConfig || to == State::kDesktopList || to == State::kError;
case State::kConfig:
return to == State::kAuthenticating || to == State::kConnecting ||
to == State::kError;
@@ -4,6 +4,7 @@
#include <QFont>
#include <QGuiApplication>
#include <QLabel>
#include <QPalette>
#include <QScreen>
#include <QTimer>
#include <QVBoxLayout>
@@ -23,9 +24,18 @@ const char* kSpinnerFrames[] = {"◐", "◓", "◑", "◒"};
TransitionOverlay::TransitionOverlay(QWidget* parent) : QWidget(parent) {
// 独立置顶无边框窗口只在启动时映射一次,后续不再与 Moonlight 抢焦点。
setWindowFlags(Qt::Window | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint |
Qt::WindowDoesNotAcceptFocus);
Qt::WindowDoesNotAcceptFocus
#ifdef Q_OS_UNIX
| Qt::X11BypassWindowManagerHint
#endif
);
setAttribute(Qt::WA_ShowWithoutActivating);
setStyleSheet(QStringLiteral("TransitionOverlay { background-color: #000000; }"));
// A plain QWidget subclass needs an explicit opaque palette; a type-name
// stylesheet alone may leave the native window using its default white.
setAutoFillBackground(true);
QPalette background = palette();
background.setColor(QPalette::Window, QColor(QStringLiteral("#0f1420")));
setPalette(background);
auto* layout = new QVBoxLayout(this);
layout->setSpacing(28);
@@ -82,6 +92,14 @@ void TransitionOverlay::finish() {
}
void TransitionOverlay::onFirstFrame() {
// The exclusive X11 kiosk has no compositor to apply window opacity.
// Once the full-screen stream is ready, unmap the opaque cover directly.
#ifdef Q_OS_UNIX
if (QGuiApplication::platformName() == QStringLiteral("xcb")) {
finish();
return;
}
#endif
// 首帧已出画:停止动画并淡出;若尚未显示/已在淡出则忽略
if (!isVisible() || fadeTimer_->isActive()) {
return;
@@ -1,9 +1,7 @@
// 会话切换过场层
//
// 用途:用户点击"连接"后,Moonlight 客户端会先在屏幕左上角创建一个小窗口,
// 再扩展到全屏并解码首帧,这段时间画面闪烁、体验割裂。
// 本控件是覆盖在 kiosk 全屏窗口之上的不透明过场层(置顶),
// 盖住这个过程,直到收到首帧信号后淡出,让用户感知为"一次平滑的转场"。
// 用户点击连接后显示产品加载提示;串流窗口在完整铺满输出后接管显示。
// X11 独占会话没有合成器,交接时直接隐藏不透明过场层;其他平台可淡出。
#ifndef KIOSK_SHELL_TRANSITION_OVERLAY_H
#define KIOSK_SHELL_TRANSITION_OVERLAY_H
@@ -23,7 +21,7 @@ public:
// 显示过场层并开始动画;title 为提示文字(如"正在进入 Windows 10")
void start(const QString& title);
// 隐藏过场层(带淡出)
// 立即隐藏过场层
void finish();
// 首次视频帧已到达
@@ -12,6 +12,7 @@ private slots:
void requiresBothReadyMarkers();
void acceptsSeparateHardwareTestDecoder();
void rejectsSecondStart();
void reportsKioskLaunchFailure();
void buildsConsistentStreamArguments();
void selectsProfileWithoutLosingStats();
void rejectsInvalidDecoderOverride();
@@ -107,6 +108,41 @@ void MoonlightSessionTest::rejectsSecondStart() {
qputenv("PATH", oldPath);
}
void MoonlightSessionTest::reportsKioskLaunchFailure() {
QTemporaryDir tempDir;
QVERIFY(tempDir.isValid());
const QString executable = tempDir.filePath(QStringLiteral("failed-client"));
QFile script(executable);
QVERIFY(script.open(QIODevice::WriteOnly | QIODevice::Text));
script.write("#!/bin/sh\n"
"test \"$VDI_KIOSK_STREAM\" = 1 || exit 2\n"
"exit 1\n");
script.close();
QVERIFY(script.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner |
QFileDevice::ExeOwner));
MoonlightSession session;
MoonlightSession::Options options;
options.program = executable;
session.setOptions(options);
ConnectionTicket ticket;
ticket.endpoint = QStringLiteral("127.0.0.1");
ticket.app = QStringLiteral("MissingApplication");
int failed = 0;
int finished = 0;
QString message;
QVERIFY(session.start(ticket, [&](const MoonlightSession::Event& event) {
if (event.type == MoonlightSession::EventType::kFailed) {
++failed;
message = event.message;
} else if (event.type == MoonlightSession::EventType::kFinished) {
++finished;
}
}));
QTRY_COMPARE_WITH_TIMEOUT(failed, 1, 2000);
QCOMPARE(finished, 0);
QVERIFY(message.contains(QStringLiteral("code=1")));
}
void MoonlightSessionTest::buildsConsistentStreamArguments() {
ConnectionTicket ticket;
ticket.endpoint = QStringLiteral("192.168.1.70");
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""Opt-in live X11 entry regression check. Follow with verify_return_surface.
DISPLAY=:0 python3 verify_entry_surface.py 0
Starts a real stream from card 0/1/2 and uses Ctrl+Alt+Shift+Q to return.
Requires verify_return_surface.py in the same directory.
"""
import ctypes as C
import json
import subprocess
import sys
import time
from verify_return_surface import x, display, root, attrs, find, child_at, Attributes
class ClassHint(C.Structure):
_fields_ = [("name", C.c_void_p), ("class_name", C.c_void_p)]
x.XQueryTree.argtypes = [C.c_void_p, C.c_ulong, C.POINTER(C.c_ulong),
C.POINTER(C.c_ulong), C.POINTER(C.POINTER(C.c_ulong)),
C.POINTER(C.c_uint)]
x.XGetClassHint.argtypes = [C.c_void_p, C.c_ulong, C.POINTER(ClassHint)]
x.XFree.argtypes = [C.c_void_p]
# A client may destroy a window between QueryTree and GetWindowAttributes.
# Ignore that expected BadWindow race; all other errors fail the check.
errors = []
class XError(C.Structure):
_fields_ = [("type", C.c_int), ("display", C.c_void_p),
("resource", C.c_ulong), ("serial", C.c_ulong),
("error_code", C.c_ubyte), ("request_code", C.c_ubyte),
("minor_code", C.c_ubyte)]
@C.CFUNCTYPE(C.c_int, C.c_void_p, C.POINTER(XError))
def on_error(_, error):
if error.contents.error_code != 3: # BadWindow
errors.append(error.contents.error_code)
return 0
x.XSetErrorHandler.argtypes = [C.c_void_p]
x.XSetErrorHandler(on_error)
def windows():
returned_root, parent, count = C.c_ulong(), C.c_ulong(), C.c_uint()
children = C.POINTER(C.c_ulong)()
x.XQueryTree(display, root, C.byref(returned_root), C.byref(parent),
C.byref(children), C.byref(count))
ids = [children[i] for i in range(count.value)]
if children:
x.XFree(children)
result = {}
for window in ids:
a = Attributes()
if not x.XGetWindowAttributes(display, window, C.byref(a)) or a.map_state != 2:
continue
hint = ClassHint()
cls = ""
if x.XGetClassHint(display, window, C.byref(hint)):
if hint.class_name:
cls = C.string_at(hint.class_name).decode("utf-8", "replace")
x.XFree(hint.class_name)
if hint.name:
x.XFree(hint.name)
result[window] = (a, cls)
return result
def main():
index = int(sys.argv[1])
if index not in (0, 1, 2):
raise ValueError("Desktop index must be 0/1/2")
geometry = attrs(root)
width, height = geometry.width, geometry.height
kiosk = find("--onlyvisible", "--name", "^VDI 云终端$")[0]
if find("--onlyvisible", "--class", "com.moonlight_stream.Moonlight"):
raise RuntimeError("Start this check from the idle desktop list")
subprocess.run(["xdotool", "mousemove", str(width - 155), str(209 + 110 * index),
"click", "1"], check=True)
started = time.monotonic()
samples, violations, states, first_geometry = 0, [], [], {}
foreground_at = None
points = [(20, 10), (width // 2, height // 2), (width - 20, height - 10)]
ready = False
while time.monotonic() - started < 35:
elapsed = time.monotonic() - started
mapped = windows()
samples += 1
tops = [child_at(px, py) for px, py in points]
classes = {w: cls for w, (_, cls) in mapped.items()}
for window, (a, cls) in mapped.items():
if "moonlight" in cls.lower():
geometry = [a.x, a.y, a.width, a.height]
first_geometry.setdefault(window, {"t": round(elapsed, 4),
"class": cls, "geometry": geometry})
if cls != "com.moonlight_stream.Moonlight" or geometry != [0, 0, width, height]:
violations.append({"t": elapsed, "window": window,
"class": cls, "geometry": geometry})
if kiosk not in mapped:
violations.append({"t": elapsed, "reason": "kiosk backing window unmapped"})
if not states or states[-1]["windows"] != tops:
states.append({"t": round(elapsed, 4), "windows": tops, "classes": classes})
if all(classes.get(w) == "com.moonlight_stream.Moonlight" for w in tops):
if foreground_at is None:
foreground_at = elapsed
if elapsed - foreground_at >= 2:
ready = True
break
else:
foreground_at = None
time.sleep(0.004)
with open("/tmp/entry-live-%s.xwd" % index, "wb") as screenshot:
subprocess.run(["xwd", "-root", "-silent"], stdout=screenshot, check=True)
subprocess.run(["xdotool", "key", "--clearmodifiers", "ctrl+alt+shift+q"], check=True)
time.sleep(1)
returned = all(child_at(px, py) == kiosk for px, py in points)
result = {"desktop_index": index, "entry_samples": samples,
"stream_foreground": ready, "returned_to_kiosk": returned,
"first_mapped_moonlight_geometry": first_geometry,
"stack_changes": states, "violations": violations,
"x11_errors": errors, "ok": ready and returned and not violations and not errors}
print(json.dumps(result, indent=2, ensure_ascii=False))
return 0 if result["ok"] else 1
try:
sys.exit(main())
finally:
x.XCloseDisplay(display)
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""Opt-in live X11 check: connect a desktop card and exit with the real hotkey.
Run on an idle terminal, e.g. DISPLAY=:0 python3 verify_return_surface.py 0.
Indices 0/1/2 select Windows/Ubuntu/Kylin in the current three-card layout.
This starts and disconnects a stream, but does not shut down the guest.
"""
import ctypes as C
import json
import subprocess
import sys
import time
class Attributes(C.Structure):
_fields_ = [("x", C.c_int), ("y", C.c_int), ("width", C.c_int),
("height", C.c_int), ("border_width", C.c_int),
("depth", C.c_int), ("visual", C.c_void_p),
("root", C.c_ulong), ("window_class", C.c_int),
("bit_gravity", C.c_int), ("win_gravity", C.c_int),
("backing_store", C.c_int), ("backing_planes", C.c_ulong),
("backing_pixel", C.c_ulong), ("save_under", C.c_int),
("colormap", C.c_ulong), ("map_installed", C.c_int),
("map_state", C.c_int), ("all_event_masks", C.c_long),
("your_event_mask", C.c_long), ("do_not_propagate_mask", C.c_long),
("override_redirect", C.c_int), ("screen", C.c_void_p)]
x = C.CDLL("libX11.so.6")
x.XOpenDisplay.argtypes = [C.c_char_p]
x.XOpenDisplay.restype = C.c_void_p
x.XDefaultRootWindow.argtypes = [C.c_void_p]
x.XDefaultRootWindow.restype = C.c_ulong
x.XGetWindowAttributes.argtypes = [C.c_void_p, C.c_ulong, C.POINTER(Attributes)]
x.XTranslateCoordinates.argtypes = [C.c_void_p, C.c_ulong, C.c_ulong,
C.c_int, C.c_int, C.POINTER(C.c_int),
C.POINTER(C.c_int), C.POINTER(C.c_ulong)]
x.XCloseDisplay.argtypes = [C.c_void_p]
display = x.XOpenDisplay(None)
if not display:
raise SystemExit("Cannot open X11 display")
root = x.XDefaultRootWindow(display)
def attrs(window):
value = Attributes()
if not x.XGetWindowAttributes(display, window, C.byref(value)):
raise RuntimeError("X11 window no longer exists")
return value
def find(*args):
p = subprocess.run(["xdotool", "search", *args], capture_output=True, text=True)
return [int(w) for w in p.stdout.split()] if p.returncode == 0 else []
def child_at(px, py):
dx, dy, child = C.c_int(), C.c_int(), C.c_ulong()
x.XTranslateCoordinates(display, root, root, px, py,
C.byref(dx), C.byref(dy), C.byref(child))
return child.value
def main():
index = int(sys.argv[1])
if index not in (0, 1, 2):
raise ValueError("Desktop index must be 0, 1, or 2")
kiosk = find("--onlyvisible", "--name", "^VDI 云终端$")
if len(kiosk) != 1:
raise RuntimeError("Expected one mapped kiosk window")
kiosk = kiosk[0]
geometry = attrs(root)
width, height = geometry.width, geometry.height
# Current card layout: margins 80/50, 96px cards, 14px spacing.
subprocess.run(["xdotool", "mousemove", str(width - 155), str(209 + index * 110),
"click", "1"], check=True)
deadline = time.monotonic() + 35
stream = None
while time.monotonic() < deadline:
for window in find("--onlyvisible", "--class", "com.moonlight_stream.Moonlight"):
a = attrs(window)
if a.width >= width and a.height >= height:
stream = window
break
if stream and child_at(width // 2, height // 2) == stream:
break
time.sleep(0.1)
else:
raise RuntimeError("Stream did not become the full-screen foreground window")
# Let the existing ready transition/overlay settle, then inspect the
# backing window while the native client still owns focus and the hotkey.
time.sleep(2)
# Moonlight replaces its Qt startup window with a separate SDL stream
# window. Resolve both only after startup instead of treating that normal
# replacement as a foreign desktop window.
stream_windows = find("--onlyvisible", "--class", "com.moonlight_stream.Moonlight")
stream = child_at(width // 2, height // 2)
if stream not in stream_windows:
raise RuntimeError("Moonlight is not foreground after startup settled")
with open("/tmp/return-live-%s.xwd" % index, "wb") as screenshot:
subprocess.run(["xwd", "-root", "-silent"], stdout=screenshot, check=True)
samples, violations, stacks = 0, [], []
started = time.monotonic()
hotkey = None
points = [(20, 10), (width // 2, height // 2),
(width - 20, height - 10), (width - 20, 10)]
while time.monotonic() - started < 3:
elapsed = time.monotonic() - started
if hotkey is None and elapsed >= 0.3:
hotkey = subprocess.Popen(["xdotool", "key", "--clearmodifiers",
"ctrl+alt+shift+q"])
a = attrs(kiosk)
tops = [child_at(px, py) for px, py in points]
samples += 1
if a.map_state != 2 or (a.x, a.y, a.width, a.height) != (0, 0, width, height):
violations.append({"t": elapsed, "reason": "kiosk not mapped full-screen"})
if any(w not in [kiosk, *stream_windows] for w in tops):
violations.append({"t": elapsed, "reason": "foreign/root window exposed",
"top_windows": tops})
if not stacks or stacks[-1]["windows"] != tops:
stacks.append({"t": round(elapsed, 4), "windows": tops})
time.sleep(0.004)
hotkey.wait(timeout=3)
returned = all(child_at(px, py) == kiosk for px, py in points)
result = {"desktop_index": index, "samples": samples,
"kiosk": kiosk, "stream": stream, "stream_windows": stream_windows,
"stack_changes": stacks,
"returned_to_kiosk": returned, "violations": violations,
"ok": returned and not violations}
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if result["ok"] else 1
if __name__ == "__main__":
try:
sys.exit(main())
finally:
x.XCloseDisplay(display)
@@ -0,0 +1,7 @@
[Desktop Entry]
Name=VDI Kiosk
Comment=VDI 云终端独占会话(直接进入桌面列表)
Exec=/home/armbian/vdi-terminal/bin/vdi-kiosk-start
TryExec=/home/armbian/vdi-terminal/bin/vdi-kiosk-start
Type=Application
DesktopNames=VDI
+1 -1
View File
@@ -5,4 +5,4 @@ Comment=产品唯一自启入口:看门狗守护 kiosk-shell(登录 -> 桌
Exec=/home/armbian/vdi-terminal/bin/vdi-kiosk-start
Terminal=false
X-GNOME-Autostart-enabled=true
X-GNOME-Autostart-Delay=2
X-GNOME-Autostart-Delay=0