From ae33ffed5b7be1973a910062a5a677fb6347a01e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:10:04 +0000 Subject: [PATCH 01/14] Initial plan From b615e8e4548659ae5c47bf70c486c43079d36668 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:32:01 +0000 Subject: [PATCH 02/14] feat: add Linux multi-arch AppImage build workflow and docs Agent-Logs-Url: https://github.com/abossard/qlcplus/sessions/4896c4af-2769-44a6-84bc-0a89aac0a078 Co-authored-by: abossard <86611+abossard@users.noreply.github.com> --- .github/workflows/linux-build.yml | 449 ++++++++++++++++++++++++++++++ README.md | 7 + docs/LINUX_BUILDS.md | 112 ++++++++ platforms/linux/CMakeLists.txt | 16 +- 4 files changed, 582 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/linux-build.yml create mode 100644 docs/LINUX_BUILDS.md diff --git a/.github/workflows/linux-build.yml b/.github/workflows/linux-build.yml new file mode 100644 index 0000000000..92f2947b90 --- /dev/null +++ b/.github/workflows/linux-build.yml @@ -0,0 +1,449 @@ +name: Linux Build + +# Produces self-contained AppImages for QLC+ (qlcplus-qml / v5) on: +# • x86_64 — native GitHub runner, Qt installed via jurplel/install-qt-action +# • aarch64 — GitHub-hosted ARM64 runner (ubuntu-22.04-arm), system Qt 6 from apt +# +# armv7 is NOT built automatically: GitHub has no armv7 runners, Qt 6 for armv7 +# is only available as Qt 6.2.x via apt on Ubuntu 22.04, and full QEMU emulation +# of a Qt 6 build takes 45–90 minutes which is impractical for CI. If you need +# an armv7 artifact, set the `build_armv7` workflow_dispatch input to true; the +# job will run but may time out on free-tier runners. + +on: + workflow_dispatch: + inputs: + build_armv7: + description: 'Also attempt armv7 (slow QEMU cross-build, may time-out)' + type: boolean + default: false + + push: + tags: + - 'v*' + + pull_request: + paths: + - '**.cpp' + - '**.h' + - '**.qml' + - '**/CMakeLists.txt' + - 'variables.cmake' + - 'coverage.cmake' + - '.github/workflows/linux-build.yml' + - 'platforms/linux/**' + +# Cancel in-progress runs for the same ref on PRs so we don't waste runners. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + # --------------------------------------------------------------------------- + # Main matrix build: x86_64 (always) + aarch64 (skipped on PRs for speed) + # --------------------------------------------------------------------------- + build: + name: Build (${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} + + # On PRs only build x86_64 to keep CI fast. + # skip_on_pr=true causes the job to be skipped when triggered by a PR. + if: ${{ !matrix.skip_on_pr || github.event_name != 'pull_request' }} + + strategy: + fail-fast: false + matrix: + include: + # ── x86_64 ────────────────────────────────────────────────────────── + # Native GitHub runner; Qt installed via jurplel/install-qt-action so + # we get a modern, self-contained Qt build and all libraries are in a + # known location. + - arch: x86_64 + runner: ubuntu-22.04 + qt_method: action + qt_version: '6.9.3' + linuxdeploy_url: https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage + linuxdeploy_qt_url: https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/continuous/linuxdeploy-plugin-qt-x86_64.AppImage + appimagetool_url: https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage + appimage_arch: x86_64 + run_tests: true + skip_on_pr: false + + # ── aarch64 ───────────────────────────────────────────────────────── + # GitHub-hosted ARM64 runner (ubuntu-22.04-arm). jurplel/install-qt- + # action does not ship Linux-ARM desktop Qt prebuilts, so we use the + # system Qt 6 packages from Ubuntu 22.04's apt repositories instead. + # If this runner label is unavailable for your fork, replace it with + # "ubuntu-22.04" and add QEMU emulation via docker/setup-qemu-action. + - arch: aarch64 + runner: ubuntu-22.04-arm + qt_method: apt + linuxdeploy_url: https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-aarch64.AppImage + linuxdeploy_qt_url: https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/continuous/linuxdeploy-plugin-qt-aarch64.AppImage + appimagetool_url: https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-aarch64.AppImage + appimage_arch: aarch64 + run_tests: false + skip_on_pr: true + + defaults: + run: + shell: bash + + env: + # Make AppImage tools extract and run without FUSE (safer in CI) + APPIMAGE_EXTRACT_AND_RUN: "1" + + steps: + # ── Checkout ──────────────────────────────────────────────────────────── + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: false + fetch-depth: 0 + + # ── System dependencies ───────────────────────────────────────────────── + - name: Install system dependencies + run: | + set -euxo pipefail + sudo apt-get update -q + sudo apt-get install -y --no-install-recommends \ + build-essential \ + cmake \ + ninja-build \ + pkg-config \ + libusb-1.0-0-dev \ + libftdi1-dev \ + libasound2-dev \ + libudev-dev \ + libfftw3-dev \ + libmad0-dev \ + libsndfile1-dev \ + liblo-dev \ + libpulse-dev \ + libfuse2 \ + patchelf \ + shared-mime-info \ + wget \ + file + + # ── Qt (x86_64): official prebuilts via jurplel/install-qt-action ─────── + - name: Install Qt via install-qt-action (x86_64) + if: ${{ matrix.qt_method == 'action' }} + uses: jurplel/install-qt-action@v4 + with: + version: ${{ matrix.qt_version }} + cache: true + modules: 'qt3d qtimageformats qtmultimedia qtserialport qtwebsockets' + + # ── Qt (aarch64): system packages from Ubuntu apt ──────────────────────── + - name: Install Qt via apt (aarch64) + if: ${{ matrix.qt_method == 'apt' }} + run: | + set -euxo pipefail + sudo apt-get install -y --no-install-recommends \ + qt6-base-dev \ + qt6-base-private-dev \ + qt6-declarative-dev \ + qt6-declarative-private-dev \ + qt6-multimedia-dev \ + qt6-websockets-dev \ + qt6-svg-dev \ + qt6-3d-dev \ + qt6-serialport-dev \ + qt6-tools-dev \ + qt6-l10n-tools \ + qt6-image-formats-plugins \ + libqt6opengl6-dev \ + qml6-module-qtquick \ + qml6-module-qtquick-controls \ + qml6-module-qtquick-layouts \ + qml6-module-qtquick-window \ + qml6-module-qtmultimedia \ + libqt6multimedia6-plugins \ + libgl1-mesa-dev + + # ── Build directory cache ───────────────────────────────────────────── + - name: Cache CMake build directory + uses: actions/cache@v4 + with: + path: build + key: ${{ runner.os }}-${{ matrix.arch }}-cmake-${{ hashFiles('**/CMakeLists.txt', 'variables.cmake') }} + restore-keys: | + ${{ runner.os }}-${{ matrix.arch }}-cmake- + + # ── Environment ──────────────────────────────────────────────────────── + - name: Set build environment variables + run: | + set -euxo pipefail + # APPVERSION from variables.cmake (qmlui variant, e.g. "5.2.2 GIT") + APPVERSION=$(awk -F'"' '/set\(APPVERSION "5\./{print $2; exit}' variables.cmake \ + | tr ' ' '-') + GIT_REV=$(git rev-parse --short HEAD) + echo "APPVERSION=${APPVERSION}-${GIT_REV}" >> "$GITHUB_ENV" + echo "GIT_REV=${GIT_REV}" >> "$GITHUB_ENV" + echo "INSTALL_ROOT=$(pwd)/AppDir" >> "$GITHUB_ENV" + # Find qmake6 (needed by linuxdeploy-plugin-qt) + if command -v qmake6 &>/dev/null; then + echo "QMAKE=$(command -v qmake6)" >> "$GITHUB_ENV" + elif [ -n "${QTDIR:-}" ] && [ -x "$QTDIR/bin/qmake6" ]; then + echo "QMAKE=$QTDIR/bin/qmake6" >> "$GITHUB_ENV" + elif [ -n "${QTDIR:-}" ] && [ -x "$QTDIR/bin/qmake" ]; then + echo "QMAKE=$QTDIR/bin/qmake" >> "$GITHUB_ENV" + else + echo "QMAKE=$(command -v qmake)" >> "$GITHUB_ENV" + fi + + # ── CMake configure ─────────────────────────────────────────────────── + - name: Configure CMake + run: | + set -euxo pipefail + CMAKE_EXTRA_ARGS=() + # For install-qt-action builds, point CMake at the custom Qt prefix + if [ "${{ matrix.qt_method }}" = "action" ] && [ -n "${QTDIR:-}" ]; then + CMAKE_EXTRA_ARGS+=("-DCMAKE_PREFIX_PATH=${QTDIR}/lib/cmake") + fi + cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -Dqmlui=ON \ + -Dmcp_server=ON \ + -Dappimage=ON \ + "-DINSTALL_ROOT=${INSTALL_ROOT}" \ + "${CMAKE_EXTRA_ARGS[@]}" + + # ── Build ────────────────────────────────────────────────────────────── + - name: Build + run: | + set -euxo pipefail + cmake --build build -j$(nproc) + + # ── Tests (x86_64 only) ──────────────────────────────────────────────── + - name: Run unit tests + if: ${{ matrix.run_tests }} + run: | + set -euxo pipefail + build/mcp/test/mcp_vc_query_filter_test + build/mcp/test/mcp_vc_validation_test + + # ── Install to AppDir ────────────────────────────────────────────────── + # cmake --install copies the binary, data files, qlcplus I/O plugins, + # desktop file, icons, and (where available) Qt libraries/plugins. + # The OPTIONAL keyword on Qt-specific installs in platforms/linux/ + # CMakeLists.txt ensures that aarch64 system-Qt builds don't fail when + # Qt libraries land in architecture-specific paths not known to cmake. + - name: Install to AppDir + run: | + set -euxo pipefail + cmake --install build + + # ── Download and extract AppImage tooling ────────────────────────────── + - name: Download linuxdeploy and plugin-qt + run: | + set -euxo pipefail + wget -q "${{ matrix.linuxdeploy_url }}" -O linuxdeploy.AppImage + wget -q "${{ matrix.linuxdeploy_qt_url }}" -O linuxdeploy-plugin-qt.AppImage + wget -q "${{ matrix.appimagetool_url }}" -O appimagetool.AppImage + chmod +x linuxdeploy.AppImage linuxdeploy-plugin-qt.AppImage appimagetool.AppImage + + # ── Bundle Qt libraries, plugins, and QML modules via linuxdeploy ────── + # linuxdeploy uses ldd to find all shared-library dependencies and copies + # them to AppDir/usr/lib. linuxdeploy-plugin-qt additionally copies Qt + # platform plugins, image-format plugins, QML imports, and creates a + # qt.conf so Qt finds its plugins at runtime inside the AppImage. + - name: Bundle dependencies with linuxdeploy + env: + QMAKE: ${{ env.QMAKE }} + QML_SOURCES_PATHS: ${{ github.workspace }}/qmlui + run: | + set -euxo pipefail + ./linuxdeploy.AppImage \ + --appdir AppDir \ + --executable AppDir/usr/bin/qlcplus-qml \ + --desktop-file platforms/linux/qlcplus5.desktop \ + --icon-file resources/icons/png/qlcplus.png \ + --plugin qt \ + --verbosity 1 + + # ── Create custom AppRun ─────────────────────────────────────────────── + # QLC+ compiles data paths as relative strings (e.g. "../share/qlcplus") + # that it resolves against the process CWD at runtime. The -Dappimage=ON + # CMake option installs data under AppDir/share/ and plugins under + # AppDir/lib/, both one level above the binary dir (AppDir/usr/bin/). + # Setting CWD to AppDir/usr before exec makes those "../..." paths resolve + # correctly inside the AppImage mount. + - name: Write custom AppRun + run: | + set -euxo pipefail + cat > AppDir/AppRun << 'APPRUN_EOF' + #!/bin/bash + set -e + HERE="$(dirname "$(readlink -f "${0}")")" + export APPDIR="$HERE" + + # Qt platform plugin and QML import paths set up by linuxdeploy-plugin-qt + export QT_PLUGIN_PATH="${HERE}/usr/plugins:${QT_PLUGIN_PATH:-}" + export QML2_IMPORT_PATH="${HERE}/usr/qml:${QML2_IMPORT_PATH:-}" + export LD_LIBRARY_PATH="${HERE}/usr/lib:${LD_LIBRARY_PATH:-}" + + # Change CWD to AppDir/usr so that relative data paths compiled into + # qlcconfig.h (e.g. "../share/qlcplus/fixtures") resolve correctly. + cd "${HERE}/usr" + + exec "${HERE}/usr/bin/qlcplus-qml" "$@" + APPRUN_EOF + # Remove leading whitespace that heredoc indentation introduced + sed -i 's/^[[:space:]]*//' AppDir/AppRun + chmod +x AppDir/AppRun + + # ── Pack into AppImage ───────────────────────────────────────────────── + - name: Build AppImage + env: + VERSION: ${{ env.APPVERSION }} + run: | + set -euxo pipefail + OUTPUT="QLC+-${APPVERSION}-${{ matrix.appimage_arch }}.AppImage" + ./appimagetool.AppImage -v AppDir "$OUTPUT" + echo "APPIMAGE_PATH=${OUTPUT}" >> "$GITHUB_ENV" + + # ── SHA-256 checksum ────────────────────────────────────────────────── + - name: Generate SHA256 checksum + run: | + set -euxo pipefail + sha256sum "${APPIMAGE_PATH}" > "${APPIMAGE_PATH}.sha256" + + # ── Upload as workflow artifact ──────────────────────────────────────── + - name: Upload AppImage artifact + uses: actions/upload-artifact@v4 + with: + name: QLC+-${{ env.APPVERSION }}-${{ matrix.appimage_arch }} + path: | + ${{ env.APPIMAGE_PATH }} + ${{ env.APPIMAGE_PATH }}.sha256 + if-no-files-found: error + + # ── Attach to GitHub Release on tag push ────────────────────────────── + - name: Attach to GitHub Release + if: ${{ startsWith(github.ref, 'refs/tags/v') }} + uses: softprops/action-gh-release@v2 + with: + files: | + ${{ env.APPIMAGE_PATH }} + ${{ env.APPIMAGE_PATH }}.sha256 + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # --------------------------------------------------------------------------- + # armv7 — optional, workflow_dispatch only + # --------------------------------------------------------------------------- + # armv7 is gated behind a manual input because: + # 1. GitHub does not offer native armv7 (32-bit ARM) hosted runners. + # 2. QEMU emulation of a full Qt 6 CMake build takes 45–90 minutes, + # which exceeds typical free-tier job time limits. + # 3. Qt 6 for Linux/armhf is only available as Qt 6.2.x via Ubuntu 22.04 + # apt (no official Qt prebuilts for Linux armhf desktop). + # If you need armv7 artifacts, consider a dedicated self-hosted armhf runner + # or a Raspberry Pi build farm instead. + build-armv7: + name: Build (armv7) [manual only] + runs-on: ubuntu-22.04 + if: ${{ github.event_name == 'workflow_dispatch' && inputs.build_armv7 == true }} + + defaults: + run: + shell: bash + + env: + APPIMAGE_EXTRACT_AND_RUN: "1" + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: false + fetch-depth: 0 + + - name: Set up QEMU for armv7 + uses: docker/setup-qemu-action@v3 + with: + platforms: arm + + - name: Build inside armv7 Docker container + uses: addnab/docker-run-action@v3 + with: + image: arm32v7/ubuntu:22.04 + options: >- + --platform linux/arm/v7 + -v ${{ github.workspace }}:/workspace + -e DEBIAN_FRONTEND=noninteractive + run: | + set -euxo pipefail + apt-get update -q + apt-get install -y --no-install-recommends \ + build-essential cmake ninja-build pkg-config \ + qt6-base-dev qt6-base-private-dev \ + qt6-declarative-dev qt6-declarative-private-dev \ + qt6-multimedia-dev qt6-websockets-dev qt6-svg-dev \ + qt6-3d-dev qt6-tools-dev qt6-l10n-tools \ + libusb-1.0-0-dev libftdi1-dev libasound2-dev libudev-dev \ + libfftw3-dev libmad0-dev libsndfile1-dev liblo-dev libpulse-dev \ + patchelf wget file git + cd /workspace + APPVERSION=$(awk -F'"' '/set\(APPVERSION "5\./{print $2; exit}' variables.cmake | tr ' ' '-') + GIT_REV=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") + APPVERSION="${APPVERSION}-${GIT_REV}" + cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -Dqmlui=ON \ + -Dmcp_server=ON \ + -Dappimage=ON \ + "-DINSTALL_ROOT=/workspace/AppDir-armv7" + cmake --build build -j$(nproc) + cmake --install build + # Package as tarball (no armhf AppImage tools available) + tar -czf "QLC+-${APPVERSION}-armv7.tar.gz" -C /workspace AppDir-armv7 + sha256sum "QLC+-${APPVERSION}-armv7.tar.gz" > "QLC+-${APPVERSION}-armv7.tar.gz.sha256" + echo "APPVERSION=${APPVERSION}" >> /workspace/.armv7_env + + - name: Read armv7 env + run: | + set -euxo pipefail + # shellcheck disable=SC1091 + source .armv7_env + echo "APPVERSION=${APPVERSION}" >> "$GITHUB_ENV" + echo "TARBALL=QLC+-${APPVERSION}-armv7.tar.gz" >> "$GITHUB_ENV" + + - name: Upload armv7 tarball artifact + uses: actions/upload-artifact@v4 + with: + name: QLC+-${{ env.APPVERSION }}-armv7 + path: | + ${{ env.TARBALL }} + ${{ env.TARBALL }}.sha256 + if-no-files-found: error + + - name: Attach armv7 tarball to GitHub Release + if: ${{ startsWith(github.ref, 'refs/tags/v') }} + uses: softprops/action-gh-release@v2 + with: + files: | + ${{ env.TARBALL }} + ${{ env.TARBALL }}.sha256 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # --------------------------------------------------------------------------- + # Aggregation job — branch protection can require this single check + # --------------------------------------------------------------------------- + ci-success: + name: CI Success + runs-on: ubuntu-22.04 + needs: [build] + if: always() + steps: + - name: Check required build jobs + run: | + if [[ "${{ needs.build.result }}" == "failure" ]]; then + echo "❌ One or more required build jobs failed." + exit 1 + fi + echo "✅ All required build jobs succeeded (result: ${{ needs.build.result }})." diff --git a/README.md b/README.md index 4bf77b43a7..486a768e2f 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,13 @@

Q Light Controller+

+## Linux Builds (AppImage) + +Pre-built, self-contained AppImages for **x86\_64** and **aarch64** are +attached to every [tagged release](../../releases). See +[docs/LINUX_BUILDS.md](docs/LINUX_BUILDS.md) for download, verification, and +run instructions. + > ## ⚠️ EXPERIMENTAL FORK — USE AT YOUR OWN RISK ⚠️ > > **This fork was largely vibe-coded with AI pair-programming.** diff --git a/docs/LINUX_BUILDS.md b/docs/LINUX_BUILDS.md new file mode 100644 index 0000000000..33be4ae864 --- /dev/null +++ b/docs/LINUX_BUILDS.md @@ -0,0 +1,112 @@ +# Linux Builds — AppImage Downloads + +QLC+ (the QML/v5 variant, `qlcplus-qml`) is built automatically as a +self-contained **AppImage** for multiple Linux architectures on every tagged +release. + +## Supported Architectures + +| Architecture | Description | +|---|---| +| `x86_64` | Intel / AMD 64-bit (most desktop and server Linux) | +| `aarch64` | ARMv8 64-bit (Raspberry Pi 4/5 in 64-bit mode, Ampere, Apple Silicon under Rosetta/Asahi, etc.) | +| `armv7` | ARMv7 hard-float *(manual dispatch only — see notes below)* | + +## Downloading an AppImage + +1. Go to the [**Releases** page](../../releases) of this repository. +2. Expand the **Assets** section of the latest release. +3. Download the AppImage that matches your architecture, for example: + - `QLC+-5.2.2-GIT-abcdef1-x86_64.AppImage` + - `QLC+-5.2.2-GIT-abcdef1-aarch64.AppImage` +4. Optionally download the matching `.sha256` file to verify integrity. + +## Verifying the Download + +```bash +sha256sum --check QLC+-5.2.2-GIT-abcdef1-x86_64.AppImage.sha256 +``` + +## Running the AppImage + +```bash +# Make it executable (only needed once) +chmod +x QLC+-5.2.2-GIT-abcdef1-x86_64.AppImage + +# Run it +./QLC+-5.2.2-GIT-abcdef1-x86_64.AppImage +``` + +### Without FUSE (fallback) + +AppImages normally require FUSE to mount themselves. If FUSE is unavailable +(e.g. inside a container or on a stripped-down server image), you can still run +the AppImage by extracting it first: + +```bash +# Extract the AppImage into a folder called "squashfs-root" +./QLC+-5.2.2-GIT-abcdef1-x86_64.AppImage --appimage-extract + +# Run the extracted binary +./squashfs-root/AppRun +``` + +Or set the environment variable before running: + +```bash +APPIMAGE_EXTRACT_AND_RUN=1 ./QLC+-5.2.2-GIT-abcdef1-x86_64.AppImage +``` + +## Runtime Requirements + +| Requirement | Minimum version | +|---|---| +| Linux kernel | 3.10+ (5.x+ recommended) | +| glibc | 2.35 (Ubuntu 22.04 baseline) | +| FUSE 2 | Required for normal AppImage execution (`libfuse2` on Debian/Ubuntu) — or use `--appimage-extract-and-run` | +| Display | X11 or Wayland (via XWayland) | + +No Qt installation is required — all Qt libraries and QML modules are bundled +inside the AppImage. + +## Installing FUSE 2 (if needed) + +```bash +# Debian / Ubuntu +sudo apt-get install libfuse2 + +# Fedora / RHEL +sudo dnf install fuse-libs + +# Arch Linux +sudo pacman -S fuse2 +``` + +## Notes on armv7 + +armv7 (32-bit ARM) builds are gated behind a manual `workflow_dispatch` trigger +because: + +- GitHub does not offer hosted armv7 runners. +- Qt 6 for Linux armv7 is only available as Qt 6.2.x (Ubuntu 22.04 apt). +- Full QEMU emulation of the build takes 45–90 minutes, exceeding typical + free-tier CI time limits. + +If you need armv7 packages, consider building on a native Raspberry Pi or +self-hosted ARM runner. armv7 artifacts are distributed as a `.tar.gz` +tarball rather than an AppImage (no `linuxdeploy` armhf AppImage tool is +publicly available). + +## Build System Details + +The CI workflow lives at +[`.github/workflows/linux-build.yml`](../.github/workflows/linux-build.yml). + +Key choices: + +| Aspect | x86_64 | aarch64 | +|---|---|---| +| Runner | `ubuntu-22.04` | `ubuntu-22.04-arm` | +| Qt installation | `jurplel/install-qt-action` (Qt 6.9.3) | System apt Qt 6 | +| Library bundling | `linuxdeploy` + `linuxdeploy-plugin-qt` | `linuxdeploy` + `linuxdeploy-plugin-qt` | +| Output | AppImage | AppImage | diff --git a/platforms/linux/CMakeLists.txt b/platforms/linux/CMakeLists.txt index 8d4a1fd6c1..364567bd0a 100644 --- a/platforms/linux/CMakeLists.txt +++ b/platforms/linux/CMakeLists.txt @@ -155,22 +155,28 @@ if(appimage) set(PLUGINS_DESTINATION ${INSTALLROOT}/${LIBSDIR}/qt5/plugins) endif() + # OPTIONAL on all Qt plugin installs so that builds using system Qt on non-x86_64 + # architectures (where paths may differ) do not fail — linuxdeploy handles + # bundling the missing Qt plugins in those cases. install( FILES ${QT_PLUGINS_PATH}/platforms/libqlinuxfb.so ${QT_PLUGINS_PATH}/platforms/libqxcb.so ${QT_PLUGINS_PATH}/platforms/libqminimal.so + OPTIONAL DESTINATION ${PLUGINS_DESTINATION}/platforms ) install( FILES ${QT_PLUGINS_PATH}/xcbglintegrations/libqxcb-glx-integration.so + OPTIONAL DESTINATION ${PLUGINS_DESTINATION}/xcbglintegrations ) if (QT_VERSION_MAJOR GREATER 5) install( FILES ${QT_PLUGINS_PATH}/multimedia/libffmpegmediaplugin.so + OPTIONAL DESTINATION ${PLUGINS_DESTINATION}/multimedia ) get_filename_component(LIBAVFORMAT_REAL "${QT_LIBS_PATH}/libavformat.so" REALPATH) @@ -179,6 +185,7 @@ if(appimage) ${QT_LIBS_PATH}/libavformat.so ${QT_LIBS_PATH}/libavformat.so.61 ${LIBAVFORMAT_REAL} + OPTIONAL DESTINATION ${INSTALLROOT}/${LIBSDIR} ) else() @@ -186,6 +193,7 @@ if(appimage) FILES ${QT_PLUGINS_PATH}/audio/libqtaudio_alsa.so ${QT_PLUGINS_PATH}/audio/libqtmedia_pulse.so + OPTIONAL DESTINATION ${PLUGINS_DESTINATION}/audio ) @@ -193,6 +201,7 @@ if(appimage) FILES ${QT_PLUGINS_PATH}/mediaservice/libgstaudiodecoder.so ${QT_PLUGINS_PATH}/mediaservice/libgstmediaplayer.so + OPTIONAL DESTINATION ${PLUGINS_DESTINATION}/mediaservice ) endif() @@ -200,6 +209,7 @@ if(appimage) install( FILES ${QT_PLUGINS_PATH}/imageformats/libqsvg.so + OPTIONAL DESTINATION ${PLUGINS_DESTINATION}/imageformats ) @@ -207,6 +217,7 @@ if(appimage) install( FILES ${QT_PLUGINS_PATH}/printsupport/libcupsprintersupport.so + OPTIONAL DESTINATION ${PLUGINS_DESTINATION}/printsupport ) @@ -219,11 +230,12 @@ if(appimage) install( FILES ${QT_PLUGINS_PATH}/geometryloaders/libdefaultgeometryloader.so + OPTIONAL DESTINATION ${PLUGINS_DESTINATION}/geometryloaders ) if(QT_VERSION VERSION_GREATER_EQUAL 5.15.0) - install(FILES ${QT_PLUGINS_PATH}/renderers/libopenglrenderer.so DESTINATION ${PLUGINS_DESTINATION}/renderers) + install(FILES ${QT_PLUGINS_PATH}/renderers/libopenglrenderer.so OPTIONAL DESTINATION ${PLUGINS_DESTINATION}/renderers) endif() set(qmldeps_files @@ -233,7 +245,7 @@ if(appimage) ${QT_QML_PATH}/Qt3D ${QT_QML_PATH}/QtMultimedia ) - install(DIRECTORY ${qmldeps_files} DESTINATION ${INSTALLROOT}/bin) + install(DIRECTORY ${qmldeps_files} OPTIONAL DESTINATION ${INSTALLROOT}/bin) endif() From 8ce14f76ec795b96b7a6e78dbb5cff6595315809 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:34:51 +0000 Subject: [PATCH 03/14] fix: add explicit permissions blocks and remove unnecessary shellcheck comment Agent-Logs-Url: https://github.com/abossard/qlcplus/sessions/4896c4af-2769-44a6-84bc-0a89aac0a078 Co-authored-by: abossard <86611+abossard@users.noreply.github.com> --- .github/workflows/linux-build.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/linux-build.yml b/.github/workflows/linux-build.yml index 92f2947b90..ac4f5a8490 100644 --- a/.github/workflows/linux-build.yml +++ b/.github/workflows/linux-build.yml @@ -38,6 +38,10 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} +# Default to read-only token; individual jobs that publish releases override. +permissions: + contents: read + jobs: # --------------------------------------------------------------------------- # Main matrix build: x86_64 (always) + aarch64 (skipped on PRs for speed) @@ -45,6 +49,8 @@ jobs: build: name: Build (${{ matrix.arch }}) runs-on: ${{ matrix.runner }} + permissions: + contents: write # needed by softprops/action-gh-release to upload AppImages # On PRs only build x86_64 to keep CI fast. # skip_on_pr=true causes the job to be skipped when triggered by a PR. @@ -347,6 +353,8 @@ jobs: name: Build (armv7) [manual only] runs-on: ubuntu-22.04 if: ${{ github.event_name == 'workflow_dispatch' && inputs.build_armv7 == true }} + permissions: + contents: write # needed by softprops/action-gh-release to upload tarballs defaults: run: @@ -407,7 +415,6 @@ jobs: - name: Read armv7 env run: | set -euxo pipefail - # shellcheck disable=SC1091 source .armv7_env echo "APPVERSION=${APPVERSION}" >> "$GITHUB_ENV" echo "TARBALL=QLC+-${APPVERSION}-armv7.tar.gz" >> "$GITHUB_ENV" @@ -439,6 +446,8 @@ jobs: runs-on: ubuntu-22.04 needs: [build] if: always() + permissions: + contents: read steps: - name: Check required build jobs run: | From 6211e9d5a32d18324f9c79d9dc9ee2f4cf83e8aa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:39:28 +0000 Subject: [PATCH 04/14] fix: use foreach+EXISTS for QML dir install to support cmake 3.16+ Agent-Logs-Url: https://github.com/abossard/qlcplus/sessions/4896c4af-2769-44a6-84bc-0a89aac0a078 Co-authored-by: abossard <86611+abossard@users.noreply.github.com> --- platforms/linux/CMakeLists.txt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/platforms/linux/CMakeLists.txt b/platforms/linux/CMakeLists.txt index 364567bd0a..6a257aff19 100644 --- a/platforms/linux/CMakeLists.txt +++ b/platforms/linux/CMakeLists.txt @@ -245,7 +245,13 @@ if(appimage) ${QT_QML_PATH}/Qt3D ${QT_QML_PATH}/QtMultimedia ) - install(DIRECTORY ${qmldeps_files} OPTIONAL DESTINATION ${INSTALLROOT}/bin) + # Use EXISTS check instead of OPTIONAL (OPTIONAL on DIRECTORY requires CMake 3.22, + # but cmake_minimum_required is 3.16; CI runners have 3.22+ but protect older builds) + foreach(_qml_dir ${qmldeps_files}) + if(EXISTS "${_qml_dir}") + install(DIRECTORY "${_qml_dir}" DESTINATION ${INSTALLROOT}/bin) + endif() + endforeach() endif() From 9106647e38e342c8e65a52aef8f4161e429a6aae Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:44:16 +0000 Subject: [PATCH 05/14] ci: add temporary branch push trigger to verify workflow runs Agent-Logs-Url: https://github.com/abossard/qlcplus/sessions/cd1b2937-1365-4589-a711-370f517fb912 Co-authored-by: abossard <86611+abossard@users.noreply.github.com> --- .github/workflows/linux-build.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/linux-build.yml b/.github/workflows/linux-build.yml index ac4f5a8490..26b01699c5 100644 --- a/.github/workflows/linux-build.yml +++ b/.github/workflows/linux-build.yml @@ -21,6 +21,10 @@ on: push: tags: - 'v*' + # Temporary: also run on the CI development branch so we can verify this + # workflow before it is merged. Remove after first successful run. + branches: + - 'copilot/add-github-actions-workflow-linux-builds' pull_request: paths: From 6e7960d799379b8009444ede38f3769631832ea1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:50:35 +0000 Subject: [PATCH 06/14] fix: rewrite workflow as separate arch jobs to fix matrix context error Agent-Logs-Url: https://github.com/abossard/qlcplus/sessions/cd1b2937-1365-4589-a711-370f517fb912 Co-authored-by: abossard <86611+abossard@users.noreply.github.com> --- .github/workflows/linux-build.yml | 384 +++++++++++++++++++----------- 1 file changed, 241 insertions(+), 143 deletions(-) diff --git a/.github/workflows/linux-build.yml b/.github/workflows/linux-build.yml index 26b01699c5..8003b9f924 100644 --- a/.github/workflows/linux-build.yml +++ b/.github/workflows/linux-build.yml @@ -46,72 +46,38 @@ concurrency: permissions: contents: read +# --------------------------------------------------------------------------- +# Shared setup steps are inlined per job because GitHub Actions does not +# allow the `matrix` context in job-level `if` conditions, so we cannot use +# a single matrix entry with a per-row skip flag. Two separate jobs keep +# the conditional logic simple and explicit. +# --------------------------------------------------------------------------- + jobs: - # --------------------------------------------------------------------------- - # Main matrix build: x86_64 (always) + aarch64 (skipped on PRs for speed) - # --------------------------------------------------------------------------- - build: - name: Build (${{ matrix.arch }}) - runs-on: ${{ matrix.runner }} + # ── x86_64 ────────────────────────────────────────────────────────────────── + # Runs on every trigger (PR, tag, dispatch, branch push). + # Qt installed via jurplel/install-qt-action (prebuilt binaries, known paths). + # Unit tests executed here. + build-x86_64: + name: Build (x86_64) + runs-on: ubuntu-22.04 permissions: - contents: write # needed by softprops/action-gh-release to upload AppImages - - # On PRs only build x86_64 to keep CI fast. - # skip_on_pr=true causes the job to be skipped when triggered by a PR. - if: ${{ !matrix.skip_on_pr || github.event_name != 'pull_request' }} - - strategy: - fail-fast: false - matrix: - include: - # ── x86_64 ────────────────────────────────────────────────────────── - # Native GitHub runner; Qt installed via jurplel/install-qt-action so - # we get a modern, self-contained Qt build and all libraries are in a - # known location. - - arch: x86_64 - runner: ubuntu-22.04 - qt_method: action - qt_version: '6.9.3' - linuxdeploy_url: https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage - linuxdeploy_qt_url: https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/continuous/linuxdeploy-plugin-qt-x86_64.AppImage - appimagetool_url: https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage - appimage_arch: x86_64 - run_tests: true - skip_on_pr: false - - # ── aarch64 ───────────────────────────────────────────────────────── - # GitHub-hosted ARM64 runner (ubuntu-22.04-arm). jurplel/install-qt- - # action does not ship Linux-ARM desktop Qt prebuilts, so we use the - # system Qt 6 packages from Ubuntu 22.04's apt repositories instead. - # If this runner label is unavailable for your fork, replace it with - # "ubuntu-22.04" and add QEMU emulation via docker/setup-qemu-action. - - arch: aarch64 - runner: ubuntu-22.04-arm - qt_method: apt - linuxdeploy_url: https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-aarch64.AppImage - linuxdeploy_qt_url: https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/continuous/linuxdeploy-plugin-qt-aarch64.AppImage - appimagetool_url: https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-aarch64.AppImage - appimage_arch: aarch64 - run_tests: false - skip_on_pr: true + contents: write # needed by softprops/action-gh-release defaults: run: shell: bash env: - # Make AppImage tools extract and run without FUSE (safer in CI) APPIMAGE_EXTRACT_AND_RUN: "1" steps: - # ── Checkout ──────────────────────────────────────────────────────────── - name: Checkout uses: actions/checkout@v4 with: submodules: false fetch-depth: 0 - # ── System dependencies ───────────────────────────────────────────────── - name: Install system dependencies run: | set -euxo pipefail @@ -136,18 +102,195 @@ jobs: wget \ file - # ── Qt (x86_64): official prebuilts via jurplel/install-qt-action ─────── - name: Install Qt via install-qt-action (x86_64) - if: ${{ matrix.qt_method == 'action' }} uses: jurplel/install-qt-action@v4 with: - version: ${{ matrix.qt_version }} + version: '6.9.3' cache: true modules: 'qt3d qtimageformats qtmultimedia qtserialport qtwebsockets' - # ── Qt (aarch64): system packages from Ubuntu apt ──────────────────────── + - name: Cache CMake build directory + uses: actions/cache@v4 + with: + path: build + key: Linux-x86_64-cmake-${{ hashFiles('**/CMakeLists.txt', 'variables.cmake') }} + restore-keys: Linux-x86_64-cmake- + + - name: Set build environment variables + run: | + set -euxo pipefail + APPVERSION=$(awk -F'"' '/set\(APPVERSION "5\./{print $2; exit}' variables.cmake \ + | tr ' ' '-') + GIT_REV=$(git rev-parse --short HEAD) + { + echo "APPVERSION=${APPVERSION}-${GIT_REV}" + echo "GIT_REV=${GIT_REV}" + echo "INSTALL_ROOT=$(pwd)/AppDir" + # qmake from install-qt-action is in QTDIR/bin + echo "QMAKE=${QTDIR}/bin/qmake" + } >> "$GITHUB_ENV" + + - name: Configure CMake + run: | + set -euxo pipefail + cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -Dqmlui=ON \ + -Dmcp_server=ON \ + -Dappimage=ON \ + "-DINSTALL_ROOT=${INSTALL_ROOT}" \ + "-DCMAKE_PREFIX_PATH=${QTDIR}/lib/cmake" + + - name: Build + run: | + set -euxo pipefail + cmake --build build -j"$(nproc)" + + - name: Run unit tests + run: | + set -euxo pipefail + build/mcp/test/mcp_vc_query_filter_test + build/mcp/test/mcp_vc_validation_test + + - name: Install to AppDir + run: | + set -euxo pipefail + cmake --install build + + - name: Download linuxdeploy and plugin-qt + run: | + set -euxo pipefail + wget -q https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage \ + -O linuxdeploy.AppImage + wget -q https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/continuous/linuxdeploy-plugin-qt-x86_64.AppImage \ + -O linuxdeploy-plugin-qt.AppImage + wget -q https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage \ + -O appimagetool.AppImage + chmod +x linuxdeploy.AppImage linuxdeploy-plugin-qt.AppImage appimagetool.AppImage + + # linuxdeploy uses ldd to find all shared-library dependencies and copies + # them to AppDir/usr/lib. linuxdeploy-plugin-qt additionally copies Qt + # platform plugins, image-format plugins, QML imports, and creates a + # qt.conf so Qt finds its plugins at runtime inside the AppImage. + - name: Bundle dependencies with linuxdeploy + env: + QMAKE: ${{ env.QMAKE }} + QML_SOURCES_PATHS: ${{ github.workspace }}/qmlui + run: | + set -euxo pipefail + ./linuxdeploy.AppImage \ + --appdir AppDir \ + --executable AppDir/usr/bin/qlcplus-qml \ + --desktop-file platforms/linux/qlcplus5.desktop \ + --icon-file resources/icons/png/qlcplus.png \ + --plugin qt \ + --verbosity 1 + + # QLC+ compiles data paths as relative strings (e.g. "../share/qlcplus") + # resolved against the process CWD. -Dappimage=ON installs data under + # AppDir/share/ and the binary under AppDir/usr/bin/. Setting CWD to + # AppDir/usr before exec makes those "../…" paths resolve correctly. + - name: Write custom AppRun + run: | + set -euxo pipefail + cat > AppDir/AppRun << 'APPRUN_EOF' + #!/bin/bash + set -e + HERE="$(dirname "$(readlink -f "${0}")")" + export APPDIR="$HERE" + export QT_PLUGIN_PATH="${HERE}/usr/plugins${QT_PLUGIN_PATH:+:${QT_PLUGIN_PATH}}" + export QML2_IMPORT_PATH="${HERE}/usr/qml${QML2_IMPORT_PATH:+:${QML2_IMPORT_PATH}}" + export LD_LIBRARY_PATH="${HERE}/usr/lib${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}" + cd "${HERE}/usr" + exec "${HERE}/usr/bin/qlcplus-qml" "$@" + APPRUN_EOF + sed -i 's/^[[:space:]]*//' AppDir/AppRun + chmod +x AppDir/AppRun + + - name: Build AppImage + run: | + set -euxo pipefail + OUTPUT="QLC+-${APPVERSION}-x86_64.AppImage" + ./appimagetool.AppImage -v AppDir "$OUTPUT" + echo "APPIMAGE_PATH=${OUTPUT}" >> "$GITHUB_ENV" + + - name: Generate SHA256 checksum + run: | + set -euxo pipefail + sha256sum "${APPIMAGE_PATH}" > "${APPIMAGE_PATH}.sha256" + + - name: Upload AppImage artifact + uses: actions/upload-artifact@v4 + with: + name: QLC+-${{ env.APPVERSION }}-x86_64 + path: | + ${{ env.APPIMAGE_PATH }} + ${{ env.APPIMAGE_PATH }}.sha256 + if-no-files-found: error + + - name: Attach to GitHub Release + if: ${{ startsWith(github.ref, 'refs/tags/v') }} + uses: softprops/action-gh-release@v2 + with: + files: | + ${{ env.APPIMAGE_PATH }} + ${{ env.APPIMAGE_PATH }}.sha256 + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # ── aarch64 ───────────────────────────────────────────────────────────────── + # Skipped on pull_request events to keep PR CI fast (ARM builds are ~2x slower). + # Uses system Qt 6 from Ubuntu 22.04 apt because jurplel/install-qt-action does + # not ship Linux/ARM desktop Qt prebuilts. + # Runner: ubuntu-22.04-arm (GitHub-hosted ARM64). If that label is unavailable, + # replace with "ubuntu-22.04" and add docker/setup-qemu-action for emulation. + build-aarch64: + name: Build (aarch64) + runs-on: ubuntu-22.04-arm + permissions: + contents: write + if: ${{ github.event_name != 'pull_request' }} + + defaults: + run: + shell: bash + + env: + APPIMAGE_EXTRACT_AND_RUN: "1" + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: false + fetch-depth: 0 + + - name: Install system dependencies + run: | + set -euxo pipefail + sudo apt-get update -q + sudo apt-get install -y --no-install-recommends \ + build-essential \ + cmake \ + ninja-build \ + pkg-config \ + libusb-1.0-0-dev \ + libftdi1-dev \ + libasound2-dev \ + libudev-dev \ + libfftw3-dev \ + libmad0-dev \ + libsndfile1-dev \ + liblo-dev \ + libpulse-dev \ + libfuse2 \ + patchelf \ + shared-mime-info \ + wget \ + file + - name: Install Qt via apt (aarch64) - if: ${{ matrix.qt_method == 'apt' }} run: | set -euxo pipefail sudo apt-get install -y --no-install-recommends \ @@ -172,93 +315,58 @@ jobs: libqt6multimedia6-plugins \ libgl1-mesa-dev - # ── Build directory cache ───────────────────────────────────────────── - name: Cache CMake build directory uses: actions/cache@v4 with: path: build - key: ${{ runner.os }}-${{ matrix.arch }}-cmake-${{ hashFiles('**/CMakeLists.txt', 'variables.cmake') }} - restore-keys: | - ${{ runner.os }}-${{ matrix.arch }}-cmake- + key: Linux-aarch64-cmake-${{ hashFiles('**/CMakeLists.txt', 'variables.cmake') }} + restore-keys: Linux-aarch64-cmake- - # ── Environment ──────────────────────────────────────────────────────── - name: Set build environment variables run: | set -euxo pipefail - # APPVERSION from variables.cmake (qmlui variant, e.g. "5.2.2 GIT") APPVERSION=$(awk -F'"' '/set\(APPVERSION "5\./{print $2; exit}' variables.cmake \ | tr ' ' '-') GIT_REV=$(git rev-parse --short HEAD) - echo "APPVERSION=${APPVERSION}-${GIT_REV}" >> "$GITHUB_ENV" - echo "GIT_REV=${GIT_REV}" >> "$GITHUB_ENV" - echo "INSTALL_ROOT=$(pwd)/AppDir" >> "$GITHUB_ENV" - # Find qmake6 (needed by linuxdeploy-plugin-qt) - if command -v qmake6 &>/dev/null; then - echo "QMAKE=$(command -v qmake6)" >> "$GITHUB_ENV" - elif [ -n "${QTDIR:-}" ] && [ -x "$QTDIR/bin/qmake6" ]; then - echo "QMAKE=$QTDIR/bin/qmake6" >> "$GITHUB_ENV" - elif [ -n "${QTDIR:-}" ] && [ -x "$QTDIR/bin/qmake" ]; then - echo "QMAKE=$QTDIR/bin/qmake" >> "$GITHUB_ENV" - else - echo "QMAKE=$(command -v qmake)" >> "$GITHUB_ENV" - fi + QMAKE=$(command -v qmake6 || command -v qmake) + { + echo "APPVERSION=${APPVERSION}-${GIT_REV}" + echo "GIT_REV=${GIT_REV}" + echo "INSTALL_ROOT=$(pwd)/AppDir" + echo "QMAKE=${QMAKE}" + } >> "$GITHUB_ENV" - # ── CMake configure ─────────────────────────────────────────────────── - name: Configure CMake run: | set -euxo pipefail - CMAKE_EXTRA_ARGS=() - # For install-qt-action builds, point CMake at the custom Qt prefix - if [ "${{ matrix.qt_method }}" = "action" ] && [ -n "${QTDIR:-}" ]; then - CMAKE_EXTRA_ARGS+=("-DCMAKE_PREFIX_PATH=${QTDIR}/lib/cmake") - fi cmake -S . -B build -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ -Dqmlui=ON \ -Dmcp_server=ON \ -Dappimage=ON \ - "-DINSTALL_ROOT=${INSTALL_ROOT}" \ - "${CMAKE_EXTRA_ARGS[@]}" + "-DINSTALL_ROOT=${INSTALL_ROOT}" - # ── Build ────────────────────────────────────────────────────────────── - name: Build run: | set -euxo pipefail - cmake --build build -j$(nproc) + cmake --build build -j"$(nproc)" - # ── Tests (x86_64 only) ──────────────────────────────────────────────── - - name: Run unit tests - if: ${{ matrix.run_tests }} - run: | - set -euxo pipefail - build/mcp/test/mcp_vc_query_filter_test - build/mcp/test/mcp_vc_validation_test - - # ── Install to AppDir ────────────────────────────────────────────────── - # cmake --install copies the binary, data files, qlcplus I/O plugins, - # desktop file, icons, and (where available) Qt libraries/plugins. - # The OPTIONAL keyword on Qt-specific installs in platforms/linux/ - # CMakeLists.txt ensures that aarch64 system-Qt builds don't fail when - # Qt libraries land in architecture-specific paths not known to cmake. - name: Install to AppDir run: | set -euxo pipefail cmake --install build - # ── Download and extract AppImage tooling ────────────────────────────── - name: Download linuxdeploy and plugin-qt run: | set -euxo pipefail - wget -q "${{ matrix.linuxdeploy_url }}" -O linuxdeploy.AppImage - wget -q "${{ matrix.linuxdeploy_qt_url }}" -O linuxdeploy-plugin-qt.AppImage - wget -q "${{ matrix.appimagetool_url }}" -O appimagetool.AppImage + wget -q https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-aarch64.AppImage \ + -O linuxdeploy.AppImage + wget -q https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/continuous/linuxdeploy-plugin-qt-aarch64.AppImage \ + -O linuxdeploy-plugin-qt.AppImage + wget -q https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-aarch64.AppImage \ + -O appimagetool.AppImage chmod +x linuxdeploy.AppImage linuxdeploy-plugin-qt.AppImage appimagetool.AppImage - # ── Bundle Qt libraries, plugins, and QML modules via linuxdeploy ────── - # linuxdeploy uses ldd to find all shared-library dependencies and copies - # them to AppDir/usr/lib. linuxdeploy-plugin-qt additionally copies Qt - # platform plugins, image-format plugins, QML imports, and creates a - # qt.conf so Qt finds its plugins at runtime inside the AppImage. - name: Bundle dependencies with linuxdeploy env: QMAKE: ${{ env.QMAKE }} @@ -273,13 +381,6 @@ jobs: --plugin qt \ --verbosity 1 - # ── Create custom AppRun ─────────────────────────────────────────────── - # QLC+ compiles data paths as relative strings (e.g. "../share/qlcplus") - # that it resolves against the process CWD at runtime. The -Dappimage=ON - # CMake option installs data under AppDir/share/ and plugins under - # AppDir/lib/, both one level above the binary dir (AppDir/usr/bin/). - # Setting CWD to AppDir/usr before exec makes those "../..." paths resolve - # correctly inside the AppImage mount. - name: Write custom AppRun run: | set -euxo pipefail @@ -288,49 +389,36 @@ jobs: set -e HERE="$(dirname "$(readlink -f "${0}")")" export APPDIR="$HERE" - - # Qt platform plugin and QML import paths set up by linuxdeploy-plugin-qt - export QT_PLUGIN_PATH="${HERE}/usr/plugins:${QT_PLUGIN_PATH:-}" - export QML2_IMPORT_PATH="${HERE}/usr/qml:${QML2_IMPORT_PATH:-}" - export LD_LIBRARY_PATH="${HERE}/usr/lib:${LD_LIBRARY_PATH:-}" - - # Change CWD to AppDir/usr so that relative data paths compiled into - # qlcconfig.h (e.g. "../share/qlcplus/fixtures") resolve correctly. + export QT_PLUGIN_PATH="${HERE}/usr/plugins${QT_PLUGIN_PATH:+:${QT_PLUGIN_PATH}}" + export QML2_IMPORT_PATH="${HERE}/usr/qml${QML2_IMPORT_PATH:+:${QML2_IMPORT_PATH}}" + export LD_LIBRARY_PATH="${HERE}/usr/lib${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}" cd "${HERE}/usr" - exec "${HERE}/usr/bin/qlcplus-qml" "$@" APPRUN_EOF - # Remove leading whitespace that heredoc indentation introduced sed -i 's/^[[:space:]]*//' AppDir/AppRun chmod +x AppDir/AppRun - # ── Pack into AppImage ───────────────────────────────────────────────── - name: Build AppImage - env: - VERSION: ${{ env.APPVERSION }} run: | set -euxo pipefail - OUTPUT="QLC+-${APPVERSION}-${{ matrix.appimage_arch }}.AppImage" + OUTPUT="QLC+-${APPVERSION}-aarch64.AppImage" ./appimagetool.AppImage -v AppDir "$OUTPUT" echo "APPIMAGE_PATH=${OUTPUT}" >> "$GITHUB_ENV" - # ── SHA-256 checksum ────────────────────────────────────────────────── - name: Generate SHA256 checksum run: | set -euxo pipefail sha256sum "${APPIMAGE_PATH}" > "${APPIMAGE_PATH}.sha256" - # ── Upload as workflow artifact ──────────────────────────────────────── - name: Upload AppImage artifact uses: actions/upload-artifact@v4 with: - name: QLC+-${{ env.APPVERSION }}-${{ matrix.appimage_arch }} + name: QLC+-${{ env.APPVERSION }}-aarch64 path: | ${{ env.APPIMAGE_PATH }} ${{ env.APPIMAGE_PATH }}.sha256 if-no-files-found: error - # ── Attach to GitHub Release on tag push ────────────────────────────── - name: Attach to GitHub Release if: ${{ startsWith(github.ref, 'refs/tags/v') }} uses: softprops/action-gh-release@v2 @@ -338,7 +426,6 @@ jobs: files: | ${{ env.APPIMAGE_PATH }} ${{ env.APPIMAGE_PATH }}.sha256 - generate_release_notes: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -358,7 +445,7 @@ jobs: runs-on: ubuntu-22.04 if: ${{ github.event_name == 'workflow_dispatch' && inputs.build_armv7 == true }} permissions: - contents: write # needed by softprops/action-gh-release to upload tarballs + contents: write defaults: run: @@ -400,7 +487,8 @@ jobs: libfftw3-dev libmad0-dev libsndfile1-dev liblo-dev libpulse-dev \ patchelf wget file git cd /workspace - APPVERSION=$(awk -F'"' '/set\(APPVERSION "5\./{print $2; exit}' variables.cmake | tr ' ' '-') + APPVERSION=$(awk -F'"' '/set\(APPVERSION "5\./{print $2; exit}' variables.cmake \ + | tr ' ' '-') GIT_REV=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") APPVERSION="${APPVERSION}-${GIT_REV}" cmake -S . -B build -G Ninja \ @@ -409,19 +497,21 @@ jobs: -Dmcp_server=ON \ -Dappimage=ON \ "-DINSTALL_ROOT=/workspace/AppDir-armv7" - cmake --build build -j$(nproc) + cmake --build build -j"$(nproc)" cmake --install build - # Package as tarball (no armhf AppImage tools available) tar -czf "QLC+-${APPVERSION}-armv7.tar.gz" -C /workspace AppDir-armv7 - sha256sum "QLC+-${APPVERSION}-armv7.tar.gz" > "QLC+-${APPVERSION}-armv7.tar.gz.sha256" + sha256sum "QLC+-${APPVERSION}-armv7.tar.gz" \ + > "QLC+-${APPVERSION}-armv7.tar.gz.sha256" echo "APPVERSION=${APPVERSION}" >> /workspace/.armv7_env - name: Read armv7 env run: | set -euxo pipefail source .armv7_env - echo "APPVERSION=${APPVERSION}" >> "$GITHUB_ENV" - echo "TARBALL=QLC+-${APPVERSION}-armv7.tar.gz" >> "$GITHUB_ENV" + { + echo "APPVERSION=${APPVERSION}" + echo "TARBALL=QLC+-${APPVERSION}-armv7.tar.gz" + } >> "$GITHUB_ENV" - name: Upload armv7 tarball artifact uses: actions/upload-artifact@v4 @@ -448,15 +538,23 @@ jobs: ci-success: name: CI Success runs-on: ubuntu-22.04 - needs: [build] + needs: [build-x86_64, build-aarch64] if: always() permissions: contents: read steps: - name: Check required build jobs run: | - if [[ "${{ needs.build.result }}" == "failure" ]]; then - echo "❌ One or more required build jobs failed." + x86_result="${{ needs.build-x86_64.result }}" + arm_result="${{ needs.build-aarch64.result }}" + if [[ "${x86_result}" != "success" ]]; then + echo "❌ x86_64 build ${x86_result}." + exit 1 + fi + # aarch64 is intentionally skipped on pull_request events; only fail + # if it actually ran and failed. + if [[ "${arm_result}" == "failure" ]]; then + echo "❌ aarch64 build failed." exit 1 fi - echo "✅ All required build jobs succeeded (result: ${{ needs.build.result }})." + echo "✅ x86_64=${x86_result}, aarch64=${arm_result}." From 94d96be0b5455af67f26a768398dc0f60d6bf6fa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:55:14 +0000 Subject: [PATCH 07/14] fix: use QT_ROOT_DIR env var; add universe repo and fix aarch64 Qt packages Agent-Logs-Url: https://github.com/abossard/qlcplus/sessions/cd1b2937-1365-4589-a711-370f517fb912 Co-authored-by: abossard <86611+abossard@users.noreply.github.com> --- .github/workflows/linux-build.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/linux-build.yml b/.github/workflows/linux-build.yml index 8003b9f924..048ec5d472 100644 --- a/.github/workflows/linux-build.yml +++ b/.github/workflows/linux-build.yml @@ -126,8 +126,8 @@ jobs: echo "APPVERSION=${APPVERSION}-${GIT_REV}" echo "GIT_REV=${GIT_REV}" echo "INSTALL_ROOT=$(pwd)/AppDir" - # qmake from install-qt-action is in QTDIR/bin - echo "QMAKE=${QTDIR}/bin/qmake" + # install-qt-action exports QT_ROOT_DIR (not QTDIR) + echo "QMAKE=${QT_ROOT_DIR}/bin/qmake" } >> "$GITHUB_ENV" - name: Configure CMake @@ -139,7 +139,7 @@ jobs: -Dmcp_server=ON \ -Dappimage=ON \ "-DINSTALL_ROOT=${INSTALL_ROOT}" \ - "-DCMAKE_PREFIX_PATH=${QTDIR}/lib/cmake" + "-DCMAKE_PREFIX_PATH=${QT_ROOT_DIR}/lib/cmake" - name: Build run: | @@ -293,6 +293,10 @@ jobs: - name: Install Qt via apt (aarch64) run: | set -euxo pipefail + # Ensure the 'universe' repository is enabled (ARM runners may ship + # with a minimal sources.list that omits it). + sudo apt-add-repository universe -y + sudo apt-get update -q sudo apt-get install -y --no-install-recommends \ qt6-base-dev \ qt6-base-private-dev \ @@ -312,7 +316,6 @@ jobs: qml6-module-qtquick-layouts \ qml6-module-qtquick-window \ qml6-module-qtmultimedia \ - libqt6multimedia6-plugins \ libgl1-mesa-dev - name: Cache CMake build directory From e210af8f81789ed16e69d0b3d5405dd3b27a7a79 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:09:43 +0000 Subject: [PATCH 08/14] fix: offscreen Qt platform for unit tests; libqt6*6-dev names for aarch64 Agent-Logs-Url: https://github.com/abossard/qlcplus/sessions/cd1b2937-1365-4589-a711-370f517fb912 Co-authored-by: abossard <86611+abossard@users.noreply.github.com> --- .github/workflows/linux-build.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/linux-build.yml b/.github/workflows/linux-build.yml index 048ec5d472..edf863df49 100644 --- a/.github/workflows/linux-build.yml +++ b/.github/workflows/linux-build.yml @@ -149,8 +149,8 @@ jobs: - name: Run unit tests run: | set -euxo pipefail - build/mcp/test/mcp_vc_query_filter_test - build/mcp/test/mcp_vc_validation_test + QT_QPA_PLATFORM=offscreen build/mcp/test/mcp_vc_query_filter_test + QT_QPA_PLATFORM=offscreen build/mcp/test/mcp_vc_validation_test - name: Install to AppDir run: | @@ -303,10 +303,10 @@ jobs: qt6-declarative-dev \ qt6-declarative-private-dev \ qt6-multimedia-dev \ - qt6-websockets-dev \ - qt6-svg-dev \ + libqt6websockets6-dev \ + libqt6svg6-dev \ qt6-3d-dev \ - qt6-serialport-dev \ + libqt6serialport6-dev \ qt6-tools-dev \ qt6-l10n-tools \ qt6-image-formats-plugins \ From 82f630f7ca4734c450a63b93eabaae308199b45f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:27:11 +0000 Subject: [PATCH 09/14] fix: LD_LIBRARY_PATH for linuxdeploy; Qt6LinguistTools libexec symlink on aarch64 Agent-Logs-Url: https://github.com/abossard/qlcplus/sessions/cd1b2937-1365-4589-a711-370f517fb912 Co-authored-by: abossard <86611+abossard@users.noreply.github.com> --- .github/workflows/linux-build.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/linux-build.yml b/.github/workflows/linux-build.yml index edf863df49..58567dd0dd 100644 --- a/.github/workflows/linux-build.yml +++ b/.github/workflows/linux-build.yml @@ -172,10 +172,13 @@ jobs: # them to AppDir/usr/lib. linuxdeploy-plugin-qt additionally copies Qt # platform plugins, image-format plugins, QML imports, and creates a # qt.conf so Qt finds its plugins at runtime inside the AppImage. + # LD_LIBRARY_PATH must include AppDir/usr/lib so linuxdeploy can resolve + # libqlcplusengine.so and other QLC+ libraries installed by cmake --install. - name: Bundle dependencies with linuxdeploy env: QMAKE: ${{ env.QMAKE }} QML_SOURCES_PATHS: ${{ github.workspace }}/qmlui + LD_LIBRARY_PATH: ${{ github.workspace }}/AppDir/usr/lib run: | set -euxo pipefail ./linuxdeploy.AppImage \ @@ -342,6 +345,15 @@ jobs: - name: Configure CMake run: | set -euxo pipefail + # Ubuntu 22.04 arm64 packaging bug: Qt6LinguistToolsTargets.cmake + # expects lprodump in /usr/lib/qt6/libexec/ but qt6-l10n-tools installs + # it in /usr/lib/qt6/bin/. Create symlinks so CMake can find them. + if [ -d /usr/lib/qt6/bin ] && [ ! -d /usr/lib/qt6/libexec ]; then + sudo mkdir -p /usr/lib/qt6/libexec + for f in /usr/lib/qt6/bin/*; do + sudo ln -sf "$f" "/usr/lib/qt6/libexec/$(basename "$f")" + done + fi cmake -S . -B build -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ -Dqmlui=ON \ @@ -374,6 +386,7 @@ jobs: env: QMAKE: ${{ env.QMAKE }} QML_SOURCES_PATHS: ${{ github.workspace }}/qmlui + LD_LIBRARY_PATH: ${{ github.workspace }}/AppDir/usr/lib run: | set -euxo pipefail ./linuxdeploy.AppImage \ From ba84370dd0e2a92e4fe91e1f7e1ccf74b7abebb9 Mon Sep 17 00:00:00 2001 From: Andre Bossard Date: Tue, 28 Apr 2026 23:40:21 +0200 Subject: [PATCH 10/14] fix: install qt6-tools-dev-tools for lprodump; check binary not directory The aarch64 build fails because lprodump is missing from /usr/lib/qt6/libexec/. The old workaround checked if the directory existed, but on aarch64 Ubuntu the directory exists while the binary is absent. Fix: - Add qt6-tools-dev-tools package (provides lprodump) - Check for the binary (! -x lprodump) instead of the directory (! -d libexec) - Apply same fix to armv7 container build section Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/linux-build.yml | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/.github/workflows/linux-build.yml b/.github/workflows/linux-build.yml index 58567dd0dd..ff76185d9e 100644 --- a/.github/workflows/linux-build.yml +++ b/.github/workflows/linux-build.yml @@ -311,6 +311,7 @@ jobs: qt6-3d-dev \ libqt6serialport6-dev \ qt6-tools-dev \ + qt6-tools-dev-tools \ qt6-l10n-tools \ qt6-image-formats-plugins \ libqt6opengl6-dev \ @@ -345,13 +346,13 @@ jobs: - name: Configure CMake run: | set -euxo pipefail - # Ubuntu 22.04 arm64 packaging bug: Qt6LinguistToolsTargets.cmake - # expects lprodump in /usr/lib/qt6/libexec/ but qt6-l10n-tools installs - # it in /usr/lib/qt6/bin/. Create symlinks so CMake can find them. - if [ -d /usr/lib/qt6/bin ] && [ ! -d /usr/lib/qt6/libexec ]; then - sudo mkdir -p /usr/lib/qt6/libexec + # Ubuntu arm64 packaging: Qt6LinguistToolsTargets.cmake expects + # lprodump in /usr/lib/qt6/libexec/ but it may be in /usr/lib/qt6/bin/. + # Check for the binary, not the directory. + sudo mkdir -p /usr/lib/qt6/libexec + if [ ! -x /usr/lib/qt6/libexec/lprodump ] && [ -d /usr/lib/qt6/bin ]; then for f in /usr/lib/qt6/bin/*; do - sudo ln -sf "$f" "/usr/lib/qt6/libexec/$(basename "$f")" + [ -f "$f" ] && sudo ln -sf "$f" "/usr/lib/qt6/libexec/$(basename "$f")" done fi cmake -S . -B build -G Ninja \ @@ -498,10 +499,17 @@ jobs: qt6-base-dev qt6-base-private-dev \ qt6-declarative-dev qt6-declarative-private-dev \ qt6-multimedia-dev qt6-websockets-dev qt6-svg-dev \ - qt6-3d-dev qt6-tools-dev qt6-l10n-tools \ + qt6-3d-dev qt6-tools-dev qt6-tools-dev-tools qt6-l10n-tools \ libusb-1.0-0-dev libftdi1-dev libasound2-dev libudev-dev \ libfftw3-dev libmad0-dev libsndfile1-dev liblo-dev libpulse-dev \ patchelf wget file git + # Fix Qt6 libexec layout if lprodump is missing + mkdir -p /usr/lib/qt6/libexec + if [ ! -x /usr/lib/qt6/libexec/lprodump ] && [ -d /usr/lib/qt6/bin ]; then + for f in /usr/lib/qt6/bin/*; do + [ -f "$f" ] && ln -sf "$f" "/usr/lib/qt6/libexec/$(basename "$f")" + done + fi cd /workspace APPVERSION=$(awk -F'"' '/set\(APPVERSION "5\./{print $2; exit}' variables.cmake \ | tr ' ' '-') From f901129b6125ec2e02053e997a3f422bca8f461d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 22:13:08 +0000 Subject: [PATCH 11/14] fix: initialize InputPatch buffered input value Agent-Logs-Url: https://github.com/abossard/qlcplus/sessions/78fccb82-719f-4c54-a2c9-a73f8068b292 Co-authored-by: abossard <86611+abossard@users.noreply.github.com> --- engine/src/inputpatch.h | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/engine/src/inputpatch.h b/engine/src/inputpatch.h index a4dc4885b6..4891d6aecd 100644 --- a/engine/src/inputpatch.h +++ b/engine/src/inputpatch.h @@ -166,7 +166,9 @@ private slots: struct InputValue { - InputValue() {} + InputValue() + : value(0) + {} InputValue(uchar v, QString const& k) : value(v) , key(k) From 4ba79604f6e9ddb1d141e629edc0ace1ab70affc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 22:28:02 +0000 Subject: [PATCH 12/14] fix: guard dark mode style hint for older Qt 6 Agent-Logs-Url: https://github.com/abossard/qlcplus/sessions/78fccb82-719f-4c54-a2c9-a73f8068b292 Co-authored-by: abossard <86611+abossard@users.noreply.github.com> --- qmlui/main.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/qmlui/main.cpp b/qmlui/main.cpp index 6b22fc65b0..a0afc32853 100644 --- a/qmlui/main.cpp +++ b/qmlui/main.cpp @@ -58,7 +58,9 @@ int main(int argc, char *argv[]) QApplication app(argc, argv); /* Force dark mode so the macOS title bar and window chrome match the dark UI */ +#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) QGuiApplication::styleHints()->setColorScheme(Qt::ColorScheme::Dark); +#endif // Since Qt6, the default rendering backend is Rhi. // QLC+ doesn't support it yet so OpenGL have to be forced. From 0707db8effd75056c8cbac9e65bce011afebd9f7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 22:43:31 +0000 Subject: [PATCH 13/14] fix: use direct Qt tools path on aarch64 CI Agent-Logs-Url: https://github.com/abossard/qlcplus/sessions/78fccb82-719f-4c54-a2c9-a73f8068b292 Co-authored-by: abossard <86611+abossard@users.noreply.github.com> --- .github/workflows/linux-build.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/linux-build.yml b/.github/workflows/linux-build.yml index ff76185d9e..632203aa4b 100644 --- a/.github/workflows/linux-build.yml +++ b/.github/workflows/linux-build.yml @@ -322,6 +322,11 @@ jobs: qml6-module-qtmultimedia \ libgl1-mesa-dev + - name: Add Qt tools to PATH (aarch64) + run: | + set -euxo pipefail + echo "/usr/lib/qt6/bin" >> "$GITHUB_PATH" + - name: Cache CMake build directory uses: actions/cache@v4 with: From f881fa4a3421e244a3d34a039f6538f9943f5777 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 22:59:12 +0000 Subject: [PATCH 14/14] ci: remove temporary linux workflow branch trigger Agent-Logs-Url: https://github.com/abossard/qlcplus/sessions/78fccb82-719f-4c54-a2c9-a73f8068b292 Co-authored-by: abossard <86611+abossard@users.noreply.github.com> --- .github/workflows/linux-build.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/linux-build.yml b/.github/workflows/linux-build.yml index 632203aa4b..54811d830c 100644 --- a/.github/workflows/linux-build.yml +++ b/.github/workflows/linux-build.yml @@ -21,10 +21,6 @@ on: push: tags: - 'v*' - # Temporary: also run on the CI development branch so we can verify this - # workflow before it is merged. Remove after first successful run. - branches: - - 'copilot/add-github-actions-workflow-linux-builds' pull_request: paths: