/* SPDX-FileCopyrightText: 2018 Kai Uwe Broulik SPDX-License-Identifier: LGPL-2.1-or-later */ #include "window.h" #include "debug.h" #include #include #include #include #include #include #include #include #include #include "actions.h" #include "dbusmenuadaptor.h" #include "icons.h" #include "menu.h" #include "utils.h" #include "../libdbusmenuqt/dbusmenushortcut_p.h" static const QString s_applicationActionsPrefix = QStringLiteral("app."); static const QString s_unityActionsPrefix = QStringLiteral("unity."); static const QString s_windowActionsPrefix = QStringLiteral("win."); Window::Window(const QString &serviceName) : QObject() , m_serviceName(serviceName) { qCDebug(DBUSMENUPROXY) << "Created menu on" << serviceName; Q_ASSERT(!serviceName.isEmpty()); GDBusMenuTypes_register(); DBusMenuTypes_register(); } Window::~Window() = default; void Window::init() { qCDebug(DBUSMENUPROXY) << "Inited window with menu for" << m_winId << "on" << m_serviceName << "at app" << m_applicationObjectPath << "win" << m_windowObjectPath << "unity" << m_unityObjectPath; if (!m_applicationMenuObjectPath.isEmpty()) { m_applicationMenu = new Menu(m_serviceName, m_applicationMenuObjectPath, this); connect(m_applicationMenu, &Menu::menuAppeared, this, &Window::updateWindowProperties); connect(m_applicationMenu, &Menu::menuDisappeared, this, &Window::updateWindowProperties); connect(m_applicationMenu, &Menu::subscribed, this, &Window::onMenuSubscribed); // basically so it replies on DBus no matter what connect(m_applicationMenu, &Menu::failedToSubscribe, this, &Window::onMenuSubscribed); connect(m_applicationMenu, &Menu::itemsChanged, this, &Window::menuItemsChanged); connect(m_applicationMenu, &Menu::menusChanged, this, &Window::menuChanged); } if (!m_menuBarObjectPath.isEmpty()) { m_menuBar = new Menu(m_serviceName, m_menuBarObjectPath, this); connect(m_menuBar, &Menu::menuAppeared, this, &Window::updateWindowProperties); connect(m_menuBar, &Menu::menuDisappeared, this, &Window::updateWindowProperties); connect(m_menuBar, &Menu::subscribed, this, &Window::onMenuSubscribed); connect(m_menuBar, &Menu::failedToSubscribe, this, &Window::onMenuSubscribed); connect(m_menuBar, &Menu::itemsChanged, this, &Window::menuItemsChanged); connect(m_menuBar, &Menu::menusChanged, this, &Window::menuChanged); } if (!m_applicationObjectPath.isEmpty()) { m_applicationActions = new Actions(m_serviceName, m_applicationObjectPath, this); connect(m_applicationActions, &Actions::actionsChanged, this, [this](const QStringList &dirtyActions) { onActionsChanged(dirtyActions, s_applicationActionsPrefix); }); connect(m_applicationActions, &Actions::loaded, this, [this] { if (m_menuInited) { onActionsChanged(m_applicationActions->getAll().keys(), s_applicationActionsPrefix); } else { initMenu(); } }); m_applicationActions->load(); } if (!m_unityObjectPath.isEmpty()) { m_unityActions = new Actions(m_serviceName, m_unityObjectPath, this); connect(m_unityActions, &Actions::actionsChanged, this, [this](const QStringList &dirtyActions) { onActionsChanged(dirtyActions, s_unityActionsPrefix); }); connect(m_unityActions, &Actions::loaded, this, [this] { if (m_menuInited) { onActionsChanged(m_unityActions->getAll().keys(), s_unityActionsPrefix); } else { initMenu(); } }); m_unityActions->load(); } if (!m_windowObjectPath.isEmpty()) { m_windowActions = new Actions(m_serviceName, m_windowObjectPath, this); connect(m_windowActions, &Actions::actionsChanged, this, [this](const QStringList &dirtyActions) { onActionsChanged(dirtyActions, s_windowActionsPrefix); }); connect(m_windowActions, &Actions::loaded, this, [this] { if (m_menuInited) { onActionsChanged(m_windowActions->getAll().keys(), s_windowActionsPrefix); } else { initMenu(); } }); m_windowActions->load(); } } WId Window::winId() const { return m_winId; } void Window::setWinId(WId winId) { m_winId = winId; } QString Window::serviceName() const { return m_serviceName; } QString Window::applicationObjectPath() const { return m_applicationObjectPath; } void Window::setApplicationObjectPath(const QString &applicationObjectPath) { m_applicationObjectPath = applicationObjectPath; } QString Window::unityObjectPath() const { return m_unityObjectPath; } void Window::setUnityObjectPath(const QString &unityObjectPath) { m_unityObjectPath = unityObjectPath; } QString Window::applicationMenuObjectPath() const { return m_applicationMenuObjectPath; } void Window::setApplicationMenuObjectPath(const QString &applicationMenuObjectPath) { m_applicationMenuObjectPath = applicationMenuObjectPath; } QString Window::menuBarObjectPath() const { return m_menuBarObjectPath; } void Window::setMenuBarObjectPath(const QString &menuBarObjectPath) { m_menuBarObjectPath = menuBarObjectPath; } QString Window::windowObjectPath() const { return m_windowObjectPath; } void Window::setWindowObjectPath(const QString &windowObjectPath) { m_windowObjectPath = windowObjectPath; } QString Window::currentMenuObjectPath() const { return m_currentMenuObjectPath; } QString Window::proxyObjectPath() const { return m_proxyObjectPath; } void Window::initMenu() { if (m_menuInited) { return; } if (!registerDBusObject()) { return; } // appmenu-gtk-module always announces a menu bar on every GTK window even if there is none // so we subscribe to the menu bar as soon as it shows up so we can figure out // if we have a menu bar, an app menu, or just nothing if (m_applicationMenu) { m_applicationMenu->start(0); } if (m_menuBar) { m_menuBar->start(0); } m_menuInited = true; } void Window::menuItemsChanged(const QList &itemIds) { if (qobject_cast(sender()) != m_currentMenu) { return; } DBusMenuItemList items; for (uint id : itemIds) { const auto newItem = m_currentMenu->getItem(id); DBusMenuItem dBusItem{// 0 is menu, items start at 1 static_cast(id), gMenuToDBusMenuProperties(newItem)}; items.append(dBusItem); } Q_EMIT ItemsPropertiesUpdated(items, {}); } void Window::menuChanged(const QList &menuIds) { if (qobject_cast(sender()) != m_currentMenu) { return; } for (uint menu : menuIds) { Q_EMIT LayoutUpdated(3 /*revision*/, menu); } } void Window::onMenuSubscribed(uint id) { // When it was a delayed GetLayout request, send the reply now const auto pendingReplies = m_pendingGetLayouts.values(id); if (!pendingReplies.isEmpty()) { for (const auto &pendingReply : pendingReplies) { if (pendingReply.type() != QDBusMessage::InvalidMessage) { auto reply = pendingReply.createReply(); DBusMenuLayoutItem item; uint revision = GetLayout(Utils::treeStructureToInt(id, 0, 0), 0, {}, item); reply << revision << QVariant::fromValue(std::move(item)); QDBusConnection::sessionBus().send(reply); } } m_pendingGetLayouts.remove(id); } else { Q_EMIT LayoutUpdated(2 /*revision*/, id); } } bool Window::getAction(const QString &name, GMenuAction &action) const { QString lookupName; Actions *actions = getActionsForAction(name, lookupName); if (!actions) { return false; } return actions->get(lookupName, action); } void Window::triggerAction(const QString &name, const QVariant &target, uint timestamp) { QString lookupName; Actions *actions = getActionsForAction(name, lookupName); if (!actions) { return; } actions->trigger(lookupName, target, timestamp); } Actions *Window::getActionsForAction(const QString &name, QString &lookupName) const { if (name.startsWith(QLatin1String("app."))) { lookupName = name.mid(4); return m_applicationActions; } else if (name.startsWith(QLatin1String("unity."))) { lookupName = name.mid(6); return m_unityActions; } else if (name.startsWith(QLatin1String("win."))) { lookupName = name.mid(4); return m_windowActions; } return nullptr; } void Window::onActionsChanged(const QStringList &dirty, const QString &prefix) { if (m_applicationMenu) { m_applicationMenu->actionsChanged(dirty, prefix); } if (m_menuBar) { m_menuBar->actionsChanged(dirty, prefix); } } bool Window::registerDBusObject() { Q_ASSERT(m_proxyObjectPath.isEmpty()); static int menus = 0; ++menus; new DbusmenuAdaptor(this); const QString objectPath = QStringLiteral("/MenuBar/%1").arg(QString::number(menus)); qCDebug(DBUSMENUPROXY) << "Registering DBus object path" << objectPath; if (!QDBusConnection::sessionBus().registerObject(objectPath, this)) { qCWarning(DBUSMENUPROXY) << "Failed to register object"; return false; } m_proxyObjectPath = objectPath; return true; } void Window::updateWindowProperties() { const bool hasMenu = ((m_applicationMenu && m_applicationMenu->hasMenu()) || (m_menuBar && m_menuBar->hasMenu())); if (!hasMenu) { Q_EMIT requestRemoveWindowProperties(); return; } Menu *oldMenu = m_currentMenu; Menu *newMenu = qobject_cast(sender()); // set current menu as needed if (!m_currentMenu) { m_currentMenu = newMenu; // Menu Bar takes precedence over application menu } else if (m_currentMenu == m_applicationMenu && newMenu == m_menuBar) { qCDebug(DBUSMENUPROXY) << "Switching from application menu to menu bar"; m_currentMenu = newMenu; // TODO update layout } if (m_currentMenu != oldMenu) { // update entire menu now Q_EMIT LayoutUpdated(4 /*revision*/, 0); } Q_EMIT requestWriteWindowProperties(); } // DBus bool Window::AboutToShow(int id) { // We always request the first time GetLayout is called and keep up-to-date internally // No need to have us prepare anything here Q_UNUSED(id); return false; } void Window::Event(int id, const QString &eventId, const QDBusVariant &data, uint timestamp) { Q_UNUSED(data); if (!m_currentMenu) { return; } // GMenu dbus doesn't have any "opened" or "closed" signals, we'll only handle "clicked" if (eventId == QLatin1String("clicked")) { const QVariantMap item = m_currentMenu->getItem(id); const QString action = item.value(QStringLiteral("action")).toString(); const QVariant target = item.value(QStringLiteral("target")); if (!action.isEmpty()) { triggerAction(action, target, timestamp); } } } DBusMenuItemList Window::GetGroupProperties(const QList &ids, const QStringList &propertyNames) { Q_UNUSED(ids); Q_UNUSED(propertyNames); return DBusMenuItemList(); } uint Window::GetLayout(int parentId, int recursionDepth, const QStringList &propertyNames, DBusMenuLayoutItem &dbusItem) { Q_UNUSED(recursionDepth); // TODO Q_UNUSED(propertyNames); int subscription; int sectionId; int index; Utils::intToTreeStructure(parentId, subscription, sectionId, index); if (!m_currentMenu) { return 1; } if (!m_currentMenu->hasSubscription(subscription)) { // let's serve multiple similar requests in one go once we've processed them m_pendingGetLayouts.insert(subscription, message()); setDelayedReply(true); m_currentMenu->start(subscription); return 1; } bool ok; const GMenuItem section = m_currentMenu->getSection(subscription, sectionId, &ok); if (!ok) { qCDebug(DBUSMENUPROXY) << "There is no section on" << subscription << "at" << sectionId << "with" << parentId; return 1; } // If a particular entry is requested, see what it is and resolve as necessary // for example the "File" entry on root is 0,0,1 but is a menu reference to e.g. 1,0,0 // so resolve that and return the correct menu if (index > 0) { // non-zero index indicates item within a menu but the index in the list still starts at zero if (section.items.count() < index) { qCDebug(DBUSMENUPROXY) << "Requested index" << index << "on" << subscription << "at" << sectionId << "with" << parentId << "is out of bounds"; return 0; } const auto &requestedItem = section.items.at(index - 1); auto it = requestedItem.constFind(QStringLiteral(":submenu")); if (it != requestedItem.constEnd()) { const GMenuSection gmenuSection = qdbus_cast(it->value()); return GetLayout(Utils::treeStructureToInt(gmenuSection.subscription, gmenuSection.menu, 0), recursionDepth, propertyNames, dbusItem); } else { // TODO return 0; } } dbusItem.id = parentId; // TODO dbusItem.properties = {{QStringLiteral("children-display"), QStringLiteral("submenu")}}; int count = 0; const auto itemsToBeAdded = section.items; for (const auto &item : itemsToBeAdded) { DBusMenuLayoutItem child{ Utils::treeStructureToInt(section.id, sectionId, ++count), gMenuToDBusMenuProperties(item), {} // children }; dbusItem.children.append(child); // Now resolve section aliases auto it = item.constFind(QStringLiteral(":section")); if (it != item.constEnd()) { // references another place, add it instead GMenuSection gmenuSection = qdbus_cast(it->value()); // remember where the item came from and give it an appropriate ID // so updates signalled by the app will map to the right place int originalSubscription = gmenuSection.subscription; int originalMenu = gmenuSection.menu; // TODO start subscription if we don't have it auto items = m_currentMenu->getSection(gmenuSection.subscription, gmenuSection.menu).items; // Check whether it's an alias to an alias // FIXME make generic/recursive if (items.count() == 1) { const auto &aliasedItem = items.constFirst(); auto findIt = aliasedItem.constFind(QStringLiteral(":section")); if (findIt != aliasedItem.constEnd()) { GMenuSection gmenuSection2 = qdbus_cast(findIt->value()); items = m_currentMenu->getSection(gmenuSection2.subscription, gmenuSection2.menu).items; originalSubscription = gmenuSection2.subscription; originalMenu = gmenuSection2.menu; } } int aliasedCount = 0; for (const auto &aliasedItem : std::as_const(items)) { DBusMenuLayoutItem aliasedChild{ Utils::treeStructureToInt(originalSubscription, originalMenu, ++aliasedCount), gMenuToDBusMenuProperties(aliasedItem), {} // children }; dbusItem.children.append(aliasedChild); } } } // revision, unused in libdbusmenuqt return 1; } QDBusVariant Window::GetProperty(int id, const QString &property) { Q_UNUSED(id); Q_UNUSED(property); QDBusVariant value; return value; } QString Window::status() const { return QStringLiteral("normal"); } uint Window::version() const { return 4; } QVariantMap Window::gMenuToDBusMenuProperties(const QVariantMap &source) const { QVariantMap result; result.insert(QStringLiteral("label"), source.value(QStringLiteral("label")).toString()); if (source.contains(QLatin1String(":section"))) { result.insert(QStringLiteral("type"), QStringLiteral("separator")); } const bool isMenu = source.contains(QLatin1String(":submenu")); if (isMenu) { result.insert(QStringLiteral("children-display"), QStringLiteral("submenu")); } QString accel = source.value(QStringLiteral("accel")).toString(); if (!accel.isEmpty()) { QStringList shortcut; // TODO use regexp or something if (accel.contains(QLatin1String("")) || accel.contains(QLatin1String(""))) { shortcut.append(QStringLiteral("Control")); accel.remove(QLatin1String("")); accel.remove(QLatin1String("")); } if (accel.contains(QLatin1String(""))) { shortcut.append(QStringLiteral("Shift")); accel.remove(QLatin1String("")); } if (accel.contains(QLatin1String(""))) { shortcut.append(QStringLiteral("Alt")); accel.remove(QLatin1String("")); } if (accel.contains(QLatin1String(""))) { shortcut.append(QStringLiteral("Super")); accel.remove(QLatin1String("")); } if (!accel.isEmpty()) { // TODO replace "+" by "plus" and "-" by "minus" shortcut.append(accel); // TODO does gmenu support multiple? DBusMenuShortcut dbusShortcut; dbusShortcut.append(shortcut); // don't let it unwrap the list we append result.insert(QStringLiteral("shortcut"), QVariant::fromValue(std::move(dbusShortcut))); } } bool enabled = true; const QString actionName = Utils::itemActionName(source); GMenuAction action; // if no action is specified this is fine but if there is an action we don't have // disable the menu entry bool actionOk = true; if (!actionName.isEmpty()) { actionOk = getAction(actionName, action); enabled = actionOk && action.enabled; } // we used to only send this if not enabled but then dbusmenuimporter does not // update the enabled state when it changes from disabled to enabled result.insert(QStringLiteral("enabled"), enabled); bool visible = true; const QString hiddenWhen = source.value(QStringLiteral("hidden-when")).toString(); if (hiddenWhen == QLatin1String("action-disabled") && (!actionOk || !enabled)) { visible = false; } else if (hiddenWhen == QLatin1String("action-missing") && !actionOk) { visible = false; // While we have Global Menu we don't have macOS menu (where Quit, Help, etc is separate) } else if (hiddenWhen == QLatin1String("macos-menubar")) { visible = true; } result.insert(QStringLiteral("visible"), visible); QString icon = source.value(QStringLiteral("icon")).toString(); if (icon.isEmpty()) { icon = source.value(QStringLiteral("verb-icon")).toString(); } icon = Icons::actionIcon(actionName); if (!icon.isEmpty()) { result.insert(QStringLiteral("icon-name"), icon); } const QVariant target = source.value(QStringLiteral("target")); if (actionOk) { const auto actionStates = action.state; if (actionStates.count() == 1) { const auto &actionState = actionStates.first(); // assume this is a checkbox if (!isMenu) { if (actionState.typeId() == QMetaType::Bool) { result.insert(QStringLiteral("toggle-type"), QStringLiteral("checkbox")); result.insert(QStringLiteral("toggle-state"), actionState.toBool() ? 1 : 0); } else if (actionState.typeId() == QMetaType::QString) { result.insert(QStringLiteral("toggle-type"), QStringLiteral("radio")); result.insert(QStringLiteral("toggle-state"), actionState == target ? 1 : 0); } } } } return result; }