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
qfakexdgportalfixture.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/qfakexdgportal_p.h>
7
8#include <QtMultimedia/private/qmultimedia_ranges_p.h>
9#include <QtMultimedia/private/qpipewire_support_p.h>
10#include <QtCore/qcoreapplication.h>
11#include <QtCore/qdeadlinetimer.h>
12#include <QtCore/qdebug.h>
13#include <QtCore/qdir.h>
14#include <QtCore/qfile.h>
15#include <QtCore/qprocess.h>
16#include <QtCore/qstandardpaths.h>
17#include <QtCore/qthread.h>
18#include <QtCore/qvarlengtharray.h>
19#include <QtDBus/qdbusconnection.h>
20#include <QtDBus/qdbusinterface.h>
21
22#include <algorithm>
23#include <chrono>
24#include <iterator>
25#include <string_view>
26#include <vector>
27
28#ifdef Q_OS_LINUX
29# include <sys/prctl.h>
30#endif
31
32#include <unistd.h>
33
34QT_BEGIN_NAMESPACE
35
36using namespace Qt::StringLiterals;
37using namespace std::chrono_literals;
38
39namespace {
40
41// Marks a process that has already been re-executed, so we never recurse.
42constexpr const char *PrivateBusMarker = "QT_MULTIMEDIA_TEST_XDG_PORTAL_PRIVATE_BUS";
43constexpr const char *FixtureModeVariable = "QT_MULTIMEDIA_TEST_XDG_PORTAL_FIXTURE";
44// Where the empty XDG data directory used to disarm D-Bus activation lives, so
45// that stop() can clean it up again.
46constexpr const char *PrivateDataDirVariable = "QT_MULTIMEDIA_TEST_XDG_PORTAL_DATA_DIR";
47
48constexpr auto StartupTimeout = 10s;
49constexpr int StartupTimeoutMs = int(std::chrono::milliseconds(StartupTimeout).count());
50
51/*
52 Deliberately not /usr/share/pipewire/minimal.conf: that one loads
53 module-protocol-pulse, which binds $XDG_RUNTIME_DIR/pulse/native and aborts the
54 whole daemon with EADDRINUSE whenever a developer's own pipewire-pulse is
55 running. We only need enough modules to host one video stream and link it.
56 Dropping spa-node-factory, access or rt from this list makes clients hang in
57 "connecting", so the set is load-bearing.
58*/
59/*
60 A session bus with no <servicedir> at all, so it can activate nothing.
61 Emptying XDG_DATA_DIRS is not enough: dbus-daemon's standard_session_servicedirs
62 also covers a compiled-in directory, so the real xdg-desktop-portal still got
63 activated. That mattered beyond just racing us for the bus name -- the activated
64 services inherit the test's stdout and outlive it, so ctest's execute_process
65 never saw EOF and hung long after the test itself had passed.
66*/
67constexpr const char *BusConfiguration = R"(<!DOCTYPE busconfig PUBLIC
68 "-//freedesktop//DTD D-Bus Bus Configuration 1.0//EN"
69 "http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
70<busconfig>
71 <type>session</type>
72 <keep_umask/>
73 <listen>unix:tmpdir=/tmp</listen>
74 <!-- deliberately no <servicedir>: nothing may be activated on this bus -->
75 <policy context="default">
76 <allow send_destination="*" eavesdrop="true"/>
77 <allow eavesdrop="true"/>
78 <allow own="*"/>
79 </policy>
80</busconfig>
81)";
82
83constexpr const char *DaemonConfiguration = R"(
84context.properties = {
85 link.max-buffers = 16
86 core.daemon = true
87 core.name = pipewire-0
88}
89
90context.spa-libs = {
91 support.* = support/libspa-support
92}
93
94context.modules = [
95 { name = libpipewire-module-rt
96 args = { nice.level = -11, rt.prio = 88 }
97 flags = [ ifexists nofail ]
98 }
99 { name = libpipewire-module-protocol-native }
100 { name = libpipewire-module-metadata }
101 { name = libpipewire-module-spa-node-factory }
102 { name = libpipewire-module-client-node }
103 { name = libpipewire-module-access }
104 { name = libpipewire-module-adapter }
105 { name = libpipewire-module-link-factory }
106]
107
108context.objects = []
109context.exec = []
110)";
111
112QString findPipeWireDaemon()
113{
114 QString path = QStandardPaths::findExecutable(u"pipewire"_s);
115 if (path.isEmpty())
116 path = QStandardPaths::findExecutable(u"pipewire"_s,
117 { u"/usr/bin"_s, u"/usr/local/bin"_s });
118 return path;
119}
120
121void killChildWithParent()
122{
123#ifdef Q_OS_LINUX
124 // Ensures an aborted or crashed test never leaves daemons behind on CI.
125 ::prctl(PR_SET_PDEATHSIG, SIGKILL);
126#endif
127}
128
129bool waitForProcessOutput(QProcess &process, QByteArray *accumulated, QByteArrayView needle,
130 std::chrono::milliseconds timeout)
131{
132 QDeadlineTimer deadline(timeout);
133 while (!deadline.hasExpired()) {
134 if (process.state() == QProcess::NotRunning && process.bytesAvailable() == 0)
135 return false;
136 if (process.waitForReadyRead(100))
137 accumulated->append(process.readAllStandardOutput());
138 if (accumulated->contains(needle))
139 return true;
140 }
141 return false;
142}
143
144} // namespace
145
146class QFakeXdgPortalFixture::Private
147{
148public:
157 bool active = false;
158
160 {
161 if (!runtimeDir.isEmpty()) {
162 QDir(runtimeDir).removeRecursively();
163 runtimeDir.clear();
164 }
165 }
166};
167
168QFakeXdgPortalFixture::QFakeXdgPortalFixture() : d(std::make_unique<Private>()) { }
169
170QFakeXdgPortalFixture::~QFakeXdgPortalFixture()
171{
172 stop();
173}
174
175bool QFakeXdgPortalFixture::isHelperProcess(int argc, char **argv)
176{
177 return std::any_of(argv + 1, argv + argc, [](const char *arg) {
178 return std::string_view(arg) == QtFakeXdgPortal::HelperArgument;
179 });
180}
181
182int QFakeXdgPortalFixture::runHelperProcess(int argc, char **argv)
183{
184 return QtFakeXdgPortal::runPortalHelper(argc, argv);
185}
186
187bool QFakeXdgPortalFixture::isForced()
188{
189#ifndef Q_OS_LINUX
190 return false;
191#else
192 const QString mode = qEnvironmentVariable(FixtureModeVariable).toLower();
193 return mode == u"fake" || mode == u"1";
194#endif
195}
196
197bool QFakeXdgPortalFixture::isRequested(QtFakeXdgPortal::Role role)
198{
199#ifndef Q_OS_LINUX
200 return false;
201#else
202 const QString mode = qEnvironmentVariable(FixtureModeVariable, u"auto"_s).toLower();
203 if (mode == u"none" || mode == u"0")
204 return false;
205 if (isForced())
206 return true;
207
208 switch (role) {
209 case QtFakeXdgPortal::Role::ScreenCast:
210 // auto: only where the real portal would need a human. On X11 the native
211 // X11 capture backend is used and needs no portal at all.
212 return qEnvironmentVariable("XDG_SESSION_TYPE").toLower() != u"x11";
213 case QtFakeXdgPortal::Role::Camera:
214 // There is no native alternative to the portal path to fall back on.
215 return true;
216 }
217 Q_UNREACHABLE_RETURN(false);
218#endif
219}
220
221bool QFakeXdgPortalFixture::prerequisitesAvailable(QString *unavailableReason)
222{
223 const auto missing = [&](const QString &reason) {
224 if (unavailableReason)
225 *unavailableReason = reason;
226 return false;
227 };
228
229 if (findPipeWireDaemon().isEmpty())
230 return missing(u"the 'pipewire' daemon was not found in PATH"_s);
231 if (QStandardPaths::findExecutable(u"dbus-run-session"_s).isEmpty())
232 return missing(u"'dbus-run-session' was not found in PATH"_s);
233 return true;
234}
235
236bool QFakeXdgPortalFixture::isRunningOnPrivateBus()
237{
238 return qEnvironmentVariableIsSet(PrivateBusMarker);
239}
240
241void QFakeXdgPortalFixture::reexecUnderPrivateBusIfRequested(int argc, char **argv,
242 QtFakeXdgPortal::Role role)
243{
244 if (isRunningOnPrivateBus() || isHelperProcess(argc, argv) || !isRequested(role))
245 return;
246 if (!prerequisitesAvailable(nullptr))
247 return; // start() reports this properly; here we simply do not re-exec
248
249 const QString dbusRunSession = QStandardPaths::findExecutable(u"dbus-run-session"_s);
250
251 // Assembled into its own QProcessEnvironment and handed to execve() below,
252 // rather than qputenv()'d into this process: if execve() fails (the binary
253 // could in principle vanish between the check above and here), this process
254 // keeps running -- as the fallback path after execve() does -- and should
255 // find its original, untouched environment there.
256 QProcessEnvironment environment = QProcessEnvironment::systemEnvironment();
257
258 // QApplication asks for org.freedesktop.portal.Desktop while setting up its
259 // platform services. On a bus that can activate things, that starts the real
260 // xdg-desktop-portal -- which claims the name before our helper exists, and
261 // whose processes inherit our stdout and outlive us. Give the bus a config
262 // with no service directories at all, and empty the XDG data dirs too.
263 QByteArray dataDirTemplate = "/tmp/qtportal.XXXXXX"_ba;
264 QString busConfigurationPath;
265 if (::mkdtemp(dataDirTemplate.data())) {
266 const QString dataDir = QString::fromLocal8Bit(dataDirTemplate);
267 environment.insert(u"XDG_DATA_DIRS"_s, dataDir);
268 environment.insert(u"XDG_DATA_HOME"_s, dataDir);
269 environment.insert(QString::fromLatin1(PrivateDataDirVariable), dataDir);
270
271 busConfigurationPath = dataDir + u"/session.conf"_s;
272 QFile busConfiguration(busConfigurationPath);
273 if (busConfiguration.open(QIODevice::WriteOnly))
274 busConfiguration.write(BusConfiguration);
275 else
276 busConfigurationPath.clear();
277 }
278 if (busConfigurationPath.isEmpty()) {
279 qWarning() << "QFakeXdgPortalFixture: could not disarm D-Bus activation; the real"
280 << "xdg-desktop-portal may claim the portal name first";
281 }
282
283 environment.insert(QString::fromLatin1(PrivateBusMarker), u"1"_s);
284 // The fixture only makes sense against the PipeWire backends, and the values
285 // are cached on first use inside the FFmpeg integration.
286 switch (role) {
287 case QtFakeXdgPortal::Role::ScreenCast:
288 environment.insert(u"QT_SCREEN_CAPTURE_BACKEND"_s, u"pipewire"_s);
289 break;
290 case QtFakeXdgPortal::Role::Camera:
291 environment.insert(u"QT_CAMERA_BACKEND"_s, u"pipewire"_s);
292 environment.insert(u"QT_PIPEWIRE_CAMERA_FORCE_PORTAL"_s, u"1"_s);
293 break;
294 }
295
296 QVarLengthArray<char *, 64> arguments;
297 QByteArray program = dbusRunSession.toLocal8Bit();
298 QByteArray configOption = "--config-file="_ba + busConfigurationPath.toLocal8Bit();
299 QByteArray separator = "--"_ba;
300 arguments.push_back(program.data());
301 if (!busConfigurationPath.isEmpty())
302 arguments.push_back(configOption.data());
303 arguments.push_back(separator.data());
304 std::copy(argv, argv + argc, std::back_inserter(arguments));
305 arguments.push_back(nullptr);
306
307 // execve() takes the replacement environment explicitly, so the "KEY=VALUE"
308 // entries need to survive as long as the exec call itself: keep their storage
309 // in environmentStorage, and only take addresses into it once it is done
310 // growing.
311 const QStringList environmentList = environment.toStringList();
312 std::vector<QByteArray> environmentStorage;
313 environmentStorage.reserve(environmentList.size());
314 QtMultimediaPrivate::ranges::transform(environmentList, std::back_inserter(environmentStorage),
315 [](const QString &entry) {
316 return entry.toLocal8Bit();
317 });
318
319 // Plain std::transform, not the ranges wrapper above: its C++17 fallback binds
320 // the source element as const, which would return a read-only char* here.
321 QVarLengthArray<char *, 64> envp;
322 std::transform(environmentStorage.begin(), environmentStorage.end(), std::back_inserter(envp),
323 [](QByteArray &entry) {
324 return entry.data();
325 });
326 envp.push_back(nullptr);
327
328 ::execve(program.constData(), arguments.data(), envp.data());
329
330 // Only reached if execve() failed; carry on without the private bus and let
331 // start() produce a comprehensible error. This process's own environment was
332 // never touched, so there is nothing to undo here.
333 qWarning() << "QFakeXdgPortalFixture: could not re-exec under dbus-run-session";
334}
335
336/*
337 (Re)launches the portal helper for a given virtual screen size. The size is
338 baked into the pw_stream format at connect time, so changing it means starting
339 a new helper -- which is cheap, and keeps the synthetic screen exactly the size
340 a test expects rather than forcing tests to accommodate the fixture.
341*/
342q23::expected<void, QString> QFakeXdgPortalFixture::startHelper(QSize frameSize)
343{
344 stopHelper();
345
346 // The helper is this very binary, re-executed in portal mode.
347 d->helper.setProcessEnvironment(d->environment);
348 d->helper.setChildProcessModifier(killChildWithParent);
349 d->helper.setProgram(QCoreApplication::applicationFilePath());
350 d->helper.setArguments({
351 QString::fromLatin1(QtFakeXdgPortal::HelperArgument),
352 u"--frame-size"_s,
353 u"%1x%2"_s.arg(frameSize.width()).arg(frameSize.height()),
354 u"--role"_s,
355 d->role == QtFakeXdgPortal::Role::Camera ? u"camera"_s : u"screencast"_s,
356 });
357 d->helper.start();
358 if (!d->helper.waitForStarted(StartupTimeoutMs))
359 return q23::unexpected(u"could not start the fake portal helper"_s);
360
361 if (!waitForProcessOutput(d->helper, &d->helperOutput,
362 QByteArrayView("QT_FAKE_XDG_PORTAL_READY"), StartupTimeout)) {
363 return q23::unexpected(u"the fake portal helper did not become ready: "_s
364 + QString::fromLocal8Bit(d->helper.readAllStandardError()));
365 }
366
367 d->nodeId = QtPipeWire::ObjectId{ d->helperOutput.split(' ').value(1).trimmed().toUInt() };
368 d->frameSize = frameSize;
369 return {};
370}
371
372void QFakeXdgPortalFixture::stopHelper()
373{
374 if (d->helper.state() != QProcess::NotRunning) {
375 d->helper.terminate();
376 if (!d->helper.waitForFinished(2000))
377 d->helper.kill();
378 d->helper.waitForFinished(2000);
379 }
380 d->helperOutput.clear();
381 d->nodeId = QtPipeWire::ObjectId{ 0 };
382 d->frameSize = {};
383}
384
385q23::expected<void, QString> QFakeXdgPortalFixture::start(const SyntheticVideoScene &initialScene,
386 QtFakeXdgPortal::Role role)
387{
388 const auto fail = [this](const QString &reason) {
389 stop();
390 return q23::unexpected(reason);
391 };
392
393 if (d->active)
394 return fail(u"already started"_s);
395
396 QString unavailableReason;
397 if (!prerequisitesAvailable(&unavailableReason))
398 return fail(unavailableReason);
399
400 if (!isRunningOnPrivateBus()) {
401 return fail(u"not running on a private session bus; "
402 "main() must call reexecUnderPrivateBusIfRequested()"_s);
403 }
404
405 // sockaddr_un.sun_path is only 108 bytes, and a build-tree path blows that
406 // limit, so the runtime directory has to live somewhere short.
407 QByteArray runtimeTemplate = "/tmp/qtpw.XXXXXX"_ba;
408 if (!::mkdtemp(runtimeTemplate.data()))
409 return fail(u"could not create a private pipewire runtime directory"_s);
410 d->runtimeDir = QString::fromLocal8Bit(runtimeTemplate);
411
412 const QString configurationPath = d->runtimeDir + u"/qt-fake-xdg-portal.conf"_s;
413 QFile configuration(configurationPath);
414 if (!configuration.open(QIODevice::WriteOnly))
415 return fail(u"could not write "_s + configurationPath);
416 configuration.write(DaemonConfiguration);
417 configuration.close();
418
419 QProcessEnvironment environment = QProcessEnvironment::systemEnvironment();
420 environment.insert(u"PIPEWIRE_RUNTIME_DIR"_s, d->runtimeDir);
421 environment.remove(u"PIPEWIRE_REMOTE"_s);
422
423 d->daemon.setProcessEnvironment(environment);
424 d->daemon.setChildProcessModifier(killChildWithParent);
425 d->daemon.setProgram(findPipeWireDaemon());
426 d->daemon.setArguments({ u"-c"_s, configurationPath });
427 d->daemon.start();
428 if (!d->daemon.waitForStarted(StartupTimeoutMs))
429 return fail(u"could not start the private pipewire daemon"_s);
430
431 const QString socketPath = d->runtimeDir + u"/pipewire-0"_s;
432 QDeadlineTimer deadline(StartupTimeout);
433 while (!QFile::exists(socketPath)) {
434 if (deadline.hasExpired() || d->daemon.state() == QProcess::NotRunning) {
435 return fail(u"the private pipewire daemon did not create %1: %2"_s.arg(
436 socketPath, QString::fromLocal8Bit(d->daemon.readAllStandardError())));
437 }
438 QThread::msleep(20);
439 }
440
441 d->environment = environment;
442 d->role = role;
443
444 if (auto result = startHelper(initialScene.frameSize); !result)
445 return fail(result.error());
446
447 d->active = true;
448
449 return setScene(initialScene);
450}
451
452void QFakeXdgPortalFixture::stop()
453{
454 stopHelper();
455
456 if (d->daemon.state() != QProcess::NotRunning) {
457 d->daemon.terminate();
458 if (!d->daemon.waitForFinished(2000))
459 d->daemon.kill();
460 d->daemon.waitForFinished(2000);
461 }
462
463 d->removeRuntimeDir();
464 if (qEnvironmentVariableIsSet(PrivateDataDirVariable)) {
465 QDir(qEnvironmentVariable(PrivateDataDirVariable)).removeRecursively();
466 qunsetenv(PrivateDataDirVariable);
467 }
468 d->helperOutput.clear();
469 d->nodeId = QtPipeWire::ObjectId{ 0 };
470 d->active = false;
471}
472
473bool QFakeXdgPortalFixture::isActive() const
474{
475 return d->active;
476}
477
478quint32 QFakeXdgPortalFixture::nodeId() const
479{
480 return d->nodeId.value;
481}
482
483q23::expected<void, QString> QFakeXdgPortalFixture::setScene(const SyntheticVideoScene &scene)
484{
485 if (!d->active)
486 return q23::unexpected(u"the fixture is not running"_s);
487
488 if (scene.frameSize.isValid() && scene.frameSize != d->frameSize) {
489 if (auto result = startHelper(scene.frameSize); !result)
490 return result;
491 }
492
493 QDBusInterface control(
494 u"org.freedesktop.portal.Desktop"_s, u"/org/qtproject/qtmultimedia/FakeXdgPortal"_s,
495 u"org.qtproject.qtmultimedia.FakeXdgPortal"_s, QDBusConnection::sessionBus());
496 const QDBusMessage reply = control.call(u"SetScene"_s, scene.toVariantMap());
497 if (reply.type() == QDBusMessage::ErrorMessage)
498 return q23::unexpected(u"SetScene failed: "_s + reply.errorMessage());
499 return {};
500}
501
502q23::expected<void, QString> QFakeXdgPortalFixture::closeSessions()
503{
504 if (!d->active)
505 return q23::unexpected(u"the fixture is not running"_s);
506
507 QDBusInterface control(
508 u"org.freedesktop.portal.Desktop"_s, u"/org/qtproject/qtmultimedia/FakeXdgPortal"_s,
509 u"org.qtproject.qtmultimedia.FakeXdgPortal"_s, QDBusConnection::sessionBus());
510 const QDBusMessage reply = control.call(u"CloseSessions"_s);
511 if (reply.type() == QDBusMessage::ErrorMessage)
512 return q23::unexpected(u"CloseSessions failed: "_s + reply.errorMessage());
513 return {};
514}
515
516QT_END_NAMESPACE