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:
156 bool active = false;
157
159 {
160 if (!runtimeDir.isEmpty()) {
161 QDir(runtimeDir).removeRecursively();
162 runtimeDir.clear();
163 }
164 }
165};
166
167QFakeXdgPortalFixture::QFakeXdgPortalFixture() : d(std::make_unique<Private>()) { }
168
169QFakeXdgPortalFixture::~QFakeXdgPortalFixture()
170{
171 stop();
172}
173
174bool QFakeXdgPortalFixture::isHelperProcess(int argc, char **argv)
175{
176 return std::any_of(argv + 1, argv + argc, [](const char *arg) {
177 return std::string_view(arg) == QtFakeXdgPortal::HelperArgument;
178 });
179}
180
181int QFakeXdgPortalFixture::runHelperProcess(int argc, char **argv)
182{
183 return QtFakeXdgPortal::runPortalHelper(argc, argv);
184}
185
186bool QFakeXdgPortalFixture::isRequested()
187{
188#ifndef Q_OS_LINUX
189 return false;
190#else
191 // This file is only compiled when QT_FEATURE_pipewire_screencapture is on.
192 const QString mode = qEnvironmentVariable(FixtureModeVariable, u"auto"_s).toLower();
193 if (mode == u"none" || mode == u"0")
194 return false;
195 if (mode == u"fake" || mode == u"1")
196 return true;
197
198 // auto: only where the real portal would need a human. On X11 the native
199 // X11 capture backend is used and needs no portal at all.
200 return qEnvironmentVariable("XDG_SESSION_TYPE").toLower() != u"x11";
201#endif
202}
203
204bool QFakeXdgPortalFixture::prerequisitesAvailable(QString *unavailableReason)
205{
206 const auto missing = [&](const QString &reason) {
207 if (unavailableReason)
208 *unavailableReason = reason;
209 return false;
210 };
211
212 if (findPipeWireDaemon().isEmpty())
213 return missing(u"the 'pipewire' daemon was not found in PATH"_s);
214 if (QStandardPaths::findExecutable(u"dbus-run-session"_s).isEmpty())
215 return missing(u"'dbus-run-session' was not found in PATH"_s);
216 return true;
217}
218
219bool QFakeXdgPortalFixture::isRunningOnPrivateBus()
220{
221 return qEnvironmentVariableIsSet(PrivateBusMarker);
222}
223
224void QFakeXdgPortalFixture::reexecUnderPrivateBusIfRequested(int argc, char **argv)
225{
226 if (isRunningOnPrivateBus() || isHelperProcess(argc, argv) || !isRequested())
227 return;
228 if (!prerequisitesAvailable(nullptr))
229 return; // start() reports this properly; here we simply do not re-exec
230
231 const QString dbusRunSession = QStandardPaths::findExecutable(u"dbus-run-session"_s);
232
233 // Assembled into its own QProcessEnvironment and handed to execve() below,
234 // rather than qputenv()'d into this process: if execve() fails (the binary
235 // could in principle vanish between the check above and here), this process
236 // keeps running -- as the fallback path after execve() does -- and should
237 // find its original, untouched environment there.
238 QProcessEnvironment environment = QProcessEnvironment::systemEnvironment();
239
240 // QApplication asks for org.freedesktop.portal.Desktop while setting up its
241 // platform services. On a bus that can activate things, that starts the real
242 // xdg-desktop-portal -- which claims the name before our helper exists, and
243 // whose processes inherit our stdout and outlive us. Give the bus a config
244 // with no service directories at all, and empty the XDG data dirs too.
245 QByteArray dataDirTemplate = "/tmp/qtportal.XXXXXX"_ba;
246 QString busConfigurationPath;
247 if (::mkdtemp(dataDirTemplate.data())) {
248 const QString dataDir = QString::fromLocal8Bit(dataDirTemplate);
249 environment.insert(u"XDG_DATA_DIRS"_s, dataDir);
250 environment.insert(u"XDG_DATA_HOME"_s, dataDir);
251 environment.insert(QString::fromLatin1(PrivateDataDirVariable), dataDir);
252
253 busConfigurationPath = dataDir + u"/session.conf"_s;
254 QFile busConfiguration(busConfigurationPath);
255 if (busConfiguration.open(QIODevice::WriteOnly))
256 busConfiguration.write(BusConfiguration);
257 else
258 busConfigurationPath.clear();
259 }
260 if (busConfigurationPath.isEmpty()) {
261 qWarning() << "QFakeXdgPortalFixture: could not disarm D-Bus activation; the real"
262 << "xdg-desktop-portal may claim the portal name first";
263 }
264
265 environment.insert(QString::fromLatin1(PrivateBusMarker), u"1"_s);
266 // The fixture only makes sense against the PipeWire capture backend, and the
267 // value is cached on first use inside the FFmpeg integration.
268 environment.insert(u"QT_SCREEN_CAPTURE_BACKEND"_s, u"pipewire"_s);
269
270 QVarLengthArray<char *, 64> arguments;
271 QByteArray program = dbusRunSession.toLocal8Bit();
272 QByteArray configOption = "--config-file="_ba + busConfigurationPath.toLocal8Bit();
273 QByteArray separator = "--"_ba;
274 arguments.push_back(program.data());
275 if (!busConfigurationPath.isEmpty())
276 arguments.push_back(configOption.data());
277 arguments.push_back(separator.data());
278 std::copy(argv, argv + argc, std::back_inserter(arguments));
279 arguments.push_back(nullptr);
280
281 // execve() takes the replacement environment explicitly, so the "KEY=VALUE"
282 // entries need to survive as long as the exec call itself: keep their storage
283 // in environmentStorage, and only take addresses into it once it is done
284 // growing.
285 const QStringList environmentList = environment.toStringList();
286 std::vector<QByteArray> environmentStorage;
287 environmentStorage.reserve(environmentList.size());
288 QtMultimediaPrivate::ranges::transform(environmentList, std::back_inserter(environmentStorage),
289 [](const QString &entry) {
290 return entry.toLocal8Bit();
291 });
292
293 // Plain std::transform, not the ranges wrapper above: its C++17 fallback binds
294 // the source element as const, which would return a read-only char* here.
295 QVarLengthArray<char *, 64> envp;
296 std::transform(environmentStorage.begin(), environmentStorage.end(), std::back_inserter(envp),
297 [](QByteArray &entry) {
298 return entry.data();
299 });
300 envp.push_back(nullptr);
301
302 ::execve(program.constData(), arguments.data(), envp.data());
303
304 // Only reached if execve() failed; carry on without the private bus and let
305 // start() produce a comprehensible error. This process's own environment was
306 // never touched, so there is nothing to undo here.
307 qWarning() << "QFakeXdgPortalFixture: could not re-exec under dbus-run-session";
308}
309
310/*
311 (Re)launches the portal helper for a given virtual screen size. The size is
312 baked into the pw_stream format at connect time, so changing it means starting
313 a new helper -- which is cheap, and keeps the synthetic screen exactly the size
314 a test expects rather than forcing tests to accommodate the fixture.
315*/
316q23::expected<void, QString> QFakeXdgPortalFixture::startHelper(QSize frameSize)
317{
318 stopHelper();
319
320 // The helper is this very binary, re-executed in portal mode.
321 d->helper.setProcessEnvironment(d->environment);
322 d->helper.setChildProcessModifier(killChildWithParent);
323 d->helper.setProgram(QCoreApplication::applicationFilePath());
324 d->helper.setArguments({
325 QString::fromLatin1(QtFakeXdgPortal::HelperArgument),
326 u"--frame-size"_s,
327 u"%1x%2"_s.arg(frameSize.width()).arg(frameSize.height()),
328 });
329 d->helper.start();
330 if (!d->helper.waitForStarted(StartupTimeoutMs))
331 return q23::unexpected(u"could not start the fake portal helper"_s);
332
333 if (!waitForProcessOutput(d->helper, &d->helperOutput,
334 QByteArrayView("QT_FAKE_XDG_PORTAL_READY"), StartupTimeout)) {
335 return q23::unexpected(u"the fake portal helper did not become ready: "_s
336 + QString::fromLocal8Bit(d->helper.readAllStandardError()));
337 }
338
339 d->nodeId = QtPipeWire::ObjectId{ d->helperOutput.split(' ').value(1).trimmed().toUInt() };
340 d->frameSize = frameSize;
341 return {};
342}
343
344void QFakeXdgPortalFixture::stopHelper()
345{
346 if (d->helper.state() != QProcess::NotRunning) {
347 d->helper.terminate();
348 if (!d->helper.waitForFinished(2000))
349 d->helper.kill();
350 d->helper.waitForFinished(2000);
351 }
352 d->helperOutput.clear();
353 d->nodeId = QtPipeWire::ObjectId{ 0 };
354 d->frameSize = {};
355}
356
357q23::expected<void, QString> QFakeXdgPortalFixture::start(const SyntheticVideoScene &initialScene)
358{
359 const auto fail = [this](const QString &reason) {
360 stop();
361 return q23::unexpected(reason);
362 };
363
364 if (d->active)
365 return fail(u"already started"_s);
366
367 QString unavailableReason;
368 if (!prerequisitesAvailable(&unavailableReason))
369 return fail(unavailableReason);
370
371 if (!isRunningOnPrivateBus()) {
372 return fail(u"not running on a private session bus; "
373 "main() must call reexecUnderPrivateBusIfRequested()"_s);
374 }
375
376 // sockaddr_un.sun_path is only 108 bytes, and a build-tree path blows that
377 // limit, so the runtime directory has to live somewhere short.
378 QByteArray runtimeTemplate = "/tmp/qtpw.XXXXXX"_ba;
379 if (!::mkdtemp(runtimeTemplate.data()))
380 return fail(u"could not create a private pipewire runtime directory"_s);
381 d->runtimeDir = QString::fromLocal8Bit(runtimeTemplate);
382
383 const QString configurationPath = d->runtimeDir + u"/qt-fake-xdg-portal.conf"_s;
384 QFile configuration(configurationPath);
385 if (!configuration.open(QIODevice::WriteOnly))
386 return fail(u"could not write "_s + configurationPath);
387 configuration.write(DaemonConfiguration);
388 configuration.close();
389
390 QProcessEnvironment environment = QProcessEnvironment::systemEnvironment();
391 environment.insert(u"PIPEWIRE_RUNTIME_DIR"_s, d->runtimeDir);
392 environment.remove(u"PIPEWIRE_REMOTE"_s);
393
394 d->daemon.setProcessEnvironment(environment);
395 d->daemon.setChildProcessModifier(killChildWithParent);
396 d->daemon.setProgram(findPipeWireDaemon());
397 d->daemon.setArguments({ u"-c"_s, configurationPath });
398 d->daemon.start();
399 if (!d->daemon.waitForStarted(StartupTimeoutMs))
400 return fail(u"could not start the private pipewire daemon"_s);
401
402 const QString socketPath = d->runtimeDir + u"/pipewire-0"_s;
403 QDeadlineTimer deadline(StartupTimeout);
404 while (!QFile::exists(socketPath)) {
405 if (deadline.hasExpired() || d->daemon.state() == QProcess::NotRunning) {
406 return fail(u"the private pipewire daemon did not create %1: %2"_s.arg(
407 socketPath, QString::fromLocal8Bit(d->daemon.readAllStandardError())));
408 }
409 QThread::msleep(20);
410 }
411
412 d->environment = environment;
413
414 if (auto result = startHelper(initialScene.frameSize); !result)
415 return fail(result.error());
416
417 d->active = true;
418
419 return setScene(initialScene);
420}
421
422void QFakeXdgPortalFixture::stop()
423{
424 stopHelper();
425
426 if (d->daemon.state() != QProcess::NotRunning) {
427 d->daemon.terminate();
428 if (!d->daemon.waitForFinished(2000))
429 d->daemon.kill();
430 d->daemon.waitForFinished(2000);
431 }
432
433 d->removeRuntimeDir();
434 if (qEnvironmentVariableIsSet(PrivateDataDirVariable)) {
435 QDir(qEnvironmentVariable(PrivateDataDirVariable)).removeRecursively();
436 qunsetenv(PrivateDataDirVariable);
437 }
438 d->helperOutput.clear();
439 d->nodeId = QtPipeWire::ObjectId{ 0 };
440 d->active = false;
441}
442
443bool QFakeXdgPortalFixture::isActive() const
444{
445 return d->active;
446}
447
448quint32 QFakeXdgPortalFixture::nodeId() const
449{
450 return d->nodeId.value;
451}
452
453q23::expected<void, QString> QFakeXdgPortalFixture::setScene(const SyntheticVideoScene &scene)
454{
455 if (!d->active)
456 return q23::unexpected(u"the fixture is not running"_s);
457
458 if (scene.frameSize.isValid() && scene.frameSize != d->frameSize) {
459 if (auto result = startHelper(scene.frameSize); !result)
460 return result;
461 }
462
463 QDBusInterface control(
464 u"org.freedesktop.portal.Desktop"_s, u"/org/qtproject/qtmultimedia/FakeXdgPortal"_s,
465 u"org.qtproject.qtmultimedia.FakeXdgPortal"_s, QDBusConnection::sessionBus());
466 const QDBusMessage reply = control.call(u"SetScene"_s, scene.toVariantMap());
467 if (reply.type() == QDBusMessage::ErrorMessage)
468 return q23::unexpected(u"SetScene failed: "_s + reply.errorMessage());
469 return {};
470}
471
472q23::expected<void, QString> QFakeXdgPortalFixture::closeSessions()
473{
474 if (!d->active)
475 return q23::unexpected(u"the fixture is not running"_s);
476
477 QDBusInterface control(
478 u"org.freedesktop.portal.Desktop"_s, u"/org/qtproject/qtmultimedia/FakeXdgPortal"_s,
479 u"org.qtproject.qtmultimedia.FakeXdgPortal"_s, QDBusConnection::sessionBus());
480 const QDBusMessage reply = control.call(u"CloseSessions"_s);
481 if (reply.type() == QDBusMessage::ErrorMessage)
482 return q23::unexpected(u"CloseSessions failed: "_s + reply.errorMessage());
483 return {};
484}
485
486QT_END_NAMESPACE