diff --git a/.gitmodules b/.gitmodules index e21742bb..67e53761 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,10 +2,6 @@ path = third-party/doxyconfig url = https://github.com/LizardByte/doxyconfig.git branch = master -[submodule "third-party/googletest"] - path = third-party/googletest - url = https://github.com/google/googletest.git - branch = v1.14.x [submodule "third-party/lizardbyte-common"] path = third-party/lizardbyte-common url = https://github.com/LizardByte/lizardbyte-common.git diff --git a/.run/docs.run.xml b/.run/docs.run.xml new file mode 100644 index 00000000..5e21d885 --- /dev/null +++ b/.run/docs.run.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/README.md b/README.md index 0c9408a4..dd136bda 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,23 @@ -# Overview +
+ tray icon +

tray

+

Cross-platform implementation of a system tray icon with a popup menu and notifications.

+
-[![GitHub Workflow Status (CI)](https://img.shields.io/github/actions/workflow/status/lizardbyte/tray/ci.yml.svg?branch=master&label=CI%20build&logo=github&style=for-the-badge)](https://github.com/LizardByte/tray/actions/workflows/ci.yml?query=branch%3Amaster) -[![Codecov](https://img.shields.io/codecov/c/gh/LizardByte/tray?token=HSX66JNEOL&style=for-the-badge&logo=codecov&label=codecov)](https://codecov.io/gh/LizardByte/tray) -[![GitHub stars](https://img.shields.io/github/stars/lizardbyte/tray.svg?logo=github&style=for-the-badge)](https://github.com/LizardByte/tray) +
+ GitHub stars + GitHub Workflow Status (CI) + Codecov + SonarCloud +
-## About +# Overview + +## â„šī¸ About Cross-platform, super tiny C99 implementation of a system tray icon with a popup menu and notifications. @@ -20,23 +33,29 @@ This fork adds the following features: - refactored code, e.g., moved source code into the `src` directory - doxygen documentation and readthedocs configuration -## Screenshots +## đŸ–ŧī¸ Screenshots
- -- Linux![linux](docs/images/screenshot_linux.png) -- macOS![macOS](docs/images/screenshot_macos.png) -- Windows![windows](docs/images/screenshot_windows.png) - +
-## Supported platforms +## đŸ–Ĩī¸ Supported platforms * Linux/Qt (Qt5 or Qt6 Widgets) * Windows XP or newer (shellapi.h) * MacOS (Cocoa/AppKit) -## Prerequisites +## 📋 Prerequisites * CMake * [Ninja](https://ninja-build.org/), to have the same build commands on all platforms. @@ -76,7 +95,7 @@ Install either Qt6 _or_ Qt5 as well as libnotify development packages. The Linux -## Building +## đŸ› ī¸ Building ```bash mkdir -p build @@ -84,7 +103,7 @@ cmake -G Ninja -B build -S . ninja -C build ``` -## Python Tooling +## âš™ī¸ Python Tooling Install [uv](https://docs.astral.sh/uv/) and initialize the shared tooling submodule: @@ -94,7 +113,7 @@ uv run --project third-party/lizardbyte-common --locked --only-group lint-c \ python third-party/lizardbyte-common/scripts/update_clang_format.py ``` -## Demo +## â–ļī¸ Demo Execute the `tray_example` application: @@ -102,7 +121,7 @@ Execute the `tray_example` application: ./build/tray_example ``` -## Tests +## ✅ Tests Execute the `tests` application: @@ -110,7 +129,7 @@ Execute the `tests` application: ./build/tests/test_tray ``` -## API +## 📚 API Tray structure defines an icon and a menu. Menu is a NULL-terminated array of items. @@ -145,7 +164,7 @@ All functions are meant to be called from the UI thread only. Menu arrays must be terminated with a NULL item, e.g. the last item in the array must have text field set to NULL. -## License +## 📄 License This software is distributed under [MIT license](http://www.opensource.org/licenses/mit-license.php), so feel free to integrate it in your commercial products. diff --git a/docs/Doxyfile b/docs/Doxyfile index 3f004c67..37c4eaf2 100644 --- a/docs/Doxyfile +++ b/docs/Doxyfile @@ -24,7 +24,9 @@ # project metadata DOCSET_BUNDLE_ID = dev.lizardbyte.tray DOCSET_PUBLISHER_ID = dev.lizardbyte.tray.documentation -PROJECT_BRIEF = "Cross-platform, super tiny C99 implementation of a system tray icon with a popup menu and notifications." +PROJECT_BRIEF = "Cross-platform implementation of a system tray icon with a popup menu and notifications." +PROJECT_ICON = ../tray.svg +PROJECT_LOGO = ../tray.svg PROJECT_NAME = tray # project specific settings diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b48cac66..95669192 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -4,7 +4,7 @@ cmake_minimum_required(VERSION 3.13) project(test_tray) # Add GoogleTest directory to the project -set(GTEST_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../third-party/googletest") +set(GTEST_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../third-party/lizardbyte-common/third-party/googletest") # For Windows: prevent overriding parent compiler/linker runtime settings. if(WIN32) @@ -16,6 +16,13 @@ set(INSTALL_GMOCK OFF) add_subdirectory("${GTEST_SOURCE_DIR}" "${CMAKE_CURRENT_BINARY_DIR}/googletest") include_directories("${GTEST_SOURCE_DIR}/googletest/include" "${GTEST_SOURCE_DIR}") +set(LIZARDBYTE_COMMON_BUILD_TEST_SUPPORT ON CACHE BOOL "Build lizardbyte-common GoogleTest support helpers" FORCE) +if(NOT TARGET lizardbyte::common) + add_subdirectory("${CMAKE_CURRENT_SOURCE_DIR}/../third-party/lizardbyte-common" + "${CMAKE_CURRENT_BINARY_DIR}/lizardbyte-common") +elseif(NOT TARGET lizardbyte::test_support) + message(FATAL_ERROR "tray tests require lizardbyte::test_support") +endif() # extra libraries for tests if (APPLE) set(TEST_LIBS "-framework Cocoa") @@ -25,7 +32,6 @@ endif() file(GLOB_RECURSE TEST_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/conftest.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/utils.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/screenshot_utils.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_*.cpp" ) @@ -40,6 +46,7 @@ target_include_directories(${PROJECT_NAME} target_link_libraries(${PROJECT_NAME} ${TEST_LIBS} tray::tray + lizardbyte::test_support gtest gtest_main # if we use this we don't need our own main function ) diff --git a/tests/conftest.cpp b/tests/conftest.cpp index 6ae7c7b9..933cd9fe 100644 --- a/tests/conftest.cpp +++ b/tests/conftest.cpp @@ -1,15 +1,14 @@ // standard includes -#include #include #include // lib includes -#include +#define LIZARDBYTE_COMMON_TESTING_KEEP_GTEST_TEST +#define LIZARDBYTE_COMMON_TESTING_NO_GLOBAL_ALIASES +#include // test includes #include "tests/screenshot_utils.h" -#include "tests/utils.h" - // Undefine the original TEST macro #undef TEST @@ -20,141 +19,56 @@ /** * @brief Base class for tests. * - * This class provides a base test fixture for all tests. - * - * ``cout``, ``stderr``, and ``stdout`` are redirected to a buffer, and the buffer is printed if the test fails. - * - * @todo Retain the color of the original output. + * This class provides a base test fixture for all tests and adds tray-specific helpers. */ -class BaseTest: public ::testing::Test { +class BaseTest: public ::lizardbyte::common::testing::BaseTest { protected: - // https://stackoverflow.com/a/58369622/11214013 - - // we can possibly use some internal googletest functions to capture stdout and stderr, but I have not tested this - // https://stackoverflow.com/a/33186201/11214013 - BaseTest() = default; ~BaseTest() override = default; void SetUp() override { + ::lizardbyte::common::testing::BaseTest::SetUp(); + // todo: only run this one time, instead of every time a test is run // see: https://stackoverflow.com/questions/2435277/googletest-accessing-the-environment-from-a-test // get command line args from the test executable - testArgs = ::testing::internal::GetArgvs(); + testArgs_ = getArgs(); // then get the directory of the test executable // std::string path = ::testing::internal::GetArgvs()[0]; - testBinary = testArgs[0]; + testBinary_ = testArgs_[0]; // get the directory of the test executable - testBinaryDir = std::filesystem::path(testBinary).parent_path(); + testBinaryDir_ = std::filesystem::path(testBinary_).parent_path(); // If testBinaryDir is empty or `.` then set it to the current directory // maybe some better options here: https://stackoverflow.com/questions/875249/how-to-get-current-directory - if (testBinaryDir.empty() || testBinaryDir.string() == ".") { - testBinaryDir = std::filesystem::current_path(); + if (testBinaryDir_.empty() || testBinaryDir_.string() == ".") { + testBinaryDir_ = std::filesystem::current_path(); } initializeScreenshotsOnce(); - - sbuf = std::cout.rdbuf(); // save cout buffer (std::cout) - std::cout.rdbuf(cout_buffer.rdbuf()); // redirect cout to buffer (std::cout) - } - - void TearDown() override { - std::cout.rdbuf(sbuf); // restore cout buffer - - // get test info - const ::testing::TestInfo *const test_info = ::testing::UnitTest::GetInstance()->current_test_info(); - - if (test_info->result()->Failed()) { - std::cout << std::endl - << "Test failed: " << test_info->name() << std::endl - << std::endl - << "Captured cout:" << std::endl - << cout_buffer.str() << std::endl - << "Captured stdout:" << std::endl - << stdout_buffer.str() << std::endl - << "Captured stderr:" << std::endl - << stderr_buffer.str() << std::endl; - } - - sbuf = nullptr; // clear sbuf - if (pipe_stdout) { - pclose(pipe_stdout); - pipe_stdout = nullptr; - } - if (pipe_stderr) { - pclose(pipe_stderr); - pipe_stderr = nullptr; - } - } - - // functions and variables - std::vector testArgs; // CLI arguments used - std::filesystem::path testBinary; // full path of this binary - std::filesystem::path testBinaryDir; // full directory of this binary - std::stringstream cout_buffer; // declare cout_buffer - std::stringstream stdout_buffer; // declare stdout_buffer - std::stringstream stderr_buffer; // declare stderr_buffer - std::streambuf *sbuf {nullptr}; - FILE *pipe_stdout {nullptr}; - FILE *pipe_stderr {nullptr}; - bool screenshotsReady {false}; - - void initializeScreenshotsOnce() { - static std::once_flag screenshotInitFlag; - std::call_once(screenshotInitFlag, [this]() { - auto root = testBinaryDir; - if (!root.empty()) { - std::error_code ec; - std::filesystem::remove_all(root / "screenshots", ec); - } - screenshot::initialize(root); - }); - } - - int exec(const char *cmd) { - std::array buffer {}; - pipe_stdout = popen((std::string(cmd) + " 2>&1").c_str(), "r"); - pipe_stderr = popen((std::string(cmd) + " 2>&1").c_str(), "r"); - if (!pipe_stdout || !pipe_stderr) { - throw std::runtime_error("popen() failed!"); - } - while (fgets(buffer.data(), buffer.size(), pipe_stdout) != nullptr) { - stdout_buffer << buffer.data(); - } - while (fgets(buffer.data(), buffer.size(), pipe_stderr) != nullptr) { - stderr_buffer << buffer.data(); - } - int returnCode = pclose(pipe_stdout); - pipe_stdout = nullptr; - if (returnCode != 0) { - std::cout << "Error: " << stderr_buffer.str() << std::endl - << "Return code: " << returnCode << std::endl; - } - return returnCode; } bool ensureScreenshotReady() { - if (screenshotsReady) { + if (screenshotsReady_) { return true; } if (std::string reason; !screenshot::is_available(&reason)) { - screenshotUnavailableReason = reason; + screenshotUnavailableReason_ = reason; return false; } if (const auto root = screenshot::output_root(); root.empty()) { - screenshotUnavailableReason = "Screenshot output directory not initialized"; + screenshotUnavailableReason_ = "Screenshot output directory not initialized"; return false; } - screenshotsReady = true; + screenshotsReady_ = true; return true; } - bool captureScreenshot(const std::string &name) { - if (!screenshotsReady) { + bool captureScreenshot(const std::string &name) const { + if (!screenshotsReady_) { return false; } bool ok = screenshot::capture(name); @@ -168,35 +82,34 @@ class BaseTest: public ::testing::Test { return screenshot::output_root(); } - std::string screenshotUnavailableReason; -}; - -class LinuxTest: public BaseTest { -protected: - void SetUp() override { -#ifndef __linux__ - GTEST_SKIP_("Skipping, this test is for Linux only."); -#endif - BaseTest::SetUp(); + [[nodiscard]] const std::filesystem::path &testBinaryDir() const { + return testBinaryDir_; } -}; -class MacOSTest: public BaseTest { -protected: - void SetUp() override { -#if !defined(__APPLE__) || !defined(__MACH__) - GTEST_SKIP_("Skipping, this test is for macOS only."); -#endif - BaseTest::SetUp(); + [[nodiscard]] const std::string &screenshotUnavailableReason() const { + return screenshotUnavailableReason_; } -}; -class WindowsTest: public BaseTest { -protected: - void SetUp() override { // NOSONAR(cpp:S1185) - contains platform skip logic, not a trivial override -#ifndef _WIN32 - GTEST_SKIP_("Skipping, this test is for Windows only."); -#endif - BaseTest::SetUp(); +private: + void initializeScreenshotsOnce() const { + static std::once_flag screenshotInitFlag; + std::call_once(screenshotInitFlag, [this]() { + auto root = testBinaryDir_; + if (!root.empty()) { + std::error_code ec; + std::filesystem::remove_all(root / "screenshots", ec); + } + screenshot::initialize(root); + }); } + + std::vector testArgs_; // CLI arguments used + std::filesystem::path testBinary_; // full path of this binary + std::filesystem::path testBinaryDir_; // full directory of this binary + bool screenshotsReady_ {false}; + std::string screenshotUnavailableReason_; }; + +using LinuxTest = ::lizardbyte::common::testing::LinuxTest; +using MacOSTest = ::lizardbyte::common::testing::MacOSTest; +using WindowsTest = ::lizardbyte::common::testing::WindowsTest; diff --git a/tests/unit/test_tray.cpp b/tests/unit/test_tray.cpp index 103896e8..c7ad21fd 100644 --- a/tests/unit/test_tray.cpp +++ b/tests/unit/test_tray.cpp @@ -99,7 +99,7 @@ class TrayTest: public BaseTest { // NOSONAR(cpp:S3656) - fixture members must } // Dismisses the open menu from a background thread. - void closeMenu() { + void closeMenu() const { #if defined(TRAY_WINAPI) PostMessage(tray_get_hwnd(), WM_CANCELMODE, 0, 0); std::this_thread::sleep_for(std::chrono::milliseconds(100)); @@ -115,7 +115,7 @@ class TrayTest: public BaseTest { // NOSONAR(cpp:S3656) - fixture members must } // Capture a screenshot while the tray menu is open, then dismiss and exit. - void captureMenuStateAndExit(const char *screenshotName) { + void captureMenuStateAndExit(const char *screenshotName) const { std::atomic_bool exitRequested {false}; std::thread capture_thread([this, screenshotName, &exitRequested]() { // NOSONAR(cpp:S6168) - std::jthread is unavailable on AppleClang 17/libc++ used in CI EXPECT_TRUE(captureScreenshot(screenshotName)); @@ -172,14 +172,14 @@ class TrayTest: public BaseTest { // NOSONAR(cpp:S3656) - fixture members must // Skip tests if screenshot tooling is not available if (!ensureScreenshotReady()) { - GTEST_SKIP() << "Screenshot tooling missing: " << screenshotUnavailableReason; + GTEST_SKIP() << "Screenshot tooling missing: " << screenshotUnavailableReason(); } if (screenshot::output_root().empty()) { GTEST_SKIP() << "Screenshot output path not initialized"; } // Ensure icon files exist in test binary directory - std::filesystem::path projectRoot = testBinaryDir.parent_path(); + std::filesystem::path projectRoot = testBinaryDir().parent_path(); auto ensureIconInTestDir = [&projectRoot, this](const char *iconName) { std::filesystem::path iconSource; @@ -192,7 +192,7 @@ class TrayTest: public BaseTest { // NOSONAR(cpp:S3656) - fixture members must } if (!iconSource.empty()) { - std::filesystem::path iconDest = testBinaryDir / iconName; + std::filesystem::path iconDest = testBinaryDir() / iconName; if (!std::filesystem::exists(iconDest)) { std::error_code ec; std::filesystem::copy_file(iconSource, iconDest, ec); @@ -226,7 +226,7 @@ class TrayTest: public BaseTest { // NOSONAR(cpp:S3656) - fixture members must // Process pending events to allow tray icon to appear. // Call this ONLY before screenshots to ensure the icon is visible. - void WaitForTrayReady() { + void WaitForTrayReady() const { #if defined(TRAY_QT) for (int i = 0; i < 100; i++) { tray_loop(0); diff --git a/tests/utils.cpp b/tests/utils.cpp deleted file mode 100644 index da5f665b..00000000 --- a/tests/utils.cpp +++ /dev/null @@ -1,20 +0,0 @@ -/** - * @file utils.cpp - * @brief Utility functions - */ -// test includes -#include "utils.h" - -/** - * @brief Set an environment variable. - * @param name Name of the environment variable - * @param value Value of the environment variable - * @return 0 on success, non-zero error code on failure - */ -int setEnv(const std::string &name, const std::string &value) { -#ifdef _WIN32 - return _putenv_s(name.c_str(), value.c_str()); -#else - return setenv(name.c_str(), value.c_str(), 1); -#endif -} diff --git a/tests/utils.h b/tests/utils.h deleted file mode 100644 index 40812795..00000000 --- a/tests/utils.h +++ /dev/null @@ -1,10 +0,0 @@ -/** - * @file utils.h - * @brief Reusable functions for tests. - */ -#pragma once - -// standard includes -#include - -int setEnv(const std::string &name, const std::string &value); diff --git a/third-party/googletest b/third-party/googletest deleted file mode 160000 index f8d7d77c..00000000 --- a/third-party/googletest +++ /dev/null @@ -1 +0,0 @@ -Subproject commit f8d7d77c06936315286eb55f8de22cd23c188571 diff --git a/third-party/lizardbyte-common b/third-party/lizardbyte-common index 8d7dcc97..06cd442b 160000 --- a/third-party/lizardbyte-common +++ b/third-party/lizardbyte-common @@ -1 +1 @@ -Subproject commit 8d7dcc97d0795e4eb2efdb50a86f83060ed47934 +Subproject commit 06cd442b808f02f1674f3192a84d25b9a503c482 diff --git a/tray.svg b/tray.svg new file mode 100644 index 00000000..393f9161 --- /dev/null +++ b/tray.svg @@ -0,0 +1,4 @@ + + + +