File indexing completed on 2025-02-02 04:26:14

0001 /*
0002     SPDX-FileCopyrightText: 2023 Aleix Pol Gonzalez <aleixpol@kde.org>
0003 
0004     SPDX-License-Identifier: LGPL-2.0-or-later
0005 */
0006 
0007 #include "VideoPlatformWayland.h"
0008 #include "ExportManager.h"
0009 #include "Platforms/VideoPlatform.h"
0010 #include "screencasting.h"
0011 #include "settings.h"
0012 #include <KLocalizedString>
0013 #include <QGuiApplication>
0014 #include <QWindow>
0015 #include <QScreen>
0016 #include <QDebug>
0017 #include <QDBusConnection>
0018 #include <QDBusMessage>
0019 #include <QDBusPendingCallWatcher>
0020 #include <QDBusPendingReply>
0021 #include <QDBusReply>
0022 #include <QStandardPaths>
0023 #include <QTemporaryDir>
0024 #include <QUrl>
0025 
0026 using namespace Qt::StringLiterals;
0027 
0028 using Format = VideoPlatform::Format;
0029 using Formats = VideoPlatform::Formats;
0030 using Encoder = PipeWireBaseEncodedStream::Encoder;
0031 
0032 VideoPlatform::Format VideoPlatformWayland::formatForEncoder(Encoder encoder) const
0033 {
0034     switch (encoder) {
0035     case Encoder::VP9: return WebM_VP9;
0036     case Encoder::H264Main: return MP4_H264;
0037     case Encoder::H264Baseline: return MP4_H264;
0038     default: return NoFormat;
0039     }
0040 }
0041 
0042 PipeWireBaseEncodedStream::Encoder VideoPlatformWayland::encoderForFormat(Format format) const
0043 {
0044     const auto encoders = m_recorder->suggestedEncoders();
0045     if (format == WebM_VP9 && encoders.contains(Encoder::VP9)) {
0046         return Encoder::VP9;
0047     }
0048     if (format == MP4_H264) {
0049         if (encoders.contains(Encoder::H264Main)) {
0050             return Encoder::H264Main;
0051         }
0052         if (encoders.contains(Encoder::H264Baseline)) {
0053             return Encoder::H264Baseline;
0054         }
0055     }
0056     return Encoder::NoEncoder;
0057 }
0058 
0059 static void minimizeIfWindowsIntersect(const QRectF &rect) {
0060     if (rect.isEmpty()) {
0061         return;
0062     }
0063     const auto &windows = qGuiApp->allWindows();
0064     for (auto window : windows) {
0065         if (rect.intersects(window->frameGeometry())
0066             && window->isVisible() && window->visibility() != QWindow::Minimized) {
0067             if (window->visibility() == QWindow::FullScreen) {
0068                 window->setVisible(false);
0069             }
0070             window->showMinimized();
0071         }
0072     }
0073 }
0074 
0075 VideoPlatformWayland::VideoPlatformWayland(QObject *parent)
0076     : VideoPlatform(parent)
0077     , m_screencasting(new Screencasting(this))
0078     , m_recorder(new PipeWireRecord())
0079 {
0080     m_recorder->setActive(false);
0081     // m_recorder->setMaxFramerate({30, 1});
0082 }
0083 
0084 VideoPlatform::RecordingModes VideoPlatformWayland::supportedRecordingModes() const
0085 {
0086     if (m_screencasting->isAvailable())
0087         return Screen | Window | Region;
0088     else
0089         return {};
0090 }
0091 
0092 VideoPlatform::Formats VideoPlatformWayland::supportedFormats() const
0093 {
0094     Formats formats;
0095     if (m_screencasting->isAvailable()) {
0096         const auto encoders = m_recorder->suggestedEncoders();
0097         for (auto encoder : encoders) {
0098             formats |= formatForEncoder(encoder);
0099         }
0100     }
0101     return formats;
0102 }
0103 
0104 static void setWindowInfo(const QVariantMap &data, QRectF &windowRect, bool &isSpectacle)
0105 {
0106     // HACK: Window geometry from queryWindowInfo is from KWin's Window::frameGeometry(),
0107     // which may not be the same as QWindow::frameGeometry() on Wayland.
0108     // Hopefully this is good enough most of the time.
0109     windowRect = {
0110         data[u"x"_s].toDouble(), data[u"y"_s].toDouble(),
0111         data[u"width"_s].toDouble(), data[u"height"_s].toDouble(),
0112     };
0113     isSpectacle = data[u"desktopFile"_s].toString() == qGuiApp->desktopFileName();
0114 }
0115 
0116 void VideoPlatformWayland::startRecording(const QUrl &fileUrl, RecordingMode recordingMode, const QVariant &option, bool includePointer)
0117 {
0118     if (recordingMode == NoRecordingModes) {
0119         // We should avoid calling startRecording without a recording mode,
0120         // but it shouldn't cause a runtime error if we can handle it gracefully.
0121         Q_EMIT recordingCanceled(u"Recording canceled: No recording mode"_s);
0122         return;
0123     }
0124     if (isRecording()) {
0125         qWarning() << "Warning: Tried to start recording while already recording.";
0126         return;
0127     }
0128     if (!fileUrl.isEmpty() && !fileUrl.isLocalFile()) {
0129         Q_EMIT recordingFailed(i18nc("@info:shell", "Failed to record: File URL is not a local file"));
0130         return;
0131     }
0132 
0133     m_recorder->setActive(false);
0134     // BUG: https://bugs.kde.org/show_bug.cgi?id=476964
0135     // CursorMode::Metadata doesn't work.
0136     Screencasting::CursorMode mode = includePointer ? Screencasting::CursorMode::Embedded : Screencasting::Hidden;
0137     ScreencastingStream *stream = nullptr;
0138     switch (recordingMode) {
0139     case Screen: {
0140         auto screen = option.value<QScreen *>();
0141         if (!screen) {
0142             selectAndRecord(fileUrl, recordingMode, includePointer);
0143             return;
0144         }
0145         Q_ASSERT(screen != nullptr);
0146         minimizeIfWindowsIntersect(screen->geometry());
0147         stream = m_screencasting->createOutputStream(screen, mode);
0148         break;
0149     }
0150     case Window: {
0151         auto window = option.toString();
0152         if (window.isEmpty()) {
0153             selectAndRecord(fileUrl, recordingMode, includePointer);
0154             return;
0155         }
0156         Q_ASSERT(!window.isEmpty());
0157         QDBusMessage message = QDBusMessage::createMethodCall(u"org.kde.KWin"_s,
0158                                                                 u"/KWin"_s,
0159                                                                 u"org.kde.KWin"_s,
0160                                                                 u"getWindowInfo"_s);
0161         message.setArguments({window});
0162         const QDBusReply<QVariantMap> reply = QDBusConnection::sessionBus().call(message);
0163         const auto &data = reply.value();
0164 
0165         QRectF windowRect;
0166         bool isSpectacle = false;
0167         if (reply.isValid()) {
0168             setWindowInfo(data, windowRect, isSpectacle);
0169             ExportManager::instance()->setWindowTitle(data[u"caption"_s].toString());
0170         }
0171 
0172         if (!windowRect.isEmpty() && !isSpectacle) {
0173             minimizeIfWindowsIntersect(windowRect);
0174         }
0175         stream = m_screencasting->createWindowStream(window, mode);
0176         break;
0177     }
0178     case Region: {
0179         auto region = option.toRect();
0180         if (region.isEmpty()) {
0181             selectAndRecord(fileUrl, recordingMode, includePointer);
0182             return;
0183         }
0184         qreal scaling = 1;
0185         const auto screens = qGuiApp->screens();
0186         // Don't make the resolution larger than it needs to be.
0187         for (auto screen : screens) {
0188             if (screen->geometry().intersects(region)) {
0189                 scaling = std::max(scaling, screen->devicePixelRatio());
0190             }
0191         }
0192         stream = m_screencasting->createRegionStream(region, scaling, mode);
0193         break;
0194     }
0195     default: break; // This shouldn't happen
0196     }
0197 
0198     Q_ASSERT(stream);
0199     connect(stream, &ScreencastingStream::created, this, [this, stream] {
0200         m_recorder->setNodeId(stream->nodeId());
0201         if (!m_recorder->output().isEmpty()) {
0202             m_recorder->setActive(true);
0203         }
0204         setRecording(true);
0205     });
0206     connect(stream, &ScreencastingStream::failed, this, [this](const QString &error) {
0207         setRecording(false);
0208         Q_EMIT recordingFailed(error);
0209     });
0210     setupOutput(fileUrl);
0211 
0212     connect(m_recorder.get(), &PipeWireRecord::stateChanged, this, [this] {
0213         if (m_recorder->state() == PipeWireRecord::Idle && isRecording()) {
0214             setRecording(false);
0215             Q_EMIT recordingSaved(QUrl::fromLocalFile(m_recorder->output()));
0216         }
0217     });
0218 }
0219 
0220 void VideoPlatformWayland::finishRecording()
0221 {
0222     Q_ASSERT(m_recorder);
0223     m_recorder->setActive(false);
0224 }
0225 
0226 bool VideoPlatformWayland::mkDirPath(const QUrl &fileUrl)
0227 {
0228     QDir dir(fileUrl.adjusted(QUrl::RemoveFilename).toLocalFile());
0229     if (dir.exists() || dir.mkpath(u"."_s)) {
0230         return true;
0231     } else {
0232         Q_EMIT recordingFailed(i18nc("@info:shell", "Failed to record: Unable to create folder (%1)", dir.path()));
0233         return false;
0234     }
0235 }
0236 
0237 void VideoPlatformWayland::setupOutput(const QUrl &fileUrl)
0238 {
0239     if (!fileUrl.isValid()) {
0240         ExportManager::instance()->updateTimestamp();
0241         const auto format = static_cast<Format>(Settings::preferredVideoFormat());
0242         auto tempUrl = ExportManager::instance()->tempVideoUrl();
0243         if (!tempUrl.isLocalFile()) {
0244             Q_EMIT recordingFailed(i18nc("@info:shell", "Failed to record: Temporary file URL is not a local file (%1)", tempUrl.toString()));
0245             return;
0246         }
0247         if (!mkDirPath(tempUrl)) {
0248             return;
0249         }
0250         m_recorder->setEncoder(encoderForFormat(format));
0251         m_recorder->setOutput(tempUrl.toLocalFile());
0252         m_recorder->setActive(m_recorder->nodeId() != 0);
0253     } else {
0254         if (!fileUrl.isLocalFile()) {
0255             Q_EMIT recordingFailed(i18nc("@info:shell", "Failed to record: Output file URL is not a local file (%1)", fileUrl.toString()));
0256             return;
0257         }
0258         if (!mkDirPath(fileUrl)) {
0259             return;
0260         }
0261         const auto &localFile = fileUrl.toLocalFile();
0262         m_recorder->setEncoder(encoderForFormat(formatForPath(localFile)));
0263         m_recorder->setOutput(localFile);
0264     }
0265 }
0266 
0267 void VideoPlatformWayland::selectAndRecord(const QUrl &fileUrl, RecordingMode recordingMode, bool includePointer)
0268 {
0269     if (recordingMode == Region) {
0270         Q_EMIT regionRequested();
0271         return;
0272     }
0273 
0274     // We should probably come up with a better way of choosing outputs. This should be okay for now. #FLW
0275     QDBusMessage message = QDBusMessage::createMethodCall(u"org.kde.KWin"_s,
0276                                                           u"/KWin"_s,
0277                                                           u"org.kde.KWin"_s,
0278                                                           u"queryWindowInfo"_s);
0279 
0280     QDBusPendingReply<QVariantMap> asyncReply = QDBusConnection::sessionBus().asyncCall(message);
0281     QDBusPendingCallWatcher *watcher = new QDBusPendingCallWatcher(asyncReply, this);
0282     auto onFinished = [this, fileUrl, recordingMode, includePointer](QDBusPendingCallWatcher *self) {
0283         QDBusPendingReply<QVariantMap> reply = *self;
0284         self->deleteLater();
0285         if (!reply.isValid()) {
0286             const auto &error = self->error();
0287             if (error.name() == u"org.kde.KWin.Error.UserCancel"_s) {
0288                 QString message;
0289                 if (recordingMode == Screen) {
0290                     message = i18nc("@info:shell", "Screen recording canceled");
0291                 } else {
0292                     message = i18nc("@info:shell", "Window recording canceled");
0293                 }
0294                 Q_EMIT recordingCanceled(message);
0295             } else {
0296                 QString message;
0297                 if (recordingMode == Screen) {
0298                     message = i18nc("@info:shell", "Failed to select screen: %1", error.message());
0299                 } else {
0300                     message = i18nc("@info:shell", "Failed to select window: %1", error.message());
0301                 }
0302                 Q_EMIT recordingFailed(message);
0303             }
0304             return;
0305         }
0306         const auto &data = reply.value();
0307         QVariant option;
0308         if (recordingMode == Screen) {
0309             QPoint pos = QCursor::pos();
0310             // if (pos.isNull()) {
0311             //     pos = {data[u"x"_s].toInt(), data[u"y"_s].toInt()};
0312             // }
0313             const auto &screens = qGuiApp->screens();
0314             QScreen *screen = nullptr;
0315             for (auto s : screens) {
0316                 if (s->geometry().contains(pos)) {
0317                     screen = s;
0318                     break;
0319                 }
0320             }
0321             if (!screen) {
0322                 Q_EMIT recordingFailed(i18nc("@info:shell", "Failed to select screen: No screen contained the mouse cursor position"));
0323                 return;
0324             }
0325             option = QVariant::fromValue(screen);
0326         } else {
0327             const auto &windowId = data.value(u"uuid"_s).toString();
0328             if (windowId.isEmpty()) {
0329                 Q_EMIT recordingFailed(i18nc("@info:shell", "Failed to select window: No window found"));
0330                 return;
0331             }
0332             option = windowId;
0333         }
0334         startRecording(fileUrl, recordingMode, option, includePointer);
0335     };
0336     connect(watcher, &QDBusPendingCallWatcher::finished, this, onFinished);
0337 }
0338 
0339 #include "moc_VideoPlatformWayland.cpp"