File indexing completed on 2024-05-05 17:43:53

0001 /*
0002     SPDX-FileCopyrightText: 2018 Kai Uwe Broulik <kde@privat.broulik.de>
0003 
0004     SPDX-License-Identifier: LGPL-2.1-or-later
0005 */
0006 
0007 #include "menu.h"
0008 
0009 #include "debug.h"
0010 
0011 #include <QDBusConnection>
0012 #include <QDBusMessage>
0013 #include <QDBusPendingCallWatcher>
0014 #include <QDBusPendingReply>
0015 #include <QDebug>
0016 #include <QVariantList>
0017 
0018 #include <algorithm>
0019 
0020 #include "utils.h"
0021 
0022 static const QString s_orgGtkMenus = QStringLiteral("org.gtk.Menus");
0023 
0024 Menu::Menu(const QString &serviceName, const QString &objectPath, QObject *parent)
0025     : QObject(parent)
0026     , m_serviceName(serviceName)
0027     , m_objectPath(objectPath)
0028 {
0029     Q_ASSERT(!serviceName.isEmpty());
0030     Q_ASSERT(!m_objectPath.isEmpty());
0031 
0032     if (!QDBusConnection::sessionBus()
0033              .connect(m_serviceName, m_objectPath, s_orgGtkMenus, QStringLiteral("Changed"), this, SLOT(onMenuChanged(GMenuChangeList)))) {
0034         qCWarning(DBUSMENUPROXY) << "Failed to subscribe to menu changes for" << parent << "on" << serviceName << "at" << objectPath;
0035     }
0036 }
0037 
0038 Menu::~Menu() = default;
0039 
0040 void Menu::cleanup()
0041 {
0042     stop(m_subscriptions);
0043 }
0044 
0045 void Menu::start(uint id)
0046 {
0047     if (m_subscriptions.contains(id)) {
0048         return;
0049     }
0050 
0051     // TODO watch service disappearing?
0052 
0053     // dbus-send --print-reply --session --dest=:1.103 /org/libreoffice/window/104857641/menus/menubar org.gtk.Menus.Start array:uint32:0
0054 
0055     QDBusMessage msg = QDBusMessage::createMethodCall(m_serviceName, m_objectPath, s_orgGtkMenus, QStringLiteral("Start"));
0056     msg.setArguments({QVariant::fromValue(QList<uint>{id})});
0057 
0058     QDBusPendingReply<GMenuItemList> reply = QDBusConnection::sessionBus().asyncCall(msg);
0059     QDBusPendingCallWatcher *watcher = new QDBusPendingCallWatcher(reply, this);
0060     connect(watcher, &QDBusPendingCallWatcher::finished, this, [this, id](QDBusPendingCallWatcher *watcher) {
0061         QScopedPointer<QDBusPendingCallWatcher, QScopedPointerDeleteLater> watcherPtr(watcher);
0062 
0063         QDBusPendingReply<GMenuItemList> reply = *watcherPtr;
0064         if (reply.isError()) {
0065             qCWarning(DBUSMENUPROXY) << "Failed to start subscription to" << id << "on" << m_serviceName << "at" << m_objectPath << reply.error();
0066             Q_EMIT failedToSubscribe(id);
0067         } else {
0068             const bool hadMenu = !m_menus.isEmpty();
0069 
0070             const auto menus = reply.value();
0071             for (const auto &menu : menus) {
0072                 m_menus[menu.id].append(menus);
0073             }
0074 
0075             // LibreOffice on startup fails to give us some menus right away, we'll also subscribe in onMenuChanged() if necessary
0076             if (menus.isEmpty()) {
0077                 qCWarning(DBUSMENUPROXY) << "Got an empty menu for" << id << "on" << m_serviceName << "at" << m_objectPath;
0078                 return;
0079             }
0080 
0081             // TODO are we subscribed to all it returns or just to the ones we requested?
0082             m_subscriptions.append(id);
0083 
0084             // do we have a menu now? let's tell everyone
0085             if (!hadMenu && !m_menus.isEmpty()) {
0086                 Q_EMIT menuAppeared();
0087             }
0088 
0089             Q_EMIT subscribed(id);
0090         }
0091     });
0092 }
0093 
0094 void Menu::stop(const QList<uint> &ids)
0095 {
0096     QDBusMessage msg = QDBusMessage::createMethodCall(m_serviceName, m_objectPath, s_orgGtkMenus, QStringLiteral("End"));
0097     msg.setArguments({
0098         QVariant::fromValue(ids) // don't let it unwrap it, hence in a variant
0099     });
0100 
0101     QDBusPendingReply<void> reply = QDBusConnection::sessionBus().asyncCall(msg);
0102     QDBusPendingCallWatcher *watcher = new QDBusPendingCallWatcher(reply, this);
0103     connect(watcher, &QDBusPendingCallWatcher::finished, this, [this, ids](QDBusPendingCallWatcher *watcher) {
0104         QDBusPendingReply<void> reply = *watcher;
0105         if (reply.isError()) {
0106             qCWarning(DBUSMENUPROXY) << "Failed to stop subscription to" << ids << "on" << m_serviceName << "at" << m_objectPath << reply.error();
0107         } else {
0108             // remove all subscriptions that we unsubscribed from
0109             // TODO is there a nicer algorithm for that?
0110             // TODO remove all m_menus also?
0111             m_subscriptions.erase(
0112 #if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
0113                 std::remove_if(m_subscriptions.begin(), m_subscriptions.end(), std::bind(&QList<uint>::contains, m_subscriptions, std::placeholders::_1)),
0114 #else
0115                 std::remove_if(m_subscriptions.begin(), m_subscriptions.end(), std::bind(&QList<uint>::contains<uint>, m_subscriptions, std::placeholders::_1)),
0116 #endif
0117                 m_subscriptions.end());
0118 
0119             if (m_subscriptions.isEmpty()) {
0120                 Q_EMIT menuDisappeared();
0121             }
0122         }
0123         watcher->deleteLater();
0124     });
0125 }
0126 
0127 bool Menu::hasMenu() const
0128 {
0129     return !m_menus.isEmpty();
0130 }
0131 
0132 bool Menu::hasSubscription(uint subscription) const
0133 {
0134     return m_subscriptions.contains(subscription);
0135 }
0136 
0137 GMenuItem Menu::getSection(int id, bool *ok) const
0138 {
0139     int subscription;
0140     int section;
0141     int index;
0142     Utils::intToTreeStructure(id, subscription, section, index);
0143     return getSection(subscription, section, ok);
0144 }
0145 
0146 GMenuItem Menu::getSection(int subscription, int section, bool *ok) const
0147 {
0148     const auto menu = m_menus.value(subscription);
0149 
0150     auto it = std::find_if(menu.begin(), menu.end(), [section](const GMenuItem &item) {
0151         return static_cast<int>(item.section) == section;
0152     });
0153 
0154     if (it == menu.end()) {
0155         if (ok) {
0156             *ok = false;
0157         }
0158         return GMenuItem();
0159     }
0160 
0161     if (ok) {
0162         *ok = true;
0163     }
0164     return *it;
0165 }
0166 
0167 QVariantMap Menu::getItem(int id) const
0168 {
0169     int subscription;
0170     int section;
0171     int index;
0172     Utils::intToTreeStructure(id, subscription, section, index);
0173     return getItem(subscription, section, index);
0174 }
0175 
0176 QVariantMap Menu::getItem(int subscription, int sectionId, int index) const
0177 {
0178     bool ok;
0179     const GMenuItem section = getSection(subscription, sectionId, &ok);
0180 
0181     if (!ok) {
0182         return QVariantMap();
0183     }
0184 
0185     const auto items = section.items;
0186 
0187     if (items.count() < index) {
0188         qCWarning(DBUSMENUPROXY) << "Cannot get action" << subscription << sectionId << index << "which is out of bounds";
0189         return QVariantMap();
0190     }
0191 
0192     // 0 is the menu itself, items start at 1
0193     return items.at(index - 1);
0194 }
0195 
0196 void Menu::onMenuChanged(const GMenuChangeList &changes)
0197 {
0198     const bool hadMenu = !m_menus.isEmpty();
0199 
0200     QVector<uint> dirtyMenus;
0201     QVector<uint> dirtyItems;
0202 
0203     for (const auto &change : changes) {
0204         auto updateSection = [&](GMenuItem &section) {
0205             // Check if the amount of inserted items is identical to the items to be removed,
0206             // just update the existing items and signal a change for that.
0207             // LibreOffice tends to do that e.g. to update its Undo menu entry
0208             if (change.itemsToRemoveCount == static_cast<uint>(change.itemsToInsert.count())) {
0209                 for (int i = 0; i < change.itemsToInsert.count(); ++i) {
0210                     const auto &newItem = change.itemsToInsert.at(i);
0211 
0212                     section.items[change.changePosition + i] = newItem;
0213 
0214                     // 0 is the menu itself, items start at 1
0215                     dirtyItems.append(Utils::treeStructureToInt(change.subscription, change.menu, change.changePosition + i + 1));
0216                 }
0217             } else {
0218                 for (uint i = 0; i < change.itemsToRemoveCount; ++i) {
0219                     section.items.removeAt(change.changePosition); // TODO bounds check
0220                 }
0221 
0222                 for (int i = 0; i < change.itemsToInsert.count(); ++i) {
0223                     section.items.insert(change.changePosition + i, change.itemsToInsert.at(i));
0224                 }
0225 
0226                 dirtyMenus.append(Utils::treeStructureToInt(change.subscription, change.menu, 0));
0227             }
0228         };
0229 
0230         // shouldn't happen, it says only Start() subscribes to changes
0231         if (!m_subscriptions.contains(change.subscription)) {
0232             qCDebug(DBUSMENUPROXY) << "Got menu change for menu" << change.subscription << "that we are not subscribed to, subscribing now";
0233             // LibreOffice doesn't give us a menu right away but takes a while and then signals us a change
0234             start(change.subscription);
0235             continue;
0236         }
0237 
0238         auto &menu = m_menus[change.subscription];
0239 
0240         bool sectionFound = false;
0241         // TODO findSectionRef
0242         for (GMenuItem &section : menu) {
0243             if (section.section != change.menu) {
0244                 continue;
0245             }
0246 
0247             qCInfo(DBUSMENUPROXY) << "Updating existing section" << change.menu << "in subscription" << change.subscription;
0248 
0249             sectionFound = true;
0250             updateSection(section);
0251             break;
0252         }
0253 
0254         // Insert new section
0255         if (!sectionFound) {
0256             qCInfo(DBUSMENUPROXY) << "Creating new section" << change.menu << "in subscription" << change.subscription;
0257 
0258             if (change.itemsToRemoveCount > 0) {
0259                 qCWarning(DBUSMENUPROXY) << "Menu change requested to remove items from a new (and as such empty) section";
0260             }
0261 
0262             GMenuItem newSection;
0263             newSection.id = change.subscription;
0264             newSection.section = change.menu;
0265             updateSection(newSection);
0266             menu.append(newSection);
0267         }
0268     }
0269 
0270     // do we have a menu now? let's tell everyone
0271     if (!hadMenu && !m_menus.isEmpty()) {
0272         Q_EMIT menuAppeared();
0273     } else if (hadMenu && m_menus.isEmpty()) {
0274         Q_EMIT menuDisappeared();
0275     }
0276 
0277     if (!dirtyItems.isEmpty()) {
0278         Q_EMIT itemsChanged(dirtyItems);
0279     }
0280 
0281     Q_EMIT menusChanged(dirtyMenus);
0282 }
0283 
0284 void Menu::actionsChanged(const QStringList &dirtyActions, const QString &prefix)
0285 {
0286     auto forEachMenuItem = [this](const std::function<bool(int subscription, int section, int index, const QVariantMap &item)> &cb) {
0287         for (auto it = m_menus.constBegin(), end = m_menus.constEnd(); it != end; ++it) {
0288             const int subscription = it.key();
0289 
0290             for (const auto &menu : it.value()) {
0291                 const int section = menu.section;
0292 
0293                 int count = 0;
0294 
0295                 const auto items = menu.items;
0296                 for (const auto &item : items) {
0297                     ++count; // 0 is a menu, entries start at 1
0298 
0299                     if (!cb(subscription, section, count, item)) {
0300                         goto loopExit; // hell yeah
0301                         break;
0302                     }
0303                 }
0304             }
0305         }
0306 
0307     loopExit: // loop exit
0308         return;
0309     };
0310 
0311     // now find in which menus these actions are and Q_EMIT a change accordingly
0312     QVector<uint> dirtyItems;
0313 
0314     for (const QString &action : dirtyActions) {
0315         const QString prefixedAction = prefix + action;
0316 
0317         forEachMenuItem([&prefixedAction, &dirtyItems](int subscription, int section, int index, const QVariantMap &item) {
0318             const QString actionName = Utils::itemActionName(item);
0319 
0320             if (actionName == prefixedAction) {
0321                 dirtyItems.append(Utils::treeStructureToInt(subscription, section, index));
0322                 return false; // break
0323             }
0324 
0325             return true; // continue
0326         });
0327     }
0328 
0329     if (!dirtyItems.isEmpty()) {
0330         Q_EMIT itemsChanged(dirtyItems);
0331     }
0332 }