| 1 | // Copyright (C) 2025 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 "qdbuslistener_p.h" |
| 5 | #include "qdbussettings_p.h" |
| 6 | #include <private/qguiapplication_p.h> |
| 7 | #include <qpa/qplatformintegration.h> |
| 8 | #include <qpa/qplatformservices.h> |
| 9 | #include <private/qdbustrayicon_p.h> |
| 10 | #include <qjsonarray.h> |
| 11 | #include <qjsondocument.h> |
| 12 | #include <qjsonobject.h> |
| 13 | |
| 14 | QT_BEGIN_NAMESPACE |
| 15 | using namespace Qt::StringLiterals; |
| 16 | Q_STATIC_LOGGING_CATEGORY(lcQpaThemeDBus, "qt.qpa.theme.dbus" ) |
| 17 | |
| 18 | /*! |
| 19 | \internal |
| 20 | The QDBusListener class listens to the SettingChanged DBus signal |
| 21 | and translates it into combinations of the enums \c Provider and \c Setting. |
| 22 | Upon construction, it logs success/failure of the DBus connection. |
| 23 | |
| 24 | The signal settingChanged delivers the normalized setting type and the new value as a string. |
| 25 | It is emitted on known setting types only. |
| 26 | */ |
| 27 | QDBusListener::QDBusListener(const QString &service, |
| 28 | const QString &path, const QString &interface, const QString &signal) |
| 29 | { |
| 30 | init (service, path, interface, signal); |
| 31 | } |
| 32 | |
| 33 | QDBusListener::QDBusListener() |
| 34 | { |
| 35 | const auto service = u""_s ; |
| 36 | const auto path = u"/org/freedesktop/portal/desktop"_s ; |
| 37 | const auto interface = u"org.freedesktop.portal.Settings"_s ; |
| 38 | const auto signal = u"SettingChanged"_s ; |
| 39 | |
| 40 | init (service, path, interface, signal); |
| 41 | } |
| 42 | |
| 43 | namespace { |
| 44 | namespace JsonKeys { |
| 45 | constexpr auto dbusLocation() { return "DBusLocation"_L1 ; } |
| 46 | constexpr auto dbusKey() { return "DBusKey"_L1 ; } |
| 47 | constexpr auto provider() { return "Provider"_L1 ; } |
| 48 | constexpr auto setting() { return "Setting"_L1 ; } |
| 49 | constexpr auto dbusSignals() { return "DbusSignals"_L1 ; } |
| 50 | constexpr auto root() { return "Q_L1.qpa.DBusSignals"_L1 ; } |
| 51 | } // namespace JsonKeys |
| 52 | } // namespace |
| 53 | |
| 54 | void QDBusListener::init(const QString &service, const QString &path, |
| 55 | const QString &interface, const QString &signal) |
| 56 | { |
| 57 | QDBusConnection dbus = QDBusConnection::sessionBus(); |
| 58 | const bool dBusRunning = dbus.isConnected(); |
| 59 | bool dBusSignalConnected = false; |
| 60 | #define LOG service << path << interface << signal; |
| 61 | |
| 62 | if (dBusRunning) { |
| 63 | populateSignalMap(); |
| 64 | qRegisterMetaType<QDBusVariant>(); |
| 65 | dBusSignalConnected = dbus.connect(service, path, interface, name: signal, receiver: this, |
| 66 | SLOT(onSettingChanged(QString,QString,QDBusVariant))); |
| 67 | } |
| 68 | |
| 69 | if (dBusSignalConnected) { |
| 70 | // Connection successful |
| 71 | qCDebug(lcQpaThemeDBus) << LOG; |
| 72 | } else { |
| 73 | if (dBusRunning) { |
| 74 | // DBus running, but connection failed |
| 75 | qCWarning(lcQpaThemeDBus) << "DBus connection failed:" << LOG; |
| 76 | } else { |
| 77 | // DBus not running |
| 78 | qCWarning(lcQpaThemeDBus) << "Session DBus not running." ; |
| 79 | } |
| 80 | qCWarning(lcQpaThemeDBus) << "Application will not react to setting changes.\n" |
| 81 | << "Check your DBus installation." ; |
| 82 | } |
| 83 | #undef LOG |
| 84 | } |
| 85 | |
| 86 | void QDBusListener::loadJson(const QString &fileName) |
| 87 | { |
| 88 | Q_ASSERT(!fileName.isEmpty()); |
| 89 | #define CHECK(cond, warning)\ |
| 90 | if (!cond) {\ |
| 91 | qCWarning(lcQpaThemeDBus) << fileName << warning << "Falling back to default.";\ |
| 92 | return;\ |
| 93 | } |
| 94 | |
| 95 | #define PARSE(var, enumeration, string)\ |
| 96 | enumeration var;\ |
| 97 | {\ |
| 98 | bool success;\ |
| 99 | const int val = QMetaEnum::fromType<enumeration>().keyToValue(string.toLatin1(), &success);\ |
| 100 | CHECK(success, "Parse Error: Invalid value" << string << "for" << #var);\ |
| 101 | var = static_cast<enumeration>(val);\ |
| 102 | } |
| 103 | |
| 104 | QFile file(fileName); |
| 105 | CHECK(file.exists(), fileName << "doesn't exist." ); |
| 106 | CHECK(file.open(QIODevice::ReadOnly), "could not be opened for reading." ); |
| 107 | |
| 108 | QJsonParseError error; |
| 109 | QJsonDocument doc = QJsonDocument::fromJson(json: file.readAll(), error: &error); |
| 110 | CHECK((error.error == QJsonParseError::NoError), error.errorString()); |
| 111 | CHECK(doc.isObject(), "Parse Error: Expected root object" << JsonKeys::root()); |
| 112 | |
| 113 | const QJsonObject &root = doc.object(); |
| 114 | CHECK(root.contains(JsonKeys::root()), "Parse Error: Expectned root object" << JsonKeys::root()); |
| 115 | CHECK(root[JsonKeys::root()][JsonKeys::dbusSignals()].isArray(), "Parse Error: Expected array" << JsonKeys::dbusSignals()); |
| 116 | |
| 117 | const QJsonArray &sigs = root[JsonKeys::root()][JsonKeys::dbusSignals()].toArray(); |
| 118 | CHECK((sigs.count() > 0), "Parse Error: Found empty array" << JsonKeys::dbusSignals()); |
| 119 | |
| 120 | for (auto sig = sigs.constBegin(); sig != sigs.constEnd(); ++sig) { |
| 121 | CHECK(sig->isObject(), "Parse Error: Expected object array" << JsonKeys::dbusSignals()); |
| 122 | const QJsonObject &obj = sig->toObject(); |
| 123 | CHECK(obj.contains(JsonKeys::dbusLocation()), "Parse Error: Expected key" << JsonKeys::dbusLocation()); |
| 124 | CHECK(obj.contains(JsonKeys::dbusKey()), "Parse Error: Expected key" << JsonKeys::dbusKey()); |
| 125 | CHECK(obj.contains(JsonKeys::provider()), "Parse Error: Expected key" << JsonKeys::provider()); |
| 126 | CHECK(obj.contains(JsonKeys::setting()), "Parse Error: Expected key" << JsonKeys::setting()); |
| 127 | const QString &location = obj[JsonKeys::dbusLocation()].toString(); |
| 128 | const QString &key = obj[JsonKeys::dbusKey()].toString(); |
| 129 | const QString &providerString = obj[JsonKeys::provider()].toString(); |
| 130 | const QString &settingString = obj[JsonKeys::setting()].toString(); |
| 131 | PARSE(provider, Provider, providerString); |
| 132 | PARSE(setting, Setting, settingString); |
| 133 | const DBusKey dkey(location, key); |
| 134 | CHECK (!m_signalMap.contains(dkey), "Duplicate key" << location << key); |
| 135 | m_signalMap.insert(key: dkey, value: ChangeSignal(provider, setting)); |
| 136 | } |
| 137 | #undef PARSE |
| 138 | #undef CHECK |
| 139 | |
| 140 | if (m_signalMap.count() > 0) |
| 141 | qCInfo(lcQpaThemeDBus) << "Successfully imported" << fileName; |
| 142 | else |
| 143 | qCWarning(lcQpaThemeDBus) << "No data imported from" << fileName << "falling back to default." ; |
| 144 | |
| 145 | #ifdef QT_DEBUG |
| 146 | const int count = m_signalMap.count(); |
| 147 | if (count == 0) |
| 148 | return; |
| 149 | |
| 150 | qCDebug(lcQpaThemeDBus) << "Listening to" << count << "signals:" ; |
| 151 | for (auto it = m_signalMap.constBegin(); it != m_signalMap.constEnd(); ++it) { |
| 152 | qDebug() << it.key().key << it.key().location << "mapped to" |
| 153 | << it.value().provider << it.value().setting; |
| 154 | } |
| 155 | |
| 156 | #endif |
| 157 | } |
| 158 | |
| 159 | void QDBusListener::saveJson(const QString &fileName) const |
| 160 | { |
| 161 | Q_ASSERT(!m_signalMap.isEmpty()); |
| 162 | Q_ASSERT(!fileName.isEmpty()); |
| 163 | QFile file(fileName); |
| 164 | if (!file.open(flags: QIODevice::WriteOnly)) { |
| 165 | qCWarning(lcQpaThemeDBus) << fileName << "could not be opened for writing." ; |
| 166 | return; |
| 167 | } |
| 168 | |
| 169 | QJsonArray sigs; |
| 170 | for (auto sig = m_signalMap.constBegin(); sig != m_signalMap.constEnd(); ++sig) { |
| 171 | const DBusKey &dkey = sig.key(); |
| 172 | const ChangeSignal &csig = sig.value(); |
| 173 | QJsonObject obj; |
| 174 | obj[JsonKeys::dbusLocation()] = dkey.location; |
| 175 | obj[JsonKeys::dbusKey()] = dkey.key; |
| 176 | obj[JsonKeys::provider()] = QLatin1StringView(QMetaEnum::fromType<Provider>() |
| 177 | .valueToKey(value: static_cast<int>(csig.provider))); |
| 178 | obj[JsonKeys::setting()] = QLatin1StringView(QMetaEnum::fromType<Setting>() |
| 179 | .valueToKey(value: static_cast<int>(csig.setting))); |
| 180 | sigs.append(value: obj); |
| 181 | } |
| 182 | QJsonObject obj; |
| 183 | obj[JsonKeys::dbusSignals()] = sigs; |
| 184 | QJsonObject root; |
| 185 | root[JsonKeys::root()] = obj; |
| 186 | QJsonDocument doc(root); |
| 187 | file.write(data: doc.toJson()); |
| 188 | file.close(); |
| 189 | } |
| 190 | |
| 191 | void QDBusListener::populateSignalMap() |
| 192 | { |
| 193 | m_signalMap.clear(); |
| 194 | const QString &loadJsonFile = qEnvironmentVariable(varName: "QT_QPA_DBUS_SIGNALS" ); |
| 195 | if (!loadJsonFile.isEmpty()) |
| 196 | loadJson(fileName: loadJsonFile); |
| 197 | if (!m_signalMap.isEmpty()) |
| 198 | return; |
| 199 | |
| 200 | m_signalMap.insert(key: DBusKey("org.kde.kdeglobals.KDE"_L1 , "widgetStyle"_L1 ), |
| 201 | value: ChangeSignal(Provider::Kde, Setting::ApplicationStyle)); |
| 202 | |
| 203 | m_signalMap.insert(key: DBusKey("org.kde.kdeglobals.General"_L1 , "ColorScheme"_L1 ), |
| 204 | value: ChangeSignal(Provider::Kde, Setting::Theme)); |
| 205 | |
| 206 | m_signalMap.insert(key: DBusKey("org.gnome.desktop.interface"_L1 , "gtk-theme"_L1 ), |
| 207 | value: ChangeSignal(Provider::Gtk, Setting::Theme)); |
| 208 | |
| 209 | using namespace QDBusSettings; |
| 210 | m_signalMap.insert(key: DBusKey(XdgSettings::AppearanceNamespace, XdgSettings::ColorSchemeKey), |
| 211 | value: ChangeSignal(Provider::Gnome, Setting::ColorScheme)); |
| 212 | |
| 213 | m_signalMap.insert(key: DBusKey(XdgSettings::AppearanceNamespace, XdgSettings::ContrastKey), |
| 214 | value: ChangeSignal(Provider::Gnome, Setting::Contrast)); |
| 215 | // Alternative solution if XDG desktop portal setting is not accessible, |
| 216 | // e.g. when using the XDG portal version 1. |
| 217 | m_signalMap.insert(key: DBusKey(GnomeSettings::AllyNamespace, GnomeSettings::ContrastKey), |
| 218 | value: ChangeSignal(Provider::Gnome, Setting::Contrast)); |
| 219 | |
| 220 | const QString &saveJsonFile = qEnvironmentVariable(varName: "QT_QPA_DBUS_SIGNALS_SAVE" ); |
| 221 | if (!saveJsonFile.isEmpty()) |
| 222 | saveJson(fileName: saveJsonFile); |
| 223 | } |
| 224 | |
| 225 | std::optional<QDBusListener::ChangeSignal> |
| 226 | QDBusListener::findSignal(const QString &location, const QString &key) const |
| 227 | { |
| 228 | const DBusKey dkey(location, key); |
| 229 | std::optional<QDBusListener::ChangeSignal> ret; |
| 230 | const auto it = m_signalMap.find(key: dkey); |
| 231 | if (it != m_signalMap.cend()) |
| 232 | ret.emplace(args: it.value()); |
| 233 | |
| 234 | return ret; |
| 235 | } |
| 236 | |
| 237 | void QDBusListener::onSettingChanged(const QString &location, const QString &key, const QDBusVariant &value) |
| 238 | { |
| 239 | auto sig = findSignal(location, key); |
| 240 | if (!sig.has_value()) |
| 241 | return; |
| 242 | |
| 243 | const Setting setting = sig.value().setting; |
| 244 | QVariant settingValue = value.variant(); |
| 245 | |
| 246 | switch (setting) { |
| 247 | case Setting::ColorScheme: |
| 248 | settingValue.setValue(QDBusSettings::XdgSettings::convertColorScheme(value: settingValue)); |
| 249 | break; |
| 250 | case Setting::Contrast: |
| 251 | using namespace QDBusSettings; |
| 252 | // To unify the value, it's necessary to convert the DBus value to Qt::ContrastPreference. |
| 253 | // Then the users of the value don't need to parse the raw value. |
| 254 | if (key == XdgSettings::ContrastKey) |
| 255 | settingValue.setValue(XdgSettings::convertContrastPreference(value: settingValue)); |
| 256 | else if (key == GnomeSettings::ContrastKey) |
| 257 | settingValue.setValue(GnomeSettings::convertContrastPreference(value: settingValue)); |
| 258 | else |
| 259 | Q_UNREACHABLE_IMPL(); |
| 260 | break; |
| 261 | default: |
| 262 | break; |
| 263 | } |
| 264 | |
| 265 | emit settingChanged(provider: sig.value().provider, setting, value: settingValue); |
| 266 | } |
| 267 | QT_END_NAMESPACE |
| 268 | |