├── app ├── resources │ ├── defaultConfig.json │ ├── resources.qrc │ ├── ntfy-symbolic.svg │ ├── ntfy-nobg.svg │ ├── ntfy.svg │ └── ntfyDesktop.svg ├── src │ ├── FileManager │ │ ├── FileManagerException.cpp │ │ ├── FileManagerException.hpp │ │ ├── FileManager.hpp │ │ └── FileManager.cpp │ ├── ProtocolHandler │ │ ├── ProtocolParseException.cpp │ │ ├── ProtocolParseException.hpp │ │ ├── ProtocolHandler.cpp │ │ └── ProtocolHandler.hpp │ ├── ntfyDesktop.hpp.in │ ├── ErrorWindow │ │ ├── ErrorWindow.hpp │ │ ├── ErrorWindow.cpp │ │ └── ErrorWindow.ui │ ├── ThreadManager │ │ ├── ThreadManager.hpp │ │ ├── NtfyThread.hpp │ │ ├── ThreadManager.cpp │ │ └── NtfyThread.cpp │ ├── SingleInstanceManager │ │ ├── SingleInstanceManager.hpp │ │ └── SingleInstanceManager.cpp │ ├── ImportDialog │ │ ├── ImportDialog.hpp │ │ ├── ImportDialog.cpp │ │ └── ImportDialog.ui │ ├── UnixSignalHandler │ │ ├── UnixSignalHandler.hpp │ │ └── UnixSignalHandler.cpp │ ├── MainWindow │ │ ├── ConfigTab.hpp │ │ ├── MainWindow.hpp │ │ ├── ConfigTab.cpp │ │ ├── ConfigTab.ui │ │ ├── MainWindow.cpp │ │ └── MainWindow.ui │ ├── NotificationManager │ │ ├── NotificationManager.hpp │ │ └── NotificationManager.cpp │ ├── Config │ │ ├── Config.hpp │ │ └── Config.cpp │ ├── NotificationStore │ │ ├── NotificationStore.hpp │ │ └── NotificationStore.cpp │ ├── Util │ │ ├── Util.hpp │ │ └── Util.cpp │ └── ntfyDesktop.cpp ├── moe.emmaexe.ntfyDesktop.desktop.in ├── moe.emmaexe.ntfyDesktop.notifyrc.in ├── moe.emmaexe.ntfyDesktop.metainfo.xml.in └── icons │ └── sc-apps-moe.emmaexe.ntfyDesktop.svg ├── assets ├── screenshot1.png ├── screenshot2.png ├── screenshot3.png └── ntfyDesktop.svg ├── .gitignore ├── .editorconfig ├── .clang-format ├── README.md └── CMakeLists.txt /app/resources/defaultConfig.json: -------------------------------------------------------------------------------- 1 | {"sources":[],"version":"1.2.0"} -------------------------------------------------------------------------------- /assets/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwinters0/ntfyDesktop/main/assets/screenshot1.png -------------------------------------------------------------------------------- /assets/screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwinters0/ntfyDesktop/main/assets/screenshot2.png -------------------------------------------------------------------------------- /assets/screenshot3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwinters0/ntfyDesktop/main/assets/screenshot3.png -------------------------------------------------------------------------------- /app/src/FileManager/FileManagerException.cpp: -------------------------------------------------------------------------------- 1 | #include "FileManagerException.hpp" 2 | 3 | FileManagerException::FileManagerException(const char* message): message(message) {} 4 | 5 | const char* FileManagerException::what() const throw() { return this->message.c_str(); } 6 | -------------------------------------------------------------------------------- /app/src/ProtocolHandler/ProtocolParseException.cpp: -------------------------------------------------------------------------------- 1 | #include "ProtocolParseException.hpp" 2 | 3 | ProtocolParseException::ProtocolParseException(const char* message): message(message) {} 4 | 5 | const char* ProtocolParseException::what() const throw() { return this->message.c_str(); } 6 | -------------------------------------------------------------------------------- /app/moe.emmaexe.ntfyDesktop.desktop.in: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Type=Application 3 | Name=Ntfy Desktop 4 | Categories=Utility;Network; 5 | Keywords=notifications; 6 | Comment=@CMAKE_PROJECT_DESCRIPTION@ 7 | Exec=@CMAKE_PROJECT_NAME@ %u 8 | Icon=moe.emmaexe.ntfyDesktop 9 | MimeType=x-scheme-handler/ntfy; 10 | -------------------------------------------------------------------------------- /app/resources/resources.qrc: -------------------------------------------------------------------------------- 1 | 2 | 3 | ntfyDesktop.svg 4 | ntfy-nobg.svg 5 | ntfy-symbolic.svg 6 | 7 | 8 | defaultConfig.json 9 | 10 | 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #VSCode 2 | .vscode 3 | 4 | #CMake 5 | CMakeLists.txt.user 6 | CMakeCache.txt 7 | CMakeFiles 8 | CMakeScripts 9 | Testing 10 | Makefile 11 | cmake_install.cmake 12 | install_manifest.txt 13 | compile_commands.json 14 | CTestTestfile.cmake 15 | _deps 16 | /build 17 | 18 | # Dolphin/KDE 19 | .directory 20 | -------------------------------------------------------------------------------- /app/src/FileManager/FileManagerException.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | 6 | class FileManagerException: public std::exception { 7 | public: 8 | FileManagerException(const char* message); 9 | const char* what() const throw(); 10 | private: 11 | std::string message; 12 | }; 13 | -------------------------------------------------------------------------------- /app/src/ProtocolHandler/ProtocolParseException.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | 6 | class ProtocolParseException: public std::exception { 7 | public: 8 | ProtocolParseException(const char* message); 9 | const char* what() const throw(); 10 | private: 11 | std::string message; 12 | }; 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [app/src/**.{cpp,hpp}] 7 | indent_style = space 8 | indent_size = 4 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | 14 | [**.txt] 15 | indent_style = space 16 | indent_size = 4 17 | end_of_line = lf 18 | charset = utf-8 19 | -------------------------------------------------------------------------------- /app/src/ntfyDesktop.hpp.in: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #define ND_VERSION "@CMAKE_PROJECT_VERSION@" 4 | #define ND_DESCRIPTION "@CPACK_PACKAGE_DESCRIPTION@" 5 | #define ND_DESCRIPTION_SUMMARY "@CPACK_PACKAGE_DESCRIPTION_SUMMARY@" 6 | #define ND_HOMEPAGE_URL "@CMAKE_PROJECT_HOMEPAGE_URL@" 7 | #define ND_ISSUES_URL "@CMAKE_PROJECT_HOMEPAGE_URL@issues/" 8 | #define ND_USERAGENT "moe.emmaexe.ntfyDesktop/@CMAKE_PROJECT_VERSION@" 9 | #define ND_BUILD_TYPE "@ND_BUILD_TYPE@" -------------------------------------------------------------------------------- /app/src/ErrorWindow/ErrorWindow.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "../Config/Config.hpp" 4 | 5 | #include 6 | #include 7 | 8 | QT_BEGIN_NAMESPACE 9 | namespace Ui { 10 | class ErrorWindow; 11 | } 12 | QT_END_NAMESPACE 13 | 14 | class ErrorWindow: public QMainWindow { 15 | Q_OBJECT 16 | public: 17 | ErrorWindow(KAboutData& aboutData, QWidget* parent = nullptr); 18 | ~ErrorWindow(); 19 | public slots: 20 | void resetConfig(); 21 | private: 22 | Ui::ErrorWindow* ui; 23 | KHelpMenu* helpMenu; 24 | }; 25 | -------------------------------------------------------------------------------- /app/src/ThreadManager/ThreadManager.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "../Config/Config.hpp" 4 | #include "NtfyThread.hpp" 5 | 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | 12 | class ThreadManager: public QObject { 13 | Q_OBJECT 14 | public: 15 | ThreadManager(QObject *parent = nullptr); 16 | ~ThreadManager(); 17 | public slots: 18 | void stopAll(); 19 | void restartConfig(); 20 | private: 21 | std::vector> threads; 22 | std::mutex mutex; 23 | }; 24 | -------------------------------------------------------------------------------- /app/moe.emmaexe.ntfyDesktop.notifyrc.in: -------------------------------------------------------------------------------- 1 | [Global] 2 | Name=Ntfy Desktop 3 | IconName=moe.emmaexe.ntfyDesktop 4 | Comment=@CMAKE_PROJECT_DESCRIPTION@ 5 | DesktopEntry=moe.emmaexe.ntfyDesktop.desktop 6 | 7 | [Event/general] 8 | Name=General Notifications 9 | Comment=Notifications that come in from notification sources. 10 | Action=Popup 11 | Urgency=Normal 12 | 13 | [Event/startup] 14 | Name=Startup Notifications 15 | Comment=Notification that is sent when the app starts. 16 | Action=Popup 17 | Urgency=Normal 18 | 19 | [Event/error] 20 | Name=Error Notifications 21 | Comment=Notifications sent for errors and other issues (e.g. when a server isn't responding). 22 | Action=Popup 23 | Urgency=Normal 24 | -------------------------------------------------------------------------------- /app/src/SingleInstanceManager/SingleInstanceManager.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | #include 6 | 7 | class SingleInstanceManager: public QObject { 8 | Q_OBJECT 9 | Q_CLASSINFO("D-Bus Interface", "moe.emmaexe.ntfyDesktop.SingleInstanceManager") 10 | public: 11 | SingleInstanceManager(std::function url)> onNewInstanceStarted, std::optional url, QObject* parent = nullptr); 12 | ~SingleInstanceManager(); 13 | std::function url)> onNewInstanceStarted; 14 | public slots: 15 | void newInstanceStarted(const QString& url); 16 | }; 17 | -------------------------------------------------------------------------------- /app/src/ImportDialog/ImportDialog.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | #include 6 | 7 | QT_BEGIN_NAMESPACE 8 | namespace Ui { 9 | class ImportDialog; 10 | } 11 | QT_END_NAMESPACE 12 | 13 | class ImportDialog: public QDialog { 14 | Q_OBJECT 15 | public: 16 | ImportDialog(QWidget* parent = nullptr); 17 | ~ImportDialog(); 18 | public slots: 19 | void fileSelectButton(); 20 | void applyButton(); 21 | private: 22 | void fileSuccess(const std::string& text, const std::string& data); 23 | void fileFailure(const std::string& text); 24 | nlohmann::json internalTempConfig; 25 | Ui::ImportDialog* ui; 26 | }; 27 | -------------------------------------------------------------------------------- /app/src/FileManager/FileManager.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "FileManagerException.hpp" 4 | 5 | #include 6 | #include 7 | 8 | /** 9 | * @brief Static class with misc file managment functionality 10 | */ 11 | class FileManager { 12 | public: 13 | FileManager() = delete; 14 | /** 15 | * @brief Temporarly download a file from the web. 16 | * 17 | * @param url Url to a file on the web. 18 | * @return QUrl - Url to a temporary locally downloaded copy of the file from the web. The file will be deleted when the QApplication exits. 19 | */ 20 | static QUrl urlToTempFile(QUrl url); 21 | private: 22 | static size_t urlToTempFileWriteCallback(char* ptr, size_t size, size_t nmemb, void* userdata); 23 | static std::vector tempFileHolder; 24 | }; 25 | -------------------------------------------------------------------------------- /app/src/ErrorWindow/ErrorWindow.cpp: -------------------------------------------------------------------------------- 1 | #include "ErrorWindow.hpp" 2 | 3 | #include "ntfyDesktop.hpp" 4 | #include "ui_ErrorWindow.h" 5 | 6 | #include 7 | 8 | ErrorWindow::ErrorWindow(KAboutData& aboutData, QWidget* parent): QMainWindow(parent), ui(new Ui::ErrorWindow) { 9 | this->ui->setupUi(this); 10 | 11 | this->ui->ErrorText->setText(QLabel::tr(Config::getError().c_str())); 12 | 13 | QObject::connect(this->ui->ResetButton, &QPushButton::clicked, this, &ErrorWindow::resetConfig); 14 | 15 | this->helpMenu = new KHelpMenu(this, aboutData); 16 | this->helpMenu->action(KHelpMenu::MenuId::menuAboutApp)->setIcon(QIcon(QStringLiteral(":/icons/ntfyDesktop.svg"))); 17 | this->ui->menuBar->addMenu(this->helpMenu->menu()); 18 | this->show(); 19 | } 20 | 21 | ErrorWindow::~ErrorWindow() { delete ui; } 22 | 23 | void ErrorWindow::resetConfig() { 24 | Config::reset(); 25 | this->ui->ErrorText->setText(QLabel::tr("The configuration was reset.")); 26 | this->ui->ResetButton->setEnabled(false); 27 | } 28 | -------------------------------------------------------------------------------- /app/src/UnixSignalHandler/UnixSignalHandler.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | 6 | #include 7 | #include 8 | #include 9 | #include 10 | 11 | class UnixSignalHandler: public QObject { 12 | Q_OBJECT 13 | public: 14 | UnixSignalHandler(std::function fun, QObject* parent = nullptr); 15 | static void hupSignalHandler(int unused); 16 | static void intSignalHandler(int unused); 17 | static void termSignalHandler(int unused); 18 | public slots: 19 | void handleSigHup(); 20 | void handleSigInt(); 21 | void handleSigTerm(); 22 | private: 23 | std::function fun; 24 | static int sighupFileDescriptor[2]; 25 | static int sigintFileDescriptor[2]; 26 | static int sigtermFileDescriptor[2]; 27 | QSocketNotifier* sighupNotifier; 28 | QSocketNotifier* sigintNotifier; 29 | QSocketNotifier* sigtermNotifier; 30 | }; 31 | -------------------------------------------------------------------------------- /app/src/ThreadManager/NtfyThread.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | 11 | class NtfyThread { 12 | public: 13 | NtfyThread(std::string name, std::string domain, std::string topic, bool secure, std::string lastNotificationID, std::mutex* mutex); 14 | ~NtfyThread(); 15 | void run(); 16 | const std::string& stop(); 17 | const std::string& name(); 18 | const std::string& domain(); 19 | const std::string& topic(); 20 | const bool secure(); 21 | private: 22 | std::thread thread; 23 | std::mutex* mutex; 24 | std::atomic running; 25 | std::string internalName, internalDomain, internalTopic, url, lastNotificationID; 26 | bool internalSecure; 27 | int internalErrorCounter = 0; 28 | static size_t writeCallback(char* ptr, size_t size, size_t nmemb, void* userdata); 29 | static int progressCallback(void* clientp, curl_off_t dltotal, curl_off_t dlnow, curl_off_t ultotal, curl_off_t ulnow); 30 | }; 31 | -------------------------------------------------------------------------------- /app/src/MainWindow/ConfigTab.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | #include 6 | 7 | QT_BEGIN_NAMESPACE 8 | namespace Ui { 9 | class ConfigTab; 10 | } 11 | QT_END_NAMESPACE 12 | 13 | class ConfigTab: public QWidget { 14 | Q_OBJECT 15 | public: 16 | ConfigTab(std::string name = "", std::string domain = "", std::string topic = "", bool secure = true, QWidget* parent = nullptr); 17 | ~ConfigTab(); 18 | std::string getName(); 19 | std::string getDomain(); 20 | std::string getTopic(); 21 | bool getSecure(); 22 | public slots: 23 | void testButton(); 24 | void testResults(const bool& result); 25 | private: 26 | Ui::ConfigTab* ui; 27 | QTimer* testLabelTimer; 28 | }; 29 | 30 | class ConnectionTester: public QObject { 31 | Q_OBJECT 32 | public: 33 | ConnectionTester(const std::string& name, const std::string& domain, const std::string& topic, const bool& secure); 34 | public slots: 35 | void runTest(); 36 | signals: 37 | void testFinished(const bool& success); 38 | private: 39 | std::string name, domain, topic; 40 | bool secure; 41 | std::atomic testSuccessful; 42 | static size_t writeCallback(char* ptr, size_t size, size_t nmemb, void* userdata); 43 | }; 44 | -------------------------------------------------------------------------------- /app/src/NotificationManager/NotificationManager.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | #include 6 | #include 7 | 8 | enum NotificationPriority { LOWEST = 1, LOW = 2, NORMAL = 3, HIGH = 4, HIGHEST = 5 }; 9 | 10 | enum NotificationAttachmentType { ICON, IMAGE }; 11 | 12 | struct NotificationAttachment { 13 | public: 14 | std::string name; 15 | std::string url; 16 | NotificationAttachmentType type; 17 | bool native; 18 | }; 19 | 20 | enum NotificationActionType { CLICK, BUTTON }; 21 | 22 | class NotificationAction { 23 | public: 24 | NotificationAction(const nlohmann::json& actionData); 25 | NotificationAction(const std::string& clickUrl); 26 | NotificationActionType type; 27 | bool useable; 28 | std::string label; 29 | std::string url; 30 | }; 31 | 32 | class NotificationManager { 33 | public: 34 | NotificationManager() = delete; 35 | static void generalNotification( 36 | const std::string title, const std::string message, std::optional priority = std::nullopt, std::optional attachment = std::nullopt, 37 | std::optional> actions = std::nullopt 38 | ); 39 | static void startupNotification(); 40 | static void errorNotification(const std::string title, const std::string message); 41 | }; 42 | -------------------------------------------------------------------------------- /app/src/ProtocolHandler/ProtocolHandler.cpp: -------------------------------------------------------------------------------- 1 | #include "ProtocolHandler.hpp" 2 | 3 | #include "../Util/Util.hpp" 4 | 5 | ProtocolHandler::ProtocolHandler(const std::string& url) { 6 | if (url.find("://") == std::string::npos) { throw ProtocolParseException("String is not valid url."); } 7 | std::vector parts = Util::split(url, "://"); 8 | 9 | this->internalProtocol = parts[0]; 10 | parts = Util::split(parts[1], "/"); 11 | 12 | this->internalDomain = parts[0]; 13 | for (int i = 1; i < parts.size() - 1; i++) { this->internalPath.push_back(parts[i]); } 14 | 15 | parts = Util::split(parts.back(), "?"); 16 | this->internalPath.push_back(parts[0]); 17 | 18 | if (parts.size() > 1 && !parts.back().empty()) { 19 | parts = Util::split(parts.back(), "&"); 20 | for (int i = 0; i < parts.size(); i++) { 21 | std::vector pair = Util::split(parts[i], "="); 22 | if (pair.size() != 2) { continue; } 23 | this->internalParams.insert(std::make_pair(pair[0], pair[1])); 24 | } 25 | } 26 | } 27 | 28 | const std::string& ProtocolHandler::protocol() { return this->internalProtocol; } 29 | 30 | const std::string& ProtocolHandler::domain() { return this->internalDomain; } 31 | 32 | const std::vector& ProtocolHandler::path() { return this->internalPath; } 33 | 34 | const std::map& ProtocolHandler::params() { return this->internalParams; } 35 | -------------------------------------------------------------------------------- /app/src/MainWindow/MainWindow.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "../Config/Config.hpp" 4 | #include "../ProtocolHandler/ProtocolHandler.hpp" 5 | #include "../ThreadManager/ThreadManager.hpp" 6 | #include "ConfigTab.hpp" 7 | 8 | #include 9 | #include 10 | #include 11 | #include 12 | #include 13 | 14 | QT_BEGIN_NAMESPACE 15 | namespace Ui { 16 | class MainWindow; 17 | } 18 | QT_END_NAMESPACE 19 | 20 | class MainWindow: public QMainWindow { 21 | Q_OBJECT 22 | public: 23 | MainWindow(std::shared_ptr threadManager, KAboutData& aboutData, QWidget* parent = nullptr); 24 | ~MainWindow(); 25 | public slots: 26 | void ntfyProtocolTriggered(ProtocolHandler url); 27 | void saveAction(); 28 | void addAction(); 29 | void removeAction(); 30 | void exitAction(); 31 | void trayIconPressed(QSystemTrayIcon::ActivationReason reason); 32 | void showHideAction(); 33 | void restartAction(); 34 | void importAction(); 35 | protected: 36 | void closeEvent(QCloseEvent* event) override; 37 | void changeEvent(QEvent* event) override; 38 | private: 39 | int newTabCounter = 1; 40 | std::vector tabs; 41 | std::shared_ptr threadManager; 42 | Ui::MainWindow* ui; 43 | QSystemTrayIcon* tray; 44 | QMenu* trayMenu; 45 | QAction* showHideQAction; 46 | KHelpMenu* helpMenu; 47 | }; 48 | -------------------------------------------------------------------------------- /app/src/ThreadManager/ThreadManager.cpp: -------------------------------------------------------------------------------- 1 | #include "ThreadManager.hpp" 2 | 3 | #include "../MainWindow/MainWindow.hpp" 4 | #include "../NotificationManager/NotificationManager.hpp" 5 | #include "../NotificationStore/NotificationStore.hpp" 6 | 7 | #include 8 | #include 9 | 10 | ThreadManager::ThreadManager(QObject* parent): QObject(parent) { this->restartConfig(); } 11 | 12 | ThreadManager::~ThreadManager() {} 13 | 14 | void ThreadManager::stopAll() { 15 | for (std::unique_ptr& thread_p: this->threads) { 16 | std::string lastNotificationID = thread_p->stop(); 17 | NotificationStore::update(thread_p->domain(), thread_p->topic(), lastNotificationID); 18 | } 19 | this->threads.clear(); 20 | } 21 | 22 | void ThreadManager::restartConfig() { 23 | this->stopAll(); 24 | for (nlohmann::json& source: Config::data()["sources"]) { 25 | try { 26 | std::string lastNotificationID = "all", name = std::string(source["name"]), domain = std::string(source["domain"]), topic = std::string(source["topic"]); 27 | bool secure = source["secure"]; 28 | if (NotificationStore::exists(domain, topic)) { lastNotificationID = NotificationStore::get(domain, topic).value(); } 29 | this->threads.push_back(std::make_unique(name, domain, topic, secure, lastNotificationID, &this->mutex)); 30 | } catch (nlohmann::json::out_of_range e) { std::cerr << "Invalid source in config, ignoring: " << source << std::endl; } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/src/ProtocolHandler/ProtocolHandler.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "ProtocolParseException.hpp" 4 | 5 | #include 6 | #include 7 | #include 8 | 9 | /** 10 | * @brief Class for handling the `ntfy://` protocol and other URLs in general. Creates an immutable object that holds parsed data from a URL. 11 | */ 12 | class ProtocolHandler { 13 | public: 14 | ProtocolHandler(const std::string& url); 15 | /** 16 | * @brief The protocol of the parsed URL. (e.g. for `https://www.example.org/some/path/?help=true&data=abc` this would be `https`) 17 | */ 18 | const std::string& protocol(); 19 | /** 20 | * @brief The domain of the parsed URL. (e.g. for `https://www.example.org/some/path/?help=true&data=abc` this would be `www.example.org`) 21 | */ 22 | const std::string& domain(); 23 | /** 24 | * @brief The path of the parsed URL. (e.g. for `https://www.example.org/some/path/?help=true&data=abc` this would be `{"some","path",""}`) 25 | */ 26 | const std::vector& path(); 27 | /** 28 | * @brief The parameters of the parsed URL. (e.g. for `https://www.example.org/some/path?help=true&data=abc` this would be `{"help":"true", "data":"abc"}`) 29 | */ 30 | const std::map& params(); 31 | private: 32 | std::string internalProtocol = "", internalDomain = ""; 33 | std::vector internalPath = {}; 34 | std::map internalParams = {}; 35 | }; 36 | -------------------------------------------------------------------------------- /app/src/Config/Config.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | #include 6 | 7 | /** 8 | * @brief A static global class, wrapper for the json config file. 9 | */ 10 | class Config { 11 | public: 12 | Config() = delete; 13 | /** 14 | * @brief The json configuration data. 15 | * 16 | * @return nlohmann::json& data 17 | */ 18 | static nlohmann::json& data(); 19 | /** 20 | * @brief Read the configuration data from the json file into the internal variable. 21 | */ 22 | static void read(); 23 | /** 24 | * @brief Write the configuration data from the internal variable into the json file. 25 | */ 26 | static void write(); 27 | /** 28 | * @brief Check if the config was initialized successfully. 29 | */ 30 | static bool ready(); 31 | /** 32 | * @brief Reset the config to the default values. 33 | */ 34 | static void reset(); 35 | /** 36 | * @brief Get the error message if the config was not initialized successfully. Otherwise, returns an empty string. 37 | * 38 | * @return const std::string& errorMessage 39 | */ 40 | static const std::string& getError(); 41 | private: 42 | static bool initialized, ok, updating; 43 | static nlohmann::json internalData; 44 | static std::string internalError; 45 | static int internalErrorCounter; 46 | static void updateToCurrent(); 47 | static const std::string getConfigPath(); 48 | static const std::string getConfigFile(); 49 | }; 50 | -------------------------------------------------------------------------------- /app/src/SingleInstanceManager/SingleInstanceManager.cpp: -------------------------------------------------------------------------------- 1 | #include "SingleInstanceManager.hpp" 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | 8 | SingleInstanceManager::SingleInstanceManager(std::function url)> onNewInstanceStarted, std::optional url, QObject* parent): 9 | QObject(parent), onNewInstanceStarted(onNewInstanceStarted) { 10 | QDBusConnection sessionBus = QDBusConnection::sessionBus(); 11 | if (!sessionBus.registerService("moe.emmaexe.ntfyDesktop")) { 12 | QDBusMessage message = QDBusMessage::createMethodCall("moe.emmaexe.ntfyDesktop", "/SingleInstanceManager", "moe.emmaexe.ntfyDesktop.SingleInstanceManager", "newInstanceStarted"); 13 | if (url.has_value()) { 14 | message << QString(url.value().c_str()); 15 | } else { 16 | message << ""; 17 | } 18 | QDBusMessage reply = sessionBus.call(message); 19 | if (reply.type() == QDBusMessage::ErrorMessage) { std::cerr << "DBus error: " << reply.errorMessage().toStdString() << std::endl; } 20 | exit(1); 21 | } 22 | 23 | if (!sessionBus.registerObject("/SingleInstanceManager", this, QDBusConnection::ExportAllSlots | QDBusConnection::ExportAllSignals)) { 24 | std::cerr << "Failed to register DBus object: " << sessionBus.lastError().message().toStdString() << std::endl; 25 | exit(1); 26 | } 27 | } 28 | 29 | SingleInstanceManager::~SingleInstanceManager() {} 30 | 31 | void SingleInstanceManager::newInstanceStarted(const QString& url) { 32 | if (this->onNewInstanceStarted) { this->onNewInstanceStarted(url.isEmpty() ? std::nullopt : std::make_optional(url.toStdString())); } 33 | } 34 | -------------------------------------------------------------------------------- /app/src/FileManager/FileManager.cpp: -------------------------------------------------------------------------------- 1 | #include "FileManager.hpp" 2 | 3 | #include "../Util/Util.hpp" 4 | #include "ntfyDesktop.hpp" 5 | 6 | #include 7 | #include 8 | 9 | #include 10 | #include 11 | 12 | std::vector FileManager::tempFileHolder = {}; 13 | 14 | QUrl FileManager::urlToTempFile(QUrl url) { 15 | QTemporaryFile* file = new QTemporaryFile(QApplication::instance()); 16 | if (!file->open()) { throw FileManagerException("Unable to create temporary file."); } 17 | file->setAutoRemove(true); 18 | FileManager::tempFileHolder.push_back(file); 19 | std::ofstream fileStream(file->fileName().toStdString(), std::ios::binary); 20 | CURL* curl = curl_easy_init(); 21 | if (curl) { 22 | curl_easy_setopt(curl, CURLOPT_URL, url.toString().toStdString().c_str()); 23 | curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, FileManager::urlToTempFileWriteCallback); 24 | curl_easy_setopt(curl, CURLOPT_WRITEDATA, &fileStream); 25 | curl_easy_setopt(curl, CURLOPT_USERAGENT, Util::getRandomUA().c_str()); 26 | curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); 27 | 28 | char curlError[CURL_ERROR_SIZE] = ""; 29 | curl_easy_setopt(curl, CURLOPT_ERRORBUFFER, curlError); 30 | 31 | if (curl_easy_perform(curl) != CURLE_OK) { 32 | std::string err = "Failed to download file: "; 33 | err.append(curlError); 34 | throw FileManagerException(err.c_str()); 35 | } 36 | } else { 37 | throw FileManagerException("Unable to create libcurl handle."); 38 | } 39 | QString fileName = file->fileName(); 40 | // if (ND_BUILD_TYPE == "Flatpak") { fileName.prepend(QString::fromStdString("/run/user/" + std::to_string(getuid()) + "/.flatpak/moe.emmaexe.ntfyDesktop")); } 41 | return QUrl::fromLocalFile(fileName); 42 | } 43 | 44 | size_t FileManager::urlToTempFileWriteCallback(char* ptr, size_t size, size_t nmemb, void* userdata) { 45 | std::ofstream* fileStream = static_cast(userdata); 46 | fileStream->write(ptr, size * nmemb); 47 | return size * nmemb; 48 | } 49 | -------------------------------------------------------------------------------- /app/src/NotificationStore/NotificationStore.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | #include 6 | 7 | /** 8 | * @brief A static global class, holds the last seen notification. 9 | */ 10 | class NotificationStore { 11 | public: 12 | NotificationStore() = delete; 13 | /** 14 | * @brief Update a domain/topic with the ID of the last seen notification. 15 | * 16 | * @param domain The domain to update 17 | * @param topic The topic to update 18 | * @param notificationID The ID of the last seen notification for that domain/topic 19 | */ 20 | static void update(const std::string& domain, const std::string& topic, const std::string& notificationID = "all"); 21 | /** 22 | * @brief Get the last seen notification ID for some domain/topic. 23 | * 24 | * @param domain The domain to get 25 | * @param topic The topic to get 26 | * @return std::optional - The ID of the last seen notification for that domain/topic. If there is no ID, returns `std::nullopt`. 27 | */ 28 | static std::optional get(const std::string& domain, const std::string& topic); 29 | /** 30 | * @brief Check if a last seen notification ID for some domain/topic exists. 31 | * 32 | * @param domain The domain to check 33 | * @param topic The topic to check 34 | */ 35 | static bool exists(const std::string& domain, const std::string& topic); 36 | /** 37 | * @brief Remove a domain/topic from the NotificationStore. 38 | * 39 | * @param domain The domain to remove 40 | * @param topic The topic to remove 41 | */ 42 | static void remove(const std::string& domain, const std::string& topic); 43 | /** 44 | * @brief Synchronize the NotificationStore to the Config. 45 | */ 46 | static void configSync(); 47 | private: 48 | static bool initialized; 49 | static std::map internalData; 50 | static void read(); 51 | static void write(); 52 | static const std::string getStorePath(); 53 | static const std::string getStoreFile(); 54 | }; 55 | -------------------------------------------------------------------------------- /app/src/Util/Util.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | 8 | /** 9 | * @brief Miscellaneous utility functions. 10 | */ 11 | namespace Util { 12 | /** 13 | * @brief Get a random number in the range [min, max]. 14 | * 15 | * @param min The start of the number range. 16 | * @param max The end of the number range. 17 | * @return int - The random number. 18 | */ 19 | int random(int min, int max); 20 | /** 21 | * @brief Get a random, commonly used user agent. 22 | * 23 | * @param limit Limit the search to the top N most commonly used user agents. 24 | * @return std::string - The user agent. 25 | */ 26 | std::string getRandomUA(int limit = 3); 27 | /** 28 | * @brief Split a string into a vector of strings based on a delimiter. 29 | * Does not remove trailing empty strings if the source string ends with the delimiter. 30 | * 31 | * @param string The string getting split 32 | * @param delimiter The delimiter 33 | * @return std::vector - The resulting vector of strings. 34 | */ 35 | std::vector split(const std::string& string, const std::string& delimiter); 36 | /** 37 | * @brief Check if a string contains a valid ntfy domain 38 | * 39 | * @param domain The domain to check 40 | */ 41 | bool isDomain(const std::string& domain); 42 | /** 43 | * @brief Check if a string contains a valid ntfy topic 44 | * 45 | * @param topic The topic to check 46 | */ 47 | bool isTopic(const std::string& topic); 48 | /** 49 | * @brief Change the visibility of an entire Qt layout. 50 | * @warning Only works with widgets and layouts. Spacers and other elements are unsupported. 51 | */ 52 | void setLayoutVisibility(QLayout* layout, bool visible); 53 | /** 54 | * @brief Compares three-part version strings in the format `major.minor.patch`. 55 | * 56 | * @param first The first version string used in the comparison 57 | * @param second The second version string used in the comparison 58 | * @return int - `-1` if `first` is bigger, `0` if they are equal and `1` if `second` is bigger. 59 | * @throws std::invalid_argument - Thrown when either of the strings can't be recognised as a valid version. 60 | */ 61 | int versionCompare(const std::string& first, const std::string& second); 62 | /** 63 | * @brief Color related functions for dynamic theme handling 64 | */ 65 | namespace Colors { 66 | const QColor textColor(); 67 | const QColor textColorSuccess(); 68 | const QColor textColorFailure(); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /.clang-format: -------------------------------------------------------------------------------- 1 | BasedOnStyle: Google 2 | AccessModifierOffset: 0 3 | AlignAfterOpenBracket: BlockIndent 4 | AlignArrayOfStructures: None 5 | AlignConsecutiveAssignments: false 6 | AlignConsecutiveBitFields: false 7 | AlignConsecutiveDeclarations: false 8 | AlignConsecutiveMacros: false 9 | AlignConsecutiveShortCaseStatements: 10 | Enabled: false 11 | AlignConsecutiveTableGenBreakingDAGArgColons: false 12 | AlignConsecutiveTableGenCondOperatorColons: false 13 | AlignConsecutiveTableGenDefinitionColons: false 14 | AlignEscapedNewlines: false 15 | AlignOperands: false 16 | AlignTrailingComments: false 17 | AllowAllArgumentsOnNextLine: true 18 | AllowAllParametersOfDeclarationOnNextLine: true 19 | AllowShortBlocksOnASingleLine: Always 20 | AllowShortCaseExpressionOnASingleLine: true 21 | AllowShortCaseLabelsOnASingleLine: true 22 | AllowShortCompoundRequirementOnASingleLine: true 23 | AllowShortEnumsOnASingleLine: true 24 | AllowShortFunctionsOnASingleLine: true 25 | AllowShortIfStatementsOnASingleLine: AllIfsAndElse 26 | AllowShortLambdasOnASingleLine: All 27 | AllowShortLoopsOnASingleLine: true 28 | BinPackArguments: false 29 | BitFieldColonSpacing: After 30 | BreakAfterAttributes: Leave 31 | BreakAfterReturnType: Automatic 32 | BreakArrays: false 33 | BreakBeforeBinaryOperators: None 34 | BreakBeforeBraces: Attach 35 | BreakBeforeConceptDeclarations: Never 36 | BreakBeforeTernaryOperators: false 37 | BreakConstructorInitializers: AfterColon 38 | BreakFunctionDefinitionParameters: false 39 | BreakInheritanceList: AfterColon 40 | BreakStringLiterals: false 41 | BreakTemplateDeclarations: No 42 | ColumnLimit: 200 43 | CompactNamespaces: false 44 | ConstructorInitializerIndentWidth: 4 45 | ContinuationIndentWidth: 4 46 | Cpp11BracedListStyle: false 47 | DerivePointerAlignment: true 48 | EmptyLineAfterAccessModifier: Never 49 | EmptyLineBeforeAccessModifier: Never 50 | FixNamespaceComments: false 51 | IncludeBlocks: Regroup 52 | IncludeCategories: 53 | # Headers in <> without extension. 54 | - Regex: '<([A-Za-z0-9\Q/-_\E])+>' 55 | Priority: 4 56 | # Headers in <> from specific external libraries. 57 | - Regex: '<(catch2|boost)\/' 58 | Priority: 3 59 | # Headers in <> with extension. 60 | - Regex: '<([A-Za-z0-9.\Q/-_\E])+>' 61 | Priority: 2 62 | # Headers in "" with extension. 63 | - Regex: '"([A-Za-z0-9.\Q/-_\E])+"' 64 | Priority: 1 65 | IndentAccessModifiers: true 66 | IndentCaseBlocks: true 67 | IndentCaseLabels: true 68 | IndentExternBlock: Indent 69 | IndentGotoLabels: true 70 | IndentPPDirectives: None 71 | IndentWidth: 4 72 | InsertBraces: true 73 | InsertNewlineAtEOF: true 74 | InsertTrailingCommas: None 75 | LineEnding: LF 76 | NamespaceIndentation: All 77 | PackConstructorInitializers: NextLine 78 | PointerAlignment: Left 79 | QualifierAlignment: Left 80 | RemoveSemicolon: true 81 | SeparateDefinitionBlocks: Leave 82 | SortIncludes: CaseSensitive 83 | SortUsingDeclarations: LexicographicNumeric 84 | SpaceAfterLogicalNot: false 85 | SpaceAfterCStyleCast: false 86 | SpaceAfterTemplateKeyword: false 87 | SpaceAroundPointerQualifiers: Default 88 | SpaceBeforeAssignmentOperators: true 89 | SpaceBeforeCaseColon: false 90 | SpaceBeforeCtorInitializerColon: false 91 | SpaceBeforeInheritanceColon: false 92 | SpaceBeforeJsonColon: false 93 | SpaceBeforeParens: ControlStatements 94 | SpaceBeforeRangeBasedForLoopColon: false 95 | SpaceBeforeSquareBrackets: false 96 | SpaceInEmptyBlock: false 97 | SpacesBeforeTrailingComments: 1 98 | SpacesInAngles: Leave 99 | TabWidth: 4 100 | UseTab: Never 101 | -------------------------------------------------------------------------------- /app/moe.emmaexe.ntfyDesktop.metainfo.xml.in: -------------------------------------------------------------------------------- 1 | 2 | 3 | moe.emmaexe.ntfyDesktop 4 | 5 | Ntfy Desktop 6 | @CPACK_PACKAGE_DESCRIPTION_SUMMARY@ 7 | 8 | CC0-1.0 9 | GPL-3.0-only 10 | 11 | 12 | 13 | @CPACK_PACKAGE_DESCRIPTION@ 14 | 15 | 16 | 17 | 18 | emmaexe 19 | 20 | 21 | @CMAKE_PROJECT_HOMEPAGE_URL@issues/ 22 | @CMAKE_PROJECT_HOMEPAGE_URL@ 23 | @CMAKE_PROJECT_HOMEPAGE_URL@ 24 | 25 | moe.emmaexe.ntfyDesktop.desktop 26 | 27 | moe.emmaexe.ntfyDesktop 28 | 29 | 30 | 31 | https://raw.githubusercontent.com/emmaexe/ntfyDesktop/main/assets/screenshot1.png 32 | The main screen of the app. 33 | 34 | 35 | https://raw.githubusercontent.com/emmaexe/ntfyDesktop/main/assets/screenshot2.png 36 | The main screen after adding some notification sources. 37 | 38 | 39 | https://raw.githubusercontent.com/emmaexe/ntfyDesktop/main/assets/screenshot3.png 40 | A notification arriving after being sent using cURL. 41 | 42 | 43 | 44 | 45 | #6fbcab 46 | #196353 47 | 48 | 49 | 50 | 51 | 52 | Utility 53 | Network 54 | 55 | 56 | 57 | notifications 58 | 59 | 60 | 61 | 62 | https://github.com/emmaexe/ntfyDesktop/releases/tag/v1.2.0 63 | 64 | Release v1.2.0 65 | 66 | Added a notification source testing system 67 | Added the ability to reorder notification sources 68 | Import system for ntfy android backups 69 | Added support for Flatpak 70 | Added proper Ubuntu support 71 | 72 | 73 | 74 | 75 | https://github.com/emmaexe/ntfyDesktop/releases/tag/v1.1.0 76 | 77 | Release v1.1.0 78 | 79 | Added a HTTP/HTTPS toggle for notification sources 80 | Added a restart button to the main UI 81 | The app now gracefully handles shutdowns via unix signals 82 | Limit the number retries after failed connections 83 | 84 | 85 | 86 | 87 | https://github.com/emmaexe/ntfyDesktop/releases/tag/v1.0.0 88 | 89 | First release 90 | 91 | Created the first release of the app 92 | 93 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /app/src/ntfyDesktop.cpp: -------------------------------------------------------------------------------- 1 | #include "ntfyDesktop.hpp" 2 | 3 | #include "Config/Config.hpp" 4 | #include "ErrorWindow/ErrorWindow.hpp" 5 | #include "MainWindow/MainWindow.hpp" 6 | #include "ProtocolHandler/ProtocolHandler.hpp" 7 | #include "SingleInstanceManager/SingleInstanceManager.hpp" 8 | #include "ThreadManager/ThreadManager.hpp" 9 | #include "UnixSignalHandler/UnixSignalHandler.hpp" 10 | 11 | #include 12 | #include 13 | 14 | #include 15 | #include 16 | #include 17 | #include 18 | #include 19 | #include 20 | #include 21 | #include 22 | #include 23 | #include 24 | #include 25 | 26 | int main(int argc, char* argv[]) { 27 | QApplication app(argc, argv); 28 | 29 | KLocalizedString::setApplicationDomain("moe.emmaexe.ntfyDesktop"); 30 | KAboutData aboutData( 31 | QStringLiteral("moe.emmaexe.ntfyDesktop"), 32 | QStringLiteral("Ntfy Desktop"), 33 | QStringLiteral(ND_VERSION), 34 | i18n(ND_DESCRIPTION_SUMMARY), 35 | KAboutLicense::GPL_V3, 36 | QStringLiteral("© 2024"), 37 | QStringLiteral(), 38 | QStringLiteral(ND_HOMEPAGE_URL), 39 | QStringLiteral(ND_ISSUES_URL) 40 | ); 41 | aboutData.setProgramLogo(QIcon(":/icons/ntfyDesktop.svg")); 42 | aboutData.setDesktopFileName("moe.emmaexe.ntfyDesktop"); 43 | aboutData.addAuthor(QStringLiteral("emmaexe"), i18n("Author"), QStringLiteral("emma.git@emmaexe.moe"), QStringLiteral("https://www.emmaexe.moe/"), QStringLiteral("")); 44 | KAboutData::setApplicationData(aboutData); 45 | 46 | QCommandLineParser parser; 47 | parser.addPositionalArgument("url", "Optional URL argument. Used by x-scheme-handler to handle the ntfy:// protocol.", "[url]"); 48 | 49 | aboutData.setupCommandLine(&parser); 50 | parser.process(app); 51 | aboutData.processCommandLine(&parser); 52 | 53 | std::optional passedUrl = std::nullopt; 54 | if (!parser.positionalArguments().empty()) { passedUrl = parser.positionalArguments().first().toStdString(); } 55 | SingleInstanceManager singleInstanceManager( 56 | [&](std::optional url) { std::cerr << "A new instance was started, but this instance does not have a main window to show." << std::endl; }, passedUrl 57 | ); 58 | 59 | std::srand(std::time(0)); 60 | curl_global_init(CURL_GLOBAL_DEFAULT); 61 | 62 | std::shared_ptr window; 63 | std::shared_ptr threadManager; 64 | std::shared_ptr signalHandler; 65 | if (Config::ready()) { 66 | threadManager = std::make_shared(); 67 | window = std::make_shared(threadManager, aboutData); 68 | singleInstanceManager.onNewInstanceStarted = [&](std::optional url) { 69 | if (url.has_value()) { 70 | std::static_pointer_cast(window).get()->ntfyProtocolTriggered(ProtocolHandler(url.value())); 71 | } else { 72 | if (window->isHidden()) { 73 | window->show(); 74 | } else { 75 | QApplication::alert(window.get()); 76 | } 77 | } 78 | }; 79 | signalHandler = std::make_shared( 80 | [threadManager, window](int signal) { 81 | if (signal == SIGTERM || signal == SIGINT || signal == SIGHUP) { 82 | std::static_pointer_cast(window).get()->hide(); 83 | threadManager->stopAll(); 84 | QApplication::quit(); 85 | } 86 | }, 87 | window.get() 88 | ); 89 | } else { 90 | window = std::make_shared(aboutData); 91 | } 92 | 93 | return app.exec(); 94 | } 95 | -------------------------------------------------------------------------------- /app/src/ErrorWindow/ErrorWindow.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | ErrorWindow 4 | 5 | 6 | 7 | 0 8 | 0 9 | 450 10 | 280 11 | 12 | 13 | 14 | 15 | 0 16 | 0 17 | 18 | 19 | 20 | Error 21 | 22 | 23 | 24 | :/icons/ntfyDesktop.svg:/icons/ntfyDesktop.svg 25 | 26 | 27 | 28 | 29 | 30 | 31 | This is text describing the error that occured. 32 | 33 | 34 | An error has occured. 35 | 36 | 37 | Qt::AlignmentFlag::AlignCenter 38 | 39 | 40 | true 41 | 42 | 43 | Qt::TextInteractionFlag::LinksAccessibleByMouse|Qt::TextInteractionFlag::TextSelectableByMouse 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | PointingHandCursor 53 | 54 | 55 | This is a reset button. It will reset the configuration file on the disk to a default state. 56 | 57 | 58 | Reset Configuration 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | PointingHandCursor 69 | 70 | 71 | This is an exit button. It will dismiss the error and exit. 72 | 73 | 74 | Exit 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 0 89 | 0 90 | 450 91 | 30 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | ExitButton 103 | clicked() 104 | ErrorWindow 105 | close() 106 | 107 | 108 | 335 109 | 198 110 | 111 | 112 | 224 113 | 139 114 | 115 | 116 | 117 | 118 | 119 | resetConfig() 120 | 121 | 122 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ntfy Desktop 2 | 3 |  4 |  5 |  6 | 7 | A desktop client for [ntfy](https://github.com/binwiederhier/ntfy). Allows you to subscribe to topics from any ntfy server and recieve notifications natively on the desktop. 8 | 9 | ## Installation 10 | 11 | [](https://flathub.org/apps/moe.emmaexe.ntfyDesktop) 12 | [](https://github.com/emmaexe/ntfyDesktop/releases/download/v1.2.0/ntfyDesktop-1.2.0.rpm) 13 | [](https://github.com/emmaexe/ntfyDesktop/releases/download/v1.2.0/ntfyDesktop-1.2.0.deb) 14 | 15 | You can also download the [latest release](https://github.com/emmaexe/ntfyDesktop/releases/latest) for manual installation. 16 | 17 | ## Screenshots 18 | 19 |  20 | 21 |  22 | 23 |  24 | 25 | ## Want to contribute? Found a bug? Have a question? 26 | 27 | [](https://github.com/emmaexe/ntfyDesktop/issues) 28 | [](https://ln.emmaexe.moe/discord-server) 29 | [](https://ln.emmaexe.moe/matrix-server) 30 | 31 | ## Building 32 | 33 | ### Download the necessary dependencies 34 | 35 | #### Fedora 36 | 37 | ```bash 38 | dnf groupinstall "Development Tools" 39 | dnf install gcc-c++ cmake extra-cmake-modules boost-devel libcurl-devel qt6-qtbase-devel kf6-kcoreaddons-devel kf6-ki18n-devel kf6-knotifications-devel kf6-kxmlgui-devel rpm-build 40 | ``` 41 | 42 | #### Ubuntu 43 | 44 | ```bash 45 | apt install git g++ cmake extra-cmake-modules libboost-serialization-dev libcurl4-openssl-dev qt6-base-dev libkf6coreaddons-dev libkf6i18n-dev libkf6notifications-dev libkf6xmlgui-dev 46 | ``` 47 | 48 | #### Others 49 | 50 | You will need the following: 51 | 52 | - Basic development tools (git, make, etc.) 53 | - A C++ compiler (e.g. g++) 54 | - CMake (with [ECM](https://api.kde.org/frameworks/extra-cmake-modules/html/index.html)) 55 | - libcurl development libraries 56 | - boost development libraries 57 | - Base Qt6 development libraries 58 | - KDE Frameworks' KCoreAddons, Ki18n, KNotifications and KXmlGui development libraries 59 | 60 | ### Clone the repository 61 | 62 | ```bash 63 | git clone https://github.com/emmaexe/ntfyDesktop.git && cd ntfyDesktop 64 | ``` 65 | 66 | #### or download the latest release 67 | 68 | ```bash 69 | curl -s https://api.github.com/repos/emmaexe/ntfyDesktop/releases/latest | grep "tarball_url" | cut -d '"' -f 4 | xargs curl -L -o ntfyDesktop.tar.gz && mkdir ntfyDesktop && tar -xzf ntfyDesktop.tar.gz -C ntfyDesktop --strip-components=1 && rm ntfyDesktop.tar.gz && cd ntfyDesktop 70 | ``` 71 | 72 | ### Build the project 73 | 74 | ```bash 75 | cmake -DCMAKE_BUILD_TYPE=Release -B build 76 | cmake --build build 77 | ``` 78 | 79 | ### Create packages for installation 80 | 81 | ```bash 82 | cd build && cpack 83 | ``` 84 | -------------------------------------------------------------------------------- /app/src/UnixSignalHandler/UnixSignalHandler.cpp: -------------------------------------------------------------------------------- 1 | #include "UnixSignalHandler.hpp" 2 | 3 | #include 4 | 5 | int UnixSignalHandler::sighupFileDescriptor[2] = {}; 6 | int UnixSignalHandler::sigintFileDescriptor[2] = {}; 7 | int UnixSignalHandler::sigtermFileDescriptor[2] = {}; 8 | 9 | UnixSignalHandler::UnixSignalHandler(std::function fun, QObject* parent): fun(fun), QObject(parent) { 10 | // Register SigHup 11 | if (socketpair(AF_UNIX, SOCK_STREAM, 0, UnixSignalHandler::sighupFileDescriptor)) { 12 | std::cerr << "Unable to handle SIGHUP: " << "Failed to create socketpair" << std::endl; 13 | } else { 14 | this->sighupNotifier = new QSocketNotifier(UnixSignalHandler::sighupFileDescriptor[1], QSocketNotifier::Read, this); 15 | connect(this->sighupNotifier, SIGNAL(activated(QSocketDescriptor)), this, SLOT(handleSigHup())); 16 | 17 | struct sigaction hup; 18 | hup.sa_handler = UnixSignalHandler::hupSignalHandler; 19 | sigemptyset(&hup.sa_mask); 20 | hup.sa_flags = 0; 21 | hup.sa_flags |= SA_RESTART; 22 | if (sigaction(SIGHUP, &hup, 0)) { std::cerr << "Unable to handle SIGHUP: " << "Failed to call sigaction" << std::endl; } 23 | } 24 | 25 | // Register SigInt 26 | if (socketpair(AF_UNIX, SOCK_STREAM, 0, UnixSignalHandler::sigintFileDescriptor)) { 27 | std::cerr << "Unable to handle SIGINT: " << "Failed to create socketpair" << std::endl; 28 | } else { 29 | this->sigintNotifier = new QSocketNotifier(UnixSignalHandler::sigintFileDescriptor[1], QSocketNotifier::Read, this); 30 | connect(this->sigintNotifier, SIGNAL(activated(QSocketDescriptor)), this, SLOT(handleSigInt())); 31 | 32 | struct sigaction intr; 33 | intr.sa_handler = UnixSignalHandler::intSignalHandler; 34 | sigemptyset(&intr.sa_mask); 35 | intr.sa_flags = 0; 36 | intr.sa_flags |= SA_RESTART; 37 | if (sigaction(SIGINT, &intr, 0)) { std::cerr << "Unable to handle SIGINT: " << "Failed to call sigaction" << std::endl; } 38 | } 39 | 40 | // Register SigTerm 41 | if (socketpair(AF_UNIX, SOCK_STREAM, 0, UnixSignalHandler::sigtermFileDescriptor)) { 42 | std::cerr << "Unable to handle SIGTERM: " << "Failed to create socketpair" << std::endl; 43 | } else { 44 | this->sigtermNotifier = new QSocketNotifier(UnixSignalHandler::sigtermFileDescriptor[1], QSocketNotifier::Read, this); 45 | connect(this->sigtermNotifier, SIGNAL(activated(QSocketDescriptor)), this, SLOT(handleSigTerm())); 46 | 47 | struct sigaction term; 48 | term.sa_handler = UnixSignalHandler::termSignalHandler; 49 | sigemptyset(&term.sa_mask); 50 | term.sa_flags = 0; 51 | term.sa_flags |= SA_RESTART; 52 | if (sigaction(SIGTERM, &term, 0)) { std::cerr << "Unable to handle SIGTERM: " << "Failed to call sigaction" << std::endl; } 53 | } 54 | } 55 | 56 | void UnixSignalHandler::hupSignalHandler(int) { 57 | char a = 1; 58 | ssize_t n = write(UnixSignalHandler::sighupFileDescriptor[0], &a, sizeof(a)); 59 | } 60 | 61 | void UnixSignalHandler::intSignalHandler(int) { 62 | char a = 1; 63 | ssize_t n = write(UnixSignalHandler::sigintFileDescriptor[0], &a, sizeof(a)); 64 | } 65 | 66 | void UnixSignalHandler::termSignalHandler(int) { 67 | char a = 1; 68 | ssize_t n = write(UnixSignalHandler::sigtermFileDescriptor[0], &a, sizeof(a)); 69 | } 70 | 71 | void UnixSignalHandler::handleSigHup() { 72 | this->sighupNotifier->setEnabled(false); 73 | char tmp; 74 | ssize_t n = read(this->sighupFileDescriptor[1], &tmp, sizeof(tmp)); 75 | 76 | this->fun(SIGHUP); 77 | 78 | this->sighupNotifier->setEnabled(true); 79 | } 80 | 81 | void UnixSignalHandler::handleSigInt() { 82 | this->sigintNotifier->setEnabled(false); 83 | char tmp; 84 | ssize_t n = read(this->sigintFileDescriptor[1], &tmp, sizeof(tmp)); 85 | 86 | this->fun(SIGINT); 87 | 88 | this->sigintNotifier->setEnabled(true); 89 | } 90 | 91 | void UnixSignalHandler::handleSigTerm() { 92 | this->sigtermNotifier->setEnabled(false); 93 | char tmp; 94 | ssize_t n = read(this->sigtermFileDescriptor[1], &tmp, sizeof(tmp)); 95 | 96 | this->fun(SIGTERM); 97 | 98 | this->sigtermNotifier->setEnabled(true); 99 | } 100 | -------------------------------------------------------------------------------- /app/src/NotificationManager/NotificationManager.cpp: -------------------------------------------------------------------------------- 1 | #include "NotificationManager.hpp" 2 | 3 | #include "../FileManager/FileManager.hpp" 4 | #include "ntfyDesktop.hpp" 5 | 6 | #include 7 | #include 8 | 9 | NotificationAction::NotificationAction(const nlohmann::json& actionData) { 10 | this->type = NotificationActionType::BUTTON; 11 | try { 12 | if (actionData["action"] == "view") { 13 | this->label = actionData["label"]; 14 | this->url = actionData["url"]; 15 | this->useable = true; 16 | } else { 17 | this->useable = false; 18 | } 19 | } catch (nlohmann::json::parse_error err) { this->useable = false; } 20 | } 21 | 22 | NotificationAction::NotificationAction(const std::string& clickUrl) { 23 | this->type = NotificationActionType::CLICK; 24 | this->label = "Open URL"; 25 | this->url = clickUrl; 26 | this->useable = true; 27 | } 28 | 29 | void NotificationManager::generalNotification( 30 | const std::string title, const std::string message, std::optional priority, std::optional attachment, 31 | std::optional> actions 32 | ) { 33 | KNotification* notification = new KNotification("general"); 34 | 35 | if (priority.has_value()) { 36 | if (priority.value() == NotificationPriority::HIGHEST) { 37 | notification->setUrgency(KNotification::Urgency::CriticalUrgency); 38 | } else if (priority.value() == NotificationPriority::HIGH) { 39 | notification->setUrgency(KNotification::Urgency::HighUrgency); 40 | } else if (priority.value() == NotificationPriority::NORMAL) { 41 | notification->setUrgency(KNotification::Urgency::NormalUrgency); 42 | } else if (priority.value() == NotificationPriority::LOW || priority.value() == NotificationPriority::LOWEST) { 43 | notification->setUrgency(KNotification::Urgency::LowUrgency); 44 | } 45 | } 46 | 47 | notification->setTitle(title.c_str()); 48 | notification->setText(message.c_str()); 49 | notification->setIconName("moe.emmaexe.ntfyDesktop"); 50 | 51 | if (attachment.has_value()) { 52 | if (ND_BUILD_TYPE == "Flatpak") { 53 | QUrl fileUrl = QUrl(QString::fromStdString(attachment->url)); 54 | KNotificationAction* knaction = notification->addAction(QStringLiteral("Open Attachment")); 55 | KNotificationAction::connect(knaction, &KNotificationAction::activated, [fileUrl]() { QDesktopServices::openUrl(fileUrl); }); 56 | } else { 57 | QUrl tempFileUrl = FileManager::urlToTempFile(QUrl(QString::fromStdString(attachment->url))); 58 | notification->setUrls({ tempFileUrl }); 59 | } 60 | } 61 | 62 | if (actions.has_value()) { 63 | for (NotificationAction action: actions.value()) { 64 | if (!action.useable) { continue; } 65 | KNotificationAction* knaction; 66 | 67 | if (action.type == NotificationActionType::CLICK) { 68 | KNotificationAction* bknaction = notification->addAction(QString::fromStdString(action.label)); 69 | KNotificationAction::connect(bknaction, &KNotificationAction::activated, [actionUrl = action.url]() { QDesktopServices::openUrl(QUrl(QString::fromStdString(actionUrl))); }); 70 | knaction = notification->addDefaultAction(QString::fromStdString(action.label)); 71 | } else if (action.type == NotificationActionType::BUTTON) { 72 | knaction = notification->addAction(QString::fromStdString(action.label)); 73 | } 74 | 75 | KNotificationAction::connect(knaction, &KNotificationAction::activated, [actionUrl = action.url]() { QDesktopServices::openUrl(QUrl(QString::fromStdString(actionUrl))); }); 76 | } 77 | } 78 | 79 | notification->sendEvent(); 80 | } 81 | 82 | void NotificationManager::startupNotification() { 83 | KNotification* notification = new KNotification("startup"); 84 | notification->setUrgency(KNotification::Urgency::LowUrgency); 85 | notification->setTitle("Ntfy Desktop"); 86 | notification->setText("Ntfy Desktop is running in the background."); 87 | notification->setIconName("moe.emmaexe.ntfyDesktop"); 88 | notification->sendEvent(); 89 | } 90 | 91 | void NotificationManager::errorNotification(const std::string title, const std::string message) { 92 | KNotification* notification = new KNotification("error"); 93 | notification->setTitle(QString::fromStdString(title)); 94 | notification->setText(QString::fromStdString(message)); 95 | notification->setUrgency(KNotification::Urgency::HighUrgency); 96 | notification->setIconName(QStringLiteral("moe.emmaexe.ntfyDesktop")); 97 | notification->sendEvent(); 98 | } 99 | -------------------------------------------------------------------------------- /app/src/ImportDialog/ImportDialog.cpp: -------------------------------------------------------------------------------- 1 | #include "ImportDialog.hpp" 2 | 3 | #include "../Config/Config.hpp" 4 | #include "../ProtocolHandler/ProtocolHandler.hpp" 5 | #include "../Util/Util.hpp" 6 | #include "ntfyDesktop.hpp" 7 | #include "ui_ImportDialog.h" 8 | 9 | #include 10 | #include 11 | #include 12 | 13 | ImportDialog::ImportDialog(QWidget* parent): QDialog(parent, Qt::Dialog | Qt::WindowStaysOnTopHint), ui(new Ui::ImportDialog) { 14 | this->ui->setupUi(this); 15 | this->setAttribute(Qt::WA_DeleteOnClose); 16 | 17 | QObject::connect(this->ui->fileSelectButton, &QToolButton::clicked, this, &ImportDialog::fileSelectButton); 18 | QObject::connect(this->ui->applyButton, &QToolButton::clicked, this, &ImportDialog::applyButton); 19 | } 20 | 21 | ImportDialog::~ImportDialog() { delete this->ui; } 22 | 23 | void ImportDialog::fileSelectButton() { 24 | this->ui->statusLabel->setStyleSheet("font-weight: bold; color: " + Util::Colors::textColor().name() + ";"); 25 | this->ui->statusLabel->setText("Loading backup..."); 26 | QFileDialog* fileDialog = new QFileDialog(this, "Select Ntfy Android backup", QStandardPaths::writableLocation(QStandardPaths::HomeLocation), "JSON files (*.json)"); 27 | fileDialog->setFileMode(QFileDialog::FileMode::ExistingFile); 28 | if (fileDialog->exec()) { 29 | std::ifstream fileStream(fileDialog->selectedFiles().at(0).toStdString()); 30 | if (fileStream.is_open()) { 31 | try { 32 | nlohmann::json data = nlohmann::json::parse(fileStream); 33 | if (data["magic"] == "ntfy2586") { 34 | this->internalTempConfig = nlohmann::json::object(); 35 | this->internalTempConfig["version"] = ND_VERSION; 36 | this->internalTempConfig["sources"] = nlohmann::json::array(); 37 | int entryCounter = 1; 38 | 39 | for (nlohmann::json source: data["subscriptions"]) { 40 | ProtocolHandler parsedUrl(source["baseUrl"]); 41 | nlohmann::json entry = nlohmann::json::object(); 42 | entry["name"] = source.contains("displayName") ? std::string(source["displayName"]) : "Imported Notification Source " + std::to_string(entryCounter); 43 | entry["domain"] = parsedUrl.domain(); 44 | entry["topic"] = source["topic"]; 45 | entry["secure"] = !(parsedUrl.protocol() == "http"); 46 | this->internalTempConfig["sources"].push_back(entry); 47 | entryCounter++; 48 | } 49 | 50 | this->fileSuccess("Backup loaded successfully. Press the apply button to merge it into the current config.", this->internalTempConfig.dump()); 51 | } else { 52 | this->fileFailure("The file you selected is not a valid Ntfy Android backup file."); 53 | } 54 | } catch (const nlohmann::json::parse_error& e) { this->fileFailure("Failed to parse JSON file."); } catch (const nlohmann::json::type_error& e) { 55 | this->fileFailure("Failed to parse JSON file."); 56 | } catch (const nlohmann::json::other_error& e) { this->fileFailure("Failed to parse JSON file."); } 57 | } else { 58 | this->fileFailure("Failed to open file."); 59 | } 60 | fileStream.close(); 61 | } else { 62 | this->fileFailure("No file selected."); 63 | } 64 | delete fileDialog; 65 | } 66 | 67 | void ImportDialog::applyButton() { 68 | for (nlohmann::json source: this->internalTempConfig["sources"]) { 69 | bool seen = false; 70 | for (nlohmann::json compareTarget: Config::data()["sources"]) { 71 | if (source["domain"] == compareTarget["domain"] && source["topic"] == compareTarget["topic"]) { 72 | seen = true; 73 | break; 74 | } 75 | } 76 | if (seen) { continue; } 77 | Config::data()["sources"].push_back(source); 78 | } 79 | Config::write(); 80 | this->accept(); 81 | } 82 | 83 | void ImportDialog::fileSuccess(const std::string& text, const std::string& data) { 84 | this->ui->statusLabel->setStyleSheet("font-weight: bold; color: " + Util::Colors::textColorSuccess().name() + ";"); 85 | this->ui->statusLabel->setText(QString::fromStdString(text)); 86 | this->ui->applyButton->setEnabled(true); 87 | this->ui->dataPlainTextEdit->setPlainText(QString::fromStdString(data)); 88 | } 89 | 90 | void ImportDialog::fileFailure(const std::string& text) { 91 | this->ui->statusLabel->setStyleSheet("font-weight: bold; color: " + Util::Colors::textColorFailure().name() + ";"); 92 | this->ui->statusLabel->setText(QString::fromStdString(text)); 93 | this->ui->applyButton->setEnabled(false); 94 | this->ui->dataPlainTextEdit->setPlainText(""); 95 | } 96 | -------------------------------------------------------------------------------- /app/resources/ntfy-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 35 | 37 | 41 | 47 | 52 | 56 | 60 | 64 | 65 | 69 | 73 | 77 | 78 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /app/src/MainWindow/ConfigTab.cpp: -------------------------------------------------------------------------------- 1 | #include "ConfigTab.hpp" 2 | 3 | #include "../Util/Util.hpp" 4 | #include "ntfyDesktop.hpp" 5 | #include "ui_ConfigTab.h" 6 | 7 | #include 8 | #include 9 | 10 | #include 11 | 12 | ConfigTab::ConfigTab(std::string name, std::string domain, std::string topic, bool secure, QWidget* parent): QWidget(parent), ui(new Ui::ConfigTab) { 13 | this->ui->setupUi(this); 14 | this->ui->nameLineEdit->setText(QString::fromStdString(name)); 15 | this->ui->domainLineEdit->setText(QString::fromStdString(domain)); 16 | this->ui->topicLineEdit->setText(QString::fromStdString(topic)); 17 | this->ui->secureCheckBox->setChecked(secure); 18 | 19 | QObject::connect(this->ui->testButton, &QToolButton::clicked, this, &ConfigTab::testButton); 20 | 21 | this->ui->testLabel->hide(); 22 | this->testLabelTimer = new QTimer(this); 23 | this->testLabelTimer->setSingleShot(true); 24 | QObject::connect(this->testLabelTimer, &QTimer::timeout, this->ui->testLabel, &QLabel::hide); 25 | } 26 | 27 | ConfigTab::~ConfigTab() { delete ui; } 28 | 29 | std::string ConfigTab::getName() { return this->ui->nameLineEdit->text().toStdString(); } 30 | 31 | std::string ConfigTab::getDomain() { return this->ui->domainLineEdit->text().toStdString(); } 32 | 33 | std::string ConfigTab::getTopic() { return this->ui->topicLineEdit->text().toStdString(); } 34 | 35 | bool ConfigTab::getSecure() { return this->ui->secureCheckBox->isChecked(); } 36 | 37 | void ConfigTab::testButton() { 38 | this->testLabelTimer->stop(); 39 | this->ui->testButton->setEnabled(false); 40 | this->ui->testLabel->setStyleSheet("font-weight: bold; color: " + Util::Colors::textColor().name() + ";"); 41 | this->ui->testLabel->setText("Testing connection..."); 42 | this->ui->testLabel->show(); 43 | 44 | QThread* thread = new QThread; 45 | ConnectionTester* tester = new ConnectionTester(this->getName(), this->getDomain(), this->getTopic(), this->getSecure()); 46 | tester->moveToThread(thread); 47 | 48 | connect(thread, &QThread::started, tester, &ConnectionTester::runTest); 49 | connect(tester, &ConnectionTester::testFinished, this, &ConfigTab::testResults); 50 | 51 | connect(tester, &ConnectionTester::testFinished, tester, &ConnectionTester::deleteLater); 52 | connect(tester, &ConnectionTester::testFinished, thread, &QThread::quit); 53 | connect(thread, &QThread::finished, thread, &QThread::deleteLater); 54 | 55 | thread->start(); 56 | } 57 | 58 | void ConfigTab::testResults(const bool& result) { 59 | if (result) { 60 | this->ui->testLabel->setStyleSheet("font-weight: bold; color: " + Util::Colors::textColorSuccess().name() + ";"); 61 | this->ui->testLabel->setText("Connection successful. Notification source is available."); 62 | } else { 63 | this->ui->testLabel->setStyleSheet("font-weight: bold; color: " + Util::Colors::textColorFailure().name() + ";"); 64 | this->ui->testLabel->setText("Connection failed."); 65 | } 66 | this->ui->testLabel->show(); 67 | this->ui->testButton->setEnabled(true); 68 | this->testLabelTimer->start(5000); 69 | } 70 | 71 | ConnectionTester::ConnectionTester(const std::string& name, const std::string& domain, const std::string& topic, const bool& secure): name(name), domain(domain), topic(topic), secure(secure) {} 72 | 73 | void ConnectionTester::runTest() { 74 | this->testSuccessful = false; 75 | std::string url = (this->secure ? "https://" : "http://") + this->domain + "/" + this->topic + "/json"; 76 | 77 | CURL* curlHandle = curl_easy_init(); 78 | char curlError[CURL_ERROR_SIZE] = ""; 79 | 80 | curl_easy_setopt(curlHandle, CURLOPT_ERRORBUFFER, curlError); 81 | curl_easy_setopt(curlHandle, CURLOPT_WRITEFUNCTION, &ConnectionTester::writeCallback); 82 | curl_easy_setopt(curlHandle, CURLOPT_WRITEDATA, this); 83 | curl_easy_setopt(curlHandle, CURLOPT_NOSIGNAL, 1L); 84 | curl_easy_setopt(curlHandle, CURLOPT_TIMEOUT, 10L); 85 | curl_easy_setopt(curlHandle, CURLOPT_USERAGENT, ND_USERAGENT); 86 | curl_easy_setopt(curlHandle, CURLOPT_URL, url.c_str()); 87 | 88 | curl_easy_perform(curlHandle); 89 | 90 | emit testFinished(this->testSuccessful); 91 | } 92 | 93 | size_t ConnectionTester::writeCallback(char* ptr, size_t size, size_t nmemb, void* userdata) { 94 | ConnectionTester* this_p = static_cast(userdata); 95 | std::vector data = Util::split(std::string(ptr, size * nmemb), "\n"); 96 | 97 | for (std::string& line: data) { 98 | if (line.empty()) { continue; } 99 | try { 100 | nlohmann::json jsonData = nlohmann::json::parse(line); 101 | if (jsonData["event"] == "open") { 102 | this_p->testSuccessful = true; 103 | return 0; 104 | } 105 | } catch (nlohmann::json::parse_error ignored) {} 106 | } 107 | 108 | return size * nmemb; 109 | } 110 | -------------------------------------------------------------------------------- /app/src/ImportDialog/ImportDialog.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | ImportDialog 4 | 5 | 6 | Qt::WindowModality::WindowModal 7 | 8 | 9 | 10 | 0 11 | 0 12 | 486 13 | 300 14 | 15 | 16 | 17 | Import from backup 18 | 19 | 20 | 21 | :/icons/ntfyDesktop.svg:/icons/ntfyDesktop.svg 22 | 23 | 24 | true 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | # Import from Ntfy Android backup 33 | 34 | 35 | Qt::TextFormat::MarkdownText 36 | 37 | 38 | Qt::AlignmentFlag::AlignCenter 39 | 40 | 41 | 42 | 43 | 44 | 45 | Qt::Orientation::Vertical 46 | 47 | 48 | 49 | 20 50 | 40 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | false 59 | 60 | 61 | true 62 | 63 | 64 | 65 | 66 | 67 | 68 | Qt::Orientation::Vertical 69 | 70 | 71 | 72 | 20 73 | 40 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | Qt::Orientation::Horizontal 84 | 85 | 86 | 87 | 40 88 | 20 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | Qt::AlignmentFlag::AlignCenter 100 | 101 | 102 | true 103 | 104 | 105 | 106 | 107 | 108 | 109 | PointingHandCursor 110 | 111 | 112 | Select a File 113 | 114 | 115 | 116 | 117 | 118 | Qt::ToolButtonStyle::ToolButtonTextBesideIcon 119 | 120 | 121 | 122 | 123 | 124 | 125 | false 126 | 127 | 128 | PointingHandCursor 129 | 130 | 131 | Apply 132 | 133 | 134 | 135 | 136 | 137 | Qt::ToolButtonStyle::ToolButtonTextBesideIcon 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | -------------------------------------------------------------------------------- /CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.10) 2 | project( 3 | ntfyDesktop 4 | VERSION 1.2.0 5 | DESCRIPTION "A desktop client for ntfy" 6 | HOMEPAGE_URL "https://github.com/emmaexe/ntfyDesktop/" 7 | LANGUAGES C CXX 8 | ) 9 | set(CMAKE_CXX_STANDARD 17) 10 | set(CMAKE_CXX_STANDARD_REQUIRED True) 11 | 12 | # Set build flags 13 | set(CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} -O2") 14 | set(CMAKE_CXX_FLAGS_DEBUG "${CMAKE_CXX_FLAGS_DEBUG} -g") 15 | 16 | # Directory variables 17 | set(APP_DIR "${CMAKE_CURRENT_SOURCE_DIR}/app") 18 | set(SRC_DIR "${CMAKE_CURRENT_SOURCE_DIR}/app/src") 19 | set(RES_DIR "${CMAKE_CURRENT_SOURCE_DIR}/app/resources") 20 | set(ICON_DIR "${CMAKE_CURRENT_SOURCE_DIR}/app/icons") 21 | set(GEN_DIR "${CMAKE_BINARY_DIR}/generated") 22 | include_directories("${GEN_DIR}") 23 | 24 | # Libraries 25 | find_package(ECM REQUIRED NO_MODULE) 26 | set(CMAKE_MODULE_PATH ${ECM_MODULE_PATH}) 27 | include(KDEInstallDirs) # This is supposed to be KDEInstallDirs6 but KDEInstallDirs6 is broken atm 28 | include(ECMInstallIcons) 29 | include(ECMAddAppIcon) 30 | 31 | if (ND_BUILD_TYPE STREQUAL "Flatpak") 32 | find_package(nlohmann_json 3.11.3 REQUIRED) 33 | find_package(emojicpp 2.0.1 REQUIRED) 34 | else() 35 | # To update CPM.cmake, update the version number in the url and SHA hash as found in the most recent release asset. 36 | # https://github.com/cpm-cmake/CPM.cmake/wiki/Downloading-CPM.cmake-in-CMake 37 | file( 38 | DOWNLOAD 39 | https://github.com/cpm-cmake/CPM.cmake/releases/download/v0.40.2/CPM.cmake 40 | ${CMAKE_CURRENT_BINARY_DIR}/cmake/CPM.cmake 41 | EXPECTED_HASH SHA256=c8cdc32c03816538ce22781ed72964dc864b2a34a310d3b7104812a5ca2d835d 42 | ) 43 | include(${CMAKE_CURRENT_BINARY_DIR}/cmake/CPM.cmake) 44 | CPMAddPackage("gh:nlohmann/json@3.11.3") 45 | CPMAddPackage("gh:emmaexe/emojicpp@2.0.1") 46 | endif() 47 | 48 | find_package(CURL REQUIRED) 49 | find_package(Boost REQUIRED COMPONENTS serialization) 50 | find_package(Qt6 REQUIRED COMPONENTS 51 | Core 52 | Gui 53 | Widgets 54 | ) 55 | find_package(KF6CoreAddons) 56 | find_package(KF6I18n) 57 | find_package(KF6Notifications) 58 | find_package(KF6XmlGui) 59 | 60 | # Find all source files 61 | file(GLOB_RECURSE SRC_FILES "${SRC_DIR}/*.cpp" "${SRC_DIR}/*.hpp" "${SRC_DIR}/*.ui") 62 | 63 | # Configure CPack 64 | include(GNUInstallDirs) 65 | set(CPACK_GENERATOR "RPM;DEB;TGZ") 66 | set(CPACK_PACKAGE_NAME "${CMAKE_PROJECT_NAME}") 67 | set(CPACK_PACKAGE_VERSION "${PROJECT_VERSION}") 68 | set(CPACK_PACKAGE_FILE_NAME "${CPACK_PACKAGE_NAME}-${CPACK_PACKAGE_VERSION}") 69 | set(CPACK_PACKAGE_DESCRIPTION "${CMAKE_PROJECT_DESCRIPTION}. Allows you to subscribe to topics from any ntfy server and recieve notifications natively on the desktop.") 70 | set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "${CMAKE_PROJECT_DESCRIPTION}") 71 | set(CPACK_PACKAGE_CHECKSUM "SHA256") 72 | set(CPACK_RESOURCE_FILE_LICENSE "${CMAKE_SOURCE_DIR}/LICENSE") 73 | set(CPACK_RESOURCE_FILE_README "${CMAKE_SOURCE_DIR}/README.md") 74 | set(CPACK_STRIP_FILES TRUE) 75 | 76 | set(CPACK_RPM_PACKAGE_LICENSE "GPL") 77 | set(CPACK_RPM_PACKAGE_GROUP "System/Utilities") 78 | set(CPACK_RPM_PACKAGE_DESCRIPTION "${CPACK_PACKAGE_DESCRIPTION}") 79 | set(CPACK_RPM_PACKAGE_AUTOREQ TRUE) 80 | 81 | set(CPACK_DEBIAN_FILE_NAME "${CPACK_PACKAGE_FILE_NAME}.deb") 82 | set(CPACK_DEBIAN_PACKAGE_DEPENDS "libboost-serialization1.83.0") 83 | set(CPACK_DEBIAN_PACKAGE_MAINTAINER "emmaexe ") 84 | set(CPACK_DEBIAN_PACKAGE_SECTION "utils") 85 | set(CPACK_DEBIAN_PACKAGE_PRIORITY "optional") 86 | 87 | set(CPACK_COMPONENTS_ALL ON) 88 | 89 | # Configure files 90 | configure_file("${SRC_DIR}/ntfyDesktop.hpp.in" "${GEN_DIR}/ntfyDesktop.hpp" @ONLY) 91 | configure_file("${APP_DIR}/moe.emmaexe.ntfyDesktop.notifyrc.in" "${GEN_DIR}/moe.emmaexe.ntfyDesktop.notifyrc" @ONLY) 92 | configure_file("${APP_DIR}/moe.emmaexe.ntfyDesktop.metainfo.xml.in" "${GEN_DIR}/moe.emmaexe.ntfyDesktop.metainfo.xml" @ONLY) 93 | configure_file("${APP_DIR}/moe.emmaexe.ntfyDesktop.desktop.in" "${GEN_DIR}/moe.emmaexe.ntfyDesktop.desktop" @ONLY) 94 | 95 | # Enable MOC 96 | set(CMAKE_AUTOMOC ON) 97 | set(CMAKE_AUTOUIC ON) 98 | set(CMAKE_AUTORCC ON) 99 | 100 | # Add resources 101 | qt_add_resources(RES_FILES "${RES_DIR}/resources.qrc") 102 | 103 | # Add executable 104 | qt_add_executable(${CMAKE_PROJECT_NAME} ${SRC_FILES} ${MOC_FILES} ${RES_FILES}) 105 | 106 | # Link libraries 107 | target_link_libraries(${CMAKE_PROJECT_NAME} PRIVATE nlohmann_json::nlohmann_json emojicpp CURL::libcurl Boost::serialization Qt6::Core Qt6::Gui Qt6::Widgets KF6::CoreAddons KF6::I18n KF6::Notifications KF6::XmlGui) 108 | 109 | # Install files 110 | file(GLOB ICONS_SRC "${ICON_DIR}/*.svg") 111 | ecm_install_icons(ICONS ${ICONS_SRC} DESTINATION ${KDE_INSTALL_ICONDIR}) 112 | 113 | install(FILES "${GEN_DIR}/moe.emmaexe.ntfyDesktop.notifyrc" DESTINATION "${KDE_INSTALL_DATAROOTDIR}/knotifications6") 114 | 115 | install(FILES "${GEN_DIR}/moe.emmaexe.ntfyDesktop.metainfo.xml" DESTINATION "${KDE_INSTALL_DATAROOTDIR}/metainfo") 116 | 117 | install(PROGRAMS "${GEN_DIR}/moe.emmaexe.ntfyDesktop.desktop" DESTINATION ${KDE_INSTALL_APPDIR}) 118 | 119 | install(TARGETS ${CMAKE_PROJECT_NAME} ${KDE_INSTALL_TARGETS_DEFAULT_ARGS}) 120 | 121 | # Package 122 | if (NOT ND_BUILD_TYPE STREQUAL "Flatpak") 123 | include(CPack) 124 | endif() -------------------------------------------------------------------------------- /app/src/NotificationStore/NotificationStore.cpp: -------------------------------------------------------------------------------- 1 | #include "NotificationStore.hpp" 2 | 3 | #include "../Config/Config.hpp" 4 | 5 | #include 6 | #include 7 | #include 8 | #include 9 | 10 | #include 11 | #include 12 | #include 13 | #include 14 | #include 15 | #include 16 | #include 17 | 18 | bool NotificationStore::initialized = false; 19 | std::map NotificationStore::internalData = {}; 20 | 21 | void NotificationStore::update(const std::string& domain, const std::string& topic, const std::string& notificationID) { 22 | if (!NotificationStore::initialized) { NotificationStore::read(); } 23 | 24 | std::string hash = QCryptographicHash::hash(QString::fromStdString(domain + "/" + topic).toUtf8(), QCryptographicHash::Sha256).toHex().toStdString(); 25 | NotificationStore::internalData[hash] = notificationID; 26 | NotificationStore::write(); 27 | } 28 | 29 | std::optional NotificationStore::get(const std::string& domain, const std::string& topic) { 30 | if (!NotificationStore::initialized) { NotificationStore::read(); } 31 | 32 | std::string hash = QCryptographicHash::hash(QString::fromStdString(domain + "/" + topic).toUtf8(), QCryptographicHash::Sha256).toHex().toStdString(); 33 | if (NotificationStore::internalData.count(hash) == 0) { return std::nullopt; } 34 | return NotificationStore::internalData[hash]; 35 | } 36 | 37 | bool NotificationStore::exists(const std::string& domain, const std::string& topic) { return NotificationStore::get(domain, topic).has_value(); } 38 | 39 | void NotificationStore::remove(const std::string& domain, const std::string& topic) { 40 | if (!NotificationStore::initialized) { NotificationStore::read(); } 41 | 42 | std::string hash = QCryptographicHash::hash(QString::fromStdString(domain + "/" + topic).toUtf8(), QCryptographicHash::Sha256).toHex().toStdString(); 43 | if (NotificationStore::internalData.count(hash) != 0) { 44 | NotificationStore::internalData.erase(hash); 45 | NotificationStore::write(); 46 | } 47 | } 48 | 49 | void NotificationStore::configSync() { 50 | if (!NotificationStore::initialized) { NotificationStore::read(); } 51 | std::vector hashes = {}; 52 | for (nlohmann::json source: Config::data()["sources"]) { 53 | try { 54 | std::string urlpart = std::string(source["domain"]) + "/" + std::string(source["topic"]); 55 | hashes.push_back(QCryptographicHash::hash(QString::fromStdString(urlpart).toUtf8(), QCryptographicHash::Sha256).toHex().toStdString()); 56 | if (!NotificationStore::exists(std::string(source["domain"]), std::string(source["topic"]))) { 57 | NotificationStore::update(std::string(source["domain"]), std::string(source["topic"]), "all"); 58 | } 59 | } catch (nlohmann::json::out_of_range e) { std::cerr << "Invalid source in config, ignoring: " << source << std::endl; } 60 | } 61 | 62 | for (auto iter = NotificationStore::internalData.begin(); iter != NotificationStore::internalData.end();) { 63 | if (std::find(hashes.begin(), hashes.end(), iter->first) == hashes.end()) { 64 | iter = NotificationStore::internalData.erase(iter); 65 | } else { 66 | iter++; 67 | } 68 | } 69 | 70 | NotificationStore::write(); 71 | } 72 | 73 | void NotificationStore::read() { 74 | if (!std::filesystem::exists(NotificationStore::getStorePath()) || !std::filesystem::is_directory(NotificationStore::getStorePath())) { 75 | std::filesystem::create_directory(NotificationStore::getStorePath()); 76 | return; 77 | } 78 | if (!std::filesystem::exists(NotificationStore::getStoreFile())) { return; } 79 | 80 | std::ifstream stream(NotificationStore::getStoreFile(), std::ios::in | std::ios::binary); 81 | if (stream.is_open()) { 82 | try { 83 | boost::archive::binary_iarchive bindata(stream); 84 | NotificationStore::internalData.clear(); 85 | bindata >> NotificationStore::internalData; 86 | } catch (const std::exception& e) { std::cerr << "Error reading NotificationStore: " << e.what() << std::endl; } 87 | } 88 | stream.close(); 89 | } 90 | 91 | void NotificationStore::write() { 92 | if (!std::filesystem::exists(NotificationStore::getStorePath()) || !std::filesystem::is_directory(NotificationStore::getStorePath())) { 93 | std::filesystem::create_directory(NotificationStore::getStorePath()); 94 | } 95 | 96 | std::ofstream stream(NotificationStore::getStoreFile(), std::ios::out | std::ios::binary | std::ios::trunc); 97 | if (stream.is_open()) { 98 | try { 99 | boost::archive::binary_oarchive bindata(stream); 100 | bindata << NotificationStore::internalData; 101 | } catch (const std::exception& e) { std::cerr << "Error writing NotificationStore: " << e.what() << std::endl; } 102 | } 103 | stream.close(); 104 | } 105 | 106 | const std::string NotificationStore::getStorePath() { return QStandardPaths::writableLocation(QStandardPaths::AppDataLocation).toStdString(); } 107 | 108 | const std::string NotificationStore::getStoreFile() { return QStandardPaths::writableLocation(QStandardPaths::AppDataLocation).toStdString() + std::string("/NotificationStore.bin"); } 109 | -------------------------------------------------------------------------------- /app/src/Util/Util.cpp: -------------------------------------------------------------------------------- 1 | #include "Util.hpp" 2 | 3 | #include 4 | #include 5 | 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | #include 12 | #include 13 | 14 | size_t stringWriteCallback(char* ptr, size_t size, size_t nmemb, void* userdata) { 15 | std::string* rawData = static_cast(userdata); 16 | rawData->append(ptr, size * nmemb); 17 | return size * nmemb; 18 | } 19 | 20 | namespace Util { 21 | int random(int min, int max) { return rand() % (max - min + 1) + min; } 22 | 23 | std::string getRandomUA(int limit) { 24 | std::string rawData; 25 | CURL* curl = curl_easy_init(); 26 | curl_easy_setopt(curl, CURLOPT_URL, "https://raw.githubusercontent.com/microlinkhq/top-user-agents/master/src/index.json"); 27 | curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, stringWriteCallback); 28 | curl_easy_setopt(curl, CURLOPT_WRITEDATA, &rawData); 29 | if (curl_easy_perform(curl) != CURLE_OK) { return "curl/" + std::string(curl_version()); } 30 | try { 31 | nlohmann::json data = nlohmann::json::parse(rawData); 32 | int target = Util::random(0, std::min(limit, static_cast(data.size()))); 33 | return data.at(target); 34 | } catch (nlohmann::json::parse_error e) { return "curl/" + std::string(curl_version()); } 35 | } 36 | 37 | std::vector split(const std::string& string, const std::string& delimiter) { 38 | std::vector parts; 39 | std::string string_c = std::string(string); 40 | size_t pos = 0; 41 | std::string token; 42 | while ((pos = string_c.find(delimiter)) != std::string::npos) { 43 | token = string_c.substr(0, pos); 44 | parts.push_back(token); 45 | string_c.erase(0, pos + delimiter.length()); 46 | } 47 | parts.push_back(string_c); 48 | return parts; 49 | } 50 | 51 | bool isDomain(const std::string& domain) { return std::regex_match(domain, std::regex("^[.0-9:A-Za-z]+$")); } 52 | 53 | bool isTopic(const std::string& topic) { return std::regex_match(topic, std::regex("^[-0-9A-Z_a-z]{1,64}$")); } 54 | 55 | void setLayoutVisibility(QLayout* layout, bool visible) { 56 | for (int i = 0; i < layout->count(); i++) { 57 | QWidget* widget = layout->itemAt(i)->widget(); 58 | QLayout* subLayout = layout->itemAt(i)->layout(); 59 | if (widget) { 60 | widget->setVisible(visible); 61 | } else if (subLayout) { 62 | Util::setLayoutVisibility(subLayout, visible); 63 | } 64 | } 65 | } 66 | 67 | int versionCompare(const std::string& first, const std::string& second) { 68 | std::vector firstParts = split(first, "."); 69 | std::vector secondParts = split(second, "."); 70 | if (firstParts.size() != 3) { throw std::invalid_argument("Could not parse version: " + first); } 71 | if (secondParts.size() != 3) { throw std::invalid_argument("Could not parse version: " + second); } 72 | 73 | int firstMajor, firstMinor, firstPatch, secondMajor, secondMinor, secondPatch; 74 | try { 75 | firstMajor = std::stoi(firstParts[0]); 76 | firstMinor = std::stoi(firstParts[1]); 77 | firstPatch = std::stoi(firstParts[2]); 78 | } catch (const std::invalid_argument& e) { throw std::invalid_argument("Could not parse version, NaN: " + first); } catch (const std::out_of_range& e) { 79 | throw std::invalid_argument("Version number is too large: " + first); 80 | } 81 | try { 82 | secondMajor = std::stoi(secondParts[0]); 83 | secondMinor = std::stoi(secondParts[1]); 84 | secondPatch = std::stoi(secondParts[2]); 85 | } catch (const std::invalid_argument& e) { throw std::invalid_argument("Could not parse version, NaN: " + second); } catch (const std::out_of_range& e) { 86 | throw std::invalid_argument("Version number is too large: " + second); 87 | } 88 | 89 | if (firstMajor != secondMajor) { 90 | return firstMajor > secondMajor ? -1 : 1; 91 | } else if (firstMinor != secondMinor) { 92 | return firstMinor > secondMinor ? -1 : 1; 93 | } else if (firstPatch != secondPatch) { 94 | return firstPatch > secondPatch ? -1 : 1; 95 | } else { 96 | return 0; 97 | } 98 | } 99 | 100 | namespace Colors { 101 | const QColor textColor() { return QApplication::palette().color(QPalette::WindowText); } 102 | const QColor textColorSuccess() { 103 | float h, s, l, a; 104 | textColor().getHslF(&h, &s, &l, &a); 105 | 106 | h = 120.0 / 360.0; // Hue for green is 120° 107 | 108 | s = std::clamp(static_cast(s * 1.2), 0.5F, 1.0F); 109 | 110 | l *= (l > 0.5) ? 0.85 : 1.15; 111 | l = std::clamp(l, 0.35F, 0.65F); 112 | 113 | QColor color; 114 | color.setHslF(h, s, l, a); 115 | return color; 116 | } 117 | const QColor textColorFailure() { 118 | float h, s, l, a; 119 | textColor().getHslF(&h, &s, &l, &a); 120 | 121 | h = 0.0; // Hue for red is 0° 122 | 123 | s = std::clamp(static_cast(s * 1.2), 0.5F, 1.0F); 124 | 125 | l *= (l > 0.5) ? 0.85 : 1.15; 126 | l = std::clamp(l, 0.35F, 0.65F); 127 | 128 | QColor color; 129 | color.setHslF(h, s, l, a); 130 | return color; 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /app/src/Config/Config.cpp: -------------------------------------------------------------------------------- 1 | #include "Config.hpp" 2 | 3 | #include "../NotificationStore/NotificationStore.hpp" 4 | #include "../Util/Util.hpp" 5 | #include "ntfyDesktop.hpp" 6 | 7 | #include 8 | #include 9 | #include 10 | #include 11 | #include 12 | 13 | bool Config::initialized = false; 14 | bool Config::ok = true; 15 | bool Config::updating = false; 16 | nlohmann::json Config::internalData = {}; 17 | std::string Config::internalError = ""; 18 | int Config::internalErrorCounter = 0; 19 | 20 | nlohmann::json& Config::data() { 21 | if (!Config::initialized && !Config::updating) { Config::read(); } 22 | return Config::internalData; 23 | } 24 | 25 | void Config::read() { 26 | Config::ok = true; 27 | Config::internalError = ""; 28 | if (!std::filesystem::exists(Config::getConfigPath()) || !std::filesystem::is_directory(Config::getConfigPath())) { std::filesystem::create_directory(Config::getConfigPath()); } 29 | std::ifstream configStream(Config::getConfigFile()); 30 | if (configStream.is_open()) { 31 | try { 32 | Config::internalData.update(nlohmann::json::parse(configStream)); 33 | } catch (const nlohmann::json::parse_error& e) { 34 | Config::ok = false; 35 | Config::internalError = "Error thrown in Config::read(): " + std::string(e.what()); 36 | } 37 | } else { 38 | if (Config::internalErrorCounter <= 3) { 39 | internalErrorCounter++; 40 | return Config::reset(); 41 | } else { 42 | Config::ok = false; 43 | Config::internalError = "The config file does not exist."; 44 | } 45 | } 46 | configStream.close(); 47 | 48 | if (!Config::updating) { 49 | Config::updateToCurrent(); 50 | Config::initialized = true; 51 | Config::internalErrorCounter = 0; 52 | NotificationStore::configSync(); 53 | } 54 | } 55 | 56 | void Config::write() { 57 | if (!Config::initialized && !Config::updating) { Config::read(); } 58 | if (!std::filesystem::exists(Config::getConfigPath()) || !std::filesystem::is_directory(Config::getConfigPath())) { std::filesystem::create_directory(Config::getConfigPath()); } 59 | std::string configSerialized = Config::internalData.dump(); 60 | std::ofstream configStream(Config::getConfigFile(), std::ios::trunc); 61 | configStream << configSerialized; 62 | configStream.close(); 63 | if (!Config::updating) { NotificationStore::configSync(); } 64 | } 65 | 66 | bool Config::ready() { 67 | if (!Config::initialized && !Config::updating) { Config::read(); } 68 | return Config::ok; 69 | } 70 | 71 | void Config::reset() { 72 | while (!QFile::copy(":/config/defaultConfig.json", QString::fromStdString(Config::getConfigFile()))) { QFile::remove(QString::fromStdString(Config::getConfigFile())); } 73 | QFile(QString::fromStdString(Config::getConfigFile())).setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ReadGroup | QFileDevice::ReadOther); 74 | Config::read(); 75 | } 76 | 77 | const std::string& Config::getError() { 78 | if (!Config::initialized && !Config::updating) { Config::read(); } 79 | return Config::internalError; 80 | } 81 | 82 | void Config::updateToCurrent() { 83 | if (Config::updating) { return; } 84 | Config::updating = true; 85 | try { 86 | std::string version = Config::internalData["version"]; 87 | if (Util::versionCompare(ND_VERSION, version) == 0) { 88 | Config::updating = false; 89 | return; 90 | } else if (Util::versionCompare(ND_VERSION, version) > 0) { 91 | Config::ok = false; 92 | Config::internalError = "The config (" + Config::getConfigFile() + ") was created for a newer version of ntfyDesktop. Please downgrade your config manually, reset it to default values or update ntfyDesktop."; 93 | } else if (Util::versionCompare(version, "1.0.0") >= 0) { 94 | // Version is 1.0.0 or older 95 | // Schema used is: {"version": string, "sources": [{"name": string, "server": string, "topic": string}, ...]} 96 | 97 | nlohmann::json data = Config::internalData; 98 | while (!QFile::copy(QString::fromStdString(Config::getConfigFile()), QString::fromStdString(Config::getConfigFile() + ".bak"))) { 99 | QFile::remove(QString::fromStdString(Config::getConfigFile() + ".bak")); 100 | } 101 | Config::reset(); 102 | 103 | for (nlohmann::json source: data["sources"]) { 104 | nlohmann::json newSource = {}; 105 | newSource["name"] = source["name"]; 106 | newSource["domain"] = source["server"]; 107 | newSource["topic"] = source["topic"]; 108 | newSource["secure"] = true; 109 | Config::data()["sources"].push_back(newSource); 110 | } 111 | 112 | Config::write(); 113 | } 114 | } catch (const nlohmann::json::parse_error& e) { 115 | Config::ok = false; 116 | Config::internalError = "Error thrown in Config::updateToCurrent(): " + std::string(e.what()); 117 | } catch (const nlohmann::json::type_error& e) { 118 | Config::ok = false; 119 | Config::internalError = "Error thrown in Config::updateToCurrent(): " + std::string(e.what()); 120 | } catch (const nlohmann::json::other_error& e) { 121 | Config::ok = false; 122 | Config::internalError = "Error thrown in Config::updateToCurrent(): " + std::string(e.what()); 123 | } 124 | Config::updating = false; 125 | } 126 | 127 | const std::string Config::getConfigPath() { return QStandardPaths::writableLocation(QStandardPaths::ConfigLocation).toStdString() + std::string("/moe.emmaexe.ntfyDesktop/"); } 128 | 129 | const std::string Config::getConfigFile() { return QStandardPaths::writableLocation(QStandardPaths::ConfigLocation).toStdString() + std::string("/moe.emmaexe.ntfyDesktop/config.json"); } 130 | -------------------------------------------------------------------------------- /app/resources/ntfy-nobg.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 17 | 36 | 38 | 48 | 51 | 55 | 59 | 60 | 61 | 65 | 71 | 75 | 79 | 83 | 87 | 91 | 92 | 96 | 100 | 104 | 105 | 106 | 107 | 108 | -------------------------------------------------------------------------------- /app/src/ThreadManager/NtfyThread.cpp: -------------------------------------------------------------------------------- 1 | #include "NtfyThread.hpp" 2 | 3 | #include "../NotificationManager/NotificationManager.hpp" 4 | #include "../Util/Util.hpp" 5 | #include "ntfyDesktop.hpp" 6 | 7 | #include 8 | #include 9 | 10 | #include 11 | #include 12 | #include 13 | 14 | const int maxRetries = 3; 15 | const int connectionLostTimeouts[] = { 1000, 1000, 5000 }; 16 | 17 | NtfyThread::NtfyThread(std::string name, std::string domain, std::string topic, bool secure, std::string lastNotificationID, std::mutex* mutex): 18 | internalName(name), internalDomain(domain), internalTopic(topic), internalSecure(secure), lastNotificationID(lastNotificationID), mutex(mutex) { 19 | this->url = (secure ? "https://" : "http://") + domain + "/" + topic + "/json"; 20 | this->thread = std::thread(&NtfyThread::run, this); 21 | } 22 | 23 | NtfyThread::~NtfyThread() { this->stop(); } 24 | 25 | void NtfyThread::run() { 26 | this->running = true; 27 | 28 | CURL* curlHandle = curl_easy_init(); 29 | 30 | curl_easy_setopt(curlHandle, CURLOPT_WRITEFUNCTION, &NtfyThread::writeCallback); 31 | curl_easy_setopt(curlHandle, CURLOPT_WRITEDATA, this); 32 | curl_easy_setopt(curlHandle, CURLOPT_XFERINFOFUNCTION, &NtfyThread::progressCallback); 33 | curl_easy_setopt(curlHandle, CURLOPT_XFERINFODATA, this); 34 | curl_easy_setopt(curlHandle, CURLOPT_NOPROGRESS, 0L); 35 | curl_easy_setopt(curlHandle, CURLOPT_NOSIGNAL, 1L); 36 | curl_easy_setopt(curlHandle, CURLOPT_USERAGENT, ND_USERAGENT); 37 | 38 | while (this->running && this->internalErrorCounter <= maxRetries) { 39 | if (this->internalErrorCounter > 0) { std::this_thread::sleep_for(std::chrono::milliseconds(connectionLostTimeouts[this->internalErrorCounter - 1])); } 40 | 41 | std::string currentUrl = this->url + "?since=" + this->lastNotificationID; 42 | curl_easy_setopt(curlHandle, CURLOPT_URL, currentUrl.c_str()); 43 | 44 | CURLcode res = curl_easy_perform(curlHandle); 45 | if (res != CURLE_OK && res != CURLE_ABORTED_BY_CALLBACK && res != CURLE_WRITE_ERROR) { std::cerr << "curl error: " << curl_easy_strerror(res) << std::endl; } 46 | if (this->running) { this->internalErrorCounter++; } 47 | } 48 | 49 | curl_easy_cleanup(curlHandle); 50 | 51 | if (internalErrorCounter > maxRetries) { 52 | this->running = false; 53 | const std::string title = "Unable to connect to " + this->internalName; 54 | const std::string message = "Maximum retries exceeded for notification source \"" + this->internalName + "\" (" + this->url + ")"; 55 | QMetaObject::invokeMethod(QApplication::instance(), [title, message]() { NotificationManager::errorNotification(title, message); }); 56 | } 57 | } 58 | 59 | const std::string& NtfyThread::stop() { 60 | this->running = false; 61 | if (this->thread.joinable()) { this->thread.join(); } 62 | return this->lastNotificationID; 63 | } 64 | 65 | size_t NtfyThread::writeCallback(char* ptr, size_t size, size_t nmemb, void* userdata) { 66 | NtfyThread* this_p = static_cast(userdata); 67 | std::vector data = Util::split(std::string(ptr, size * nmemb), "\n"); 68 | 69 | if (!this_p->running) { return 0; } 70 | 71 | for (std::string& line: data) { 72 | if (line.empty()) { continue; } 73 | try { 74 | nlohmann::json jsonData = nlohmann::json::parse(line); 75 | if (jsonData["event"] == "message") { 76 | std::string notificationID(jsonData["id"]); 77 | this_p->lastNotificationID = notificationID; 78 | 79 | std::string title(jsonData.contains("title") ? jsonData["title"] : jsonData["topic"]); 80 | std::string message(jsonData["message"]); 81 | 82 | if (jsonData.contains("tags")) { 83 | bool seenTag = false; 84 | for (nlohmann::json item: jsonData["tags"]) { 85 | std::string tag = static_cast(item); 86 | std::string ptag = emojicpp::emoji::parse(":" + tag + ":"); 87 | if (":" + tag + ":" == ptag) { 88 | if (!seenTag) { 89 | seenTag = true; 90 | message += " Tags: "; 91 | } 92 | message += tag + " "; 93 | } else { 94 | title = ptag + " " + title; 95 | } 96 | } 97 | } 98 | 99 | std::optional priority = std::nullopt; 100 | if (jsonData.contains("priority")) { priority = static_cast(jsonData["priority"].get()); } 101 | 102 | std::optional attachment = std::nullopt; 103 | if (jsonData.contains("icon")) { 104 | attachment = NotificationAttachment(); 105 | attachment.value().type = NotificationAttachmentType::ICON; 106 | attachment.value().url = jsonData["icon"]; 107 | } 108 | if (jsonData.contains("attachment")) { 109 | attachment = NotificationAttachment(); 110 | attachment.value().type = NotificationAttachmentType::IMAGE; 111 | attachment.value().name = jsonData["attachment"]["name"]; 112 | attachment.value().url = jsonData["attachment"]["url"]; 113 | attachment.value().native = jsonData["attachment"].contains("type"); 114 | } 115 | 116 | std::optional> actions = std::nullopt; 117 | if (jsonData.contains("click")) { 118 | if (!actions.has_value()) { actions = std::vector({}); } 119 | actions.value().push_back(NotificationAction(static_cast(jsonData["click"]))); 120 | } 121 | if (jsonData.contains("actions")) { 122 | if (!actions.has_value()) { actions = std::vector({}); } 123 | for (nlohmann::json element: jsonData["actions"]) { actions.value().push_back(NotificationAction(element)); } 124 | } 125 | 126 | QMetaObject::invokeMethod(QApplication::instance(), [title, message, priority, attachment, actions]() { 127 | NotificationManager::generalNotification(title, message, priority, attachment, actions); 128 | }); 129 | } 130 | } catch (nlohmann::json::parse_error e) { 131 | this_p->mutex->lock(); 132 | std::cerr << "Malformed JSON data from notification source: " << this_p->url + "?since=" + this_p->lastNotificationID << '\n' << e.what() << std::endl; 133 | this_p->mutex->unlock(); 134 | } 135 | } 136 | 137 | return size * nmemb; 138 | } 139 | 140 | int NtfyThread::progressCallback(void* clientp, curl_off_t dltotal, curl_off_t dlnow, curl_off_t ultotal, curl_off_t ulnow) { 141 | NtfyThread* this_p = static_cast(clientp); 142 | 143 | if (!this_p->running) { return 1; } 144 | 145 | return 0; 146 | } 147 | 148 | const std::string& NtfyThread::name() { return this->internalName; } 149 | 150 | const std::string& NtfyThread::domain() { return this->internalDomain; } 151 | 152 | const std::string& NtfyThread::topic() { return this->internalTopic; } 153 | 154 | const bool NtfyThread::secure() { return this->internalSecure; } 155 | -------------------------------------------------------------------------------- /app/src/MainWindow/ConfigTab.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | ConfigTab 4 | 5 | 6 | 7 | 0 8 | 0 9 | 400 10 | 300 11 | 12 | 13 | 14 | 15 | 0 16 | 0 17 | 18 | 19 | 20 | 21 | 22 | 23 | QLayout::SizeConstraint::SetMaximumSize 24 | 25 | 26 | Qt::AlignmentFlag::AlignCenter 27 | 28 | 29 | Qt::AlignmentFlag::AlignCenter 30 | 31 | 32 | 33 | 34 | The name of this notification source. 35 | 36 | 37 | This is the name of the notification source you are viewing. 38 | 39 | 40 | Name 41 | 42 | 43 | 44 | 45 | 46 | 47 | The name of this notification source. 48 | 49 | 50 | This is the name of the notification source you are viewing. 51 | 52 | 53 | New Notification Source 54 | 55 | 56 | 57 | 58 | 59 | 60 | A valid domain or IP, with or without a port e.g. ntfy.sh, 192.168.1.69:8080, 91.198.174.192 61 | 62 | 63 | This is the domain of the notification source you are viewing. It determines the server where the notifications are fetched from for this notification source. 64 | 65 | 66 | Domain 67 | 68 | 69 | 70 | 71 | 72 | 73 | A valid domain or IP, with or without a port e.g. ntfy.sh, 192.168.1.69:8080, 91.198.174.192 74 | 75 | 76 | This is the domain of the notification source you are viewing. It determines the server where the notifications are fetched from for this notification source. 77 | 78 | 79 | ntfy.sh 80 | 81 | 82 | 83 | 84 | 85 | 86 | The name of the ntfy topic. 87 | 88 | 89 | This is the topic of the notification source you are viewing. It determines the topic that you are subscribing to. 90 | 91 | 92 | Topic 93 | 94 | 95 | 96 | 97 | 98 | 99 | The name of the ntfy topic. 100 | 101 | 102 | This is the topic of the notification source you are viewing. It determines the topic that you are subscribing to. 103 | 104 | 105 | ntfyDesktop 106 | 107 | 108 | 109 | 110 | 111 | 112 | Choose whether to use https or not. 113 | 114 | 115 | This is a checkbox that toggles between https (secure) and http (unsecure) modes for the server that is hosting ntfy, that you are connecting to. This is useful if you are selfhosting a server on a local network and can't or don't want to use https. 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | Choose whether to use https or not. 126 | 127 | 128 | This is a checkbox that toggles between https (secure) and http (unsecure) modes for the server that is hosting ntfy, that you are connecting to. This is useful if you are selfhosting a server on a local network and can't or don't want to use https. 129 | 130 | 131 | HTTPS 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | Qt::Orientation::Horizontal 146 | 147 | 148 | 149 | 40 150 | 20 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter 159 | 160 | 161 | true 162 | 163 | 164 | 165 | 166 | 167 | 168 | PointingHandCursor 169 | 170 | 171 | Test Connection 172 | 173 | 174 | 175 | 176 | 177 | Qt::ToolButtonStyle::ToolButtonTextBesideIcon 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | Qt::Orientation::Vertical 187 | 188 | 189 | 190 | 20 191 | 40 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | -------------------------------------------------------------------------------- /app/src/MainWindow/MainWindow.cpp: -------------------------------------------------------------------------------- 1 | #include "MainWindow.hpp" 2 | 3 | #include "../ImportDialog/ImportDialog.hpp" 4 | #include "../NotificationManager/NotificationManager.hpp" 5 | #include "../Util/Util.hpp" 6 | #include "ui_MainWindow.h" 7 | 8 | #include 9 | #include 10 | #include 11 | #include 12 | 13 | MainWindow::MainWindow(std::shared_ptr threadManager, KAboutData& aboutData, QWidget* parent): QMainWindow(parent), ui(new Ui::MainWindow), threadManager(threadManager) { 14 | this->ui->setupUi(this); 15 | QObject::connect(this->ui->saveAction, &QAction::triggered, this, &MainWindow::saveAction); 16 | QObject::connect(this->ui->addAction, &QAction::triggered, this, &MainWindow::addAction); 17 | QObject::connect(this->ui->removeAction, &QAction::triggered, this, &MainWindow::removeAction); 18 | QObject::connect(this->ui->importAction, &QAction::triggered, this, &MainWindow::importAction); 19 | QObject::connect(this->ui->restartAction, &QAction::triggered, this, &MainWindow::restartAction); 20 | QObject::connect(this->ui->exitAction, &QAction::triggered, this, &MainWindow::exitAction); 21 | 22 | nlohmann::json sources = Config::data()["sources"]; 23 | for (int i = 0; i < sources.size(); i++) { 24 | this->tabs.push_back(new ConfigTab(sources[i]["name"], sources[i]["domain"], sources[i]["topic"], sources[i]["secure"], this)); 25 | this->ui->tabs->addTab(this->tabs.at(i), this->tabs.at(i)->getName().c_str()); 26 | } 27 | 28 | if (this->tabs.size() == 0) { 29 | this->ui->tabs->hide(); 30 | Util::setLayoutVisibility(this->ui->noSourcesContainer, true); 31 | } else { 32 | this->ui->tabs->show(); 33 | Util::setLayoutVisibility(this->ui->noSourcesContainer, false); 34 | } 35 | 36 | this->trayMenu = new QMenu(this); 37 | 38 | this->showHideQAction = new QAction(QIcon(":/icons/ntfy-symbolic.svg"), QAction::tr("Show/hide window"), this); 39 | QObject::connect(this->showHideQAction, &QAction::triggered, this, &MainWindow::showHideAction); 40 | this->trayMenu->addAction(this->showHideQAction); 41 | this->trayMenu->addAction(this->ui->restartAction); 42 | this->trayMenu->addAction(this->ui->exitAction); 43 | 44 | this->tray = new QSystemTrayIcon(this); 45 | this->tray->setIcon(QIcon(":/icons/ntfy-symbolic.svg").pixmap(236, 236)); 46 | this->tray->setContextMenu(this->trayMenu); 47 | this->tray->show(); 48 | QObject::connect(this->tray, &QSystemTrayIcon::activated, this, &MainWindow::trayIconPressed); 49 | 50 | this->helpMenu = new KHelpMenu(this, aboutData); 51 | this->helpMenu->action(KHelpMenu::MenuId::menuAboutApp)->setIcon(QIcon(QStringLiteral(":/icons/ntfyDesktop.svg"))); 52 | this->ui->menuBar->addMenu(this->helpMenu->menu()); 53 | 54 | if (this->tabs.size() == 0) { 55 | this->show(); 56 | this->ui->statusBar->showMessage(QStatusBar::tr("Ready"), 2000); 57 | } else { 58 | NotificationManager::startupNotification(); 59 | } 60 | } 61 | 62 | MainWindow::~MainWindow() { delete ui, tray; } 63 | 64 | void MainWindow::ntfyProtocolTriggered(ProtocolHandler url) { 65 | if (url.protocol() == "ntfy") { 66 | this->tabs.push_back(new ConfigTab("New Notification Source " + std::to_string(this->newTabCounter), url.domain(), url.path()[0], true, this)); 67 | this->newTabCounter++; 68 | this->ui->tabs->addTab(this->tabs.at(this->tabs.size() - 1), this->tabs.at(this->tabs.size() - 1)->getName().c_str()); 69 | this->ui->tabs->setCurrentIndex(this->tabs.size() - 1); 70 | if (this->isHidden()) { 71 | this->show(); 72 | } else { 73 | QApplication::alert(this); 74 | } 75 | } 76 | } 77 | 78 | void MainWindow::saveAction() { 79 | Config::data()["sources"] = nlohmann::json::array(); 80 | std::vector seen = {}; 81 | for (int i = 0; i < this->ui->tabs->count(); i++) { 82 | ConfigTab* tab = static_cast(this->ui->tabs->widget(i)); 83 | std::string domainTopic = tab->getDomain() + "/" + tab->getTopic(); 84 | if (std::find(seen.begin(), seen.end(), domainTopic) != seen.end()) { 85 | std::string tabName = "⚠️" + tab->getName(); 86 | this->ui->tabs->setTabText(i, QString::fromStdString(tabName)); 87 | this->ui->tabs->setCurrentIndex(i); 88 | this->ui->statusBar->showMessage(QStatusBar::tr("⚠️ Duplicate configuration found. Did not save."), 5000); 89 | return; 90 | } else if (!Util::isDomain(tab->getDomain()) || !Util::isTopic(tab->getTopic())) { 91 | std::string tabName = "⚠️" + tab->getName(); 92 | this->ui->tabs->setTabText(i, QString::fromStdString(tabName)); 93 | this->ui->tabs->setCurrentIndex(i); 94 | this->ui->statusBar->showMessage(QStatusBar::tr("⚠️ Invalid domain or topic. Did not save."), 5000); 95 | return; 96 | } else { 97 | seen.push_back(domainTopic); 98 | this->ui->tabs->setTabText(i, tab->getName().c_str()); 99 | nlohmann::json tabData; 100 | tabData["name"] = tab->getName(); 101 | tabData["domain"] = tab->getDomain(); 102 | tabData["topic"] = tab->getTopic(); 103 | tabData["secure"] = tab->getSecure(); 104 | Config::data()["sources"].push_back(tabData); 105 | } 106 | } 107 | Config::write(); 108 | this->ui->statusBar->showMessage(QStatusBar::tr("Configuration saved."), 2000); 109 | } 110 | 111 | void MainWindow::addAction() { 112 | if (this->tabs.size() == 0) { 113 | this->ui->tabs->show(); 114 | Util::setLayoutVisibility(this->ui->noSourcesContainer, false); 115 | } 116 | this->tabs.push_back(new ConfigTab("New Notification Source " + std::to_string(this->newTabCounter), "", "", true, this)); 117 | this->newTabCounter++; 118 | this->ui->tabs->addTab(this->tabs.at(this->tabs.size() - 1), this->tabs.at(this->tabs.size() - 1)->getName().c_str()); 119 | this->ui->tabs->setCurrentIndex(this->tabs.size() - 1); 120 | } 121 | 122 | void MainWindow::removeAction() { 123 | if (this->ui->tabs->count() > 0) { 124 | int i = this->ui->tabs->currentIndex(); 125 | this->ui->tabs->removeTab(i); 126 | delete this->tabs.at(i); 127 | this->tabs.erase(this->tabs.begin() + i); 128 | } 129 | if (this->tabs.size() == 0) { 130 | this->ui->tabs->hide(); 131 | Util::setLayoutVisibility(this->ui->noSourcesContainer, true); 132 | } 133 | } 134 | 135 | void MainWindow::exitAction() { 136 | if (!this->isHidden()) { 137 | this->hide(); 138 | QApplication::processEvents(); 139 | } 140 | this->threadManager->stopAll(); 141 | QApplication::quit(); 142 | } 143 | 144 | void MainWindow::closeEvent(QCloseEvent* event) { 145 | event->ignore(); 146 | this->hide(); 147 | } 148 | 149 | void MainWindow::changeEvent(QEvent* event) { 150 | if (event->type() == QEvent::WindowStateChange) { 151 | if (this->isMinimized()) { this->hide(); } 152 | } 153 | QMainWindow::changeEvent(event); 154 | } 155 | 156 | void MainWindow::trayIconPressed(QSystemTrayIcon::ActivationReason reason) { 157 | switch (reason) { 158 | case QSystemTrayIcon::Trigger: 159 | case QSystemTrayIcon::MiddleClick: 160 | if (this->isHidden()) { 161 | this->show(); 162 | } else { 163 | this->hide(); 164 | } 165 | break; 166 | default: break; 167 | } 168 | } 169 | 170 | void MainWindow::showHideAction() { 171 | if (this->isHidden()) { 172 | this->show(); 173 | } else { 174 | this->hide(); 175 | } 176 | } 177 | 178 | void MainWindow::restartAction() { 179 | bool wasShown = !this->isHidden(); 180 | if (wasShown) { 181 | this->hide(); 182 | QApplication::processEvents(); 183 | } 184 | 185 | this->tabs.clear(); 186 | this->ui->tabs->clear(); 187 | 188 | Config::read(); 189 | 190 | nlohmann::json sources = Config::data()["sources"]; 191 | for (int i = 0; i < sources.size(); i++) { 192 | this->tabs.push_back(new ConfigTab(sources[i]["name"], sources[i]["domain"], sources[i]["topic"], sources[i]["secure"], this)); 193 | this->ui->tabs->addTab(this->tabs.at(i), this->tabs.at(i)->getName().c_str()); 194 | } 195 | 196 | if (this->tabs.size() == 0) { 197 | this->ui->tabs->hide(); 198 | Util::setLayoutVisibility(this->ui->noSourcesContainer, true); 199 | } else { 200 | this->ui->tabs->show(); 201 | Util::setLayoutVisibility(this->ui->noSourcesContainer, false); 202 | } 203 | 204 | this->threadManager->restartConfig(); 205 | this->newTabCounter = 1; 206 | 207 | if (wasShown) { this->show(); } 208 | } 209 | 210 | void MainWindow::importAction() { 211 | ImportDialog* dialog = new ImportDialog(this); 212 | if (dialog->exec()) { 213 | this->restartAction(); 214 | this->ui->statusBar->showMessage("Ntfy Android backup merged into existing config.", 5000); 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /app/resources/ntfy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 23 | 25 | 28 | 32 | 36 | 37 | 40 | 44 | 48 | 49 | 59 | 69 | 77 | 82 | 88 | 93 | 98 | 104 | 105 | 106 | 134 | 138 | 142 | 146 | 150 | 154 | 158 | 162 | 166 | 167 | 169 | 170 | 172 | image/svg+xml 173 | 175 | 176 | 177 | 178 | 183 | 190 | 191 | 196 | 201 | 202 | 208 | 212 | 217 | 221 | 225 | 229 | 230 | 234 | 238 | 242 | 243 | 244 | 249 | 254 | 255 | 256 | -------------------------------------------------------------------------------- /assets/ntfyDesktop.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 23 | 25 | 28 | 32 | 36 | 37 | 40 | 44 | 48 | 49 | 59 | 69 | 77 | 82 | 88 | 93 | 98 | 104 | 105 | 117 | 118 | 148 | 153 | 158 | 163 | 168 | 173 | 178 | 183 | 188 | 189 | 191 | 192 | 194 | image/svg+xml 195 | 197 | 198 | 199 | 200 | 205 | 210 | 211 | 216 | 221 | 222 | 228 | 232 | 237 | 241 | 245 | 249 | 250 | 254 | 258 | 262 | 263 | 264 | 265 | -------------------------------------------------------------------------------- /app/resources/ntfyDesktop.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 23 | 25 | 28 | 32 | 36 | 37 | 40 | 44 | 48 | 49 | 59 | 69 | 77 | 82 | 88 | 93 | 98 | 104 | 105 | 117 | 118 | 148 | 153 | 158 | 163 | 168 | 173 | 178 | 183 | 188 | 189 | 191 | 192 | 194 | image/svg+xml 195 | 197 | 198 | 199 | 200 | 205 | 210 | 211 | 216 | 221 | 222 | 228 | 232 | 237 | 241 | 245 | 249 | 250 | 254 | 258 | 262 | 263 | 264 | 265 | -------------------------------------------------------------------------------- /app/icons/sc-apps-moe.emmaexe.ntfyDesktop.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 23 | 25 | 28 | 32 | 36 | 37 | 40 | 44 | 48 | 49 | 59 | 69 | 77 | 82 | 88 | 93 | 98 | 104 | 105 | 117 | 118 | 148 | 153 | 158 | 163 | 168 | 173 | 178 | 183 | 188 | 189 | 191 | 192 | 194 | image/svg+xml 195 | 197 | 198 | 199 | 200 | 205 | 210 | 211 | 216 | 221 | 222 | 228 | 232 | 237 | 241 | 245 | 249 | 250 | 254 | 258 | 262 | 263 | 264 | 265 | -------------------------------------------------------------------------------- /app/src/MainWindow/MainWindow.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | MainWindow 4 | 5 | 6 | 7 | 0 8 | 0 9 | 800 10 | 400 11 | 12 | 13 | 14 | Ntfy Desktop 15 | 16 | 17 | 18 | :/icons/ntfyDesktop.svg:/icons/ntfyDesktop.svg 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | PointingHandCursor 28 | 29 | 30 | This is a button that saves the configuration to the disk. 31 | 32 | 33 | Save 34 | 35 | 36 | 37 | 38 | 39 | Qt::ToolButtonStyle::ToolButtonTextBesideIcon 40 | 41 | 42 | 43 | 44 | 45 | 46 | PointingHandCursor 47 | 48 | 49 | This is a button that adds a new notification source to the config. 50 | 51 | 52 | Add 53 | 54 | 55 | 56 | 57 | 58 | Qt::ToolButtonStyle::ToolButtonTextBesideIcon 59 | 60 | 61 | 62 | 63 | 64 | 65 | PointingHandCursor 66 | 67 | 68 | This is a button that removes the currently selected notification source from the config. 69 | 70 | 71 | Remove 72 | 73 | 74 | 75 | 76 | 77 | Qt::ToolButtonStyle::ToolButtonTextBesideIcon 78 | 79 | 80 | 81 | 82 | 83 | 84 | Qt::Orientation::Horizontal 85 | 86 | 87 | 88 | 40 89 | 20 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | PointingHandCursor 98 | 99 | 100 | Saving the config does not automatically restart the background processes that handle delivering notifications. This is a button that triggers the restart action from the menu bar which then in turn reloads the config and restarts those background processes. It can also be used to discard any unsaved changes since it reloads the config from the disk. 101 | 102 | 103 | Restart 104 | 105 | 106 | 107 | 108 | 109 | Qt::ToolButtonStyle::ToolButtonTextBesideIcon 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | -1 119 | 120 | 121 | true 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 0 132 | 0 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | false 141 | 142 | 143 | This is the text that shows when there are no notification sources in the config file. 144 | 145 | 146 | # There are no notification sources 147 | 148 | 149 | Qt::TextFormat::MarkdownText 150 | 151 | 152 | Qt::AlignmentFlag::AlignCenter 153 | 154 | 155 | 156 | 157 | 158 | 159 | QLayout::SizeConstraint::SetFixedSize 160 | 161 | 162 | 163 | 164 | 165 | 0 166 | 0 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | false 175 | 176 | 177 | 178 | 0 179 | 0 180 | 181 | 182 | 183 | This is the text that shows when there are no notification sources in the config file. 184 | 185 | 186 | # Click on the 187 | 188 | 189 | Qt::TextFormat::MarkdownText 190 | 191 | 192 | Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter 193 | 194 | 195 | 196 | 197 | 198 | 199 | false 200 | 201 | 202 | This is the text that shows when there are no notification sources in the config file. 203 | 204 | 205 | Add 206 | 207 | 208 | 209 | 210 | 211 | Qt::ToolButtonStyle::ToolButtonTextBesideIcon 212 | 213 | 214 | 215 | 216 | 217 | 218 | false 219 | 220 | 221 | 222 | 0 223 | 0 224 | 225 | 226 | 227 | This is the text that shows when there are no notification sources in the config file. 228 | 229 | 230 | # button to get started 231 | 232 | 233 | Qt::TextFormat::MarkdownText 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 0 242 | 0 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | QLayout::SizeConstraint::SetFixedSize 253 | 254 | 255 | 0 256 | 257 | 258 | 259 | 260 | 261 | 0 262 | 0 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | false 271 | 272 | 273 | 274 | 0 275 | 0 276 | 277 | 278 | 279 | This is the text that shows when there are no notification sources in the config file. 280 | 281 | 282 | # After you're done, click on the 283 | 284 | 285 | Qt::TextFormat::MarkdownText 286 | 287 | 288 | Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter 289 | 290 | 291 | 292 | 293 | 294 | 295 | false 296 | 297 | 298 | This is the text that shows when there are no notification sources in the config file. 299 | 300 | 301 | Restart 302 | 303 | 304 | 305 | 306 | 307 | Qt::ToolButtonStyle::ToolButtonTextBesideIcon 308 | 309 | 310 | 311 | 312 | 313 | 314 | false 315 | 316 | 317 | 318 | 0 319 | 0 320 | 321 | 322 | 323 | This is the text that shows when there are no notification sources in the config file. 324 | 325 | 326 | # button 327 | 328 | 329 | Qt::TextFormat::MarkdownText 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 0 338 | 0 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 0 350 | 0 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 0 364 | 0 365 | 800 366 | 30 367 | 368 | 369 | 370 | 371 | &File 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | &Edit 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | &Save 396 | 397 | 398 | Save Configuration 399 | 400 | 401 | This is the menu for managing the configuration file. 402 | 403 | 404 | Ctrl+S 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | &Add 413 | 414 | 415 | This is the edit menu, for managing and editing the notification sources in the config. 416 | 417 | 418 | Ctrl+N 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | &Remove 427 | 428 | 429 | Ctrl+W 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | &Exit 438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | &Restart 446 | 447 | 448 | 449 | 450 | &Import 451 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | &Import 459 | 460 | 461 | 462 | 463 | 464 | saveButton 465 | addButton 466 | removeButton 467 | restartButton 468 | tabs 469 | dummyAddButton 470 | 471 | 472 | 473 | 474 | 475 | 476 | saveButton 477 | clicked() 478 | saveAction 479 | trigger() 480 | 481 | 482 | 22 483 | 52 484 | 485 | 486 | -1 487 | -1 488 | 489 | 490 | 491 | 492 | removeButton 493 | clicked() 494 | removeAction 495 | trigger() 496 | 497 | 498 | 240 499 | 69 500 | 501 | 502 | -1 503 | -1 504 | 505 | 506 | 507 | 508 | addButton 509 | clicked() 510 | addAction 511 | trigger() 512 | 513 | 514 | 142 515 | 69 516 | 517 | 518 | -1 519 | -1 520 | 521 | 522 | 523 | 524 | restartButton 525 | clicked() 526 | restartAction 527 | trigger() 528 | 529 | 530 | 750 531 | 53 532 | 533 | 534 | -1 535 | -1 536 | 537 | 538 | 539 | 540 | 541 | --------------------------------------------------------------------------------
13 | @CPACK_PACKAGE_DESCRIPTION@ 14 |
Release v1.2.0
Release v1.1.0
First release