├── .gitattributes ├── scripts ├── requirements.txt └── update_clang_format.py ├── icons ├── icon.ico └── icon.png ├── docs ├── images │ ├── screenshot_linux.png │ ├── screenshot_macos.png │ └── screenshot_windows.png └── Doxyfile ├── .flake8 ├── tests ├── utils.h ├── utils.cpp ├── CMakeLists.txt ├── unit │ └── test_tray.cpp └── conftest.cpp ├── .gitmodules ├── codecov.yml ├── .gitignore ├── .github ├── semantic.yml ├── workflows │ ├── _common-lint.yml │ ├── _codeql.yml │ ├── _update-docs.yml │ └── ci.yml └── dependabot.yml ├── .readthedocs.yaml ├── LICENSE ├── cmake ├── FindAPPINDICATOR.cmake └── FindLibNotify.cmake ├── src ├── tray.h ├── example.c ├── tray_darwin.m ├── tray_linux.c └── tray_windows.c ├── .clang-format ├── README.md └── CMakeLists.txt /.gitattributes: -------------------------------------------------------------------------------- 1 | *.h linguist-language=C 2 | -------------------------------------------------------------------------------- /scripts/requirements.txt: -------------------------------------------------------------------------------- 1 | clang-format==21.* 2 | -------------------------------------------------------------------------------- /icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LizardByte/tray/HEAD/icons/icon.ico -------------------------------------------------------------------------------- /icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LizardByte/tray/HEAD/icons/icon.png -------------------------------------------------------------------------------- /docs/images/screenshot_linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LizardByte/tray/HEAD/docs/images/screenshot_linux.png -------------------------------------------------------------------------------- /docs/images/screenshot_macos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LizardByte/tray/HEAD/docs/images/screenshot_macos.png -------------------------------------------------------------------------------- /docs/images/screenshot_windows.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LizardByte/tray/HEAD/docs/images/screenshot_windows.png -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | filename = 3 | *.py 4 | max-line-length = 120 5 | extend-exclude = 6 | .venv/ 7 | venv/ 8 | -------------------------------------------------------------------------------- /tests/utils.h: -------------------------------------------------------------------------------- 1 | /** 2 | * @file utils.h 3 | * @brief Reusable functions for tests. 4 | */ 5 | #pragma once 6 | 7 | // standard includes 8 | #include 9 | 10 | int setEnv(const std::string &name, const std::string &value); 11 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "third-party/doxyconfig"] 2 | path = third-party/doxyconfig 3 | url = https://github.com/LizardByte/doxyconfig.git 4 | branch = master 5 | [submodule "third-party/googletest"] 6 | path = third-party/googletest 7 | url = https://github.com/google/googletest.git 8 | branch = v1.14.x 9 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | --- 2 | codecov: 3 | branch: master 4 | 5 | coverage: 6 | status: 7 | project: 8 | default: 9 | target: auto 10 | threshold: 10% 11 | 12 | comment: 13 | layout: "diff, flags, files" 14 | behavior: default 15 | require_changes: false # if true: only post the comment if coverage changes 16 | 17 | ignore: 18 | - "tests" 19 | - "third-party" 20 | -------------------------------------------------------------------------------- /tests/utils.cpp: -------------------------------------------------------------------------------- 1 | /** 2 | * @file utils.cpp 3 | * @brief Utility functions 4 | */ 5 | // test includes 6 | #include "utils.h" 7 | 8 | /** 9 | * @brief Set an environment variable. 10 | * @param name Name of the environment variable 11 | * @param value Value of the environment variable 12 | * @return 0 on success, non-zero error code on failure 13 | */ 14 | int setEnv(const std::string &name, const std::string &value) { 15 | #ifdef _WIN32 16 | return _putenv_s(name.c_str(), value.c_str()); 17 | #else 18 | return setenv(name.c_str(), value.c_str(), 1); 19 | #endif 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Prerequisites 2 | *.d 3 | 4 | # Compiled Object files 5 | *.slo 6 | *.lo 7 | *.o 8 | *.obj 9 | 10 | # Precompiled Headers 11 | *.gch 12 | *.pch 13 | 14 | # Compiled Dynamic libraries 15 | *.so 16 | *.dylib 17 | *.dll 18 | 19 | # Fortran module files 20 | *.mod 21 | *.smod 22 | 23 | # Compiled Static libraries 24 | *.lai 25 | *.la 26 | *.a 27 | *.lib 28 | 29 | # Executables 30 | *.exe 31 | *.out 32 | *.app 33 | 34 | # JetBrains IDE 35 | .idea/ 36 | 37 | # VSCode IDE 38 | .vscode/ 39 | 40 | # build directories 41 | build/ 42 | cmake-*/ 43 | 44 | # doxyconfig 45 | docs/doxyconfig* 46 | -------------------------------------------------------------------------------- /.github/semantic.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # This file is centrally managed in https://github.com//.github/ 3 | # Don't make changes to this file in this repo as they will be overwritten with changes made to the same file in 4 | # the above-mentioned repo. 5 | 6 | # This is the configuration file for https://github.com/Ezard/semantic-prs 7 | 8 | enabled: true 9 | titleOnly: true # We only use the PR title as we squash and merge 10 | commitsOnly: false 11 | titleAndCommits: false 12 | anyCommit: false 13 | allowMergeCommits: false 14 | allowRevertCommits: false 15 | targetUrl: https://docs.lizardbyte.dev/latest/developers/contributing.html#creating-a-pull-request 16 | -------------------------------------------------------------------------------- /.github/workflows/_common-lint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # This workflow is centrally managed in https://github.com/LizardByte/.github/ 3 | # Don't make changes to this file in this repo as they will be overwritten with changes made to the same file in 4 | # the above-mentioned repo. 5 | 6 | name: common lint 7 | permissions: 8 | contents: read 9 | 10 | on: 11 | pull_request: 12 | branches: 13 | - master 14 | types: 15 | - opened 16 | - synchronize 17 | - reopened 18 | 19 | concurrency: 20 | group: "${{ github.workflow }}-${{ github.ref }}" 21 | cancel-in-progress: true 22 | 23 | jobs: 24 | lint: 25 | name: Common Lint 26 | uses: LizardByte/.github/.github/workflows/__call-common-lint.yml@master 27 | if: ${{ github.repository != 'LizardByte/.github' }} 28 | -------------------------------------------------------------------------------- /.github/workflows/_codeql.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # This workflow is centrally managed in https://github.com/LizardByte/.github/ 3 | # Don't make changes to this file in this repo as they will be overwritten with changes made to the same file in 4 | # the above-mentioned repo. 5 | 6 | name: CodeQL 7 | permissions: 8 | actions: read 9 | contents: read 10 | security-events: write 11 | 12 | on: 13 | push: 14 | branches: 15 | - master 16 | pull_request: 17 | branches: 18 | - master 19 | schedule: 20 | - cron: '00 12 * * 0' # every Sunday at 12:00 UTC 21 | 22 | concurrency: 23 | group: "${{ github.workflow }}-${{ github.ref }}" 24 | cancel-in-progress: true 25 | 26 | jobs: 27 | call-codeql: 28 | name: CodeQL 29 | uses: LizardByte/.github/.github/workflows/__call-codeql.yml@master 30 | if: ${{ github.repository != 'LizardByte/.github' }} 31 | -------------------------------------------------------------------------------- /scripts/update_clang_format.py: -------------------------------------------------------------------------------- 1 | # standard imports 2 | import os 3 | import subprocess 4 | 5 | # variables 6 | directories = [ 7 | 'src', 8 | 'tests', 9 | ] 10 | file_types = [ 11 | 'c', 12 | 'cpp', 13 | 'h', 14 | 'hpp', 15 | 'm', 16 | 'mm' 17 | ] 18 | 19 | 20 | def clang_format(file: str): 21 | print(f'Formatting {file} ...') 22 | subprocess.run(['clang-format', '-i', file]) 23 | 24 | 25 | def main(): 26 | """ 27 | Main entry point. 28 | """ 29 | # walk the directories 30 | for directory in directories: 31 | for root, dirs, files in os.walk(directory): 32 | for file in files: 33 | file_path = os.path.join(root, file) 34 | if os.path.isfile(file_path) and file.rsplit('.')[-1] in file_types: 35 | clang_format(file=file_path) 36 | 37 | 38 | if __name__ == '__main__': 39 | main() 40 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # .readthedocs.yaml 3 | # Read the Docs configuration file 4 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 5 | 6 | version: 2 7 | 8 | build: 9 | os: ubuntu-24.04 10 | tools: 11 | python: "miniconda-latest" 12 | commands: 13 | - | 14 | if [ -f readthedocs_build.sh ]; then 15 | doxyconfig_dir="." 16 | else 17 | doxyconfig_dir="./third-party/doxyconfig" 18 | fi 19 | chmod +x "${doxyconfig_dir}/readthedocs_build.sh" 20 | export DOXYCONFIG_DIR="${doxyconfig_dir}" 21 | "${doxyconfig_dir}/readthedocs_build.sh" 22 | 23 | # using conda, we can get newer doxygen and graphviz than ubuntu provide 24 | # https://github.com/readthedocs/readthedocs.org/issues/8151#issuecomment-890359661 25 | conda: 26 | environment: third-party/doxyconfig/environment.yml 27 | 28 | submodules: 29 | include: all 30 | recursive: true 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Serge Zaitsev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/_update-docs.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # This workflow is centrally managed in https://github.com/LizardByte/.github/ 3 | # Don't make changes to this file in this repo as they will be overwritten with changes made to the same file in 4 | # the above-mentioned repo. 5 | 6 | # To use, add the `rtd` repository label to identify repositories that should trigger this workflow. 7 | # If the project slug is not the repository name, add a repository variable named `READTHEDOCS_SLUG` with the value of 8 | # the ReadTheDocs project slug. 9 | 10 | # Update readthedocs on release events. 11 | 12 | name: Update docs 13 | permissions: {} 14 | 15 | on: 16 | release: 17 | types: 18 | - created 19 | - edited 20 | - deleted 21 | 22 | concurrency: 23 | group: "${{ github.workflow }}-${{ github.event.release.tag_name }}" 24 | cancel-in-progress: true 25 | 26 | jobs: 27 | update-docs: 28 | name: Update docs 29 | uses: LizardByte/.github/.github/workflows/__call-update-docs.yml@master 30 | if: github.repository_owner == 'LizardByte' 31 | with: 32 | readthedocs_slug: ${{ vars.READTHEDOCS_SLUG }} 33 | secrets: 34 | READTHEDOCS_TOKEN: ${{ secrets.READTHEDOCS_TOKEN }} 35 | -------------------------------------------------------------------------------- /cmake/FindAPPINDICATOR.cmake: -------------------------------------------------------------------------------- 1 | # Remmina - The GTK+ Remote Desktop Client 2 | # 3 | # Copyright (C) 2011 Marc-Andre Moreau 4 | # Copyright (C) 2014-2015 Antenore Gatta, Fabio Castelli, Giovanni Panozzo 5 | # Copyright (C) 2016-2023 Antenore Gatta, Giovanni Panozzo 6 | # 7 | # This program is free software; you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation; either version 2 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with this program; if not, write to the Free Software 19 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, 20 | # Boston, MA 02110-1301, USA. 21 | 22 | include(FindPackageHandleStandardArgs) 23 | 24 | pkg_check_modules(APPINDICATOR ayatana-appindicator3-0.1) 25 | if(APPINDICATOR_FOUND) 26 | SET(APPINDICATOR_AYATANA 1) 27 | else() 28 | pkg_check_modules(APPINDICATOR appindicator3-0.1) 29 | if(APPINDICATOR_FOUND) 30 | SET(APPINDICATOR_LEGACY 1) 31 | endif() 32 | endif() 33 | 34 | mark_as_advanced(APPINDICATOR_INCLUDE_DIR APPINDICATOR_LIBRARY) 35 | -------------------------------------------------------------------------------- /docs/Doxyfile: -------------------------------------------------------------------------------- 1 | # This file describes the settings to be used by the documentation system 2 | # doxygen (www.doxygen.org) for a project. 3 | # 4 | # All text after a double hash (##) is considered a comment and is placed in 5 | # front of the TAG it is preceding. 6 | # 7 | # All text after a single hash (#) is considered a comment and will be ignored. 8 | # The format is: 9 | # TAG = value [value, ...] 10 | # For lists, items can also be appended using: 11 | # TAG += value [value, ...] 12 | # Values that contain spaces should be placed between quotes (\" \"). 13 | # 14 | # Note: 15 | # 16 | # Use doxygen to compare the used configuration file with the template 17 | # configuration file: 18 | # doxygen -x [configFile] 19 | # Use doxygen to compare the used configuration file with the template 20 | # configuration file without replacing the environment variables or CMake type 21 | # replacement variables: 22 | # doxygen -x_noenv [configFile] 23 | 24 | # project metadata 25 | DOCSET_BUNDLE_ID = dev.lizardbyte.tray 26 | DOCSET_PUBLISHER_ID = dev.lizardbyte.tray.documentation 27 | PROJECT_BRIEF = "Cross-platform, super tiny C99 implementation of a system tray icon with a popup menu and notifications." 28 | PROJECT_NAME = tray 29 | 30 | # project specific settings 31 | DOT_GRAPH_MAX_NODES = 50 32 | IMAGE_PATH = ../docs/images 33 | INCLUDE_PATH = 34 | 35 | # files and directories to process 36 | USE_MDFILE_AS_MAINPAGE = ../README.md 37 | INPUT = ../README.md \ 38 | ../third-party/doxyconfig/docs/source_code.md \ 39 | ../src 40 | -------------------------------------------------------------------------------- /tests/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.13) 2 | # https://github.com/google/oss-policies-info/blob/main/foundational-cxx-support-matrix.md#foundational-c-support 3 | 4 | project(test_tray) 5 | 6 | include_directories("${CMAKE_SOURCE_DIR}") 7 | 8 | # Add GoogleTest directory to the project 9 | set(GTEST_SOURCE_DIR "${CMAKE_SOURCE_DIR}/third-party/googletest") 10 | set(INSTALL_GTEST OFF) 11 | set(INSTALL_GMOCK OFF) 12 | add_subdirectory("${GTEST_SOURCE_DIR}" "${CMAKE_CURRENT_BINARY_DIR}/googletest") 13 | include_directories("${GTEST_SOURCE_DIR}/googletest/include" "${GTEST_SOURCE_DIR}") 14 | 15 | # if windows 16 | if (WIN32) 17 | # For Windows: Prevent overriding the parent project's compiler/linker settings 18 | set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) # cmake-lint: disable=C0103 19 | endif () 20 | 21 | file(GLOB_RECURSE TEST_SOURCES 22 | ${CMAKE_SOURCE_DIR}/tests/conftest.cpp 23 | ${CMAKE_SOURCE_DIR}/tests/utils.cpp 24 | ${CMAKE_SOURCE_DIR}/tests/test_*.cpp) 25 | 26 | add_executable(${PROJECT_NAME} 27 | ${TEST_SOURCES} 28 | ${TRAY_SOURCES}) 29 | set_target_properties(${PROJECT_NAME} PROPERTIES CXX_STANDARD 17) 30 | target_link_directories(${PROJECT_NAME} PRIVATE ${TRAY_EXTERNAL_DIRECTORIES}) 31 | target_link_libraries(${PROJECT_NAME} 32 | ${TRAY_EXTERNAL_LIBRARIES} 33 | gtest 34 | gtest_main) # if we use this we don't need our own main function 35 | target_compile_definitions(${PROJECT_NAME} PUBLIC ${TRAY_DEFINITIONS} ${TEST_DEFINITIONS}) 36 | target_compile_options(${PROJECT_NAME} PRIVATE $<$:${TRAY_COMPILE_OPTIONS}>) 37 | target_link_options(${PROJECT_NAME} PRIVATE) 38 | 39 | add_test(NAME ${PROJECT_NAME} COMMAND tray_test) 40 | -------------------------------------------------------------------------------- /src/tray.h: -------------------------------------------------------------------------------- 1 | /** 2 | * @file src/tray.h 3 | * @brief Definition of the tray API. 4 | */ 5 | #ifndef TRAY_H 6 | #define TRAY_H 7 | 8 | #ifdef __cplusplus 9 | extern "C" { 10 | #endif 11 | 12 | /** 13 | * @brief Tray menu item. 14 | */ 15 | struct tray_menu; 16 | 17 | /** 18 | * @brief Tray icon. 19 | */ 20 | struct tray { 21 | const char *icon; ///< Icon to display. 22 | const char *tooltip; ///< Tooltip to display. 23 | const char *notification_icon; ///< Icon to display in the notification. 24 | const char *notification_text; ///< Text to display in the notification. 25 | const char *notification_title; ///< Title to display in the notification. 26 | void (*notification_cb)(); ///< Callback to invoke when the notification is clicked. 27 | struct tray_menu *menu; ///< Menu items. 28 | const int iconPathCount; ///< Number of icon paths. 29 | const char *allIconPaths[]; ///< Array of icon paths. 30 | }; 31 | 32 | /** 33 | * @brief Tray menu item. 34 | */ 35 | struct tray_menu { 36 | const char *text; ///< Text to display. 37 | int disabled; ///< Whether the item is disabled. 38 | int checked; ///< Whether the item is checked. 39 | int checkbox; ///< Whether the item is a checkbox. 40 | 41 | void (*cb)(struct tray_menu *); ///< Callback to invoke when the item is clicked. 42 | void *context; ///< Context to pass to the callback. 43 | 44 | struct tray_menu *submenu; ///< Submenu items. 45 | }; 46 | 47 | /** 48 | * @brief Create tray icon. 49 | * @param tray The tray to initialize. 50 | * @return 0 on success, -1 on error. 51 | */ 52 | int tray_init(struct tray *tray); 53 | 54 | /** 55 | * @brief Run one iteration of the UI loop. 56 | * @param blocking Whether to block the call or not. 57 | * @return 0 on success, -1 if tray_exit() was called. 58 | */ 59 | int tray_loop(int blocking); 60 | 61 | /** 62 | * @brief Update the tray icon and menu. 63 | * @param tray The tray to update. 64 | */ 65 | void tray_update(struct tray *tray); 66 | 67 | /** 68 | * @brief Terminate UI loop. 69 | */ 70 | void tray_exit(void); 71 | 72 | #ifdef __cplusplus 73 | } // extern "C" 74 | #endif 75 | 76 | #endif /* TRAY_H */ 77 | -------------------------------------------------------------------------------- /cmake/FindLibNotify.cmake: -------------------------------------------------------------------------------- 1 | # - Try to find LibNotify 2 | # This module defines the following variables: 3 | # 4 | # LIBNOTIFY_FOUND - LibNotify was found 5 | # LIBNOTIFY_INCLUDE_DIRS - the LibNotify include directories 6 | # LIBNOTIFY_LIBRARIES - link these to use LibNotify 7 | # 8 | # Copyright (C) 2012 Raphael Kubo da Costa 9 | # Copyright (C) 2014 Collabora Ltd. 10 | # 11 | # Redistribution and use in source and binary forms, with or without 12 | # modification, are permitted provided that the following conditions 13 | # are met: 14 | # 1. Redistributions of source code must retain the above copyright 15 | # notice, this list of conditions and the following disclaimer. 16 | # 2. Redistributions in binary form must reproduce the above copyright 17 | # notice, this list of conditions and the following disclaimer in the 18 | # documentation and/or other materials provided with the distribution. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER AND ITS CONTRIBUTORS ``AS 21 | # IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, 22 | # THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 23 | # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR ITS 24 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 25 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 26 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 27 | # OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 28 | # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 29 | # OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 30 | # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | 32 | find_package(PkgConfig) 33 | pkg_check_modules(LIBNOTIFY QUIET libnotify) 34 | 35 | find_path(LIBNOTIFY_INCLUDE_DIRS 36 | NAMES notify.h 37 | HINTS ${LIBNOTIFY_INCLUDEDIR} 38 | ${LIBNOTIFY_INCLUDE_DIRS} 39 | PATH_SUFFIXES libnotify 40 | ) 41 | 42 | find_library(LIBNOTIFY_LIBRARIES 43 | NAMES notify 44 | HINTS ${LIBNOTIFY_LIBDIR} 45 | ${LIBNOTIFY_LIBRARY_DIRS} 46 | ) 47 | 48 | include(FindPackageHandleStandardArgs) 49 | FIND_PACKAGE_HANDLE_STANDARD_ARGS(LibNotify REQUIRED_VARS LIBNOTIFY_INCLUDE_DIRS LIBNOTIFY_LIBRARIES 50 | VERSION_VAR LIBNOTIFY_VERSION) 51 | 52 | mark_as_advanced( 53 | LIBNOTIFY_INCLUDE_DIRS 54 | LIBNOTIFY_LIBRARIES 55 | ) 56 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # This file is centrally managed in https://github.com//.github/ 3 | # Don't make changes to this file in this repo as they will be overwritten with changes made to the same file in 4 | # the above-mentioned repo. 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "cargo" 9 | directory: "/" 10 | rebase-strategy: disabled 11 | schedule: 12 | interval: "cron" 13 | cronjob: "0 1 * * *" 14 | timezone: "America/New_York" 15 | open-pull-requests-limit: 10 16 | 17 | - package-ecosystem: "docker" 18 | directory: "/" 19 | rebase-strategy: disabled 20 | schedule: 21 | interval: "cron" 22 | cronjob: "30 1 * * *" 23 | timezone: "America/New_York" 24 | open-pull-requests-limit: 10 25 | 26 | - package-ecosystem: "github-actions" 27 | directories: 28 | - "/" 29 | - "/.github/actions/*" 30 | - "/actions/*" 31 | rebase-strategy: disabled 32 | schedule: 33 | interval: "cron" 34 | cronjob: "0 2 * * *" 35 | timezone: "America/New_York" 36 | open-pull-requests-limit: 10 37 | groups: 38 | docker-actions: 39 | applies-to: version-updates 40 | patterns: 41 | - "docker/*" 42 | github-actions: 43 | applies-to: version-updates 44 | patterns: 45 | - "actions/*" 46 | - "github/*" 47 | lizardbyte-actions: 48 | applies-to: version-updates 49 | patterns: 50 | - "LizardByte/*" 51 | 52 | - package-ecosystem: "gitsubmodule" 53 | directory: "/" 54 | rebase-strategy: disabled 55 | schedule: 56 | interval: "cron" 57 | cronjob: "30 2 * * *" 58 | timezone: "America/New_York" 59 | open-pull-requests-limit: 10 60 | 61 | - package-ecosystem: "npm" 62 | directory: "/" 63 | rebase-strategy: disabled 64 | schedule: 65 | interval: "cron" 66 | cronjob: "0 3 * * *" 67 | timezone: "America/New_York" 68 | open-pull-requests-limit: 10 69 | groups: 70 | dev-dependencies: 71 | applies-to: version-updates 72 | dependency-type: "development" 73 | 74 | - package-ecosystem: "nuget" 75 | directory: "/" 76 | rebase-strategy: disabled 77 | schedule: 78 | interval: "cron" 79 | cronjob: "30 3 * * *" 80 | timezone: "America/New_York" 81 | open-pull-requests-limit: 10 82 | 83 | - package-ecosystem: "pip" 84 | directory: "/" 85 | rebase-strategy: disabled 86 | schedule: 87 | interval: "cron" 88 | cronjob: "0 4 * * *" 89 | timezone: "America/New_York" 90 | open-pull-requests-limit: 10 91 | groups: 92 | pytest-dependencies: 93 | applies-to: version-updates 94 | patterns: 95 | - "pytest*" 96 | 97 | - package-ecosystem: "rust-toolchain" 98 | directory: "/" 99 | rebase-strategy: disabled 100 | schedule: 101 | interval: "cron" 102 | cronjob: "30 4 * * *" 103 | timezone: "America/New_York" 104 | open-pull-requests-limit: 1 105 | -------------------------------------------------------------------------------- /src/example.c: -------------------------------------------------------------------------------- 1 | /** 2 | * @file src/example.c 3 | * @brief Example usage of the tray library. 4 | */ 5 | // standard includes 6 | #include 7 | #include 8 | 9 | #if defined(_WIN32) || defined(_WIN64) 10 | #define TRAY_WINAPI 1 ///< Use WinAPI. 11 | #elif defined(__linux__) || defined(linux) || defined(__linux) 12 | #define TRAY_APPINDICATOR 1 13 | #elif defined(__APPLE__) || defined(__MACH__) 14 | #define TRAY_APPKIT 1 15 | #endif 16 | 17 | // local includes 18 | #include "tray.h" 19 | 20 | #if TRAY_APPINDICATOR 21 | #define TRAY_ICON1 "mail-message-new" 22 | #define TRAY_ICON2 "mail-message-new" 23 | #elif TRAY_APPKIT 24 | #define TRAY_ICON1 "icon.png" 25 | #define TRAY_ICON2 "icon.png" 26 | #elif TRAY_WINAPI 27 | #define TRAY_ICON1 "icon.ico" ///< Path to first icon. 28 | #define TRAY_ICON2 "icon.ico" ///< Path to second icon. 29 | #endif 30 | 31 | static struct tray tray; 32 | 33 | static void toggle_cb(struct tray_menu *item) { 34 | printf("toggle cb\n"); 35 | item->checked = !item->checked; 36 | tray_update(&tray); 37 | } 38 | 39 | static void hello_cb(struct tray_menu *item) { 40 | (void) item; 41 | printf("hello cb\n"); 42 | if (strcmp(tray.icon, TRAY_ICON1) == 0) { 43 | tray.icon = TRAY_ICON2; 44 | } else { 45 | tray.icon = TRAY_ICON1; 46 | } 47 | tray_update(&tray); 48 | } 49 | 50 | static void quit_cb(struct tray_menu *item) { 51 | (void) item; 52 | printf("quit cb\n"); 53 | tray_exit(); 54 | } 55 | 56 | static void submenu_cb(struct tray_menu *item) { 57 | (void) item; 58 | printf("submenu: clicked on %s\n", item->text); 59 | tray_update(&tray); 60 | } 61 | 62 | // Test tray init 63 | static struct tray tray = { 64 | .icon = TRAY_ICON1, 65 | #if TRAY_WINAPI 66 | .tooltip = "Tray", 67 | #endif 68 | .menu = 69 | (struct tray_menu[]) { 70 | {.text = "Hello", .cb = hello_cb}, 71 | {.text = "Checked", .checked = 1, .checkbox = 1, .cb = toggle_cb}, 72 | {.text = "Disabled", .disabled = 1}, 73 | {.text = "-"}, 74 | {.text = "SubMenu", 75 | .submenu = 76 | (struct tray_menu[]) { 77 | {.text = "FIRST", .checked = 1, .checkbox = 1, .cb = submenu_cb}, 78 | {.text = "SECOND", 79 | .submenu = 80 | (struct tray_menu[]) { 81 | {.text = "THIRD", 82 | .submenu = 83 | (struct tray_menu[]) { 84 | {.text = "7", .cb = submenu_cb}, 85 | {.text = "-"}, 86 | {.text = "8", .cb = submenu_cb}, 87 | {.text = NULL} 88 | }}, 89 | {.text = "FOUR", 90 | .submenu = 91 | (struct tray_menu[]) { 92 | {.text = "5", .cb = submenu_cb}, 93 | {.text = "6", .cb = submenu_cb}, 94 | {.text = NULL} 95 | }}, 96 | {.text = NULL} 97 | }}, 98 | {.text = NULL} 99 | }}, 100 | {.text = "-"}, 101 | {.text = "Quit", .cb = quit_cb}, 102 | {.text = NULL} 103 | }, 104 | }; 105 | 106 | /** 107 | * @brief Main entry point. 108 | * @return 0 on success, 1 on error. 109 | */ 110 | int main() { 111 | if (tray_init(&tray) < 0) { 112 | printf("failed to create tray\n"); 113 | return 1; 114 | } 115 | while (tray_loop(1) == 0) { 116 | printf("iteration\n"); 117 | } 118 | return 0; 119 | } 120 | -------------------------------------------------------------------------------- /src/tray_darwin.m: -------------------------------------------------------------------------------- 1 | /** 2 | * @file src/tray_darwin.m 3 | * @brief System tray implementation for macOS. 4 | */ 5 | // standard includes 6 | #include 7 | 8 | // lib includes 9 | #include 10 | 11 | // local includes 12 | #include "tray.h" 13 | 14 | /** 15 | * @class AppDelegate 16 | * @brief The application delegate that handles menu actions. 17 | */ 18 | @interface AppDelegate: NSObject 19 | /** 20 | * @brief Callback function for menu item actions. 21 | * @param sender The object that sent the action message. 22 | * @return void 23 | */ 24 | - (IBAction)menuCallback:(id)sender; 25 | @end 26 | 27 | @implementation AppDelegate { 28 | } 29 | 30 | - (IBAction)menuCallback:(id)sender { 31 | struct tray_menu *m = [[sender representedObject] pointerValue]; 32 | if (m != NULL && m->cb != NULL) { 33 | m->cb(m); 34 | } 35 | } 36 | 37 | @end 38 | 39 | static NSApplication *app; 40 | static NSStatusBar *statusBar; 41 | static NSStatusItem *statusItem; 42 | 43 | #define QUIT_EVENT_SUBTYPE 0x0DED ///< NSEvent subtype used to signal exit. 44 | 45 | static NSMenu *_tray_menu(struct tray_menu *m) { 46 | NSMenu *menu = [[NSMenu alloc] init]; 47 | [menu setAutoenablesItems:FALSE]; 48 | 49 | for (; m != NULL && m->text != NULL; m++) { 50 | if (strcmp(m->text, "-") == 0) { 51 | [menu addItem:[NSMenuItem separatorItem]]; 52 | } else { 53 | NSMenuItem *menuItem = [[NSMenuItem alloc] 54 | initWithTitle:[NSString stringWithUTF8String:m->text] 55 | action:@selector(menuCallback:) 56 | keyEquivalent:@""]; 57 | [menuItem setEnabled:(m->disabled ? FALSE : TRUE)]; 58 | [menuItem setState:(m->checked ? 1 : 0)]; 59 | [menuItem setRepresentedObject:[NSValue valueWithPointer:m]]; 60 | [menu addItem:menuItem]; 61 | if (m->submenu != NULL) { 62 | [menu setSubmenu:_tray_menu(m->submenu) forItem:menuItem]; 63 | } 64 | } 65 | } 66 | return menu; 67 | } 68 | 69 | int tray_init(struct tray *tray) { 70 | AppDelegate *delegate = [[AppDelegate alloc] init]; 71 | app = [NSApplication sharedApplication]; 72 | [app setDelegate:delegate]; 73 | statusBar = [NSStatusBar systemStatusBar]; 74 | statusItem = [statusBar statusItemWithLength:NSVariableStatusItemLength]; 75 | tray_update(tray); 76 | [app activateIgnoringOtherApps:TRUE]; 77 | return 0; 78 | } 79 | 80 | int tray_loop(int blocking) { 81 | NSDate *until = (blocking ? [NSDate distantFuture] : [NSDate distantPast]); 82 | NSEvent *event = [app nextEventMatchingMask:ULONG_MAX 83 | untilDate:until 84 | inMode:[NSString stringWithUTF8String:"kCFRunLoopDefaultMode"] 85 | dequeue:TRUE]; 86 | if (event) { 87 | if (event.type == NSEventTypeApplicationDefined && event.subtype == QUIT_EVENT_SUBTYPE) { 88 | return -1; 89 | } 90 | 91 | [app sendEvent:event]; 92 | } 93 | return 0; 94 | } 95 | 96 | void tray_update(struct tray *tray) { 97 | NSImage *image = [[NSImage alloc] initWithContentsOfFile:[NSString stringWithUTF8String:tray->icon]]; 98 | NSSize size = NSMakeSize(16, 16); 99 | [image setSize:NSMakeSize(16, 16)]; 100 | statusItem.button.image = image; 101 | [statusItem setMenu:_tray_menu(tray->menu)]; 102 | } 103 | 104 | void tray_exit(void) { 105 | NSEvent *event = [NSEvent otherEventWithType:NSEventTypeApplicationDefined 106 | location:NSMakePoint(0, 0) 107 | modifierFlags:0 108 | timestamp:0 109 | windowNumber:0 110 | context:nil 111 | subtype:QUIT_EVENT_SUBTYPE 112 | data1:0 113 | data2:0]; 114 | [app postEvent:event atStart:FALSE]; 115 | } 116 | -------------------------------------------------------------------------------- /.clang-format: -------------------------------------------------------------------------------- 1 | --- 2 | # This file is centrally managed in https://github.com//.github/ 3 | # Don't make changes to this file in this repo as they will be overwritten with changes made to the same file in 4 | # the above-mentioned repo. 5 | 6 | # Generated from CLion C/C++ Code Style settings 7 | BasedOnStyle: LLVM 8 | AccessModifierOffset: -2 9 | AlignAfterOpenBracket: BlockIndent 10 | AlignConsecutiveAssignments: None 11 | AlignEscapedNewlines: DontAlign 12 | AlignOperands: Align 13 | AllowAllArgumentsOnNextLine: false 14 | AllowAllConstructorInitializersOnNextLine: false 15 | AllowAllParametersOfDeclarationOnNextLine: false 16 | AllowShortBlocksOnASingleLine: Empty 17 | AllowShortCaseLabelsOnASingleLine: false 18 | AllowShortEnumsOnASingleLine: false 19 | AllowShortFunctionsOnASingleLine: Empty 20 | AllowShortIfStatementsOnASingleLine: Never 21 | AllowShortLambdasOnASingleLine: None 22 | AllowShortLoopsOnASingleLine: true 23 | AlignTrailingComments: false 24 | AlwaysBreakAfterDefinitionReturnType: None 25 | AlwaysBreakAfterReturnType: None 26 | AlwaysBreakBeforeMultilineStrings: true 27 | AlwaysBreakTemplateDeclarations: MultiLine 28 | BinPackArguments: false 29 | BinPackParameters: false 30 | BracedInitializerIndentWidth: 2 31 | BraceWrapping: 32 | AfterCaseLabel: false 33 | AfterClass: false 34 | AfterControlStatement: Never 35 | AfterEnum: false 36 | AfterExternBlock: true 37 | AfterFunction: false 38 | AfterNamespace: false 39 | AfterObjCDeclaration: false 40 | AfterUnion: false 41 | BeforeCatch: true 42 | BeforeElse: true 43 | IndentBraces: false 44 | SplitEmptyFunction: false 45 | SplitEmptyRecord: true 46 | BreakArrays: true 47 | BreakBeforeBinaryOperators: None 48 | BreakBeforeBraces: Attach 49 | BreakBeforeTernaryOperators: false 50 | BreakConstructorInitializers: AfterColon 51 | BreakInheritanceList: AfterColon 52 | ColumnLimit: 0 53 | CompactNamespaces: false 54 | ContinuationIndentWidth: 2 55 | Cpp11BracedListStyle: true 56 | EmptyLineAfterAccessModifier: Never 57 | EmptyLineBeforeAccessModifier: Always 58 | ExperimentalAutoDetectBinPacking: true 59 | FixNamespaceComments: true 60 | IncludeBlocks: Regroup 61 | IndentAccessModifiers: false 62 | IndentCaseBlocks: true 63 | IndentCaseLabels: true 64 | IndentExternBlock: Indent 65 | IndentGotoLabels: true 66 | IndentPPDirectives: BeforeHash 67 | IndentWidth: 2 68 | IndentWrappedFunctionNames: true 69 | InsertBraces: true 70 | InsertNewlineAtEOF: true 71 | KeepEmptyLinesAtTheStartOfBlocks: false 72 | MaxEmptyLinesToKeep: 1 73 | NamespaceIndentation: All 74 | ObjCBinPackProtocolList: Never 75 | ObjCSpaceAfterProperty: true 76 | ObjCSpaceBeforeProtocolList: true 77 | PackConstructorInitializers: Never 78 | PenaltyBreakBeforeFirstCallParameter: 1 79 | PenaltyBreakComment: 1 80 | PenaltyBreakString: 1 81 | PenaltyBreakFirstLessLess: 0 82 | PenaltyExcessCharacter: 1000000 83 | PenaltyReturnTypeOnItsOwnLine: 100000000 84 | PointerAlignment: Right 85 | ReferenceAlignment: Pointer 86 | ReflowComments: true 87 | RemoveBracesLLVM: false 88 | RemoveSemicolon: false 89 | SeparateDefinitionBlocks: Always 90 | SortIncludes: CaseInsensitive 91 | SortUsingDeclarations: Lexicographic 92 | SpaceAfterCStyleCast: true 93 | SpaceAfterLogicalNot: false 94 | SpaceAfterTemplateKeyword: false 95 | SpaceBeforeAssignmentOperators: true 96 | SpaceBeforeCaseColon: false 97 | SpaceBeforeCpp11BracedList: true 98 | SpaceBeforeCtorInitializerColon: false 99 | SpaceBeforeInheritanceColon: false 100 | SpaceBeforeJsonColon: false 101 | SpaceBeforeParens: ControlStatements 102 | SpaceBeforeRangeBasedForLoopColon: true 103 | SpaceBeforeSquareBrackets: false 104 | SpaceInEmptyBlock: false 105 | SpaceInEmptyParentheses: false 106 | SpacesBeforeTrailingComments: 2 107 | SpacesInAngles: Never 108 | SpacesInCStyleCastParentheses: false 109 | SpacesInContainerLiterals: false 110 | SpacesInLineCommentPrefix: 111 | Maximum: 3 112 | Minimum: 1 113 | SpacesInParentheses: false 114 | SpacesInSquareBrackets: false 115 | TabWidth: 2 116 | UseTab: Never 117 | -------------------------------------------------------------------------------- /tests/unit/test_tray.cpp: -------------------------------------------------------------------------------- 1 | // test includes 2 | #include "tests/conftest.cpp" 3 | 4 | #if defined(_WIN32) || defined(_WIN64) 5 | #define TRAY_WINAPI 1 6 | #elif defined(__linux__) || defined(linux) || defined(__linux) 7 | #define TRAY_APPINDICATOR 1 8 | #elif defined(__APPLE__) || defined(__MACH__) 9 | #define TRAY_APPKIT 1 10 | #endif 11 | 12 | // local includes 13 | #include "src/tray.h" 14 | 15 | #if TRAY_APPINDICATOR 16 | #define TRAY_ICON1 "mail-message-new" 17 | #define TRAY_ICON2 "mail-message-new" 18 | #elif TRAY_APPKIT 19 | #define TRAY_ICON1 "icon.png" 20 | #define TRAY_ICON2 "icon.png" 21 | #elif TRAY_WINAPI 22 | #define TRAY_ICON1 "icon.ico" 23 | #define TRAY_ICON2 "icon.ico" 24 | #endif 25 | 26 | class TrayTest: public BaseTest { 27 | protected: 28 | static struct tray testTray; 29 | 30 | // Static arrays for submenus 31 | static struct tray_menu submenu7_8[]; 32 | static struct tray_menu submenu5_6[]; 33 | static struct tray_menu submenu_second[]; 34 | static struct tray_menu submenu[]; 35 | 36 | // Non-static member functions 37 | static void hello_cb(struct tray_menu *item) { 38 | // Mock implementation 39 | } 40 | 41 | static void toggle_cb(struct tray_menu *item) { 42 | item->checked = !item->checked; 43 | tray_update(&testTray); 44 | } 45 | 46 | static void quit_cb(struct tray_menu *item) { 47 | tray_exit(); 48 | } 49 | 50 | static void submenu_cb(struct tray_menu *item) { 51 | // Mock implementation 52 | tray_update(&testTray); 53 | } 54 | 55 | void SetUp() override { 56 | testTray.icon = TRAY_ICON1; 57 | testTray.tooltip = "TestTray"; 58 | testTray.menu = submenu; 59 | } 60 | 61 | void TearDown() override { 62 | // Clean up any resources if needed 63 | } 64 | }; 65 | 66 | // Define the static arrays 67 | struct tray_menu TrayTest::submenu7_8[] = { 68 | {.text = "7", .cb = submenu_cb}, 69 | {.text = "-"}, 70 | {.text = "8", .cb = submenu_cb}, 71 | {.text = nullptr} 72 | }; 73 | struct tray_menu TrayTest::submenu5_6[] = { 74 | {.text = "5", .cb = submenu_cb}, 75 | {.text = "6", .cb = submenu_cb}, 76 | {.text = nullptr} 77 | }; 78 | struct tray_menu TrayTest::submenu_second[] = { 79 | {.text = "THIRD", .submenu = submenu7_8}, 80 | {.text = "FOUR", .submenu = submenu5_6}, 81 | {.text = nullptr} 82 | }; 83 | struct tray_menu TrayTest::submenu[] = { 84 | {.text = "Hello", .cb = hello_cb}, 85 | {.text = "Checked", .checked = 1, .checkbox = 1, .cb = toggle_cb}, 86 | {.text = "Disabled", .disabled = 1}, 87 | {.text = "-"}, 88 | {.text = "SubMenu", .submenu = submenu_second}, 89 | {.text = "-"}, 90 | {.text = "Quit", .cb = quit_cb}, 91 | {.text = nullptr} 92 | }; 93 | struct tray TrayTest::testTray = { 94 | .icon = TRAY_ICON1, 95 | .tooltip = "TestTray", 96 | .menu = submenu 97 | }; 98 | 99 | TEST_F(TrayTest, TestTrayInit) { 100 | int result = tray_init(&testTray); 101 | EXPECT_EQ(result, 0); // make sure return value is 0 102 | } 103 | 104 | TEST_F(TrayTest, TestTrayLoop) { 105 | int result = tray_loop(1); 106 | EXPECT_EQ(result, 0); // make sure return value is 0 107 | } 108 | 109 | TEST_F(TrayTest, TestTrayUpdate) { 110 | // check the initial values 111 | EXPECT_EQ(testTray.icon, TRAY_ICON1); 112 | EXPECT_EQ(testTray.tooltip, "TestTray"); 113 | 114 | // update the values 115 | testTray.icon = TRAY_ICON2; 116 | testTray.tooltip = "TestTray2"; 117 | tray_update(&testTray); 118 | EXPECT_EQ(testTray.icon, TRAY_ICON2); 119 | EXPECT_EQ(testTray.tooltip, "TestTray2"); 120 | 121 | // put back the original values 122 | testTray.icon = TRAY_ICON1; 123 | testTray.tooltip = "TestTray"; 124 | tray_update(&testTray); 125 | EXPECT_EQ(testTray.icon, TRAY_ICON1); 126 | EXPECT_EQ(testTray.tooltip, "TestTray"); 127 | } 128 | 129 | TEST_F(TrayTest, TestToggleCallback) { 130 | bool initialCheckedState = testTray.menu[1].checked; 131 | toggle_cb(&testTray.menu[1]); 132 | EXPECT_EQ(testTray.menu[1].checked, !initialCheckedState); 133 | } 134 | 135 | TEST_F(TrayTest, TestTrayExit) { 136 | tray_exit(); 137 | // TODO: Check the state after tray_exit 138 | } 139 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | [![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) 4 | [![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) 5 | [![GitHub stars](https://img.shields.io/github/stars/lizardbyte/tray.svg?logo=github&style=for-the-badge)](https://github.com/LizardByte/tray) 6 | 7 | ## About 8 | 9 | Cross-platform, super tiny C99 implementation of a system tray icon with a popup menu and notifications. 10 | 11 | The code is C++ friendly and will compile fine in C++98 and up. This is a fork of 12 | [dmikushin/tray](https://github.com/dmikushin/tray) and is intended to add additional features required for our own 13 | [Sunshine](https://github.com/LizardByte/Sunshine) project. 14 | 15 | This fork adds the following features: 16 | 17 | - system tray notifications 18 | - support for both linux appindicator versions 19 | - unit tests 20 | - code coverage 21 | - refactored code, e.g. moved source code into the `src` directory 22 | - doxygen documentation, and readthedocs configuration 23 | 24 | ## Screenshots 25 | 26 |
27 | 28 | - Linux![linux](docs/images/screenshot_linux.png) 29 | - macOS![macOS](docs/images/screenshot_macos.png) 30 | - Windows![windows](docs/images/screenshot_windows.png) 31 | 32 |
33 | 34 | ## Supported platforms 35 | 36 | * Linux/Gtk (libayatana-appindicator3 or libappindicator3) 37 | * Windows XP or newer (shellapi.h) 38 | * MacOS (Cocoa/AppKit) 39 | 40 | ## Prerequisites 41 | 42 | * CMake 43 | * [Ninja](https://ninja-build.org/), in order to have the same build commands on all platforms 44 | 45 | ### Linux Dependencies 46 | 47 |
48 | 49 | - Arch 50 | ```bash 51 | sudo pacman -S libayatana-appindicator 52 | ``` 53 | 54 | - Debian/Ubuntu 55 | ```bash 56 | sudo apt install libappindicator3-dev 57 | ``` 58 | 59 | - Fedora 60 | ```bash 61 | sudo dnf install libappindicator-gtk3-devel 62 | ``` 63 | 64 |
65 | 66 | ## Building 67 | 68 | ```bash 69 | mkdir -p build 70 | cmake -G Ninja -B build -S . 71 | ninja -C build 72 | ``` 73 | 74 | ## Demo 75 | 76 | Execute the `tray_example` application: 77 | 78 | ```bash 79 | ./build/tray_example 80 | ``` 81 | 82 | ## Tests 83 | 84 | Execute the `tests` application: 85 | 86 | ```bash 87 | ./build/tests/test_tray 88 | ``` 89 | 90 | ## API 91 | 92 | Tray structure defines an icon and a menu. 93 | Menu is a NULL-terminated array of items. 94 | Menu item defines menu text, menu checked and disabled (grayed) flags and a 95 | callback with some optional context pointer. 96 | 97 | ```c 98 | struct tray { 99 | char *icon; 100 | struct tray_menu *menu; 101 | }; 102 | 103 | struct tray_menu { 104 | char *text; 105 | int disabled; 106 | int checked; 107 | 108 | void (*cb)(struct tray_menu *); 109 | void *context; 110 | 111 | struct tray_menu *submenu; 112 | }; 113 | ``` 114 | 115 | * `int tray_init(struct tray *)` - creates tray icon. Returns -1 if tray icon/menu can't be created. 116 | * `void tray_update(struct tray *)` - updates tray icon and menu. 117 | * `int tray_loop(int blocking)` - runs one iteration of the UI loop. Returns -1 if `tray_exit()` has been called. 118 | * `void tray_exit()` - terminates UI loop. 119 | 120 | All functions are meant to be called from the UI thread only. 121 | 122 | Menu arrays must be terminated with a NULL item, e.g. the last item in the 123 | array must have text field set to NULL. 124 | 125 | ## License 126 | 127 | This software is distributed under [MIT license](http://www.opensource.org/licenses/mit-license.php), 128 | so feel free to integrate it in your commercial products. 129 | 130 |
131 | 132 | [TOC] 133 |
134 | -------------------------------------------------------------------------------- /CMakeLists.txt: -------------------------------------------------------------------------------- 1 | # 2 | # Project configuration 3 | # 4 | cmake_minimum_required(VERSION 3.13 FATAL_ERROR) # target_link_directories 5 | project(tray VERSION 0.0.0 6 | DESCRIPTION "A cross-platform system tray library" 7 | HOMEPAGE_URL "https://app.lizardbyte.dev" 8 | LANGUAGES C) 9 | 10 | set(PROJECT_LICENSE "MIT") 11 | 12 | if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) 13 | message(STATUS "Setting build type to 'Release' as none was specified.") 14 | set(CMAKE_BUILD_TYPE "Release" CACHE STRING "Choose the type of build." FORCE) 15 | endif() 16 | 17 | # Add our custom CMake modules to the global path 18 | set(CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake") 19 | 20 | # 21 | # Project optional configuration 22 | # 23 | if(CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME) 24 | option(BUILD_DOCS "Build documentation" ON) 25 | option(BUILD_TESTS "Build tests" ON) 26 | endif() 27 | 28 | # Generate 'compile_commands.json' for clang_complete 29 | set(CMAKE_COLOR_MAKEFILE ON) 30 | set(CMAKE_EXPORT_COMPILE_COMMANDS ON) 31 | 32 | find_package (PkgConfig REQUIRED) 33 | 34 | file(GLOB TRAY_SOURCES 35 | "${CMAKE_SOURCE_DIR}/src/*.h" 36 | "${CMAKE_SOURCE_DIR}/icons/*.ico" 37 | "${CMAKE_SOURCE_DIR}/icons/*.png") 38 | 39 | if(WIN32) 40 | list(APPEND TRAY_SOURCES "${CMAKE_SOURCE_DIR}/src/tray_windows.c") 41 | else() 42 | if(UNIX) 43 | if(APPLE) 44 | find_library(COCOA Cocoa REQUIRED) 45 | list(APPEND TRAY_SOURCES "${CMAKE_SOURCE_DIR}/src/tray_darwin.m") 46 | else() 47 | find_package(APPINDICATOR REQUIRED) 48 | find_package(LibNotify REQUIRED) 49 | list(APPEND TRAY_SOURCES "${CMAKE_SOURCE_DIR}/src/tray_linux.c") 50 | endif() 51 | endif() 52 | endif() 53 | 54 | add_library(${PROJECT_NAME} STATIC ${TRAY_SOURCES}) 55 | set_property(TARGET ${PROJECT_NAME} PROPERTY C_STANDARD 99) 56 | 57 | if(WIN32) 58 | list(APPEND TRAY_DEFINITIONS TRAY_WINAPI=1 WIN32_LEAN_AND_MEAN NOMINMAX) 59 | if(MSVC) 60 | list(APPEND TRAY_COMPILE_OPTIONS "/MT$<$:d>") 61 | endif() 62 | else() 63 | if(UNIX) 64 | if(APPLE) 65 | list(APPEND TRAY_DEFINITIONS TRAY_APPKIT=1) 66 | list(APPEND TRAY_EXTERNAL_LIBRARIES ${COCOA}) 67 | else() 68 | list(APPEND TRAY_COMPILE_OPTIONS ${APPINDICATOR_CFLAGS}) 69 | list(APPEND TRAY_EXTERNAL_DIRECTORIES ${APPINDICATOR_LIBRARY_DIRS}) 70 | list(APPEND TRAY_DEFINITIONS TRAY_APPINDICATOR=1) 71 | if(APPINDICATOR_AYATANA) 72 | list(APPEND TRAY_DEFINITIONS TRAY_AYATANA_APPINDICATOR=1) 73 | endif() 74 | if(APPINDICATOR_LEGACY) 75 | list(APPEND TRAY_DEFINITIONS TRAY_LEGACY_APPINDICATOR=1) 76 | endif() 77 | list(APPEND TRAY_LIBNOTIFY=1) 78 | list(APPEND TRAY_EXTERNAL_LIBRARIES ${APPINDICATOR_LIBRARIES} ${LIBNOTIFY_LIBRARIES}) 79 | 80 | include_directories(SYSTEM ${APPINDICATOR_INCLUDE_DIRS} ${LIBNOTIFY_INCLUDE_DIRS}) 81 | link_directories(${APPINDICATOR_LIBRARY_DIRS} ${LIBNOTIFY_LIBRARY_DIRS}) 82 | endif() 83 | endif() 84 | endif() 85 | 86 | add_library(tray::tray ALIAS ${PROJECT_NAME}) 87 | 88 | add_executable(tray_example "${CMAKE_SOURCE_DIR}/src/example.c") 89 | target_link_libraries(tray_example tray::tray) 90 | 91 | configure_file("${CMAKE_SOURCE_DIR}/icons/icon.ico" "${CMAKE_BINARY_DIR}/icon.ico" COPYONLY) 92 | configure_file("${CMAKE_SOURCE_DIR}/icons/icon.png" "${CMAKE_BINARY_DIR}/icon.png" COPYONLY) 93 | 94 | INSTALL(TARGETS tray tray DESTINATION lib) 95 | 96 | IF(NOT WIN32) 97 | INSTALL(FILES tray.h DESTINATION include) 98 | ENDIF() 99 | 100 | target_compile_definitions(${PROJECT_NAME} PRIVATE ${TRAY_DEFINITIONS}) 101 | target_compile_options(${PROJECT_NAME} PRIVATE ${TRAY_COMPILE_OPTIONS}) 102 | target_link_directories(${PROJECT_NAME} PRIVATE ${TRAY_EXTERNAL_DIRECTORIES}) 103 | target_link_libraries(${PROJECT_NAME} PRIVATE ${TRAY_EXTERNAL_LIBRARIES}) 104 | 105 | # 106 | # Testing and documentation are only available if this is the main project 107 | # 108 | if(CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME) 109 | if(BUILD_DOCS) 110 | add_subdirectory(third-party/doxyconfig docs) 111 | endif() 112 | 113 | if(BUILD_TESTS) 114 | # 115 | # Additional setup for coverage 116 | # https://gcovr.com/en/stable/guide/compiling.html#compiler-options 117 | # 118 | if(NOT CMAKE_CXX_COMPILER_ID STREQUAL "MSVC") 119 | set(CMAKE_CXX_FLAGS "-fprofile-arcs -ftest-coverage -ggdb -O0") 120 | set(CMAKE_C_FLAGS "-fprofile-arcs -ftest-coverage -ggdb -O0") 121 | endif() 122 | 123 | enable_testing() 124 | add_subdirectory(tests) 125 | endif() 126 | endif() 127 | -------------------------------------------------------------------------------- /tests/conftest.cpp: -------------------------------------------------------------------------------- 1 | // standard includes 2 | #include 3 | #include 4 | 5 | // lib includes 6 | #include 7 | 8 | // test includes 9 | #include "tests/utils.h" 10 | 11 | // Undefine the original TEST macro 12 | #undef TEST 13 | 14 | // Redefine TEST to use our BaseTest class, to automatically use our BaseTest fixture 15 | #define TEST(test_case_name, test_name) \ 16 | GTEST_TEST_(test_case_name, test_name, ::BaseTest, ::testing::internal::GetTypeId<::BaseTest>()) 17 | 18 | /** 19 | * @brief Base class for tests. 20 | * 21 | * This class provides a base test fixture for all tests. 22 | * 23 | * ``cout``, ``stderr``, and ``stdout`` are redirected to a buffer, and the buffer is printed if the test fails. 24 | * 25 | * @todo Retain the color of the original output. 26 | */ 27 | class BaseTest: public ::testing::Test { 28 | protected: 29 | // https://stackoverflow.com/a/58369622/11214013 30 | 31 | // we can possibly use some internal googletest functions to capture stdout and stderr, but I have not tested this 32 | // https://stackoverflow.com/a/33186201/11214013 33 | 34 | BaseTest(): 35 | sbuf {nullptr}, 36 | pipe_stdout {nullptr}, 37 | pipe_stderr {nullptr} { 38 | // intentionally empty 39 | } 40 | 41 | ~BaseTest() override = default; 42 | 43 | void SetUp() override { 44 | // todo: only run this one time, instead of every time a test is run 45 | // see: https://stackoverflow.com/questions/2435277/googletest-accessing-the-environment-from-a-test 46 | // get command line args from the test executable 47 | testArgs = ::testing::internal::GetArgvs(); 48 | 49 | // then get the directory of the test executable 50 | // std::string path = ::testing::internal::GetArgvs()[0]; 51 | testBinary = testArgs[0]; 52 | 53 | // get the directory of the test executable 54 | testBinaryDir = std::filesystem::path(testBinary).parent_path(); 55 | 56 | // If testBinaryDir is empty or `.` then set it to the current directory 57 | // maybe some better options here: https://stackoverflow.com/questions/875249/how-to-get-current-directory 58 | if (testBinaryDir.empty() || testBinaryDir.string() == ".") { 59 | testBinaryDir = std::filesystem::current_path(); 60 | } 61 | 62 | sbuf = std::cout.rdbuf(); // save cout buffer (std::cout) 63 | std::cout.rdbuf(cout_buffer.rdbuf()); // redirect cout to buffer (std::cout) 64 | } 65 | 66 | void TearDown() override { 67 | std::cout.rdbuf(sbuf); // restore cout buffer 68 | 69 | // get test info 70 | const ::testing::TestInfo *const test_info = ::testing::UnitTest::GetInstance()->current_test_info(); 71 | 72 | if (test_info->result()->Failed()) { 73 | std::cout << std::endl 74 | << "Test failed: " << test_info->name() << std::endl 75 | << std::endl 76 | << "Captured cout:" << std::endl 77 | << cout_buffer.str() << std::endl 78 | << "Captured stdout:" << std::endl 79 | << stdout_buffer.str() << std::endl 80 | << "Captured stderr:" << std::endl 81 | << stderr_buffer.str() << std::endl; 82 | } 83 | 84 | sbuf = nullptr; // clear sbuf 85 | if (pipe_stdout) { 86 | pclose(pipe_stdout); 87 | pipe_stdout = nullptr; 88 | } 89 | if (pipe_stderr) { 90 | pclose(pipe_stderr); 91 | pipe_stderr = nullptr; 92 | } 93 | } 94 | 95 | // functions and variables 96 | std::vector testArgs; // CLI arguments used 97 | std::filesystem::path testBinary; // full path of this binary 98 | std::filesystem::path testBinaryDir; // full directory of this binary 99 | std::stringstream cout_buffer; // declare cout_buffer 100 | std::stringstream stdout_buffer; // declare stdout_buffer 101 | std::stringstream stderr_buffer; // declare stderr_buffer 102 | std::streambuf *sbuf; 103 | FILE *pipe_stdout; 104 | FILE *pipe_stderr; 105 | 106 | int exec(const char *cmd) { 107 | std::array buffer {}; 108 | pipe_stdout = popen((std::string(cmd) + " 2>&1").c_str(), "r"); 109 | pipe_stderr = popen((std::string(cmd) + " 2>&1").c_str(), "r"); 110 | if (!pipe_stdout || !pipe_stderr) { 111 | throw std::runtime_error("popen() failed!"); 112 | } 113 | while (fgets(buffer.data(), buffer.size(), pipe_stdout) != nullptr) { 114 | stdout_buffer << buffer.data(); 115 | } 116 | while (fgets(buffer.data(), buffer.size(), pipe_stderr) != nullptr) { 117 | stderr_buffer << buffer.data(); 118 | } 119 | int returnCode = pclose(pipe_stdout); 120 | pipe_stdout = nullptr; 121 | if (returnCode != 0) { 122 | std::cout << "Error: " << stderr_buffer.str() << std::endl 123 | << "Return code: " << returnCode << std::endl; 124 | } 125 | return returnCode; 126 | } 127 | }; 128 | 129 | class LinuxTest: public BaseTest { 130 | protected: 131 | void SetUp() override { 132 | #ifndef __linux__ 133 | GTEST_SKIP_("Skipping, this test is for Linux only."); 134 | #endif 135 | } 136 | 137 | void TearDown() override { 138 | BaseTest::TearDown(); 139 | } 140 | }; 141 | 142 | class MacOSTest: public BaseTest { 143 | protected: 144 | void SetUp() override { 145 | #if !defined(__APPLE__) || !defined(__MACH__) 146 | GTEST_SKIP_("Skipping, this test is for macOS only."); 147 | #endif 148 | } 149 | 150 | void TearDown() override { 151 | BaseTest::TearDown(); 152 | } 153 | }; 154 | 155 | class WindowsTest: public BaseTest { 156 | protected: 157 | void SetUp() override { 158 | #ifndef _WIN32 159 | GTEST_SKIP_("Skipping, this test is for Windows only."); 160 | #endif 161 | BaseTest::SetUp(); 162 | } 163 | 164 | void TearDown() override { 165 | BaseTest::TearDown(); 166 | } 167 | }; 168 | -------------------------------------------------------------------------------- /src/tray_linux.c: -------------------------------------------------------------------------------- 1 | /** 2 | * @file src/tray_linux.c 3 | * @brief System tray implementation for Linux. 4 | */ 5 | // standard includes 6 | #include 7 | #include 8 | #include 9 | 10 | // lib includes 11 | #ifdef TRAY_AYATANA_APPINDICATOR 12 | #include 13 | #elif TRAY_LEGACY_APPINDICATOR 14 | #include 15 | #endif 16 | #ifndef IS_APP_INDICATOR 17 | #define IS_APP_INDICATOR APP_IS_INDICATOR ///< Define IS_APP_INDICATOR for app-indicator compatibility. 18 | #endif 19 | #include 20 | #define TRAY_APPINDICATOR_ID "tray-id" ///< Tray appindicator ID. 21 | 22 | // local includes 23 | #include "tray.h" 24 | 25 | static bool async_update_pending = false; 26 | static pthread_cond_t async_update_cv = PTHREAD_COND_INITIALIZER; 27 | static pthread_mutex_t async_update_mutex = PTHREAD_MUTEX_INITIALIZER; 28 | 29 | static AppIndicator *indicator = NULL; 30 | static int loop_result = 0; 31 | static NotifyNotification *currentNotification = NULL; 32 | 33 | static void _tray_menu_cb(GtkMenuItem *item, gpointer data) { 34 | (void) item; 35 | struct tray_menu *m = (struct tray_menu *) data; 36 | m->cb(m); 37 | } 38 | 39 | static GtkMenuShell *_tray_menu(struct tray_menu *m) { 40 | GtkMenuShell *menu = (GtkMenuShell *) gtk_menu_new(); 41 | for (; m != NULL && m->text != NULL; m++) { 42 | GtkWidget *item; 43 | if (strcmp(m->text, "-") == 0) { 44 | item = gtk_separator_menu_item_new(); 45 | } else { 46 | if (m->submenu != NULL) { 47 | item = gtk_menu_item_new_with_label(m->text); 48 | gtk_menu_item_set_submenu(GTK_MENU_ITEM(item), GTK_WIDGET(_tray_menu(m->submenu))); 49 | } else if (m->checkbox) { 50 | item = gtk_check_menu_item_new_with_label(m->text); 51 | gtk_check_menu_item_set_active(GTK_CHECK_MENU_ITEM(item), !!m->checked); 52 | } else { 53 | item = gtk_menu_item_new_with_label(m->text); 54 | } 55 | gtk_widget_set_sensitive(item, !m->disabled); 56 | if (m->cb != NULL) { 57 | g_signal_connect(item, "activate", G_CALLBACK(_tray_menu_cb), m); 58 | } 59 | } 60 | gtk_widget_show(item); 61 | gtk_menu_shell_append(menu, item); 62 | } 63 | return menu; 64 | } 65 | 66 | int tray_init(struct tray *tray) { 67 | if (gtk_init_check(0, NULL) == FALSE) { 68 | return -1; 69 | } 70 | notify_init("tray-icon"); 71 | indicator = app_indicator_new(TRAY_APPINDICATOR_ID, tray->icon, APP_INDICATOR_CATEGORY_APPLICATION_STATUS); 72 | if (indicator == NULL || !IS_APP_INDICATOR(indicator)) { 73 | return -1; 74 | } 75 | app_indicator_set_status(indicator, APP_INDICATOR_STATUS_ACTIVE); 76 | tray_update(tray); 77 | return 0; 78 | } 79 | 80 | int tray_loop(int blocking) { 81 | gtk_main_iteration_do(blocking); 82 | return loop_result; 83 | } 84 | 85 | static gboolean tray_update_internal(gpointer user_data) { 86 | struct tray *tray = user_data; 87 | 88 | if (indicator != NULL && IS_APP_INDICATOR(indicator)) { 89 | app_indicator_set_icon_full(indicator, tray->icon, tray->icon); 90 | // GTK is all about reference counting, so previous menu should be destroyed 91 | // here 92 | app_indicator_set_menu(indicator, GTK_MENU(_tray_menu(tray->menu))); 93 | } 94 | if (tray->notification_text != 0 && strlen(tray->notification_text) > 0 && notify_is_initted()) { 95 | if (currentNotification != NULL && NOTIFY_IS_NOTIFICATION(currentNotification)) { 96 | notify_notification_close(currentNotification, NULL); 97 | g_object_unref(G_OBJECT(currentNotification)); 98 | } 99 | const char *notification_icon = tray->notification_icon != NULL ? tray->notification_icon : tray->icon; 100 | currentNotification = notify_notification_new(tray->notification_title, tray->notification_text, notification_icon); 101 | if (currentNotification != NULL && NOTIFY_IS_NOTIFICATION(currentNotification)) { 102 | if (tray->notification_cb != NULL) { 103 | notify_notification_add_action(currentNotification, "default", "Default", NOTIFY_ACTION_CALLBACK(tray->notification_cb), NULL, NULL); 104 | } 105 | notify_notification_show(currentNotification, NULL); 106 | } 107 | } 108 | 109 | // Unwait any pending tray_update() calls 110 | pthread_mutex_lock(&async_update_mutex); 111 | async_update_pending = false; 112 | pthread_cond_broadcast(&async_update_cv); 113 | pthread_mutex_unlock(&async_update_mutex); 114 | return G_SOURCE_REMOVE; 115 | } 116 | 117 | void tray_update(struct tray *tray) { 118 | // Perform the tray update on the tray loop thread, but block 119 | // in this thread to ensure none of the strings stored in the 120 | // tray icon struct go out of scope before the callback runs. 121 | 122 | if (g_main_context_is_owner(g_main_context_default())) { 123 | // Invoke the callback directly if we're on the loop thread 124 | tray_update_internal(tray); 125 | } else { 126 | // If there's already an update pending, wait for it to complete 127 | // and claim the next pending update slot. 128 | pthread_mutex_lock(&async_update_mutex); 129 | while (async_update_pending) { 130 | pthread_cond_wait(&async_update_cv, &async_update_mutex); 131 | } 132 | async_update_pending = true; 133 | pthread_mutex_unlock(&async_update_mutex); 134 | 135 | // Queue the update callback to the tray thread 136 | g_main_context_invoke(NULL, tray_update_internal, tray); 137 | 138 | // Wait for the callback to run 139 | pthread_mutex_lock(&async_update_mutex); 140 | while (async_update_pending) { 141 | pthread_cond_wait(&async_update_cv, &async_update_mutex); 142 | } 143 | pthread_mutex_unlock(&async_update_mutex); 144 | } 145 | } 146 | 147 | static gboolean tray_exit_internal(gpointer user_data) { 148 | if (currentNotification != NULL && NOTIFY_IS_NOTIFICATION(currentNotification)) { 149 | int v = notify_notification_close(currentNotification, NULL); 150 | if (v == TRUE) { 151 | g_object_unref(G_OBJECT(currentNotification)); 152 | } 153 | } 154 | notify_uninit(); 155 | return G_SOURCE_REMOVE; 156 | } 157 | 158 | void tray_exit(void) { 159 | // Wait for any pending callbacks to complete 160 | pthread_mutex_lock(&async_update_mutex); 161 | while (async_update_pending) { 162 | pthread_cond_wait(&async_update_cv, &async_update_mutex); 163 | } 164 | pthread_mutex_unlock(&async_update_mutex); 165 | 166 | // Perform cleanup on the main thread 167 | loop_result = -1; 168 | g_main_context_invoke(NULL, tray_exit_internal, NULL); 169 | } 170 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | permissions: 4 | contents: read 5 | 6 | on: 7 | pull_request: 8 | branches: 9 | - master 10 | types: 11 | - opened 12 | - synchronize 13 | - reopened 14 | push: 15 | branches: 16 | - master 17 | 18 | concurrency: 19 | group: "${{ github.workflow }}-${{ github.ref }}" 20 | cancel-in-progress: true 21 | 22 | jobs: 23 | build: 24 | name: Build (${{ matrix.os }} - ${{ matrix.appindicator || 'default' }}) 25 | defaults: 26 | run: 27 | shell: ${{ matrix.shell }} 28 | runs-on: ${{ matrix.os }} 29 | strategy: 30 | fail-fast: false 31 | matrix: 32 | include: 33 | - os: macos-latest 34 | shell: "bash" 35 | - os: ubuntu-latest 36 | appindicator: "libayatana-appindicator3-dev" 37 | shell: "bash" 38 | - os: ubuntu-latest 39 | appindicator: "libappindicator3-dev" 40 | shell: "bash" 41 | - os: windows-latest 42 | shell: "msys2 {0}" 43 | steps: 44 | - name: Checkout 45 | uses: actions/checkout@v6 46 | with: 47 | submodules: recursive 48 | 49 | - name: Setup Dependencies Linux 50 | if: runner.os == 'Linux' 51 | run: | 52 | sudo apt-get update 53 | sudo apt-get install -y \ 54 | build-essential \ 55 | cmake \ 56 | ${{ matrix.appindicator }} \ 57 | libglib2.0-dev \ 58 | libnotify-dev \ 59 | ninja-build \ 60 | xvfb 61 | 62 | - name: Setup Dependencies macOS 63 | if: runner.os == 'macOS' 64 | run: | 65 | brew update 66 | brew install \ 67 | cmake \ 68 | doxygen \ 69 | graphviz \ 70 | ninja \ 71 | node 72 | 73 | - name: Setup Dependencies Windows 74 | if: runner.os == 'Windows' 75 | uses: msys2/setup-msys2@4f806de0a5a7294ffabaff804b38a9b435a73bda # v2.30.0 76 | with: 77 | msystem: ucrt64 78 | update: true 79 | install: >- 80 | doxygen 81 | mingw-w64-ucrt-x86_64-binutils 82 | mingw-w64-ucrt-x86_64-cmake 83 | mingw-w64-ucrt-x86_64-graphviz 84 | mingw-w64-ucrt-x86_64-ninja 85 | mingw-w64-ucrt-x86_64-nodejs 86 | mingw-w64-ucrt-x86_64-toolchain 87 | 88 | - name: Setup python 89 | id: setup-python 90 | uses: actions/setup-python@v6 91 | with: 92 | python-version: '3.11' 93 | 94 | - name: Python Path 95 | id: python-path 96 | run: | 97 | if [ "${{ runner.os }}" = "Windows" ]; then 98 | # replace backslashes with double backslashes 99 | python_path=$(echo "${{ steps.setup-python.outputs.python-path }}" | sed 's/\\/\\\\/g') 100 | else 101 | python_path=${{ steps.setup-python.outputs.python-path }} 102 | fi 103 | 104 | # step output 105 | echo "python-path=${python_path}" 106 | echo "python-path=${python_path}" >> $GITHUB_OUTPUT 107 | 108 | - name: Build 109 | run: | 110 | mkdir -p build 111 | 112 | if [ "${{ runner.os }}" = "Linux" ]; then 113 | # Doxygen from Ubuntu is too old, need Doxygen >= 1.10 114 | DOCS=OFF 115 | else 116 | DOCS=ON 117 | fi 118 | 119 | cmake \ 120 | -DBUILD_DOCS=${DOCS} \ 121 | -DCMAKE_BUILD_TYPE:STRING=Debug \ 122 | -B build \ 123 | -G Ninja \ 124 | -S . 125 | ninja -C build 126 | 127 | - name: Run tests 128 | id: test 129 | # TODO: tests randomly hang on Linux, https://github.com/LizardByte/tray/issues/45 130 | timeout-minutes: 1 131 | working-directory: build/tests 132 | run: | 133 | if [ "${{ runner.os }}" = "Linux" ]; then 134 | export DISPLAY=:1 135 | Xvfb ${DISPLAY} -screen 0 1024x768x24 & 136 | fi 137 | 138 | ./test_tray --gtest_color=yes --gtest_output=xml:test_results.xml 139 | 140 | - name: Generate gcov report 141 | id: test_report 142 | # any except canceled or skipped 143 | if: >- 144 | always() && 145 | (steps.test.outcome == 'success' || steps.test.outcome == 'failure') 146 | working-directory: build 147 | run: | 148 | ${{ steps.python-path.outputs.python-path }} -m pip install gcovr 149 | ${{ steps.python-path.outputs.python-path }} -m gcovr . -r ../src \ 150 | --exclude-noncode-lines \ 151 | --exclude-throw-branches \ 152 | --exclude-unreachable-branches \ 153 | --verbose \ 154 | --xml-pretty \ 155 | -o coverage.xml 156 | 157 | - name: Debug coverage file 158 | run: cat build/coverage.xml 159 | 160 | - name: Set codecov flags 161 | id: codecov_flags 162 | run: | 163 | flags="${{ runner.os }}" 164 | if [ -n "${{ matrix.appindicator }}" ]; then 165 | flags="${flags},${{ matrix.appindicator }}" 166 | fi 167 | echo "flags=${flags}" >> $GITHUB_OUTPUT 168 | 169 | # todo: upload coverage in separate job similar to LizardByte/libdisplaydevice 170 | - name: Upload test results to Codecov 171 | # any except canceled or skipped 172 | if: >- 173 | always() && 174 | (steps.test.outcome == 'success' || steps.test.outcome == 'failure') && 175 | startsWith(github.repository, 'LizardByte/') 176 | uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1.1.1 177 | with: 178 | disable_search: true 179 | fail_ci_if_error: true 180 | files: ./build/tests/test_results.xml 181 | flags: "${{ steps.codecov_flags.outputs.flags }}" 182 | token: ${{ secrets.CODECOV_TOKEN }} 183 | verbose: true 184 | 185 | # todo: upload coverage in separate job similar to LizardByte/libdisplaydevice 186 | - name: Upload coverage 187 | # any except canceled or skipped 188 | if: >- 189 | always() && 190 | steps.test_report.outcome == 'success' && 191 | startsWith(github.repository, 'LizardByte/') 192 | uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 193 | with: 194 | disable_search: true 195 | fail_ci_if_error: true 196 | files: ./build/coverage.xml 197 | flags: "${{ steps.codecov_flags.outputs.flags }}" 198 | token: ${{ secrets.CODECOV_TOKEN }} 199 | verbose: true 200 | -------------------------------------------------------------------------------- /src/tray_windows.c: -------------------------------------------------------------------------------- 1 | /** 2 | * @file src/tray_windows.c 3 | * @brief System tray implementation for Windows. 4 | */ 5 | // standard includes 6 | #include 7 | // clang-format off 8 | // build fails if shellapi.h is included before windows.h 9 | #include 10 | // clang-format on 11 | 12 | // local includes 13 | #include "tray.h" 14 | 15 | #define WM_TRAY_CALLBACK_MESSAGE (WM_USER + 1) ///< Tray callback message. 16 | #define WC_TRAY_CLASS_NAME "TRAY" ///< Tray window class name. 17 | #define ID_TRAY_FIRST 1000 ///< First tray identifier. 18 | 19 | /** 20 | * @brief Icon information. 21 | */ 22 | struct icon_info { 23 | const char *path; ///< Path to the icon 24 | HICON icon; ///< Regular icon 25 | HICON large_icon; ///< Large icon 26 | HICON notification_icon; ///< Notification icon 27 | }; 28 | 29 | /** 30 | * @brief Icon type. 31 | */ 32 | enum IconType { 33 | REGULAR = 1, ///< Regular icon 34 | LARGE, ///< Large icon 35 | NOTIFICATION ///< Notification icon 36 | }; 37 | 38 | static WNDCLASSEX wc; 39 | static NOTIFYICONDATAW nid; 40 | static HWND hwnd; 41 | static HMENU hmenu = NULL; 42 | static void (*notification_cb)() = 0; 43 | static UINT wm_taskbarcreated; 44 | 45 | static struct icon_info *icon_infos; 46 | static int icon_info_count; 47 | 48 | static LRESULT CALLBACK _tray_wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam) { 49 | switch (msg) { 50 | case WM_CLOSE: 51 | DestroyWindow(hwnd); 52 | return 0; 53 | case WM_DESTROY: 54 | PostQuitMessage(0); 55 | return 0; 56 | case WM_TRAY_CALLBACK_MESSAGE: 57 | if (lparam == WM_LBUTTONUP || lparam == WM_RBUTTONUP) { 58 | POINT p; 59 | GetCursorPos(&p); 60 | SetForegroundWindow(hwnd); 61 | WORD cmd = TrackPopupMenu(hmenu, TPM_LEFTALIGN | TPM_RIGHTBUTTON | TPM_RETURNCMD | TPM_NONOTIFY, p.x, p.y, 0, hwnd, NULL); 62 | SendMessage(hwnd, WM_COMMAND, cmd, 0); 63 | return 0; 64 | } else if (lparam == NIN_BALLOONUSERCLICK && notification_cb != NULL) { 65 | notification_cb(); 66 | } 67 | break; 68 | case WM_COMMAND: 69 | if (wparam >= ID_TRAY_FIRST) { 70 | MENUITEMINFO item = { 71 | .cbSize = sizeof(MENUITEMINFO), 72 | .fMask = MIIM_ID | MIIM_DATA, 73 | }; 74 | if (GetMenuItemInfo(hmenu, wparam, FALSE, &item)) { 75 | struct tray_menu *menu = (struct tray_menu *) item.dwItemData; 76 | if (menu != NULL && menu->cb != NULL) { 77 | menu->cb(menu); 78 | } 79 | } 80 | return 0; 81 | } 82 | break; 83 | } 84 | 85 | if (msg == wm_taskbarcreated) { 86 | Shell_NotifyIconW(NIM_ADD, &nid); 87 | return 0; 88 | } 89 | 90 | return DefWindowProc(hwnd, msg, wparam, lparam); 91 | } 92 | 93 | static HMENU _tray_menu(struct tray_menu *m, UINT *id) { 94 | HMENU hmenu = CreatePopupMenu(); 95 | for (; m != NULL && m->text != NULL; m++, (*id)++) { 96 | if (strcmp(m->text, "-") == 0) { 97 | InsertMenuW(hmenu, *id, MF_SEPARATOR, TRUE, L""); 98 | } else { 99 | MENUITEMINFOW item; 100 | memset(&item, 0, sizeof(item)); 101 | item.cbSize = sizeof(MENUITEMINFOW); 102 | item.fMask = MIIM_ID | MIIM_TYPE | MIIM_STATE | MIIM_DATA; 103 | item.fType = 0; 104 | item.fState = 0; 105 | if (m->submenu != NULL) { 106 | item.fMask = item.fMask | MIIM_SUBMENU; 107 | item.hSubMenu = _tray_menu(m->submenu, id); 108 | } 109 | if (m->disabled) { 110 | item.fState |= MFS_DISABLED; 111 | } 112 | if (m->checked) { 113 | item.fState |= MFS_CHECKED; 114 | } 115 | item.wID = *id; 116 | 117 | // Convert UTF-8 text to UTF-16 (wide string) 118 | int wide_size = MultiByteToWideChar(CP_UTF8, 0, m->text, -1, NULL, 0); 119 | wchar_t *wide_text = (wchar_t *) malloc(wide_size * sizeof(wchar_t)); 120 | if (wide_text == NULL) { 121 | DestroyMenu(hmenu); 122 | return NULL; 123 | } 124 | MultiByteToWideChar(CP_UTF8, 0, m->text, -1, wide_text, wide_size); 125 | 126 | item.dwTypeData = wide_text; 127 | item.dwItemData = (ULONG_PTR) m; 128 | 129 | InsertMenuItemW(hmenu, *id, TRUE, &item); 130 | // Free the allocated wide string 131 | free(wide_text); 132 | } 133 | } 134 | return hmenu; 135 | } 136 | 137 | /** 138 | * @brief Create icon information. 139 | * @param path Path to the icon. 140 | * @return Icon information. 141 | */ 142 | struct icon_info _create_icon_info(const char *path) { 143 | struct icon_info info; 144 | info.path = strdup(path); 145 | 146 | // These must be separate invocations otherwise Windows may opt to only return large or small icons. 147 | // MSDN does not explicitly state this anywhere, but it has been observed on some machines. 148 | ExtractIconEx(path, 0, &info.large_icon, NULL, 1); 149 | ExtractIconEx(path, 0, NULL, &info.icon, 1); 150 | 151 | info.notification_icon = LoadImageA(NULL, path, IMAGE_ICON, GetSystemMetrics(SM_CXICON) * 2, GetSystemMetrics(SM_CYICON) * 2, LR_LOADFROMFILE); 152 | return info; 153 | } 154 | 155 | /** 156 | * @brief Initialize icon cache. 157 | * @param paths Paths to the icons. 158 | * @param count Number of paths. 159 | */ 160 | void _init_icon_cache(const char **paths, int count) { 161 | icon_info_count = count; 162 | icon_infos = malloc(sizeof(struct icon_info) * icon_info_count); 163 | 164 | for (int i = 0; i < count; ++i) { 165 | icon_infos[i] = _create_icon_info(paths[i]); 166 | } 167 | } 168 | 169 | /** 170 | * @brief Destroy icon cache. 171 | */ 172 | void _destroy_icon_cache() { 173 | for (int i = 0; i < icon_info_count; ++i) { 174 | DestroyIcon(icon_infos[i].icon); 175 | DestroyIcon(icon_infos[i].large_icon); 176 | DestroyIcon(icon_infos[i].notification_icon); 177 | free((void *) icon_infos[i].path); 178 | } 179 | 180 | free(icon_infos); 181 | icon_infos = NULL; 182 | icon_info_count = 0; 183 | } 184 | 185 | /** 186 | * @brief Fetch cached icon. 187 | * @param icon_record Icon record. 188 | * @param icon_type Icon type. 189 | * @return Icon. 190 | */ 191 | HICON _fetch_cached_icon(struct icon_info *icon_record, enum IconType icon_type) { 192 | switch (icon_type) { 193 | case REGULAR: 194 | return icon_record->icon; 195 | case LARGE: 196 | return icon_record->large_icon; 197 | case NOTIFICATION: 198 | return icon_record->notification_icon; 199 | } 200 | } 201 | 202 | /** 203 | * @brief Fetch icon. 204 | * @param path Path to the icon. 205 | * @param icon_type Icon type. 206 | * @return Icon. 207 | */ 208 | HICON _fetch_icon(const char *path, enum IconType icon_type) { 209 | // Find a cached icon by path 210 | for (int i = 0; i < icon_info_count; ++i) { 211 | if (strcmp(icon_infos[i].path, path) == 0) { 212 | return _fetch_cached_icon(&icon_infos[i], icon_type); 213 | } 214 | } 215 | 216 | // Expand cache, fetch, and retry 217 | icon_info_count += 1; 218 | icon_infos = realloc(icon_infos, sizeof(struct icon_info) * icon_info_count); 219 | int index = icon_info_count - 1; 220 | icon_infos[icon_info_count - 1] = _create_icon_info(path); 221 | 222 | return _fetch_cached_icon(&icon_infos[icon_info_count - 1], icon_type); 223 | } 224 | 225 | int tray_init(struct tray *tray) { 226 | wm_taskbarcreated = RegisterWindowMessage("TaskbarCreated"); 227 | 228 | _init_icon_cache(tray->allIconPaths, tray->iconPathCount); 229 | 230 | memset(&wc, 0, sizeof(wc)); 231 | wc.cbSize = sizeof(WNDCLASSEX); 232 | wc.lpfnWndProc = _tray_wnd_proc; 233 | wc.hInstance = GetModuleHandle(NULL); 234 | wc.lpszClassName = WC_TRAY_CLASS_NAME; 235 | if (!RegisterClassEx(&wc)) { 236 | return -1; 237 | } 238 | 239 | hwnd = CreateWindowEx(0, WC_TRAY_CLASS_NAME, NULL, 0, 0, 0, 0, 0, 0, 0, 0, 0); 240 | if (hwnd == NULL) { 241 | return -1; 242 | } 243 | UpdateWindow(hwnd); 244 | 245 | memset(&nid, 0, sizeof(nid)); 246 | nid.cbSize = sizeof(NOTIFYICONDATAW); 247 | nid.hWnd = hwnd; 248 | nid.uID = 0; 249 | nid.uFlags = NIF_ICON | NIF_MESSAGE; 250 | nid.uCallbackMessage = WM_TRAY_CALLBACK_MESSAGE; 251 | Shell_NotifyIconW(NIM_ADD, &nid); 252 | 253 | tray_update(tray); 254 | return 0; 255 | } 256 | 257 | int tray_loop(int blocking) { 258 | MSG msg; 259 | if (blocking) { 260 | GetMessage(&msg, hwnd, 0, 0); 261 | } else { 262 | PeekMessage(&msg, hwnd, 0, 0, PM_REMOVE); 263 | } 264 | if (msg.message == WM_QUIT) { 265 | return -1; 266 | } 267 | TranslateMessage(&msg); 268 | DispatchMessage(&msg); 269 | return 0; 270 | } 271 | 272 | void tray_update(struct tray *tray) { 273 | UINT id = ID_TRAY_FIRST; 274 | HMENU prevmenu = hmenu; 275 | hmenu = _tray_menu(tray->menu, &id); 276 | SendMessage(hwnd, WM_INITMENUPOPUP, (WPARAM) hmenu, 0); 277 | 278 | HICON icon = _fetch_icon(tray->icon, REGULAR); 279 | HICON largeIcon = tray->notification_icon != 0 ? _fetch_icon(tray->notification_icon, NOTIFICATION) : _fetch_icon(tray->icon, LARGE); 280 | 281 | if (icon != NULL) { 282 | nid.hIcon = icon; 283 | } 284 | 285 | if (largeIcon != 0) { 286 | nid.hBalloonIcon = largeIcon; 287 | nid.dwInfoFlags = NIIF_USER | NIIF_LARGE_ICON; 288 | } 289 | if (tray->tooltip != 0 && strlen(tray->tooltip) > 0) { 290 | MultiByteToWideChar(CP_UTF8, 0, tray->tooltip, -1, nid.szTip, sizeof(nid.szTip) / sizeof(wchar_t)); 291 | nid.uFlags |= NIF_TIP; 292 | } 293 | QUERY_USER_NOTIFICATION_STATE notification_state; 294 | HRESULT ns = SHQueryUserNotificationState(¬ification_state); 295 | int can_show_notifications = ns == S_OK && notification_state == QUNS_ACCEPTS_NOTIFICATIONS; 296 | if (can_show_notifications == 1 && tray->notification_title != 0 && strlen(tray->notification_title) > 0) { 297 | MultiByteToWideChar(CP_UTF8, 0, tray->notification_title, -1, nid.szInfoTitle, sizeof(nid.szInfoTitle) / sizeof(wchar_t)); 298 | nid.uFlags |= NIF_INFO; 299 | } else if ((nid.uFlags & NIF_INFO) == NIF_INFO) { 300 | nid.szInfoTitle[0] = L'\0'; 301 | } 302 | if (can_show_notifications == 1 && tray->notification_text != 0 && strlen(tray->notification_text) > 0) { 303 | MultiByteToWideChar(CP_UTF8, 0, tray->notification_text, -1, nid.szInfo, sizeof(nid.szInfo) / sizeof(wchar_t)); 304 | } else if ((nid.uFlags & NIF_INFO) == NIF_INFO) { 305 | nid.szInfo[0] = L'\0'; 306 | } 307 | if (can_show_notifications == 1 && tray->notification_cb != NULL) { 308 | notification_cb = tray->notification_cb; 309 | } 310 | 311 | Shell_NotifyIconW(NIM_MODIFY, &nid); 312 | 313 | if (prevmenu != NULL) { 314 | DestroyMenu(prevmenu); 315 | } 316 | } 317 | 318 | void tray_exit(void) { 319 | Shell_NotifyIconW(NIM_DELETE, &nid); 320 | SendMessage(hwnd, WM_CLOSE, 0, 0); 321 | _destroy_icon_cache(); 322 | if (hmenu != 0) { 323 | DestroyMenu(hmenu); 324 | } 325 | UnregisterClass(WC_TRAY_CLASS_NAME, GetModuleHandle(NULL)); 326 | } 327 | --------------------------------------------------------------------------------