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
393class FakeCameraAdaptor : public QDBusAbstractAdaptor
394{
395 Q_OBJECT
396 Q_CLASSINFO("D-Bus Interface", "org.freedesktop.portal.Camera")
397 Q_PROPERTY(uint version READ version)
398 Q_PROPERTY(bool IsCameraPresent READ isCameraPresent)
399
400public:
401 explicit FakeCameraAdaptor(FakePortalObject *portal)
402 : QDBusAbstractAdaptor(portal), m_portal(portal)
403 {
404 }
405
406 uint version() const { return 1; }
407 bool isCameraPresent() const { return true; }
408
409public Q_SLOTS:
410 QDBusObjectPath AccessCamera(const QVariantMap &options)
411 {
412 m_portal->supersedePending(FakePortalObject::RequestScope::Camera);
413 return m_portal->respond(options, {}, FakePortalObject::RequestScope::Camera);
414 }
415
416 QDBusUnixFileDescriptor OpenPipeWireRemote(const QVariantMap &options)
417 {
418 Q_UNUSED(options);
419
420 auto fd = connectToPipeWire();
421 if (!fd) {
422 m_portal->failWith(QDBusError::Failed, fd.error());
423 return QDBusUnixFileDescriptor();
424 }
425
426 return QDBusUnixFileDescriptor(fd->get()); // duplicates the descriptor
427 }
428
429private:
430 FakePortalObject *m_portal;
431};
432
433//! Control interface used by QFakeXdgPortalFixture to drive the helper.
434class FakeXdgPortalControl : public QObject
435{
436 Q_OBJECT
437 Q_CLASSINFO("D-Bus Interface", "org.qtproject.qtmultimedia.FakeXdgPortal")
438
439public:
440 FakeXdgPortalControl(QSyntheticPipeWireVideoSource &source, FakePortalObject &portal,
441 QSize frameSize, QObject *parent)
442 : QObject(parent), m_source(source), m_portal(portal), m_frameSize(frameSize)
443 {
444 }
445
446public Q_SLOTS:
447 Q_SCRIPTABLE void SetScene(const QVariantMap &sceneMap)
448 {
449 SyntheticVideoScene scene = SyntheticVideoScene::fromVariantMap(sceneMap);
450 // The stream format was negotiated at startup, so the frame size is fixed
451 // for the lifetime of the helper.
452 scene.frameSize = m_frameSize;
453 m_source.setScene(scene);
454 }
455
456 Q_SCRIPTABLE uint NodeId() { return m_source.nodeId().value; }
457
458 //! What the client asked for in SelectSources, so tests can assert on it.
459 Q_SCRIPTABLE QVariantMap TakeSelectSourcesOptions()
460 {
461 return m_portal.takeSelectSourcesOptions();
462 }
463
464 //! Makes the next portal request answer with \a code, e.g. 1 for "cancelled".
465 Q_SCRIPTABLE void SetNextResponseCode(uint code) { m_portal.setNextResponseCode(code); }
466
467 //! Revokes the session mid-stream, as a compositor would.
468 Q_SCRIPTABLE void CloseSessions() { m_portal.closeSessions(); }
469
470private:
471 QSyntheticPipeWireVideoSource &m_source;
472 FakePortalObject &m_portal;
473 const QSize m_frameSize;
474};
475
476} // namespace
477
478namespace {
479
480struct CLIArgs
481{
482 QSize frameSize;
483 std::chrono::milliseconds responseDelay;
484 int frameRate = 0;
486};
487
488std::optional<CLIArgs> parseArgs(int argc, char **argv)
489{
490 QCommandLineOption frameSizeOption(u"frame-size"_s, u"virtual screen size, WxH"_s, u"size"_s,
491 u"1920x1080"_s);
492 QCommandLineOption responseDelayOption(u"response-delay"_s,
493 u"milliseconds before answering a portal request"_s,
494 u"ms"_s, u"25"_s);
495 QCommandLineOption frameRateOption(u"frame-rate"_s, u"frames per second to publish"_s, u"fps"_s,
496 u"60"_s);
497 QCommandLineOption roleOption(u"role"_s, u"portal to fake, screencast or camera"_s, u"role"_s,
498 u"screencast"_s);
499 QCommandLineParser parser;
500 parser.addOptions({ frameSizeOption, responseDelayOption, frameRateOption, roleOption });
501 parser.addOption(
502 QCommandLineOption(QString::fromLatin1(QtFakeXdgPortal::HelperArgument).mid(2)));
503
504 using namespace QtMultimediaPrivate;
505
506 QStringList arguments = views::transform(QSpan(argv, argc), [](const char *arg) {
507 return QString::fromLocal8Bit(arg);
508 }) | ranges::to<QStringList>();
509
510 parser.process(arguments);
511
512 const QStringList dimensions = parser.value(frameSizeOption).split(u'x');
513 const QSize frameSize(dimensions.value(0).toInt(), dimensions.value(1).toInt());
514 if (frameSize.isEmpty()) {
515 qCritical("fake xdg portal helper: invalid --frame-size");
516 return std::nullopt;
517 }
518
519 const QString role = parser.value(roleOption);
520 if (role != u"screencast" && role != u"camera") {
521 qCritical("fake xdg portal helper: invalid --role");
522 return std::nullopt;
523 }
524
525 return CLIArgs{
526 .frameSize = frameSize,
527 .responseDelay = std::chrono::milliseconds(parser.value(responseDelayOption).toInt()),
528 .frameRate = std::max(1, parser.value(frameRateOption).toInt()),
529 .role = role == u"camera" ? QtFakeXdgPortal::Role::Camera
531 };
532}
533
534} // namespace
535
537
538int runPortalHelper(int argc, char **argv)
539{
540 QCoreApplication app(argc, argv);
541
542 const std::optional<CLIArgs> args = parseArgs(argc, argv);
543 if (!args)
544 return 1;
545 const QSize frameSize = args->frameSize;
546
547 qDBusRegisterMetaType<FakePortalPoint>();
548 qDBusRegisterMetaType<FakePortalStream>();
549 qDBusRegisterMetaType<QList<FakePortalStream>>();
550
551 QDBusConnection bus = QDBusConnection::sessionBus();
552 if (!bus.isConnected()) {
553 qCritical("fake xdg portal helper: no session bus");
554 return 1;
555 }
556
557 SyntheticVideoScene scene;
558 scene.frameSize = frameSize;
559
560 QSyntheticPipeWireVideoSource source;
561 const QSyntheticPipeWireVideoSourceConfig sourceConfig = args->role == Role::Camera
562 ? QSyntheticPipeWireVideoSourceConfig::camera()
563 : QSyntheticPipeWireVideoSourceConfig::screenCast();
564 if (auto result = source.start(scene, sourceConfig); !result) {
565 qCritical("fake xdg portal helper: %s", qPrintable(result.error()));
566 return 1;
567 }
568
569 // Clock the graph ourselves: with no session manager there is no other driver.
570 QChronoTimer frameTimer(1000ms / args->frameRate);
571 frameTimer.callOnTimeout(&app, [&] {
572 source.renderNextFrame();
573 });
574 frameTimer.start();
575
576 auto *portal = new FakePortalObject(source, frameSize, args->responseDelay, &app);
577 switch (args->role) {
578 case Role::ScreenCast:
579 new FakeScreenCastAdaptor(portal);
580 break;
581 case Role::Camera:
582 new FakeCameraAdaptor(portal);
583 break;
584 }
585
586 if (!bus.registerObject(u"/org/freedesktop/portal/desktop"_s, portal,
587 QDBusConnection::ExportAdaptors)) {
588 qCritical("fake xdg portal helper: cannot export the portal object");
589 return 1;
590 }
591
592 auto *control = new FakeXdgPortalControl(source, *portal, frameSize, &app);
593 bus.registerObject(u"/org/qtproject/qtmultimedia/FakeXdgPortal"_s, control,
594 QDBusConnection::ExportScriptableContents);
595
596 // Claim the name only once the node exists, so that a client which sees the
597 // service is guaranteed to get a usable node id out of Start().
598 if (!bus.registerService(u"org.freedesktop.portal.Desktop"_s)) {
599 qCritical("fake xdg portal helper: cannot own org.freedesktop.portal.Desktop: %s",
600 qPrintable(bus.lastError().message()));
601 return 1;
602 }
603
604 // Handshake with QFakeXdgPortalFixture, which waits for this line.
605 std::printf("QT_FAKE_XDG_PORTAL_READY %u\n", source.nodeId().value);
606 std::fflush(stdout);
607
608 const int result = app.exec();
609 source.stop();
610 return result;
611}
612
613} // namespace QtFakeXdgPortal
614
615QT_END_NAMESPACE
616
617#include "qfakexdgportal.moc"
int runPortalHelper(int argc, char **argv)