1// Copyright (C) 2016 The Qt Company Ltd.
2// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
3
4#include "qgenericunixservices_p.h"
5#include <QtGui/private/qtguiglobal_p.h>
6#include "qguiapplication.h"
7#include "qwindow.h"
8#include <QtGui/qpa/qplatformwindow_p.h>
9#include <QtGui/qpa/qplatformwindow.h>
10#include <QtGui/qpa/qplatformnativeinterface.h>
11
12#include <QtCore/QDebug>
13#include <QtCore/QFile>
14#if QT_CONFIG(process)
15# include <QtCore/QProcess>
16#endif
17#if QT_CONFIG(settings)
18#include <QtCore/QSettings>
19#endif
20#include <QtCore/QStandardPaths>
21#include <QtCore/QUrl>
22
23#if QT_CONFIG(dbus)
24// These QtCore includes are needed for xdg-desktop-portal support
25#include <QtCore/private/qcore_unix_p.h>
26
27#include <QtCore/QFileInfo>
28#include <QtCore/QUrlQuery>
29
30#include <QtDBus/QDBusConnection>
31#include <QtDBus/QDBusMessage>
32#include <QtDBus/QDBusPendingCall>
33#include <QtDBus/QDBusPendingCallWatcher>
34#include <QtDBus/QDBusPendingReply>
35#include <QtDBus/QDBusUnixFileDescriptor>
36
37#include <fcntl.h>
38
39#endif // QT_CONFIG(dbus)
40
41#include <stdlib.h>
42
43QT_BEGIN_NAMESPACE
44
45using namespace Qt::StringLiterals;
46
47#if QT_CONFIG(multiprocess)
48
49enum { debug = 0 };
50
51static inline QByteArray detectDesktopEnvironment()
52{
53 const QByteArray xdgCurrentDesktop = qgetenv(varName: "XDG_CURRENT_DESKTOP");
54 if (!xdgCurrentDesktop.isEmpty())
55 return xdgCurrentDesktop.toUpper(); // KDE, GNOME, UNITY, LXDE, MATE, XFCE...
56
57 // Classic fallbacks
58 if (!qEnvironmentVariableIsEmpty(varName: "KDE_FULL_SESSION"))
59 return QByteArrayLiteral("KDE");
60 if (!qEnvironmentVariableIsEmpty(varName: "GNOME_DESKTOP_SESSION_ID"))
61 return QByteArrayLiteral("GNOME");
62
63 // Fallback to checking $DESKTOP_SESSION (unreliable)
64 QByteArray desktopSession = qgetenv(varName: "DESKTOP_SESSION");
65
66 // This can be a path in /usr/share/xsessions
67 int slash = desktopSession.lastIndexOf(c: '/');
68 if (slash != -1) {
69#if QT_CONFIG(settings)
70 QSettings desktopFile(QFile::decodeName(localFileName: desktopSession + ".desktop"), QSettings::IniFormat);
71 desktopFile.beginGroup(QStringLiteral("Desktop Entry"));
72 QByteArray desktopName = desktopFile.value(QStringLiteral("DesktopNames")).toByteArray();
73 if (!desktopName.isEmpty())
74 return desktopName;
75#endif
76
77 // try decoding just the basename
78 desktopSession = desktopSession.mid(index: slash + 1);
79 }
80
81 if (desktopSession == "gnome")
82 return QByteArrayLiteral("GNOME");
83 else if (desktopSession == "xfce")
84 return QByteArrayLiteral("XFCE");
85 else if (desktopSession == "kde")
86 return QByteArrayLiteral("KDE");
87
88 return QByteArrayLiteral("UNKNOWN");
89}
90
91static inline bool checkExecutable(const QString &candidate, QString *result)
92{
93 *result = QStandardPaths::findExecutable(executableName: candidate);
94 return !result->isEmpty();
95}
96
97static inline bool detectWebBrowser(const QByteArray &desktop,
98 bool checkBrowserVariable,
99 QString *browser)
100{
101 const char *browsers[] = {"google-chrome", "firefox", "mozilla", "opera"};
102
103 browser->clear();
104 if (checkExecutable(QStringLiteral("xdg-open"), result: browser))
105 return true;
106
107 if (checkBrowserVariable) {
108 QByteArray browserVariable = qgetenv(varName: "DEFAULT_BROWSER");
109 if (browserVariable.isEmpty())
110 browserVariable = qgetenv(varName: "BROWSER");
111 if (!browserVariable.isEmpty() && checkExecutable(candidate: QString::fromLocal8Bit(ba: browserVariable), result: browser))
112 return true;
113 }
114
115 if (desktop == QByteArray("KDE")) {
116 if (checkExecutable(QStringLiteral("kde-open5"), result: browser))
117 return true;
118 // Konqueror launcher
119 if (checkExecutable(QStringLiteral("kfmclient"), result: browser)) {
120 browser->append(s: " exec"_L1);
121 return true;
122 }
123 } else if (desktop == QByteArray("GNOME")) {
124 if (checkExecutable(QStringLiteral("gnome-open"), result: browser))
125 return true;
126 }
127
128 for (size_t i = 0; i < sizeof(browsers)/sizeof(char *); ++i)
129 if (checkExecutable(candidate: QLatin1StringView(browsers[i]), result: browser))
130 return true;
131 return false;
132}
133
134static inline bool launch(const QString &launcher, const QUrl &url,
135 const QString &xdgActivationToken)
136{
137 if (!xdgActivationToken.isEmpty()) {
138 qputenv(varName: "XDG_ACTIVATION_TOKEN", value: xdgActivationToken.toUtf8());
139 }
140
141 const QString command = launcher + u' ' + QLatin1StringView(url.toEncoded());
142 if (debug)
143 qDebug(msg: "Launching %s", qPrintable(command));
144#if !QT_CONFIG(process)
145 const bool ok = ::system(qPrintable(command + " &"_L1));
146#else
147 QStringList args = QProcess::splitCommand(command);
148 bool ok = false;
149 if (!args.isEmpty()) {
150 QString program = args.takeFirst();
151 ok = QProcess::startDetached(program, arguments: args);
152 }
153#endif
154 if (!ok)
155 qWarning(msg: "Launch failed (%s)", qPrintable(command));
156
157 qunsetenv(varName: "XDG_ACTIVATION_TOKEN");
158
159 return ok;
160}
161
162#if QT_CONFIG(dbus)
163static inline bool checkNeedPortalSupport()
164{
165 return QFileInfo::exists(file: "/.flatpak-info"_L1) || qEnvironmentVariableIsSet(varName: "SNAP");
166}
167
168static inline QDBusMessage xdgDesktopPortalOpenFile(const QUrl &url, const QString &parentWindow,
169 const QString &xdgActivationToken)
170{
171 // DBus signature:
172 // OpenFile (IN s parent_window,
173 // IN h fd,
174 // IN a{sv} options,
175 // OUT o handle)
176 // Options:
177 // handle_token (s) - A string that will be used as the last element of the @handle.
178 // writable (b) - Whether to allow the chosen application to write to the file.
179
180 const int fd = qt_safe_open(pathname: QFile::encodeName(fileName: url.toLocalFile()), O_RDONLY);
181 if (fd != -1) {
182 QDBusMessage message = QDBusMessage::createMethodCall(destination: "org.freedesktop.portal.Desktop"_L1,
183 path: "/org/freedesktop/portal/desktop"_L1,
184 interface: "org.freedesktop.portal.OpenURI"_L1,
185 method: "OpenFile"_L1);
186
187 QDBusUnixFileDescriptor descriptor;
188 descriptor.giveFileDescriptor(fileDescriptor: fd);
189
190 QVariantMap options = {};
191
192 if (!xdgActivationToken.isEmpty()) {
193 options.insert(key: "activation_token"_L1, value: xdgActivationToken);
194 }
195
196 message << parentWindow << QVariant::fromValue(value: descriptor) << options;
197
198 return QDBusConnection::sessionBus().call(message);
199 }
200
201 return QDBusMessage::createError(type: QDBusError::InternalError, msg: qt_error_string());
202}
203
204static inline QDBusMessage xdgDesktopPortalOpenUrl(const QUrl &url, const QString &parentWindow,
205 const QString &xdgActivationToken)
206{
207 // DBus signature:
208 // OpenURI (IN s parent_window,
209 // IN s uri,
210 // IN a{sv} options,
211 // OUT o handle)
212 // Options:
213 // handle_token (s) - A string that will be used as the last element of the @handle.
214 // writable (b) - Whether to allow the chosen application to write to the file.
215 // This key only takes effect the uri points to a local file that is exported in the document portal,
216 // and the chosen application is sandboxed itself.
217
218 QDBusMessage message = QDBusMessage::createMethodCall(destination: "org.freedesktop.portal.Desktop"_L1,
219 path: "/org/freedesktop/portal/desktop"_L1,
220 interface: "org.freedesktop.portal.OpenURI"_L1,
221 method: "OpenURI"_L1);
222 // FIXME parent_window_id and handle writable option
223 QVariantMap options;
224
225 if (!xdgActivationToken.isEmpty()) {
226 options.insert(key: "activation_token"_L1, value: xdgActivationToken);
227 }
228
229 message << parentWindow << url.toString() << options;
230
231 return QDBusConnection::sessionBus().call(message);
232}
233
234static inline QDBusMessage xdgDesktopPortalSendEmail(const QUrl &url, const QString &parentWindow,
235 const QString &xdgActivationToken)
236{
237 // DBus signature:
238 // ComposeEmail (IN s parent_window,
239 // IN a{sv} options,
240 // OUT o handle)
241 // Options:
242 // address (s) - The email address to send to.
243 // subject (s) - The subject for the email.
244 // body (s) - The body for the email.
245 // attachment_fds (ah) - File descriptors for files to attach.
246
247 QUrlQuery urlQuery(url);
248 QVariantMap options;
249 options.insert(key: "address"_L1, value: url.path());
250 options.insert(key: "subject"_L1, value: urlQuery.queryItemValue(key: "subject"_L1));
251 options.insert(key: "body"_L1, value: urlQuery.queryItemValue(key: "body"_L1));
252
253 // O_PATH seems to be present since Linux 2.6.39, which is not case of RHEL 6
254#ifdef O_PATH
255 QList<QDBusUnixFileDescriptor> attachments;
256 const QStringList attachmentUris = urlQuery.allQueryItemValues(key: "attachment"_L1);
257
258 for (const QString &attachmentUri : attachmentUris) {
259 const int fd = qt_safe_open(pathname: QFile::encodeName(fileName: attachmentUri), O_PATH);
260 if (fd != -1) {
261 QDBusUnixFileDescriptor descriptor(fd);
262 attachments << descriptor;
263 qt_safe_close(fd);
264 }
265 }
266
267 options.insert(key: "attachment_fds"_L1, value: QVariant::fromValue(value: attachments));
268#endif
269
270 if (!xdgActivationToken.isEmpty()) {
271 options.insert(key: "activation_token"_L1, value: xdgActivationToken);
272 }
273
274 QDBusMessage message = QDBusMessage::createMethodCall(destination: "org.freedesktop.portal.Desktop"_L1,
275 path: "/org/freedesktop/portal/desktop"_L1,
276 interface: "org.freedesktop.portal.Email"_L1,
277 method: "ComposeEmail"_L1);
278
279 message << parentWindow << options;
280
281 return QDBusConnection::sessionBus().call(message);
282}
283
284namespace {
285struct XDGDesktopColor
286{
287 double r = 0;
288 double g = 0;
289 double b = 0;
290
291 QColor toQColor() const
292 {
293 constexpr auto rgbMax = 255;
294 return { static_cast<int>(r * rgbMax), static_cast<int>(g * rgbMax),
295 static_cast<int>(b * rgbMax) };
296 }
297};
298
299const QDBusArgument &operator>>(const QDBusArgument &argument, XDGDesktopColor &myStruct)
300{
301 argument.beginStructure();
302 argument >> myStruct.r >> myStruct.g >> myStruct.b;
303 argument.endStructure();
304 return argument;
305}
306
307class XdgDesktopPortalColorPicker : public QPlatformServiceColorPicker
308{
309 Q_OBJECT
310public:
311 XdgDesktopPortalColorPicker(const QString &parentWindowId, QWindow *parent)
312 : QPlatformServiceColorPicker(parent), m_parentWindowId(parentWindowId)
313 {
314 }
315
316 void pickColor() override
317 {
318 // DBus signature:
319 // PickColor (IN s parent_window,
320 // IN a{sv} options
321 // OUT o handle)
322 // Options:
323 // handle_token (s) - A string that will be used as the last element of the @handle.
324
325 QDBusMessage message = QDBusMessage::createMethodCall(
326 destination: "org.freedesktop.portal.Desktop"_L1, path: "/org/freedesktop/portal/desktop"_L1,
327 interface: "org.freedesktop.portal.Screenshot"_L1, method: "PickColor"_L1);
328 message << m_parentWindowId << QVariantMap();
329
330 QDBusPendingCall pendingCall = QDBusConnection::sessionBus().asyncCall(message);
331 auto watcher = new QDBusPendingCallWatcher(pendingCall, this);
332 connect(sender: watcher, signal: &QDBusPendingCallWatcher::finished, context: this,
333 slot: [this](QDBusPendingCallWatcher *watcher) {
334 watcher->deleteLater();
335 QDBusPendingReply<QDBusObjectPath> reply = *watcher;
336 if (reply.isError()) {
337 qWarning(msg: "DBus call to pick color failed: %s",
338 qPrintable(reply.error().message()));
339 Q_EMIT colorPicked(color: {});
340 } else {
341 QDBusConnection::sessionBus().connect(
342 service: "org.freedesktop.portal.Desktop"_L1, path: reply.value().path(),
343 interface: "org.freedesktop.portal.Request"_L1, name: "Response"_L1, receiver: this,
344 // clang-format off
345 SLOT(gotColorResponse(uint,QVariantMap))
346 // clang-format on
347 );
348 }
349 });
350 }
351
352private Q_SLOTS:
353 void gotColorResponse(uint result, const QVariantMap &map)
354 {
355 if (result != 0)
356 return;
357 XDGDesktopColor color{};
358 map.value(key: u"color"_s).value<QDBusArgument>() >> color;
359 Q_EMIT colorPicked(color: color.toQColor());
360 deleteLater();
361 }
362
363private:
364 const QString m_parentWindowId;
365};
366} // namespace
367
368#endif // QT_CONFIG(dbus)
369
370QGenericUnixServices::QGenericUnixServices()
371{
372#if QT_CONFIG(dbus)
373 if (qEnvironmentVariableIntValue(varName: "QT_NO_XDG_DESKTOP_PORTAL") > 0) {
374 return;
375 }
376 QDBusMessage message = QDBusMessage::createMethodCall(
377 destination: "org.freedesktop.portal.Desktop"_L1, path: "/org/freedesktop/portal/desktop"_L1,
378 interface: "org.freedesktop.DBus.Properties"_L1, method: "Get"_L1);
379 message << "org.freedesktop.portal.Screenshot"_L1
380 << "version"_L1;
381
382 QDBusPendingCall pendingCall = QDBusConnection::sessionBus().asyncCall(message);
383 auto watcher = new QDBusPendingCallWatcher(pendingCall);
384 QObject::connect(sender: watcher, signal: &QDBusPendingCallWatcher::finished, context: watcher,
385 slot: [this](QDBusPendingCallWatcher *watcher) {
386 watcher->deleteLater();
387 QDBusPendingReply<QVariant> reply = *watcher;
388 if (!reply.isError() && reply.value().toUInt() >= 2)
389 m_hasScreenshotPortalWithColorPicking = true;
390 });
391
392#endif
393}
394
395QPlatformServiceColorPicker *QGenericUnixServices::colorPicker(QWindow *parent)
396{
397#if QT_CONFIG(dbus)
398 // Make double sure that we are in a wayland environment. In particular check
399 // WAYLAND_DISPLAY so also XWayland apps benefit from portal-based color picking.
400 // Outside wayland we'll rather rely on other means than the XDG desktop portal.
401 if (!qEnvironmentVariableIsEmpty(varName: "WAYLAND_DISPLAY")
402 || QGuiApplication::platformName().startsWith(s: "wayland"_L1)) {
403 return new XdgDesktopPortalColorPicker(portalWindowIdentifier(window: parent), parent);
404 }
405 return nullptr;
406#else
407 Q_UNUSED(parent);
408 return nullptr;
409#endif
410}
411
412QByteArray QGenericUnixServices::desktopEnvironment() const
413{
414 static const QByteArray result = detectDesktopEnvironment();
415 return result;
416}
417
418template<typename F>
419void runWithXdgActivationToken(F &&functionToCall)
420{
421 QWindow *window = qGuiApp->focusWindow();
422
423 if (!window) {
424 functionToCall({});
425 return;
426 }
427
428 auto waylandApp = dynamic_cast<QNativeInterface::QWaylandApplication *>(
429 qGuiApp->platformNativeInterface());
430 auto waylandWindow =
431 dynamic_cast<QNativeInterface::Private::QWaylandWindow *>(window->handle());
432
433 if (!waylandWindow || !waylandApp) {
434 functionToCall({});
435 return;
436 }
437
438 QObject::connect(waylandWindow,
439 &QNativeInterface::Private::QWaylandWindow::xdgActivationTokenCreated,
440 waylandWindow, functionToCall, Qt::SingleShotConnection);
441 waylandWindow->requestXdgActivationToken(serial: waylandApp->lastInputSerial());
442}
443
444bool QGenericUnixServices::openUrl(const QUrl &url)
445{
446 auto openUrlInternal = [this](const QUrl &url, const QString &xdgActivationToken) {
447 if (url.scheme() == "mailto"_L1) {
448# if QT_CONFIG(dbus)
449 if (checkNeedPortalSupport()) {
450 const QString parentWindow = QGuiApplication::focusWindow()
451 ? portalWindowIdentifier(window: QGuiApplication::focusWindow())
452 : QString();
453 QDBusError error = xdgDesktopPortalSendEmail(url, parentWindow, xdgActivationToken);
454 if (!error.isValid())
455 return true;
456
457 // service not running, fall back
458 }
459# endif
460 return openDocument(url);
461 }
462
463# if QT_CONFIG(dbus)
464 if (checkNeedPortalSupport()) {
465 const QString parentWindow = QGuiApplication::focusWindow()
466 ? portalWindowIdentifier(window: QGuiApplication::focusWindow())
467 : QString();
468 QDBusError error = xdgDesktopPortalOpenUrl(url, parentWindow, xdgActivationToken);
469 if (!error.isValid())
470 return true;
471 }
472# endif
473
474 if (m_webBrowser.isEmpty()
475 && !detectWebBrowser(desktop: desktopEnvironment(), checkBrowserVariable: true, browser: &m_webBrowser)) {
476 qWarning(msg: "Unable to detect a web browser to launch '%s'", qPrintable(url.toString()));
477 return false;
478 }
479 return launch(launcher: m_webBrowser, url, xdgActivationToken);
480 };
481
482 if (QGuiApplication::platformName().startsWith(s: "wayland"_L1)) {
483 runWithXdgActivationToken(
484 functionToCall: [openUrlInternal, url](const QString &token) { openUrlInternal(url, token); });
485
486 return true;
487
488 } else {
489 return openUrlInternal(url, QString());
490 }
491}
492
493bool QGenericUnixServices::openDocument(const QUrl &url)
494{
495 auto openDocumentInternal = [this](const QUrl &url, const QString &xdgActivationToken) {
496
497# if QT_CONFIG(dbus)
498 if (checkNeedPortalSupport()) {
499 const QString parentWindow = QGuiApplication::focusWindow()
500 ? portalWindowIdentifier(window: QGuiApplication::focusWindow())
501 : QString();
502 QDBusError error = xdgDesktopPortalOpenFile(url, parentWindow, xdgActivationToken);
503 if (!error.isValid())
504 return true;
505 }
506# endif
507
508 if (m_documentLauncher.isEmpty()
509 && !detectWebBrowser(desktop: desktopEnvironment(), checkBrowserVariable: false, browser: &m_documentLauncher)) {
510 qWarning(msg: "Unable to detect a launcher for '%s'", qPrintable(url.toString()));
511 return false;
512 }
513 return launch(launcher: m_documentLauncher, url, xdgActivationToken);
514 };
515
516 if (QGuiApplication::platformName().startsWith(s: "wayland"_L1)) {
517 runWithXdgActivationToken(functionToCall: [openDocumentInternal, url](const QString &token) {
518 openDocumentInternal(url, token);
519 });
520
521 return true;
522 } else {
523 return openDocumentInternal(url, QString());
524 }
525}
526
527#else
528QGenericUnixServices::QGenericUnixServices() = default;
529
530QByteArray QGenericUnixServices::desktopEnvironment() const
531{
532 return QByteArrayLiteral("UNKNOWN");
533}
534
535bool QGenericUnixServices::openUrl(const QUrl &url)
536{
537 Q_UNUSED(url);
538 qWarning("openUrl() not supported on this platform");
539 return false;
540}
541
542bool QGenericUnixServices::openDocument(const QUrl &url)
543{
544 Q_UNUSED(url);
545 qWarning("openDocument() not supported on this platform");
546 return false;
547}
548
549QPlatformServiceColorPicker *QGenericUnixServices::colorPicker(QWindow *parent)
550{
551 Q_UNUSED(parent);
552 return nullptr;
553}
554
555#endif // QT_NO_MULTIPROCESS
556
557QString QGenericUnixServices::portalWindowIdentifier(QWindow *window)
558{
559 if (QGuiApplication::platformName() == QLatin1String("xcb"))
560 return "x11:"_L1 + QString::number(window->winId(), base: 16);
561
562 return QString();
563}
564
565bool QGenericUnixServices::hasCapability(Capability capability) const
566{
567 switch (capability) {
568 case Capability::ColorPicking:
569 return m_hasScreenshotPortalWithColorPicking;
570 }
571 return false;
572}
573
574void QGenericUnixServices::setApplicationBadge(qint64 number)
575{
576#if QT_CONFIG(dbus)
577 if (qGuiApp->desktopFileName().isEmpty()) {
578 qWarning(msg: "QGuiApplication::desktopFileName() is empty");
579 return;
580 }
581
582
583 const QString launcherUrl = QStringLiteral("application://") + qGuiApp->desktopFileName() + QStringLiteral(".desktop");
584 const qint64 count = qBound(min: 0, val: number, max: 9999);
585 QVariantMap dbusUnityProperties;
586
587 if (count > 0) {
588 dbusUnityProperties[QStringLiteral("count")] = count;
589 dbusUnityProperties[QStringLiteral("count-visible")] = true;
590 } else {
591 dbusUnityProperties[QStringLiteral("count-visible")] = false;
592 }
593
594 auto signal = QDBusMessage::createSignal(QStringLiteral("/com/canonical/unity/launcherentry/")
595 + qGuiApp->applicationName(), QStringLiteral("com.canonical.Unity.LauncherEntry"), QStringLiteral("Update"));
596
597 signal.setArguments({launcherUrl, dbusUnityProperties});
598
599 QDBusConnection::sessionBus().send(message: signal);
600#else
601 Q_UNUSED(number)
602#endif
603}
604
605QT_END_NAMESPACE
606
607#include "qgenericunixservices.moc"
608

source code of qtbase/src/gui/platform/unix/qgenericunixservices.cpp