diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..4beaeac --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,257 @@ +# ============================================================================= +# AudioCapture Build Workflow +# ============================================================================= +# This workflow builds, tests, and packages AudioCapture for all +# supported platforms. +# +# 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] + 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 \ + libasound2-dev libpulse-dev + + - name: Install Qt + uses: jurplel/install-qt-action@v4 + with: + version: '6.8.*' + modules: 'qtmultimedia' + 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 }} + + # ----------------------------------------------------------------------- + # 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 AudioCapture-*/ | 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: | + APP_PATH=$(find packages -name "*.app" -type d | head -1) + if [[ -n "$APP_PATH" ]]; then + ./scripts/sign_and_notarize.sh "$APP_PATH" --notarize + fi + + # ----------------------------------------------------------------------- + # Repackage + # ----------------------------------------------------------------------- + - name: Repackage + run: | + cd packages + + echo "Contents of packages directory:" + ls -la + + rm -f *.tar.gz + + VERSION=$(grep -A1 'project(AudioCapture' ../CMakeLists.txt | grep VERSION | sed 's/.*VERSION \([0-9.]*\).*/\1/') + echo "Detected version: $VERSION" + + tar -cvzf "AudioCapture-${VERSION}-macOS_universal-signed.tar.gz" \ + AudioCapture.app + + 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/package-ubuntu-*/*.tar.gz + artifacts/**/*.deb diff --git a/.github/workflows/cppcmake.yml b/.github/workflows/cppcmake.yml deleted file mode 100644 index cc5854b..0000000 --- a/.github/workflows/cppcmake.yml +++ /dev/null @@ -1,103 +0,0 @@ -name: C/C++ CI - -on: - push: - tags: - - v*.* - pull_request: - branches: - - master - -env: - LSL_URL: 'https://github.com/sccn/liblsl/releases/download' - LSL_RELEASE_PREFIX: 'v' - LSL_RELEASE: '1.14.0' - LSL_RELEASE_SUFFIX: 'b1' - - -jobs: - build: - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: - - ubuntu-latest - - windows-latest - - macOS-latest - fail-fast: false - - steps: - - uses: actions/checkout@v2 - - - name: Install Qt (Windows) - if: matrix.os == 'windows-latest' - uses: jurplel/install-qt-action@v2 - with: - version: 5.14.0 - - - name: Get liblsl (Windows) - if: matrix.os == 'windows-latest' - run: | - Invoke-WebRequest -Uri $Env:LSL_URL/$Env:LSL_RELEASE_PREFIX$Env:LSL_RELEASE$Env:LSL_RELEASE_SUFFIX/liblsl-$Env:LSL_RELEASE-Win64.zip -o liblsl.7z - 7z x liblsl.7z -oLSL - - - name: Get liblsl and Qt (Ubuntu) - if: matrix.os == 'ubuntu-latest' - run: | - echo ${{ github.ref }} - curl -L ${LSL_URL}/${LSL_RELEASE_PREFIX}${LSL_RELEASE}${LSL_RELEASE_SUFFIX}/liblsl-${LSL_RELEASE}-Linux64-bionic.deb -o liblsl.deb - sudo dpkg -i liblsl.deb - sudo apt install -y qtbase5-dev qtmultimedia5-dev - - - name: Get liblsl and Qt (macOS) - if: matrix.os == 'macOS-latest' - run: | - curl -L ${LSL_URL}/${LSL_RELEASE_PREFIX}${LSL_RELEASE}${LSL_RELEASE_SUFFIX}/liblsl-${LSL_RELEASE}-OSX64.tar.bz2 -o liblsl.tar.bz2 - mkdir LSL - tar -xvf liblsl.tar.bz2 -C LSL - brew install qt - echo '::set-env name=CMAKE_PREFIX_PATH::/usr/local/opt/qt' - - - name: Configure CMake - shell: bash - run: | - cmake -S . -B build -DLSL_INSTALL_ROOT=$PWD/LSL/ -DCPACK_DEBIAN_PACKAGE_SHLIBDEPS=ON -DCPACK_DEBIAN_PACKAGE_DEPENDS=1 - - - name: Make & Install - run: cmake --build build --config Release -j --target install - - - name: Package - run: cmake --build build --config Release -j --target package - - - name: Upload Artifacts - uses: actions/upload-artifact@v2-preview - with: - name: pkg-${{ matrix.os }} - path: build/*.[dbz][ezi][b2p] # Hack to get deb, bz2, zip. Will also get e.g. de2, dep, dzb, dz2, dzp, etc... - - release: - needs: build - runs-on: ubuntu-latest - steps: - - - name: Download Artifacts - if: startsWith(github.ref, 'refs/tags/') - uses: actions/download-artifact@v2-preview - # By not specifying with: name:, it defaults to downloading all artifacts. - - # Official GitHub Upload-Asset action does not allow for uploading multiple files. - # There are many community alternatives. Below is one that combines release and upload, with globbing. - # See also: svenstaro/upload-release-action shogo82148/actions-upload-release-asset meeDamian/github-release csexton/release-asset-action - - name: Create Release - if: startsWith(github.ref, 'refs/tags/') - id: create_release - uses: softprops/action-gh-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - # tag_name: ${{ github.ref }} # ${{ github.ref }} is default - name: Release ${{ github.ref }} - draft: false - prerelease: true - # body_path: CHANGELOG.txt - files: pkg-*-latest/* diff --git a/.gitignore b/.gitignore index 59498ed..2de315a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ ui_*.h /build*/ +/install*/ /CMakeLists.txt.user /CMakeLists.json /CMakeSettings.json @@ -8,4 +9,4 @@ out/ .DS_Store LSL/ liblsl.tar.bz2 - +.idea/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 54207e6..ed3af33 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,49 +1,95 @@ -cmake_minimum_required(VERSION 3.12) -set(CMAKE_OSX_DEPLOYMENT_TARGET "10.15" CACHE STRING "Minimum MacOS deployment version") +# ============================================================================= +# AudioCapture - CMakeLists.txt +# ============================================================================= +# Capture audio and stream it over Lab Streaming Layer. +# ============================================================================= + +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(AudioCapture + VERSION 0.2.0 DESCRIPTION "Capture audio and stream it over LabStreamingLayer" HOMEPAGE_URL "https://github.com/labstreaminglayer/App-AudioCapture/" LANGUAGES CXX C - VERSION 0.1) - -# Needed for customized MacOSXBundleInfo.plist.in -SET(CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/cmake" ${CMAKE_MODULE_PATH}) +) -set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_EXPORT_COMPILE_COMMANDS 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") -## Qt -set(CMAKE_AUTOMOC ON) # The later version of this in LSLCMake is somehow not enough. -set(CMAKE_AUTORCC ON) -set(CMAKE_AUTOUIC ON) -find_package(Qt6 COMPONENTS Core Widgets Network DBus Multimedia) # Multimedia requires Qt6.2 -if(NOT Qt6_FOUND) - if(APPLE) - list(APPEND CMAKE_PREFIX_PATH "/usr/local/opt/qt@5") - endif() - # If we require 5.15 then we can use version-agnostic linking, but 5.15 not easily available on Ubuntu. - find_package(Qt5 COMPONENTS Core Widgets Network DBus Multimedia REQUIRED) - add_executable(${PROJECT_NAME} MACOSX_BUNDLE) - set(LSLAPP_QT_VER Qt5) +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() - qt_add_executable(${PROJECT_NAME} MACOSX_BUNDLE MANUAL_FINALIZATION) - set(LSLAPP_QT_VER Qt) + # 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() -# LSL -find_package(LSL REQUIRED - HINTS ${LSL_INSTALL_ROOT} - "${CMAKE_CURRENT_LIST_DIR}/../../LSL/liblsl/build/" - "${CMAKE_CURRENT_LIST_DIR}/../../LSL/liblsl/build/install" - "${CMAKE_CURRENT_LIST_DIR}/../../LSL/liblsl/out/build/x64-Release" - "${CMAKE_CURRENT_LIST_DIR}/../../LSL/liblsl/out/install/x64-Release" - PATH_SUFFIXES share/LSL) -get_filename_component(LSL_PATH ${LSL_CONFIG} DIRECTORY) +# ============================================================================= +# Qt6 +# ============================================================================= +set(CMAKE_AUTOMOC ON) +set(CMAKE_AUTORCC ON) +set(CMAKE_AUTOUIC ON) +find_package(Qt6 REQUIRED COMPONENTS Core Widgets Multimedia) + +# 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() + +# ============================================================================= +# Common Dependencies +# ============================================================================= find_package(Threads REQUIRED) +# ============================================================================= +# RPATH Configuration (must be set before targets are created) +# ============================================================================= +LSL_configure_rpath() + +# ============================================================================= +# Application Target +# ============================================================================= +qt_add_executable(${PROJECT_NAME} MACOSX_BUNDLE) + target_sources(${PROJECT_NAME} PRIVATE main.cpp mainwindow.cpp @@ -53,40 +99,131 @@ target_sources(${PROJECT_NAME} PRIVATE reader.cpp ) -target_link_libraries(${PROJECT_NAME} - PRIVATE - ${LSLAPP_QT_VER}::Widgets - ${LSLAPP_QT_VER}::Multimedia +target_link_libraries(${PROJECT_NAME} PRIVATE + Qt::Widgets + Qt::Multimedia Threads::Threads LSL::lsl ) -set(CMAKE_XCODE_ATTRIBUTE_CODE_SIGNING_ALLOWED "NO") -set_target_properties(${PROJECT_NAME} PROPERTIES - XCODE_ATTRIBUTE_CODE_SIGNING_REQUIRED "NO" +# macOS bundle configuration +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}") + set_target_properties(${PROJECT_NAME} PROPERTIES + MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/cmake/MacOSXBundleInfo.plist.in" + ) +endif() + +# ============================================================================= +# Windows: Copy DLLs to build directory for debugging +# ============================================================================= +if(WIN32) + 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 + ) + + # Copy runtime DLLs + 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 + 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() + +# ============================================================================= +# Installation +# ============================================================================= +include(GNUInstallDirs) + +# Platform-specific install directories +if(WIN32) + set(INSTALL_BINDIR ".") + set(INSTALL_LIBDIR ".") +elseif(APPLE) + set(INSTALL_BINDIR ".") + set(INSTALL_LIBDIR ".") +else() + set(INSTALL_BINDIR "${CMAKE_INSTALL_BINDIR}") + set(INSTALL_LIBDIR "${CMAKE_INSTALL_LIBDIR}") +endif() + +install(TARGETS ${PROJECT_NAME} + BUNDLE DESTINATION "${INSTALL_BINDIR}" + RUNTIME DESTINATION "${INSTALL_BINDIR}" +) + +# ============================================================================= +# Bundle liblsl with the application +# ============================================================================= +LSL_install_liblsl( + DESTINATION "${INSTALL_LIBDIR}" + FRAMEWORK_DESTINATION "${INSTALL_BINDIR}/${PROJECT_NAME}.app/Contents/Frameworks" ) -# Copy the required dll's into the build folder --> useful for debugging from IDE -# create a list of files to copy -set(THIRD_PARTY_DLLS - LSL::lsl - ${LSLAPP_QT_VER}::Core - ${LSLAPP_QT_VER}::Gui - ${LSLAPP_QT_VER}::Widgets - ${LSLAPP_QT_VER}::Multimedia - ${LSLAPP_QT_VER}::Network +# ============================================================================= +# Qt Deployment +# ============================================================================= +LSL_deploy_qt(TARGET "${PROJECT_NAME}" DESTINATION "${INSTALL_BINDIR}") + +# ============================================================================= +# MinGW Runtime Deployment +# ============================================================================= +LSL_install_mingw_runtime(DESTINATION "${INSTALL_BINDIR}") + +# ============================================================================= +# macOS Code Signing +# ============================================================================= +LSL_codesign( + TARGET "${PROJECT_NAME}" + DESTINATION "${INSTALL_BINDIR}" + ENTITLEMENTS "${CMAKE_CURRENT_SOURCE_DIR}/app.entitlements" + BUNDLE ) -foreach(_lib ${THIRD_PARTY_DLLS}) - add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy_if_different - $ - $) -endforeach() +# ============================================================================= +# CPack Configuration +# ============================================================================= +LSL_get_target_arch() +LSL_get_os_name() -if(Qt6_FOUND) - qt_finalize_executable(${PROJECT_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") + set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS OFF) + set(CPACK_DEBIAN_PACKAGE_DEPENDS "libqt6widgets6, libqt6multimedia6") endif() -installLSLApp(${PROJECT_NAME}) -LSLGenerateCPackConfig() +include(CPack) diff --git a/README.md b/README.md index 8490b32..6960c14 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,16 @@ # Overview -The AudioCapture application uses Qt's [QAudioInput](https://doc.qt.io/qt-5/qaudioinput.html) for cross-platform audio capturing. This program has been tested on Windows and MacOS. Download the latest version [from the releases page](https://github.com/labstreaminglayer/App-AudioCapture/releases). +The AudioCapture application uses Qt's [QAudioSource](https://doc.qt.io/qt-6/qaudiosource.html) for cross-platform audio capturing and streaming over [LSL](https://labstreaminglayer.org). -The Windows release requires vc_redist.x64.exe [from Microsoft](https://support.microsoft.com/en-gb/help/2977003/the-latest-supported-visual-c-downloads). +# Getting Started + +Download the latest version [from the releases page](https://github.com/labstreaminglayer/App-AudioCapture/releases). + +The Windows release requires vc_redist.x64.exe; if you don't already have it then you can install the download [from Microsoft](https://support.microsoft.com/en-gb/help/2977003/the-latest-supported-visual-c-downloads). -# Usage Using this app is very simple: * Make sure that you have connected a microphone to your computer. - * On Ubuntu, you need to `sudo apt-get install libqt5multimedia5-plugins` * Start the AudioCapture app. You should see a window like the following. > ![audiocapture.PNG](audiocapture.PNG) * Set the audio capture parameters. @@ -18,18 +20,62 @@ Using this app is very simple: * Click the "Link" button to link the app to the lab network. If successful, the button should turn into "Unlink". * If a firewall complains, allow the app to connect to the network. * Please allow microphone access if asked. - * You should now have a stream on your lab network that has type "Audio" and its name is the name entered in the GUI. Note that you cannot close the app while it is linked. + * You should now have a stream on your lab network that has type "Audio" and its name is the name entered in the GUI. + * Note that you cannot close the app while it is linked. + +# Build from source + +## Pre-requisites + +### liblsl + +TODO + +### Qt6 >= 6.5 + +* MacOS: `brew install qt` +* Windows or Linux: Download and run installer + * You will need a (free) Qt account + * This is an open source project so you can use the LGPL Qt6 open source version + * QtMultimedia should be enabled by default so default options are fine + +### Build Essentials + +* CMake >= 3.25 +* Compiler -# Build +## Instructions -The build instructions for this app are mostly the same as the [generic LSL App build instructions](https://labstreaminglayer.readthedocs.io/dev/app_build.html). -Qt Multimedia module is required. On Ubuntu this does not come with qt5 by default. Install it with `sudo apt-get install qtmultimedia5-dev`. On Mac, with homebrew, it is only included in qt6 >= 6.2, which isn't out yet, so qt5 is required. -On Mac, it appears to be necessary to use the Xcode generator: `cmake -B build -S . -DCMAKE_INSTALL_PREFIX="build/install" -G Xcode` +** Configure: ** + * MacOS: `cmake -B build -S . -DCMAKE_INSTALL_PREFIX=build/install -DCMAKE_BUILD_TYPE=Release -G Xcode` + * Linux: `cmake -B build -S . -DCMAKE_INSTALL_PREFIX=build/install -DCMAKE_BUILD_TYPE=Release -DQt6_DIR=~/Qt/6.9.1/gcc_64/lib/cmake/Qt6` + * Windows: `cmake -B build -S . -DCMAKE_INSTALL_PREFIX=build/install -DCMAKE_BUILD_TYPE=Release -DQt6_DIR=C:\\Qt\\6.9.1\\mingw_64\\lib\\cmake\\Qt6` -Note that code-signing has been disabled. +** Build: ** + * `cmake --build build -DCMAKE_BUILD_TYPE=Release --target install` + +Note on MacOS: If the `APPLE_CODE_SIGN_IDENTITY_APP` env variable is set then the package will be code-signed at this stage. + +** Package: ** + +TODO + +** Notarization (MacOS only; Optional): ** + +TODO + +** Deploy and Use: ** + +Without the packaging step in place, the build/install/ folder will container # Further Notes The previous version of AudioCaptureWin can be found as [release v0.1](https://github.com/labstreaminglayer/App-AudioCapture/releases/tag/v0.1) in this repository. -For Windows XP there is an older LSL audio recording app available on request; it uses the [irrKlang](http://www.ambiera.com/irrklang/) audio library, which in turn uses DirectX audio on Windows. That application does not support support accurate time synchronization and is therefore deprecated. +For Windows XP there is an older LSL audio recording app available on request; it uses the [irrKlang](http://www.ambiera.com/irrklang/) audio library, which in turn uses DirectX audio on Windows. That application does not support accurate time synchronization and is therefore deprecated. + +# Developer Notes + +There were quite a few changes with Qt6. We follow the general pattern outlined [here](https://doc.qt.io/qt-6/audiooverview.html#low-level-audio-playback-and-recording). + +> Conversely, for pull mode with QAudioSource, when audio data is available then the data will be written directly to the QIODevice. diff --git a/app.entitlements b/app.entitlements new file mode 100644 index 0000000..9a7d01a --- /dev/null +++ b/app.entitlements @@ -0,0 +1,21 @@ + + + + + + com.apple.security.network.client + + + + com.apple.security.network.server + + + + com.apple.security.network.multicast + + + + com.apple.security.device.audio-input + + + diff --git a/cmake/MacOSXBundleInfo.plist b/cmake/MacOSXBundleInfo.plist.in similarity index 100% rename from cmake/MacOSXBundleInfo.plist rename to cmake/MacOSXBundleInfo.plist.in diff --git a/mainwindow.cpp b/mainwindow.cpp index e9fe2bb..5594ac7 100644 --- a/mainwindow.cpp +++ b/mainwindow.cpp @@ -2,7 +2,7 @@ #include "reader.h" #include "ui_mainwindow.h" -#include +#include #include #include #include @@ -16,128 +16,166 @@ #include #include -lsl::channel_format_t bits2fmt(int bits) { - if (bits == 8) return lsl::cf_int8; - if (bits == 16) return lsl::cf_int16; - if (bits == 32) return lsl::cf_float32; - // if (bits == 64) return lsl::cf_double64; - throw std::runtime_error("Unsupported sample bits."); +lsl::channel_format_t sampleFormatToLSL(QAudioFormat::SampleFormat fmt) { + switch (fmt) { + case QAudioFormat::Float: return lsl::cf_float32; + case QAudioFormat::Int32: return lsl::cf_int32; + case QAudioFormat::Int16: return lsl::cf_int16; + case QAudioFormat::UInt8: return lsl::cf_int8; + default: return lsl::cf_float32; + } +} + +// Helper to convert SampleFormat to string +QString sampleFormatToString(QAudioFormat::SampleFormat fmt) { + switch (fmt) { + case QAudioFormat::UInt8: return "UInt8"; + case QAudioFormat::Int16: return "Int16"; + case QAudioFormat::Int32: return "Int32"; + case QAudioFormat::Float: return "Float"; + default: return "Unknown"; + } +} + +// Helper to convert string to SampleFormat +QAudioFormat::SampleFormat stringToSampleFormat(const QString &str) { + if (str == "UInt8") return QAudioFormat::UInt8; + if (str == "Int16") return QAudioFormat::Int16; + if (str == "Int32") return QAudioFormat::Int32; + if (str == "Float") return QAudioFormat::Float; + return QAudioFormat::Unknown; } MainWindow::MainWindow(QWidget *parent, const char *config_file) : QMainWindow(parent), ui(new Ui::MainWindow), - devices(QAudioDeviceInfo::availableDevices(QAudio::Mode::AudioInput)) { + devices(QMediaDevices::audioInputs()) { if(devices.empty()) { QMessageBox::warning(this, "Fatal error", "No capture devices found, quitting."); exit(1); } ui->setupUi(this); + 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); // audio devices - for (auto info : devices) ui->input_device->addItem(info.deviceName()); - auto changeSignal = static_cast(&QComboBox::currentIndexChanged); + for (const auto &dev : devices) ui->input_device->addItem(dev.description()); + const auto changeSignal = static_cast(&QComboBox::currentIndexChanged); + connect(ui->input_device, changeSignal, this, &MainWindow::deviceChanged); deviceChanged(); + connect(ui->btn_checkfmt, &QPushButton::clicked, this, &MainWindow::checkAudioFormat); QString cfgfilepath = find_config_file(config_file); load_config(cfgfilepath); - checkAudioFormat(); + checkAudioFormat(cfgfilepath.length() > 0); } -QAudioDeviceInfo MainWindow::currentDeviceInfo() { +QAudioDevice MainWindow::currentDevice() const { return devices.at(ui->input_device->currentIndex()); } -void MainWindow::deviceChanged() { - auto info = currentDeviceInfo(); - updateComboBoxItems(ui->input_channels, info.supportedChannelCounts()); - updateComboBoxItems(ui->input_samplerate, info.supportedSampleRates()); - updateComboBoxItems(ui->input_samplesize, info.supportedSampleSizes()); - QAudioFormat fmt(info.preferredFormat()); - if ((fmt.sampleSize() == 8 || fmt.sampleSize() == 24) && info.supportedSampleSizes().contains(16)) fmt.setSampleSize(16); +void MainWindow::deviceChanged() const { + auto const dev = currentDevice(); + + // Channel count + ui->input_channels->setMinimum(dev.minimumChannelCount()); + ui->input_channels->setMaximum(dev.maximumChannelCount()); + ui->input_channels->setValue(dev.preferredFormat().channelCount()); + // Sample rate + ui->input_samplerate->setMinimum(dev.minimumSampleRate()); + ui->input_samplerate->setMaximum(dev.maximumSampleRate()); + ui->input_samplerate->setValue(dev.preferredFormat().sampleRate()); + ui->input_samplerate->setToolTip( + QString("%1 - %2 Hz") + .arg(dev.minimumSampleRate()) + .arg(dev.maximumSampleRate()) + ); // Not showing up? + ui->input_samplerate->setToolTipDuration(5000); + // Sample format + ui->input_sampleformat->clear(); + for (auto samp_fmt : dev.supportedSampleFormats()) { + ui->input_sampleformat->addItem(sampleFormatToString(samp_fmt), QVariant::fromValue(samp_fmt)); + } + auto const fmt(dev.preferredFormat()); + if (const auto idx = ui->input_sampleformat->findData(QVariant::fromValue(fmt.sampleFormat())); idx >= 0) ui->input_sampleformat->setCurrentIndex(idx); setFmt(fmt); } -QAudioFormat MainWindow::selectedAudioFormat() { - auto info = currentDeviceInfo(); - QAudioFormat fmt(info.preferredFormat()); - fmt.setByteOrder(QAudioFormat::LittleEndian); - fmt.setSampleType(QAudioFormat::SampleType::SignedInt); +QAudioFormat MainWindow::selectedAudioFormat() const { + const auto dev = currentDevice(); + QAudioFormat fmt(dev.preferredFormat()); qInfo() << "Preferred: " << fmt; - fmt.setSampleRate(ui->input_samplerate->currentText().toInt()); - fmt.setSampleSize(ui->input_samplesize->currentText().toInt()); - fmt.setChannelCount(ui->input_channels->currentText().toInt()); + fmt.setSampleRate(ui->input_samplerate->value()); + fmt.setChannelCount(ui->input_channels->value()); + // fmt.setByteOrder(QAudioFormat::LittleEndian); + // fmt.setChannelConfig(??); + fmt.setSampleFormat(stringToSampleFormat(ui->input_sampleformat->currentText())); return fmt; } -void MainWindow::setFmt(const QAudioFormat &fmt) { +void MainWindow::setFmt(const QAudioFormat &fmt) const { qInfo() << "Setting fmt: " << fmt; - ui->input_samplerate->setCurrentText(QString::number(fmt.sampleRate())); - ui->input_samplesize->setCurrentText(QString::number(fmt.sampleSize())); - ui->input_channels->setCurrentText(QString::number(fmt.channelCount())); - auto fmtStr = QStringLiteral("%1 channels, %2 bit @ %3 Hz") + ui->input_samplerate->setValue(fmt.sampleRate()); + ui->input_sampleformat->setCurrentText(sampleFormatToString(fmt.sampleFormat())); + ui->input_channels->setValue(fmt.channelCount()); + const auto fmtStr = QStringLiteral("%1 channels, %2 bit @ %3 Hz") .arg(fmt.channelCount()) - .arg(fmt.sampleSize()) + .arg(fmt.sampleFormat()) .arg(fmt.sampleRate()); ui->label_fmtresult->setText(fmtStr); } -void MainWindow::checkAudioFormat() { +void MainWindow::checkAudioFormat(const bool showModal = true) { auto fmt = selectedAudioFormat(); - auto info = currentDeviceInfo(); - if (info.isFormatSupported(fmt)) + if (const auto dev = currentDevice(); dev.isFormatSupported(fmt)) qInfo() << "Format is supported"; else { - QMessageBox::warning(this, "Format not supported", - "The requested format isn't supported; a supported format was automatically selected."); - fmt = info.nearestFormat(fmt); + if (showModal) + QMessageBox::warning(this, "Format not supported", + "The requested format isn't supported; the preferred format was automatically selected."); + fmt = dev.preferredFormat(); } setFmt(fmt); } -void MainWindow::updateComboBoxItems(QComboBox *box, QList values) { - const int lastValue = box->currentText().toInt(); - box->clear(); - for (int value : values) { - box->addItem(QString::number(value)); - if (lastValue == value) box->setCurrentIndex(box->count() - 1); - } -} - -void MainWindow::load_config(const QString &filename) { - QSettings settings(filename, QSettings::Format::IniFormat); +void MainWindow::load_config(const QString &filename) const { + const QSettings settings(filename, QSettings::Format::IniFormat); ui->input_name->setText(settings.value("AudioCapture/name", "MyAudioStream").toString()); ui->input_device->setCurrentIndex(settings.value("AudioCapture/device", 0).toInt()); - ui->input_samplerate->setCurrentIndex(settings.value("AudioCapture/samplerate", 1).toInt()); - ui->input_samplesize->setCurrentIndex(settings.value("AudioCapture/samplesize", 1).toInt()); - ui->input_channels->setCurrentIndex(settings.value("AudioCapture/channels", 0).toInt()); + ui->input_samplerate->setValue(settings.value("AudioCapture/samplerate", 1).toInt()); + const QString sampleSize = settings.value("AudioCapture/samplesize", "Int16").toString(); + ui->input_sampleformat->setCurrentIndex(ui->input_sampleformat->findData(QVariant::fromValue(sampleSize))); + ui->input_channels->setValue(settings.value("AudioCapture/channels", 0).toInt()); } -void MainWindow::save_config(const QString &filename) { +void MainWindow::save_config(const QString &filename) const { QSettings settings(filename, QSettings::Format::IniFormat); settings.beginGroup("AudioCapture"); settings.setValue("name", ui->input_name->text()); settings.setValue("device", ui->input_device->currentIndex()); - settings.setValue("samplerate", ui->input_samplerate->currentIndex()); - settings.setValue("samplesize", ui->input_samplesize->currentIndex()); - settings.setValue("channels", ui->input_channels->currentIndex()); + settings.setValue("samplerate", ui->input_samplerate->value()); + settings.setValue("samplesize", ui->input_sampleformat->currentText()); + settings.setValue("channels", ui->input_channels->value()); settings.sync(); } @@ -151,35 +189,39 @@ void MainWindow::closeEvent(QCloseEvent *ev) { void MainWindow::toggleRecording() { if (!reader) { // read the configuration from the UI fields - std::string name = ui->input_name->text().toStdString(); + auto const name = ui->input_name->text().toStdString(); auto fmt = selectedAudioFormat(); - int channel_count = fmt.channelCount(); - int samplerate = fmt.sampleRate(); - auto channel_format = bits2fmt(fmt.sampleSize()); - - std::string stream_id = currentDeviceInfo().deviceName().toStdString(); + auto const channel_count = fmt.channelCount(); + auto const samplerate = fmt.sampleRate(); + auto const channel_format = sampleFormatToLSL(fmt.sampleFormat()); + auto const stream_id = currentDevice().description().toStdString(); + // Create the LSL stream info lsl::stream_info info(name, "Audio", channel_count, samplerate, channel_format, stream_id); info.desc().append_child("provider").append_child_value("api", "QtMultimedia"); info.desc().append_child_value("device", ui->input_device->currentText().toStdString()); - audiodev = std::make_unique(currentDeviceInfo(), fmt, this); - auto buffer_ms = ui->input_buffersize->value(); - audiodev->setBufferSize(fmt.bytesForDuration(2 * buffer_ms * 1000)); + // Create and open the QIODevice that will receive the audio data reader = std::make_unique(lsl::stream_outlet(info)); reader->open(QIODevice::OpenModeFlag::WriteOnly); - audiodev->start(&*reader); - qInfo() << audiodev->state() << ' ' << audiodev->error(); + // Create the AudioSource + audiosrc = std::make_unique(currentDevice(), fmt, this); + // auto const buffer_ms = ui->input_buffersize->value(); + // audiosrc->setBufferSize(fmt.bytesForDuration(2 * buffer_ms * 1000)); + + // Start sinking audio data to the LSL IO device + audiosrc->start(&*reader); + qInfo() << audiosrc->state() << ' ' << audiosrc->error(); ui->linkButton->setText("Unlink"); } else { qInfo() << "Read " << reader->pos() << " bytes, " << reader->samples_written() << " samples, " << - ((double) reader->samples_written()/audiodev->format().sampleRate()) << 's'; - audiodev->stop(); - qInfo() << audiodev->state() << ' ' << audiodev->error(); + (static_cast(reader->samples_written())/audiosrc->format().sampleRate()) << 's'; + audiosrc->stop(); + qInfo() << audiosrc->state() << ' ' << audiosrc->error(); reader->close(); - audiodev = nullptr; + audiosrc = nullptr; reader = nullptr; ui->linkButton->setText("Link"); } @@ -189,7 +231,7 @@ void MainWindow::toggleRecording() { /** * 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: + * - [executablename].cfg in one the following folders: * - the current working directory * - the default config folder, e.g. '~/Library/Preferences' on OS X * - the executable folder @@ -206,16 +248,16 @@ QString MainWindow::find_config_file(const char *filename) { else return qfilename; } - QFileInfo exeInfo(QCoreApplication::applicationFilePath()); - QString defaultCfgFilename(exeInfo.completeBaseName() + ".cfg"); + const QFileInfo exeInfo(QCoreApplication::applicationFilePath()); + const QString defaultCfgFilename(exeInfo.completeBaseName() + ".cfg"); QStringList cfgpaths; cfgpaths << QDir::currentPath() << QStandardPaths::standardLocations(QStandardPaths::ConfigLocation) << exeInfo.path(); - for (auto path : cfgpaths) { + for (const auto& path : cfgpaths) { QString cfgfilepath = path + QDir::separator() + defaultCfgFilename; if (QFileInfo::exists(cfgfilepath)) return cfgfilepath; } - QMessageBox(QMessageBox::Warning, "No config file not found", + QMessageBox msg_box(QMessageBox::Warning, "No config file not found", QStringLiteral("No default config file could be found"), QMessageBox::Ok, this); return ""; } diff --git a/mainwindow.h b/mainwindow.h index 4602b5f..56d2a43 100644 --- a/mainwindow.h +++ b/mainwindow.h @@ -1,7 +1,8 @@ #ifndef MAINWINDOW_H #define MAINWINDOW_H #include "ui_mainwindow.h" -#include +#include +#include #include #include //for std::unique_ptr @@ -18,25 +19,24 @@ class MainWindow : public QMainWindow { private slots: void closeEvent(QCloseEvent *ev) override; void toggleRecording(); - void deviceChanged(); - void checkAudioFormat(); + void deviceChanged() const; + void checkAudioFormat(bool showModal); private: // Audio device handling - QAudioDeviceInfo currentDeviceInfo(); - void setFmt(const QAudioFormat &fmt); - QAudioFormat selectedAudioFormat(); - void updateSampleRates(); - void updateComboBoxItems(QComboBox *box, QList values); + [[nodiscard]] QAudioDevice currentDevice() const; + void setFmt(const QAudioFormat &fmt) const; + [[nodiscard]] QAudioFormat selectedAudioFormat() const; + // void updateSampleRates(); // 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); + void load_config(const QString &filename) const; + void save_config(const QString &filename) const; std::unique_ptr reader; - std::unique_ptr audiodev; + std::unique_ptr audiosrc; std::unique_ptr ui; // window pointer - QList devices; + QList devices; }; #endif // MAINWINDOW_H diff --git a/mainwindow.ui b/mainwindow.ui index 8894257..6071048 100644 --- a/mainwindow.ui +++ b/mainwindow.ui @@ -62,21 +62,15 @@ - - - - + - Sample Size + Sample Format - - - - + @@ -114,6 +108,23 @@ + + + + 99999 + + + + + + + 20 + + + 1 + + + @@ -122,7 +133,7 @@ 0 0 254 - 30 + 24 diff --git a/reader.cpp b/reader.cpp index 31e5c33..307716b 100644 --- a/reader.cpp +++ b/reader.cpp @@ -7,7 +7,7 @@ LslPusher::LslPusher(lsl::stream_outlet &&outlet) : out(std::move(outlet)), sample_bytes(out.info().sample_bytes()), cf(out.info().channel_format()) {} -qint64 LslPusher::writeData(const char* data, qint64 maxSize) +qint64 LslPusher::writeData(const char* data, const qint64 maxSize) { // qInfo() << "Write " << maxSize << ' ' << (maxSize/sample_bytes) << ' ' << (maxSize%sample_bytes); switch(cf) { diff --git a/reader.h b/reader.h index 2636563..03b80df 100644 --- a/reader.h +++ b/reader.h @@ -7,10 +7,10 @@ class LslPusher : public QIODevice { public: - LslPusher(lsl::stream_outlet &&outlet); + explicit LslPusher(lsl::stream_outlet &&outlet); qint64 writeData(const char *data, qint64 maxSize) override; - qint64 readData(char*, qint64 maxSize) override { return maxSize;} - qint64 samples_written() const { return pos() / sample_bytes; } + qint64 readData(char*, const qint64 maxSize) override { return maxSize;} + [[nodiscard]] qint64 samples_written() const { return pos() / sample_bytes; } private: lsl::stream_outlet out; diff --git a/scripts/sign_and_notarize.sh b/scripts/sign_and_notarize.sh new file mode 100755 index 0000000..425e9db --- /dev/null +++ b/scripts/sign_and_notarize.sh @@ -0,0 +1,107 @@ +#!/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: +# ./scripts/sign_and_notarize.sh build/install/AudioCapture.app +# ./scripts/sign_and_notarize.sh build/install/AudioCapture.app --notarize +# ============================================================================= + +set -e + +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 + +SIGN_IDENTITY="${APPLE_CODE_SIGN_IDENTITY_APP:--}" +ENTITLEMENTS_ARG="" + +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 + codesign --force --deep --sign "$SIGN_IDENTITY" \ + --options runtime \ + $ENTITLEMENTS_ARG \ + "$APP_PATH" +else + codesign --force --sign "$SIGN_IDENTITY" \ + --options runtime \ + $ENTITLEMENTS_ARG \ + "$APP_PATH" +fi + +echo "Verifying signature..." +codesign --verify --verbose "$APP_PATH" + +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 ===" + + 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 + + if [[ -d "$APP_PATH" ]]; then + echo "Stapling notarization ticket..." + xcrun stapler staple "$APP_PATH" + xcrun stapler validate "$APP_PATH" + fi + + rm -f "$ZIP_PATH" + + echo "" + echo "=== Notarization Complete ===" +fi + +echo "" +echo "Done!"