diff --git a/.github/scripts/install-ndi-linux.sh b/.github/scripts/install-ndi-linux.sh new file mode 100755 index 0000000..a99b015 --- /dev/null +++ b/.github/scripts/install-ndi-linux.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ -z "${NDI_SDK_URL_LINUX:-}" ]]; then + echo "NDI_SDK_URL_LINUX is not set" >&2 + exit 1 +fi + +archive="$RUNNER_TEMP/ndi-sdk-linux" +extract_dir="$RUNNER_TEMP/ndi-sdk-linux-extract" +mkdir -p "$extract_dir" + +curl -L --retry 3 --retry-delay 5 "$NDI_SDK_URL_LINUX" -o "$archive" + +if file "$archive" | grep -qi 'gzip compressed'; then + tar -xzf "$archive" -C "$extract_dir" +elif file "$archive" | grep -qi 'bzip2 compressed'; then + tar -xjf "$archive" -C "$extract_dir" +elif file "$archive" | grep -qi 'Zip archive'; then + unzip -q "$archive" -d "$extract_dir" +else + chmod +x "$archive" + (cd "$extract_dir" && printf 'y\ny\n' | "$archive") +fi + +# NDI's Linux download is a .tar.gz containing a self-extracting shell installer. +# Run it non-interactively after unpacking the outer archive. +installer="$(find "$extract_dir" -maxdepth 2 -type f -name 'Install_NDI_SDK*_Linux*.sh' -print -quit || true)" +existing_header="$(find "$extract_dir" -type f -path '*/include/Processing.NDI.Lib.h' -print -quit || true)" +if [[ -n "$installer" && -z "$existing_header" ]]; then + chmod +x "$installer" + (cd "$(dirname "$installer")" && printf 'y\ny\n' | PAGER=cat ./"$(basename "$installer")") +fi + +sdk_root="" +while IFS= read -r header; do + candidate="$(dirname "$(dirname "$header")")" + first_lib="$(find "$candidate" -type f -name 'libndi.so*' -print -quit || true)" + if [[ -n "$first_lib" ]]; then + sdk_root="$candidate" + break + fi +done < <(find "$extract_dir" /usr/local /opt -type f -path '*/include/Processing.NDI.Lib.h' 2>/dev/null || true) + +if [[ -z "$sdk_root" ]]; then + echo "Could not locate Processing.NDI.Lib.h and libndi.so after extracting Linux NDI SDK" >&2 + find "$extract_dir" -maxdepth 4 -type f | sort >&2 || true + exit 1 +fi + +# Normalize into the project-local layout CMake searches first. Preserve symlinks +# so versioned libndi.so chains remain intact. +case "$(uname -m)" in + x86_64|amd64) ndi_arch_dir="x86_64-linux-gnu" ;; + aarch64|arm64) ndi_arch_dir="aarch64-rpi4-linux-gnueabi" ;; + armv7l) ndi_arch_dir="arm-rpi4-linux-gnueabihf" ;; + *) ndi_arch_dir="" ;; +esac + +lib_source_dir="" +if [[ -n "$ndi_arch_dir" && -d "$sdk_root/lib/$ndi_arch_dir" ]]; then + lib_source_dir="$sdk_root/lib/$ndi_arch_dir" +elif [[ -d "$sdk_root/lib" ]]; then + lib_source_dir="$sdk_root/lib" +fi + +rm -rf ndi +mkdir -p ndi/include ndi/lib +cp -a "$sdk_root/include"/. ndi/include/ +if [[ -z "$lib_source_dir" ]]; then + echo "Could not locate an NDI library directory for $(uname -m) under $sdk_root/lib" >&2 + exit 1 +fi +cp -a "$lib_source_dir"/libndi.so* ndi/lib/ + +if [[ ! -e ndi/lib/libndi.so ]]; then + first_lib="$(find ndi/lib -type f -name 'libndi.so*' | sort | head -n1)" + [[ -n "$first_lib" ]] || { echo "No libndi.so found in normalized SDK" >&2; exit 1; } + ln -s "$(basename "$first_lib")" ndi/lib/libndi.so +fi + +echo "NDI SDK normalized to $PWD/ndi" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1468b2e..88c6982 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,6 +4,8 @@ on: push: branches: ["**"] tags: ["v*"] + pull_request: + branches: ["**"] workflow_dispatch: inputs: tag: @@ -265,11 +267,198 @@ jobs: path: OnPoint-*-Setup.exe if-no-files-found: error + # ───────────────────────────────────────────────────────────────────────────── + build-linux: + name: Linux x64 + runs-on: ubuntu-22.04 + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }} + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential \ + ninja-build \ + curl \ + file \ + unzip \ + libgl1-mesa-dev \ + libxkbcommon-dev \ + libxcb-cursor0 \ + libxcb-cursor-dev \ + libopencv-dev \ + libavahi-compat-libdnssd-dev + + - name: Install Qt + uses: jurplel/install-qt-action@v4 + with: + version: "6.8.3" + host: linux + target: desktop + arch: linux_gcc_64 + modules: "qtmultimedia" + cache: true + + - name: Install NDI SDK + env: + NDI_SDK_URL_LINUX: ${{ secrets.NDI_SDK_URL_LINUX }} + run: bash .github/scripts/install-ndi-linux.sh + + - name: Configure + run: | + cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_PREFIX_PATH="$QT_ROOT_DIR" \ + -DCMAKE_INSTALL_PREFIX="$PWD/package/OnPoint-Linux-x86_64" + + - name: Build + run: cmake --build build --parallel $(nproc) + + - name: Package + run: | + set -euo pipefail + + appdir="$PWD/package/OnPoint-Linux-x86_64" + mkdir -p "$appdir/bin" "$appdir/lib" "$appdir/plugins" "$appdir/share/doc/onpoint" + + cp build/onpoint "$appdir/bin/" + cp -a ndi/lib/libndi.so* "$appdir/lib/" + cp README.md "$appdir/share/doc/onpoint/" 2>/dev/null || true + + # Bundle Qt from the aqt installation. The Linux artifact is built + # against Qt 6.8 from QT_ROOT_DIR, which is not available on a clean + # Ubuntu install, so the launcher must not depend on system Qt. + cp -aL "$QT_ROOT_DIR/lib"/libQt6*.so* "$appdir/lib/" + + for plugin_dir in \ + platforms \ + platformthemes \ + imageformats \ + iconengines \ + tls \ + xcbglintegrations \ + multimedia; do + if [ -d "$QT_ROOT_DIR/plugins/$plugin_dir" ]; then + cp -a "$QT_ROOT_DIR/plugins/$plugin_dir" "$appdir/plugins/" + fi + done + + cat > "$appdir/bin/qt.conf" <<'EOF' + [Paths] + Prefix = .. + Plugins = plugins + EOF + + should_bundle() { + local lib="$1" + local soname + soname="$(basename "$lib")" + + # Do not bundle glibc/loader primitives; they must come from the + # target system. Bundle the rest of the runtime closure so the + # archive works on minimal Ubuntu installs that may not have GLX, + # OpenCV, TBB, PulseAudio, or Qt plugin dependencies preinstalled. + case "$soname" in + libc.so.*|ld-linux-x86-64.so.*|libpthread.so.*|libdl.so.*|librt.so.*|libm.so.*|libresolv.so.*) + return 1 + ;; + esac + + case "$lib" in + "$appdir"/*|"$QT_ROOT_DIR"/*|"$PWD"/ndi/*|/usr/lib/*|/lib/*) + return 0 + ;; + *) + return 1 + ;; + esac + } + + copy_deps() { + local target="$1" + ldd "$target" \ + | awk '/=> \/.*\.so/ {print $3} /^\s*\/.*\.so/ {print $1}' \ + | while IFS= read -r lib; do + [ -f "$lib" ] || continue + case "$lib" in + "$appdir"/*) + continue + ;; + esac + if should_bundle "$lib"; then + cp -aL "$lib" "$appdir/lib/" + fi + done + } + + copy_deps build/onpoint + find "$appdir/plugins" -type f -name '*.so' -print0 | while IFS= read -r -d '' plugin; do + copy_deps "$plugin" + done + + # Resolve transitive dependencies from the libraries we just copied. + for _ in 1 2 3; do + find "$appdir/lib" -type f -name '*.so*' -print0 | while IFS= read -r -d '' lib; do + copy_deps "$lib" || true + done + done + + { + echo '#!/usr/bin/env bash' + echo 'set -euo pipefail' + echo 'APPDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"' + echo 'export LD_LIBRARY_PATH="$APPDIR/lib:${LD_LIBRARY_PATH:-}"' + echo 'export QT_PLUGIN_PATH="$APPDIR/plugins:${QT_PLUGIN_PATH:-}"' + echo 'exec "$APPDIR/bin/onpoint" "$@"' + } > "$appdir/run-onpoint.sh" + chmod +x "$appdir/run-onpoint.sh" + + echo "Checking packaged binary dependencies" + LD_LIBRARY_PATH="$appdir/lib" ldd "$appdir/bin/onpoint" | tee /tmp/onpoint-ldd.txt + if grep -q 'not found' /tmp/onpoint-ldd.txt; then + exit 1 + fi + + echo "Checking bundled runtime closure" + check_unbundled() { + local target="$1" + LD_LIBRARY_PATH="$appdir/lib" ldd "$target" \ + | awk '/=> \/.*\.so/ {print $1 " " $3} /^\s*\/.*\.so/ {print $1 " " $1}' \ + | while read -r soname lib; do + [ -f "$lib" ] || continue + if should_bundle "$lib" && [ "$lib" != "$appdir/lib/$soname" ]; then + echo "Missing bundled runtime library: $soname from $lib" + exit 1 + fi + done + } + + check_unbundled "$appdir/bin/onpoint" + find "$appdir/plugins" -type f -name '*.so' -print0 | while IFS= read -r -d '' plugin; do + check_unbundled "$plugin" + done + find "$appdir/lib" -type f -name '*.so*' -print0 | while IFS= read -r -d '' lib; do + check_unbundled "$lib" || true + done + + tar -C package -czf OnPoint-Linux-x86_64.tar.gz OnPoint-Linux-x86_64 + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: OnPoint-Linux-x86_64 + path: OnPoint-Linux-x86_64.tar.gz + if-no-files-found: error + # ───────────────────────────────────────────────────────────────────────────── release: name: GitHub Release if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' - needs: [build-macos, build-windows] + needs: [build-macos, build-windows, build-linux] runs-on: ubuntu-latest env: TAG_NAME: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }} @@ -287,3 +476,4 @@ jobs: files: | OnPoint.dmg OnPoint-*-Setup.exe + OnPoint-Linux-x86_64.tar.gz diff --git a/CMakeLists.txt b/CMakeLists.txt index e55e5b7..36ed545 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -109,11 +109,20 @@ if(WIN32) "C:/Program Files (x86)/NDI/NDI Advanced SDK 6" "$ENV{NDI_SDK_DIR}" ) -else() +elseif(APPLE) set(NDI_SEARCH_PATHS "${CMAKE_SOURCE_DIR}/ndi" "/Library/NDI SDK for Apple" "/usr/local" + "$ENV{NDI_SDK_DIR}" + ) +else() + set(NDI_SEARCH_PATHS + "${CMAKE_SOURCE_DIR}/ndi" + "/opt/ndi" + "/usr/local" + "/usr" + "$ENV{NDI_SDK_DIR}" ) endif() @@ -131,12 +140,23 @@ foreach(NDI_BASE ${NDI_SEARCH_PATHS}) break() endif() endforeach() - else() + elseif(APPLE) if(EXISTS "${NDI_BASE}/lib/macOS/libndi.dylib") set(NDI_LIB "${NDI_BASE}/lib/macOS/libndi.dylib") elseif(EXISTS "${NDI_BASE}/lib/libndi.dylib") set(NDI_LIB "${NDI_BASE}/lib/libndi.dylib") endif() + else() + foreach(_lib_path + "${NDI_BASE}/lib/x86_64-linux-gnu/libndi.so" + "${NDI_BASE}/lib/x64/libndi.so" + "${NDI_BASE}/lib/libndi.so" + "${NDI_BASE}/lib64/libndi.so") + if(EXISTS "${_lib_path}") + set(NDI_LIB "${_lib_path}") + break() + endif() + endforeach() endif() if(NDI_LIB) set(NDI_FOUND TRUE) @@ -198,11 +218,16 @@ else() "Install the NDI 6 SDK for Windows from https://ndi.video/download-ndi-sdk/ " "or place the SDK directory under ${CMAKE_SOURCE_DIR}/ndi/.\n" "You can also set the NDI_SDK_DIR environment variable to the SDK root.") - else() + elseif(APPLE) message(FATAL_ERROR "NDI SDK not found. " "Install 'NDI SDK for Apple' (6.x) so CMake can find " "Processing.NDI.Lib.h and libndi.dylib.") + else() + message(FATAL_ERROR + "NDI SDK not found. Install the NDI SDK for Linux so CMake can find " + "Processing.NDI.Lib.h and libndi.so, place it under ${CMAKE_SOURCE_DIR}/ndi/, " + "or set NDI_SDK_DIR to the SDK root.") endif() endif() @@ -268,6 +293,25 @@ if(APPLE) endif() +# ── Linux-specific ──────────────────────────────────────────────────────────── +if(UNIX AND NOT APPLE) + find_path(DNSSD_INCLUDE_DIR dns_sd.h) + find_library(DNSSD_LIB dns_sd) + if(NOT DNSSD_INCLUDE_DIR OR NOT DNSSD_LIB) + message(FATAL_ERROR + "DNS-SD compatibility library not found. Install libavahi-compat-libdnssd-dev " + "(Debian/Ubuntu) or the equivalent package for your distribution.") + endif() + + target_sources(onpoint PRIVATE src/DnsSdBridge_win.cpp) + target_include_directories(onpoint PRIVATE "${DNSSD_INCLUDE_DIR}") + target_link_libraries(onpoint PRIVATE "${DNSSD_LIB}") + target_compile_definitions(onpoint PRIVATE DNSSD_AVAILABLE=1) + message(STATUS "Found DNS-SD compatibility library — session discovery enabled") + + install(TARGETS onpoint RUNTIME DESTINATION bin) +endif() + # ── Windows-specific ────────────────────────────────────────────────────────── if(WIN32) # Use the Windows GUI subsystem (no console window) diff --git a/src/DnsSdBridge_win.cpp b/src/DnsSdBridge_win.cpp index 53ececb..a9e418e 100644 --- a/src/DnsSdBridge_win.cpp +++ b/src/DnsSdBridge_win.cpp @@ -3,7 +3,11 @@ #if defined(DNSSD_AVAILABLE) && DNSSD_AVAILABLE -#include +#ifdef _WIN32 +# include +#else +# include +#endif #include #include #include @@ -201,8 +205,8 @@ DnsSdBridge::~DnsSdBridge() {} void DnsSdBridge::advertise(const QString&, quint16) { QTimer::singleShot(0, this, [this]() { emit advertiseError( - QStringLiteral("Session discovery unavailable: " - "install Apple Bonjour for Windows")); + QStringLiteral("Session discovery unavailable: install a DNS-SD compatibility library " + "(Bonjour on Windows or Avahi libdns_sd on Linux)")); }); } void DnsSdBridge::stopAdvertising() {} diff --git a/src/ui/StreamSourcePanel.cpp b/src/ui/StreamSourcePanel.cpp index 288127d..0bf14ea 100644 --- a/src/ui/StreamSourcePanel.cpp +++ b/src/ui/StreamSourcePanel.cpp @@ -511,7 +511,6 @@ class DecklinkSourceTab : public VideoSourceTab { bool settingSource_ = false; #else void refreshSources() override {} - QString selectedSource() const override { return {}; } void setCurrentSource(const QString&) override {} #endif };