修复:完善终端开机及串流交接并接入云管平台
This commit is contained in:
parent
8276da0d45
commit
18a9ce995d
@ -13,7 +13,7 @@ if(NOT CMAKE_BUILD_TYPE)
|
|||||||
endif()
|
endif()
|
||||||
|
|
||||||
# ---- Qt5 ----
|
# ---- Qt5 ----
|
||||||
find_package(Qt5 REQUIRED COMPONENTS Core Gui Widgets)
|
find_package(Qt5 REQUIRED COMPONENTS Core Gui Widgets Network)
|
||||||
include(CTest)
|
include(CTest)
|
||||||
|
|
||||||
add_executable(kiosk-shell
|
add_executable(kiosk-shell
|
||||||
@ -23,6 +23,7 @@ add_executable(kiosk-shell
|
|||||||
src/desktop_list_page.cpp
|
src/desktop_list_page.cpp
|
||||||
src/desktop_settings_dialog.cpp
|
src/desktop_settings_dialog.cpp
|
||||||
src/desktop_service.cpp
|
src/desktop_service.cpp
|
||||||
|
src/platform_client.cpp
|
||||||
src/desktop_store.cpp
|
src/desktop_store.cpp
|
||||||
src/moonlight_session.cpp
|
src/moonlight_session.cpp
|
||||||
src/transition_overlay.cpp
|
src/transition_overlay.cpp
|
||||||
@ -38,6 +39,7 @@ target_link_libraries(kiosk-shell PRIVATE
|
|||||||
Qt5::Core
|
Qt5::Core
|
||||||
Qt5::Gui
|
Qt5::Gui
|
||||||
Qt5::Widgets
|
Qt5::Widgets
|
||||||
|
Qt5::Network
|
||||||
)
|
)
|
||||||
|
|
||||||
if(UNIX AND NOT APPLE)
|
if(UNIX AND NOT APPLE)
|
||||||
|
|||||||
5
terminal/kiosk-shell/etc/92-vdi-kiosk.conf
Normal file
5
terminal/kiosk-shell/etc/92-vdi-kiosk.conf
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
[Seat:*]
|
||||||
|
autologin-user=armbian
|
||||||
|
autologin-user-timeout=0
|
||||||
|
user-session=vdi-kiosk
|
||||||
|
autologin-session=vdi-kiosk
|
||||||
@ -15,7 +15,7 @@
|
|||||||
# vsync 1=开启垂直同步(--vsync),0=关闭(--no-vsync)
|
# vsync 1=开启垂直同步(--vsync),0=关闭(--no-vsync)
|
||||||
# 同步开关 A/B 未见明确延迟收益,保留开启以避免撕裂。
|
# 同步开关 A/B 未见明确延迟收益,保留开启以避免撕裂。
|
||||||
# 连续动画确认服务端可发送约59FPS;当前2K软件呈现选择20FPS档,
|
# 连续动画确认服务端可发送约59FPS;当前2K软件呈现选择20FPS档,
|
||||||
# NVENC重启故障及各档性能记录见 docs/moonlight-kiosk-20261028.md。
|
# NVENC重启故障及各档性能记录见 docs/terminal-guide.md#session。
|
||||||
# frame-pacing 1=开启帧率匀速(--frame-pacing),0=关闭(--no-frame-pacing)
|
# frame-pacing 1=开启帧率匀速(--frame-pacing),0=关闭(--no-frame-pacing)
|
||||||
# performance-overlay 1=显示性能叠图(调试用)
|
# performance-overlay 1=显示性能叠图(调试用)
|
||||||
# profile smooth / low-latency / diagnostic;为空时保留下方显式开关
|
# profile smooth / low-latency / diagnostic;为空时保留下方显式开关
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
namespace vdi {
|
namespace vdi {
|
||||||
|
|
||||||
void DesktopService::login(const QString& username, const QString& password, AuthCallback callback) {
|
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+用户身份校验)。
|
// 正式版本替换为 /api/v1/terminal/session/login(SN+用户身份校验)。
|
||||||
AuthResult result;
|
AuthResult result;
|
||||||
@ -22,6 +23,7 @@ void DesktopService::login(const QString& username, const QString& password, Aut
|
|||||||
}
|
}
|
||||||
|
|
||||||
void DesktopService::listDesktops(DesktopListCallback callback) {
|
void DesktopService::listDesktops(DesktopListCallback callback) {
|
||||||
|
if (platformManaged()) { platform_.list(std::move(callback)); return; }
|
||||||
// 桌面与连接参数来自本地连接配置(现场可配);
|
// 桌面与连接参数来自本地连接配置(现场可配);
|
||||||
// 正式版本替换为 /api/v1/terminal/session/desktops(按用户绑定返回)。
|
// 正式版本替换为 /api/v1/terminal/session/desktops(按用户绑定返回)。
|
||||||
std::vector<DesktopInfo> desktops = DesktopStore::load();
|
std::vector<DesktopInfo> desktops = DesktopStore::load();
|
||||||
@ -31,6 +33,7 @@ void DesktopService::listDesktops(DesktopListCallback callback) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void DesktopService::createTicket(const QString& desktopId, TicketCallback callback) {
|
void DesktopService::createTicket(const QString& desktopId, TicketCallback callback) {
|
||||||
|
if (platformManaged()) { platform_.ticket(desktopId,std::move(callback)); return; }
|
||||||
// 本地演示票据:由连接配置构造。
|
// 本地演示票据:由连接配置构造。
|
||||||
// 正式版本替换为 /api/v1/terminal/session/connect-ticket(短期一次性票据)。
|
// 正式版本替换为 /api/v1/terminal/session/connect-ticket(短期一次性票据)。
|
||||||
ConnectionTicket ticket;
|
ConnectionTicket ticket;
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
// 桌面服务适配层
|
// 桌面服务适配层
|
||||||
//
|
//
|
||||||
// 职责:登录认证、桌面列表、连接票据。
|
// 职责:登录认证、桌面列表、连接票据。
|
||||||
// 当前为本地演示实现(账号非空即通过;Windows 桌面走 Moonlight,
|
// 配置 platform.json 时通过 HTTPS 使用真实设备身份、授权列表和单次票据;
|
||||||
// Ubuntu/麒麟未分配连接信息)。后续接入管理平台时替换/扩展本类为
|
// 未配置平台时保留本地 POC。平台认证失败不会回退本地授权。
|
||||||
// API 适配器(/api/v1/terminal/session/login|desktops|connect-ticket),
|
|
||||||
// 页面与状态机不感知数据来源变化。
|
// 页面与状态机不感知数据来源变化。
|
||||||
|
|
||||||
#ifndef KIOSK_SHELL_DESKTOP_SERVICE_H
|
#ifndef KIOSK_SHELL_DESKTOP_SERVICE_H
|
||||||
@ -14,6 +13,7 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "desktop_session.h"
|
#include "desktop_session.h"
|
||||||
|
#include "platform_client.h"
|
||||||
|
|
||||||
namespace vdi {
|
namespace vdi {
|
||||||
|
|
||||||
@ -35,9 +35,14 @@ public:
|
|||||||
void logout();
|
void logout();
|
||||||
|
|
||||||
QString currentUser() const { return currentUser_; }
|
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:
|
private:
|
||||||
QString currentUser_;
|
QString currentUser_;
|
||||||
|
PlatformClient platform_;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace vdi
|
} // namespace vdi
|
||||||
|
|||||||
@ -37,6 +37,7 @@ struct DesktopInfo {
|
|||||||
|
|
||||||
// 一次会话的连接凭据;当前为本地构造,后续由平台 connect-ticket 接口下发
|
// 一次会话的连接凭据;当前为本地构造,后续由平台 connect-ticket 接口下发
|
||||||
struct ConnectionTicket {
|
struct ConnectionTicket {
|
||||||
|
QString sessionId;
|
||||||
QString desktopId;
|
QString desktopId;
|
||||||
QString endpoint;
|
QString endpoint;
|
||||||
QString app;
|
QString app;
|
||||||
|
|||||||
@ -46,16 +46,45 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
|
|||||||
setupHeartbeat();
|
setupHeartbeat();
|
||||||
|
|
||||||
config_ = loadConfig();
|
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();
|
const QString deviceUser = QString::fromStdString(config_.sn).trimmed();
|
||||||
desktopService_.login(deviceUser.isEmpty() ? QStringLiteral("terminal") : deviceUser,
|
desktopService_.login(deviceUser.isEmpty() ? QStringLiteral("terminal") : deviceUser,
|
||||||
QStringLiteral("kiosk"), [this](const AuthResult& result) {
|
QStringLiteral("kiosk"), [this](const AuthResult& result) {
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
currentUser_ = result.displayName;
|
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() {
|
MainWindow::~MainWindow() {
|
||||||
@ -87,6 +116,18 @@ void MainWindow::setupUi() {
|
|||||||
setStyleSheet(QStringLiteral("QMainWindow { background: #0f1420; }"));
|
setStyleSheet(QStringLiteral("QMainWindow { background: #0f1420; }"));
|
||||||
qApp->installEventFilter(this);
|
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;
|
Qt::WindowFlags coverFlags = Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint | Qt::Tool;
|
||||||
#ifdef Q_OS_UNIX
|
#ifdef Q_OS_UNIX
|
||||||
coverFlags |= Qt::X11BypassWindowManagerHint;
|
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.
|
// state keeps the kiosk keyboard grab, including the connecting overlay.
|
||||||
const bool streamSurface = to == State::kSession || to == State::kReconnect;
|
const bool streamSurface = to == State::kSession || to == State::kReconnect;
|
||||||
if (streamSurface) {
|
if (streamSurface) {
|
||||||
if (isVisible()) {
|
// Moonlight owns Ctrl+Alt+Shift+Q while streaming and can destroy its
|
||||||
hide();
|
// 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);
|
setKioskInputGuard(false);
|
||||||
} else {
|
} else {
|
||||||
setKioskInputGuard(true);
|
setKioskInputGuard(true);
|
||||||
if (to != State::kBoot) {
|
|
||||||
presentKioskSurface();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (to) {
|
switch (to) {
|
||||||
@ -234,15 +273,33 @@ void MainWindow::onStateChanged(State from, State to, const QString& detail) {
|
|||||||
desktopListPage_->setBusy(false);
|
desktopListPage_->setBusy(false);
|
||||||
desktopListPage_->setStatus(QStringLiteral("连接失败:%1").arg(detail), true);
|
desktopListPage_->setStatus(QStringLiteral("连接失败:%1").arg(detail), true);
|
||||||
}
|
}
|
||||||
break;
|
return;
|
||||||
case State::kBoot:
|
case State::kBoot:
|
||||||
break;
|
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) {
|
void MainWindow::showEvent(QShowEvent* event) {
|
||||||
QMainWindow::showEvent(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_) {
|
if (kioskInputGuard_) {
|
||||||
retryKeyboardGrab();
|
retryKeyboardGrab();
|
||||||
}
|
}
|
||||||
@ -252,8 +309,15 @@ void MainWindow::applyKioskSurface() {
|
|||||||
if (!isVisible()) {
|
if (!isVisible()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
raise();
|
// Geometry updates and delayed show callbacks must not steal the stream's
|
||||||
activateWindow();
|
// 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()) {
|
if (QScreen* screen = windowHandle() ? windowHandle()->screen() : QApplication::primaryScreen()) {
|
||||||
// An override-redirect window does not receive the WM fullscreen
|
// An override-redirect window does not receive the WM fullscreen
|
||||||
// negotiation; explicitly cover the complete output geometry.
|
// negotiation; explicitly cover the complete output geometry.
|
||||||
@ -441,11 +505,15 @@ void MainWindow::refreshDesktopList() {
|
|||||||
desktopService_.listDesktops([this](std::vector<DesktopInfo> list, const QString& error) {
|
desktopService_.listDesktops([this](std::vector<DesktopInfo> list, const QString& error) {
|
||||||
if (error.isEmpty()) {
|
if (error.isEmpty()) {
|
||||||
desktops_ = list;
|
desktops_ = list;
|
||||||
|
} else if (desktopService_.platformManaged()) {
|
||||||
|
desktops_.clear();
|
||||||
}
|
}
|
||||||
if (desktopListPage_) {
|
if (desktopListPage_) {
|
||||||
desktopListPage_->setDesktops(desktops_);
|
desktopListPage_->setDesktops(desktops_);
|
||||||
if (!error.isEmpty()) {
|
if (!error.isEmpty()) {
|
||||||
desktopListPage_->setStatus(error, true);
|
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,
|
desktopService_.createTicket(desktopId, [this, attempt](const ConnectionTicket& ticket,
|
||||||
const QString& error) {
|
const QString& error) {
|
||||||
if (attempt != connectionAttempt_ || connectionPhase_ != ConnectionPhase::kTicket) {
|
if (attempt != connectionAttempt_ || connectionPhase_ != ConnectionPhase::kTicket) {
|
||||||
|
desktopService_.sessionEnded(ticket.sessionId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
connectionTimer_->stop();
|
connectionTimer_->stop();
|
||||||
@ -507,6 +576,10 @@ void MainWindow::onDesktopSelected(const QString& desktopId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::onEditDesktopRequested(const QString& desktopId) {
|
void MainWindow::onEditDesktopRequested(const QString& desktopId) {
|
||||||
|
if (desktopService_.platformManaged()) {
|
||||||
|
if (desktopListPage_) desktopListPage_->setStatus(QStringLiteral("桌面与连接参数由管理平台维护"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
DesktopSettingsDialog dialog(desktops_, desktopId, this);
|
DesktopSettingsDialog dialog(desktops_, desktopId, this);
|
||||||
dialog.setStyleSheet(
|
dialog.setStyleSheet(
|
||||||
QStringLiteral("QWidget { background: #0f1420; color: #e6e9ef; }"
|
QStringLiteral("QWidget { background: #0f1420; color: #e6e9ef; }"
|
||||||
@ -568,6 +641,7 @@ void MainWindow::onMoonlightEvent(quint64 attempt, const MoonlightSession::Event
|
|||||||
failConnection(attempt, event.message);
|
failConnection(attempt, event.message);
|
||||||
break;
|
break;
|
||||||
case MoonlightSession::EventType::kFinished: {
|
case MoonlightSession::EventType::kFinished: {
|
||||||
|
desktopService_.sessionEnded(activeTicket_.sessionId);
|
||||||
connectionTimer_->stop();
|
connectionTimer_->stop();
|
||||||
logConnectionPhase(attempt, QStringLiteral("session"), QStringLiteral("finished"));
|
logConnectionPhase(attempt, QStringLiteral("session"), QStringLiteral("finished"));
|
||||||
++connectionAttempt_;
|
++connectionAttempt_;
|
||||||
@ -590,6 +664,14 @@ void MainWindow::waitForMoonlightWindow(quint64 attempt) {
|
|||||||
if (attempt != connectionAttempt_ || connectionPhase_ != ConnectionPhase::kStreamReady) {
|
if (attempt != connectionAttempt_ || connectionPhase_ != ConnectionPhase::kStreamReady) {
|
||||||
return;
|
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()) {
|
if (!moonlightSession_->activateStreamWindow()) {
|
||||||
QTimer::singleShot(100, this, [this, attempt]() { waitForMoonlightWindow(attempt); });
|
QTimer::singleShot(100, this, [this, attempt]() { waitForMoonlightWindow(attempt); });
|
||||||
return;
|
return;
|
||||||
@ -598,6 +680,7 @@ void MainWindow::waitForMoonlightWindow(quint64 attempt) {
|
|||||||
connectionTimer_->stop();
|
connectionTimer_->stop();
|
||||||
logConnectionPhase(attempt, QStringLiteral("stream_ready"), QStringLiteral("ready"));
|
logConnectionPhase(attempt, QStringLiteral("stream_ready"), QStringLiteral("ready"));
|
||||||
connectionPhase_ = ConnectionPhase::kSession;
|
connectionPhase_ = ConnectionPhase::kSession;
|
||||||
|
desktopService_.sessionConnected(activeTicket_.sessionId);
|
||||||
fsm_.transitionTo(State::kSession, QStringLiteral("云桌面画面已就绪"));
|
fsm_.transitionTo(State::kSession, QStringLiteral("云桌面画面已就绪"));
|
||||||
overlay_->onFirstFrame();
|
overlay_->onFirstFrame();
|
||||||
}
|
}
|
||||||
@ -613,6 +696,7 @@ void MainWindow::failConnection(quint64 attempt, const QString& message) {
|
|||||||
if (moonlightSession_) {
|
if (moonlightSession_) {
|
||||||
moonlightSession_->stop();
|
moonlightSession_->stop();
|
||||||
}
|
}
|
||||||
|
releasePlatformSessionAfterStop(activeTicket_.sessionId);
|
||||||
overlay_->finish();
|
overlay_->finish();
|
||||||
if (fsm_.isIn(State::kConnecting) || fsm_.isIn(State::kSession) ||
|
if (fsm_.isIn(State::kConnecting) || fsm_.isIn(State::kSession) ||
|
||||||
fsm_.isIn(State::kReconnect)) {
|
fsm_.isIn(State::kReconnect)) {
|
||||||
@ -663,22 +747,29 @@ void MainWindow::stopSessions(bool invalidateAttempt) {
|
|||||||
if (moonlightSession_) {
|
if (moonlightSession_) {
|
||||||
moonlightSession_->stop();
|
moonlightSession_->stop();
|
||||||
}
|
}
|
||||||
|
releasePlatformSessionAfterStop(activeTicket_.sessionId);
|
||||||
if (overlay_) {
|
if (overlay_) {
|
||||||
overlay_->finish();
|
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) {
|
void MainWindow::onMenuAction(int action) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 1:
|
case 1:
|
||||||
if (fsm_.canConnect() && !activeTicket_.desktopId.isEmpty()) {
|
if (fsm_.canConnect() && !activeTicket_.desktopId.isEmpty()) {
|
||||||
const quint64 attempt = ++connectionAttempt_;
|
onDesktopSelected(activeTicket_.desktopId);
|
||||||
connectingDesktopId_ = activeTicket_.desktopId;
|
|
||||||
connectionClock_.restart();
|
|
||||||
if (fsm_.transitionTo(State::kConnecting, activeTicket_.endpoint) &&
|
|
||||||
!startMoonlightSession(activeTicket_, attempt)) {
|
|
||||||
failConnection(attempt, QStringLiteral("已有串流进程正在运行"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 2:
|
case 2:
|
||||||
|
|||||||
@ -66,6 +66,7 @@ private:
|
|||||||
void waitForMoonlightWindow(quint64 attempt);
|
void waitForMoonlightWindow(quint64 attempt);
|
||||||
void stopSessions();
|
void stopSessions();
|
||||||
void stopSessions(bool invalidateAttempt);
|
void stopSessions(bool invalidateAttempt);
|
||||||
|
void releasePlatformSessionAfterStop(const QString& id);
|
||||||
void failConnection(quint64 attempt, const QString& message);
|
void failConnection(quint64 attempt, const QString& message);
|
||||||
void armConnectionTimeout(quint64 attempt, ConnectionPhase phase, int timeoutMs);
|
void armConnectionTimeout(quint64 attempt, ConnectionPhase phase, int timeoutMs);
|
||||||
void logConnectionPhase(quint64 attempt, const QString& phase,
|
void logConnectionPhase(quint64 attempt, const QString& phase,
|
||||||
|
|||||||
@ -207,9 +207,50 @@ bool MoonlightSession::activateStreamWindow() {
|
|||||||
return false;
|
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"),
|
return QProcess::execute(QStringLiteral("xdotool"),
|
||||||
{QStringLiteral("windowactivate"), QStringLiteral("--sync"),
|
{QStringLiteral("windowsize"), windowId, width, height}) == 0;
|
||||||
windowId}) == 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool MoonlightSession::start(const ConnectionTicket& ticket, EventCallback callback) {
|
bool MoonlightSession::start(const ConnectionTicket& ticket, EventCallback callback) {
|
||||||
@ -227,6 +268,7 @@ bool MoonlightSession::start(const ConnectionTicket& ticket, EventCallback callb
|
|||||||
|
|
||||||
process_->setProgram(options_.program);
|
process_->setProgram(options_.program);
|
||||||
QProcessEnvironment environment = QProcessEnvironment::systemEnvironment();
|
QProcessEnvironment environment = QProcessEnvironment::systemEnvironment();
|
||||||
|
environment.insert(QStringLiteral("VDI_KIOSK_STREAM"), QStringLiteral("1"));
|
||||||
environment.insert(QStringLiteral("GL_IS_SLOW"), QStringLiteral("0"));
|
environment.insert(QStringLiteral("GL_IS_SLOW"), QStringLiteral("0"));
|
||||||
environment.insert(QStringLiteral("SDL_RENDER_DRIVER"),
|
environment.insert(QStringLiteral("SDL_RENDER_DRIVER"),
|
||||||
environment.value(QStringLiteral("VDI_SDL_RENDER_DRIVER"), QStringLiteral("opengles2")));
|
environment.value(QStringLiteral("VDI_SDL_RENDER_DRIVER"), QStringLiteral("opengles2")));
|
||||||
@ -286,11 +328,11 @@ void MoonlightSession::onProcessFinished(int exitCode, QProcess::ExitStatus exit
|
|||||||
return;
|
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)
|
||||||
: QStringLiteral("串流已结束 (code=%1)").arg(exitCode);
|
: QStringLiteral("串流已结束 (code=%1)").arg(exitCode);
|
||||||
emitTerminalEvent(exitStatus == QProcess::CrashExit ? EventType::kFailed
|
emitTerminalEvent(failed ? EventType::kFailed : EventType::kFinished,
|
||||||
: EventType::kFinished,
|
|
||||||
message);
|
message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
210
terminal/kiosk-shell/src/platform_client.cpp
Normal file
210
terminal/kiosk-shell/src/platform_client.cpp
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
46
terminal/kiosk-shell/src/platform_client.h
Normal file
46
terminal/kiosk-shell/src/platform_client.h
Normal 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
|
||||||
@ -21,7 +21,7 @@ StateMachine::StateMachine(StateListener listener) : listener_(std::move(listene
|
|||||||
bool StateMachine::isLegalTransition(State from, State to) {
|
bool StateMachine::isLegalTransition(State from, State to) {
|
||||||
switch (from) {
|
switch (from) {
|
||||||
case State::kBoot:
|
case State::kBoot:
|
||||||
return to == State::kConfig || to == State::kError;
|
return to == State::kConfig || to == State::kDesktopList || to == State::kError;
|
||||||
case State::kConfig:
|
case State::kConfig:
|
||||||
return to == State::kAuthenticating || to == State::kConnecting ||
|
return to == State::kAuthenticating || to == State::kConnecting ||
|
||||||
to == State::kError;
|
to == State::kError;
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
#include <QFont>
|
#include <QFont>
|
||||||
#include <QGuiApplication>
|
#include <QGuiApplication>
|
||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
|
#include <QPalette>
|
||||||
#include <QScreen>
|
#include <QScreen>
|
||||||
#include <QTimer>
|
#include <QTimer>
|
||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
@ -23,9 +24,18 @@ const char* kSpinnerFrames[] = {"◐", "◓", "◑", "◒"};
|
|||||||
TransitionOverlay::TransitionOverlay(QWidget* parent) : QWidget(parent) {
|
TransitionOverlay::TransitionOverlay(QWidget* parent) : QWidget(parent) {
|
||||||
// 独立置顶无边框窗口只在启动时映射一次,后续不再与 Moonlight 抢焦点。
|
// 独立置顶无边框窗口只在启动时映射一次,后续不再与 Moonlight 抢焦点。
|
||||||
setWindowFlags(Qt::Window | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint |
|
setWindowFlags(Qt::Window | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint |
|
||||||
Qt::WindowDoesNotAcceptFocus);
|
Qt::WindowDoesNotAcceptFocus
|
||||||
|
#ifdef Q_OS_UNIX
|
||||||
|
| Qt::X11BypassWindowManagerHint
|
||||||
|
#endif
|
||||||
|
);
|
||||||
setAttribute(Qt::WA_ShowWithoutActivating);
|
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);
|
auto* layout = new QVBoxLayout(this);
|
||||||
layout->setSpacing(28);
|
layout->setSpacing(28);
|
||||||
@ -82,6 +92,14 @@ void TransitionOverlay::finish() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void TransitionOverlay::onFirstFrame() {
|
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()) {
|
if (!isVisible() || fadeTimer_->isActive()) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
// 会话切换过场层
|
// 会话切换过场层
|
||||||
//
|
//
|
||||||
// 用途:用户点击"连接"后,Moonlight 客户端会先在屏幕左上角创建一个小窗口,
|
// 用户点击连接后显示产品加载提示;串流窗口在完整铺满输出后接管显示。
|
||||||
// 再扩展到全屏并解码首帧,这段时间画面闪烁、体验割裂。
|
// X11 独占会话没有合成器,交接时直接隐藏不透明过场层;其他平台可淡出。
|
||||||
// 本控件是覆盖在 kiosk 全屏窗口之上的不透明过场层(置顶),
|
|
||||||
// 盖住这个过程,直到收到首帧信号后淡出,让用户感知为"一次平滑的转场"。
|
|
||||||
|
|
||||||
#ifndef KIOSK_SHELL_TRANSITION_OVERLAY_H
|
#ifndef KIOSK_SHELL_TRANSITION_OVERLAY_H
|
||||||
#define KIOSK_SHELL_TRANSITION_OVERLAY_H
|
#define KIOSK_SHELL_TRANSITION_OVERLAY_H
|
||||||
@ -23,7 +21,7 @@ public:
|
|||||||
|
|
||||||
// 显示过场层并开始动画;title 为提示文字(如"正在进入 Windows 10")
|
// 显示过场层并开始动画;title 为提示文字(如"正在进入 Windows 10")
|
||||||
void start(const QString& title);
|
void start(const QString& title);
|
||||||
// 隐藏过场层(带淡出)
|
// 立即隐藏过场层
|
||||||
void finish();
|
void finish();
|
||||||
|
|
||||||
// 首次视频帧已到达
|
// 首次视频帧已到达
|
||||||
|
|||||||
@ -12,6 +12,7 @@ private slots:
|
|||||||
void requiresBothReadyMarkers();
|
void requiresBothReadyMarkers();
|
||||||
void acceptsSeparateHardwareTestDecoder();
|
void acceptsSeparateHardwareTestDecoder();
|
||||||
void rejectsSecondStart();
|
void rejectsSecondStart();
|
||||||
|
void reportsKioskLaunchFailure();
|
||||||
void buildsConsistentStreamArguments();
|
void buildsConsistentStreamArguments();
|
||||||
void selectsProfileWithoutLosingStats();
|
void selectsProfileWithoutLosingStats();
|
||||||
void rejectsInvalidDecoderOverride();
|
void rejectsInvalidDecoderOverride();
|
||||||
@ -107,6 +108,41 @@ void MoonlightSessionTest::rejectsSecondStart() {
|
|||||||
qputenv("PATH", oldPath);
|
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() {
|
void MoonlightSessionTest::buildsConsistentStreamArguments() {
|
||||||
ConnectionTicket ticket;
|
ConnectionTicket ticket;
|
||||||
ticket.endpoint = QStringLiteral("192.168.1.70");
|
ticket.endpoint = QStringLiteral("192.168.1.70");
|
||||||
|
|||||||
134
terminal/kiosk-shell/tests/verify_entry_surface.py
Normal file
134
terminal/kiosk-shell/tests/verify_entry_surface.py
Normal 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)
|
||||||
138
terminal/kiosk-shell/tests/verify_return_surface.py
Normal file
138
terminal/kiosk-shell/tests/verify_return_surface.py
Normal 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)
|
||||||
7
terminal/kiosk-shell/vdi-kiosk-session.desktop
Normal file
7
terminal/kiosk-shell/vdi-kiosk-session.desktop
Normal 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
|
||||||
@ -5,4 +5,4 @@ Comment=产品唯一自启入口:看门狗守护 kiosk-shell(登录 -> 桌
|
|||||||
Exec=/home/armbian/vdi-terminal/bin/vdi-kiosk-start
|
Exec=/home/armbian/vdi-terminal/bin/vdi-kiosk-start
|
||||||
Terminal=false
|
Terminal=false
|
||||||
X-GNOME-Autostart-enabled=true
|
X-GNOME-Autostart-enabled=true
|
||||||
X-GNOME-Autostart-Delay=2
|
X-GNOME-Autostart-Delay=0
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
本目录维护当前 RK3528 / Xorg POC 私有客户端的源码修改,不替换系统 Moonlight。
|
本目录维护当前 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` 固定。
|
基线: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 或麒麟客体系统。
|
- `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)。
|
- `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 留在“按住拖拽/点击”状态。
|
- `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 行为。
|
`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 没有主线程呈现事件,新调度开关不能用作该路径的性能提升宣传。
|
新增 `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保持单个远程白色光标。
|
||||||
|
|||||||
128
terminal/moonlight/patches/0007-vdi-kiosk-launch-surface.patch
Normal file
128
terminal/moonlight/patches/0007-vdi-kiosk-launch-surface.patch
Normal 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.
|
||||||
@ -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,避免旧客户端连入无光标捕获。
|
紧急恢复可在客体执行 `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`。
|
||||||
|
|||||||
@ -44,5 +44,4 @@ Windows 端 `tools/set-usb-experience.ps1` 支持 Apply/Restore,为当前登
|
|||||||
`VDI_USBREDIRECT_BINARY=/usr/local/libexec/vdi-usbredirect`。
|
`VDI_USBREDIRECT_BINARY=/usr/local/libexec/vdi-usbredirect`。
|
||||||
未配置时监控程序仍使用 `/usr/bin/usbredirect`;更新运行程序前须安全弹出并拔下 U 盘。
|
未配置时监控程序仍使用 `/usr/bin/usbredirect`;更新运行程序前须安全弹出并拔下 U 盘。
|
||||||
|
|
||||||
完整现场、部署、限制和回退见
|
现场结论、部署入口和限制见[云桌面说明](../../docs/desktop-guide.md#usb);完整过程与逐步回退见[历史归档](../../docs/archive/README.md)中的 `usb-device-poc-20261103.md` 和 `usb-any-storage-20261104.md`。
|
||||||
[`docs/usb-device-poc-20261103.md`](../../docs/usb-device-poc-20261103.md)。
|
|
||||||
|
|||||||
@ -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/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/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/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
|
# 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.
|
# include from Qt 6. Our Qt 5 build needs the standard header explicitly.
|
||||||
|
|||||||
@ -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'
|
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-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-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 热插拔恢复服务"
|
echo "==> 安装 HDMI 热插拔恢复服务"
|
||||||
bash "$REPO/tools/deploy_display_guard.sh"
|
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'
|
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 "==> 安装二进制"
|
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 一条链)"
|
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'
|
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 "==> 启动受管栈并验证"
|
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 "==> 抓屏确认当前界面(应为桌面列表)"
|
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"
|
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"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user