功能:接入Linux原生光标并完善设备接入工具

This commit is contained in:
songyiming committed 2026-11-05 19:18:54 +08:00
1 parent a14f028402
commit a47968496e
92 files changed
+3090 -22

No files matched your search

+5
View File
@@ -40,6 +40,11 @@ target_link_libraries(kiosk-shell PRIVATE
Qt5::Widgets
)
if(UNIX AND NOT APPLE)
find_package(X11 REQUIRED)
target_link_libraries(kiosk-shell PRIVATE X11::X11)
endif()
# 看门狗 Monitor:独立进程,监控主进程假死/崩溃
add_executable(vdi-kiosk-monitor
src/monitor_main.cpp
@@ -183,6 +183,11 @@ build_cmd() {
CMD="$CMD --video-codec $CODEC"
CMD="$CMD --video-decoder $DECODER"
CMD="$CMD --keep-awake"
# The kiosk must keep system shortcuts inside the streamed desktop. Without
# this, combinations such as Win+Shift+S are handled by the box OS and can
# expose its UI over the cloud desktop. Apply this to Windows, Ubuntu and
# Kylin sessions alike.
CMD="$CMD --capture-system-keys always"
[ "$ABSOLUTE_MOUSE" = "1" ] && CMD="$CMD --absolute-mouse"
[ "$AUDIO" = "1" ] && CMD="$CMD --audio-on-host" || CMD="$CMD --no-audio-on-host"
# 按profile选择同步策略;默认保持当前稳定档。
@@ -35,4 +35,7 @@ if [ "${1:-}" = stream ] && [ -z "${COLOR_RANGE_OVERRIDE+x}" ]; then
;;
esac
fi
if [ -f "$HOME/vdi-terminal/bin/vdi-native-cursor" ]; then
exec /usr/bin/python3 "$HOME/vdi-terminal/bin/vdi-native-cursor" "$client" "$@"
fi
exec "$client" "$@"
+1 -1
View File
@@ -16,7 +16,7 @@ int main(int argc, char** argv) {
app.setOrganizationName(QStringLiteral("vdi"));
auto* window = new vdi::MainWindow();
window->show();
window->showFullScreen();
return app.exec();
}
+243 -2
View File
@@ -3,12 +3,25 @@
#include <QApplication>
#include <QDateTime>
#include <QDebug>
#include <QEvent>
#include <QEventLoop>
#include <QFile>
#include <QKeyEvent>
#include <QMenu>
#include <QShortcut>
#include <QScreen>
#include <QShowEvent>
#include <QStackedWidget>
#include <QStatusBar>
#include <QTimer>
#include <QWidget>
#include <QWindow>
#ifdef Q_OS_UNIX
#include <X11/Xlib.h>
#undef KeyPress
#undef KeyRelease
#endif
#include "desktop_list_page.h"
#include "desktop_settings_dialog.h"
@@ -46,13 +59,42 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
}
MainWindow::~MainWindow() {
qApp->removeEventFilter(this);
setKioskInputGuard(false);
#ifdef Q_OS_UNIX
if (grabDisplay_) {
XCloseDisplay(static_cast<Display*>(grabDisplay_));
grabDisplay_ = nullptr;
}
#endif
stopSessions();
delete returnCover_;
returnCover_ = nullptr;
}
void MainWindow::setupUi() {
setWindowTitle(QStringLiteral("VDI 云终端"));
// The login/list pages are the security boundary of the terminal. Keep a
// borderless, top-level surface over the desktop panels while the guest
// stream is not active. X11BypassWindowManagerHint also prevents MATE's
// panel from being raised above the shell after a global shortcut.
Qt::WindowFlags kioskFlags = Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint;
#ifdef Q_OS_UNIX
kioskFlags |= Qt::X11BypassWindowManagerHint;
#endif
setWindowFlags(kioskFlags);
setWindowState(Qt::WindowFullScreen);
setStyleSheet(QStringLiteral("QMainWindow { background: #0f1420; }"));
qApp->installEventFilter(this);
Qt::WindowFlags coverFlags = Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint | Qt::Tool;
#ifdef Q_OS_UNIX
coverFlags |= Qt::X11BypassWindowManagerHint;
#endif
returnCover_ = new QWidget(nullptr, coverFlags);
returnCover_->setAttribute(Qt::WA_ShowWithoutActivating);
returnCover_->setStyleSheet(QStringLiteral("QWidget { background: #0f1420; }"));
returnCover_->hide();
stack_ = new QStackedWidget(this);
stack_->setStyleSheet(QStringLiteral("background: #0f1420;"));
@@ -120,12 +162,18 @@ void MainWindow::setupHotkey() {
connect(quitShortcut, &QShortcut::activated, this, [this]() {
if (fsm_.isIn(State::kConnecting) || fsm_.isIn(State::kSession) ||
fsm_.isIn(State::kReconnect)) {
stopSessions();
// Put the opaque kiosk surface back on top before asking
// Moonlight to terminate. This prevents one compositor frame of
// the box desktop from showing while the stream window closes.
showReturnCover();
fsm_.transitionTo(State::kDesktopList, QStringLiteral("已退出串流"));
presentKioskSurface();
stopSessions();
if (desktopListPage_) {
desktopListPage_->setBusy(false);
desktopListPage_->setStatus(QStringLiteral("已退出串流"));
}
releaseReturnCoverWhenReady();
}
});
}
@@ -148,6 +196,21 @@ void MainWindow::onStateChanged(State from, State to, const QString& detail) {
const QString stateName = QString::fromLatin1(stateToString(to));
updateStatusBar(detail.isEmpty() ? stateName : QStringLiteral("%1 %2").arg(stateName, detail));
// Only the Moonlight window may receive system shortcuts. Every other
// state keeps the kiosk keyboard grab, including the connecting overlay.
const bool streamSurface = to == State::kSession || to == State::kReconnect;
if (streamSurface) {
if (isVisible()) {
hide();
}
setKioskInputGuard(false);
} else {
setKioskInputGuard(true);
if (to != State::kBoot) {
presentKioskSurface();
}
}
switch (to) {
case State::kConfig:
showLogin();
@@ -177,6 +240,182 @@ void MainWindow::onStateChanged(State from, State to, const QString& detail) {
}
}
void MainWindow::showEvent(QShowEvent* event) {
QMainWindow::showEvent(event);
applyKioskSurface();
if (kioskInputGuard_) {
retryKeyboardGrab();
}
}
void MainWindow::applyKioskSurface() {
if (!isVisible()) {
return;
}
raise();
activateWindow();
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.
setGeometry(screen->geometry());
}
}
void MainWindow::presentKioskSurface() {
if (!isVisible()) {
showFullScreen();
}
// Let Qt map and paint the opaque surface before changing the X11 stack.
// The direct X11 calls are needed because Moonlight uses an override/full-
// screen window and a WM-only raise can otherwise be deferred one frame.
applyKioskSurface();
QApplication::processEvents(QEventLoop::ExcludeUserInputEvents);
#ifdef Q_OS_UNIX
if (!grabDisplay_) {
grabDisplay_ = XOpenDisplay(nullptr);
}
if (grabDisplay_) {
auto* display = static_cast<Display*>(grabDisplay_);
const Window window = static_cast<Window>(winId());
XMapRaised(display, window);
XRaiseWindow(display, window);
XSetInputFocus(display, window, RevertToParent, CurrentTime);
XSync(display, False);
}
#endif
applyKioskSurface();
}
void MainWindow::showReturnCover() {
if (!returnCover_) {
return;
}
QScreen* screen = QApplication::primaryScreen();
if (screen) {
returnCover_->setGeometry(screen->geometry());
}
returnCover_->show();
returnCover_->raise();
QApplication::processEvents(QEventLoop::ExcludeUserInputEvents);
#ifdef Q_OS_UNIX
if (!grabDisplay_) {
grabDisplay_ = XOpenDisplay(nullptr);
}
if (grabDisplay_) {
auto* display = static_cast<Display*>(grabDisplay_);
const Window window = static_cast<Window>(returnCover_->winId());
XMapRaised(display, window);
XRaiseWindow(display, window);
XSync(display, False);
}
#endif
}
void MainWindow::hideReturnCover() {
if (!returnCover_) {
return;
}
returnCover_->hide();
QApplication::processEvents(QEventLoop::ExcludeUserInputEvents);
}
void MainWindow::releaseReturnCoverWhenReady() {
if (!returnCover_ || !returnCover_->isVisible()) {
return;
}
bool ready = isVisible();
#ifdef Q_OS_UNIX
if (ready && grabDisplay_) {
XWindowAttributes attributes{};
ready = XGetWindowAttributes(static_cast<Display*>(grabDisplay_),
static_cast<Window>(winId()), &attributes) != 0 &&
attributes.map_state == IsViewable;
}
#endif
if (ready) {
presentKioskSurface();
hideReturnCover();
return;
}
// The X server may need a few event-loop turns to map the Qt surface
// after Moonlight relinquishes its fullscreen window. Keep the opaque
// cover in place until that mapping is observable; never expose the root
// desktop during this interval.
QTimer::singleShot(20, this, [this]() { releaseReturnCoverWhenReady(); });
}
void MainWindow::setKioskInputGuard(bool enabled) {
kioskInputGuard_ = enabled;
if (!enabled) {
#ifdef Q_OS_UNIX
if (keyboardGrabActive_ && grabDisplay_) {
auto* display = static_cast<Display*>(grabDisplay_);
XUngrabKeyboard(display, CurrentTime);
XFlush(display);
}
#endif
keyboardGrabActive_ = false;
return;
}
if (isVisible()) {
retryKeyboardGrab();
}
}
void MainWindow::retryKeyboardGrab() {
if (!kioskInputGuard_ || !isVisible() || keyboardGrabActive_) {
return;
}
#ifdef Q_OS_UNIX
if (!grabDisplay_) {
grabDisplay_ = XOpenDisplay(nullptr);
}
if (grabDisplay_) {
auto* display = static_cast<Display*>(grabDisplay_);
const int result = XGrabKeyboard(display, static_cast<Window>(winId()), False,
GrabModeAsync, GrabModeAsync, CurrentTime);
if (result == GrabSuccess) {
XSetInputFocus(display, static_cast<Window>(winId()), RevertToParent, CurrentTime);
}
XFlush(display);
keyboardGrabActive_ = result == GrabSuccess;
}
#else
keyboardGrabActive_ = true;
#endif
if (!keyboardGrabActive_ && !keyboardGrabRetryPending_) {
keyboardGrabRetryPending_ = true;
QTimer::singleShot(200, this, [this]() {
keyboardGrabRetryPending_ = false;
retryKeyboardGrab();
});
}
}
bool MainWindow::eventFilter(QObject* watched, QEvent* event) {
Q_UNUSED(watched);
if (kioskInputGuard_ &&
(event->type() == QEvent::KeyPress || event->type() == QEvent::KeyRelease ||
event->type() == QEvent::ShortcutOverride)) {
auto* keyEvent = static_cast<QKeyEvent*>(event);
const Qt::KeyboardModifiers modifiers = keyEvent->modifiers();
const int key = keyEvent->key();
const bool metaShortcut = modifiers.testFlag(Qt::MetaModifier) || key == Qt::Key_Meta;
const bool altWindowShortcut = modifiers.testFlag(Qt::AltModifier) &&
(key == Qt::Key_Tab || key == Qt::Key_F4);
const bool vtShortcut = modifiers.testFlag(Qt::ControlModifier) &&
modifiers.testFlag(Qt::AltModifier) &&
(key == Qt::Key_Delete || (key >= Qt::Key_F1 && key <= Qt::Key_F12));
const bool screenshotKey = key == Qt::Key_Print;
if (metaShortcut || altWindowShortcut || vtShortcut || screenshotKey) {
return true;
}
}
return QMainWindow::eventFilter(watched, event);
}
void MainWindow::updateStatusBar(const QString& text) {
statusBar_->showMessage(text);
}
@@ -443,10 +682,12 @@ void MainWindow::onMenuAction(int action) {
}
break;
case 2:
stopSessions();
showReturnCover();
if (fsm_.current() != State::kConfig) {
fsm_.transitionTo(State::kDesktopList, QStringLiteral("已断开"));
}
stopSessions();
releaseReturnCoverWhenReady();
break;
case 3:
onQuitRequested();
+21
View File
@@ -19,6 +19,9 @@
class QStackedWidget;
class QStatusBar;
class QTimer;
class QEvent;
class QShowEvent;
class QWidget;
namespace vdi {
@@ -33,6 +36,10 @@ public:
explicit MainWindow(QWidget* parent = nullptr);
~MainWindow() override;
protected:
void showEvent(QShowEvent* event) override;
bool eventFilter(QObject* watched, QEvent* event) override;
private slots:
void onLoginRequested(const QString& username, const QString& password);
void onDesktopSelected(const QString& desktopId);
@@ -67,6 +74,13 @@ private:
void showLogin();
void showDesktopList();
void updateStatusBar(const QString& text);
void setKioskInputGuard(bool enabled);
void applyKioskSurface();
void presentKioskSurface();
void showReturnCover();
void hideReturnCover();
void releaseReturnCoverWhenReady();
void retryKeyboardGrab();
StateMachine fsm_;
DesktopService desktopService_;
@@ -88,6 +102,13 @@ private:
quint64 connectionAttempt_ = 0;
ConnectionPhase connectionPhase_ = ConnectionPhase::kIdle;
TerminalConfig config_;
QWidget* returnCover_ = nullptr;
bool kioskInputGuard_ = true;
bool keyboardGrabActive_ = false;
bool keyboardGrabRetryPending_ = false;
#ifdef Q_OS_UNIX
void* grabDisplay_ = nullptr;
#endif
};
} // namespace vdi
@@ -157,6 +157,11 @@ QStringList MoonlightSession::buildStreamArguments(const ConnectionTicket& ticke
<< QStringLiteral("--video-codec") << options.videoCodec
<< QStringLiteral("--video-decoder") << options.decoder
<< QStringLiteral("--absolute-mouse")
// The terminal is a kiosk: system shortcuts must be delivered to the
// streamed desktop instead of being handled by the box OS. This is
// required for Win+Shift+S, Alt+Tab, Super shortcuts, etc. on every
// supported desktop guest (Windows, Ubuntu and Kylin).
<< QStringLiteral("--capture-system-keys") << QStringLiteral("always")
<< QStringLiteral("--keep-awake")
<< (options.vsync ? QStringLiteral("--vsync") : QStringLiteral("--no-vsync"))
<< (options.framePacing ? QStringLiteral("--frame-pacing")
@@ -134,6 +134,9 @@ void MoonlightSessionTest::buildsConsistentStreamArguments() {
QVERIFY(args.contains(QStringLiteral("--no-frame-pacing")));
QVERIFY(args.contains(QStringLiteral("--performance-overlay")));
QVERIFY(args.contains(QStringLiteral("--absolute-mouse")));
const int captureOption = args.indexOf(QStringLiteral("--capture-system-keys"));
QVERIFY(captureOption >= 0);
QCOMPARE(args.value(captureOption + 1), QStringLiteral("always"));
}
void MoonlightSessionTest::selectsProfileWithoutLosingStats() {
+5 -3
View File
@@ -9,12 +9,14 @@
- `0001-vdi-input-and-latest-frame.patch` 在 Moonlight Qt 根目录应用。鼠标移动只合并队首连续事件,遇到按钮、键盘、窗口、呈现事件或其他鼠标设备立即停止;每次最多额外合并 64 个事件。绝对模式在发送按键前使用该按键事件的坐标。低延迟队列可选地把已等待的解码帧替换成新帧。
- `0002-vdi-absolute-click-barrier.patch` 在 `moonlight-common-c/moonlight-common-c` 中应用。鼠标按钮封住此前的位置批次,后续移动只能进入新批次;发送线程只锁定自己的批次,避免旧包出队时误清除新批次。所有包依旧走原可靠鼠标通道,保留 1ms 合并限流和原坐标边界修正。
- `0003-vdi-input-priority.patch` 在 Qt 根目录应用。可选地让帧就绪后的连续输入先执行,最多 32 条或 2ms,不能跨窗口/焦点/其他事件。`VDI_INPUT_PRIORITY=1` 才启用,默认关闭。另提供 `VDI_INPUT_TIMING=1` 的汇总统计,区分 SDL 输入派发年龄与主线程呈现调用耗时,不代表端到端延迟。
- `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)。
2026-11-03 的现场 RKMPP/EGL 使用独立 PacerRender 线程,诊断会话主线程 render 调用数为 0;因此第三补丁的新调度没有已证明的现场收益,未启用。诊断候选二进制保存为 `moonlight.input-priority-20261103`;默认客户端已恢复到本轮前 SHA256 `35de7b1a4ade583823b23b5f2e012307f6d0a3848c8eb97bd6d4de9660afa00f`。详细计划/初测限制见 `docs/mouse-optimization-plan-20261103.md`,独立光标接入设计见 `docs/independent-cursor-design-20261103.md`。
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清洁应用通过与现场补丁集合应分开判断。
`VDI_LATEST_FRAME=1` 只在没有 VSync pacing source 的呈现队列生效。最多保留一个待呈现解码帧,丢弃数计入原 `pacerDroppedFrames`。不丢压缩包,不改变 H.264 参考链,也不提前释放正在呈现或延迟释放的上一帧。未设置或设为 `0` 时保留上游帧队列策略;kiosk 私有 wrapper 默认设为 `1`。smooth 档仍使用原 pacing 行为。
这些补丁不增加本地光标,不隐藏 Windows 光标,不改变控制协议。远程光标仍随视频显示,其延迟仍受服务器捕获/编码、网络、解码和显示影响。
0001~0004不增加本地光标。0005仅在已配置Linux主机启用原生独立光标,不改变Windows光标或Moonlight原可靠输入通道。Windows远程光标仍随视频显示,其延迟受服务器捕获/编码、网络、解码和显示影响。
## 构建与测试
@@ -41,4 +43,4 @@ VDI_TEST_SANITIZE=1 ASAN_OPTIONS=detect_leaks=1 bash tools/test-moonlight-latenc
GPU修复后实测见`docs/2k60-validation-20261103.md`:当前正常二进制保持不变,VDD60Hz/串流45FPS已配置并从kiosk重连;约43~44FPS渲染。2K60请求受当前盒子呈现路径限制,不能宣称稳定60。`experiments/0004-vdi-egl-exact-size-sampling.patch`为未采用的精确尺寸采样实验,未进入正常构建器;默认仍为原GL_LINEAR取样。该实验候选二进制也未被正常wrapper使用。
独立光标已完成受控X11辅助程序原型,结果见`docs/independent-cursor-feasibility-20261103.md`。尚无自动服务端形状同步和可靠帧交接,未加入正常构建/启动流程;不能把Ctrl+Alt+Shift+C显示本地箭头和Sunshine全局N切换直接当生产方案。现有客户端保持单个Windows远程白色光标。
2026-11-03的Windows受控X11辅助程序原型见`docs/independent-cursor-feasibility-20261103.md`;该Windows原型没有自动形状同步或可靠帧交接,未进入正常启动流程。2026-11-05的Linux方案由0005和`terminal/native-cursor/`实现自动形状同步,通过连接前/整次会话后交接避开运行中帧切换;完整失焦和广泛缩放等场景尚未验收。不能把固定本地箭头和Sunshine全局N切换当作该方案。Windows保持单个远程白色光标。
@@ -0,0 +1,237 @@
--- a/app/streaming/session.cpp
+++ b/app/streaming/session.cpp
@@ -1,3 +1,4 @@
+#include "vdi_native_cursor.h"
#include "session.h"
#include "settings/streamingpreferences.h"
#include "streaming/streamutils.h"
@@ -1968,8 +1969,10 @@
VdiInputTiming vdiInputTiming(qEnvironmentVariableIntValue("VDI_INPUT_TIMING") == 1);
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "VDI input priority: %s",
qEnvironmentVariableIntValue("VDI_INPUT_PRIORITY") == 1 ? "enabled" : "disabled");
+ VdiNativeCursor vdiNativeCursor(m_Window, m_InputHandler);
SDL_Event event;
for (;;) {
+ if (!vdiNativeCursor.tick()) goto DispatchDeferredCleanup;
if (!vdiInputPriority.takeDeferred(event, SDL_GetWindowID(m_Window))) {
#if SDL_VERSION_ATLEAST(2, 0, 18) && !defined(STEAM_LINK)
// SDL 2.0.18 has a proper wait event implementation that uses platform
@@ -1981,7 +1984,7 @@
// NB: This behavior was introduced in SDL 2.0.16, but had a few critical
// issues that could cause indefinite timeouts, delayed joystick detection,
// and other problems.
- if (!SDL_WaitEventTimeout(&event, 1000)) {
+ if (!SDL_WaitEventTimeout(&event, vdiNativeCursor.enabled() ? 4 : 1000)) {
presence.runCallbacks();
continue;
}
@@ -2006,6 +2009,7 @@
continue;
}
}
+ vdiNativeCursor.observe(event);
vdiInputTiming.observe(event, SDL_GetWindowID(m_Window));
switch (event.type) {
case SDL_QUIT:
@@ -2322,6 +2326,7 @@
}
DispatchDeferredCleanup:
+ vdiNativeCursor.shutdown();
vdiInputTiming.report(vdiInputPriority.prioritized());
// Switch back to synchronous logging mode
StreamUtils::exitAsyncLoggingMode();
--- a/app/streaming/input/input.h
+++ b/app/streaming/input/input.h
@@ -89,6 +89,12 @@
~SdlInputHandler();
void setWindow(SDL_Window* window);
+
+ bool isNativeCursorAbsoluteMode() const { return m_AbsoluteMouseMode; }
+ void enableNativeCursorVisibility() {
+ m_MouseCursorCapturedVisibilityState = SDL_ENABLE;
+ SDL_ShowCursor(SDL_ENABLE);
+ }
void handleKeyEvent(SDL_KeyboardEvent* event);
--- a/app/streaming/input/keyboard.cpp
+++ b/app/streaming/input/keyboard.cpp
@@ -68,6 +68,7 @@
break;
case KeyComboToggleMouseMode:
+ if (SDL_getenv("VDI_NATIVE_CURSOR_STATE")) break;
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION,
"Detected mouse mode toggle combo");
@@ -82,6 +83,7 @@
break;
case KeyComboToggleCursorHide:
+ if (SDL_getenv("VDI_NATIVE_CURSOR_STATE")) break;
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION,
"Detected show mouse combo");
--- /dev/null
+++ b/app/streaming/vdi_native_cursor.h
@@ -0,0 +1,158 @@
+#pragma once
+
+#include <QFile>
+#include <QImage>
+#include <QSaveFile>
+#include <QtEndian>
+#include <time.h>
+#include "input/input.h"
+#include "streamutils.h"
+
+// Linux X11 single-seat native cursor. Called only by the SDL main thread.
+// The producer starts cursor-free before this session's first captured frame.
+class VdiNativeCursor {
+public:
+ VdiNativeCursor(SDL_Window* window, SdlInputHandler* input) :
+ m_Window(window), m_Input(input), m_Path(qgetenv("VDI_NATIVE_CURSOR_STATE")),
+ m_Epoch(qgetenv("VDI_NATIVE_CURSOR_EPOCH").toULongLong()) {}
+
+ bool enabled() const { return !m_Path.isEmpty(); }
+
+ void observe(const SDL_Event& event) {
+ if (event.type == SDL_MOUSEMOTION || event.type == SDL_MOUSEBUTTONDOWN ||
+ event.type == SDL_MOUSEBUTTONUP) {
+ m_LastInput = nowNs();
+ ++m_InputSequence;
+ }
+ }
+
+ bool tick() {
+ if (!enabled()) return true;
+ if (QByteArray(SDL_GetCurrentVideoDriver()) != "x11") return fail("requires X11 cursor backend");
+ const quint64 pollTime = nowNs();
+ if (pollTime - m_LastPoll < 4000000) return true;
+ m_LastPoll = pollTime;
+ QFile f(QString::fromUtf8(m_Path));
+ if (!f.open(QIODevice::ReadOnly) || f.size() < 76 || f.size() > 65608)
+ return fail("missing or oversized state");
+ const QByteArray packet = f.readAll();
+ // The producer may atomically replace state after pollTime. Compare
+ // against a timestamp taken AFTER the read, never before opening it.
+ const quint64 now = nowNs();
+ const auto* p = reinterpret_cast<const uchar*>(packet.constData());
+ auto u32 = [p](int offset) { return qFromLittleEndian<quint32>(p + offset); };
+ auto u64 = [p](int offset) { return qFromLittleEndian<quint64>(p + offset); };
+ if (packet.left(8) != QByteArray("VDICUR1\0", 8) || u64(8) != m_Epoch ||
+ u64(16) < m_Sequence || u64(24) > now || now - u64(24) > 2000000000)
+ return fail("stale or mismatched lease");
+ const int w = u32(32), h = u32(36), hx = u32(40), hy = u32(44);
+ const int x = qint32(u32(48)), y = qint32(u32(52));
+ const int sw = u32(56), sh = u32(60);
+ if (w < 1 || w > 128 || h < 1 || h > 128 || hx < 0 || hx >= w ||
+ hy < 0 || hy >= h || sw != 2560 || sh != 1440 ||
+ x < 0 || x >= sw || y < 0 || y >= sh || packet.size() != 72 + w*h*4 ||
+ !m_Input->isNativeCursorAbsoluteMode())
+ return fail("unsupported shape, display or input mode");
+ m_Sequence = u64(16);
+ int ww, wh;
+ SDL_GetWindowSize(m_Window, &ww, &wh);
+ SDL_Rect src = {0, 0, sw, sh}, dst = {0, 0, ww, wh};
+ StreamUtils::scaleSourceToDestinationSurface(&src, &dst);
+ if (dst.w <= 0 || dst.h <= 0) return fail("empty viewport");
+ const int cw = qMax(1, qRound(double(w) * dst.w / sw));
+ const int ch = qMax(1, qRound(double(h) * dst.h / sh));
+ if (cw > 256 || ch > 256) return fail("oversized scaled cursor");
+ const quint64 acknowledged = u64(64 + w*h*4);
+ const QByteArray pixels = packet.mid(64, w*h*4);
+ if (pixels != m_Pixels || w != m_Width || h != m_Height || hx != m_HotX ||
+ hy != m_HotY || dst.w != m_ViewWidth || dst.h != m_ViewHeight) {
+ QImage image(reinterpret_cast<const uchar*>(pixels.constData()), w, h,
+ w * 4, QImage::Format_RGBA8888);
+ image = image.scaled(cw, ch, Qt::IgnoreAspectRatio, Qt::FastTransformation);
+ // SDL2's X11 Xcursor backend copies pixels without premultiplying.
+ // XRender requires premultiplied channels, including antialiased edges.
+ image = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied);
+ SDL_Surface* surface = SDL_CreateRGBSurfaceWithFormatFrom(
+ image.bits(), cw, ch, 32, image.bytesPerLine(), SDL_PIXELFORMAT_RGBA32);
+ if (!surface) return fail("SDL surface failed");
+ SDL_Cursor* next = SDL_CreateColorCursor(surface,
+ qMin(cw - 1, qRound(double(hx) * dst.w / sw)),
+ qMin(ch - 1, qRound(double(hy) * dst.h / sh)));
+ SDL_FreeSurface(surface);
+ if (!next) return fail("SDL cursor failed");
+ SDL_SetCursor(next);
+ if (m_Cursor) SDL_FreeCursor(m_Cursor);
+ m_Cursor = next;
+ m_Pixels = pixels;
+ m_Width = w; m_Height = h; m_HotX = hx; m_HotY = hy;
+ m_ViewWidth = dst.w; m_ViewHeight = dst.h;
+ SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION,
+ "VDI native shape: %dx%d hotspot=%d,%d sequence=%llu",
+ w, h, hx, hy, (unsigned long long)m_Sequence);
+ }
+ int mx, my;
+ const auto buttons = SDL_GetMouseState(&mx, &my);
+ const bool inside = mx >= dst.x && my >= dst.y && mx < dst.x + dst.w && my < dst.y + dst.h;
+ const bool focused = SDL_GetKeyboardFocus() == m_Window;
+ if (m_PublishedInput != m_InputSequence) {
+ QByteArray message(16, '\0');
+ qToLittleEndian<quint64>(m_InputSequence, reinterpret_cast<uchar*>(message.data()));
+ qToLittleEndian<qint32>(qBound(0, (mx-dst.x)*sw/dst.w, sw-1),
+ reinterpret_cast<uchar*>(message.data()+8));
+ qToLittleEndian<qint32>(qBound(0, (my-dst.y)*sh/dst.h, sh-1),
+ reinterpret_cast<uchar*>(message.data()+12));
+ QSaveFile outgoing(QString::fromUtf8(m_Path)+".input");
+ if (!outgoing.open(QIODevice::WriteOnly) || outgoing.write(message) != 16 || !outgoing.commit())
+ return fail("input acknowledgement file failed");
+ m_PublishedInput = m_InputSequence;
+ }
+ m_Input->enableNativeCursorVisibility();
+ SDL_SetCursor(focused && inside ? m_Cursor : SDL_GetDefaultCursor());
+ // Initial alignment, and host-initiated warps only after local input has
+ // settled. Never pull the pointer back using delayed motion echoes.
+ if (focused && inside && !buttons && acknowledged == m_InputSequence &&
+ now - m_LastInput > 500000000) {
+ const int tx = dst.x + qRound(double(x) * dst.w / sw);
+ const int ty = dst.y + qRound(double(y) * dst.h / sh);
+ if (qAbs(tx - mx) > 2 || qAbs(ty - my) > 2) {
+ // SDL2's X11 in-window warp is a no-op with pointer barriers
+ // (used by fullscreen absolute mode). Global warp honors this
+ // in-viewport target while actually moving the native pointer.
+ int wx, wy;
+ SDL_GetWindowPosition(m_Window, &wx, &wy);
+ if (SDL_WarpMouseGlobal(wx+tx, wy+ty) < 0)
+ return fail("host cursor warp failed");
+ m_LastInput = now;
+ }
+ }
+ return true;
+ }
+
+ void shutdown() {
+ if (m_Cursor) {
+ SDL_SetCursor(SDL_GetDefaultCursor());
+ SDL_FreeCursor(m_Cursor);
+ m_Cursor = nullptr;
+ }
+ }
+
+private:
+ static quint64 nowNs() {
+ struct timespec t;
+ clock_gettime(CLOCK_MONOTONIC, &t);
+ return quint64(t.tv_sec)*1000000000 + t.tv_nsec;
+ }
+ bool fail(const char* reason) {
+ SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "VDI native cursor ended: %s", reason);
+ shutdown();
+ return false;
+ }
+ SDL_Window* m_Window;
+ SdlInputHandler* m_Input;
+ QByteArray m_Path, m_Pixels;
+ quint64 m_Epoch, m_Sequence = 0, m_LastPoll = 0, m_LastInput = nowNs();
+ quint64 m_InputSequence = 0, m_PublishedInput = 0;
+ SDL_Cursor* m_Cursor = nullptr;
+ int m_Width = 0, m_Height = 0, m_HotX = 0, m_HotY = 0;
+ int m_ViewWidth = 0, m_ViewHeight = 0;
+};
+50
View File
@@ -0,0 +1,50 @@
# Linux 原生独立光标(X11 / 单座席 / 2K)
用于当前盒子 `.188` 的 Ubuntu `.72` 和麒麟 `.71`。主机光标图像、热点、动画帧和透明隐藏状态通过认证 SSH 同步;位置由盒子的 X11 原生指针立即显示。没有改系统光标主题,也没有用固定本地箭头替代原生形状。点击、拖窗内容和视频编码延迟仍存在。
本实现绕过两个不同 Sunshine 版本的大型重编译,使用**按精确 SHA256 固定的私有副本和捕获适配器**。这是本项目的受限接入方式,不是上游 Sunshine/Moonlight 已支持的协议能力。后续要支持其他捕获后端、相对鼠标或多个并发客户端,应扩展双方正式控制协议。
## 生命周期
1. `vdi-moonlight-rkmpp` 调用 `client.py`。没有对应主机 JSON 配置,或 `VDI_NATIVE_CURSOR=0`,直接 exec 原客户端。Windows 没有该配置。启用时先核验客户端 SHA256,再获取远端租约;旧客户端或未复核的新构建会在修改主机服务前被拒绝。
2. 客户端使用各主机独立密钥、固定 known_hosts、BatchMode 和严格主机校验打开 SSH。服务器 authorized_keys 使用 `restrict,from="192.168.1.188",command="/usr/bin/python3 .../host.py stream"`,不提供任意 shell。
3. `host.py stream` 获取独占 flock,检查版本、随机租约、单屏尺寸与光标边界;启动用户级 guard。guard 校验私有 Sunshine 和适配器 SHA256,停止原服务,在 `/run/user/UID/systemd/user/` 创建临时 drop-in,再以适配器启动私有副本。
4. `capture-no-cursor.c` 只在该 Sunshine 进程的 XFixesGetCursorImage 捕获结果中清零像素,不修改 X server 的实际光标、全局主题或系统库。adapter 的 dlsym receipt 和 `/proc/PID/maps` 都确认后才返回 READY,随后才允许新视频连接。因此所有新视频帧从一开始都不含光标,不在旧视频仍显示光标时直接切换。
5. SSH 消息包含版本、128位随机租约、递增序号、尺寸、热点、RGBA 像素、源分辨率和输入确认。最多128×128像素;坏包、错误尺寸、重复序号或超时结束当前串流。客户端/bridge状态等待上限2秒,主机租约4秒;短暂停顿期间保留上次形状,本地指针位置继续移动。源几何变化要求重新连接。
6. 盒子监督进程写入0700私有目录的原子状态文件。SDL主线程每约4ms检查状态,只在形状/缩放改变时创建和释放光标;X11原生指针移动不等该轮询或视频帧。SDL2 X11后端直接复制Xcursor像素,需要预乘alpha,已单独适配。
7. 本地输入序号和目标坐标通过SSH回传;服务器实际指针到达该位置后确认。只在最新输入已确认、没有按键按下且本地静止500ms后接受服务器主动warp,避免迟到的位置回拉正在移动的光标。触摸/相对游戏模式不在本接入范围。
8. 客户端保留原PID(fork监督进程后exec),正常kiosk窗口激活仍能识别。退出、父进程被kill或SSH异常时结束租约。guard先停止无光标生产进程,再删除临时drop-in并启动原服务;原视频连接不会被新普通生产进程接续。
9. guard本身被kill时,systemd ExecStopPost仍执行恢复。重启后 `/run` 中的drop-in自然不存在;原已启用服务仍正常启动。没有启用全局LD_PRELOAD或永久无光标模式。
## 构建和安装
- 客户端补丁:`../moonlight/patches/0005-vdi-native-linux-cursor.patch`;由 `tools/build-rkmpp-client.sh` 在0001~0004之后应用。头文件源码保留在本目录,修改时必须同步补丁中的新增头文件。
- `tools/deploy_kiosk.sh` 部署 `client.py` 为 `~/vdi-terminal/bin/vdi-native-cursor`。配置/密钥不会由普通kiosk部署脚本生成。
- 两个客体的私有目录:`~/vdi-terminal/native-cursor-v1/`,包含 `host.py`、`capture-no-cursor.c/.so`、**无额外file capability的**原版Sunshine副本,以及 `manifest.json`。原系统二进制/能力保持原值。
- `.so`在麒麟glibc2.31/GCC9上编译,两台复用。最小编译不依赖缺失的libc开发包:
```sh
gcc -shared -nostdlib -fPIC -O2 -Wall -Wextra -Werror -Wl,-z,defs \
capture-no-cursor.c /lib/x86_64-linux-gnu/libdl.so.2 \
/lib/x86_64-linux-gnu/libc.so.6 -o capture-no-cursor.so
```
麒麟仅对本次 `host.py`、私有 `sunshine` 和 `.so` 做 `kysec_set -n exectl -v verified` 单文件认证;源码变更后须重新认证。没有关闭KYSEC。Ubuntu原文件具有capabilities,安全加载器会忽略LD_PRELOAD,因此仅租约内使用相同SHA的无capability私有副本;X11捕获无需SYS_ADMIN,启动时其他捕获后端的能力探测告警不代表X11失败。
用户级 `vdi-native-cursor-guard.service` 的ExecStart为 `/usr/bin/python3 <目录>/host.py guard`,ExecStopPost为同脚本的 `recover`;Type=simple,TimeoutStopSec=50。服务按需启动,不enable。`manifest.json`仅包含两个键 `sunshine`、`capture-no-cursor.so`,值为部署文件的SHA256。
盒子 `~/.config/vdi-terminal/native-cursor/<IP>.json`:
```json
{"host":"192.168.1.72","user":"test2","key":"/绝对路径/该主机.key","known_hosts":"/绝对路径/该主机.known_hosts","size":[2560,1440],"client_sha256":"5d4330f703d7f8e053bc3795ca42bdaf231c9e9dd8ebb00a612013adbcccee03"}
```
每台主机单独的ed25519私钥和known_hosts留在盒子配置目录(目录700、私钥600),不进仓库。known_hosts是在工作站用用户提供的SSH凭据成功认证后取得的真实主机公钥,没有在运行时自动接受新主机密钥。JSON和密钥应在已验证0005的客户端部署后才启用。上例SHA为2026-11-05现场构建;重新构建后须先停用profile、完成对应验证,再更新SHA并启用,不能把旧值机械替换为任意新文件的SHA。
## 停用与回退
退出当前Linux串流,等guard inactive且原vdi-sunshine active。将对应 `<IP>.json` 改名为 `.disabled`,下次从同一正常列表连接即使用原视频光标。临时诊断也可设置 `VDI_NATIVE_CURSOR=0`,但必须在当前租约结束、原服务恢复之后重连。
紧急恢复可在客体执行 `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`。
@@ -0,0 +1,54 @@
/* Linux/glibc X11 capture adapter. Only preload into a leased Sunshine process.
* Sunshine resolves XFixesGetCursorImage with dlsym(), so a plain symbol
* interposer is insufficient. No system library or desktop theme is replaced.
* ABI matches XFixesCursorImage from the Xorg Xfixes.h public header.
*/
#define _GNU_SOURCE
#include <stddef.h>
extern void *dlvsym(void *, const char *, const char *);
extern char *getenv(const char *);
extern int strcmp(const char *, const char *);
extern void abort(void);
extern int open(const char *, int, ...);
extern long write(int, const void *, unsigned long);
extern int close(int);
typedef struct {
short x, y;
unsigned short width, height, xhot, yhot;
unsigned long serial;
unsigned long *pixels;
unsigned long atom;
const char *name;
} CursorImage;
typedef void *(*Lookup)(void *, const char *);
typedef CursorImage *(*GetImage)(void *);
static GetImage real_image;
static CursorImage *capture_image(void *display) {
GetImage fn = __atomic_load_n(&real_image, __ATOMIC_ACQUIRE);
if (!fn) abort();
CursorImage *image = fn(display);
if (image && image->pixels) {
unsigned long n = (unsigned long)image->width * image->height;
if (n > 1048576) abort();
for (unsigned long i = 0; i < n; ++i) image->pixels[i] = 0;
}
return image;
}
void *dlsym(void *handle, const char *name) {
Lookup lookup = (Lookup)dlvsym((void *)-1L, "dlsym", "GLIBC_2.2.5");
if (!lookup) abort();
void *result = lookup(handle, name);
if (result && getenv("VDI_CURSOR_LEASE") &&
strcmp(name, "XFixesGetCursorImage") == 0) {
__atomic_store_n(&real_image, (GetImage)result, __ATOMIC_RELEASE);
const char *receipt = getenv("VDI_CURSOR_RECEIPT");
if (receipt) {
int fd = open(receipt, 0101 | 01000 | 02000000, 0600);
if (fd >= 0) { write(fd, "xfixes-v1\n", 10); close(fd); }
}
return (void *)capture_image;
}
return result;
}
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env python3
"""Box supervisor. Exec preserves the PID expected by kiosk window activation.
Metadata travels over pinned-host-key SSH with a per-host forced-command key.
The private state directory is not a public socket/port. Native cursor resources
are created and freed by Moonlight's SDL main thread, never by this process.
"""
import base64
import hashlib
import json
import os
from pathlib import Path
import select
import shutil
import signal
import struct
import subprocess
import sys
import tempfile
import time
import uuid
HEADER = struct.Struct('<8sQQQIIIIiiII')
MAX_LINE = 100000
STATE_TIMEOUT = 2.0
def verify_client(path, expected):
if (not isinstance(expected, str) or len(expected) != 64 or
hashlib.sha256(Path(path).read_bytes()).hexdigest() != expected):
raise ValueError('native cursor client build changed; verify build before enabling profile')
def validate(state, lease, size, previous):
if type(state.get('version')) is not int or state.get('version') != 1 or state.get('lease') != lease or state.get('type') != 'cursor':
raise ValueError('cursor protocol/session mismatch')
fields = ('seq', 'ack', 'w', 'h', 'hx', 'hy', 'x', 'y', 'sw', 'sh')
if any(type(state.get(k)) is not int for k in fields):
raise ValueError('noninteger field')
if not previous < state['seq'] < 2**63:
raise ValueError('stale sequence')
if not 0 <= state['ack'] < 2**63:
raise ValueError('invalid input acknowledgement')
w, h = state['w'], state['h']
if not (0 < w <= 128 and 0 < h <= 128 and 0 <= state['hx'] < w and 0 <= state['hy'] < h):
raise ValueError('cursor bounds')
if [state['sw'], state['sh']] != size:
raise ValueError('display changed')
if not (0 <= state['x'] < size[0] and 0 <= state['y'] < size[1]):
raise ValueError('cursor position outside single display')
pixels = base64.b64decode(state['rgba'], validate=True)
if len(pixels) != w * h * 4:
raise ValueError('pixel length')
return HEADER.pack(b'VDICUR1\0', int(lease[:16], 16), state['seq'],
time.monotonic_ns(), w, h, state['hx'], state['hy'],
state['x'], state['y'], *size) + pixels + struct.pack('<Q', state['ack'])
def write_state(directory, payload):
tmp = directory / 'state.tmp'
with tmp.open('wb') as f:
f.write(payload)
tmp.replace(directory / 'state')
def supervise(profile, size, lease, directory, ready_fd, parent):
ssh = None
ready = False
try:
cmd = ['ssh', '-T', '-i', profile['key'], '-o', 'IdentitiesOnly=yes',
'-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes',
'-o', 'UserKnownHostsFile=' + profile['known_hosts'],
'-o', 'ConnectTimeout=5', '-o', 'ServerAliveInterval=1',
'-o', 'ServerAliveCountMax=2', profile['user'] + '@' + profile['host']]
ssh = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
ssh.stdin.write((json.dumps(dict(version=1, lease=lease, size=size)) + '\n').encode())
ssh.stdin.flush()
started = last = last_ping = time.monotonic()
seq, buffer, acknowledged = 0, b'', False
last_input = 0
while True:
# A kiosk SIGKILL of the exec'd client must still tear down the lease.
if os.getppid() != parent:
break
now = time.monotonic()
if now - last_ping >= .25:
ssh.stdin.write(b'ping\n')
ssh.stdin.flush()
last_ping = now
input_path = directory / 'state.input'
if ready and input_path.exists():
data = input_path.read_bytes()
if len(data) != 16:
raise ValueError('invalid local input record')
input_seq, px, py = struct.unpack('<Qii', data)
if input_seq > last_input:
if not (input_seq < 2**63 and 0 <= px < size[0] and 0 <= py < size[1]):
raise ValueError('local input bounds')
ssh.stdin.write((json.dumps(dict(input=input_seq, x=px, y=py))+'\n').encode())
ssh.stdin.flush()
last_input = input_seq
if (ready and now - last > STATE_TIMEOUT) or (not ready and now - started > 50):
raise RuntimeError('native cursor heartbeat timed out (%.3fs)' % (now - last))
if select.select([ssh.stdout], [], [], .025)[0]:
chunk = os.read(ssh.stdout.fileno(), 65536)
if not chunk:
raise RuntimeError('native cursor SSH ended')
buffer += chunk
if len(buffer) > MAX_LINE * 2:
raise ValueError('cursor receive buffer limit')
while b'\n' in buffer:
line, buffer = buffer.split(b'\n', 1)
if len(line) > MAX_LINE:
raise ValueError('cursor packet limit')
state = json.loads(line)
if not acknowledged:
if state != dict(type='ready', version=1, lease=lease):
raise ValueError('cursor handshake rejected')
acknowledged = True
continue
payload = validate(state, lease, size, seq)
seq = state['seq']
write_state(directory, payload)
last = time.monotonic()
if not ready:
os.write(ready_fd, b'ready\n')
os.close(ready_fd)
ready = True
print('VDI native cursor: authenticated cursor-free session ready',
file=sys.stderr, flush=True)
return 0
except Exception as error:
print('VDI native cursor: ' + str(error), file=sys.stderr, flush=True)
if ready and os.getppid() == parent:
os.kill(parent, signal.SIGTERM)
return 78
finally:
if ssh:
try:
ssh.stdin.write(b'stop\n')
ssh.stdin.flush()
ssh.stdin.close()
ssh.wait(timeout=12)
except (BrokenPipeError, subprocess.TimeoutExpired, OSError):
ssh.terminate()
try:
ssh.wait(timeout=3)
except subprocess.TimeoutExpired:
ssh.kill()
shutil.rmtree(directory)
def launch(argv):
client, args = argv[0], argv[1:]
if not (len(args) >= 2 and args[0] == 'stream'):
os.execv(client, argv)
host = args[1]
if any(c not in '0123456789.' for c in host):
os.execv(client, argv)
config = Path.home() / '.config/vdi-terminal/native-cursor' / (host + '.json')
if os.environ.get('VDI_NATIVE_CURSOR') == '0' or not config.exists():
os.execv(client, argv)
profile = json.loads(config.read_text())
# An old or separately rebuilt client must never enter cursor-free video
# without the SDL receiver. Verify BEFORE acquiring any remote lease.
verify_client(client, profile.get('client_sha256'))
if profile['host'] != host or '--absolute-mouse' not in args:
raise ValueError('native cursor needs matching absolute desktop mode')
resolution = args[args.index('--resolution') + 1]
size = list(map(int, resolution.split('x')))
if size != profile['size']:
raise ValueError('native cursor needs configured single-screen size')
lease = uuid.uuid4().hex
directory = Path(tempfile.mkdtemp(prefix='vdi-native-cursor-',
dir=os.environ.get('XDG_RUNTIME_DIR', '/tmp')))
rfd, wfd = os.pipe()
parent = os.getpid()
worker = os.fork()
if worker == 0:
os.close(rfd)
os._exit(supervise(profile, size, lease, directory, wfd, parent))
os.close(wfd)
if not select.select([rfd], [], [], 55)[0] or os.read(rfd, 32) != b'ready\n':
# No normal-mode launch while a cursor-free producer might still live.
raise RuntimeError('native cursor preflight failed; reconnect after recovery')
os.close(rfd)
os.environ['VDI_NATIVE_CURSOR_STATE'] = str(directory / 'state')
os.environ['VDI_NATIVE_CURSOR_EPOCH'] = str(int(lease[:16], 16))
os.execv(client, argv)
if __name__ == '__main__':
launch(sys.argv[1:])
+299
View File
@@ -0,0 +1,299 @@
#!/usr/bin/env python3
"""Authenticated single-seat Linux cursor lease; run by a restricted SSH key.
The guard owns the Sunshine lifecycle. Cursor-free capture starts BEFORE the
new video connection; ending the lease stops that capture process before
restoring normal capture. There is deliberately no mid-stream mode switch.
"""
import base64
import ctypes as C
import fcntl
import hashlib
import json
import os
from pathlib import Path
import select
import signal
import subprocess
import sys
import time
ROOT = Path(__file__).resolve().parent
RUN = Path('/run/user/%d/vdi-native-cursor' % os.getuid())
DROP = RUN.parent / 'systemd/user/vdi-sunshine.service.d/90-native-cursor.conf'
SERVICE = 'vdi-sunshine.service'
GUARD = 'vdi-native-cursor-guard.service'
TTL = 4.0
def systemctl(*args):
return subprocess.run(['systemctl', '--user'] + list(args), check=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
timeout=20).stdout.decode().strip()
def recover():
# Stop the cursor-free producer FIRST. A new normal producer cannot resume
# an old Moonlight connection, so lost SSH never creates a double cursor.
if DROP.exists():
systemctl('stop', SERVICE)
DROP.unlink()
systemctl('daemon-reload')
systemctl('start', SERVICE)
for name in ('lease', 'ready', 'receipt'):
(RUN / name).unlink(missing_ok=True)
def guard():
lease = (RUN / 'lease').read_text().strip()
if len(lease) != 32 or any(c not in '0123456789abcdef' for c in lease):
raise ValueError('invalid lease')
manifest = json.loads((ROOT / 'manifest.json').read_text())
for name, expected in manifest.items():
if name not in ('sunshine', 'capture-no-cursor.so') or hashlib.sha256(
(ROOT / name).read_bytes()).hexdigest() != expected:
raise RuntimeError('pinned capture build changed')
kms_config = ROOT / 'kms.json'
scanout = None
def checked_scanout(timeout=3):
deadline = time.monotonic() + timeout
last_error = None
while True:
try:
return scanout.check()
except RuntimeError as exc:
last_error = exc
if time.monotonic() >= deadline:
raise last_error
time.sleep(.1)
if kms_config.exists():
from kms import VirtioScanout
scanout = VirtioScanout(json.loads(kms_config.read_text()))
# Wake an idle output before inspecting its active scanout. This does
# not unlock the session or disable the user's screen lock.
subprocess.run(['/usr/bin/xset', 'dpms', 'force', 'on'], check=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=5)
if hashlib.sha256(Path('/usr/bin/sunshine').read_bytes()).hexdigest() != manifest['sunshine']:
raise RuntimeError('pinned system capture build changed')
systemctl('stop', SERVICE)
DROP.parent.mkdir(parents=True, exist_ok=True)
if scanout:
# File capabilities remain on the original, verified system binary.
# Command-line override is lease-local; recover restores X11 capture.
DROP.write_text('[Service]\nExecStart=\nExecStart=/usr/bin/sunshine %s capture=kms\n' %
(Path.home() / '.config/sunshine/sunshine.conf'))
else:
DROP.write_text('[Service]\nEnvironment="LD_PRELOAD=%s" "VDI_CURSOR_LEASE=%s" '
'"VDI_CURSOR_RECEIPT=%s"\n' %
(ROOT / 'capture-no-cursor.so', lease, RUN / 'receipt') +
'ExecStart=\nExecStart=%s %s\n' %
(ROOT / 'sunshine', Path.home() / '.config/sunshine/sunshine.conf'))
systemctl('daemon-reload')
# The normal X11 producer can leave the DRM plane idle while it is being
# stopped. Install the recoverable drop-in first, then validate the
# stopped display before launching KMS. ExecStopPost restores X11 if this
# fail-closed check rejects the current output.
if scanout:
subprocess.run(['/usr/bin/xset', 'dpms', 'force', 'on'], check=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=5)
checked_scanout()
systemctl('start', SERVICE)
# dlsym receipt proves this exact process resolved the adapter entrypoint.
deadline = time.monotonic() + 12
while not scanout and not (RUN / 'receipt').exists():
if time.monotonic() > deadline:
raise RuntimeError('capture adapter not resolved')
time.sleep(.05)
pid = int(systemctl('show', SERVICE, '-p', 'MainPID', '--value'))
if scanout:
checked_scanout()
if b'capture=kms\0' not in Path('/proc/%d/cmdline' % pid).read_bytes():
raise RuntimeError('KMS capture override not active')
elif str(ROOT / 'capture-no-cursor.so') not in Path('/proc/%d/maps' % pid).read_text():
raise RuntimeError('capture adapter not mapped')
(RUN / 'ready').write_text(lease)
while (RUN / 'lease').exists():
if time.time() - (RUN / 'lease').stat().st_mtime > TTL:
break
if int(systemctl('show', SERVICE, '-p', 'MainPID', '--value')) != pid:
break # Never accept an encoder restart inside the same lease.
if scanout:
checked_scanout(1)
time.sleep(.15)
class Cursor(C.Structure):
_fields_ = [('x', C.c_short), ('y', C.c_short), ('w', C.c_ushort),
('h', C.c_ushort), ('hx', C.c_ushort), ('hy', C.c_ushort),
('serial', C.c_ulong), ('pixels', C.POINTER(C.c_ulong)),
('atom', C.c_ulong), ('name', C.c_char_p)]
class XCursor:
def __init__(self):
# Absolute original library path; never inherit the capture adapter.
self.x = C.CDLL('libX11.so.6')
self.f = C.CDLL('/lib/x86_64-linux-gnu/libXfixes.so.3')
self.x.XOpenDisplay.argtypes = [C.c_char_p]
self.x.XOpenDisplay.restype = C.c_void_p
self.x.XDefaultScreen.argtypes = [C.c_void_p]
self.x.XDefaultRootWindow.argtypes = [C.c_void_p]
self.x.XDefaultRootWindow.restype = C.c_ulong
self.x.XGetGeometry.argtypes = [C.c_void_p, C.c_ulong, C.POINTER(C.c_ulong),
C.POINTER(C.c_int), C.POINTER(C.c_int), C.POINTER(C.c_uint),
C.POINTER(C.c_uint), C.POINTER(C.c_uint), C.POINTER(C.c_uint)]
for name in ('XDisplayWidth', 'XDisplayHeight'):
getattr(self.x, name).argtypes = [C.c_void_p, C.c_int]
self.x.XFree.argtypes = [C.c_void_p]
self.f.XFixesGetCursorImage.argtypes = [C.c_void_p]
self.f.XFixesGetCursorImage.restype = C.POINTER(Cursor)
self.display = self.x.XOpenDisplay(None)
if not self.display:
raise RuntimeError('no authenticated X11 display')
self.screen = self.x.XDefaultScreen(self.display)
self.root = self.x.XDefaultRootWindow(self.display)
self.size = self.dimensions()
def dimensions(self):
root = C.c_ulong()
x, y = C.c_int(), C.c_int()
w, h, border, depth = [C.c_uint() for _ in range(4)]
if not self.x.XGetGeometry(self.display, self.root, C.byref(root),
C.byref(x), C.byref(y), C.byref(w), C.byref(h),
C.byref(border), C.byref(depth)):
raise RuntimeError('root geometry unavailable')
return w.value, h.value
def read(self):
if self.dimensions() != self.size:
raise RuntimeError('display changed; reconnect required')
ptr = self.f.XFixesGetCursorImage(self.display)
if not ptr:
raise RuntimeError('cursor unavailable')
try:
c = ptr.contents
if not (0 <= c.w <= 128 and 0 <= c.h <= 128):
raise ValueError('unsupported cursor bounds')
rgba = bytearray()
for i in range(c.w * c.h):
p = c.pixels[i]
a = (p >> 24) & 255
# XRender pixels are premultiplied; SDL expects straight RGBA.
rgb = [min(255, (((p >> s) & 255) * 255 + a // 2) // a)
if a else 0 for s in (16, 8, 0)]
rgba.extend(rgb + [a])
w, h, hx, hy = c.w, c.h, c.hx, c.hy
if not any(rgba[3::4]):
# GTK's blank cursor can be 1x1 with a hotspot of (1,1).
# Canonicalize invisible pixels; never clip a visible hotspot.
w, h, hx, hy, rgba = 1, 1, 0, 0, bytearray(4)
elif not (hx < w and hy < h):
raise ValueError('visible cursor hotspot outside image')
return dict(w=w, h=h, hx=hx, hy=hy, x=c.x, y=c.y,
sw=self.size[0], sh=self.size[1], shape=int(c.serial),
rgba=base64.b64encode(rgba).decode())
finally:
self.x.XFree(ptr)
def stream():
RUN.mkdir(mode=0o700, parents=True, exist_ok=True)
with (RUN / 'lock').open('w') as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
# Version/capabilities come through the authenticated SSH stdin.
if not select.select([sys.stdin], [], [], 5)[0]:
raise RuntimeError('missing hello')
hello = json.loads(sys.stdin.buffer.readline(2048))
lease = hello.get('lease', '')
if hello.get('version') != 1 or len(lease) != 32 or any(
c not in '0123456789abcdef' for c in lease):
raise ValueError('bad hello')
x = XCursor()
if list(x.size) != hello.get('size'):
raise ValueError('source size differs')
# Fail before touching the service if the native shape is unsupported.
x.read()
systemctl('stop', GUARD)
(RUN / 'lease').write_text(lease)
systemctl('start', GUARD)
deadline = time.monotonic() + 40
try:
while not (RUN / 'ready').exists():
if time.monotonic() > deadline:
raise RuntimeError('guard not ready')
os.utime(RUN / 'lease', None)
time.sleep(.05)
if (RUN / 'ready').read_text() != lease:
raise RuntimeError('stale guard')
print(json.dumps(dict(type='ready', version=1, lease=lease)), flush=True)
seq = 0
last_shape = None
last_sent = 0
heartbeat = time.monotonic()
acknowledged_input = 0
pending_input = None
while True:
now = time.monotonic()
if select.select([sys.stdin], [], [], 0)[0]:
line = sys.stdin.buffer.readline(2048)
if not line or line.strip() == b'stop':
break
if line.strip() != b'ping':
request = json.loads(line)
if (set(request) != {'input', 'x', 'y'} or
any(type(request[k]) is not int for k in request) or
not acknowledged_input < request['input'] < 2**63 or
not 0 <= request['x'] < x.size[0] or
not 0 <= request['y'] < x.size[1]):
raise ValueError('bad input acknowledgement request')
pending_input = request
heartbeat = now
if now - heartbeat > TTL:
raise RuntimeError('client lease expired')
os.utime(RUN / 'lease', None)
if not (RUN / 'ready').exists():
raise RuntimeError('capture guard ended')
read_started = time.monotonic()
state = x.read()
read_seconds = time.monotonic() - read_started
if read_seconds > .1:
print('VDI native cursor: X11 read blocked %.3fs' % read_seconds,
file=sys.stderr, flush=True)
if pending_input and (abs(state['x'] - pending_input['x']) <= 2 and
abs(state['y'] - pending_input['y']) <= 2):
acknowledged_input = pending_input['input']
pending_input = None
state['ack'] = acknowledged_input
identity = (state['shape'], state['rgba'])
if identity != last_shape or now - last_sent >= .05:
seq += 1
state.update(type='cursor', version=1, lease=lease, seq=seq)
print(json.dumps(state, separators=(',', ':')), flush=True)
last_shape, last_sent = identity, now
time.sleep(.008)
finally:
(RUN / 'lease').unlink(missing_ok=True)
systemctl('stop', GUARD)
def main():
os.environ['DISPLAY'] = ':0'
os.environ['XDG_RUNTIME_DIR'] = '/run/user/%d' % os.getuid()
os.environ['DBUS_SESSION_BUS_ADDRESS'] = 'unix:path=%s/bus' % os.environ['XDG_RUNTIME_DIR']
gdm = Path(os.environ['XDG_RUNTIME_DIR']) / 'gdm/Xauthority'
os.environ['XAUTHORITY'] = str(gdm if gdm.exists() else Path.home() / '.Xauthority')
for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
signal.signal(sig, lambda *_: sys.exit(0))
mode = sys.argv[1] if len(sys.argv) > 1 else 'stream'
if mode == 'recover':
recover()
elif mode == 'guard':
guard()
elif mode == 'stream':
stream()
else:
raise ValueError('unknown mode')
if __name__ == '__main__':
main()
+124
View File
@@ -0,0 +1,124 @@
"""Fail-closed guard for the current virtio GPU's cursor-free KMS scanout.
Only primary planes visible with Sunshine's exact DRM client capabilities are
accepted. Virtio cursor hotspot planes are hidden from atomic clients that do
not request DRM_CLIENT_CAP_CURSOR_PLANE_HOTSPOT. This is not a cursor filter.
"""
import ctypes as C
import os
import re
class PlaneResources(C.Structure):
_fields_ = [('count', C.c_uint32), ('planes', C.POINTER(C.c_uint32))]
class Plane(C.Structure):
_fields_ = [('count_formats', C.c_uint32), ('formats', C.POINTER(C.c_uint32)),
('plane_id', C.c_uint32), ('crtc_id', C.c_uint32),
('fb_id', C.c_uint32), ('crtc_x', C.c_uint32),
('crtc_y', C.c_uint32), ('x', C.c_uint32), ('y', C.c_uint32),
('possible_crtcs', C.c_uint32), ('gamma_size', C.c_uint32)]
class Properties(C.Structure):
_fields_ = [('count', C.c_uint32), ('props', C.POINTER(C.c_uint32)),
('values', C.POINTER(C.c_uint64))]
class PropertyPrefix(C.Structure):
_fields_ = [('id', C.c_uint32), ('flags', C.c_uint32), ('name', C.c_char * 32)]
class Framebuffer(C.Structure):
_fields_ = [('id', C.c_uint32), ('width', C.c_uint32), ('height', C.c_uint32),
('format', C.c_uint32), ('modifier', C.c_uint64),
('flags', C.c_uint32), ('handles', C.c_uint32 * 4),
('pitches', C.c_uint32 * 4), ('offsets', C.c_uint32 * 4)]
class VersionPrefix(C.Structure):
_fields_ = [('major', C.c_int), ('minor', C.c_int), ('patch', C.c_int),
('name_len', C.c_int), ('name', C.c_char_p)]
class VirtioScanout:
def __init__(self, config):
if set(config) != {'backend', 'card', 'size'} or config['backend'] != 'kms-virtio-no-cursor-plane':
raise ValueError('unsupported KMS cursor configuration')
self.card = config['card']
if not isinstance(self.card, str) or not re.fullmatch('/dev/dri/card[0-9]+', self.card):
raise ValueError('invalid DRM card')
self.size = config['size']
if self.size != [2560, 1440]:
raise ValueError('unverified KMS geometry')
self.drm = C.CDLL('libdrm.so.2')
self.drm.drmSetClientCap.argtypes = [C.c_int, C.c_uint64, C.c_uint64]
for name, args, result in [
('drmModeGetPlaneResources', [C.c_int], C.POINTER(PlaneResources)),
('drmModeGetPlane', [C.c_int,C.c_uint32], C.POINTER(Plane)),
('drmModeObjectGetProperties', [C.c_int,C.c_uint32,C.c_uint32], C.POINTER(Properties)),
('drmModeGetProperty', [C.c_int,C.c_uint32], C.POINTER(PropertyPrefix)),
('drmModeGetFB2', [C.c_int,C.c_uint32], C.POINTER(Framebuffer)),
('drmGetVersion', [C.c_int], C.POINTER(VersionPrefix)),
]:
fn = getattr(self.drm, name); fn.argtypes = args; fn.restype = result
for name in ['drmModeFreePlaneResources','drmModeFreePlane',
'drmModeFreeObjectProperties','drmModeFreeProperty','drmModeFreeFB2',
'drmFreeVersion']:
fn = getattr(self.drm, name); fn.argtypes = [C.c_void_p]; fn.restype = None
def check(self):
fd = os.open(self.card, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW)
try:
version = self.drm.drmGetVersion(fd)
if not version: raise RuntimeError('DRM driver unavailable')
try:
if version.contents.name != b'virtio_gpu':
raise RuntimeError('KMS driver changed')
finally: self.drm.drmFreeVersion(version)
if self.drm.drmSetClientCap(fd, 2, 1):
raise RuntimeError('cannot enumerate universal planes')
# Match the pinned Sunshine KMS backend. Do not request capability
# 6 (cursor-plane hotspots), which would expose the virtio cursor.
if self.drm.drmSetClientCap(fd, 3, 1):
raise RuntimeError('atomic KMS is required')
resources = self.drm.drmModeGetPlaneResources(fd)
if not resources: raise RuntimeError('DRM plane list unavailable')
try:
# This tested virtio device exposes exactly one primary plane.
if resources.contents.count != 1:
raise RuntimeError('unverified DRM plane topology')
plane = self.drm.drmModeGetPlane(fd, resources.contents.planes[0])
if not plane: raise RuntimeError('DRM plane unavailable')
try:
p = plane.contents
props = self.drm.drmModeObjectGetProperties(fd, p.plane_id, 0xeeeeeeee)
if not props: raise RuntimeError('DRM plane properties unavailable')
kind = None
crtc_w = None
crtc_h = None
try:
if props.contents.count > 128: raise RuntimeError('invalid property count')
for i in range(props.contents.count):
prop = self.drm.drmModeGetProperty(fd, props.contents.props[i])
if not prop: raise RuntimeError('DRM property unavailable')
try:
name = prop.contents.name
value = props.contents.values[i]
if name == b'type': kind = value
elif name == b'CRTC_W': crtc_w = value
elif name == b'CRTC_H': crtc_h = value
finally: self.drm.drmModeFreeProperty(prop)
finally: self.drm.drmModeFreeObjectProperties(props)
# Atomic virtio clients expose CRTC geometry while the
# legacy plane fields remain zero. Sunshine uses the same
# atomic client capability pair and validates the actual
# framebuffer when it captures it.
if kind != 1 or [crtc_w, crtc_h] != self.size:
raise RuntimeError('scanout is not an active primary-only plane')
return {'driver': 'virtio_gpu', 'plane': p.plane_id, 'type': kind,
'crtc': int(p.crtc_id), 'size': self.size, 'cursor_planes': 0}
finally: self.drm.drmModeFreePlane(plane)
finally: self.drm.drmModeFreePlaneResources(resources)
finally: os.close(fd)
@@ -0,0 +1 @@
{"backend":"kms-virtio-no-cursor-plane","card":"/dev/dri/card1","size":[2560,1440]}
+158
View File
@@ -0,0 +1,158 @@
#pragma once
#include <QFile>
#include <QImage>
#include <QSaveFile>
#include <QtEndian>
#include <time.h>
#include "input/input.h"
#include "streamutils.h"
// Linux X11 single-seat native cursor. Called only by the SDL main thread.
// The producer starts cursor-free before this session's first captured frame.
class VdiNativeCursor {
public:
VdiNativeCursor(SDL_Window* window, SdlInputHandler* input) :
m_Window(window), m_Input(input), m_Path(qgetenv("VDI_NATIVE_CURSOR_STATE")),
m_Epoch(qgetenv("VDI_NATIVE_CURSOR_EPOCH").toULongLong()) {}
bool enabled() const { return !m_Path.isEmpty(); }
void observe(const SDL_Event& event) {
if (event.type == SDL_MOUSEMOTION || event.type == SDL_MOUSEBUTTONDOWN ||
event.type == SDL_MOUSEBUTTONUP) {
m_LastInput = nowNs();
++m_InputSequence;
}
}
bool tick() {
if (!enabled()) return true;
if (QByteArray(SDL_GetCurrentVideoDriver()) != "x11") return fail("requires X11 cursor backend");
const quint64 pollTime = nowNs();
if (pollTime - m_LastPoll < 4000000) return true;
m_LastPoll = pollTime;
QFile f(QString::fromUtf8(m_Path));
if (!f.open(QIODevice::ReadOnly) || f.size() < 76 || f.size() > 65608)
return fail("missing or oversized state");
const QByteArray packet = f.readAll();
// The producer may atomically replace state after pollTime. Compare
// against a timestamp taken AFTER the read, never before opening it.
const quint64 now = nowNs();
const auto* p = reinterpret_cast<const uchar*>(packet.constData());
auto u32 = [p](int offset) { return qFromLittleEndian<quint32>(p + offset); };
auto u64 = [p](int offset) { return qFromLittleEndian<quint64>(p + offset); };
if (packet.left(8) != QByteArray("VDICUR1\0", 8) || u64(8) != m_Epoch ||
u64(16) < m_Sequence || u64(24) > now || now - u64(24) > 2000000000)
return fail("stale or mismatched lease");
const int w = u32(32), h = u32(36), hx = u32(40), hy = u32(44);
const int x = qint32(u32(48)), y = qint32(u32(52));
const int sw = u32(56), sh = u32(60);
if (w < 1 || w > 128 || h < 1 || h > 128 || hx < 0 || hx >= w ||
hy < 0 || hy >= h || sw != 2560 || sh != 1440 ||
x < 0 || x >= sw || y < 0 || y >= sh || packet.size() != 72 + w*h*4 ||
!m_Input->isNativeCursorAbsoluteMode())
return fail("unsupported shape, display or input mode");
m_Sequence = u64(16);
int ww, wh;
SDL_GetWindowSize(m_Window, &ww, &wh);
SDL_Rect src = {0, 0, sw, sh}, dst = {0, 0, ww, wh};
StreamUtils::scaleSourceToDestinationSurface(&src, &dst);
if (dst.w <= 0 || dst.h <= 0) return fail("empty viewport");
const int cw = qMax(1, qRound(double(w) * dst.w / sw));
const int ch = qMax(1, qRound(double(h) * dst.h / sh));
if (cw > 256 || ch > 256) return fail("oversized scaled cursor");
const quint64 acknowledged = u64(64 + w*h*4);
const QByteArray pixels = packet.mid(64, w*h*4);
if (pixels != m_Pixels || w != m_Width || h != m_Height || hx != m_HotX ||
hy != m_HotY || dst.w != m_ViewWidth || dst.h != m_ViewHeight) {
QImage image(reinterpret_cast<const uchar*>(pixels.constData()), w, h,
w * 4, QImage::Format_RGBA8888);
image = image.scaled(cw, ch, Qt::IgnoreAspectRatio, Qt::FastTransformation);
// SDL2's X11 Xcursor backend copies pixels without premultiplying.
// XRender requires premultiplied channels, including antialiased edges.
image = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied);
SDL_Surface* surface = SDL_CreateRGBSurfaceWithFormatFrom(
image.bits(), cw, ch, 32, image.bytesPerLine(), SDL_PIXELFORMAT_RGBA32);
if (!surface) return fail("SDL surface failed");
SDL_Cursor* next = SDL_CreateColorCursor(surface,
qMin(cw - 1, qRound(double(hx) * dst.w / sw)),
qMin(ch - 1, qRound(double(hy) * dst.h / sh)));
SDL_FreeSurface(surface);
if (!next) return fail("SDL cursor failed");
SDL_SetCursor(next);
if (m_Cursor) SDL_FreeCursor(m_Cursor);
m_Cursor = next;
m_Pixels = pixels;
m_Width = w; m_Height = h; m_HotX = hx; m_HotY = hy;
m_ViewWidth = dst.w; m_ViewHeight = dst.h;
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION,
"VDI native shape: %dx%d hotspot=%d,%d sequence=%llu",
w, h, hx, hy, (unsigned long long)m_Sequence);
}
int mx, my;
const auto buttons = SDL_GetMouseState(&mx, &my);
const bool inside = mx >= dst.x && my >= dst.y && mx < dst.x + dst.w && my < dst.y + dst.h;
const bool focused = SDL_GetKeyboardFocus() == m_Window;
if (m_PublishedInput != m_InputSequence) {
QByteArray message(16, '\0');
qToLittleEndian<quint64>(m_InputSequence, reinterpret_cast<uchar*>(message.data()));
qToLittleEndian<qint32>(qBound(0, (mx-dst.x)*sw/dst.w, sw-1),
reinterpret_cast<uchar*>(message.data()+8));
qToLittleEndian<qint32>(qBound(0, (my-dst.y)*sh/dst.h, sh-1),
reinterpret_cast<uchar*>(message.data()+12));
QSaveFile outgoing(QString::fromUtf8(m_Path)+".input");
if (!outgoing.open(QIODevice::WriteOnly) || outgoing.write(message) != 16 || !outgoing.commit())
return fail("input acknowledgement file failed");
m_PublishedInput = m_InputSequence;
}
m_Input->enableNativeCursorVisibility();
SDL_SetCursor(focused && inside ? m_Cursor : SDL_GetDefaultCursor());
// Initial alignment, and host-initiated warps only after local input has
// settled. Never pull the pointer back using delayed motion echoes.
if (focused && inside && !buttons && acknowledged == m_InputSequence &&
now - m_LastInput > 500000000) {
const int tx = dst.x + qRound(double(x) * dst.w / sw);
const int ty = dst.y + qRound(double(y) * dst.h / sh);
if (qAbs(tx - mx) > 2 || qAbs(ty - my) > 2) {
// SDL2's X11 in-window warp is a no-op with pointer barriers
// (used by fullscreen absolute mode). Global warp honors this
// in-viewport target while actually moving the native pointer.
int wx, wy;
SDL_GetWindowPosition(m_Window, &wx, &wy);
if (SDL_WarpMouseGlobal(wx+tx, wy+ty) < 0)
return fail("host cursor warp failed");
m_LastInput = now;
}
}
return true;
}
void shutdown() {
if (m_Cursor) {
SDL_SetCursor(SDL_GetDefaultCursor());
SDL_FreeCursor(m_Cursor);
m_Cursor = nullptr;
}
}
private:
static quint64 nowNs() {
struct timespec t;
clock_gettime(CLOCK_MONOTONIC, &t);
return quint64(t.tv_sec)*1000000000 + t.tv_nsec;
}
bool fail(const char* reason) {
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "VDI native cursor ended: %s", reason);
shutdown();
return false;
}
SDL_Window* m_Window;
SdlInputHandler* m_Input;
QByteArray m_Path, m_Pixels;
quint64 m_Epoch, m_Sequence = 0, m_LastPoll = 0, m_LastInput = nowNs();
quint64 m_InputSequence = 0, m_PublishedInput = 0;
SDL_Cursor* m_Cursor = nullptr;
int m_Width = 0, m_Height = 0, m_HotX = 0, m_HotY = 0;
int m_ViewWidth = 0, m_ViewHeight = 0;
};
+3 -1
View File
@@ -18,10 +18,12 @@ cargo test --offline
cargo build --release --offline
target/release/vdi-usb-monitor list
sudo target/release/vdi-usb-monitor serve \
--any-storage --listen 127.0.0.1:40001
--any-storage --listen 127.0.0.1:40001 --status 127.0.0.1:40002
```
`list` 输出 JSON。`serve --any-storage` 只接受纯 USB Mass Storage 类设备,
`--status` 提供仅监听回环地址的 ready/empty 状态给 PVE watcher;没有可导出的存储设备时,
PVE 会移除 Windows 中的虚拟 USB 端点,避免托盘残留空设备图标。
只能监听 IPv4 回环地址;拒绝键鼠、Hub、包含非存储接口的复合设备以及本地尚在使用
的磁盘。仍可使用 `--vendor/--product/--serial` 保留旧的精确设备策略。
本地已挂载时不会强制卸载,应先关闭本地文件操作并正常卸载。
@@ -5,7 +5,7 @@ After=network.target systemd-udevd.service
[Service]
Type=simple
EnvironmentFile=/etc/vdi-usb-monitor/device.env
ExecStart=/usr/local/libexec/vdi-usb-monitor serve --any-storage --listen 127.0.0.1:40001
ExecStart=/usr/local/libexec/vdi-usb-monitor serve --any-storage --listen 127.0.0.1:40001 --status 127.0.0.1:40002
Restart=on-failure
RestartSec=3
KillMode=control-group
+47 -3
View File
@@ -1,7 +1,8 @@
//! Device-level USB redirection. Never calls the hypervisor API or exports HID devices.
use std::{collections::BTreeSet, env, fs, io::{BufRead, BufReader}, net::SocketAddr,
use std::{collections::BTreeSet, env, fs, io::{BufRead, BufReader, Write}, net::{SocketAddr, TcpListener},
path::{Path, PathBuf}, process::{self, Child, Command, Stdio},
sync::mpsc::{self, Receiver}, thread, time::{Duration, Instant, SystemTime, UNIX_EPOCH}};
use std::sync::{Arc, atomic::{AtomicBool, Ordering}};
// udev delivers completed USB changes; the timeout also covers startup and a
// stopped event monitor. The monitor never runs a command supplied by a device.
@@ -167,6 +168,22 @@ enum MatchPolicy {
#[derive(Debug)]
struct Policy { matcher: MatchPolicy, listen: SocketAddr }
// This loopback-only status endpoint lets the PVE watcher know whether a
// physical storage device is ready before creating the guest usb-redir device.
// The USB data channel remains separate and is never exposed on the LAN.
fn start_status_server(addr: SocketAddr, ready: Arc<AtomicBool>) -> Result<(), String> {
let listener = TcpListener::bind(addr).map_err(|e| format!("status listener: {e}"))?;
thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { continue };
let state = if ready.load(Ordering::Acquire) { "ready\n" } else { "empty\n" };
let _ = stream.write_all(state.as_bytes());
let _ = stream.shutdown(std::net::Shutdown::Both);
}
});
Ok(())
}
fn parse_policy(args: &[String]) -> Result<Policy, String> {
let mut any_storage = false;
let mut vendor = None;
@@ -262,14 +279,37 @@ fn run() -> Result<(), String> {
return Ok(());
}
if args.first().map(String::as_str) != Some("serve") {
return Err("usage: vdi-usb-monitor list | serve (--any-storage | --vendor XXXX --product XXXX --serial SERIAL) --listen 127.0.0.1:PORT".into());
return Err("usage: vdi-usb-monitor list | serve (--any-storage | --vendor XXXX --product XXXX --serial SERIAL) --listen 127.0.0.1:PORT [--status 127.0.0.1:PORT]".into());
}
let policy = parse_policy(&args[1..])?;
let mut policy_args = Vec::new();
let mut status_addr = None;
let mut i = 1;
while i < args.len() {
if args[i] == "--status" {
if i + 1 >= args.len() { return Err("--status requires an address".into()); }
if status_addr.is_some() { return Err("duplicate --status".into()); }
let addr: SocketAddr = args[i + 1].parse().map_err(|_| "invalid status address")?;
if !addr.ip().is_loopback() || !addr.is_ipv4() || addr.port() == 0 {
return Err("status must be an IPv4 loopback listener with a nonzero port".into());
}
status_addr = Some(addr);
i += 2;
} else {
policy_args.push(args[i].clone());
i += 1;
}
}
let policy = parse_policy(&policy_args)?;
let redirect_binary = env::var("VDI_USBREDIRECT_BINARY").unwrap_or_else(|_| "/usr/bin/usbredirect".into());
if !Path::new(&redirect_binary).is_absolute() || !Path::new(&redirect_binary).is_file() {
return Err("VDI_USBREDIRECT_BINARY must name an installed absolute executable path".into());
}
log_event(&format!("USB exporter: {redirect_binary}"));
let ready = Arc::new(AtomicBool::new(false));
if let Some(addr) = status_addr {
start_status_server(addr, ready.clone())?;
log_event(&format!("USB status listener: {addr}"));
}
let mut events = UsbEvents::start();
let mut last = String::new();
loop {
@@ -286,12 +326,15 @@ fn run() -> Result<(), String> {
let mut child = Command::new(&redirect_binary)
.args(["--device", &selector, "--as", &policy.listen.to_string(), "--keepalive"])
.spawn().map_err(|e| e.to_string())?;
ready.store(true, Ordering::Release);
loop {
if let Some(status) = child.try_wait().map_err(|e| e.to_string())? {
ready.store(false, Ordering::Release);
return Err(format!("export session ended: {status}; waiting before retry"));
}
events.wait(Duration::from_secs(1));
if !still_present(device) {
ready.store(false, Ordering::Release);
let _ = child.kill();
let _ = child.wait();
log_event(&format!("storage {} disconnected", device.bus_id));
@@ -299,6 +342,7 @@ fn run() -> Result<(), String> {
}
}
});
ready.store(false, Ordering::Release);
if let Err(message) = result {
if message != last { log_event(&message); last = message; }
}