#include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include static const char *PORTAL_SERVICE = "org.freedesktop.portal.Desktop"; static const char *PORTAL_PATH = "/org/freedesktop/portal/desktop"; static const char *SCREENCAST_IFACE = "org.freedesktop.portal.ScreenCast"; static const uint SOURCE_TYPE_MONITOR = 1; static const uint SOURCE_TYPE_WINDOW = 2; static const uint CURSOR_MODE_HIDDEN = 1; struct PipeWireStream { uint nodeId = 0; QVariantMap properties; }; Q_DECLARE_METATYPE(PipeWireStream) Q_DECLARE_METATYPE(QList) const QDBusArgument &operator>>(const QDBusArgument &argument, PipeWireStream &stream) { argument.beginStructure(); argument >> stream.nodeId >> stream.properties; argument.endStructure(); return argument; } QDBusArgument &operator<<(QDBusArgument &argument, const PipeWireStream &stream) { argument.beginStructure(); argument << stream.nodeId << stream.properties; argument.endStructure(); return argument; } class RequestWatcher : public QObject { Q_OBJECT public: uint response = 2; QVariantMap results; bool done = false; QString error; public slots: void Response(uint responseCode, const QVariantMap &responseResults) { response = responseCode; results = responseResults; done = true; QCoreApplication::quit(); } }; QDBusConnection sessionBus() { QDBusConnection bus = QDBusConnection::sessionBus(); if (bus.isConnected()) { return bus; } QString runtimeDir = qEnvironmentVariable("XDG_RUNTIME_DIR"); if (runtimeDir.isEmpty()) { runtimeDir = QStringLiteral("/run/user/%1").arg(getuid()); } return QDBusConnection::connectToBus( QStringLiteral("unix:path=%1/bus").arg(runtimeDir), QStringLiteral("reforger-portal-session")); } QString token() { return QStringLiteral("reforger_%1_%2") .arg(QCoreApplication::applicationPid()) .arg(QString::number(QRandomGenerator::global()->generate(), 16)); } bool waitForRequest(QCoreApplication &app, QDBusConnection &bus, const QDBusObjectPath &handle, int timeoutMs, RequestWatcher &watcher, QTextStream &err) { const bool connected = bus.connect( QString::fromLatin1(PORTAL_SERVICE), handle.path(), QStringLiteral("org.freedesktop.portal.Request"), QStringLiteral("Response"), &watcher, SLOT(Response(uint,QVariantMap))); if (!connected) { err << "failed to listen for portal request response on " << handle.path() << ": " << bus.lastError().message() << "\n"; return false; } QTimer::singleShot(timeoutMs, &app, [&watcher]() { if (!watcher.done) { watcher.error = QStringLiteral("timed out waiting for portal response"); watcher.done = true; QCoreApplication::quit(); } }); app.exec(); bus.disconnect( QString::fromLatin1(PORTAL_SERVICE), handle.path(), QStringLiteral("org.freedesktop.portal.Request"), QStringLiteral("Response"), &watcher, SLOT(Response(uint,QVariantMap))); if (!watcher.error.isEmpty()) { err << watcher.error << "\n"; return false; } if (watcher.response == 1) { err << "portal request was cancelled\n"; return false; } if (watcher.response != 0) { err << "portal request failed with response code " << watcher.response << "\n"; return false; } return true; } QVariantMap callRequest( QCoreApplication &app, QDBusConnection &bus, QDBusInterface &portal, const QString &method, const QList &args, int timeoutMs, QTextStream &err, bool *ok) { *ok = false; QDBusMessage reply = portal.callWithArgumentList(QDBus::Block, method, args); if (reply.type() == QDBusMessage::ErrorMessage) { err << method << " failed: " << reply.errorName() << ": " << reply.errorMessage() << "\n"; return {}; } if (reply.arguments().isEmpty()) { err << method << " returned no request handle\n"; return {}; } QDBusObjectPath handle = qdbus_cast(reply.arguments().at(0)); if (handle.path().isEmpty()) { err << method << " returned an invalid request handle\n"; return {}; } RequestWatcher watcher; if (!waitForRequest(app, bus, handle, timeoutMs, watcher, err)) { return {}; } *ok = true; return watcher.results; } uint portalUIntProperty(QDBusConnection &bus, const QString &name) { QDBusInterface props( QString::fromLatin1(PORTAL_SERVICE), QString::fromLatin1(PORTAL_PATH), QStringLiteral("org.freedesktop.DBus.Properties"), bus); QDBusReply reply = props.call(QStringLiteral("Get"), QString::fromLatin1(SCREENCAST_IFACE), name); if (!reply.isValid()) { return 0; } return reply.value().variant().toUInt(); } QList parseStreams(const QVariant &value) { QList streams = qdbus_cast>(value); if (!streams.isEmpty()) { return streams; } if (value.canConvert()) { return qdbus_cast>(value.value()); } return {}; } QString variantString(const QVariantMap &map, const QString &key) { const QVariant value = map.value(key); if (value.canConvert()) { return value.value().path(); } return value.toString(); } void closePortalSession(QDBusConnection &bus, const QString &sessionHandle) { if (sessionHandle.isEmpty()) { return; } QDBusInterface session( QString::fromLatin1(PORTAL_SERVICE), sessionHandle, QStringLiteral("org.freedesktop.portal.Session"), bus); if (session.isValid()) { session.call(QStringLiteral("Close")); } } int writePipeWireFrame(int fd, uint nodeId, const QString &outputPath, int timeoutMs, QTextStream &err) { GstElement *pipeline = gst_pipeline_new("reforger-portal-capture"); GstElement *source = gst_element_factory_make("pipewiresrc", "source"); GstElement *convert = gst_element_factory_make("videoconvert", "convert"); GstElement *filter = gst_element_factory_make("capsfilter", "filter"); GstElement *sink = gst_element_factory_make("appsink", "sink"); if (!pipeline || !source || !convert || !filter || !sink) { err << "failed to create GStreamer PipeWire capture pipeline\n"; if (pipeline) { gst_object_unref(pipeline); } return 1; } const QByteArray node = QByteArray::number(nodeId); g_object_set(source, "fd", fd, "path", node.constData(), "num-buffers", 1, nullptr); GstCaps *caps = gst_caps_new_simple("video/x-raw", "format", G_TYPE_STRING, "BGRA", nullptr); g_object_set(filter, "caps", caps, nullptr); gst_caps_unref(caps); g_object_set(sink, "emit-signals", FALSE, "sync", FALSE, "max-buffers", 1, "drop", TRUE, nullptr); gst_bin_add_many(GST_BIN(pipeline), source, convert, filter, sink, nullptr); if (!gst_element_link_many(source, convert, filter, sink, nullptr)) { err << "failed to link GStreamer PipeWire capture pipeline\n"; gst_object_unref(pipeline); return 1; } if (gst_element_set_state(pipeline, GST_STATE_PLAYING) == GST_STATE_CHANGE_FAILURE) { err << "failed to start GStreamer PipeWire capture pipeline\n"; gst_object_unref(pipeline); return 1; } GstSample *sample = gst_app_sink_try_pull_sample(GST_APP_SINK(sink), timeoutMs * GST_MSECOND); if (!sample) { GstBus *bus = gst_element_get_bus(pipeline); GstMessage *message = gst_bus_pop_filtered(bus, static_cast(GST_MESSAGE_ERROR | GST_MESSAGE_EOS)); if (message && GST_MESSAGE_TYPE(message) == GST_MESSAGE_ERROR) { GError *error = nullptr; gchar *debug = nullptr; gst_message_parse_error(message, &error, &debug); err << "GStreamer PipeWire capture failed: " << (error ? error->message : "unknown error") << "\n"; if (debug) { err << debug << "\n"; } if (error) { g_error_free(error); } g_free(debug); } else { err << "timed out waiting for a PipeWire video frame\n"; } if (message) { gst_message_unref(message); } gst_object_unref(bus); gst_element_set_state(pipeline, GST_STATE_NULL); gst_object_unref(pipeline); return 1; } GstCaps *sampleCaps = gst_sample_get_caps(sample); GstVideoInfo info; if (!sampleCaps || !gst_video_info_from_caps(&info, sampleCaps)) { err << "failed to read video frame format from PipeWire sample\n"; gst_sample_unref(sample); gst_element_set_state(pipeline, GST_STATE_NULL); gst_object_unref(pipeline); return 1; } GstBuffer *buffer = gst_sample_get_buffer(sample); GstMapInfo map; if (!buffer || !gst_buffer_map(buffer, &map, GST_MAP_READ)) { err << "failed to map PipeWire video frame\n"; gst_sample_unref(sample); gst_element_set_state(pipeline, GST_STATE_NULL); gst_object_unref(pipeline); return 1; } const int width = GST_VIDEO_INFO_WIDTH(&info); const int height = GST_VIDEO_INFO_HEIGHT(&info); const int stride = GST_VIDEO_INFO_PLANE_STRIDE(&info, 0); QImage image(map.data, width, height, stride, QImage::Format_ARGB32); const bool saved = image.copy().save(outputPath, "PNG"); gst_buffer_unmap(buffer, &map); gst_sample_unref(sample); gst_element_set_state(pipeline, GST_STATE_NULL); gst_object_unref(pipeline); if (!saved) { err << "failed to write PNG output: " << outputPath << "\n"; return 1; } return 0; } int captureWindow(QCoreApplication &app, const QString &outputPath, const QString &restoreToken, bool persist, int timeoutMs, QTextStream &out, QTextStream &err) { QDBusConnection bus = sessionBus(); if (!bus.isConnected()) { err << "failed to connect to the session D-Bus: " << bus.lastError().message() << "\n"; return 1; } const uint sourceTypes = portalUIntProperty(bus, QStringLiteral("AvailableSourceTypes")); if (!(sourceTypes & SOURCE_TYPE_WINDOW)) { err << "desktop portal does not advertise window capture support"; if (sourceTypes) { err << " (AvailableSourceTypes=" << sourceTypes << ")"; } err << "\n"; return 1; } QDBusInterface portal( QString::fromLatin1(PORTAL_SERVICE), QString::fromLatin1(PORTAL_PATH), QString::fromLatin1(SCREENCAST_IFACE), bus); if (!portal.isValid()) { err << "failed to connect to desktop ScreenCast portal: " << portal.lastError().message() << "\n"; return 1; } QVariantMap createOptions; createOptions.insert(QStringLiteral("handle_token"), token()); createOptions.insert(QStringLiteral("session_handle_token"), token()); bool ok = false; QVariantMap createResults = callRequest(app, bus, portal, QStringLiteral("CreateSession"), {createOptions}, timeoutMs, err, &ok); if (!ok) { return 1; } const QString sessionHandle = variantString(createResults, QStringLiteral("session_handle")); if (sessionHandle.isEmpty()) { err << "portal CreateSession did not return a session handle\n"; return 1; } QVariantMap selectOptions; selectOptions.insert(QStringLiteral("handle_token"), token()); selectOptions.insert(QStringLiteral("types"), SOURCE_TYPE_WINDOW); selectOptions.insert(QStringLiteral("multiple"), false); selectOptions.insert(QStringLiteral("cursor_mode"), CURSOR_MODE_HIDDEN); if (!restoreToken.isEmpty()) { selectOptions.insert(QStringLiteral("restore_token"), restoreToken); } if (persist) { selectOptions.insert(QStringLiteral("persist_mode"), 2u); } callRequest(app, bus, portal, QStringLiteral("SelectSources"), {QVariant::fromValue(QDBusObjectPath(sessionHandle)), selectOptions}, timeoutMs, err, &ok); if (!ok) { closePortalSession(bus, sessionHandle); return 1; } QVariantMap startOptions; startOptions.insert(QStringLiteral("handle_token"), token()); QVariantMap startResults = callRequest(app, bus, portal, QStringLiteral("Start"), {QVariant::fromValue(QDBusObjectPath(sessionHandle)), QString(), startOptions}, timeoutMs, err, &ok); if (!ok) { closePortalSession(bus, sessionHandle); return 1; } const QList streams = parseStreams(startResults.value(QStringLiteral("streams"))); if (streams.isEmpty()) { err << "portal Start did not return any PipeWire streams\n"; closePortalSession(bus, sessionHandle); return 1; } const PipeWireStream stream = streams.first(); if (stream.nodeId == 0) { err << "portal returned an invalid PipeWire node id\n"; closePortalSession(bus, sessionHandle); return 1; } QDBusReply fdReply = portal.call( QStringLiteral("OpenPipeWireRemote"), QVariant::fromValue(QDBusObjectPath(sessionHandle)), QVariantMap()); if (!fdReply.isValid()) { err << "OpenPipeWireRemote failed: " << fdReply.error().name() << ": " << fdReply.error().message() << "\n"; closePortalSession(bus, sessionHandle); return 1; } const QDBusUnixFileDescriptor remoteFd = fdReply.value(); if (!remoteFd.isValid()) { err << "OpenPipeWireRemote returned an invalid file descriptor\n"; closePortalSession(bus, sessionHandle); return 1; } const int status = writePipeWireFrame(remoteFd.fileDescriptor(), stream.nodeId, outputPath, timeoutMs, err); closePortalSession(bus, sessionHandle); if (status != 0) { return status; } QJsonObject info; info.insert(QStringLiteral("node_id"), static_cast(stream.nodeId)); info.insert(QStringLiteral("session_handle"), sessionHandle); const QString newRestoreToken = variantString(startResults, QStringLiteral("restore_token")); if (!newRestoreToken.isEmpty()) { info.insert(QStringLiteral("restore_token"), newRestoreToken); } QJsonArray streamArray; for (const PipeWireStream &pipeWireStream : streams) { QJsonObject streamObject; streamObject.insert(QStringLiteral("node_id"), static_cast(pipeWireStream.nodeId)); QJsonObject properties; for (auto it = pipeWireStream.properties.constBegin(); it != pipeWireStream.properties.constEnd(); ++it) { properties.insert(it.key(), QJsonValue::fromVariant(it.value())); } streamObject.insert(QStringLiteral("properties"), properties); streamArray.append(streamObject); } info.insert(QStringLiteral("streams"), streamArray); out << QJsonDocument(info).toJson(QJsonDocument::Compact) << "\n"; return 0; } int main(int argc, char *argv[]) { QCoreApplication app(argc, argv); gst_init(&argc, &argv); qDBusRegisterMetaType(); qDBusRegisterMetaType>(); QTextStream out(stdout); QTextStream err(stderr); const QStringList args = QCoreApplication::arguments(); QString outputPath; QString restoreToken; bool persist = false; int timeoutMs = 60000; for (int i = 1; i < args.size(); ++i) { const QString arg = args.at(i); if (arg == QLatin1String("--window") && i + 1 < args.size()) { outputPath = args.at(++i); } else if (arg == QLatin1String("--restore-token") && i + 1 < args.size()) { restoreToken = args.at(++i); } else if (arg == QLatin1String("--persist")) { persist = true; } else if (arg == QLatin1String("--timeout") && i + 1 < args.size()) { bool ok = false; const double seconds = args.at(++i).toDouble(&ok); if (!ok || seconds <= 0) { err << "invalid --timeout value\n"; return 64; } timeoutMs = qRound(seconds * 1000.0); } else { err << "unknown or incomplete argument: " << arg << "\n"; return 64; } } if (outputPath.isEmpty()) { err << "usage: " << args.value(0) << " --window OUTPUT.png [--restore-token TOKEN] [--persist] [--timeout SECONDS]\n"; return 64; } return captureWindow(app, outputPath, restoreToken, persist, timeoutMs, out, err); } #include "portal_capture_frame.moc"