功能:完善2K串流、显示恢复与输入优化

This commit is contained in:
songyiming committed 2026-11-03 13:43:50 +08:00
1 parent e47b01beec
commit 938ec97cce
95 files changed
+5788 -72

No files matched your search

+5
View File
@@ -55,6 +55,11 @@ target_link_libraries(vdi-kiosk-monitor PRIVATE Threads::Threads)
if(BUILD_TESTING)
find_package(Qt5 REQUIRED COMPONENTS Test)
if(UNIX)
find_package(Python3 REQUIRED COMPONENTS Interpreter)
add_test(NAME display-guard COMMAND ${Python3_EXECUTABLE}
${CMAKE_CURRENT_SOURCE_DIR}/tests/test_display_guard.py)
endif()
add_executable(test-moonlight-session
tests/test_moonlight_session.cpp
@@ -0,0 +1,4 @@
# RK3528 vendor-kernel media interfaces. Members of video may use the VPU.
KERNEL=="mpp_service", GROUP="video", MODE="0660"
KERNEL=="rga", GROUP="video", MODE="0660"
SUBSYSTEM=="dma_heap", GROUP="video", MODE="0660"
+2
View File
@@ -18,6 +18,7 @@
# NVENC重启故障及各档性能记录见 docs/moonlight-kiosk-20261028.md。
# frame-pacing 1=开启帧率匀速(--frame-pacing),0=关闭(--no-frame-pacing)
# performance-overlay 1=显示性能叠图(调试用)
# profile smooth / low-latency / diagnostic;为空时保留下方显式开关
# hang-timeout 串流假死判定时间(秒),0 表示关闭该检测
host=192.168.1.70
port=47984
@@ -34,6 +35,7 @@ absolute-mouse=1
vsync=1
frame-pacing=1
performance-overlay=0
profile=smooth
retry=3
retry-delay=5
hang-timeout=90
@@ -0,0 +1,14 @@
[Unit]
Description=VDI HDMI mode recovery after monitor power cycle (X11 POC)
[Service]
Type=simple
Environment=DISPLAY=:0
Environment=XAUTHORITY=%h/.Xauthority
ExecStart=%h/vdi-terminal/bin/vdi-display-guard
Restart=on-failure
RestartSec=5
TimeoutStopSec=15
[Install]
WantedBy=default.target
@@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""Restore this RK3528 terminal's verified HDMI timing after X11 hotplug."""
import argparse
import fcntl
import os
from pathlib import Path
import re
import signal
import subprocess
import sys
import time
from dataclasses import dataclass
OUTPUT = "HDMI-1"
MODE = "2560x1440_60_fixed297"
# The vendor PHY rejects the EDID's 241.70 MHz timing. Keep the verified PLL.
TIMING = "297.00 2560 2608 2640 2720 1440 1443 1448 1820 +HSync -VSync".split()
POLL_SECONDS = 2
SETTLE_SECONDS = 2
def log(message):
print(time.strftime("%Y-%m-%d %H:%M:%S"), message, flush=True)
def xrandr(*args):
result = subprocess.run(["xrandr", *args], capture_output=True, text=True,
timeout=10, check=False)
if result.returncode:
raise RuntimeError((result.stderr or result.stdout).strip()[:400])
return result.stdout
@dataclass
class DisplayState:
connected: bool
geometry: str
active_mode: str
modes: set
defined: bool
@property
def healthy(self):
return (self.connected and self.geometry == "2560x1440+0+0"
and self.active_mode == MODE)
def parse_state(query):
connected, geometry, active = False, "", ""
modes, defined, in_output = set(), False, False
for line in query.splitlines():
fields = line.split()
if not fields:
continue
if fields[0] == MODE:
defined = True # Includes a globally defined but detached mode.
if not line[0].isspace():
in_output = fields[0] == OUTPUT
if in_output:
connected = len(fields) > 1 and fields[1] == "connected"
geometry = next((f for f in fields[2:]
if re.fullmatch(r"\d+x\d+\+\d+\+\d+", f)), "")
elif in_output and len(fields) > 1 and re.match(r"^\d+(?:\.\d+)?[+*]*$", fields[1]):
modes.add(fields[0])
if "*" in " ".join(fields[1:]):
active = fields[0]
return DisplayState(connected, geometry, active, modes, defined)
class Guard:
def __init__(self, runner=xrandr, reporter=log):
self.run = runner
self.log = reporter
self.bad_since = None
self.next_retry = 0
self.failures = 0
self.last_status = None
def report(self, status):
if status != self.last_status:
self.log(status)
self.last_status = status
def step(self, now):
try:
# Xorg already receives DRM hotplug events. Read its current state
# without probing every EDID/mode: the vendor driver logs clock
# warnings for those probes, adding unnecessary work while streaming.
state = parse_state(self.run("--current"))
except (OSError, RuntimeError, subprocess.TimeoutExpired) as error:
self.bad_since = None
self.report("X11 unavailable; waiting: " + str(error)[:400])
return False
if not state.connected:
self.bad_since = None
self.next_retry = 0
self.failures = 0
self.report(OUTPUT + " disconnected; no mode changes")
return False
if state.healthy:
self.bad_since = None
self.next_retry = 0
self.failures = 0
self.report("healthy: " + MODE)
return True
self.report("mode drift: " + (state.active_mode or "off") + " " + state.geometry)
if self.bad_since is None:
self.bad_since = now
if now - self.bad_since < SETTLE_SECONDS or now < self.next_retry:
return False
try:
if not state.defined:
self.run("--newmode", MODE, *TIMING)
if MODE not in state.modes:
self.run("--addmode", OUTPUT, MODE)
self.run("--output", OUTPUT, "--mode", MODE, "--pos", "0x0")
if not parse_state(self.run("--current")).healthy:
raise RuntimeError("mode was not retained after apply")
except (OSError, RuntimeError, subprocess.TimeoutExpired) as error:
self.failures = min(self.failures + 1, 6)
delay = min(60, 2 ** self.failures)
# Base backoff on completion, including a slow/timed-out modeset.
self.next_retry = time.monotonic() + delay
self.log("restore failed; retry in %ds: %s" % (delay, str(error)[:400]))
return False
self.bad_since = None
self.next_retry = 0
self.failures = 0
self.report("restored: " + MODE + " (stream process untouched)")
return True
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--once", action="store_true",
help="initial display recovery with a 20s retry window, then exit")
args = parser.parse_args()
os.environ.setdefault("DISPLAY", ":0")
os.environ.setdefault("XAUTHORITY", str(Path.home() / ".Xauthority"))
runtime = Path(os.environ.get("XDG_RUNTIME_DIR", str(Path.home() / ".cache/vdi-terminal")))
runtime.mkdir(parents=True, exist_ok=True)
# Same lock for startup and the systemd service. Never run competing guards.
with (runtime / "vdi-display-guard.lock").open("a") as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
log("display guard already running")
return 0
stop = False
def request_stop(signum, frame):
nonlocal stop
stop = True
signal.signal(signal.SIGTERM, request_stop)
signal.signal(signal.SIGINT, request_stop)
guard = Guard()
deadline = time.monotonic() + 20
while not stop:
if guard.step(time.monotonic()) and args.once:
return 0
if args.once and time.monotonic() >= deadline:
return 1
time.sleep(POLL_SECONDS)
return 0
if __name__ == "__main__":
sys.exit(main())
+22 -20
View File
@@ -3,26 +3,28 @@
export DISPLAY="${DISPLAY:-:0}"
export XAUTHORITY="${XAUTHORITY:-/home/armbian/.Xauthority}"
# Match the local output to the 2K Moonlight stream.
# This kernel rejects the monitor's 241.70 MHz timing but accepts the 297 MHz
# PLL rate. Longer vertical blanking produces 2560x1440 at approximately 60 Hz.
# HDMI can appear a few seconds after the desktop autostart entry runs.
: >/tmp/vdi-display-mode.log
mode="2560x1440_60_fixed297"
for _ in $(seq 1 20); do
if xrandr --query 2>>/tmp/vdi-display-mode.log | grep -q '^HDMI-1 connected'; then
xrandr --newmode "$mode" 297.00 \
2560 2608 2640 2720 1440 1443 1448 1820 \
+HSync -VSync >>/tmp/vdi-display-mode.log 2>&1 || true
xrandr --addmode HDMI-1 "$mode" >>/tmp/vdi-display-mode.log 2>&1 || true
if xrandr --output HDMI-1 --mode "$mode" >>/tmp/vdi-display-mode.log 2>&1; then
break
fi
xrandr --output HDMI-1 --mode 1920x1080 --rate 60 \
>>/tmp/vdi-display-mode.log 2>&1 || true
fi
sleep 1
done
# Optional verified rendering deployment. Parse an allowlist as data; never
# source/eval configuration. Explicit environment overrides the saved defaults.
render_conf="${VDI_RENDER_CONF:-$HOME/.config/vdi-terminal/render.conf}"
if [ -r "$render_conf" ]; then
while IFS='=' read -r key value || [ -n "$key" ]; do
case "$key" in
profile) export VDI_STREAM_PROFILE="${VDI_STREAM_PROFILE:-$value}" ;;
stats) export VDI_STREAM_STATS="${VDI_STREAM_STATS:-$value}" ;;
program) export VDI_MOONLIGHT_PROGRAM="${VDI_MOONLIGHT_PROGRAM:-$value}" ;;
decoder) export VDI_MOONLIGHT_DECODER="${VDI_MOONLIGHT_DECODER:-$value}" ;;
codec) export VDI_MOONLIGHT_CODEC="${VDI_MOONLIGHT_CODEC:-$value}" ;;
esac
done < "$render_conf"
fi
# Use the same idempotent recovery for boot and HDMI hotplug. The user service
# supervises ongoing recovery; a singleton lock prevents competing instances.
"$(dirname "$0")/vdi-display-guard" --once >>/tmp/vdi-display-mode.log 2>&1 || true
if ! systemctl --user start vdi-display-guard.service >>/tmp/vdi-display-mode.log 2>&1; then
echo 'WARNING: display guard service unavailable; only boot recovery active' \
>>/tmp/vdi-display-mode.log
fi
exec /home/armbian/vdi-terminal/bin/vdi-kiosk-monitor -- \
/home/armbian/vdi-terminal/bin/kiosk-shell
+54 -7
View File
@@ -3,7 +3,7 @@
#
# 职责:
# 1. 读取本地配置(主机、应用、分辨率、帧率、码率、编解码)
# 2. 以软件解码拉起 moonlight-qt,全屏串流云桌面
# 2. 按配置拉起 Moonlight,全屏串流云桌面
# 3. 写心跳文件,供 vdi-kiosk-monitor 判断是否假死
# 4. 检测串流假死(CPU 与网络均无流量)并杀掉重连
#
@@ -12,7 +12,8 @@
#
# 说明:RK3528 当前没有可用的 V4L2 request/m2m 硬解设备,moonlight-qt 的
# --video-decoder auto 会选到 h264_v4l2m2m 并报 "Could not find a valid device",
# 因此配置里显式指定 decoder=software。接入 MPP/VPU 硬解后再改回 auto。
# 因此系统客户端默认 decoder=software;验证后的独立 RKMPP 客户端
# 通过 VDI_MOONLIGHT_PROGRAM 与 decoder=hardware 显式启用。
set -u
@@ -38,9 +39,10 @@ DECODER="software"
DISPLAY_MODE="fullscreen"
AUDIO=1
ABSOLUTE_MOUSE=1
VSYNC=0
FRAME_PACING=0
VSYNC=1
FRAME_PACING=1
PERFORMANCE_OVERLAY=0
PROFILE=""
RETRY=3
RETRY_DELAY=5
HANG_TIMEOUT=90
@@ -76,6 +78,7 @@ load_config() {
vsync) VSYNC="$value" ;;
frame-pacing) FRAME_PACING="$value" ;;
performance-overlay) PERFORMANCE_OVERLAY="$value" ;;
profile) PROFILE="$value" ;;
retry) RETRY="$value" ;;
retry-delay) RETRY_DELAY="$value" ;;
hang-timeout) HANG_TIMEOUT="$value" ;;
@@ -84,6 +87,33 @@ load_config() {
done < "$CONF"
}
apply_profile() {
case "$PROFILE" in
smooth)
VSYNC=1
FRAME_PACING=1
;;
low-latency)
VSYNC=0
FRAME_PACING=0
;;
diagnostic)
VSYNC=1
FRAME_PACING=1
PERFORMANCE_OVERLAY=1
;;
"")
# 兼容旧配置:没有profile时保留moonlight.conf中的显式开关。
;;
*)
log "未知串流 profile=$PROFILE,回退 smooth"
PROFILE=smooth
VSYNC=1
FRAME_PACING=1
;;
esac
}
# 默认路由出接口的累计收字节数,用于判断串流是否还有数据流入
rx_bytes() {
iface=$(awk '$2=="00000000" {print $1; exit}' /proc/net/route)
@@ -145,7 +175,7 @@ disable_screensaver() {
build_cmd() {
# moonlight-qt stream 只接受 <host> <app> 位置参数,没有 --port 选项,
# 服务端端口由 moonlight 自身按 GameStream 协议协商。
CMD="moonlight-qt stream $HOST $APP"
CMD="${VDI_MOONLIGHT_PROGRAM:-moonlight-qt} stream $HOST $APP"
CMD="$CMD --resolution ${WIDTH}x${HEIGHT}"
CMD="$CMD --fps $FPS"
CMD="$CMD --bitrate $BITRATE"
@@ -155,7 +185,7 @@ build_cmd() {
CMD="$CMD --keep-awake"
[ "$ABSOLUTE_MOUSE" = "1" ] && CMD="$CMD --absolute-mouse"
[ "$AUDIO" = "1" ] && CMD="$CMD --audio-on-host" || CMD="$CMD --no-audio-on-host"
# 低延迟开关:默认关闭 vsync 与 frame-pacing,削减帧队列/呈现延迟
# 按profile选择同步策略;默认保持当前稳定档。
[ "$VSYNC" = "1" ] && CMD="$CMD --vsync" || CMD="$CMD --no-vsync"
[ "$FRAME_PACING" = "1" ] && CMD="$CMD --frame-pacing" || CMD="$CMD --no-frame-pacing"
[ "$PERFORMANCE_OVERLAY" = "1" ] && CMD="$CMD --performance-overlay"
@@ -163,7 +193,7 @@ build_cmd() {
run_stream() {
build_cmd
log "启动串流: $CMD"
log "启动串流 profile=${PROFILE:-config} decoder=$DECODER codec=$CODEC vsync=$VSYNC frame-pacing=$FRAME_PACING renderer=${SDL_RENDER_DRIVER:-} video-driver=${SDL_VIDEODRIVER:-}: $CMD"
# shellcheck disable=SC2086
$CMD >> "$LOG_FILE" 2>&1 &
ML_PID=$!
@@ -220,6 +250,19 @@ stop)
esac
load_config
PROFILE="${VDI_STREAM_PROFILE:-$PROFILE}"
apply_profile
DECODER="${VDI_MOONLIGHT_DECODER:-$DECODER}"
CODEC="${VDI_MOONLIGHT_CODEC:-$CODEC}"
case "$DECODER" in
software|hardware|auto) ;;
*) log "未知解码器 $DECODER,回退 software"; DECODER=software ;;
esac
case "$CODEC" in
H.264|HEVC|AV1|auto) ;;
*) log "未知视频编码 $CODEC,回退 H.264"; CODEC=H.264 ;;
esac
PERFORMANCE_OVERLAY="${VDI_STREAM_STATS:-$PERFORMANCE_OVERLAY}"
if [ -z "$HOST" ]; then
log "配置缺失或缺少 host: $CONF"
exit 2
@@ -227,6 +270,10 @@ fi
export DISPLAY="${VDI_DISPLAY:-:0}"
export XAUTHORITY="${VDI_XAUTHORITY:-$HOME/.Xauthority}"
export SDL_RENDER_DRIVER="${VDI_SDL_RENDER_DRIVER:-opengles2}"
export SDL_VIDEO_X11_NET_WM_BYPASS_COMPOSITOR="${VDI_SDL_BYPASS_COMPOSITOR:-1}"
[ -n "${VDI_SDL_FORCE_EGL:-}" ] && export SDL_VIDEO_X11_FORCE_EGL="$VDI_SDL_FORCE_EGL"
[ -n "${VDI_SDL_VIDEODRIVER:-}" ] && export SDL_VIDEODRIVER="$VDI_SDL_VIDEODRIVER"
disable_screensaver
kill_stale
@@ -0,0 +1,20 @@
#!/bin/sh
# Select explicitly after RKMPP decode + real stream validation. Does not replace
# the distribution Moonlight or add a second cursor.
set -eu
task_root="${VDI_RKMPP_BUILD_ROOT:-$HOME/vdi-build/rkmpp-experiment}"
client="${VDI_RKMPP_CLIENT:-$task_root/build/moonlight/app/moonlight}"
if [ ! -x "$client" ] || [ ! -r /dev/mpp_service ] || [ ! -w /dev/mpp_service ]; then
echo 'RKMPP client or device access unavailable' >&2
exit 78
fi
export LD_LIBRARY_PATH="$task_root/runtime/lib:$task_root/deps/usr/lib/aarch64-linux-gnu${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
export QML2_IMPORT_PATH="$task_root/deps/usr/lib/aarch64-linux-gnu/qt5/qml${QML2_IMPORT_PATH:+:$QML2_IMPORT_PATH}"
export QT_PLUGIN_PATH="$task_root/deps/usr/lib/aarch64-linux-gnu/qt5/plugins${QT_PLUGIN_PATH:+:$QT_PLUGIN_PATH}"
export GL_IS_SLOW="${GL_IS_SLOW:-0}"
export H264_DECODER_HINT=h264_rkmpp
export HEVC_DECODER_HINT=hevc_rkmpp
# Only affects unpaced rendering in the patched client. Leave the last presented
# frame and decoder references intact; replace only waiting decoded frames.
export VDI_LATEST_FRAME="${VDI_LATEST_FRAME:-1}"
exec "$client" "$@"
@@ -82,10 +82,10 @@ void DesktopListPage::rebuildCards() {
for (const DesktopInfo& d : desktops_) {
auto* card = new QFrame(this);
card->setObjectName(QStringLiteral("desktopCard"));
card->setMinimumHeight(96);
card->setStyleSheet(QStringLiteral(
"QFrame { background: #1a2233; border: 1px solid #2b3654; border-radius: 10px; }"
"QFrame:hover { border: 1px solid #2f6feb; }"
"QFrame#desktopCard { background: #1a2233; border: 1px solid #2b3654; border-radius: 10px; }"
"QLabel { border: none; background: transparent; }"));
auto* row = new QHBoxLayout(card);
+2 -6
View File
@@ -64,7 +64,6 @@ void MainWindow::setupUi() {
QStringLiteral("QWidget { background: #0f1420; color: #e6e9ef; }"
"QLineEdit { background: #1a2233; border: 1px solid #2b3654; "
"border-radius: 4px; padding: 8px; color: #e6e9ef; }"
"QLineEdit:focus { border: 1px solid #2f6feb; }"
"QPushButton { background: #232d44; border: 1px solid #2b3654; "
"border-radius: 6px; color: #e6e9ef; }"
"QPushButton:hover { background: #2b3654; }"));
@@ -289,11 +288,8 @@ void MainWindow::onEditDesktopRequested(const QString& desktopId) {
}
bool MainWindow::startMoonlightSession(const ConnectionTicket& ticket, quint64 attempt) {
MoonlightSession::Options opts;
opts.decoder = QStringLiteral("software");
opts.videoCodec = QStringLiteral("H.264");
opts.audioOnHost = true;
moonlightSession_->setOptions(opts);
moonlightSession_->setOptions(MoonlightSession::optionsFromEnvironment(
QProcessEnvironment::systemEnvironment()));
phaseClock_.restart();
connectionPhase_ = ConnectionPhase::kProcessStart;
+98 -25
View File
@@ -14,7 +14,11 @@ constexpr int kReadyStableMs = 750;
constexpr int kTerminateGraceMs = 2000;
bool isTestDecodeLine(const QByteArray& line) {
return line.toLower().contains("test decode successful");
const QByteArray lower = line.toLower();
// Hardware decoders use a separate test context. Upstream logs this only
// after that context has successfully decoded and validated a test frame.
return lower.contains("test decode successful") ||
lower.contains("not reusing test decoder for ");
}
bool isRendererLine(const QByteArray& line) {
@@ -105,6 +109,66 @@ void MoonlightSession::setOptions(const Options& options) {
options_ = options;
}
MoonlightSession::Options MoonlightSession::optionsFromEnvironment(
const QProcessEnvironment& environment) {
Options options;
const QString profile = environment.value(QStringLiteral("VDI_STREAM_PROFILE"));
if (profile == QStringLiteral("low-latency")) {
options.profile = profile;
options.vsync = false;
options.framePacing = false;
} else if (profile == QStringLiteral("diagnostic")) {
options.profile = profile;
options.performanceOverlay = true;
}
// Statistics must be independently selectable for a fair profile A/B test.
if (environment.contains(QStringLiteral("VDI_STREAM_STATS"))) {
options.performanceOverlay = environment.value(QStringLiteral("VDI_STREAM_STATS")) == QStringLiteral("1");
}
const QString decoder = environment.value(QStringLiteral("VDI_MOONLIGHT_DECODER"));
if (QStringList{QStringLiteral("software"), QStringLiteral("hardware"),
QStringLiteral("auto")}.contains(decoder)) {
options.decoder = decoder;
}
const QString codec = environment.value(QStringLiteral("VDI_MOONLIGHT_CODEC"));
if (QStringList{QStringLiteral("H.264"), QStringLiteral("HEVC"),
QStringLiteral("AV1"), QStringLiteral("auto")}.contains(codec)) {
options.videoCodec = codec;
}
const QString program = environment.value(QStringLiteral("VDI_MOONLIGHT_PROGRAM"));
if (!program.isEmpty()) {
options.program = program;
}
return options;
}
QStringList MoonlightSession::buildStreamArguments(const ConnectionTicket& ticket,
const Options& options) {
const int bitrateKbps = ticket.width >= 2560 ? 30000 : 20000;
QStringList args;
args << QStringLiteral("stream")
<< ticket.endpoint
<< ticket.app
<< QStringLiteral("--resolution")
<< QStringLiteral("%1x%2").arg(ticket.width).arg(ticket.height)
<< QStringLiteral("--fps") << QString::number(ticket.fps)
<< QStringLiteral("--bitrate") << QString::number(bitrateKbps)
<< QStringLiteral("--display-mode") << QStringLiteral("fullscreen")
<< QStringLiteral("--video-codec") << options.videoCodec
<< QStringLiteral("--video-decoder") << options.decoder
<< QStringLiteral("--absolute-mouse")
<< QStringLiteral("--keep-awake")
<< (options.vsync ? QStringLiteral("--vsync") : QStringLiteral("--no-vsync"))
<< (options.framePacing ? QStringLiteral("--frame-pacing")
: QStringLiteral("--no-frame-pacing"));
if (options.performanceOverlay) {
args << QStringLiteral("--performance-overlay");
}
args << (options.audioOnHost ? QStringLiteral("--audio-on-host")
: QStringLiteral("--no-audio-on-host"));
return args;
}
bool MoonlightSession::isRunning() const {
return process_->state() != QProcess::NotRunning;
}
@@ -156,34 +220,26 @@ bool MoonlightSession::start(const ConnectionTicket& ticket, EventCallback callb
readyTimer_->stop();
killTimer_->stop();
process_->setProgram(QStringLiteral("moonlight-qt"));
process_->setProgram(options_.program);
QProcessEnvironment environment = QProcessEnvironment::systemEnvironment();
environment.insert(QStringLiteral("GL_IS_SLOW"), QStringLiteral("0"));
environment.insert(QStringLiteral("SDL_RENDER_DRIVER"), QStringLiteral("opengles2"));
environment.insert(QStringLiteral("SDL_RENDER_DRIVER"),
environment.value(QStringLiteral("VDI_SDL_RENDER_DRIVER"), QStringLiteral("opengles2")));
// Xorg下绕过桌面合成器;Wayland/KMS部署时可通过SDL_VIDEODRIVER切换,
// 不在客户端里硬编码会话类型,避免当前Xorg镜像无法启动。
environment.insert(QStringLiteral("SDL_VIDEO_X11_NET_WM_BYPASS_COMPOSITOR"),
environment.value(QStringLiteral("VDI_SDL_BYPASS_COMPOSITOR"), QStringLiteral("1")));
const QString forceEgl = qEnvironmentVariable("VDI_SDL_FORCE_EGL");
if (!forceEgl.isEmpty()) {
environment.insert(QStringLiteral("SDL_VIDEO_X11_FORCE_EGL"), forceEgl);
}
const QString sdlVideoDriver = qEnvironmentVariable("VDI_SDL_VIDEODRIVER");
if (!sdlVideoDriver.isEmpty()) {
environment.insert(QStringLiteral("SDL_VIDEODRIVER"), sdlVideoDriver);
}
process_->setProcessEnvironment(environment);
const int bitrateKbps = ticket.width >= 2560 ? 30000 : 20000;
QStringList args;
args << QStringLiteral("stream")
<< ticket.endpoint
<< ticket.app
<< QStringLiteral("--resolution")
<< QStringLiteral("%1x%2").arg(ticket.width).arg(ticket.height)
<< QStringLiteral("--fps") << QString::number(ticket.fps)
<< QStringLiteral("--bitrate") << QString::number(bitrateKbps)
<< QStringLiteral("--display-mode") << QStringLiteral("fullscreen")
<< QStringLiteral("--video-codec") << options_.videoCodec
<< QStringLiteral("--video-decoder") << options_.decoder
<< QStringLiteral("--absolute-mouse")
<< QStringLiteral("--keep-awake");
args << (options_.vsync ? QStringLiteral("--vsync") : QStringLiteral("--no-vsync"));
args << (options_.framePacing ? QStringLiteral("--frame-pacing")
: QStringLiteral("--no-frame-pacing"));
if (options_.performanceOverlay) {
args << QStringLiteral("--performance-overlay");
}
args << (options_.audioOnHost ? QStringLiteral("--audio-on-host")
: QStringLiteral("--no-audio-on-host"));
const QStringList args = buildStreamArguments(ticket, options_);
process_->setArguments(args);
const QString logDir = QDir::homePath() + QStringLiteral("/vdi-terminal/logs");
@@ -191,6 +247,23 @@ bool MoonlightSession::start(const ConnectionTicket& ticket, EventCallback callb
logPath_ = logDir + QStringLiteral("/moonlight-%1.log")
.arg(QDate::currentDate().toString(QStringLiteral("yyyyMMdd")));
// 把最终生效的环境和参数写入日志,避免只看Moonlight叠图时无法判断
// 产品入口到底使用了哪一套呈现策略。
QFile launchLog(logPath_);
if (launchLog.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text)) {
QTextStream stream(&launchLog);
stream << "\n[VDI launch]\n"
<< "profile=" << options_.profile << "\n"
<< "program=" << options_.program << "\n"
<< "decoder=" << options_.decoder << "\n"
<< "codec=" << options_.videoCodec << "\n"
<< "vsync=" << (options_.vsync ? "1" : "0") << "\n"
<< "frame-pacing=" << (options_.framePacing ? "1" : "0") << "\n"
<< "renderer=" << environment.value(QStringLiteral("SDL_RENDER_DRIVER")) << "\n"
<< "video-driver=" << environment.value(QStringLiteral("SDL_VIDEODRIVER")) << "\n"
<< "args=" << args.join(QLatin1Char(' ')) << "\n";
}
process_->start();
return true;
}
@@ -8,6 +8,8 @@
#include <QByteArray>
#include <QProcess>
#include <QProcessEnvironment>
#include <QStringList>
#include <QString>
#include <functional>
@@ -37,7 +39,13 @@ class MoonlightSession : public QObject {
Q_OBJECT
public:
// 产品入口与调试脚本遵循相同的profile语义。
// smooth:优先稳定呈现,适合当前2K20/2K30;
// low-latency:关闭客户端额外的同步等待,适合1080p60 A/B;
// diagnostic:在smooth基础上打开Moonlight性能叠图。
struct Options {
QString program = QStringLiteral("moonlight-qt");
QString profile = QStringLiteral("smooth");
bool vsync = true;
bool framePacing = true;
bool performanceOverlay = false;
@@ -64,6 +72,9 @@ public:
~MoonlightSession() override;
void setOptions(const Options& options);
static Options optionsFromEnvironment(const QProcessEnvironment& environment);
static QStringList buildStreamArguments(const ConnectionTicket& ticket,
const Options& options);
bool start(const ConnectionTicket& ticket, EventCallback callback);
void stop();
bool isRunning() const;
@@ -0,0 +1,140 @@
"""Failure and hotplug scenarios; no real display or sleep required."""
from pathlib import Path
import runpy
import unittest
from unittest.mock import patch
guard_module = runpy.run_path(str(Path(__file__).resolve().parents[1] / "scripts/vdi-display-guard"))
Guard = guard_module["Guard"]
MODE = guard_module["MODE"]
class FakeRandR:
def __init__(self):
self.connected = True
self.defined = True
self.attached = True
self.active = "1920x1440"
self.writes = []
self.fail_apply = False
self.query_error = False
self.keep_wrong_mode = False
def __call__(self, *args):
if args == ("--current",):
if self.query_error:
raise RuntimeError("Can't open display :0")
status = "connected primary " + ("2560x1440+0+0" if self.active == MODE else "1920x1440+0+0")
if not self.connected:
status = "disconnected"
query = "Screen 0: current 1920 x 1440\nHDMI-1 " + status + " (normal)\n"
if self.connected:
query += " 1920x1440 75.00" + ("*" if self.active != MODE else "") + "\n"
if self.attached:
query += " " + MODE + " 60.00" + ("*" if self.active == MODE else "") + "\n"
if self.defined and (not self.attached or not self.connected):
query += " " + MODE + " (0x189) 297.000MHz +HSync -VSync\n"
query += " h: width 2560 start 2608\n v: height 1440 start 1443\n"
return query
self.writes.append(args)
if args[0] == "--newmode":
if self.defined:
raise RuntimeError("BadName: duplicate mode")
self.defined = True
elif args[0] == "--addmode":
assert self.defined
self.attached = True
elif args[0] == "--output":
if self.fail_apply:
raise RuntimeError("Configure crtc failed")
assert self.attached
if not self.keep_wrong_mode:
self.active = MODE
return ""
class DisplayGuardTests(unittest.TestCase):
def setUp(self):
self.xr = FakeRandR()
self.logs = []
self.guard = Guard(self.xr, self.logs.append)
def tick(self, now):
with patch("time.monotonic", return_value=now):
return self.guard.step(now)
def test_mode_drift_restores_after_settling(self):
self.assertFalse(self.tick(0))
self.assertEqual(self.xr.writes, [])
self.assertTrue(self.tick(2))
self.assertEqual(len(self.xr.writes), 1)
self.assertEqual(self.xr.active, MODE)
def test_healthy_display_does_not_modeset_or_repeat_logs(self):
self.xr.active = MODE
for now in range(0, 100, 2):
self.assertTrue(self.tick(now))
self.assertEqual(self.xr.writes, [])
self.assertEqual(len(self.logs), 1)
def test_disconnected_never_writes_and_reconnect_settles(self):
self.tick(0)
self.xr.connected = False
for now in range(2, 20, 2):
self.assertFalse(self.tick(now))
self.assertEqual(self.xr.writes, [])
self.xr.connected = True
self.assertFalse(self.tick(20))
self.assertTrue(self.tick(22))
def test_new_x_server_recreates_and_attaches_mode(self):
self.xr.defined = self.xr.attached = False
self.tick(0)
self.assertTrue(self.tick(2))
self.assertEqual([w[0] for w in self.xr.writes], ["--newmode", "--addmode", "--output"])
def test_hotplug_detached_mode_is_not_created_twice(self):
self.xr.attached = False
self.tick(0)
self.assertTrue(self.tick(2))
self.assertEqual([w[0] for w in self.xr.writes], ["--addmode", "--output"])
def test_late_desktop_settings_overwrite_is_corrected_again(self):
self.tick(0)
self.assertTrue(self.tick(2))
self.xr.active = "1920x1440"
self.assertFalse(self.tick(4))
self.assertTrue(self.tick(6))
self.assertEqual(len(self.xr.writes), 2)
def test_modeset_failure_backs_off_and_eventually_recovers(self):
self.xr.fail_apply = True
self.tick(0)
self.tick(2) # retry at 4
self.tick(4) # retry at 8
self.tick(6) # no write
self.assertEqual(len(self.xr.writes), 2)
self.xr.fail_apply = False
self.assertTrue(self.tick(8))
self.assertEqual(len(self.xr.writes), 3)
def test_apply_success_must_be_verified(self):
self.xr.keep_wrong_mode = True
self.tick(0)
self.assertFalse(self.tick(2))
self.assertIn("not retained", self.logs[-1])
def test_x11_unavailable_does_not_write_and_then_recovers(self):
self.xr.query_error = True
for now in range(0, 20, 2):
self.assertFalse(self.tick(now))
self.assertEqual(self.xr.writes, [])
self.assertEqual(len(self.logs), 1)
self.xr.query_error = False
self.assertFalse(self.tick(20))
self.assertTrue(self.tick(22))
if __name__ == "__main__":
unittest.main()
@@ -10,7 +10,11 @@ class MoonlightSessionTest : public QObject {
private slots:
void parsesSplitReadyMarkers();
void requiresBothReadyMarkers();
void acceptsSeparateHardwareTestDecoder();
void rejectsSecondStart();
void buildsConsistentStreamArguments();
void selectsProfileWithoutLosingStats();
void rejectsInvalidDecoderOverride();
};
void MoonlightSessionTest::parsesSplitReadyMarkers() {
@@ -38,6 +42,19 @@ void MoonlightSessionTest::requiresBothReadyMarkers() {
QVERIFY(!parser.isReady());
}
void MoonlightSessionTest::acceptsSeparateHardwareTestDecoder() {
MoonlightOutputParser parser;
parser.append("Using custom H.264 decoder (H264_DECODER_HINT): h264_rkmpp\n");
QVERIFY(!parser.isReady());
parser.append("Not reusing test decoder for h264_rkmpp\n");
QVERIFY(parser.hasTestDecode());
QVERIFY(!parser.isReady());
parser.append("Renderer 'EGL' with 'DRM' backend chosen\n");
QVERIFY(parser.isReady());
parser.append("Connection terminated: -1\n");
QVERIFY(!parser.isReady());
}
void MoonlightSessionTest::rejectsSecondStart() {
QTemporaryDir tempDir;
QVERIFY(tempDir.isValid());
@@ -90,5 +107,61 @@ void MoonlightSessionTest::rejectsSecondStart() {
qputenv("PATH", oldPath);
}
void MoonlightSessionTest::buildsConsistentStreamArguments() {
ConnectionTicket ticket;
ticket.endpoint = QStringLiteral("192.168.1.70");
ticket.app = QStringLiteral("Desktop");
ticket.width = 2560;
ticket.height = 1440;
ticket.fps = 30;
MoonlightSession::Options options;
options.profile = QStringLiteral("low-latency");
options.decoder = QStringLiteral("software");
options.videoCodec = QStringLiteral("H.264");
options.vsync = false;
options.framePacing = false;
options.performanceOverlay = true;
const QStringList args = MoonlightSession::buildStreamArguments(ticket, options);
QVERIFY(args.contains(QStringLiteral("--resolution")));
QVERIFY(args.contains(QStringLiteral("2560x1440")));
QVERIFY(args.contains(QStringLiteral("--fps")));
QVERIFY(args.contains(QStringLiteral("30")));
QVERIFY(args.contains(QStringLiteral("--bitrate")));
QVERIFY(args.contains(QStringLiteral("30000")));
QVERIFY(args.contains(QStringLiteral("--no-vsync")));
QVERIFY(args.contains(QStringLiteral("--no-frame-pacing")));
QVERIFY(args.contains(QStringLiteral("--performance-overlay")));
QVERIFY(args.contains(QStringLiteral("--absolute-mouse")));
}
void MoonlightSessionTest::selectsProfileWithoutLosingStats() {
QProcessEnvironment environment;
environment.insert(QStringLiteral("VDI_STREAM_PROFILE"), QStringLiteral("low-latency"));
environment.insert(QStringLiteral("VDI_STREAM_STATS"), QStringLiteral("1"));
auto options = MoonlightSession::optionsFromEnvironment(environment);
QVERIFY(!options.vsync);
QVERIFY(!options.framePacing);
QVERIFY(options.performanceOverlay);
environment.insert(QStringLiteral("VDI_STREAM_PROFILE"), QStringLiteral("smooth"));
options = MoonlightSession::optionsFromEnvironment(environment);
QVERIFY(options.vsync);
QVERIFY(options.framePacing);
QVERIFY(options.performanceOverlay);
}
void MoonlightSessionTest::rejectsInvalidDecoderOverride() {
QProcessEnvironment environment;
environment.insert(QStringLiteral("VDI_MOONLIGHT_DECODER"), QStringLiteral("h264_rkmpp"));
environment.insert(QStringLiteral("VDI_STREAM_PROFILE"), QStringLiteral("invalid"));
auto options = MoonlightSession::optionsFromEnvironment(environment);
QCOMPARE(options.decoder, QStringLiteral("software"));
QCOMPARE(options.profile, QStringLiteral("smooth"));
environment.insert(QStringLiteral("VDI_MOONLIGHT_DECODER"), QStringLiteral("hardware"));
options = MoonlightSession::optionsFromEnvironment(environment);
QCOMPARE(options.decoder, QStringLiteral("hardware"));
}
QTEST_GUILESS_MAIN(MoonlightSessionTest)
#include "test_moonlight_session.moc"