#include "moonlight_session.h" #include #include #include #include #include #include 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::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(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