功能:接入Linux原生光标并完善设备接入工具
This commit is contained in:
1 parent
a14f028402
commit
a47968496e
92 files changed
+3090
-22
No files matched your search
@@ -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" "$@"
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
+};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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:])
|
||||
@@ -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()
|
||||
@@ -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]}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user