File indexing completed on 2024-04-28 16:54:30

0001 /* This file is part of the dbusmenu-qt library
0002     SPDX-FileCopyrightText: 2009 Canonical
0003     SPDX-FileContributor: Aurelien Gateau <aurelien.gateau@canonical.com>
0004 
0005     SPDX-License-Identifier: LGPL-2.0-or-later
0006 */
0007 #include "dbusmenuimporter.h"
0008 
0009 #include "debug.h"
0010 
0011 // Qt
0012 #include <QActionGroup>
0013 #include <QCoreApplication>
0014 #include <QDBusConnection>
0015 #include <QDBusInterface>
0016 #include <QDBusReply>
0017 #include <QDBusVariant>
0018 #include <QDebug>
0019 #include <QFont>
0020 #include <QMenu>
0021 #include <QPointer>
0022 #include <QSet>
0023 #include <QTime>
0024 #include <QTimer>
0025 #include <QToolButton>
0026 #include <QWidgetAction>
0027 
0028 // Local
0029 #include "dbusmenushortcut_p.h"
0030 #include "dbusmenutypes_p.h"
0031 #include "utils_p.h"
0032 
0033 // Generated
0034 #include "dbusmenu_interface.h"
0035 
0036 // #define BENCHMARK
0037 #ifdef BENCHMARK
0038 static QTime sChrono;
0039 #endif
0040 
0041 #define DMRETURN_IF_FAIL(cond)                                                                                                                                 \
0042     if (!(cond)) {                                                                                                                                             \
0043         qCWarning(DBUSMENUQT) << "Condition failed: " #cond;                                                                                                   \
0044         return;                                                                                                                                                \
0045     }
0046 
0047 static const char *DBUSMENU_PROPERTY_ID = "_dbusmenu_id";
0048 static const char *DBUSMENU_PROPERTY_ICON_NAME = "_dbusmenu_icon_name";
0049 static const char *DBUSMENU_PROPERTY_ICON_DATA_HASH = "_dbusmenu_icon_data_hash";
0050 
0051 static QAction *createKdeTitle(QAction *action, QWidget *parent)
0052 {
0053     QToolButton *titleWidget = new QToolButton(nullptr);
0054     QFont font = titleWidget->font();
0055     font.setBold(true);
0056     titleWidget->setFont(font);
0057     titleWidget->setIcon(action->icon());
0058     titleWidget->setText(action->text());
0059     titleWidget->setDown(true);
0060     titleWidget->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
0061 
0062     QWidgetAction *titleAction = new QWidgetAction(parent);
0063     titleAction->setDefaultWidget(titleWidget);
0064     return titleAction;
0065 }
0066 
0067 class DBusMenuImporterPrivate
0068 {
0069 public:
0070     DBusMenuImporter *q;
0071 
0072     DBusMenuInterface *m_interface;
0073     QMenu *m_menu;
0074     using ActionForId = QMap<int, QAction *>;
0075     ActionForId m_actionForId;
0076     QTimer *m_pendingLayoutUpdateTimer;
0077 
0078     QSet<int> m_idsRefreshedByAboutToShow;
0079     QSet<int> m_pendingLayoutUpdates;
0080 
0081     QDBusPendingCallWatcher *refresh(int id)
0082     {
0083         auto call = m_interface->GetLayout(id, 1, QStringList());
0084         QDBusPendingCallWatcher *watcher = new QDBusPendingCallWatcher(call, q);
0085         watcher->setProperty(DBUSMENU_PROPERTY_ID, id);
0086         QObject::connect(watcher, &QDBusPendingCallWatcher::finished, q, &DBusMenuImporter::slotGetLayoutFinished);
0087 
0088         return watcher;
0089     }
0090 
0091     QMenu *createMenu(QWidget *parent)
0092     {
0093         QMenu *menu = q->createMenu(parent);
0094         return menu;
0095     }
0096 
0097     /**
0098      * Init all the immutable action properties here
0099      * TODO: Document immutable properties?
0100      *
0101      * Note: we remove properties we handle from the map (using QMap::take()
0102      * instead of QMap::value()) to avoid warnings about these properties in
0103      * updateAction()
0104      */
0105     QAction *createAction(int id, const QVariantMap &_map, QWidget *parent)
0106     {
0107         QVariantMap map = _map;
0108         QAction *action = new QAction(parent);
0109         action->setProperty(DBUSMENU_PROPERTY_ID, id);
0110 
0111         QString type = map.take(QStringLiteral("type")).toString();
0112         if (type == QLatin1String("separator")) {
0113             action->setSeparator(true);
0114         }
0115 
0116         if (map.take(QStringLiteral("children-display")).toString() == QLatin1String("submenu")) {
0117             QMenu *menu = createMenu(parent);
0118             action->setMenu(menu);
0119         }
0120 
0121         QString toggleType = map.take(QStringLiteral("toggle-type")).toString();
0122         if (!toggleType.isEmpty()) {
0123             action->setCheckable(true);
0124             if (toggleType == QLatin1String("radio")) {
0125                 QActionGroup *group = new QActionGroup(action);
0126                 group->addAction(action);
0127             }
0128         }
0129 
0130         bool isKdeTitle = map.take(QStringLiteral("x-kde-title")).toBool();
0131         updateAction(action, map, map.keys());
0132 
0133         if (isKdeTitle) {
0134             action = createKdeTitle(action, parent);
0135         }
0136 
0137         return action;
0138     }
0139 
0140     /**
0141      * Update mutable properties of an action. A property may be listed in
0142      * requestedProperties but not in map, this means we should use the default value
0143      * for this property.
0144      *
0145      * @param action the action to update
0146      * @param map holds the property values
0147      * @param requestedProperties which properties has been requested
0148      */
0149     void updateAction(QAction *action, const QVariantMap &map, const QStringList &requestedProperties)
0150     {
0151         for (const QString &key : requestedProperties) {
0152             updateActionProperty(action, key, map.value(key));
0153         }
0154     }
0155 
0156     void updateActionProperty(QAction *action, const QString &key, const QVariant &value)
0157     {
0158         if (key == QLatin1String("label")) {
0159             updateActionLabel(action, value);
0160         } else if (key == QLatin1String("enabled")) {
0161             updateActionEnabled(action, value);
0162         } else if (key == QLatin1String("toggle-state")) {
0163             updateActionChecked(action, value);
0164         } else if (key == QLatin1String("icon-name")) {
0165             updateActionIconByName(action, value);
0166         } else if (key == QLatin1String("icon-data")) {
0167             updateActionIconByData(action, value);
0168         } else if (key == QLatin1String("visible")) {
0169             updateActionVisible(action, value);
0170         } else if (key == QLatin1String("shortcut")) {
0171             updateActionShortcut(action, value);
0172         } else {
0173             qDebug(DBUSMENUQT) << "Unhandled property update" << key;
0174         }
0175     }
0176 
0177     void updateActionLabel(QAction *action, const QVariant &value)
0178     {
0179         QString text = swapMnemonicChar(value.toString(), '_', '&');
0180         action->setText(text);
0181     }
0182 
0183     void updateActionEnabled(QAction *action, const QVariant &value)
0184     {
0185         action->setEnabled(value.isValid() ? value.toBool() : true);
0186     }
0187 
0188     void updateActionChecked(QAction *action, const QVariant &value)
0189     {
0190         if (action->isCheckable() && value.isValid()) {
0191             action->setChecked(value.toInt() == 1);
0192         }
0193     }
0194 
0195     void updateActionIconByName(QAction *action, const QVariant &value)
0196     {
0197         const QString iconName = value.toString();
0198         const QString previous = action->property(DBUSMENU_PROPERTY_ICON_NAME).toString();
0199         if (previous == iconName) {
0200             return;
0201         }
0202         action->setProperty(DBUSMENU_PROPERTY_ICON_NAME, iconName);
0203         if (iconName.isEmpty()) {
0204             action->setIcon(QIcon());
0205             return;
0206         }
0207         action->setIcon(q->iconForName(iconName));
0208     }
0209 
0210     void updateActionIconByData(QAction *action, const QVariant &value)
0211     {
0212         const QByteArray data = value.toByteArray();
0213         uint dataHash = qHash(data);
0214         uint previousDataHash = action->property(DBUSMENU_PROPERTY_ICON_DATA_HASH).toUInt();
0215         if (previousDataHash == dataHash) {
0216             return;
0217         }
0218         action->setProperty(DBUSMENU_PROPERTY_ICON_DATA_HASH, dataHash);
0219         QPixmap pix;
0220         if (!pix.loadFromData(data)) {
0221             qDebug(DBUSMENUQT) << "Failed to decode icon-data property for action" << action->text();
0222             action->setIcon(QIcon());
0223             return;
0224         }
0225         action->setIcon(QIcon(pix));
0226     }
0227 
0228     void updateActionVisible(QAction *action, const QVariant &value)
0229     {
0230         action->setVisible(value.isValid() ? value.toBool() : true);
0231     }
0232 
0233     void updateActionShortcut(QAction *action, const QVariant &value)
0234     {
0235         QDBusArgument arg = value.value<QDBusArgument>();
0236         DBusMenuShortcut dmShortcut;
0237         arg >> dmShortcut;
0238         QKeySequence keySequence = dmShortcut.toKeySequence();
0239         action->setShortcut(keySequence);
0240     }
0241 
0242     QMenu *menuForId(int id) const
0243     {
0244         if (id == 0) {
0245             return q->menu();
0246         }
0247         QAction *action = m_actionForId.value(id);
0248         if (!action) {
0249             return nullptr;
0250         }
0251         return action->menu();
0252     }
0253 
0254     void slotItemsPropertiesUpdated(const DBusMenuItemList &updatedList, const DBusMenuItemKeysList &removedList);
0255 
0256     void sendEvent(int id, const QString &eventId)
0257     {
0258         m_interface->Event(id, eventId, QDBusVariant(QString()), 0u);
0259     }
0260 };
0261 
0262 DBusMenuImporter::DBusMenuImporter(const QString &service, const QString &path, QObject *parent)
0263     : QObject(parent)
0264     , d(new DBusMenuImporterPrivate)
0265 {
0266     DBusMenuTypes_register();
0267 
0268     d->q = this;
0269     d->m_interface = new DBusMenuInterface(service, path, QDBusConnection::sessionBus(), this);
0270     d->m_menu = nullptr;
0271 
0272     d->m_pendingLayoutUpdateTimer = new QTimer(this);
0273     d->m_pendingLayoutUpdateTimer->setSingleShot(true);
0274     connect(d->m_pendingLayoutUpdateTimer, &QTimer::timeout, this, &DBusMenuImporter::processPendingLayoutUpdates);
0275 
0276     connect(d->m_interface, &DBusMenuInterface::LayoutUpdated, this, &DBusMenuImporter::slotLayoutUpdated);
0277     connect(d->m_interface, &DBusMenuInterface::ItemActivationRequested, this, &DBusMenuImporter::slotItemActivationRequested);
0278     connect(d->m_interface,
0279             &DBusMenuInterface::ItemsPropertiesUpdated,
0280             this,
0281             [this](const DBusMenuItemList &updatedList, const DBusMenuItemKeysList &removedList) {
0282                 d->slotItemsPropertiesUpdated(updatedList, removedList);
0283             });
0284 
0285     d->refresh(0);
0286 }
0287 
0288 DBusMenuImporter::~DBusMenuImporter()
0289 {
0290     // Do not use "delete d->m_menu": even if we are being deleted we should
0291     // leave enough time for the menu to finish what it was doing, for example
0292     // if it was being displayed.
0293     d->m_menu->deleteLater();
0294     delete d;
0295 }
0296 
0297 void DBusMenuImporter::slotLayoutUpdated(uint revision, int parentId)
0298 {
0299     Q_UNUSED(revision)
0300     if (d->m_idsRefreshedByAboutToShow.remove(parentId)) {
0301         return;
0302     }
0303     d->m_pendingLayoutUpdates << parentId;
0304     if (!d->m_pendingLayoutUpdateTimer->isActive()) {
0305         d->m_pendingLayoutUpdateTimer->start();
0306     }
0307 }
0308 
0309 void DBusMenuImporter::processPendingLayoutUpdates()
0310 {
0311     const QSet<int> ids = d->m_pendingLayoutUpdates;
0312     d->m_pendingLayoutUpdates.clear();
0313     for (int id : ids) {
0314         d->refresh(id);
0315     }
0316 }
0317 
0318 QMenu *DBusMenuImporter::menu() const
0319 {
0320     if (!d->m_menu) {
0321         d->m_menu = d->createMenu(nullptr);
0322     }
0323     return d->m_menu;
0324 }
0325 
0326 void DBusMenuImporterPrivate::slotItemsPropertiesUpdated(const DBusMenuItemList &updatedList, const DBusMenuItemKeysList &removedList)
0327 {
0328     for (const DBusMenuItem &item : updatedList) {
0329         QAction *action = m_actionForId.value(item.id);
0330         if (!action) {
0331             // We don't know this action. It probably is in a menu we haven't fetched yet.
0332             continue;
0333         }
0334 
0335         QVariantMap::ConstIterator it = item.properties.constBegin(), end = item.properties.constEnd();
0336         for (; it != end; ++it) {
0337             updateActionProperty(action, it.key(), it.value());
0338         }
0339     }
0340 
0341     for (const DBusMenuItemKeys &item : removedList) {
0342         QAction *action = m_actionForId.value(item.id);
0343         if (!action) {
0344             // We don't know this action. It probably is in a menu we haven't fetched yet.
0345             continue;
0346         }
0347 
0348         const auto properties{item.properties};
0349         for (const QString &key : properties) {
0350             updateActionProperty(action, key, QVariant());
0351         }
0352     }
0353 }
0354 
0355 QAction *DBusMenuImporter::actionForId(int id) const
0356 {
0357     return d->m_actionForId.value(id);
0358 }
0359 
0360 void DBusMenuImporter::slotItemActivationRequested(int id, uint /*timestamp*/)
0361 {
0362     QAction *action = d->m_actionForId.value(id);
0363     DMRETURN_IF_FAIL(action);
0364     actionActivationRequested(action);
0365 }
0366 
0367 void DBusMenuImporter::slotGetLayoutFinished(QDBusPendingCallWatcher *watcher)
0368 {
0369     int parentId = watcher->property(DBUSMENU_PROPERTY_ID).toInt();
0370     watcher->deleteLater();
0371 
0372     QMenu *menu = d->menuForId(parentId);
0373 
0374     QDBusPendingReply<uint, DBusMenuLayoutItem> reply = *watcher;
0375     if (!reply.isValid()) {
0376         qDebug(DBUSMENUQT) << reply.error().message();
0377         if (menu) {
0378             Q_EMIT menuUpdated(menu);
0379         }
0380         return;
0381     }
0382 
0383 #ifdef BENCHMARK
0384     DMDEBUG << "- items received:" << sChrono.elapsed() << "ms";
0385 #endif
0386     DBusMenuLayoutItem rootItem = reply.argumentAt<1>();
0387 
0388     if (!menu) {
0389         qDebug(DBUSMENUQT) << "No menu for id" << parentId;
0390         return;
0391     }
0392 
0393     // remove outdated actions
0394     QSet<int> newDBusMenuItemIds;
0395     newDBusMenuItemIds.reserve(rootItem.children.count());
0396     for (const DBusMenuLayoutItem &item : qAsConst(rootItem.children)) {
0397         newDBusMenuItemIds << item.id;
0398     }
0399     for (QAction *action : menu->actions()) {
0400         int id = action->property(DBUSMENU_PROPERTY_ID).toInt();
0401         if (!newDBusMenuItemIds.contains(id)) {
0402             // Not calling removeAction() as QMenu will immediately close when it becomes empty,
0403             // which can happen when an application completely reloads this menu.
0404             // When the action is deleted deferred, it is removed from the menu.
0405             action->deleteLater();
0406             if (action->menu()) {
0407                 action->menu()->deleteLater();
0408             }
0409             d->m_actionForId.remove(id);
0410         }
0411     }
0412 
0413     // insert or update new actions into our menu
0414     for (const DBusMenuLayoutItem &dbusMenuItem : qAsConst(rootItem.children)) {
0415         DBusMenuImporterPrivate::ActionForId::Iterator it = d->m_actionForId.find(dbusMenuItem.id);
0416         QAction *action = nullptr;
0417         if (it == d->m_actionForId.end()) {
0418             int id = dbusMenuItem.id;
0419             action = d->createAction(id, dbusMenuItem.properties, menu);
0420             d->m_actionForId.insert(id, action);
0421 
0422             connect(action, &QObject::destroyed, this, [this, id]() {
0423                 d->m_actionForId.remove(id);
0424             });
0425 
0426             connect(action, &QAction::triggered, this, [id, this]() {
0427                 sendClickedEvent(id);
0428             });
0429 
0430             if (QMenu *menuAction = action->menu()) {
0431                 connect(menuAction, &QMenu::aboutToShow, this, &DBusMenuImporter::slotMenuAboutToShow, Qt::UniqueConnection);
0432             }
0433             connect(menu, &QMenu::aboutToHide, this, &DBusMenuImporter::slotMenuAboutToHide, Qt::UniqueConnection);
0434 
0435             menu->addAction(action);
0436         } else {
0437             action = *it;
0438             QStringList filteredKeys = dbusMenuItem.properties.keys();
0439             filteredKeys.removeOne(QStringLiteral("type"));
0440             filteredKeys.removeOne(QStringLiteral("toggle-type"));
0441             filteredKeys.removeOne(QStringLiteral("children-display"));
0442             d->updateAction(*it, dbusMenuItem.properties, filteredKeys);
0443             // Move the action to the tail so we can keep the order same as the dbus request.
0444             menu->removeAction(action);
0445             menu->addAction(action);
0446         }
0447     }
0448 
0449     Q_EMIT menuUpdated(menu);
0450 }
0451 
0452 void DBusMenuImporter::sendClickedEvent(int id)
0453 {
0454     d->sendEvent(id, QStringLiteral("clicked"));
0455 }
0456 
0457 void DBusMenuImporter::updateMenu()
0458 {
0459     updateMenu(DBusMenuImporter::menu());
0460 }
0461 
0462 void DBusMenuImporter::updateMenu(QMenu *menu)
0463 {
0464     Q_ASSERT(menu);
0465 
0466     QAction *action = menu->menuAction();
0467     Q_ASSERT(action);
0468 
0469     int id = action->property(DBUSMENU_PROPERTY_ID).toInt();
0470 
0471     auto call = d->m_interface->AboutToShow(id);
0472     QDBusPendingCallWatcher *watcher = new QDBusPendingCallWatcher(call, this);
0473     watcher->setProperty(DBUSMENU_PROPERTY_ID, id);
0474     connect(watcher, &QDBusPendingCallWatcher::finished, this, &DBusMenuImporter::slotAboutToShowDBusCallFinished);
0475 
0476     // Firefox deliberately ignores "aboutToShow" whereas Qt ignores" opened", so we'll just send both all the time...
0477     d->sendEvent(id, QStringLiteral("opened"));
0478 }
0479 
0480 void DBusMenuImporter::slotAboutToShowDBusCallFinished(QDBusPendingCallWatcher *watcher)
0481 {
0482     int id = watcher->property(DBUSMENU_PROPERTY_ID).toInt();
0483     watcher->deleteLater();
0484 
0485     QMenu *menu = d->menuForId(id);
0486     if (!menu) {
0487         return;
0488     }
0489 
0490     QDBusPendingReply<bool> reply = *watcher;
0491     if (reply.isError()) {
0492         qDebug(DBUSMENUQT) << "Call to AboutToShow() failed:" << reply.error().message();
0493         Q_EMIT menuUpdated(menu);
0494         return;
0495     }
0496     // Note, this isn't used by Qt's QPT - but we get a LayoutChanged emitted before
0497     // this returns, which equates to the same thing
0498     bool needRefresh = reply.argumentAt<0>();
0499 
0500     if (needRefresh || menu->actions().isEmpty()) {
0501         d->m_idsRefreshedByAboutToShow << id;
0502         d->refresh(id);
0503     } else if (menu) {
0504         Q_EMIT menuUpdated(menu);
0505     }
0506 }
0507 
0508 void DBusMenuImporter::slotMenuAboutToHide()
0509 {
0510     QMenu *menu = qobject_cast<QMenu *>(sender());
0511     Q_ASSERT(menu);
0512 
0513     QAction *action = menu->menuAction();
0514     Q_ASSERT(action);
0515 
0516     int id = action->property(DBUSMENU_PROPERTY_ID).toInt();
0517     d->sendEvent(id, QStringLiteral("closed"));
0518 }
0519 
0520 void DBusMenuImporter::slotMenuAboutToShow()
0521 {
0522     QMenu *menu = qobject_cast<QMenu *>(sender());
0523     Q_ASSERT(menu);
0524 
0525     updateMenu(menu);
0526 }
0527 
0528 QMenu *DBusMenuImporter::createMenu(QWidget *parent)
0529 {
0530     return new QMenu(parent);
0531 }
0532 
0533 QIcon DBusMenuImporter::iconForName(const QString &name)
0534 {
0535     return QIcon::fromTheme(name);
0536 }
0537 
0538 #include "moc_dbusmenuimporter.cpp"