diff --git a/.appveyor.yml b/.appveyor.yml deleted file mode 100644 index 80b36a9..0000000 --- a/.appveyor.yml +++ /dev/null @@ -1,29 +0,0 @@ -version: 1.13.0.{build} -pull_requests: - do_not_increment_build_number: true -shallow_clone: true -environment: - lsltag: 1.13.0 - lslversion: 1.13.0 - LSLDIST_URL: "https://github.com/sccn/liblsl/releases/download" - CMakeArgs: "" - matrix: - - APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2017 - QTCOMPILER: msvc2017_64 - QTVER: 5.13 - - APPVEYOR_BUILD_WORKER_IMAGE: Ubuntu1804 - DEPLOYNAME: Linux64-bionic -install: -- cmd: appveyor DownloadFile %LSLDIST_URL%/%lsltag%/liblsl-%lslversion%-Win64.zip -FileName liblsl_x64.zip -- cmd: 7z x liblsl_x64.zip -oLSL -- sh: wget ${LSLDIST_URL}/${lsltag}/liblsl-${lslversion}-${DEPLOYNAME}.deb -O lsl.deb -- sh: sudo dpkg -i lsl.deb -- sh: sudo apt update && sudo apt install -y qtbase5-dev -build_script: -- cmd: cmake -S . -B build -DQt5_DIR=C:/Qt/%QTVER%/%QTCOMPILER%/lib/cmake/Qt5 -DLSL_INSTALL_ROOT=LSL/ %CMakeArgs% -A x64 -- sh: cmake -S . -B build -DLSL_UNIXFOLDERS=1 -DCPACK_DEBIAN_PACKAGE_SHLIBDEPS=1 ${CMakeArgs} -- cmake --build build --config Release -j --target package -artifacts: -- path: 'build/*.deb' -- path: 'build/*.tar.*' -- path: 'build/*.7z' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..c029cae --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,287 @@ +# ============================================================================= +# LSL Application Build Workflow +# ============================================================================= +# This workflow builds, tests, and packages the LSL application for all +# supported platforms. It serves as a reference for other LSL applications. +# +# Features: +# - Multi-platform builds (Linux, macOS, Windows) +# - Qt6 integration +# - Automatic liblsl fetch via FetchContent +# - CPack packaging +# - macOS code signing and notarization (on release) +# ============================================================================= + +name: Build + +on: + push: + branches: [main, master, dev] + tags: ['v*'] + pull_request: + branches: [main, master] + release: + types: [published] + workflow_dispatch: + +env: + BUILD_TYPE: Release + +jobs: + # =========================================================================== + # Build Job - Multi-platform builds + # =========================================================================== + build: + name: ${{ matrix.config.name }} + runs-on: ${{ matrix.config.os }} + strategy: + fail-fast: false + matrix: + config: + - { name: "Ubuntu 22.04", os: ubuntu-22.04 } + - { name: "Ubuntu 24.04", os: ubuntu-24.04 } + - { name: "macOS", os: macos-14, cmake_extra: '-DCMAKE_OSX_ARCHITECTURES="x86_64;arm64"' } + - { name: "Windows", os: windows-latest } + + steps: + - name: Checkout + uses: actions/checkout@v4 + + # ----------------------------------------------------------------------- + # Install CMake 3.28+ (Ubuntu 22.04 ships with 3.22) + # ----------------------------------------------------------------------- + - name: Install CMake + if: runner.os == 'Linux' + uses: lukka/get-cmake@latest + + # ----------------------------------------------------------------------- + # Install Qt6 (6.8 LTS across all platforms) + # ----------------------------------------------------------------------- + - name: Install Linux dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libgl1-mesa-dev libxkbcommon-dev libxcb-cursor0 + + - name: Install Qt + uses: jurplel/install-qt-action@v4 + with: + version: '6.8.*' + cache: true + + # ----------------------------------------------------------------------- + # Configure + # ----------------------------------------------------------------------- + - name: Configure CMake + run: > + cmake -S . -B build + -DCMAKE_BUILD_TYPE=${{ env.BUILD_TYPE }} + -DCMAKE_INSTALL_PREFIX=${{ github.workspace }}/install + -DLSL_FETCH_IF_MISSING=ON + ${{ matrix.config.cmake_extra }} + + # ----------------------------------------------------------------------- + # Build + # ----------------------------------------------------------------------- + - name: Build + run: cmake --build build --config ${{ env.BUILD_TYPE }} --parallel + + # ----------------------------------------------------------------------- + # Install + # ----------------------------------------------------------------------- + - name: Install + run: cmake --install build --config ${{ env.BUILD_TYPE }} + + # ----------------------------------------------------------------------- + # Test CLI + # ----------------------------------------------------------------------- + - name: Test CLI (Linux) + if: runner.os == 'Linux' + run: ./install/bin/LSLTemplateCLI --help + + - name: Test CLI (macOS) + if: runner.os == 'macOS' + run: ./install/LSLTemplateCLI --help + + - name: Test CLI (Windows) + if: runner.os == 'Windows' + run: ./install/LSLTemplateCLI.exe --help + + # ----------------------------------------------------------------------- + # Package + # ----------------------------------------------------------------------- + - name: Package + run: cpack -C ${{ env.BUILD_TYPE }} + working-directory: build + + # ----------------------------------------------------------------------- + # Upload Artifacts + # ----------------------------------------------------------------------- + - name: Upload Artifacts + uses: actions/upload-artifact@v4 + with: + name: package-${{ matrix.config.os }} + path: | + build/*.zip + build/*.tar.gz + build/*.deb + if-no-files-found: ignore + + # =========================================================================== + # macOS Signing and Notarization (Release only) + # =========================================================================== + sign-macos: + name: Sign & Notarize (macOS) + needs: build + if: github.event_name == 'release' + runs-on: macos-14 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download macOS Artifact + uses: actions/download-artifact@v4 + with: + name: package-macos-14 + path: packages + + - name: Extract Package + run: | + cd packages + tar -xzf *.tar.gz + # Move contents out of versioned subdirectory to packages/ + SUBDIR=$(ls -d LSLTemplate-*/ | head -1) + mv "$SUBDIR"/* . + rmdir "$SUBDIR" + ls -la + + # ----------------------------------------------------------------------- + # Install Apple Certificates + # ----------------------------------------------------------------------- + - name: Install Apple Certificates + env: + MACOS_CERTIFICATE: ${{ secrets.PROD_MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PWD: ${{ secrets.PROD_MACOS_CERTIFICATE_PWD }} + MACOS_CI_KEYCHAIN_PWD: ${{ secrets.PROD_MACOS_CI_KEYCHAIN_PWD }} + run: | + # Create temporary keychain + KEYCHAIN_PATH=$RUNNER_TEMP/build.keychain + security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" $KEYCHAIN_PATH + security default-keychain -s $KEYCHAIN_PATH + security set-keychain-settings -lut 21600 $KEYCHAIN_PATH + security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" $KEYCHAIN_PATH + + # Import certificate + CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12 + echo -n "$MACOS_CERTIFICATE" | base64 --decode -o $CERTIFICATE_PATH + security import $CERTIFICATE_PATH -P "$MACOS_CERTIFICATE_PWD" -k $KEYCHAIN_PATH -A -t cert -f pkcs12 + rm $CERTIFICATE_PATH + + # Allow codesign to access keychain + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MACOS_CI_KEYCHAIN_PWD" $KEYCHAIN_PATH + security list-keychain -d user -s $KEYCHAIN_PATH + + # Extract identity name and export to environment + IDENTITY=$(security find-identity -v -p codesigning $KEYCHAIN_PATH | grep "Developer ID Application" | head -1 | awk -F'"' '{print $2}') + echo "APPLE_CODE_SIGN_IDENTITY_APP=$IDENTITY" >> $GITHUB_ENV + + # ----------------------------------------------------------------------- + # Setup Notarization Credentials + # ----------------------------------------------------------------------- + - name: Setup Notarization + env: + NOTARIZATION_APPLE_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_APPLE_ID }} + NOTARIZATION_PWD: ${{ secrets.PROD_MACOS_NOTARIZATION_PWD }} + NOTARIZATION_TEAM_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_TEAM_ID }} + run: | + xcrun notarytool store-credentials "notarize-profile" \ + --apple-id "$NOTARIZATION_APPLE_ID" \ + --password "$NOTARIZATION_PWD" \ + --team-id "$NOTARIZATION_TEAM_ID" + echo "APPLE_NOTARIZE_KEYCHAIN_PROFILE=notarize-profile" >> $GITHUB_ENV + + # ----------------------------------------------------------------------- + # Sign and Notarize + # ----------------------------------------------------------------------- + - name: Sign and Notarize + env: + ENTITLEMENTS_FILE: ${{ github.workspace }}/app.entitlements + run: | + # Sign GUI app bundle (--deep handles all nested code including lsl.framework) + APP_PATH=$(find packages -name "*.app" -type d | head -1) + if [[ -n "$APP_PATH" ]]; then + ./scripts/sign_and_notarize.sh "$APP_PATH" --notarize + fi + + # Sign CLI and its bundled lsl.framework + CLI_PATH=$(find packages -name "LSLTemplateCLI" -type f | head -1) + if [[ -n "$CLI_PATH" ]]; then + CLI_DIR=$(dirname "$CLI_PATH") + # Sign framework first (dependency must be signed before dependent) + if [[ -d "$CLI_DIR/Frameworks/lsl.framework" ]]; then + codesign --force --sign "$APPLE_CODE_SIGN_IDENTITY_APP" --options runtime \ + "$CLI_DIR/Frameworks/lsl.framework" + fi + ./scripts/sign_and_notarize.sh "$CLI_PATH" --notarize + fi + + # ----------------------------------------------------------------------- + # Repackage + # ----------------------------------------------------------------------- + - name: Repackage + run: | + cd packages + + # Debug: show what we have + echo "Contents of packages directory:" + ls -la + + # Remove original unsigned package + rm -f *.tar.gz + + # Get project version from CMakeLists.txt + VERSION=$(grep -A1 'project(LSLTemplate' ../CMakeLists.txt | grep VERSION | sed 's/.*VERSION \([0-9.]*\).*/\1/') + echo "Detected version: $VERSION" + + # Create signed package with CLI and its Frameworks (universal binary) + tar -cvzf "LSLTemplate-${VERSION}-macOS_universal-signed.tar.gz" \ + LSLTemplate.app LSLTemplateCLI Frameworks + + echo "Created package:" + ls -la *.tar.gz + + - name: Upload Signed Package + uses: actions/upload-artifact@v4 + with: + name: package-macos-signed + path: packages/*-signed.tar.gz + + - name: Upload to Release + if: github.event_name == 'release' + uses: softprops/action-gh-release@v2 + with: + files: packages/*-signed.tar.gz + + # =========================================================================== + # Upload unsigned packages to release + # =========================================================================== + release: + name: Upload to Release + needs: build + if: github.event_name == 'release' + runs-on: ubuntu-latest + + steps: + - name: Download All Artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Upload to Release + uses: softprops/action-gh-release@v2 + with: + files: | + artifacts/**/*.zip + artifacts/**/*.tar.gz + artifacts/**/*.deb diff --git a/.github/workflows/cppcmake.yml b/.github/workflows/cppcmake.yml deleted file mode 100644 index c10f614..0000000 --- a/.github/workflows/cppcmake.yml +++ /dev/null @@ -1,99 +0,0 @@ -name: C/C++ CI - -on: - push: - tags: - - v*.* - pull_request: - branches: - - master - release: - types: ['created'] - -env: - LSL_RELEASE_URL: 'https://github.com/sccn/liblsl/releases/download/v1.14.0rc1' - LSL_RELEASE: '1.14.0' - -defaults: - run: - shell: bash - - -jobs: - build: - name: ${{ matrix.config.name }} - runs-on: ${{ matrix.config.os }} - strategy: - fail-fast: false - matrix: - config: - - {name: "ubuntu-20.04", os: "ubuntu-20.04"} -# - {name: "ubuntu-18.04", os: "ubuntu-18.04"} - - {name: "ubuntu-16.04", os: "ubuntu-16.04"} - - {name: "windows-x64", os: "windows-latest"} - - {name: "macOS-latest", os: "macOS-latest"} - steps: - - uses: actions/checkout@v2 - - name: download liblsl (Ubuntu) - if: startsWith(matrix.config.os, 'ubuntu-') - run: | - curl -L ${LSL_RELEASE_URL}/liblsl-${LSL_RELEASE}-$(lsb_release -sc)_amd64.deb -o liblsl.deb - sudo dpkg -i liblsl.deb - sudo apt-get install -y qtbase5-dev qtmultimedia5-dev - - - name: download liblsl (Windows) - if: matrix.config.os == 'windows-latest' - run: | - curl -L ${LSL_RELEASE_URL}/liblsl-${LSL_RELEASE}-Win_amd64.zip -o liblsl.zip - 7z x liblsl.zip -oLSL - - - name: download liblsl (macOS) - if: matrix.config.os == 'macOS-latest' - run: | - curl -L ${LSL_RELEASE_URL}/liblsl-${LSL_RELEASE}-OSX_amd64.tar.bz2 -o liblsl.tar.bz2 - mkdir LSL - tar -xvf liblsl.tar.bz2 -C LSL - brew install qt - - - name: Install Qt - if: matrix.config.os == 'windows-latest' - uses: jurplel/install-qt-action@v2 - with: - version: 5.15.1 - - - name: Configure CMake - run: | - cmake --version - test "${{ runner.os }}" == "macOS" && export CMAKE_PREFIX_PATH=/usr/local/opt/qt - cmake -S . -B build \ - -DCMAKE_BUILD_TYPE=Release \ - -DCMAKE_INSTALL_PREFIX=${PWD}/install \ - -DCPACK_PACKAGE_DIRECTORY=${PWD}/package \ - -DLSL_INSTALL_ROOT=$PWD/LSL/ \ - -DCPACK_DEBIAN_PACKAGE_SHLIBDEPS=ON - - - name: make - run: cmake --build build --config Release -j --target install - - - name: package - run: | - cmake --build build --config Release -j --target package - cmake -E remove_directory package/_CPack_Packages - - - name: Upload Artifacts - uses: actions/upload-artifact@v2 - with: - name: pkg-${{ matrix.config.name }} - path: package - - name: upload to release page - if: github.event_name == 'release' - env: - TOKEN: "token ${{ secrets.GITHUB_TOKEN }}" - UPLOAD_URL: ${{ github.event.release.upload_url }} - run: | - UPLOAD_URL=${UPLOAD_URL%\{*} # remove "{name,label}" suffix - for pkg in package/*.*; do - NAME=$(basename $pkg) - MIME=$(file --mime-type $pkg|cut -d ' ' -f2) - curl -X POST -H "Accept: application/vnd.github.v3+json" -H "Authorization: $TOKEN" -H "Content-Type: $MIME" --data-binary @$pkg $UPLOAD_URL?name=$NAME - done diff --git a/.gitignore b/.gitignore index 9b4f87d..3cf638c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ ui_*.h /CMakeSettings.json /out/ .DS_Store +.idea diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 55e65c7..0000000 --- a/.travis.yml +++ /dev/null @@ -1,29 +0,0 @@ -language: cpp -dist: xenial -compiler: clang -env: - LSLDIST_URL="https://github.com/sccn/liblsl/releases/download" - UBUNTU_DIST="xenial" - LSL_VERSION="1.13.0" - - CMakeArgs="" -matrix: - include: - - os: osx -#: use the default image, if an other image version is needed check -#: https://docs.travis-ci.com/user/reference/osx/ -# osx_image: xcode10.1 - before_script: - - travis_retry wget ${LSLDIST_URL}/${LSL_VERSION}/liblsl-1.13.0-OSX64.tar.bz2 - - mkdir LSL; tar -xf *.tar.bz2 -C LSL - - export LSL_INSTALL_ROOT=LSL - - brew install qt - - export CMAKE_PREFIX_PATH=/usr/local/opt/qt/ -script: -- mkdir -p build/install -- cd build -- cmake --version -- cmake -DLSL_INSTALL_ROOT=${LSL_INSTALL_ROOT} $CMakeArgs ../ -- cmake --build . --config Release --target install -- if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then for app in *.app; do ${CMAKE_PREFIX_PATH}/bin/macdeployqt ${app} -dmg; mv "${app%.app}.dmg" "${app%.app}-${LSL_VERSION}-OSX64.dmg}"; done; fi -- if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then cpack; fi -- cd .. diff --git a/AppTemplate_cpp_qt.cfg b/AppTemplate_cpp_qt.cfg deleted file mode 100644 index 696f28f..0000000 --- a/AppTemplate_cpp_qt.cfg +++ /dev/null @@ -1,3 +0,0 @@ -[BPG] -device=0 -name=Default name diff --git a/CMakeLists.txt b/CMakeLists.txt index 6d1a1a9..df51702 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,118 +1,252 @@ -# CMakeLists.txt contains the build configuration that can be used to build -# your App without any IDE or to generate project files for your specific IDE. -# First, set the minimum required CMake version. -# Here, [CMake 3.10](https://cmake.org/cmake/help/latest/release/3.10.html) -# was chosen. Before setting this to a higher version, please check -# if it has arrived in -# debian stable https://packages.debian.org/stable/cmake -# and Ubuntu LTS https://packages.ubuntu.com/bionic/cmake -cmake_minimum_required(VERSION 3.10) - -project(AppTemplate_cpp_qt - LANGUAGES C CXX - VERSION 1.13.0 - ) - -# also look for CMake modules in the cmake subfolder -list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") - -# Find an installed liblsl in paths set by the user (LSL_INSTALL_ROOT) -# and some default paths -find_package(LSL REQUIRED - HINTS ${LSL_INSTALL_ROOT} - "${CMAKE_CURRENT_LIST_DIR}/../../LSL/liblsl/build/install" - "${CMAKE_CURRENT_LIST_DIR}/../../LSL/liblsl/out/install/x64-Release" - PATH_SUFFIXES share/LSL) -get_filename_component(LSL_PATH ${LSL_CONFIG} DIRECTORY) -message(STATUS "Found LSL lib in ${LSL_PATH}") -LSLAPP_Setup_Boilerplate() - -#: Then we can simply use find_package. This enables us to later link LSL::lsl -#: You can read more about [find_package](https://cmake.org/cmake/help/latest/command/find_package.html). - -#: ## Finding vendor SDKs -#: -#: More often than not, vendors offer a library file (`.so`, `.dylib`, `.dll`) -#: and a header file (`.h`). CMake can search for these files and create an -#: imported target, so all compilers get the correct settings for finding and linking them. -#: -#: Before you begin writing cmake code for the library, search the internet -#: (especially GitHub) for Find.cmake or Find.cmake -#: Be sure to look through the resources here: https://github.com/onqtam/awesome-cmake#modules -#: If found, then drop that file in your app repository's cmake folder. -#: Be sure to read that cmake file for further instructions, but typically -#: one would be required to set Vendor_ROOT_DIR to tell the package finder -#: where to look. -#: Then one could use `find_package(Vendor REQUIRED)` to import its variables. -#: -#: Note that some (older) find modules will set the following variables: -#: Vendor_INCLUDE_DIRS -- Use this with target_include_dirs -#: Vendor_LIBRARIES -- Use this with target_link_libraries -#: -#: Whereas newer find modules will provide an imported target: -#: (https://cmake.org/cmake/help/latest/manual/cmake-buildsystem.7.html#imported-targets) -#: Vendor::Device -- Use this with target_link_libraries. - -find_package(Vendor QUIET) - -# Find our custom package. But we set this to QUIET because it will fail. -# You should set yours to REQUIRED - -#: ## Using Qt -#: -#: Qt is a popular-enough library that cmake already knows how to find it. -#: -#: Nevertheless, it is often necessary in Windows to give cmake a hint where to -#: find it by adding the following to your cmake invocation: -#: -DQt5_Dir="C:/Qt///lib/cmake/Qt5" -#: Where is 5.something and is something like msvc2017_64 -# If you are on MacOS and you installed Qt5 via homebrew, then use the following: -# -DQt5_DIR=$(brew --prefix qt5)/lib/cmake/Qt5 -#: -#: Then, to import Qt, just call `find_package` with the components your app uses -#: (usually just `Widgets`). - -#: Needed for ui_XY.h files -set(CMAKE_INCLUDE_CURRENT_DIR ON) -#: Enable automatic compilation of .cpp->.moc, xy.ui->ui_xy.h and resource files -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - -find_package(Qt5 REQUIRED COMPONENTS Widgets) - -# Native `std::thread`s still require a platform thread library. -# CMake can find and link to it with the `Threads` package (link your -# executable with `Threads::Threads` afterwards). -find_package(Threads REQUIRED) - -# Add executable targets, the default target has the same name as the project - -add_executable(${PROJECT_NAME} MACOSX_BUNDLE WIN32 - main.cpp - mainwindow.cpp - mainwindow.hpp - mainwindow.ui - reader.hpp - reader.cpp -) - -target_link_libraries(${PROJECT_NAME} - PRIVATE - Qt5::Widgets - Threads::Threads - LSL::lsl -# Vendor::DeviceModule +# ============================================================================= +# LSL Application Template - CMakeLists.txt +# ============================================================================= +# This is the reference template for building Lab Streaming Layer applications. +# It demonstrates: +# - Automatic liblsl fetching via FetchContent +# - CLI and GUI application separation with shared core library +# - Qt6 integration +# - Cross-platform packaging with CPack +# - macOS code signing with entitlements +# +# Copy this template to start a new LSL application. +# ============================================================================= + +cmake_minimum_required(VERSION 3.28) + +# CMP0177: install() DESTINATION paths are normalized (CMake 3.31+) +if(POLICY CMP0177) + cmake_policy(SET CMP0177 NEW) +endif() + +project(LSLTemplate + VERSION 2.0.0 + DESCRIPTION "LSL Application Template" + HOMEPAGE_URL "https://github.com/labstreaminglayer/AppTemplate_cpp_qt" + LANGUAGES CXX ) -target_compile_features(${PROJECT_NAME} PRIVATE cxx_std_14) - -# If using QML, uncomment the following line and update the path to the qml directory: -# set_target_properties(${PROJECT_NAME} PROPERTIES qml_directory "${CMAKE_CURRENT_LIST_DIR}/src/qml") +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +# ============================================================================= +# Build Options +# ============================================================================= +option(LSLTEMPLATE_BUILD_GUI "Build the GUI application (requires Qt6)" ON) +option(LSLTEMPLATE_BUILD_CLI "Build the CLI application" ON) + +# ============================================================================= +# liblsl Dependency +# ============================================================================= +# By default, liblsl is fetched automatically from GitHub. +# To use a pre-installed liblsl, set LSL_INSTALL_ROOT. +set(LSL_INSTALL_ROOT "" CACHE PATH "Path to installed liblsl (optional)") +set(LSL_FETCH_REF "v1.17.4" CACHE STRING "liblsl version to fetch from GitHub") + +if(LSL_INSTALL_ROOT) + # Use pre-installed liblsl + find_package(LSL REQUIRED + HINTS "${LSL_INSTALL_ROOT}" + PATH_SUFFIXES share/LSL lib/cmake/LSL Frameworks/lsl.framework/Resources/CMake + ) + message(STATUS "Using installed liblsl: ${LSL_DIR}") + include("${LSL_DIR}/LSLCMake.cmake") +else() + # Fetch liblsl from GitHub + message(STATUS "Fetching liblsl ${LSL_FETCH_REF} from GitHub...") + include(FetchContent) + set(LSL_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) + set(LSL_BUILD_TESTING OFF CACHE BOOL "" FORCE) + FetchContent_Declare(liblsl + GIT_REPOSITORY https://github.com/sccn/liblsl.git + GIT_TAG ${LSL_FETCH_REF} + GIT_SHALLOW ON + EXCLUDE_FROM_ALL + ) + FetchContent_MakeAvailable(liblsl) + if(NOT TARGET LSL::lsl) + add_library(LSL::lsl ALIAS lsl) + endif() + include("${liblsl_SOURCE_DIR}/cmake/LSLCMake.cmake") +endif() + +# ============================================================================= +# Qt6 (for GUI build only) +# ============================================================================= +if(LSLTEMPLATE_BUILD_GUI) + set(CMAKE_AUTOMOC ON) + set(CMAKE_AUTORCC ON) + set(CMAKE_AUTOUIC ON) + + find_package(Qt6 REQUIRED COMPONENTS Core Widgets) + # Note: targeting lastest LTS release according to https://wiki.qt.io/QtReleasing + + # macOS: Work around deprecated AGL framework issue in some Qt6 builds + if(APPLE AND TARGET WrapOpenGL::WrapOpenGL) + get_target_property(_wrap_gl_libs WrapOpenGL::WrapOpenGL INTERFACE_LINK_LIBRARIES) + if(_wrap_gl_libs) + list(FILTER _wrap_gl_libs EXCLUDE REGEX ".*AGL.*") + set_target_properties(WrapOpenGL::WrapOpenGL PROPERTIES INTERFACE_LINK_LIBRARIES "${_wrap_gl_libs}") + endif() + endif() +endif() + +# ============================================================================= +# Common Dependencies +# ============================================================================= +find_package(Threads REQUIRED) -# Setting up deployment (let CPack generate a zip/dmg file) -installLSLApp(${PROJECT_NAME}) -installLSLAuxFiles(${PROJECT_NAME} - ${PROJECT_NAME}.cfg -) -LSLGenerateCPackConfig() +# ============================================================================= +# RPATH Configuration (must be set before targets are created) +# ============================================================================= +LSL_configure_rpath() + +# ============================================================================= +# Targets +# ============================================================================= + +# Core library (Qt-independent, shared between CLI and GUI) +add_subdirectory(src/core) + +# CLI application +if(LSLTEMPLATE_BUILD_CLI) + add_subdirectory(src/cli) +endif() + +# GUI application +if(LSLTEMPLATE_BUILD_GUI) + add_subdirectory(src/gui) +endif() + +# ============================================================================= +# Installation +# ============================================================================= +include(GNUInstallDirs) + +# Platform-specific install directories +if(WIN32) + set(INSTALL_BINDIR ".") + set(INSTALL_LIBDIR ".") + set(INSTALL_DATADIR ".") +elseif(APPLE) + set(INSTALL_BINDIR ".") + set(INSTALL_LIBDIR ".") + set(INSTALL_DATADIR ".") +else() + # Linux: use standard FHS layout + set(INSTALL_BINDIR "${CMAKE_INSTALL_BINDIR}") + set(INSTALL_LIBDIR "${CMAKE_INSTALL_LIBDIR}") + set(INSTALL_DATADIR "${CMAKE_INSTALL_DATADIR}/${PROJECT_NAME}") +endif() + +# Install CLI +if(LSLTEMPLATE_BUILD_CLI) + install(TARGETS ${PROJECT_NAME}CLI + RUNTIME DESTINATION "${INSTALL_BINDIR}" + ) +endif() + +# Install GUI +if(LSLTEMPLATE_BUILD_GUI) + install(TARGETS ${PROJECT_NAME} + RUNTIME DESTINATION "${INSTALL_BINDIR}" + BUNDLE DESTINATION "${INSTALL_BINDIR}" + ) +endif() + +# Install config file +set(_config_dest "${INSTALL_DATADIR}") +if(APPLE AND LSLTEMPLATE_BUILD_GUI) + set(_config_dest "${INSTALL_BINDIR}/${PROJECT_NAME}.app/Contents/MacOS") +endif() +install(FILES ${PROJECT_NAME}.cfg DESTINATION "${_config_dest}") + +# ============================================================================= +# Bundle liblsl with the application +# ============================================================================= +if(APPLE) + # macOS: Install framework to GUI bundle and/or CLI Frameworks directory + if(LSLTEMPLATE_BUILD_GUI) + LSL_install_liblsl( + FRAMEWORK_DESTINATION "${INSTALL_BINDIR}/${PROJECT_NAME}.app/Contents/Frameworks" + ) + endif() + if(LSLTEMPLATE_BUILD_CLI) + LSL_install_liblsl(FRAMEWORK_DESTINATION "Frameworks") + endif() +else() + # Windows/Linux: Single install location for all targets + LSL_install_liblsl(DESTINATION "${INSTALL_LIBDIR}") +endif() + +# ============================================================================= +# Qt Deployment +# ============================================================================= +if(LSLTEMPLATE_BUILD_GUI) + LSL_deploy_qt(TARGET "${PROJECT_NAME}" DESTINATION "${INSTALL_BINDIR}") +endif() + +# ============================================================================= +# MinGW Runtime Deployment +# ============================================================================= +LSL_install_mingw_runtime(DESTINATION "${INSTALL_BINDIR}") + +# ============================================================================= +# macOS: Code Sign +# ============================================================================= +if(APPLE) + set(MACOSX_BUNDLE_GUI_IDENTIFIER "org.labstreaminglayer.${PROJECT_NAME}") + set(MACOSX_BUNDLE_BUNDLE_VERSION "${PROJECT_VERSION}") + set(MACOSX_BUNDLE_SHORT_VERSION_STRING "${PROJECT_VERSION}") + + if(LSLTEMPLATE_BUILD_GUI) + LSL_codesign( + TARGET "${PROJECT_NAME}" + DESTINATION "${INSTALL_BINDIR}" + ENTITLEMENTS "${CMAKE_CURRENT_SOURCE_DIR}/app.entitlements" + BUNDLE + ) + endif() + + if(LSLTEMPLATE_BUILD_CLI) + LSL_codesign( + TARGET "${PROJECT_NAME}CLI" + DESTINATION "${INSTALL_BINDIR}" + ENTITLEMENTS "${CMAKE_CURRENT_SOURCE_DIR}/app.entitlements" + FRAMEWORK "Frameworks/lsl.framework" + ) + endif() +endif() + +# ============================================================================= +# CPack Configuration +# ============================================================================= +LSL_get_target_arch() +LSL_get_os_name() + +set(CPACK_PACKAGE_NAME "${PROJECT_NAME}") +set(CPACK_PACKAGE_VERSION "${PROJECT_VERSION}") +set(CPACK_PACKAGE_VENDOR "Labstreaminglayer") +set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "${PROJECT_DESCRIPTION}") +set(CPACK_PACKAGE_HOMEPAGE_URL "${PROJECT_HOMEPAGE_URL}") +set(CPACK_PACKAGE_FILE_NAME "${PROJECT_NAME}-${PROJECT_VERSION}-${LSL_OS}_${LSL_ARCH}") +set(CPACK_STRIP_FILES ON) + +if(WIN32) + set(CPACK_GENERATOR ZIP) +elseif(APPLE) + set(CPACK_GENERATOR TGZ) +else() + set(CPACK_GENERATOR DEB TGZ) + set(CPACK_DEBIAN_PACKAGE_MAINTAINER "LabStreamingLayer Developers") + set(CPACK_DEBIAN_PACKAGE_SECTION "science") + set(CPACK_DEBIAN_FILE_NAME "${CPACK_PACKAGE_FILE_NAME}.deb") + # Note: shlibdeps doesn't work well with bundled liblsl + set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS OFF) + if(LSLTEMPLATE_BUILD_GUI) + set(CPACK_DEBIAN_PACKAGE_DEPENDS "libqt6widgets6") + endif() +endif() + +include(CPack) diff --git a/LICENSE.txt b/LICENSE similarity index 100% rename from LICENSE.txt rename to LICENSE diff --git a/LSLTemplate.cfg b/LSLTemplate.cfg new file mode 100644 index 0000000..79b8ae7 --- /dev/null +++ b/LSLTemplate.cfg @@ -0,0 +1,11 @@ +# LSL Template Configuration +# This file configures the LSL stream parameters + +[Stream] +name=LSLTemplate +type=Counter +channels=1 +sample_rate=10 + +[Device] +device_param=0 diff --git a/README.md b/README.md index 70ee68c..81f6d6f 100644 --- a/README.md +++ b/README.md @@ -1,57 +1,194 @@ -Click on the green "Use this template" button on GitHub to generate a repository from this template. -Then edit this README.md to delete this text and replace the below text for your own app. +# LSL Application Template -# Application Description +This is the reference template for building Lab Streaming Layer (LSL) applications in C++ with Qt6. Use this as a starting point for creating new LSL-compatible applications. -Use this section to describe your application. +## Features -The template app doesn't send any useful data, but provides a starting point to develop your own apps. -This app is written in C++ and uses the Qt framework. +- **Modern CMake** (3.28+) with clean, documented structure +- **4-tier liblsl discovery**: source, install_root, system, FetchContent +- **CLI and GUI separation** with shared core library +- **Qt6** for the GUI (with Qt5 intentionally dropped for simplicity) +- **Cross-platform**: Linux, macOS, Windows +- **macOS code signing** with entitlements for network capabilities +- **Automated CI/CD** via GitHub Actions -The important source files are listed below: +## Project Structure -- `main.cpp` is the entry point -- `mainwindow.cpp` contains the UI code and code to access the recording device -- `mainwindow.hpp` is the corresponding header +``` +LSLTemplate/ +├── CMakeLists.txt # Root build configuration +├── app.entitlements # macOS network capabilities +├── LSLTemplate.cfg # Default configuration file +├── src/ +│ ├── core/ # Qt-independent core library +│ │ ├── include/lsltemplate/ +│ │ │ ├── Device.hpp # Device interface +│ │ │ ├── LSLOutlet.hpp # LSL outlet wrapper +│ │ │ ├── Config.hpp # Configuration management +│ │ │ └── StreamThread.hpp # Background streaming +│ │ └── src/ +│ ├── cli/ # Command-line application +│ │ └── main.cpp +│ └── gui/ # Qt6 GUI application +│ ├── MainWindow.hpp/cpp +│ ├── MainWindow.ui +│ └── main.cpp +├── scripts/ +│ └── sign_and_notarize.sh # macOS signing script +└── .github/workflows/ + └── build.yml # CI/CD workflow +``` -## Dependencies +## Building -Use this section to describe what libraries/tools are required to RUN this application. -Build dependencies should be listed in the build instructions elsewhere. +### Prerequisites -For example, if the data provider uses a server/client architecture, -then the user will need to download, install, and run the server. +- CMake 3.28 or later +- C++20 compatible compiler +- Qt6.8 (for GUI build) +- liblsl (optional - will be fetched automatically if not found) -If the application requires a device library (typically a DLL on Windows, sometimes shipped in an SDK), -then instruct the user on how to obtain the library and how to install it on their system. -For example, "You must obtain the device_client.dll from the manufacturer. Copy that dll file into the -same folder as this application executable." +### Installing Qt 6.8 on Ubuntu -This template application has no dependencies. +Ubuntu's default repositories don't include Qt 6.8. Use [aqtinstall](https://github.com/miurahr/aqtinstall) to install it: -## Download +```bash +# Install aqtinstall +pip install aqtinstall -Use this section to describe how/where to download prebuilt applications. -Typically this means using the GitHub repository release page. +# Install Qt 6.8 (adjust version as needed) +aqt install-qt linux desktop 6.8.3 gcc_64 -O ~/Qt + +# Set environment for CMake to find Qt +export CMAKE_PREFIX_PATH=~/Qt/6.8.3/gcc_64 + +# Install system dependencies +sudo apt-get install libgl1-mesa-dev libxkbcommon-dev libxcb-cursor0 +``` + +To run the GUI application, ensure Qt libraries are in your library path: + +```bash +export LD_LIBRARY_PATH=~/Qt/6.8.3/gcc_64/lib:$LD_LIBRARY_PATH +``` + +Alternatively, build CLI-only with `-DLSLTEMPLATE_BUILD_GUI=OFF` to avoid the Qt dependency. + +### Quick Start + +```bash +# Clone and build +git clone https://github.com/labstreaminglayer/AppTemplate_cpp_qt.git +cd AppTemplate_cpp_qt + +# Configure (liblsl will be fetched automatically) +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release # Build +cmake --build build --parallel + +# Install +cmake --install build --prefix build/install + +# Package +cd build && cpack +``` + +### Build Options + +| Option | Default | Description | +|--------|---------|-------------| +| `LSLTEMPLATE_BUILD_GUI` | ON | Build the GUI application | +| `LSLTEMPLATE_BUILD_CLI` | ON | Build the CLI application | +| `LSL_FETCH_IF_MISSING` | ON | Auto-fetch liblsl from GitHub | +| `LSL_FETCH_REF` | (see CMakeLists.txt) | liblsl git ref to fetch (tag, branch, or commit) | +| `LSL_SOURCE_DIR` | - | Path to liblsl source (for development) | +| `LSL_INSTALL_ROOT` | - | Path to installed liblsl | + +### liblsl Discovery Priority + +The build system searches for liblsl in this order: + +1. **LSL_SOURCE_DIR** - Build from local source (for parallel liblsl development) +2. **LSL_INSTALL_ROOT** - Explicit installation path +3. **System** - Standard CMake search paths +4. **FetchContent** - Automatic download from GitHub + +### CLI-Only Build + +For headless systems or servers: + +```bash +cmake -S . -B build -DLSLTEMPLATE_BUILD_GUI=OFF +cmake --build build +``` + +### Building with Local liblsl + +For parallel development with liblsl: + +```bash +cmake -S . -B build -DLSL_SOURCE_DIR=/path/to/liblsl +``` + +## Usage + +### GUI Application + +```bash +./LSLTemplate # Use default config +./LSLTemplate myconfig.cfg # Use custom config +``` + +### CLI Application + +```bash +./LSLTemplateCLI --help +./LSLTemplateCLI --name MyStream --rate 256 --channels 8 +./LSLTemplateCLI --config myconfig.cfg +``` + +## Customizing for Your Device + +1. **Fork/copy this template** +2. **Rename the project** in `CMakeLists.txt` +3. **Implement your device class** by deriving from `IDevice` in `src/core/include/lsltemplate/Device.hpp` +4. **Update the GUI** for device-specific settings in `src/gui/MainWindow.ui` +5. **Update configuration** fields in `src/core/include/lsltemplate/Config.hpp` + +## macOS Code Signing + +For local development, the build automatically applies ad-hoc signing with network entitlements. This allows the app to use LSL's multicast discovery. + +For distribution, use the signing script: + +```bash +# Sign only +./scripts/sign_and_notarize.sh build/install/LSLTemplate.app -This application can be built following general -[LSL Application build instructions](https://labstreaminglayer.readthedocs.io/dev/app_build.html). +# Sign and notarize +export APPLE_CODE_SIGN_IDENTITY_APP="Developer ID Application: Your Name" +export APPLE_NOTARIZE_KEYCHAIN_PROFILE="your-profile" +./scripts/sign_and_notarize.sh build/install/LSLTemplate.app --notarize +``` -Use this section to provide additional specific build instructions. -Alternatively, provide required information in a separate BUILD.md. +## GitHub Actions Secrets -# License +For automated signing and notarization, the workflow expects these secrets from the `labstreaminglayer` organization: -Since using this app as a starting point is actively encouraged, it is licensed -under the [MIT](https://choosealicense.com/licenses/mit/) license. +| Secret | Description | +|--------|-------------| +| `PROD_MACOS_CERTIFICATE` | Base64-encoded Developer ID Application certificate (.p12) | +| `PROD_MACOS_CERTIFICATE_PWD` | Certificate password | +| `PROD_MACOS_CI_KEYCHAIN_PWD` | Password for temporary CI keychain | +| `PROD_MACOS_NOTARIZATION_APPLE_ID` | Apple ID email for notarization | +| `PROD_MACOS_NOTARIZATION_PWD` | App-specific password for Apple ID | +| `PROD_MACOS_NOTARIZATION_TEAM_ID` | Apple Developer Team ID | -If you want others to share the code to derivatives of your app, you should -consider licensing your app under a less permissive license like the -[MPL](https://choosealicense.com/licenses/mpl-2.0/) or -[GPL](https://choosealicense.com/licenses/gpl-3.0/). +**Important:** These organization secrets must be shared with your repository. In GitHub: +1. Go to Organization Settings → Secrets and variables → Actions +2. For each secret, click to edit and under "Repository access" select the repositories that need access -Even in case you want to keep the MIT license, change the `LICENSE.txt` to -reflect that you're the copyright holder. +## License +MIT License - see LICENSE diff --git a/app.entitlements b/app.entitlements new file mode 100644 index 0000000..99d0921 --- /dev/null +++ b/app.entitlements @@ -0,0 +1,17 @@ + + + + + + com.apple.security.network.client + + + + com.apple.security.network.server + + + + com.apple.security.network.multicast + + + diff --git a/cmake/FindVendor.cmake b/cmake/FindVendor.cmake deleted file mode 100644 index bcfe452..0000000 --- a/cmake/FindVendor.cmake +++ /dev/null @@ -1,92 +0,0 @@ -# Copyright Chadwick Boulay and Tristan Stenner -# Distributed under the MIT License. -# See https://opensource.org/licenses/MIT - -#[[ -FindVendor ----------- - -This file is an example of a cmake find-module. -https://cmake.org/cmake/help/latest/manual/cmake-developer.7.html#id4 - -It attempts to find a fictitious library that does not exist and thus -will always fail. - -It would be good to put a link to your device . - -Import Targets -^^^^^^^^^^^^^^ - -This module provides the following imported targets, if found (i.e., never): - -``Vendor::DeviceModule`` - The library for Vendor's Device SDK. - -Cache Variables -^^^^^^^^^^^^^^^^ - -The following cache variables may also be set to assist/control the operation of this module: - - ``Vendor_ROOT_DIR`` - The root to search for Vendor's SDK. - -The following cache variables are set if a GSL target is not already found: - - ``Vendor_INCLUDE_DIRS`` - The directory to add to your include path to be able to #include -]] - -# Se up cache variables -set(Vendor_ROOT_DIR - "${Vendor_ROOT_DIR}" - CACHE PATH "The root to search for Vendor SDK" -) - -# find the vendorsdk.h header in the Vendor_ROOT_DIR/include -find_path(Vendor_INCLUDE_DIR - name "vendorsdk.h" - PATHS ${Vendor_ROOT_DIR} - PATH_SUFFIXES include -) - -# find a shared library called e.g. vendorsdk.lib or libvendorsdk.so -# in Vendor_ROOT_DIR/lib -find_library(Vendor_LIBRARY - name vendorsdk - PATHS ${Vendor_ROOT_DIR} - PATH_SUFFIXES lib -) - -# Handle the _INCLUDE_DIR and _LIBRARY arguments. If found then Vendor_FOUND will be set to TRUE. -include(FindPackageHandleStandardArgs) -find_package_handle_standard_args(Vendor REQUIRED_VARS Vendor_INCLUDE_DIR Vendor_LIBRARY) - -if(Vendor_FOUND) - # For backwards-compatibility with older style, set a couple Vendor_ variables in case some users expect it. - set(Vendor_INCLUDE_DIRS ${Vendor_INCLUDE_DIR}) - set(Vendor_LIBRARIES ${Vendor_LIBRARY}) - - if(NOT TARGET Vendor::Device) - # Declare the target - add_library(Vendor::Device SHARED IMPORTED) - #Use INTERFACE for header-only libs. - # https://cmake.org/cmake/help/latest/command/add_library.html#id3 - - # On Windows, the IMPORTED_LIB is the .lib, and the IMPORTED_LOCATION (below) is the .dll - get_filename_component(libext vendorsdk_SDK_LIB EXT) - if(libext STREQUAL ".lib") - set_target_properties(Vendor::Device PROPERTIES - IMPORTED_IMPLIB ${Vendor_LIBRARY}) - string(REPLACE ".lib" ".dll" Vendor_LIBRARY ${Vendor_LIBRARY}) - endif() - - set_target_properties(Vendor::Device PROPERTIES - INTERFACE_INCLUDE_DIRECTORIES "${Vendor_INCLUDE_DIR}" - IMPORTED_LOCATION ${Vendor_LIBRARY} - IMPORTED_LINK_INTERFACE_LANGUAGES "C" - INTERFACE_COMPILE_FEATURES cxx_std_14 - ) - - endif(NOT TARGET Vendor::Device) -endif(Vendor_FOUND) -mark_as_advanced(Vendor_ROOT_DIR Vendor_INCLUDE_DIR Vendor_LIBRARY) diff --git a/main.cpp b/main.cpp deleted file mode 100644 index 943b0c5..0000000 --- a/main.cpp +++ /dev/null @@ -1,12 +0,0 @@ -/*: # main.cpp ([source](../appskeleton/main.cpp)) - * main.cpp is the entry point to the application. It parses the command line - * arguments and lets Qt handle the rest. */ -#include "mainwindow.hpp" -#include - -int main(int argc, char *argv[]) { - QApplication a(argc, argv); - MainWindow w(nullptr, argc > 1 ? argv[1] : nullptr); - w.show(); - return a.exec(); -} diff --git a/mainwindow.cpp b/mainwindow.cpp deleted file mode 100644 index 4bd02f0..0000000 --- a/mainwindow.cpp +++ /dev/null @@ -1,204 +0,0 @@ -/* mainwindow.cpp contains the implementations of everything your window does. */ -/*: The next two includes are our own headers that define the interfaces for - * our window class and the recording device */ -#include "mainwindow.hpp" -#include "reader.hpp" -/*: `ui_mainwindow.h` is automatically generated from `mainwindow.ui`. - * It defines the `Ui::MainWindow` class with the widgets as members. */ -#include "ui_mainwindow.h" - - -//: Qt headers -#include -#include -#include -#include -#include -#include -//: standard C++ headers -#include -#include -#include -#include - - -/*: The constructor mainly sets up the `Ui::MainWindow` class and creates the - * connections between signals (e.g. 'button X was clicked') and slots - * (e.g. 'close the window') or functions (e.g. 'save the configuration') - */ -MainWindow::MainWindow(QWidget *parent, const char *config_file) - : QMainWindow(parent), ui(new Ui::MainWindow) { - ui->setupUi(this); - /*: C++11 has anonymous functions [lambdas](http://en.cppreference.com/w/cpp/language/lambda) - * that can get defined once where they are needed. They are mainly useful - * for simple actions as a result of an event */ - connect(ui->actionLoad_Configuration, &QAction::triggered, [this]() { - load_config(QFileDialog::getOpenFileName( - this, "Load Configuration File", "", "Configuration Files (*.cfg)")); - }); - connect(ui->actionSave_Configuration, &QAction::triggered, [this]() { - save_config(QFileDialog::getSaveFileName( - this, "Save Configuration File", "", "Configuration Files (*.cfg)")); - }); - connect(ui->actionQuit, &QAction::triggered, this, &MainWindow::close); - connect(ui->actionAbout, &QAction::triggered, [this]() { - QString infostr = QStringLiteral("LSL library version: ") + - QString::number(lsl::library_version()) + - "\nLSL library info:" + lsl::library_info(); - QMessageBox::about(this, "About this app", infostr); - }); - connect(ui->linkButton, &QPushButton::clicked, this, &MainWindow::toggleRecording); - - - //: At the end of the constructor, we load the supplied config file or find it - //: in one of the default paths - QString cfgfilepath = find_config_file(config_file); - load_config(cfgfilepath); -} - - -/*: ## Loading / saving the configuration - * Most apps have some kind of configuration parameters, e.g. which device to - * use, how to name the channels, ... - * - * The settings are mostly saved in `.ini` files. Qt ships with a parser and - * writer for these kinds of files ([QSettings](http://doc.qt.io/qt-5/qsettings.html)). - * The general format is `settings.value("key", "default value").toType()`*/ -void MainWindow::load_config(const QString &filename) { - QSettings settings(filename, QSettings::Format::IniFormat); - ui->input_name->setText(settings.value("BPG/name", "Default name").toString()); - ui->input_device->setValue(settings.value("BPG/device", 0).toInt()); -} - - -//: Save function, same as above -void MainWindow::save_config(const QString &filename) { - QSettings settings(filename, QSettings::Format::IniFormat); - settings.beginGroup("BPG"); - settings.setValue("name", ui->input_name->text()); - settings.setValue("device", ui->input_device->value()); - settings.sync(); -} - - -/*: ## The close event - * to avoid accidentally closing the window, we can ignore the close event - * when there's a recording in progress */ -void MainWindow::closeEvent(QCloseEvent *ev) { - if (reader) { - QMessageBox::warning(this, "Recording still running", "Can't quit while recording"); - ev->ignore(); - } -} - - -/*: ## The recording thread - * - * We run the recording in a separate thread to keep the UI responsive. - * The recording thread function generally gets called with - * - * - the configuration parameters (here `name`, `device_param`) - * - a reference to an `std::atomic` - * - * the shutdown flag indicates that the recording should stop as soon as possible */ -void recording_thread_function( - std::string name, int32_t device_param, std::atomic &shutdown) { - //: create an outlet and a send buffer - lsl::stream_info info(name, "Counter", 1, 10, lsl::cf_int32); - lsl::stream_outlet outlet(info); - std::vector buffer(1, 20); - - - //: Connect to the device, depending on the SDK you might also have to - //: create a device object and connect to it via a method call - Reader device(device_param); - - - /*: the recording loop. The logic here is as follows: - * - acquire some data - * - copy it to the buffer (here in one step) - * - push it to the outlet - * - do that again unless the shutdown flag was set in the meantime */ - while (!shutdown) { - // "Acquire data" - if (device.getData(buffer)) { - outlet.push_chunk_multiplexed(buffer); - } else { - // Acquisition was unsuccessful? -> Quit - break; - } - } -} - - -//: ## Toggling the recording state -//: Our record button has two functions: start a recording and -//: stop it if a recording is running already. -void MainWindow::toggleRecording() { - /*: the `std::unique_ptr` evaluates to false if it doesn't point to an object, - * so we need to start a recording. - * First, we load the configuration from the UI fields, set the shutdown flag - * to false so the recording thread doesn't quit immediately and create the - * recording thread. */ - if (!reader) { - // read the configuration from the UI fields - std::string name = ui->input_name->text().toStdString(); - int32_t device_param = (int32_t)ui->input_device->value(); - - shutdown = false; - /*: `make_unique` allocates a new `std::thread` with our recording - * thread function as first parameters and all parameters to the - * function after that. - * Reference parameters have to be wrapped as a `std::ref`. */ - reader = std::make_unique( - &recording_thread_function, name, device_param, std::ref(shutdown)); - ui->linkButton->setText("Unlink"); - } else { - /*: Shutting a thread involves 3 things: - * - setting the shutdown flag so the thread doesn't continue acquiring data - * - wait for the thread to complete (`join()`) - * - delete the thread object and set the variable to nullptr */ - shutdown = true; - reader->join(); - reader.reset(); - ui->linkButton->setText("Link"); - } -} - -/** - * Find a config file to load. This is (in descending order or preference): - * - a file supplied on the command line - * - [executablename].cfg in one the the following folders: - * - the current working directory - * - the default config folder, e.g. '~/Library/Preferences' on OS X - * - the executable folder - * @param filename Optional file name supplied e.g. as command line parameter - * @return Path to a found config file - */ -QString MainWindow::find_config_file(const char *filename) { - if (filename) { - QString qfilename(filename); - if (!QFileInfo::exists(qfilename)) - QMessageBox(QMessageBox::Warning, "Config file not found", - QStringLiteral("The file '%1' doesn't exist").arg(qfilename), QMessageBox::Ok, - this); - else - return qfilename; - } - QFileInfo exeInfo(QCoreApplication::applicationFilePath()); - QString defaultCfgFilename(exeInfo.completeBaseName() + ".cfg"); - QStringList cfgpaths; - cfgpaths << QDir::currentPath() - << QStandardPaths::standardLocations(QStandardPaths::ConfigLocation) << exeInfo.path(); - for (auto path : cfgpaths) { - QString cfgfilepath = path + QDir::separator() + defaultCfgFilename; - if (QFileInfo::exists(cfgfilepath)) return cfgfilepath; - } - QMessageBox(QMessageBox::Warning, "No config file not found", - QStringLiteral("No default config file could be found"), QMessageBox::Ok, this); - return ""; -} - - -//: Tell the compiler to put the default destructor in this object file -MainWindow::~MainWindow() noexcept = default; diff --git a/mainwindow.hpp b/mainwindow.hpp deleted file mode 100644 index 1f817fd..0000000 --- a/mainwindow.hpp +++ /dev/null @@ -1,40 +0,0 @@ -#ifndef MAINWINDOW_H -#define MAINWINDOW_H - -#include -#include -#include //for std::unique_ptr -#include - - -//: to keep our include lists and compile times short we only provide forward -//: declarations for classes we only have pointers to -namespace Ui { -class MainWindow; -} - -class MainWindow : public QMainWindow { - Q_OBJECT -public: - explicit MainWindow(QWidget *parent, const char *config_file); - ~MainWindow() noexcept override; - -private slots: - void closeEvent(QCloseEvent *ev) override; - void toggleRecording(); - -private: - // function for loading / saving the config file - QString find_config_file(const char *filename); - void load_config(const QString &filename); - void save_config(const QString &filename); - //: `std::unique_ptr` prevents us from copying objects we should only have - //: once and automatically deletes the objects when the `unique_ptr` goes - //: out of scope. - std::unique_ptr reader{nullptr}; - - std::unique_ptr ui; // window pointer - std::atomic shutdown{false}; // flag indicating whether the recording thread should quit -}; - -#endif // MAINWINDOW_H diff --git a/mainwindow.ui b/mainwindow.ui deleted file mode 100644 index 16c8124..0000000 --- a/mainwindow.ui +++ /dev/null @@ -1,110 +0,0 @@ - - - MainWindow - - - AppTemplate_cpp_qt - - - - - - - Name - - - - - - - - 0 - 0 - - - - Link - - - - - - - - - - Device parameter - - - - - - - 400 - - - - - - - - - 0 - 0 - 262 - 30 - - - - - &File - - - - - - - - - Help - - - - - - - - - - &Load Configuration - - - Ctrl+L - - - - - &Save Configuration - - - Ctrl+S - - - - - &Quit - - - Ctrl+Q - - - - - &About - - - - - - - diff --git a/reader.cpp b/reader.cpp deleted file mode 100644 index 30e6780..0000000 --- a/reader.cpp +++ /dev/null @@ -1,19 +0,0 @@ -#include "reader.hpp" -#include -#include - - -Reader::Reader(int32_t device_param) : counter(device_param) { - // Here we would connect to the device -} - -Reader::~Reader() noexcept { - // Close the connection -} - -bool Reader::getData(std::vector &buffer) { - // Acquire some data and return it - for (auto &elem : buffer) elem = counter++; - std::this_thread::sleep_for(std::chrono::milliseconds(buffer.size() * 100)); - return true; -} diff --git a/reader.hpp b/reader.hpp deleted file mode 100644 index 9b21706..0000000 --- a/reader.hpp +++ /dev/null @@ -1,27 +0,0 @@ -//: # `reader.h` ([source](../appskeleton/reader.h)) -#pragma once - -#include -#include - -//: Most recording device APIs provide some sort of handle to a device and -//: functions to query the state, read data and put it in a buffer etc. -//: -//: This is a very simple example to demonstrate how to integrate it with LSL. -//: The provided functions are: -//: -//: - the constructor -//: - the destructor -//: - `getData` with the buffer as one output parameter and the status as return value -//: - `getStatus` to check if everything's ok -class Reader { -public: - explicit Reader(int32_t device_param); - ~Reader() noexcept; - bool getData(std::vector &buffer); - bool getStatus() { return true; } - - -private: - int32_t counter; -}; diff --git a/scripts/sign_and_notarize.sh b/scripts/sign_and_notarize.sh new file mode 100755 index 0000000..9410e44 --- /dev/null +++ b/scripts/sign_and_notarize.sh @@ -0,0 +1,119 @@ +#!/bin/bash +# ============================================================================= +# Apple Code Signing and Notarization Script +# ============================================================================= +# This script handles identity-based signing and notarization for macOS apps. +# It's designed to be called from CI (GitHub Actions) after the build. +# +# Usage: +# ./scripts/sign_and_notarize.sh [--notarize] +# +# Environment Variables (set in CI): +# APPLE_CODE_SIGN_IDENTITY_APP - Developer ID Application certificate name +# APPLE_NOTARIZE_KEYCHAIN_PROFILE - notarytool credential profile name +# ENTITLEMENTS_FILE - Path to entitlements file (optional) +# +# Examples: +# # Sign only (for testing) +# ./scripts/sign_and_notarize.sh build/install/LSLTemplate.app +# +# # Sign and notarize (for release) +# ./scripts/sign_and_notarize.sh build/install/LSLTemplate.app --notarize +# ============================================================================= + +set -e + +# Parse arguments +APP_PATH="$1" +DO_NOTARIZE=false + +if [[ "$2" == "--notarize" ]]; then + DO_NOTARIZE=true +fi + +if [[ -z "$APP_PATH" ]]; then + echo "Usage: $0 [--notarize]" + exit 1 +fi + +if [[ ! -e "$APP_PATH" ]]; then + echo "Error: $APP_PATH does not exist" + exit 1 +fi + +# Default to ad-hoc signing if no identity specified +SIGN_IDENTITY="${APPLE_CODE_SIGN_IDENTITY_APP:--}" +ENTITLEMENTS_ARG="" + +# Use entitlements if specified and exists +if [[ -n "${ENTITLEMENTS_FILE}" && -f "${ENTITLEMENTS_FILE}" ]]; then + ENTITLEMENTS_ARG="--entitlements ${ENTITLEMENTS_FILE}" +elif [[ -f "$(dirname "$0")/../app.entitlements" ]]; then + ENTITLEMENTS_ARG="--entitlements $(dirname "$0")/../app.entitlements" +fi + +echo "=== Code Signing ===" +echo "Target: $APP_PATH" +echo "Identity: $SIGN_IDENTITY" +echo "Entitlements: ${ENTITLEMENTS_ARG:-none}" + +if [[ -d "$APP_PATH" ]]; then + # App bundle - sign with deep and hardened runtime + codesign --force --deep --sign "$SIGN_IDENTITY" \ + --options runtime \ + $ENTITLEMENTS_ARG \ + "$APP_PATH" +else + # Single binary + codesign --force --sign "$SIGN_IDENTITY" \ + --options runtime \ + $ENTITLEMENTS_ARG \ + "$APP_PATH" +fi + +echo "Verifying signature..." +codesign --verify --verbose "$APP_PATH" + +# Check if we should notarize +if [[ "$DO_NOTARIZE" == true ]]; then + if [[ "$SIGN_IDENTITY" == "-" ]]; then + echo "Warning: Cannot notarize with ad-hoc signature. Skipping notarization." + exit 0 + fi + + if [[ -z "$APPLE_NOTARIZE_KEYCHAIN_PROFILE" ]]; then + echo "Error: APPLE_NOTARIZE_KEYCHAIN_PROFILE not set" + exit 1 + fi + + echo "" + echo "=== Notarizing ===" + + # Create zip for notarization submission + BASENAME=$(basename "$APP_PATH") + ZIP_PATH="/tmp/${BASENAME%.*}_notarize.zip" + + echo "Creating zip for submission: $ZIP_PATH" + ditto -c -k --keepParent "$APP_PATH" "$ZIP_PATH" + + echo "Submitting to Apple notarization service..." + xcrun notarytool submit "$ZIP_PATH" \ + --keychain-profile "$APPLE_NOTARIZE_KEYCHAIN_PROFILE" \ + --wait + + # Staple the notarization ticket + if [[ -d "$APP_PATH" ]]; then + echo "Stapling notarization ticket..." + xcrun stapler staple "$APP_PATH" + xcrun stapler validate "$APP_PATH" + fi + + # Clean up + rm -f "$ZIP_PATH" + + echo "" + echo "=== Notarization Complete ===" +fi + +echo "" +echo "Done!" diff --git a/src/cli/CMakeLists.txt b/src/cli/CMakeLists.txt new file mode 100644 index 0000000..ca40902 --- /dev/null +++ b/src/cli/CMakeLists.txt @@ -0,0 +1,20 @@ +# CLI Application +add_executable(${PROJECT_NAME}CLI + main.cpp +) + +target_link_libraries(${PROJECT_NAME}CLI + PRIVATE + LSLTemplate::core +) + +# Windows: Copy DLLs to build directory for debugging +if(WIN32) + add_custom_command(TARGET ${PROJECT_NAME}CLI POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $ + COMMAND_EXPAND_LISTS + COMMENT "Copying runtime DLLs for ${PROJECT_NAME}CLI" + ) +endif() diff --git a/src/cli/main.cpp b/src/cli/main.cpp new file mode 100644 index 0000000..00509d7 --- /dev/null +++ b/src/cli/main.cpp @@ -0,0 +1,130 @@ +/** + * @file main.cpp + * @brief CLI entry point for LSL Template application + * + * This provides a headless (no GUI) version of the application, + * useful for servers, embedded systems, or automated testing. + */ + +#include +#include +#include + +#include +#include +#include +#include + +namespace { + +std::atomic g_shutdown{false}; + +void signalHandler(int /*signum*/) { + std::cout << "\nShutdown requested..." << std::endl; + g_shutdown = true; +} + +void printUsage(const char* program_name) { + std::cout << "Usage: " << program_name << " [options]\n" + << "\n" + << "Options:\n" + << " -h, --help Show this help message\n" + << " -c, --config FILE Load configuration from FILE\n" + << " -n, --name NAME Stream name (default: LSLTemplate)\n" + << " -t, --type TYPE Stream type (default: Counter)\n" + << " -r, --rate RATE Sample rate in Hz (default: 10)\n" + << " --channels N Number of channels (default: 1)\n" + << "\n" + << "Example:\n" + << " " << program_name << " --name MyDevice --rate 256 --channels 8\n" + << std::endl; +} + +void statusCallback(const std::string& message, bool is_error) { + if (is_error) { + std::cerr << "[ERROR] " << message << std::endl; + } else { + std::cout << "[INFO] " << message << std::endl; + } +} + +} // anonymous namespace + +int main(int argc, char* argv[]) { + // Parse command line arguments + lsltemplate::AppConfig config; + std::string config_file; + + for (int i = 1; i < argc; ++i) { + std::string arg = argv[i]; + + if (arg == "-h" || arg == "--help") { + printUsage(argv[0]); + return 0; + } else if ((arg == "-c" || arg == "--config") && i + 1 < argc) { + config_file = argv[++i]; + } else if ((arg == "-n" || arg == "--name") && i + 1 < argc) { + config.stream_name = argv[++i]; + } else if ((arg == "-t" || arg == "--type") && i + 1 < argc) { + config.stream_type = argv[++i]; + } else if ((arg == "-r" || arg == "--rate") && i + 1 < argc) { + config.sample_rate = std::stod(argv[++i]); + } else if (arg == "--channels" && i + 1 < argc) { + config.channel_count = std::stoi(argv[++i]); + } else { + std::cerr << "Unknown option: " << arg << std::endl; + printUsage(argv[0]); + return 1; + } + } + + // Load config file if specified + if (!config_file.empty()) { + auto loaded = lsltemplate::ConfigManager::load(config_file); + if (loaded) { + config = *loaded; + std::cout << "Loaded configuration from: " << config_file << std::endl; + } else { + std::cerr << "Failed to load config file: " << config_file << std::endl; + return 1; + } + } + + // Set up signal handling for graceful shutdown + std::signal(SIGINT, signalHandler); + std::signal(SIGTERM, signalHandler); + + std::cout << "LSL Template CLI" << std::endl; + std::cout << "Stream: " << config.stream_name << " (" << config.stream_type << ")" << std::endl; + std::cout << "Channels: " << config.channel_count << " @ " << config.sample_rate << " Hz" << std::endl; + std::cout << "Press Ctrl+C to stop..." << std::endl; + + // Create mock device (replace with your actual device) + lsltemplate::MockDevice::Config device_config{ + .name = config.stream_name, + .type = config.stream_type, + .channel_count = config.channel_count, + .sample_rate = config.sample_rate, + .start_value = config.device_param + }; + auto device = std::make_unique(device_config); + + // Create and start the stream thread + lsltemplate::StreamThread stream(std::move(device), statusCallback); + + if (!stream.start()) { + std::cerr << "Failed to start streaming" << std::endl; + return 1; + } + + // Wait for shutdown signal + while (!g_shutdown && stream.isRunning()) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + + // Clean shutdown + stream.stop(); + + std::cout << "Shutdown complete." << std::endl; + return 0; +} diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt new file mode 100644 index 0000000..ca27b35 --- /dev/null +++ b/src/core/CMakeLists.txt @@ -0,0 +1,22 @@ +# Core library - Qt-independent, shared between CLI and GUI +add_library(lsltemplate_core STATIC + src/Device.cpp + src/LSLOutlet.cpp + src/Config.cpp + src/StreamThread.cpp +) + +target_include_directories(lsltemplate_core + PUBLIC + $ + $ +) + +target_link_libraries(lsltemplate_core + PUBLIC + LSL::lsl + Threads::Threads +) + +# Alias for consistent naming +add_library(LSLTemplate::core ALIAS lsltemplate_core) diff --git a/src/core/include/lsltemplate/Config.hpp b/src/core/include/lsltemplate/Config.hpp new file mode 100644 index 0000000..cde9fe6 --- /dev/null +++ b/src/core/include/lsltemplate/Config.hpp @@ -0,0 +1,66 @@ +#pragma once +/** + * @file Config.hpp + * @brief Configuration management for LSL applications + * + * Provides platform-independent configuration loading and saving. + */ + +#include +#include +#include + +namespace lsltemplate { + +/** + * @brief Application configuration + * + * Customize this struct for your application's settings. + */ +struct AppConfig { + std::string stream_name = "LSLTemplate"; + std::string stream_type = "Counter"; + int channel_count = 1; + double sample_rate = 10.0; + int device_param = 0; // Device-specific parameter +}; + +/** + * @brief Configuration file manager + */ +class ConfigManager { +public: + /** + * @brief Load configuration from file + * @param path Path to config file + * @return Loaded config, or nullopt on error + */ + static std::optional load(const std::filesystem::path& path); + + /** + * @brief Save configuration to file + * @param config Configuration to save + * @param path Path to config file + * @return true on success + */ + static bool save(const AppConfig& config, const std::filesystem::path& path); + + /** + * @brief Find config file in standard locations + * @param filename Config filename (e.g., "MyApp.cfg") + * @param hint Optional hint path to check first + * @return Path to found config, or empty path if not found + * + * Search order: + * 1. Hint path (if provided) + * 2. Current working directory + * 3. Executable directory + * 4. Platform config directory (e.g., ~/.config on Linux) + */ + static std::filesystem::path findConfigFile( + const std::string& filename, + const std::optional& hint = std::nullopt + ); +}; + +} // namespace lsltemplate diff --git a/src/core/include/lsltemplate/Device.hpp b/src/core/include/lsltemplate/Device.hpp new file mode 100644 index 0000000..86e67c1 --- /dev/null +++ b/src/core/include/lsltemplate/Device.hpp @@ -0,0 +1,96 @@ +#pragma once +/** + * @file Device.hpp + * @brief Abstract device interface for LSL data acquisition + * + * This file defines the interface that device implementations should follow. + * Replace the MockDevice implementation with your actual device SDK integration. + */ + +#include +#include +#include +#include + +namespace lsltemplate { + +/** + * @brief Device information structure + */ +struct DeviceInfo { + std::string name; ///< Device name for LSL stream + std::string type; ///< Stream type (e.g., "EEG", "Markers", "Audio") + int channel_count = 1; ///< Number of channels + double sample_rate = 0.0; ///< Nominal sample rate (0 for irregular) + std::string source_id; ///< Unique source identifier +}; + +/** + * @brief Abstract base class for device implementations + * + * Derive from this class to implement support for your specific device. + * The key methods to implement are: + * - connect() - Initialize connection to hardware + * - disconnect() - Clean up hardware connection + * - getData() - Retrieve samples from the device + * - getInfo() - Return device metadata for LSL stream creation + */ +class IDevice { +public: + virtual ~IDevice() = default; + + /// Connect to the device. Returns true on success. + virtual bool connect() = 0; + + /// Disconnect from the device. + virtual void disconnect() = 0; + + /// Check if currently connected + virtual bool isConnected() const = 0; + + /// Get device information for LSL stream setup + virtual DeviceInfo getInfo() const = 0; + + /** + * @brief Retrieve data from the device + * @param buffer Output buffer to fill with samples (channel-interleaved) + * @return true if data was retrieved successfully, false on error or shutdown + * + * This method should block until data is available or an error occurs. + * The buffer size determines how many samples to retrieve. + */ + virtual bool getData(std::vector& buffer) = 0; +}; + +/** + * @brief Mock device for testing and template demonstration + * + * This implementation generates synthetic data. Replace with your actual + * device SDK integration. + */ +class MockDevice : public IDevice { +public: + struct Config { + std::string name = "MockDevice"; + std::string type = "Counter"; + int channel_count = 1; + double sample_rate = 10.0; // 10 Hz + int32_t start_value = 0; + }; + + explicit MockDevice(const Config& config); + ~MockDevice() override; + + bool connect() override; + void disconnect() override; + bool isConnected() const override; + DeviceInfo getInfo() const override; + bool getData(std::vector& buffer) override; + +private: + Config config_; + bool connected_ = false; + int32_t counter_ = 0; +}; + +} // namespace lsltemplate diff --git a/src/core/include/lsltemplate/LSLOutlet.hpp b/src/core/include/lsltemplate/LSLOutlet.hpp new file mode 100644 index 0000000..f711814 --- /dev/null +++ b/src/core/include/lsltemplate/LSLOutlet.hpp @@ -0,0 +1,64 @@ +#pragma once +/** + * @file LSLOutlet.hpp + * @brief LSL stream outlet wrapper + * + * Provides a clean interface for creating and managing LSL stream outlets. + */ + +#include "Device.hpp" +#include +#include +#include +#include + +namespace lsltemplate { + +/** + * @brief Wrapper for LSL stream outlet + * + * Creates and manages an LSL outlet based on device information. + * Handles stream creation, data pushing, and cleanup. + */ +class LSLOutlet { +public: + /** + * @brief Construct an outlet for the given device + * @param info Device information for stream setup + */ + explicit LSLOutlet(const DeviceInfo& info); + + ~LSLOutlet(); + + // Non-copyable + LSLOutlet(const LSLOutlet&) = delete; + LSLOutlet& operator=(const LSLOutlet&) = delete; + + // Movable + LSLOutlet(LSLOutlet&&) noexcept = default; + LSLOutlet& operator=(LSLOutlet&&) noexcept = default; + + /** + * @brief Push a chunk of samples to the outlet + * @param data Channel-interleaved sample data + */ + void pushChunk(const std::vector& data); + + /** + * @brief Push a single sample to the outlet + * @param sample Single sample (one value per channel) + */ + void pushSample(const std::vector& sample); + + /// Get the stream name + std::string getStreamName() const; + + /// Check if outlet has consumers + bool hasConsumers() const; + +private: + std::unique_ptr outlet_; + DeviceInfo info_; +}; + +} // namespace lsltemplate diff --git a/src/core/include/lsltemplate/StreamThread.hpp b/src/core/include/lsltemplate/StreamThread.hpp new file mode 100644 index 0000000..8f50696 --- /dev/null +++ b/src/core/include/lsltemplate/StreamThread.hpp @@ -0,0 +1,73 @@ +#pragma once +/** + * @file StreamThread.hpp + * @brief Background thread for LSL streaming + * + * Manages the acquisition loop in a separate thread. + */ + +#include "Device.hpp" +#include "LSLOutlet.hpp" +#include +#include +#include +#include + +namespace lsltemplate { + +/** + * @brief Status callback type + */ +using StatusCallback = std::function; + +/** + * @brief Manages device acquisition and LSL streaming in a background thread + */ +class StreamThread { +public: + /** + * @brief Construct a stream thread for the given device + * @param device Device to stream from (takes ownership) + * @param callback Optional status callback for notifications + */ + explicit StreamThread( + std::unique_ptr device, + StatusCallback callback = nullptr + ); + + ~StreamThread(); + + // Non-copyable, non-movable (owns a running thread) + StreamThread(const StreamThread&) = delete; + StreamThread& operator=(const StreamThread&) = delete; + + /** + * @brief Start streaming + * @return true if started successfully + */ + bool start(); + + /** + * @brief Stop streaming + * + * Signals the thread to stop and waits for it to finish. + */ + void stop(); + + /// Check if currently streaming + bool isRunning() const; + + /// Get the device info + DeviceInfo getDeviceInfo() const; + +private: + void threadFunction(); + + std::unique_ptr device_; + std::unique_ptr thread_; + std::atomic running_{false}; + std::atomic shutdown_{false}; + StatusCallback statusCallback_; +}; + +} // namespace lsltemplate diff --git a/src/core/src/Config.cpp b/src/core/src/Config.cpp new file mode 100644 index 0000000..c46754f --- /dev/null +++ b/src/core/src/Config.cpp @@ -0,0 +1,195 @@ +#include "lsltemplate/Config.hpp" +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#include +#else +#include +#include +#ifdef __APPLE__ +#include +#else +#include +#endif +#endif + +namespace lsltemplate { + +namespace { + +// Simple INI parser helpers +std::string trim(const std::string& str) { + auto start = std::find_if_not(str.begin(), str.end(), + [](unsigned char c) { return std::isspace(c); }); + auto end = std::find_if_not(str.rbegin(), str.rend(), + [](unsigned char c) { return std::isspace(c); }).base(); + return (start < end) ? std::string(start, end) : std::string(); +} + +std::filesystem::path getExecutablePath() { +#ifdef _WIN32 + char buffer[MAX_PATH]; + GetModuleFileNameA(nullptr, buffer, MAX_PATH); + return std::filesystem::path(buffer).parent_path(); +#elif defined(__APPLE__) + char buffer[PATH_MAX]; + uint32_t size = sizeof(buffer); + if (_NSGetExecutablePath(buffer, &size) == 0) { + return std::filesystem::path(buffer).parent_path(); + } + return {}; +#else + char buffer[PATH_MAX]; + ssize_t len = readlink("/proc/self/exe", buffer, sizeof(buffer) - 1); + if (len != -1) { + buffer[len] = '\0'; + return std::filesystem::path(buffer).parent_path(); + } + return {}; +#endif +} + +std::filesystem::path getConfigDirectory() { +#ifdef _WIN32 + char buffer[MAX_PATH]; + if (SUCCEEDED(SHGetFolderPathA(nullptr, CSIDL_APPDATA, nullptr, 0, buffer))) { + return std::filesystem::path(buffer); + } + return {}; +#elif defined(__APPLE__) + const char* home = getenv("HOME"); + if (home) { + return std::filesystem::path(home) / "Library" / "Preferences"; + } + return {}; +#else + // XDG Base Directory Specification + const char* xdg_config = getenv("XDG_CONFIG_HOME"); + if (xdg_config && *xdg_config) { + return std::filesystem::path(xdg_config); + } + const char* home = getenv("HOME"); + if (home) { + return std::filesystem::path(home) / ".config"; + } + return {}; +#endif +} + +} // anonymous namespace + +std::optional ConfigManager::load(const std::filesystem::path& path) { + std::ifstream file(path); + if (!file.is_open()) { + return std::nullopt; + } + + AppConfig config; + std::string line; + std::string current_section; + + while (std::getline(file, line)) { + line = trim(line); + + // Skip empty lines and comments + if (line.empty() || line[0] == '#' || line[0] == ';') { + continue; + } + + // Section header + if (line.front() == '[' && line.back() == ']') { + current_section = line.substr(1, line.size() - 2); + continue; + } + + // Key=value pair + auto eq_pos = line.find('='); + if (eq_pos != std::string::npos) { + std::string key = trim(line.substr(0, eq_pos)); + std::string value = trim(line.substr(eq_pos + 1)); + + // Remove quotes if present + if (value.size() >= 2 && value.front() == '"' && value.back() == '"') { + value = value.substr(1, value.size() - 2); + } + + // Map to config fields (customize for your application) + if (key == "name" || key == "stream_name") { + config.stream_name = value; + } else if (key == "type" || key == "stream_type") { + config.stream_type = value; + } else if (key == "channels" || key == "channel_count") { + config.channel_count = std::stoi(value); + } else if (key == "sample_rate" || key == "srate") { + config.sample_rate = std::stod(value); + } else if (key == "device" || key == "device_param") { + config.device_param = std::stoi(value); + } + } + } + + return config; +} + +bool ConfigManager::save(const AppConfig& config, const std::filesystem::path& path) { + std::ofstream file(path); + if (!file.is_open()) { + return false; + } + + file << "# LSL Application Configuration\n"; + file << "[Stream]\n"; + file << "name=" << config.stream_name << "\n"; + file << "type=" << config.stream_type << "\n"; + file << "channels=" << config.channel_count << "\n"; + file << "sample_rate=" << config.sample_rate << "\n"; + file << "\n"; + file << "[Device]\n"; + file << "device_param=" << config.device_param << "\n"; + + return file.good(); +} + +std::filesystem::path ConfigManager::findConfigFile( + const std::string& filename, + const std::optional& hint +) { + std::vector search_paths; + + // 1. Hint path + if (hint && std::filesystem::exists(*hint)) { + return *hint; + } + + // 2. Current working directory + search_paths.push_back(std::filesystem::current_path()); + + // 3. Executable directory + auto exe_path = getExecutablePath(); + if (!exe_path.empty()) { + search_paths.push_back(exe_path); + } + + // 4. Platform config directory + auto config_dir = getConfigDirectory(); + if (!config_dir.empty()) { + search_paths.push_back(config_dir); + } + + // Search for the file + for (const auto& dir : search_paths) { + auto full_path = dir / filename; + if (std::filesystem::exists(full_path)) { + return full_path; + } + } + + return {}; +} + +} // namespace lsltemplate diff --git a/src/core/src/Device.cpp b/src/core/src/Device.cpp new file mode 100644 index 0000000..1b97e42 --- /dev/null +++ b/src/core/src/Device.cpp @@ -0,0 +1,72 @@ +#include "lsltemplate/Device.hpp" +#include +#include + +namespace lsltemplate { + +// ============================================================================= +// MockDevice Implementation +// ============================================================================= + +MockDevice::MockDevice(const Config& config) + : config_(config) + , counter_(config.start_value) +{ +} + +MockDevice::~MockDevice() { + disconnect(); +} + +bool MockDevice::connect() { + // In a real implementation, initialize hardware connection here + connected_ = true; + counter_ = config_.start_value; + return true; +} + +void MockDevice::disconnect() { + connected_ = false; +} + +bool MockDevice::isConnected() const { + return connected_; +} + +DeviceInfo MockDevice::getInfo() const { + return { + .name = config_.name, + .type = config_.type, + .channel_count = config_.channel_count, + .sample_rate = config_.sample_rate, + .source_id = config_.name + "_mock" + }; +} + +bool MockDevice::getData(std::vector& buffer) { + if (!connected_) { + return false; + } + + // Calculate samples based on buffer size and channel count + const size_t samples_requested = buffer.size() / config_.channel_count; + + // Generate synthetic data + for (size_t i = 0; i < buffer.size(); ++i) { + buffer[i] = static_cast(counter_++); + } + + // Simulate real-time acquisition by sleeping + if (config_.sample_rate > 0) { + const auto sample_period = std::chrono::duration( + samples_requested / config_.sample_rate + ); + std::this_thread::sleep_for( + std::chrono::duration_cast(sample_period) + ); + } + + return true; +} + +} // namespace lsltemplate diff --git a/src/core/src/LSLOutlet.cpp b/src/core/src/LSLOutlet.cpp new file mode 100644 index 0000000..725f09e --- /dev/null +++ b/src/core/src/LSLOutlet.cpp @@ -0,0 +1,60 @@ +#include "lsltemplate/LSLOutlet.hpp" + +namespace lsltemplate { + +LSLOutlet::LSLOutlet(const DeviceInfo& info) + : info_(info) +{ + // Determine channel format (using float32 for this template) + lsl::channel_format_t format = lsl::cf_float32; + + // Create stream info + lsl::stream_info stream_info( + info.name, + info.type, + info.channel_count, + info.sample_rate, + format, + info.source_id + ); + + // Add metadata (customize for your application) + lsl::xml_element desc = stream_info.desc(); + desc.append_child_value("manufacturer", "LSL Template"); + + // Add channel descriptions + lsl::xml_element channels = desc.append_child("channels"); + for (int i = 0; i < info.channel_count; ++i) { + lsl::xml_element ch = channels.append_child("channel"); + ch.append_child_value("label", "Ch" + std::to_string(i + 1)); + ch.append_child_value("unit", "arbitrary"); + ch.append_child_value("type", info.type); + } + + // Create the outlet + outlet_ = std::make_unique(stream_info); +} + +LSLOutlet::~LSLOutlet() = default; + +void LSLOutlet::pushChunk(const std::vector& data) { + if (outlet_ && !data.empty()) { + outlet_->push_chunk_multiplexed(data); + } +} + +void LSLOutlet::pushSample(const std::vector& sample) { + if (outlet_ && !sample.empty()) { + outlet_->push_sample(sample); + } +} + +std::string LSLOutlet::getStreamName() const { + return info_.name; +} + +bool LSLOutlet::hasConsumers() const { + return outlet_ && outlet_->have_consumers(); +} + +} // namespace lsltemplate diff --git a/src/core/src/StreamThread.cpp b/src/core/src/StreamThread.cpp new file mode 100644 index 0000000..3b0c59e --- /dev/null +++ b/src/core/src/StreamThread.cpp @@ -0,0 +1,130 @@ +#include "lsltemplate/StreamThread.hpp" +#include + +namespace lsltemplate { + +StreamThread::StreamThread( + std::unique_ptr device, + StatusCallback callback +) + : device_(std::move(device)) + , statusCallback_(std::move(callback)) +{ +} + +StreamThread::~StreamThread() { + stop(); +} + +bool StreamThread::start() { + if (running_) { + return false; // Already running + } + + if (!device_) { + if (statusCallback_) { + statusCallback_("No device configured", true); + } + return false; + } + + // Connect to device + if (!device_->connect()) { + if (statusCallback_) { + statusCallback_("Failed to connect to device", true); + } + return false; + } + + // Start the streaming thread + shutdown_ = false; + running_ = true; + thread_ = std::make_unique(&StreamThread::threadFunction, this); + + if (statusCallback_) { + statusCallback_("Streaming started", false); + } + + return true; +} + +void StreamThread::stop() { + if (!running_) { + return; + } + + // Signal shutdown + shutdown_ = true; + + // Wait for thread to finish + if (thread_ && thread_->joinable()) { + thread_->join(); + } + thread_.reset(); + + // Disconnect device + if (device_) { + device_->disconnect(); + } + + running_ = false; + + if (statusCallback_) { + statusCallback_("Streaming stopped", false); + } +} + +bool StreamThread::isRunning() const { + return running_; +} + +DeviceInfo StreamThread::getDeviceInfo() const { + if (device_) { + return device_->getInfo(); + } + return {}; +} + +void StreamThread::threadFunction() { + try { + // Create LSL outlet + auto info = device_->getInfo(); + LSLOutlet outlet(info); + + if (statusCallback_) { + statusCallback_("LSL outlet created: " + info.name, false); + } + + // Allocate buffer for acquisition + // Buffer size: enough for ~100ms of data, minimum 1 sample + size_t samples_per_chunk = std::max( + 1, + static_cast(info.sample_rate * 0.1) + ); + std::vector buffer(samples_per_chunk * info.channel_count); + + // Acquisition loop + while (!shutdown_) { + if (device_->getData(buffer)) { + outlet.pushChunk(buffer); + } else { + // getData returned false - device error or disconnection + if (!shutdown_) { + if (statusCallback_) { + statusCallback_("Device acquisition error", true); + } + } + break; + } + } + + } catch (const std::exception& e) { + if (statusCallback_) { + statusCallback_(std::string("Streaming error: ") + e.what(), true); + } + } + + running_ = false; +} + +} // namespace lsltemplate diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt new file mode 100644 index 0000000..e19644b --- /dev/null +++ b/src/gui/CMakeLists.txt @@ -0,0 +1,52 @@ +# GUI Application +add_executable(${PROJECT_NAME} MACOSX_BUNDLE WIN32 + main.cpp + MainWindow.cpp + MainWindow.hpp + MainWindow.ui +) + +target_link_libraries(${PROJECT_NAME} + PRIVATE + LSLTemplate::core + Qt6::Core + Qt6::Widgets +) + +# macOS bundle properties +if(APPLE) + set_target_properties(${PROJECT_NAME} PROPERTIES + MACOSX_BUNDLE_GUI_IDENTIFIER "org.labstreaminglayer.${PROJECT_NAME}" + MACOSX_BUNDLE_BUNDLE_VERSION "${PROJECT_VERSION}" + MACOSX_BUNDLE_SHORT_VERSION_STRING "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}" + MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/Info.plist.in" + ) +endif() + +# Windows: Copy DLLs to build directory for debugging +if(WIN32) + add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $ + COMMAND_EXPAND_LISTS + COMMENT "Copying runtime DLLs for ${PROJECT_NAME}" + ) + # Copy Qt plugins + get_target_property(_qmake_executable Qt6::qmake IMPORTED_LOCATION) + execute_process( + COMMAND ${_qmake_executable} -query QT_INSTALL_PLUGINS + OUTPUT_VARIABLE QT_PLUGINS_DIR + OUTPUT_STRIP_TRAILING_WHITESPACE + ) + add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${QT_PLUGINS_DIR}/platforms" + "$/platforms" + ) + add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${QT_PLUGINS_DIR}/styles" + "$/styles" + ) +endif() diff --git a/src/gui/Info.plist.in b/src/gui/Info.plist.in new file mode 100644 index 0000000..6014027 --- /dev/null +++ b/src/gui/Info.plist.in @@ -0,0 +1,30 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + ${MACOSX_BUNDLE_EXECUTABLE_NAME} + CFBundleIconFile + + CFBundleIdentifier + ${MACOSX_BUNDLE_GUI_IDENTIFIER} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ${MACOSX_BUNDLE_BUNDLE_NAME} + CFBundlePackageType + APPL + CFBundleShortVersionString + ${MACOSX_BUNDLE_SHORT_VERSION_STRING} + CFBundleVersion + ${MACOSX_BUNDLE_BUNDLE_VERSION} + LSMinimumSystemVersion + ${CMAKE_OSX_DEPLOYMENT_TARGET} + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + + diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp new file mode 100644 index 0000000..51c51c3 --- /dev/null +++ b/src/gui/MainWindow.cpp @@ -0,0 +1,227 @@ +/** + * @file MainWindow.cpp + * @brief Main window implementation for LSL Template GUI application + */ + +#include "MainWindow.hpp" +#include "ui_MainWindow.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +MainWindow::MainWindow(const QString& config_file, QWidget* parent) + : QMainWindow(parent) + , ui_(std::make_unique()) +{ + ui_->setupUi(this); + + // Connect signals + connect(ui_->linkButton, &QPushButton::clicked, this, &MainWindow::onLinkButtonClicked); + connect(ui_->actionLoad_Configuration, &QAction::triggered, this, &MainWindow::onLoadConfig); + connect(ui_->actionSave_Configuration, &QAction::triggered, this, &MainWindow::onSaveConfig); + connect(ui_->actionQuit, &QAction::triggered, this, &QMainWindow::close); + connect(ui_->actionAbout, &QAction::triggered, this, &MainWindow::onAbout); + + // Load configuration + QString cfg_path = config_file.isEmpty() ? findDefaultConfigFile() : config_file; + if (!cfg_path.isEmpty()) { + loadConfig(cfg_path); + } +} + +MainWindow::~MainWindow() { + // Stop streaming if running + if (stream_) { + stream_->stop(); + } +} + +void MainWindow::closeEvent(QCloseEvent* event) { + if (stream_ && stream_->isRunning()) { + auto result = QMessageBox::question( + this, + "Streaming Active", + "Streaming is still active. Stop and quit?", + QMessageBox::Yes | QMessageBox::No + ); + + if (result == QMessageBox::No) { + event->ignore(); + return; + } + + stream_->stop(); + } + + event->accept(); +} + +void MainWindow::onLinkButtonClicked() { + if (stream_ && stream_->isRunning()) { + // Stop streaming + stream_->stop(); + stream_.reset(); + setStreaming(false); + } else { + // Start streaming + lsltemplate::MockDevice::Config device_config{ + .name = ui_->input_name->text().toStdString(), + .type = ui_->input_type->text().toStdString(), + .channel_count = ui_->input_channels->value(), + .sample_rate = ui_->input_srate->value(), + .start_value = ui_->input_device->value() + }; + + auto device = std::make_unique(device_config); + + // Create status callback that updates UI (must be thread-safe) + auto callback = [this](const std::string& message, bool is_error) { + // Use Qt's thread-safe signal mechanism + QMetaObject::invokeMethod(this, [this, message, is_error]() { + updateStatus(QString::fromStdString(message), is_error); + }); + }; + + stream_ = std::make_unique(std::move(device), callback); + + if (stream_->start()) { + setStreaming(true); + } else { + stream_.reset(); + QMessageBox::warning(this, "Error", "Failed to start streaming"); + } + } +} + +void MainWindow::onLoadConfig() { + QString filename = QFileDialog::getOpenFileName( + this, + "Load Configuration", + last_config_path_, + "Configuration Files (*.cfg);;All Files (*)" + ); + + if (!filename.isEmpty()) { + loadConfig(filename); + } +} + +void MainWindow::onSaveConfig() { + QString filename = QFileDialog::getSaveFileName( + this, + "Save Configuration", + last_config_path_, + "Configuration Files (*.cfg);;All Files (*)" + ); + + if (!filename.isEmpty()) { + saveConfig(filename); + } +} + +void MainWindow::onAbout() { + QString info = QString( + "

LSL Template

" + "

Version 2.0.0

" + "

A template application for Lab Streaming Layer.

" + "
" + "

LSL Library: %1

" + "

Protocol: %2

" + ).arg(QString::number(lsl::library_version()), + QString::fromStdString(lsl::library_info())); + + QMessageBox::about(this, "About LSL Template", info); +} + +void MainWindow::loadConfig(const QString& filename) { + auto config = lsltemplate::ConfigManager::load(filename.toStdString()); + + if (config) { + ui_->input_name->setText(QString::fromStdString(config->stream_name)); + ui_->input_type->setText(QString::fromStdString(config->stream_type)); + ui_->input_channels->setValue(config->channel_count); + ui_->input_srate->setValue(config->sample_rate); + ui_->input_device->setValue(config->device_param); + last_config_path_ = filename; + updateStatus("Loaded: " + filename, false); + } else { + QMessageBox::warning( + this, + "Load Failed", + QString("Failed to load configuration from:\n%1").arg(filename) + ); + } +} + +void MainWindow::saveConfig(const QString& filename) { + lsltemplate::AppConfig config{ + .stream_name = ui_->input_name->text().toStdString(), + .stream_type = ui_->input_type->text().toStdString(), + .channel_count = ui_->input_channels->value(), + .sample_rate = ui_->input_srate->value(), + .device_param = ui_->input_device->value() + }; + + if (lsltemplate::ConfigManager::save(config, filename.toStdString())) { + last_config_path_ = filename; + updateStatus("Saved: " + filename, false); + } else { + QMessageBox::warning( + this, + "Save Failed", + QString("Failed to save configuration to:\n%1").arg(filename) + ); + } +} + +QString MainWindow::findDefaultConfigFile() { + QFileInfo exe_info(QCoreApplication::applicationFilePath()); + QString default_name = exe_info.completeBaseName() + ".cfg"; + + QStringList search_paths = { + QDir::currentPath(), + exe_info.absolutePath() + }; + search_paths.append(QStandardPaths::standardLocations(QStandardPaths::ConfigLocation)); + + for (const auto& path : search_paths) { + QString full_path = path + QDir::separator() + default_name; + if (QFileInfo::exists(full_path)) { + return full_path; + } + } + + return QString(); +} + +void MainWindow::updateStatus(const QString& message, bool is_error) { + ui_->statusbar->showMessage(message, is_error ? 0 : 5000); + + if (is_error) { + ui_->statusbar->setStyleSheet("color: red;"); + } else { + ui_->statusbar->setStyleSheet(""); + } +} + +void MainWindow::setStreaming(bool streaming) { + ui_->linkButton->setText(streaming ? "Unlink" : "Link"); + + // Disable config inputs while streaming + ui_->input_name->setEnabled(!streaming); + ui_->input_type->setEnabled(!streaming); + ui_->input_channels->setEnabled(!streaming); + ui_->input_srate->setEnabled(!streaming); + ui_->input_device->setEnabled(!streaming); +} diff --git a/src/gui/MainWindow.hpp b/src/gui/MainWindow.hpp new file mode 100644 index 0000000..4add433 --- /dev/null +++ b/src/gui/MainWindow.hpp @@ -0,0 +1,44 @@ +#pragma once +/** + * @file MainWindow.hpp + * @brief Main window for LSL Template GUI application + */ + +#include +#include + +namespace Ui { +class MainWindow; +} + +namespace lsltemplate { +class StreamThread; +} + +class MainWindow : public QMainWindow { + Q_OBJECT + +public: + explicit MainWindow(const QString& config_file = QString(), QWidget* parent = nullptr); + ~MainWindow() override; + +protected: + void closeEvent(QCloseEvent* event) override; + +private slots: + void onLinkButtonClicked(); + void onLoadConfig(); + void onSaveConfig(); + void onAbout(); + +private: + void loadConfig(const QString& filename); + void saveConfig(const QString& filename); + QString findDefaultConfigFile(); + void updateStatus(const QString& message, bool is_error); + void setStreaming(bool streaming); + + std::unique_ptr ui_; + std::unique_ptr stream_; + QString last_config_path_; +}; diff --git a/src/gui/MainWindow.ui b/src/gui/MainWindow.ui new file mode 100644 index 0000000..059fb77 --- /dev/null +++ b/src/gui/MainWindow.ui @@ -0,0 +1,191 @@ + + + MainWindow + + + LSL Template + + + + 350 + 250 + + + + + + + + + + Stream Name + + + + + + + LSLTemplate + + + + + + + Stream Type + + + + + + + Counter + + + + + + + Channels + + + + + + + 1 + + + 1024 + + + 1 + + + + + + + Sample Rate (Hz) + + + + + + + 2 + + + 0.000000000000000 + + + 100000.000000000000000 + + + 10.000000000000000 + + + + + + + Device Parameter + + + + + + + 9999 + + + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + 0 + 0 + + + + + 0 + 40 + + + + Link + + + + + + + + + &File + + + + + + + + + &Help + + + + + + + + + + &Load Configuration... + + + Ctrl+L + + + + + &Save Configuration... + + + Ctrl+S + + + + + &Quit + + + Ctrl+Q + + + + + &About... + + + + + + diff --git a/src/gui/main.cpp b/src/gui/main.cpp new file mode 100644 index 0000000..eb3cccd --- /dev/null +++ b/src/gui/main.cpp @@ -0,0 +1,27 @@ +/** + * @file main.cpp + * @brief GUI entry point for LSL Template application + */ + +#include "MainWindow.hpp" +#include + +int main(int argc, char* argv[]) { + QApplication app(argc, argv); + + app.setApplicationName("LSL Template"); + app.setApplicationVersion("2.0.0"); + app.setOrganizationName("LabStreamingLayer"); + app.setOrganizationDomain("labstreaminglayer.org"); + + // Get config file from command line if provided + QString config_file; + if (argc > 1) { + config_file = QString::fromLocal8Bit(argv[1]); + } + + MainWindow window(config_file); + window.show(); + + return app.exec(); +}