// Copyright (c) 2025 Proton AG // // This file is part of Proton Mail Bridge. // // Proton Mail Bridge is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Proton Mail Bridge is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Proton Mail Bridge. If not, see . #include "TrayIcon.h" #include "QMLBackend.h" #include #include #include "Settings.h" using namespace bridgepp; namespace { QColor const normalColor(30, 168, 133); /// The normal state color. QColor const errorColor(220, 50, 81); ///< The error state color. QColor const warnColor(255, 153, 0); ///< The warn state color. QColor const updateColor(35, 158, 206); ///< The warn state color. QColor const greyColor(112, 109, 107); ///< The grey color. qint64 const iconRefreshTimerIntervalMs = 1000; ///< The interval for the refresh timer when switching DPI / screen config, in milliseconds. qint64 const iconRefreshDurationSecs = 10; ///< The total number of seconds during which we periodically refresh the icon after a DPI change. //**************************************************************************************************************************************************** /// \brief Create a single resolution icon from an image. Throw an exception in case of failure. //**************************************************************************************************************************************************** QIcon loadIconFromImage(QString const &path) { QPixmap const pixmap(path); if (pixmap.isNull()) { throw Exception(QString("Could not create an icon from an image '%1'.").arg(path)); } return QIcon(pixmap); } //**************************************************************************************************************************************************** /// \brief Generate an icon from a SVG renderer (a.k.a. path). /// /// \param[in] renderer The SVG renderer. /// \param[in] color The color to use in case the SVG path is to be used as a mask. /// \return The icon. //**************************************************************************************************************************************************** QIcon loadIconFromSVGRenderer(QSvgRenderer &renderer, QColor const &color = QColor()) { if (!renderer.isValid()) { return QIcon(); } QIcon icon; qint32 size = 256; while (size >= 16) { QPixmap pixmap(size, size); pixmap.fill(QColor(0, 0, 0, 0)); QPainter painter(&pixmap); renderer.render(&painter); if (color.isValid()) { painter.setCompositionMode(QPainter::CompositionMode_SourceIn); painter.fillRect(pixmap.rect(), color); } painter.end(); icon.addPixmap(pixmap); size /= 2; } return icon; } //**************************************************************************************************************************************************** /// \brief Load a multi-resolution icon from a SVG file. The image is assumed to be square. SVG is rasterized in 256, 128, 64, 32 and 16px. /// /// Note: QPixmap can load SVG files directly, but our SVG file are defined in small shape size and QPixmap will rasterize them a very low resolution /// by default (eg. 16x16), which is insufficient for some uses. As a consequence, we manually generate a multi-resolution icon that render smoothly /// at any acceptable resolution for an icon. /// /// \param[in] path The path of the SVG file. /// \return The icon. //**************************************************************************************************************************************************** QIcon loadIconFromSVG(QString const &path, QColor const &color = QColor()) { QSvgRenderer renderer(path); QIcon const icon = loadIconFromSVGRenderer(renderer, color); if (icon.isNull()) { Exception(QString("Could not create an icon from a vector image '%1'.").arg(path)); } return icon; } //**************************************************************************************************************************************************** // //**************************************************************************************************************************************************** QIcon loadIcon(QString const &path) { if (path.endsWith(".svg", Qt::CaseInsensitive)) { return loadIconFromSVG(path); } return loadIconFromImage(path); } //**************************************************************************************************************************************************** /// \brief Retrieve the color associated with a tray icon state. /// /// \param[in] state The state. /// \return The color associated with a given tray icon state. //**************************************************************************************************************************************************** QColor stateColor(TrayIcon::State state) { switch (state) { case TrayIcon::State::Normal: return normalColor; case TrayIcon::State::Error: return errorColor; case TrayIcon::State::Warn: return warnColor; case TrayIcon::State::Update: return updateColor; default: app().log().error(QString("Unknown tray icon state %1.").arg(static_cast(state))); return normalColor; } } //**************************************************************************************************************************************************** /// \brief Return the text identifying a state in resource file names. /// /// \param[in] state The state. /// \param[in] The text identifying the state in resource file names. //**************************************************************************************************************************************************** QString stateText(TrayIcon::State state) { switch (state) { case TrayIcon::State::Normal: return "norm"; case TrayIcon::State::Error: return "error"; case TrayIcon::State::Warn: return "warn"; case TrayIcon::State::Update: return "update"; default: app().log().error(QString("Unknown tray icon state %1.").arg(static_cast(state))); return "norm"; } } //**************************************************************************************************************************************************** /// \brief converts a QML resource path to Qt resource path. /// QML resource paths are a bit different from qt resource paths /// \param[in] path The resource path. /// \return //**************************************************************************************************************************************************** QString qmlResourcePathToQt(QString const &path) { QString result = path; result.replace(QRegularExpression(R"(^\.\/)"), ":/qml/"); return result; } } // anonymous namespace //**************************************************************************************************************************************************** // //**************************************************************************************************************************************************** TrayIcon::TrayIcon() : QSystemTrayIcon() , menu_(new QMenu) , notificationErrorIcon_(loadIconFromSVG(":/qml/icons/ic-alert.svg")) { this->generateDotIcons(); this->setContextMenu(menu_.get()); this->setToolTip(PROJECT_FULL_NAME); connect(menu_.get(), &QMenu::aboutToShow, this, &TrayIcon::onMenuAboutToShow); connect(this, &TrayIcon::selectUser, &app().backend(), [](QString const& userID, bool forceShowWindow) { app().backend().selectUser(userID, forceShowWindow); }); connect(this, &TrayIcon::activated, this, &TrayIcon::onActivated); // some OSes/Desktop managers will automatically show main window when clicked, but not all, so we do it manually. if (!onLinux()) { // we disable this on linux because of a Qt bug that causes the signal to be emitted for other apps (GODT-2750) connect(this, &TrayIcon::messageClicked, []() { app().backend().showMainWindow("tray icon popup notification clicked"); }); } this->setIcon(); this->setVisible(app().settings().trayIconVisible()); // TrayIcon does not expose its screen, so we connect relevant screen events to our DPI change handler. for (QScreen *screen: QGuiApplication::screens()) { connect(screen, &QScreen::logicalDotsPerInchChanged, this, &TrayIcon::handleDPIChange); } connect(qApp, &QApplication::screenAdded, [&](QScreen *screen) { connect(screen, &QScreen::logicalDotsPerInchChanged, this, &TrayIcon::handleDPIChange); }); connect(qApp, &QApplication::primaryScreenChanged, [&](QScreen *screen) { connect(screen, &QScreen::logicalDotsPerInchChanged, this, &TrayIcon::handleDPIChange); }); iconRefreshTimer_.setSingleShot(false); iconRefreshTimer_.setInterval(iconRefreshTimerIntervalMs); connect(&iconRefreshTimer_, &QTimer::timeout, this, &TrayIcon::onIconRefreshTimer); } //**************************************************************************************************************************************************** // //**************************************************************************************************************************************************** void TrayIcon::onMenuAboutToShow() { this->refreshContextMenu(); } //**************************************************************************************************************************************************** // //**************************************************************************************************************************************************** void TrayIcon::onUserClicked() { try { auto action = dynamic_cast(this->sender()); if (!action) { throw Exception("Could not retrieve context menu action."); } QString const &userID = action->data().toString(); if (userID.isNull()) { throw Exception("Could not retrieve context menu's selected user."); } app().backend().selectUser(userID, true, "tray menu user clicked"); } catch (Exception const &e) { app().log().error(e.qwhat()); } } //**************************************************************************************************************************************************** /// \param[in] reason The icon activation reason. //**************************************************************************************************************************************************** void TrayIcon::onActivated(QSystemTrayIcon::ActivationReason reason) { if ((QSystemTrayIcon::Trigger == reason) && !onMacOS()) { app().backend().showMainWindow("tray icon activated"); } } //**************************************************************************************************************************************************** // //**************************************************************************************************************************************************** void TrayIcon::handleDPIChange() { this->setIcon(); // Windows forces us to apply a hack. Tray icon does not redraw by itself, so we use the Qt signal that detects screen and DPI changes. // But the moment we get the signal the DPI change is not yet in effect. so redrawing now will have no effect, and we don't really // know when we can safely redraw. So we will redraw the icon every second for some time. iconRefreshDeadline_ = QDateTime::currentDateTime().addSecs(iconRefreshDurationSecs); if (!iconRefreshTimer_.isActive()) { iconRefreshTimer_.start(); } } //**************************************************************************************************************************************************** // //**************************************************************************************************************************************************** void TrayIcon::setIcon() { QString const style = onMacOS() ? "mono" : "color"; QString const text = stateText(state_); QIcon icon = loadIconFromImage(QString(":/qml/icons/systray-%1-%2.png").arg(style, text)); icon.setIsMask(true); QSystemTrayIcon::setIcon(icon); } //**************************************************************************************************************************************************** // //**************************************************************************************************************************************************** void TrayIcon::onIconRefreshTimer() { this->setIcon(); if (QDateTime::currentDateTime() > iconRefreshDeadline_) { iconRefreshTimer_.stop(); } } //**************************************************************************************************************************************************** // //**************************************************************************************************************************************************** void TrayIcon::generateDotIcons() { QSvgRenderer dotSVG(QString(":/qml/icons/ic-dot.svg")); struct IconColor { QIcon &icon; QColor color; }; for (auto pair: QList {{ greenDot_, normalColor }, { greyDot_, greyColor }, { orangeDot_, warnColor }}) { pair.icon = loadIconFromSVGRenderer(dotSVG, pair.color); if (pair.icon.isNull()) { throw Exception("Could not generate dot icon from vector file."); } } } //**************************************************************************************************************************************************** /// \param[in] state The state. /// \param[in] stateString A string describing the state. /// \param[in] statusIconPath The status icon path. /// \param[in] statusIconColor The color for the status icon in hex. //**************************************************************************************************************************************************** void TrayIcon::setState(TrayIcon::State state, QString const &stateString, QString const &statusIconPath) { stateString_ = stateString; state_ = state; this->setIcon(); this->generateStatusIcon(statusIconPath, stateColor(state)); } //**************************************************************************************************************************************************** /// \brief A helper struct to temporarily force the tray to be visible. Useful for operations that do not work when the tray is not visible, /// such as showMessage(). //**************************************************************************************************************************************************** struct ScopedTrayVisibility { explicit ScopedTrayVisibility(TrayIcon& trayIcon) : trayIcon_(trayIcon), wasVisible_(app().settings().trayIconVisible()) { trayIcon_.setVisible(true); } ~ScopedTrayVisibility() { trayIcon_.setVisible(wasVisible_); } private: TrayIcon &trayIcon_; bool wasVisible_; }; //**************************************************************************************************************************************************** /// \param[in] title The title. /// \param[in] message The message. //**************************************************************************************************************************************************** void TrayIcon::showErrorPopupNotification(QString const &title, QString const &message) { ScopedTrayVisibility visible(*this); this->showMessage(title, message, notificationErrorIcon_); } //**************************************************************************************************************************************************** /// Used only by user notifications received from the event loop /// \param[in] title The title. /// \param[in] subtitle The subtitle. //**************************************************************************************************************************************************** void TrayIcon::showUserNotification(QString const &title, QString const &subtitle) { ScopedTrayVisibility visible(*this); this->showMessage(title, subtitle, QSystemTrayIcon::NoIcon); } //**************************************************************************************************************************************************** /// \param[in] svgPath The path of the SVG file for the icon. /// \param[in] color The color to apply to the icon. //**************************************************************************************************************************************************** void TrayIcon::generateStatusIcon(QString const &svgPath, QColor const &color) { // We use the SVG path as pixmap mask and fill it with the appropriate color statusIcon_ = loadIconFromSVG(qmlResourcePathToQt(svgPath), color); } //**************************************************************************************************************************************************** // //**************************************************************************************************************************************************** void TrayIcon::refreshContextMenu() { if (!menu_) { app().log().error("Native tray icon context menu is null."); return; } bool const internetOn = app().backend().isInternetOn(); menu_->clear(); menu_->addAction(statusIcon_, stateString_, []() {app().backend().showMainWindow("tray menu status clicked");}); menu_->addSeparator(); QKeySequence noShortcut; UserList const &users = app().backend().users(); qint32 const userCount = users.count(); bool const onMac = onMacOS(); for (qint32 i = 0; i < userCount; i++) { User const &user = *users.get(i); UserState const state = user.state(); auto action = new QAction(user.primaryEmailOrUsername()); if (internetOn) { action->setIcon((UserState::Connected == state) ? greenDot_ : (UserState::Locked == state ? orangeDot_ : greyDot_)); } action->setData(user.id()); connect(action, &QAction::triggered, this, &TrayIcon::onUserClicked); if ((i < 10) && onMac) { action->setShortcut(QKeySequence(QString("Ctrl+%1").arg((i + 1) % 10))); } menu_->addAction(action); } if (userCount) { menu_->addSeparator(); } menu_->addAction(tr("&Open Bridge"), onMac ? QKeySequence("Ctrl+O") : noShortcut, []() { app().backend().showMainWindow("tray menu 'open bridge' clicked"); }); menu_->addAction(tr("&Help"), onMac ? QKeySequence("Ctrl+F1") : noShortcut, []() { app().backend().showHelp("tray menu 'Help' clicked"); }); menu_->addAction(tr("&Settings"), onMac ? QKeySequence("Ctrl+,") : noShortcut, []() { app().backend().showSettings("tray menu 'Settings' clicked"); }); menu_->addSeparator(); menu_->addAction(tr("&Quit Bridge"), onMac ? QKeySequence("Ctrl+Q") : noShortcut, &app().backend(), &QMLBackend::quit); }