Qt
Internal/Contributor docs for the Qt SDK. Note: These are NOT official API docs; those are found at https://doc.qt.io/
Loading...
Searching...
No Matches
qfakexdgportal.cpp
Go to the documentation of this file.
1// Copyright (C) 2026 The Qt Company Ltd.
2// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
3
5
6#include <private/qsyntheticvideoscene_p.h>
7#include <private/qsyntheticpipewirevideosource_p.h>
8
9#include <QtMultimedia/private/qmultimedia_ranges_p.h>
10#include <QtMultimedia/private/qpipewire_support_p.h>
11#include <QtCore/qchronotimer.h>
12#include <QtCore/qcommandlineparser.h>
13#include <QtCore/qcoreapplication.h>
14#include <QtCore/qdebug.h>
15#include <QtCore/qhash.h>
16#include <QtCore/qspan.h>
17#include <QtCore/qtimer.h>
18#include <QtCore/private/quniquehandle_types_p.h>
19#include <QtDBus/qdbusabstractadaptor.h>
20#include <QtDBus/qdbusargument.h>
21#include <QtDBus/qdbusconnection.h>
22#include <QtDBus/qdbuscontext.h>
23#include <QtDBus/qdbuserror.h>
24#include <QtDBus/qdbusmessage.h>
25#include <QtDBus/qdbusmetatype.h>
26#include <QtDBus/qdbusextratypes.h>
27#include <QtDBus/qdbusunixfiledescriptor.h>
28
29#include <algorithm>
30#include <cerrno>
31#include <chrono>
32#include <cstdio>
33#include <cstring>
34#include <optional>
35#include <sys/socket.h>
36#include <sys/un.h>
37#include <unistd.h>
38
39QT_BEGIN_NAMESPACE
40
41using namespace Qt::StringLiterals;
42using namespace std::chrono_literals;
43
44namespace {
45
46// results["streams"] has D-Bus signature a(ua{sv}), and "position"/"size" inside
47// the per-stream property map are (ii). This mirrors what
48// QPipeWireCaptureHelper::updateStreams() demarshals.
49struct FakePortalPoint
50{
51 qint32 x = 0;
52 qint32 y = 0;
53};
54
55struct FakePortalStream
56{
57 QtPipeWire::ObjectId nodeId{ 0 };
58 QVariantMap properties;
59};
60
61QDBusArgument &operator<<(QDBusArgument &argument, const FakePortalPoint &point)
62{
63 argument.beginStructure();
64 argument << point.x << point.y;
65 argument.endStructure();
66 return argument;
67}
68
69const QDBusArgument &operator>>(const QDBusArgument &argument, FakePortalPoint &point)
70{
71 argument.beginStructure();
72 argument >> point.x >> point.y;
73 argument.endStructure();
74 return argument;
75}
76
77QDBusArgument &operator<<(QDBusArgument &argument, const FakePortalStream &stream)
78{
79 argument.beginStructure();
80 argument << stream.nodeId.value << stream.properties;
81 argument.endStructure();
82 return argument;
83}
84
85const QDBusArgument &operator>>(const QDBusArgument &argument, FakePortalStream &stream)
86{
87 argument.beginStructure();
88 argument >> stream.nodeId.value >> stream.properties;
89 argument.endStructure();
90 return argument;
91}
92
93// Opens a bare connection to the PipeWire daemon. This is all the real portal's
94// OpenPipeWireRemote has to give us: pw_context_connect_fd() performs the protocol
95// handshake itself, so an un-negotiated connected socket is exactly right.
96q23::expected<QUniqueFileDescriptorHandle, QString> connectToPipeWire()
97{
98 QByteArray runtimeDir = qgetenv("PIPEWIRE_RUNTIME_DIR");
99 if (runtimeDir.isEmpty())
100 runtimeDir = qgetenv("XDG_RUNTIME_DIR");
101 QByteArray coreName = qgetenv("PIPEWIRE_CORE");
102 if (coreName.isEmpty())
103 coreName = "pipewire-0"_ba;
104
105 const QByteArray path = runtimeDir + '/' + coreName;
106
107 sockaddr_un address{
108 .sun_family = AF_UNIX,
109 .sun_path = {},
110 };
111 if (size_t(path.size()) >= sizeof(address.sun_path)) {
112 return q23::unexpected(u"pipewire socket path exceeds sockaddr_un limits: "_s
113 + QString::fromLocal8Bit(path));
114 }
115 memcpy(address.sun_path, path.constData(), size_t(path.size()));
116
117 QUniqueFileDescriptorHandle fd{ ::socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0) };
118 if (!fd)
119 return q23::unexpected(u"socket(): "_s + QString::fromLocal8Bit(strerror(errno)));
120 if (::connect(fd.get(), reinterpret_cast<sockaddr *>(&address), sizeof(address)) < 0) {
121 return q23::unexpected(u"connect("_s + QString::fromLocal8Bit(path) + u"): "_s
122 + QString::fromLocal8Bit(strerror(errno)));
123 }
124 return fd;
125}
126
127class FakePortalRequest : public QObject
128{
129 Q_OBJECT
130 Q_CLASSINFO("D-Bus Interface", "org.freedesktop.portal.Request")
131public:
132 using QObject::QObject;
133
134public Q_SLOTS:
135 Q_SCRIPTABLE void Close() { }
136
137Q_SIGNALS:
138 Q_SCRIPTABLE void Response(uint response, QVariantMap results);
139};
140
141class FakePortalSession : public QObject
142{
143 Q_OBJECT
144 Q_CLASSINFO("D-Bus Interface", "org.freedesktop.portal.Session")
145public:
146 using QObject::QObject;
147
148public Q_SLOTS:
149 Q_SCRIPTABLE void Close() { Q_EMIT Closed(); }
150
151Q_SIGNALS:
152 Q_SCRIPTABLE void Closed();
153};
154
155/*!
156 The object exported at /org/freedesktop/portal/desktop.
157
158 It carries no D-Bus interface of its own: every portal interface is exported by
159 a QDBusAbstractAdaptor attached to it. Adaptors are required here rather than
160 merely tidier, because org.freedesktop.portal.ScreenCast and
161 org.freedesktop.portal.Camera both define OpenPipeWireRemote -- with different
162 signatures -- and both define a version property. A plain QObject dispatches by
163 name and signature across its whole metaobject, so the two would be told apart
164 only by signature coincidence; adaptors dispatch strictly per interface.
165
166 QDBusContext lives here rather than on the adaptors: QDBusContextPrivate::set()
167 redirects to parent() for adaptors, and the documentation says explicitly not to
168 inherit QDBusContext and QDBusAbstractAdaptor in the same class.
169*/
170class FakePortalObject : public QObject, public QDBusContext
171{
172 Q_OBJECT
173
174public:
175 /*!
176 Which conversation a request belongs to. A new conversation supersedes
177 anything still pending from an earlier one, and the interfaces must not
178 cancel each other's responses.
179 */
180 enum class RequestScope { ScreenCast, Camera };
181
182 FakePortalObject(QSyntheticPipeWireVideoSource &source, QSize frameSize,
183 std::chrono::milliseconds responseDelay, QObject *parent)
184 : QObject(parent), m_source(source), m_frameSize(frameSize), m_responseDelay(responseDelay)
185 {
186 }
187
188 QSyntheticPipeWireVideoSource &source() const { return m_source; }
189 QSize frameSize() const { return m_frameSize; }
190
191 //! QDBusContext::sendErrorReply() is protected, so adaptors cannot reach it.
192 void failWith(QDBusError::ErrorType type, const QString &message)
193 {
194 sendErrorReply(type, message);
195 }
196
197 //! Exports a session object for \a token, reusing one that already exists.
198 QString ensureSession(const QString &token)
199 {
200 const QString sessionPath =
201 u"/org/freedesktop/portal/desktop/session/%1/%2"_s.arg(callerName(), token);
202
203 if (!m_sessions.contains(sessionPath)) {
204 auto *session = new FakePortalSession(this);
205 QDBusConnection::sessionBus().registerObject(sessionPath, session,
206 QDBusConnection::ExportScriptableContents);
207 m_sessions.insert(sessionPath, session);
208 }
209 return sessionPath;
210 }
211
212 /*!
213 Drops responses still pending in \a scope. Without this, a response left over
214 from a previous capture cycle is delivered into the new one --
215 QPipeWireCaptureHelper subscribes to Response on every object path and
216 dispatches purely on its own state, so a stale response corrupts its state
217 machine and aborts the process.
218 */
219 void supersedePending(RequestScope scope) { ++generationFor(scope); }
220
221 QDBusObjectPath respond(const QVariantMap &options, const QVariantMap &results,
222 RequestScope scope)
223 {
224 const QString token =
225 options.value(u"handle_token"_s, u"qt%1"_s.arg(++m_fallbackToken)).toString();
226 const QString path =
227 u"/org/freedesktop/portal/desktop/request/%1/%2"_s.arg(callerName(), token);
228
229 // QPipeWireCaptureHelper::getRequestToken() caches its token and reuses it
230 // for SelectSources and Start, so the same path shows up more than once.
231 // The object must be reused: registering a second object on a taken path
232 // fails, and unregistering after each Response tears down the signal relay
233 // before the message reaches the bus -- in both cases the Response is
234 // silently lost and the client stalls.
235 FakePortalRequest *request = m_requests.value(path);
236 if (!request) {
237 request = new FakePortalRequest(this);
238 if (!QDBusConnection::sessionBus().registerObject(
239 path, request, QDBusConnection::ExportScriptableContents)) {
240 qWarning() << "FakePortalObject: cannot export request object at" << path;
241 delete request;
242 return QDBusObjectPath(path);
243 }
244 m_requests.insert(path, request);
245 }
246
247 const uint responseCode = std::exchange(m_nextResponseCode, 0u);
248 const quint64 generation = generationFor(scope);
249
250 // Deliberately deferred. QPipeWireCaptureHelper assigns m_operationState
251 // only after its blocking call returns, and does so on a different thread
252 // from the one this signal is delivered on; responding synchronously can
253 // beat it and leave the client's state machine stuck.
254 QTimer::singleShot(m_responseDelay, request,
255#if __cplusplus >= 202002L
256 [=, this] {
257#else
258 [=] {
259#endif
260 if (generation != generationFor(scope))
261 return; // superseded by a newer conversation
262 Q_EMIT request->Response(responseCode, results);
263 });
264
265 return QDBusObjectPath(path);
266 }
267
268 // Test hooks. Deliberately not D-Bus slots here: exporting them would add
269 // non-standard methods to a standard portal interface. They are reached through
270 // FakeXdgPortalControl instead.
271 void rememberSelectSourcesOptions(const QVariantMap &options)
272 {
273 m_selectSourcesOptions = options;
274 }
275 QVariantMap takeSelectSourcesOptions() { return std::exchange(m_selectSourcesOptions, {}); }
276 void setNextResponseCode(uint code) { m_nextResponseCode = code; }
277 void closeSessions()
278 {
279 for (FakePortalSession *session : std::as_const(m_sessions))
280 Q_EMIT session->Closed();
281 }
282
283private:
284 quint64 &generationFor(RequestScope scope)
285 {
286 return scope == RequestScope::ScreenCast ? m_screenCastGeneration : m_cameraGeneration;
287 }
288
289 // Portal spec: the caller's unique bus name with ':' dropped and '.' -> '_'.
290 QString callerName() const
291 {
292 QString name = message().service();
293 if (name.startsWith(u':'))
294 name.remove(0, 1);
295 return name.replace(u'.', u'_');
296 }
297
298 QSyntheticPipeWireVideoSource &m_source;
299 const QSize m_frameSize;
300 const std::chrono::milliseconds m_responseDelay;
301 QHash<QString, FakePortalRequest *> m_requests;
302 QHash<QString, FakePortalSession *> m_sessions;
303 QVariantMap m_selectSourcesOptions;
304 uint m_nextResponseCode = 0;
305 quint64 m_screenCastGeneration = 0;
306 quint64 m_cameraGeneration = 0;
307 int m_fallbackToken = 0;
308};
309
310class FakeScreenCastAdaptor : public QDBusAbstractAdaptor
311{
312 Q_OBJECT
313 Q_CLASSINFO("D-Bus Interface", "org.freedesktop.portal.ScreenCast")
314 Q_PROPERTY(uint version READ version)
315 Q_PROPERTY(uint AvailableSourceTypes READ availableSourceTypes)
316 Q_PROPERTY(uint AvailableCursorModes READ availableCursorModes)
317
318public:
319 explicit FakeScreenCastAdaptor(FakePortalObject *portal)
320 : QDBusAbstractAdaptor(portal), m_portal(portal)
321 {
322 }
323
324 uint version() const { return 5; }
325 uint availableSourceTypes() const { return 3; } // MONITOR | WINDOW
326 uint availableCursorModes() const { return 7; } // HIDDEN | EMBEDDED | METADATA
327
328 // Adaptors export all public slots, signals and properties, so no Q_SCRIPTABLE
329 // is needed and ExportAdaptors alone is enough at registration time.
330public Q_SLOTS:
331 QDBusObjectPath CreateSession(const QVariantMap &options)
332 {
333 m_portal->supersedePending(FakePortalObject::RequestScope::ScreenCast);
334
335 const QString sessionPath =
336 m_portal->ensureSession(options.value(u"session_handle_token"_s).toString());
337
338 return m_portal->respond(options, QVariantMap{ { u"session_handle"_s, sessionPath } },
339 FakePortalObject::RequestScope::ScreenCast);
340 }
341
342 QDBusObjectPath SelectSources(const QDBusObjectPath &session, const QVariantMap &options)
343 {
344 Q_UNUSED(session);
345 m_portal->rememberSelectSourcesOptions(options);
346 return m_portal->respond(options, {}, FakePortalObject::RequestScope::ScreenCast);
347 }
348
349 QDBusObjectPath Start(const QDBusObjectPath &session, const QString &parentWindow,
350 const QVariantMap &options)
351 {
352 Q_UNUSED(session);
353 Q_UNUSED(parentWindow);
354
355 const QSize frameSize = m_portal->frameSize();
356
357 FakePortalStream stream;
358 stream.nodeId = m_portal->source().nodeId();
359 stream.properties = QVariantMap{
360 { u"id"_s, u"qt-fake-monitor-0"_s },
361 { u"position"_s, QVariant::fromValue(FakePortalPoint{ 0, 0 }) },
362 { u"size"_s,
363 QVariant::fromValue(FakePortalPoint{ frameSize.width(), frameSize.height() }) },
364 { u"source_type"_s, uint(1) }, // MONITOR
365 };
366
367 return m_portal->respond(
368 options,
369 QVariantMap{
370 { u"streams"_s, QVariant::fromValue(QList<FakePortalStream>{ stream }) } },
371 FakePortalObject::RequestScope::ScreenCast);
372 }
373
374 QDBusUnixFileDescriptor OpenPipeWireRemote(const QDBusObjectPath &session,
375 const QVariantMap &options)
376 {
377 Q_UNUSED(session);
378 Q_UNUSED(options);
379
380 auto fd = connectToPipeWire();
381 if (!fd) {
382 m_portal->failWith(QDBusError::Failed, fd.error());
383 return QDBusUnixFileDescriptor();
384 }
385
386 return QDBusUnixFileDescriptor(fd->get()); // duplicates the descriptor
387 }
388
389private:
390 FakePortalObject *m_portal;
391};
392
393//! Control interface used by QFakeXdgPortalFixture to drive the helper.
394class FakeXdgPortalControl : public QObject
395{
396 Q_OBJECT
397 Q_CLASSINFO("D-Bus Interface", "org.qtproject.qtmultimedia.FakeXdgPortal")
398
399public:
400 FakeXdgPortalControl(QSyntheticPipeWireVideoSource &source, FakePortalObject &portal,
401 QSize frameSize, QObject *parent)
402 : QObject(parent), m_source(source), m_portal(portal), m_frameSize(frameSize)
403 {
404 }
405
406public Q_SLOTS:
407 Q_SCRIPTABLE void SetScene(const QVariantMap &sceneMap)
408 {
409 SyntheticVideoScene scene = SyntheticVideoScene::fromVariantMap(sceneMap);
410 // The stream format was negotiated at startup, so the frame size is fixed
411 // for the lifetime of the helper.
412 scene.frameSize = m_frameSize;
413 m_source.setScene(scene);
414 }
415
416 Q_SCRIPTABLE uint NodeId() { return m_source.nodeId().value; }
417
418 //! What the client asked for in SelectSources, so tests can assert on it.
419 Q_SCRIPTABLE QVariantMap TakeSelectSourcesOptions()
420 {
421 return m_portal.takeSelectSourcesOptions();
422 }
423
424 //! Makes the next portal request answer with \a code, e.g. 1 for "cancelled".
425 Q_SCRIPTABLE void SetNextResponseCode(uint code) { m_portal.setNextResponseCode(code); }
426
427 //! Revokes the session mid-stream, as a compositor would.
428 Q_SCRIPTABLE void CloseSessions() { m_portal.closeSessions(); }
429
430private:
431 QSyntheticPipeWireVideoSource &m_source;
432 FakePortalObject &m_portal;
433 const QSize m_frameSize;
434};
435
436} // namespace
437
438namespace {
439
440struct CLIArgs
441{
442 QSize frameSize;
443 std::chrono::milliseconds responseDelay;
444 int frameRate = 0;
445};
446
447std::optional<CLIArgs> parseArgs(int argc, char **argv)
448{
449 QCommandLineOption frameSizeOption(u"frame-size"_s, u"virtual screen size, WxH"_s, u"size"_s,
450 u"1920x1080"_s);
451 QCommandLineOption responseDelayOption(u"response-delay"_s,
452 u"milliseconds before answering a portal request"_s,
453 u"ms"_s, u"25"_s);
454 QCommandLineOption frameRateOption(u"frame-rate"_s, u"frames per second to publish"_s, u"fps"_s,
455 u"60"_s);
456 QCommandLineParser parser;
457 parser.addOptions({ frameSizeOption, responseDelayOption, frameRateOption });
458 parser.addOption(
459 QCommandLineOption(QString::fromLatin1(QtFakeXdgPortal::HelperArgument).mid(2)));
460
461 using namespace QtMultimediaPrivate;
462
463 QStringList arguments = views::transform(QSpan(argv, argc), [](const char *arg) {
464 return QString::fromLocal8Bit(arg);
465 }) | ranges::to<QStringList>();
466
467 parser.process(arguments);
468
469 const QStringList dimensions = parser.value(frameSizeOption).split(u'x');
470 const QSize frameSize(dimensions.value(0).toInt(), dimensions.value(1).toInt());
471 if (frameSize.isEmpty()) {
472 qCritical("fake xdg portal helper: invalid --frame-size");
473 return std::nullopt;
474 }
475
476 return CLIArgs{
477 .frameSize = frameSize,
478 .responseDelay = std::chrono::milliseconds(parser.value(responseDelayOption).toInt()),
479 .frameRate = std::max(1, parser.value(frameRateOption).toInt()),
480 };
481}
482
483} // namespace
484
486
487int runPortalHelper(int argc, char **argv)
488{
489 QCoreApplication app(argc, argv);
490
491 const std::optional<CLIArgs> args = parseArgs(argc, argv);
492 if (!args)
493 return 1;
494 const QSize frameSize = args->frameSize;
495
496 qDBusRegisterMetaType<FakePortalPoint>();
497 qDBusRegisterMetaType<FakePortalStream>();
498 qDBusRegisterMetaType<QList<FakePortalStream>>();
499
500 QDBusConnection bus = QDBusConnection::sessionBus();
501 if (!bus.isConnected()) {
502 qCritical("fake xdg portal helper: no session bus");
503 return 1;
504 }
505
506 SyntheticVideoScene scene;
507 scene.frameSize = frameSize;
508
509 QSyntheticPipeWireVideoSource source;
510 if (auto result = source.start(scene); !result) {
511 qCritical("fake xdg portal helper: %s", qPrintable(result.error()));
512 return 1;
513 }
514
515 // Clock the graph ourselves: with no session manager there is no other driver.
516 QChronoTimer frameTimer(1000ms / args->frameRate);
517 frameTimer.callOnTimeout(&app, [&] {
518 source.renderNextFrame();
519 });
520 frameTimer.start();
521
522 auto *portal = new FakePortalObject(source, frameSize, args->responseDelay, &app);
523 new FakeScreenCastAdaptor(portal);
524
525 if (!bus.registerObject(u"/org/freedesktop/portal/desktop"_s, portal,
526 QDBusConnection::ExportAdaptors)) {
527 qCritical("fake xdg portal helper: cannot export the portal object");
528 return 1;
529 }
530
531 auto *control = new FakeXdgPortalControl(source, *portal, frameSize, &app);
532 bus.registerObject(u"/org/qtproject/qtmultimedia/FakeXdgPortal"_s, control,
533 QDBusConnection::ExportScriptableContents);
534
535 // Claim the name only once the node exists, so that a client which sees the
536 // service is guaranteed to get a usable node id out of Start().
537 if (!bus.registerService(u"org.freedesktop.portal.Desktop"_s)) {
538 qCritical("fake xdg portal helper: cannot own org.freedesktop.portal.Desktop: %s",
539 qPrintable(bus.lastError().message()));
540 return 1;
541 }
542
543 // Handshake with QFakeXdgPortalFixture, which waits for this line.
544 std::printf("QT_FAKE_XDG_PORTAL_READY %u\n", source.nodeId().value);
545 std::fflush(stdout);
546
547 const int result = app.exec();
548 source.stop();
549 return result;
550}
551
552} // namespace QtFakeXdgPortal
553
554QT_END_NAMESPACE
555
556#include "qfakexdgportal.moc"
int runPortalHelper(int argc, char **argv)