409 lines
16 KiB
C++
409 lines
16 KiB
C++
#include "moonlight_session.h"
|
|
|
|
#include <QDate>
|
|
#include <QDir>
|
|
#include <QFile>
|
|
#include <QProcessEnvironment>
|
|
#include <QTextStream>
|
|
#include <QTimer>
|
|
|
|
namespace vdi {
|
|
namespace {
|
|
|
|
constexpr int kReadyStableMs = 750;
|
|
constexpr int kTerminateGraceMs = 2000;
|
|
|
|
bool isTestDecodeLine(const QByteArray& line) {
|
|
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) {
|
|
const QByteArray lower = line.toLower();
|
|
return lower.contains("renderer '") && lower.contains(" chosen");
|
|
}
|
|
|
|
bool isTerminalErrorLine(const QByteArray& line) {
|
|
const QByteArray lower = line.toLower();
|
|
if (lower.contains("会话已终止")) {
|
|
return true;
|
|
}
|
|
const QByteArray marker("connection terminated:");
|
|
const int markerPos = lower.indexOf(marker);
|
|
if (markerPos < 0) {
|
|
return false;
|
|
}
|
|
bool ok = false;
|
|
const int code = lower.mid(markerPos + marker.size()).trimmed().toInt(&ok);
|
|
return !ok || code != 0;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
void MoonlightOutputParser::reset() {
|
|
buffer_.clear();
|
|
hasTestDecode_ = false;
|
|
hasRenderer_ = false;
|
|
hasTerminalError_ = false;
|
|
}
|
|
|
|
void MoonlightOutputParser::append(const QByteArray& output) {
|
|
buffer_.append(output);
|
|
int newline = -1;
|
|
while ((newline = buffer_.indexOf('\n')) >= 0) {
|
|
const QByteArray line = buffer_.left(newline).trimmed();
|
|
buffer_.remove(0, newline + 1);
|
|
hasTestDecode_ = hasTestDecode_ || isTestDecodeLine(line);
|
|
hasRenderer_ = hasRenderer_ || isRendererLine(line);
|
|
hasTerminalError_ = hasTerminalError_ || isTerminalErrorLine(line);
|
|
}
|
|
}
|
|
|
|
bool MoonlightOutputParser::isReady() const {
|
|
return hasTestDecode_ && hasRenderer_ && !hasTerminalError_;
|
|
}
|
|
|
|
MoonlightSession::MoonlightSession(QObject* parent) : QObject(parent) {
|
|
process_ = new QProcess(this);
|
|
process_->setProcessChannelMode(QProcess::MergedChannels);
|
|
connect(process_, &QProcess::started, this, &MoonlightSession::onProcessStarted);
|
|
connect(process_,
|
|
QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished), this,
|
|
&MoonlightSession::onProcessFinished);
|
|
connect(process_, &QProcess::errorOccurred, this, &MoonlightSession::onProcessError);
|
|
connect(process_, &QProcess::readyReadStandardOutput, this,
|
|
&MoonlightSession::onProcessOutput);
|
|
|
|
readyTimer_ = new QTimer(this);
|
|
readyTimer_->setSingleShot(true);
|
|
connect(readyTimer_, &QTimer::timeout, this, [this]() {
|
|
if (!readyEventDelivered_ && !terminalEventDelivered_ && isRunning()) {
|
|
readyEventDelivered_ = true;
|
|
emitEvent(EventType::kStreamReady, QStringLiteral("串流画面已就绪"));
|
|
}
|
|
});
|
|
|
|
killTimer_ = new QTimer(this);
|
|
killTimer_->setSingleShot(true);
|
|
connect(killTimer_, &QTimer::timeout, this, [this]() {
|
|
if (isRunning()) {
|
|
process_->kill();
|
|
}
|
|
});
|
|
}
|
|
|
|
MoonlightSession::~MoonlightSession() {
|
|
callback_ = nullptr;
|
|
readyTimer_->stop();
|
|
killTimer_->stop();
|
|
if (isRunning()) {
|
|
process_->kill();
|
|
process_->waitForFinished(1000);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
bool validBitrate = false;
|
|
const int bitrate = environment.value(QStringLiteral("VDI_MOONLIGHT_BITRATE_KBPS")).toInt(&validBitrate);
|
|
if (validBitrate && bitrate >= 1000 && bitrate <= 150000) options.bitrateKbps = bitrate;
|
|
return options;
|
|
}
|
|
|
|
QStringList MoonlightSession::buildStreamArguments(const ConnectionTicket& ticket,
|
|
const Options& options) {
|
|
const int bitrateKbps = options.bitrateKbps >= 1000 && options.bitrateKbps <= 150000
|
|
? options.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")
|
|
// 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")
|
|
: 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;
|
|
}
|
|
|
|
qint64 MoonlightSession::processId() const {
|
|
return isRunning() ? process_->processId() : 0;
|
|
}
|
|
|
|
bool MoonlightSession::activateStreamWindow() {
|
|
if (!isRunning()) {
|
|
return false;
|
|
}
|
|
|
|
QProcess finder;
|
|
finder.start(QStringLiteral("xdotool"),
|
|
{QStringLiteral("search"), QStringLiteral("--onlyvisible"),
|
|
QStringLiteral("--pid"), QString::number(process_->processId()),
|
|
QStringLiteral("--class"), QStringLiteral("com.moonlight_stream.Moonlight")});
|
|
if (!finder.waitForFinished(500)) {
|
|
finder.kill();
|
|
finder.waitForFinished();
|
|
return false;
|
|
}
|
|
if (finder.exitCode() != 0) {
|
|
return false;
|
|
}
|
|
const QString windowId = QString::fromUtf8(finder.readAllStandardOutput())
|
|
.split(QLatin1Char('\n'), Qt::SkipEmptyParts)
|
|
.value(0).trimmed();
|
|
if (windowId.isEmpty()) {
|
|
return false;
|
|
}
|
|
|
|
// The production kiosk X11 session intentionally runs without an EWMH
|
|
// window manager. In that mode windowactivate always fails with
|
|
// "Your windowmanager claims not to support _NET_ACTIVE_WINDOW", even
|
|
// though the Moonlight window is already mapped and usable. Treating
|
|
// that failure as a stream failure leaves the opaque transition overlay
|
|
// up until the 30s timeout and then kills a healthy session.
|
|
if (QProcess::execute(QStringLiteral("xdotool"),
|
|
{QStringLiteral("windowactivate"), QStringLiteral("--sync"),
|
|
windowId}) == 0) {
|
|
return true;
|
|
}
|
|
|
|
// Fallback for the no-window-manager kiosk: map/raise the window and
|
|
// enforce the physical output geometry directly. SDL's fullscreen hint
|
|
// cannot be honoured by a WM-less X11 session, so geometry is the reliable
|
|
// handoff signal here.
|
|
if (QProcess::execute(QStringLiteral("xdotool"),
|
|
{QStringLiteral("windowmap"), windowId}) != 0) {
|
|
return false;
|
|
}
|
|
QProcess::execute(QStringLiteral("xdotool"), {QStringLiteral("windowraise"), windowId});
|
|
|
|
QProcess geometry;
|
|
geometry.start(QStringLiteral("xdotool"), {QStringLiteral("getdisplaygeometry" )});
|
|
if (!geometry.waitForFinished(500)) {
|
|
geometry.kill();
|
|
geometry.waitForFinished();
|
|
return false;
|
|
}
|
|
const QStringList size = QString::fromUtf8(geometry.readAllStandardOutput())
|
|
.simplified()
|
|
.split(QLatin1Char(' '), Qt::SkipEmptyParts);
|
|
if (size.size() != 2) {
|
|
return false;
|
|
}
|
|
const QString width = size.at(0);
|
|
const QString height = size.at(1);
|
|
if (QProcess::execute(QStringLiteral("xdotool"),
|
|
{QStringLiteral("windowmove"), windowId, QStringLiteral("0"),
|
|
QStringLiteral("0")}) != 0) {
|
|
return false;
|
|
}
|
|
return QProcess::execute(QStringLiteral("xdotool"),
|
|
{QStringLiteral("windowsize"), windowId, width, height}) == 0;
|
|
}
|
|
|
|
bool MoonlightSession::start(const ConnectionTicket& ticket, EventCallback callback) {
|
|
if (isRunning()) {
|
|
return false;
|
|
}
|
|
|
|
callback_ = std::move(callback);
|
|
stopRequested_ = false;
|
|
terminalEventDelivered_ = false;
|
|
readyEventDelivered_ = false;
|
|
outputParser_.reset();
|
|
readyTimer_->stop();
|
|
killTimer_->stop();
|
|
|
|
process_->setProgram(options_.program);
|
|
QProcessEnvironment environment = QProcessEnvironment::systemEnvironment();
|
|
environment.insert(QStringLiteral("VDI_KIOSK_STREAM"), QStringLiteral("1"));
|
|
environment.insert(QStringLiteral("GL_IS_SLOW"), QStringLiteral("0"));
|
|
environment.insert(QStringLiteral("SDL_RENDER_DRIVER"),
|
|
environment.value(QStringLiteral("VDI_SDL_RENDER_DRIVER"), QStringLiteral("opengles2")));
|
|
// 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 QStringList args = buildStreamArguments(ticket, options_);
|
|
process_->setArguments(args);
|
|
|
|
const QString logDir = QDir::homePath() + QStringLiteral("/vdi-terminal/logs");
|
|
QDir().mkpath(logDir);
|
|
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;
|
|
}
|
|
|
|
void MoonlightSession::onProcessStarted() {
|
|
emitEvent(EventType::kStarted, QStringLiteral("串流进程已启动"));
|
|
}
|
|
|
|
void MoonlightSession::onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus) {
|
|
readyTimer_->stop();
|
|
killTimer_->stop();
|
|
|
|
if (stopRequested_) {
|
|
emitTerminalEvent(EventType::kFinished, QStringLiteral("串流已结束"));
|
|
return;
|
|
}
|
|
|
|
const bool failed = exitStatus == QProcess::CrashExit || exitCode != 0;
|
|
const QString message = failed
|
|
? QStringLiteral("串流异常退出 (code=%1)").arg(exitCode)
|
|
: QStringLiteral("串流已结束 (code=%1)").arg(exitCode);
|
|
emitTerminalEvent(failed ? EventType::kFailed : EventType::kFinished,
|
|
message);
|
|
}
|
|
|
|
void MoonlightSession::onProcessError(QProcess::ProcessError error) {
|
|
if (error == QProcess::Crashed) {
|
|
return;
|
|
}
|
|
|
|
const QString message = error == QProcess::FailedToStart
|
|
? QStringLiteral("无法启动 moonlight-qt,请检查安装")
|
|
: QStringLiteral("串流进程错误 (%1)").arg(static_cast<int>(error));
|
|
emitTerminalEvent(EventType::kFailed, message);
|
|
}
|
|
|
|
void MoonlightSession::onProcessOutput() {
|
|
const QByteArray output = process_->readAllStandardOutput();
|
|
if (output.isEmpty()) {
|
|
return;
|
|
}
|
|
|
|
QFile log(logPath_);
|
|
if (log.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text)) {
|
|
log.write(output);
|
|
}
|
|
|
|
const bool wasReady = outputParser_.isReady();
|
|
outputParser_.append(output);
|
|
if (outputParser_.hasTerminalError()) {
|
|
readyTimer_->stop();
|
|
emitTerminalEvent(EventType::kFailed, QStringLiteral("云桌面连接已中断"));
|
|
stop();
|
|
return;
|
|
}
|
|
if (!wasReady && outputParser_.isReady()) {
|
|
scheduleReadyEvent();
|
|
}
|
|
}
|
|
|
|
void MoonlightSession::stop() {
|
|
if (!isRunning()) {
|
|
return;
|
|
}
|
|
stopRequested_ = true;
|
|
readyTimer_->stop();
|
|
process_->terminate();
|
|
killTimer_->start(kTerminateGraceMs);
|
|
}
|
|
|
|
void MoonlightSession::emitEvent(EventType type, const QString& message) {
|
|
if (callback_) {
|
|
callback_(Event{type, message});
|
|
}
|
|
}
|
|
|
|
void MoonlightSession::emitTerminalEvent(EventType type, const QString& message) {
|
|
if (terminalEventDelivered_) {
|
|
return;
|
|
}
|
|
terminalEventDelivered_ = true;
|
|
emitEvent(type, message);
|
|
}
|
|
|
|
void MoonlightSession::scheduleReadyEvent() {
|
|
if (!readyEventDelivered_ && !terminalEventDelivered_ && isRunning()) {
|
|
readyTimer_->start(kReadyStableMs);
|
|
}
|
|
}
|
|
|
|
} // namespace vdi
|