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

This commit is contained in:
宋一鸣 2026-11-09 17:54:18 +08:00
parent 8276da0d45
commit 18a9ce995d
25 changed files with 922 additions and 48 deletions

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)

View File

@ -0,0 +1,5 @@
[Seat:*]
autologin-user=armbian
autologin-user-timeout=0
user-session=vdi-kiosk
autologin-session=vdi-kiosk

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;为空时保留下方显式开关

View File

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

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

View File

@ -37,6 +37,7 @@ struct DesktopInfo {
// 一次会话的连接凭据;当前为本地构造,后续由平台 connect-ticket 接口下发
struct ConnectionTicket {
QString sessionId;
QString desktopId;
QString endpoint;
QString app;

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:

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,

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

View File

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

View File

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

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;

View File

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

View File

@ -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();
// 首次视频帧已到达

View File

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

View File

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

View File

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

View File

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

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

View File

@ -2,6 +2,8 @@
本目录维护当前 RK3528 / Xorg POC 私有客户端的源码修改,不替换系统 Moonlight。
当前部署与进出交接见[终端说明](../../docs/terminal-guide.md),性能/光标结论见[云桌面说明](../../docs/desktop-guide.md)。原日期专题的完整过程与旧版本回退步骤见[历史归档](../../docs/archive/README.md);下文带日期的 SHA 和试验状态按当时版本理解。
基线:Moonlight Qt v6.2.0 `de2467e433821664cdd2224aad8c89a625be1ad9`,其检出的 moonlight-common-c 子模块保持对应版本。MPP、FFmpeg 版本仍由 `tools/build-rkmpp-client.sh` 固定。
## 补丁
@ -12,8 +14,9 @@
- `0004-vdi-power-key-policy.patch` 在 Moonlight Qt 根目录应用。客户端丢弃电源/睡眠键,以及 `Ctrl+Alt+Delete`、`Alt+F4`、`Win+L`、`Win+X`,防止云桌面被关机、重启、锁定或打开包含电源操作的系统菜单;按键不会发送到 Windows、Ubuntu 或麒麟客体系统。
- `0005-vdi-native-linux-cursor.patch` 在 Qt 根目录应用,为已配置的单座席Linux/X11/2K/绝对鼠标会话创建原生SDL光标,形状/热点/显隐经认证SSH同步,位置不等待视频帧;主机以精确SHA固定的捕获适配器在新连接前去除视频光标。状态过期等故障结束整次会话,恢复原服务后重连。Windows无对应profile,继续原逻辑。安装和限制见[模块说明](../native-cursor/README.md)。
- `0006-vdi-release-stuck-mouse-buttons.patch` 在焦点丢失和会话退出时显式释放五个远程鼠标按钮,避免一次中断丢失 `BUTTONUP` 后把 Ubuntu 留在“按住拖拽/点击”状态。
- `0007-vdi-kiosk-launch-surface.patch` 在 Qt 根目录应用。kiosk 通过 `VDI_KIOSK_STREAM=1` 启用:CLI 启动/错误界面保持隐藏,SDL 串流窗口创建时就覆盖物理输出,防止无窗口管理器会话先显示左上角小窗。启动错误通过退出码交给 kiosk 显示;正常 Moonlight GUI 不受该模式影响。2026-11-09 部署 SHA256 为 `2e7441df1f6af5c4ddc79b89af15eed3b6b63cf265bea294ffc090483d8c6c51`,Linux 光标配置经复核后更新到该版本;进出验证见 `docs/terminal-guide.md#session`。
2026-11-03 的现场 RKMPP/EGL 使用独立 PacerRender 线程,诊断会话主线程 render 调用数为 0;因此第三补丁的新调度没有已证明的现场收益,未启用。当时默认客户端恢复至 SHA256 `35de7b1a4ade583823b23b5f2e012307f6d0a3848c8eb97bd6d4de9660afa00f`,该版本现保留作备份。2026-11-05加入0005后现场SHA为 `5d4330f703d7f8e053bc3795ca42bdaf231c9e9dd8ebb00a612013adbcccee03`,Linux profile在获取远端租约前严格校验此SHA;独立光标回归见[实施记录](../../docs/linux-native-cursor-20261105.md)。现场构建保留已有修改并加入0005,尚未重建部署其他任务的0004;构建器0001~0005清洁应用通过与现场补丁集合应分开判断。
2026-11-03 的现场 RKMPP/EGL 使用独立 PacerRender 线程,诊断会话主线程 render 调用数为 0;因此第三补丁的新调度没有已证明的现场收益,未启用。当时默认客户端恢复至 SHA256 `35de7b1a4ade583823b23b5f2e012307f6d0a3848c8eb97bd6d4de9660afa00f`,该版本现保留作备份。2026-11-05加入0005后现场SHA为 `5d4330f703d7f8e053bc3795ca42bdaf231c9e9dd8ebb00a612013adbcccee03`,Linux profile在获取远端租约前严格校验此SHA;独立光标回归见[实施记录](../../docs/desktop-guide.md#cursor)。现场构建保留已有修改并加入0005,尚未重建部署其他任务的0004;构建器0001~0005清洁应用通过与现场补丁集合应分开判断。
`VDI_LATEST_FRAME=1` 只在没有 VSync pacing source 的呈现队列生效。最多保留一个待呈现解码帧,丢弃数计入原 `pacerDroppedFrames`。不丢压缩包,不改变 H.264 参考链,也不提前释放正在呈现或延迟释放的上一帧。未设置或设为 `0` 时保留上游帧队列策略;kiosk 私有 wrapper 默认设为 `1`。smooth 档仍使用原 pacing 行为。
@ -40,8 +43,8 @@ VDI_TEST_SANITIZE=1 ASAN_OPTIONS=detect_leaks=1 bash tools/test-moonlight-latenc
新增 `test_input_priority.cpp` 验证优先调度不越过焦点/窗口/退出/解码器重置事件,保持输入 FIFO,隔离其他窗口与合成触摸,并验证数量与时间限制。普通与 ASAN 版本均通过。当前 RKMPP/EGL 没有主线程呈现事件,新调度开关不能用作该路径的性能提升宣传。
现场数据、局限与完整回退入口见 `docs/input-rendering-optimization-20261103.md`。
输入优化结论与边界见[云桌面说明](../../docs/desktop-guide.md#cursor),原始数据与逐步回退记录见上述历史归档中的 `input-rendering-optimization-20261103.md`。
GPU修复后实测见`docs/2k60-validation-20261103.md`:当前正常二进制保持不变,VDD60Hz/串流45FPS已配置并从kiosk重连;约43~44FPS渲染。2K60请求受当前盒子呈现路径限制,不能宣称稳定60。`experiments/0004-vdi-egl-exact-size-sampling.patch`为未采用的精确尺寸采样实验,未进入正常构建器;默认仍为原GL_LINEAR取样。该实验候选二进制也未被正常wrapper使用。
GPU修复后实测见`docs/desktop-guide.md#windows`:当前正常二进制保持不变,VDD60Hz/串流45FPS已配置并从kiosk重连;约43~44FPS渲染。2K60请求受当前盒子呈现路径限制,不能宣称稳定60。`experiments/0004-vdi-egl-exact-size-sampling.patch`为未采用的精确尺寸采样实验,未进入正常构建器;默认仍为原GL_LINEAR取样。该实验候选二进制也未被正常wrapper使用。
2026-11-03的Windows受控X11辅助程序原型见`docs/independent-cursor-feasibility-20261103.md`;该Windows原型没有自动形状同步或可靠帧交接,未进入正常启动流程。2026-11-05的Linux方案由0005和`terminal/native-cursor/`实现自动形状同步,通过连接前/整次会话后交接避开运行中帧切换;完整失焦和广泛缩放等场景尚未验收。不能把固定本地箭头和Sunshine全局N切换当作该方案。Windows保持单个远程白色光标。
2026-11-03的Windows受控X11辅助程序原型见`docs/desktop-guide.md#cursor`;该Windows原型没有自动形状同步或可靠帧交接,未进入正常启动流程。2026-11-05的Linux方案由0005和`terminal/native-cursor/`实现自动形状同步,通过连接前/整次会话后交接避开运行中帧切换;完整失焦和广泛缩放等场景尚未验收。不能把固定本地箭头和Sunshine全局N切换当作该方案。Windows保持单个远程白色光标。

View File

@ -0,0 +1,128 @@
--- a/app/main.cpp
+++ b/app/main.cpp
@@ -1033,6 +1033,14 @@
}
if (hasGUI) {
+ // The VDI shell owns all connection/error UI. Keep the CLI Qt window
+ // unmapped; only the full-screen SDL stream may appear over the shell.
+ const bool vdiKioskLaunch = commandLineParserResult == GlobalCommandLineParser::StreamRequested &&
+ qEnvironmentVariableIntValue("VDI_KIOSK_STREAM") == 1;
+ engine.rootContext()->setContextProperty("vdiKioskLaunch", vdiKioskLaunch);
+ if (vdiKioskLaunch) {
+ app.setQuitOnLastWindowClosed(false);
+ }
engine.rootContext()->setContextProperty("initialView", initialView);
engine.rootContext()->setContextProperty("runConfigChecks", commandLineParserResult == GlobalCommandLineParser::NormalStartRequested);
--- a/app/gui/main.qml
+++ b/app/gui/main.qml
@@ -35,6 +35,9 @@
}
Component.onCompleted: {
+ if (vdiKioskLaunch) {
+ return
+ }
// Show the window according to the user's preferences
if (SystemProperties.hasDesktopEnvironment) {
if (StreamingPreferences.uiDisplayMode == StreamingPreferences.UI_MAXIMIZED) {
--- a/app/gui/CliStartStreamSegue.qml
+++ b/app/gui/CliStartStreamSegue.qml
@@ -23,12 +23,21 @@
}
function onLaunchFailed(message) {
+ if (vdiKioskLaunch) {
+ console.error(message)
+ Qt.exit(1)
+ return
+ }
errorDialog.text = message
errorDialog.open()
console.error(message)
}
function onAppQuitRequired(appName) {
+ if (vdiKioskLaunch) {
+ onLaunchFailed(qsTr("Another application is already running: %1").arg(appName))
+ return
+ }
quitAppDialog.appName = appName
quitAppDialog.open()
}
--- a/app/gui/StreamSegue.qml
+++ b/app/gui/StreamSegue.qml
@@ -56,11 +56,19 @@
stackView.replace(stackView.currentItem, component.createObject(stackView, {"appName": appName}), StackView.Immediate)
// Show the Qt window again to show quit segue
- window.visible = true
+ window.visible = !vdiKioskLaunch
}
function sessionFinished(portTestResult)
{
+ if (vdiKioskLaunch) {
+ // Never restore a hidden CLI dialog over the product surface.
+ if (streamSegueErrorDialog.text) {
+ console.error(streamSegueErrorDialog.text)
+ }
+ Qt.exit(streamSegueErrorDialog.text ? 1 : 0)
+ return
+ }
if (portTestResult !== 0 && portTestResult !== -1 && streamSegueErrorDialog.text) {
streamSegueErrorDialog.text += "\n\n" + qsTr("This PC's Internet connection is blocking Moonlight. Streaming over the Internet may not work while connected to this network.")
}
@@ -145,7 +153,8 @@
Loader {
id: streamLoader
active: false
- asynchronous: true
+ // A hidden Qt window has no render frames to drive incubation.
+ asynchronous: !vdiKioskLaunch
onLoaded: {
// Set the hint text. We do this here rather than
@@ -178,6 +187,9 @@
for (var i = 0; i < session.launchWarnings.length; i++) {
var text = session.launchWarnings[i]
console.warn(text)
+ if (vdiKioskLaunch) {
+ continue
+ }
// Show the tooltip for 3 seconds
var toast = Qt.createQmlObject('import QtQuick.Controls 2.2; ToolTip {}', parent, '')
--- a/app/streaming/session.cpp
+++ b/app/streaming/session.cpp
@@ -1365,6 +1365,19 @@
}
}
+ if (m_IsFullScreen && qEnvironmentVariableIntValue("VDI_KIOSK_STREAM") == 1) {
+ // No EWMH window manager: size the surface before SDL maps it so the
+ // first mapped frame already covers the output.
+ SDL_Rect bounds;
+ if (SDL_GetDisplayBounds(displayIndex, &bounds) == 0) {
+ x = bounds.x;
+ y = bounds.y;
+ width = bounds.w;
+ height = bounds.h;
+ return;
+ }
+ }
+
SDL_Rect usableBounds;
if (SDL_GetDisplayUsableBounds(displayIndex, &usableBounds) == 0) {
// If the stream resolution fits within the usable display area, use it directly
@@ -1817,6 +1830,9 @@
// We always want a resizable window with High DPI enabled
Uint32 defaultWindowFlags = SDL_WINDOW_ALLOW_HIGHDPI | SDL_WINDOW_RESIZABLE;
+ if (m_IsFullScreen && qEnvironmentVariableIntValue("VDI_KIOSK_STREAM") == 1) {
+ defaultWindowFlags |= SDL_WINDOW_BORDERLESS;
+ }
// If we're starting in windowed mode and the Moonlight GUI is maximized or
// minimized, match that with the streaming window.

View File

@ -47,4 +47,4 @@ gcc -shared -nostdlib -fPIC -O2 -Wall -Wextra -Werror -Wl,-z,defs \
紧急恢复可在客体执行 `systemctl --user stop vdi-native-cursor-guard.service`,检查 `/run/user/UID/systemd/user/vdi-sunshine.service.d/90-native-cursor.conf` 已移除、MainPID路径为原二进制。盒子旧客户端和wrapper备份在 `~/vdi-terminal/backups/native-cursor-20261105/`。仅恢复旧二进制前必须先停用两个主机JSON,避免旧客户端连入无光标捕获。
完整测试、已知限制及失败实验见 `../../docs/linux-native-cursor-20261105.md`。本地边界单测:`python -m unittest discover -s tools/tests -p test_native_cursor.py -v`。
测试结论与边界见[云桌面说明](../../docs/desktop-guide.md#cursor),完整测试与失败实验原文见[历史归档](../../docs/archive/README.md)中的 `linux-native-cursor-20261105.md`;最新部署 SHA 见[终端说明](../../docs/terminal-guide.md#build)。本地边界单测:`python -m unittest discover -s tools/tests -p test_native_cursor.py -v`。

View File

@ -44,5 +44,4 @@ Windows 端 `tools/set-usb-experience.ps1` 支持 Apply/Restore,为当前登
`VDI_USBREDIRECT_BINARY=/usr/local/libexec/vdi-usbredirect`。
未配置时监控程序仍使用 `/usr/bin/usbredirect`;更新运行程序前须安全弹出并拔下 U 盘。
完整现场、部署、限制和回退见
[`docs/usb-device-poc-20261103.md`](../../docs/usb-device-poc-20261103.md)。
现场结论、部署入口和限制见[云桌面说明](../../docs/desktop-guide.md#usb);完整过程与逐步回退见[历史归档](../../docs/archive/README.md)中的 `usb-device-poc-20261103.md` 和 `usb-any-storage-20261104.md`。

View File

@ -43,6 +43,7 @@ apply_vdi_patch "$TASK_ROOT/src/moonlight" "$PATCH_DIR/0003-vdi-input-priority.p
apply_vdi_patch "$TASK_ROOT/src/moonlight" "$PATCH_DIR/0004-vdi-power-key-policy.patch"
apply_vdi_patch "$TASK_ROOT/src/moonlight" "$PATCH_DIR/0005-vdi-native-linux-cursor.patch"
apply_vdi_patch "$TASK_ROOT/src/moonlight" "$PATCH_DIR/0006-vdi-release-stuck-mouse-buttons.patch"
apply_vdi_patch "$TASK_ROOT/src/moonlight" "$PATCH_DIR/0007-vdi-kiosk-launch-surface.patch"
# Moonlight 6.2.0 uses std::optional in drm.h but relies on a transitive
# include from Qt 6. Our Qt 5 build needs the standard header explicitly.

View File

@ -32,6 +32,9 @@ scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/etc/moo
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'mkdir -p ~/.config/vdi-terminal; test -f ~/.config/vdi-terminal/moonlight.conf || cp /tmp/vdi-moonlight.conf ~/.config/vdi-terminal/moonlight.conf; rm -f /tmp/vdi-moonlight.conf'
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/vdi-kiosk.desktop" "$BOX":~/.config/autostart/vdi-kiosk.desktop
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/vdi-moonlight.desktop" "$BOX":~/.config/autostart/vdi-moonlight.desktop
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/vdi-kiosk-session.desktop" "$BOX":/tmp/vdi-kiosk-session.desktop
scp -q -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$SRC/etc/92-vdi-kiosk.conf" "$BOX":/tmp/92-vdi-kiosk.conf
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'sudo -n install -m 0644 /tmp/vdi-kiosk-session.desktop /usr/share/xsessions/vdi-kiosk.desktop; sudo -n install -m 0644 /tmp/92-vdi-kiosk.conf /etc/lightdm/lightdm.conf.d/92-vdi-kiosk.conf; rm -f /tmp/vdi-kiosk-session.desktop /tmp/92-vdi-kiosk.conf'
echo "==> 安装 HDMI 热插拔恢复服务"
bash "$REPO/tools/deploy_display_guard.sh"
@ -40,13 +43,16 @@ echo "==> 在盒子上编译"
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'sed -i "s/\r$//" ~/vdi-build/repo/kiosk-shell/src/*.cpp ~/vdi-build/repo/kiosk-shell/src/*.h ~/vdi-build/repo/kiosk-shell/tests/*.cpp ~/vdi-terminal/bin/vdi-moonlight ~/vdi-terminal/bin/vdi-kiosk-start; chmod +x ~/vdi-terminal/bin/vdi-moonlight ~/vdi-terminal/bin/vdi-kiosk-start; cd ~/vdi-build/repo/kiosk-shell && (cmake -G Ninja -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON > /tmp/cmake.log 2>&1 || (tail -30 /tmp/cmake.log; exit 1)) && cmake --build build && QT_QPA_PLATFORM=offscreen ctest --test-dir build --output-on-failure'
echo "==> 安装二进制"
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'monitor_pids=$(pgrep -f "^/home/armbian/vdi-terminal/bin/vdi-kiosk-monitor -- " 2>/dev/null || true); test -z "$monitor_pids" || kill $monitor_pids 2>/dev/null || true; pair_pids=$(pgrep -f "^bash -c tail -f .*(pin|pair)_fifo" 2>/dev/null || true); test -z "$pair_pids" || kill $pair_pids 2>/dev/null || true; tail_pids=$(pgrep -f "^tail -f .*(pin|pair)_fifo" 2>/dev/null || true); test -z "$tail_pids" || kill $tail_pids 2>/dev/null || true; rm -f ~/pair_fifo /tmp/pin_fifo; for name in vdi-moonlight moonlight-qt moonlight kiosk-shell; do pids=$(pidof "$name" 2>/dev/null || true); test -z "$pids" || kill $pids 2>/dev/null || true; done; for i in $(seq 1 40); do pgrep -f "^/home/armbian/vdi-terminal/bin/vdi-kiosk-monitor -- " >/dev/null 2>&1 || break; sleep 0.2; done; monitor_pids=$(pgrep -f "^/home/armbian/vdi-terminal/bin/vdi-kiosk-monitor -- " 2>/dev/null || true); test -z "$monitor_pids" || kill -9 $monitor_pids 2>/dev/null || true; cp ~/vdi-build/repo/kiosk-shell/build/kiosk-shell ~/vdi-terminal/bin/kiosk-shell && cp ~/vdi-build/repo/kiosk-shell/build/vdi-kiosk-monitor ~/vdi-terminal/bin/vdi-kiosk-monitor && chmod +x ~/vdi-terminal/bin/kiosk-shell ~/vdi-terminal/bin/vdi-kiosk-monitor && ls -la ~/vdi-terminal/bin/'
# Replace running executables atomically. Killing the LightDM session leader
# here ends autologin and brings up the host greeter; a detached SSH restart
# cannot restore the exclusive session. Let LightDM own the new stack below.
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'set -e; for name in kiosk-shell vdi-kiosk-monitor; do install -m 0755 ~/vdi-build/repo/kiosk-shell/build/$name ~/vdi-terminal/bin/$name.new; mv -f ~/vdi-terminal/bin/$name.new ~/vdi-terminal/bin/$name; done'
echo "==> 收敛自启项(只保留 kiosk-shell 一条链)"
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'mkdir -p ~/.config/autostart; rm -f ~/.config/autostart/vdi-kiosk.desktop.bak; sed -i "s/^Hidden=.*/Hidden=true/;s/^X-GNOME-Autostart-enabled=.*/X-GNOME-Autostart-enabled=false/" ~/.config/autostart/vdi-moonlight.desktop 2>/dev/null || true; grep -H . ~/.config/autostart/*.desktop'
echo "==> 启动受管栈并验证"
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'rm -f /tmp/vdi-kiosk.heartbeat; nohup env DISPLAY=:0 XAUTHORITY=/home/armbian/.Xauthority /home/armbian/vdi-terminal/bin/vdi-kiosk-start > /tmp/vdi-monitor.log 2>&1 & sleep 8; echo "--- 进程 ---"; pgrep -af "vdi-kiosk-monitor|kiosk-shell|moonlight-qt" | grep -v pgrep; echo "--- 看门狗日志 ---"; tail -5 /tmp/vdi-monitor.log; echo "--- 心跳 ---"; cat /tmp/vdi-kiosk.heartbeat 2>/dev/null; echo'
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'set -e; printf "[Desktop]\nSession=vdi-kiosk\n" > ~/.dmrc; old_monitors=$(pgrep -u "$(id -u)" -x vdi-kiosk-monit || true); if [ -n "$old_monitors" ]; then kill $old_monitors || true; fi; rm -f /tmp/vdi-kiosk.heartbeat; sudo -n systemctl restart lightdm; sleep 12; if pgrep -x mate-session >/dev/null || pgrep -x mate-panel >/dev/null; then echo "ERROR: host desktop session still active" >&2; exit 1; fi; pid=$(pgrep -n -x kiosk-shell); test -n "$pid"; tr "\0" "\n" </proc/$pid/environ | grep -Fx "DESKTOP_SESSION=vdi-kiosk"; test -s /tmp/vdi-kiosk.heartbeat; ps -o pid,ppid,args -p "$pid"; cat /tmp/vdi-kiosk.heartbeat'
echo "==> 抓屏确认当前界面(应为桌面列表)"
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$BOX" 'DISPLAY=:0 xwd -root -silent' 2>/dev/null | python "$REPO/tools/xwd2png.py" /tmp/kiosk-login.png && echo "captured /tmp/kiosk-login.png"