diff --git a/terminal/kiosk-shell/CMakeLists.txt b/terminal/kiosk-shell/CMakeLists.txt index 555f238..6b37a08 100644 --- a/terminal/kiosk-shell/CMakeLists.txt +++ b/terminal/kiosk-shell/CMakeLists.txt @@ -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) diff --git a/terminal/kiosk-shell/etc/92-vdi-kiosk.conf b/terminal/kiosk-shell/etc/92-vdi-kiosk.conf new file mode 100644 index 0000000..618c76e --- /dev/null +++ b/terminal/kiosk-shell/etc/92-vdi-kiosk.conf @@ -0,0 +1,5 @@ +[Seat:*] +autologin-user=armbian +autologin-user-timeout=0 +user-session=vdi-kiosk +autologin-session=vdi-kiosk diff --git a/terminal/kiosk-shell/etc/moonlight.conf b/terminal/kiosk-shell/etc/moonlight.conf index fc6d8f1..69b3e53 100644 --- a/terminal/kiosk-shell/etc/moonlight.conf +++ b/terminal/kiosk-shell/etc/moonlight.conf @@ -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;为空时保留下方显式开关 diff --git a/terminal/kiosk-shell/src/desktop_service.cpp b/terminal/kiosk-shell/src/desktop_service.cpp index 74007a2..7d0a49b 100644 --- a/terminal/kiosk-shell/src/desktop_service.cpp +++ b/terminal/kiosk-shell/src/desktop_service.cpp @@ -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 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; diff --git a/terminal/kiosk-shell/src/desktop_service.h b/terminal/kiosk-shell/src/desktop_service.h index fbc3488..b97ecb2 100644 --- a/terminal/kiosk-shell/src/desktop_service.h +++ b/terminal/kiosk-shell/src/desktop_service.h @@ -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 #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)> reset, std::function state) { platform_.resetSession=std::move(reset); platform_.kioskState=std::move(state); } private: QString currentUser_; + PlatformClient platform_; }; } // namespace vdi diff --git a/terminal/kiosk-shell/src/desktop_session.h b/terminal/kiosk-shell/src/desktop_session.h index 0cfb495..eeee972 100644 --- a/terminal/kiosk-shell/src/desktop_session.h +++ b/terminal/kiosk-shell/src/desktop_session.h @@ -37,6 +37,7 @@ struct DesktopInfo { // 一次会话的连接凭据;当前为本地构造,后续由平台 connect-ticket 接口下发 struct ConnectionTicket { + QString sessionId; QString desktopId; QString endpoint; QString app; diff --git a/terminal/kiosk-shell/src/main_window.cpp b/terminal/kiosk-shell/src/main_window.cpp index b868368..0474308 100644 --- a/terminal/kiosk-shell/src/main_window.cpp +++ b/terminal/kiosk-shell/src/main_window.cpp @@ -46,16 +46,45 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { setupHeartbeat(); config_ = loadConfig(); - fsm_.transitionTo(State::kConfig, QStringLiteral("就绪")); + desktopService_.setRuntimeHandlers([this](std::function 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 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: diff --git a/terminal/kiosk-shell/src/main_window.h b/terminal/kiosk-shell/src/main_window.h index 7b826ba..f0fd057 100644 --- a/terminal/kiosk-shell/src/main_window.h +++ b/terminal/kiosk-shell/src/main_window.h @@ -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, diff --git a/terminal/kiosk-shell/src/moonlight_session.cpp b/terminal/kiosk-shell/src/moonlight_session.cpp index 6860014..ef8298a 100644 --- a/terminal/kiosk-shell/src/moonlight_session.cpp +++ b/terminal/kiosk-shell/src/moonlight_session.cpp @@ -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); } diff --git a/terminal/kiosk-shell/src/platform_client.cpp b/terminal/kiosk-shell/src/platform_client.cpp new file mode 100644 index 0000000..47e2b2d --- /dev/null +++ b/terminal/kiosk-shell/src/platform_client.cpp @@ -0,0 +1,210 @@ +#include "platform_client.h" +#include "config.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 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,const QString&)> callback) { + if (!authenticated_) { callback({},QStringLiteral("正在连接管理平台")); return; } + request("GET","/terminal/desktops?pageSize=100",{},[callback](const QJsonObject& reply,const QString& error){ + std::vector 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 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(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::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); + } + }); +} +} diff --git a/terminal/kiosk-shell/src/platform_client.h b/terminal/kiosk-shell/src/platform_client.h new file mode 100644 index 0000000..a681c62 --- /dev/null +++ b/terminal/kiosk-shell/src/platform_client.h @@ -0,0 +1,46 @@ +#ifndef KIOSK_SHELL_PLATFORM_CLIENT_H +#define KIOSK_SHELL_PLATFORM_CLIENT_H +#include +#include +#include +#include +#include +#include +#include +#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; + PlatformClient(); + bool configured() const { return configured_; } + void authenticate(std::function callback); + void list(std::function, const QString&)> callback); + void ticket(const QString& desktopId, std::function callback); + void connected(const QString& sessionId); + void ended(const QString& sessionId); + std::function)> resetSession; + std::function 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 ending_, ackInFlight_; + bool configured_=false, authenticated_=false, polling_=false, connected_=false; +}; +} +#endif diff --git a/terminal/kiosk-shell/src/state_machine.cpp b/terminal/kiosk-shell/src/state_machine.cpp index a41ac2a..7907206 100644 --- a/terminal/kiosk-shell/src/state_machine.cpp +++ b/terminal/kiosk-shell/src/state_machine.cpp @@ -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; diff --git a/terminal/kiosk-shell/src/transition_overlay.cpp b/terminal/kiosk-shell/src/transition_overlay.cpp index e2694de..43bd4d7 100644 --- a/terminal/kiosk-shell/src/transition_overlay.cpp +++ b/terminal/kiosk-shell/src/transition_overlay.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -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; diff --git a/terminal/kiosk-shell/src/transition_overlay.h b/terminal/kiosk-shell/src/transition_overlay.h index 5a8ea84..4bb0663 100644 --- a/terminal/kiosk-shell/src/transition_overlay.h +++ b/terminal/kiosk-shell/src/transition_overlay.h @@ -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(); // 首次视频帧已到达 diff --git a/terminal/kiosk-shell/tests/test_moonlight_session.cpp b/terminal/kiosk-shell/tests/test_moonlight_session.cpp index d2dca66..cd04e5d 100644 --- a/terminal/kiosk-shell/tests/test_moonlight_session.cpp +++ b/terminal/kiosk-shell/tests/test_moonlight_session.cpp @@ -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"); diff --git a/terminal/kiosk-shell/tests/verify_entry_surface.py b/terminal/kiosk-shell/tests/verify_entry_surface.py new file mode 100644 index 0000000..ed0ca36 --- /dev/null +++ b/terminal/kiosk-shell/tests/verify_entry_surface.py @@ -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) diff --git a/terminal/kiosk-shell/tests/verify_return_surface.py b/terminal/kiosk-shell/tests/verify_return_surface.py new file mode 100644 index 0000000..1bfab64 --- /dev/null +++ b/terminal/kiosk-shell/tests/verify_return_surface.py @@ -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) diff --git a/terminal/kiosk-shell/vdi-kiosk-session.desktop b/terminal/kiosk-shell/vdi-kiosk-session.desktop new file mode 100644 index 0000000..d65e4d5 --- /dev/null +++ b/terminal/kiosk-shell/vdi-kiosk-session.desktop @@ -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 diff --git a/terminal/kiosk-shell/vdi-kiosk.desktop b/terminal/kiosk-shell/vdi-kiosk.desktop index de73953..1da4695 100644 --- a/terminal/kiosk-shell/vdi-kiosk.desktop +++ b/terminal/kiosk-shell/vdi-kiosk.desktop @@ -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 diff --git a/terminal/moonlight/README.md b/terminal/moonlight/README.md index dbbdd37..2db1a36 100644 --- a/terminal/moonlight/README.md +++ b/terminal/moonlight/README.md @@ -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保持单个远程白色光标。 diff --git a/terminal/moonlight/patches/0007-vdi-kiosk-launch-surface.patch b/terminal/moonlight/patches/0007-vdi-kiosk-launch-surface.patch new file mode 100644 index 0000000..e4b2bb6 --- /dev/null +++ b/terminal/moonlight/patches/0007-vdi-kiosk-launch-surface.patch @@ -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. diff --git a/terminal/native-cursor/README.md b/terminal/native-cursor/README.md index a7088d7..b377649 100644 --- a/terminal/native-cursor/README.md +++ b/terminal/native-cursor/README.md @@ -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`。 diff --git a/terminal/usb-monitor/README.md b/terminal/usb-monitor/README.md index bad5806..997b48a 100644 --- a/terminal/usb-monitor/README.md +++ b/terminal/usb-monitor/README.md @@ -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`。 diff --git a/tools/build-rkmpp-client.sh b/tools/build-rkmpp-client.sh index 13ee325..3e9b743 100644 --- a/tools/build-rkmpp-client.sh +++ b/tools/build-rkmpp-client.sh @@ -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. diff --git a/tools/deploy_kiosk.sh b/tools/deploy_kiosk.sh index dc7e903..1bc5233 100644 --- a/tools/deploy_kiosk.sh +++ b/tools/deploy_kiosk.sh @@ -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" 抓屏确认当前界面(应为桌面列表)" 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"