diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index bb807c59a..cb0a2def5 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -24,7 +24,9 @@ body: label: Version description: What version of our software are you running? options: - - 2.0.10 (Default) + - 2.0.11.1 (Default) + - 2.0.11 + - 2.0.10 - 2.0.9 - 2.0.8 - 2.0.7 @@ -33,6 +35,7 @@ body: - 2.0.4 - 2.0.3 - 2.0.2 + - 2.0.1 - 2.0.0 - < 2.0.0 default: 0 diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml index 9d2f6b962..9319c398d 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -47,7 +47,9 @@ body: label: Version description: What version of our software did you last run? options: - - 2.0.10 (Default) + - 2.0.11.1 (Default) + - 2.0.11 + - 2.0.10 - 2.0.9 - 2.0.8 - 2.0.7 @@ -56,6 +58,7 @@ body: - 2.0.4 - 2.0.3 - 2.0.2 + - 2.0.1 - 2.0.0 - < 2.0.0 default: 0 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ae90135c5..96a0339da 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -306,19 +306,43 @@ jobs: with: fetch-depth: 0 + # A stalled apt mirror on the hosted runner has hung this step for 40+ + # minutes more than once (it normally finishes in well under two), which + # burns the job's whole time budget before a compiler ever runs. Kill an + # attempt that stops making progress and retry instead of waiting it out. + # GitHub's runners point at the azure.archive.ubuntu.com mirrors, which is + # what stalls, so the retry falls back to the canonical archive. - name: Install build dependencies + timeout-minutes: 20 run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - build-essential cmake libgnutls28-dev libudev-dev \ - libgl1-mesa-dev libfontconfig1-dev libfreetype-dev \ - libx11-dev libx11-xcb-dev libxcb-cursor-dev libxcb-glx0-dev \ - libxcb-icccm4-dev libxcb-image0-dev libxcb-keysyms1-dev \ - libxcb-randr0-dev libxcb-render-util0-dev libxcb-shape0-dev \ - libxcb-shm0-dev libxcb-sync-dev libxcb-util-dev \ - libxcb-xfixes0-dev libxcb-xkb-dev libxcb1-dev libxext-dev \ - libxfixes-dev libxi-dev libxkbcommon-dev libxkbcommon-x11-dev \ - libxrender-dev fuse libfuse2 + set -uo pipefail + packages="build-essential cmake libgnutls28-dev libudev-dev libgl1-mesa-dev libfontconfig1-dev libfreetype-dev libx11-dev libx11-xcb-dev libxcb-cursor-dev libxcb-glx0-dev libxcb-icccm4-dev libxcb-image0-dev libxcb-keysyms1-dev libxcb-randr0-dev libxcb-render-util0-dev libxcb-shape0-dev libxcb-shm0-dev libxcb-sync-dev libxcb-util-dev libxcb-xfixes0-dev libxcb-xkb-dev libxcb1-dev libxext-dev libxfixes-dev libxi-dev libxkbcommon-dev libxkbcommon-x11-dev libxrender-dev fuse libfuse2" + + apt_try() { + # shellcheck disable=SC2086 # $packages must word-split into arguments + sudo timeout 300 apt-get update && \ + sudo timeout 600 apt-get install -y --no-install-recommends $packages + } + + for attempt in 1 2 3; do + if apt_try; then + echo "apt succeeded on attempt $attempt" + exit 0 + fi + echo "::warning::apt attempt $attempt stalled or failed - retrying" + # A killed apt leaves its locks and a half-applied dpkg state behind. + sudo pkill -9 -f 'apt-get|dpkg' || true + sudo rm -f /var/lib/apt/lists/lock /var/cache/apt/archives/lock /var/lib/dpkg/lock* + sudo dpkg --configure -a || true + if [ "$attempt" = 1 ]; then + echo "Switching off the azure mirrors for the retry" + sudo sed -i 's|//azure.archive.ubuntu.com|//archive.ubuntu.com|g' /etc/apt/sources.list + fi + sleep 15 + done + + echo "::error::apt-get failed after 3 attempts" + exit 1 - name: Install Qt ${{ env.QT_VERSION }} run: | diff --git a/.github/workflows/e2e-qemu.yml b/.github/workflows/e2e-qemu.yml index e9ce4f193..c39d74dcf 100644 --- a/.github/workflows/e2e-qemu.yml +++ b/.github/workflows/e2e-qemu.yml @@ -32,20 +32,44 @@ jobs: with: fetch-depth: 0 + # A stalled apt mirror on the hosted runner has hung this step for 40+ + # minutes more than once (it normally finishes in well under two), which + # burns the job's whole time budget before a compiler ever runs. Kill an + # attempt that stops making progress and retry instead of waiting it out. + # GitHub's runners point at the azure.archive.ubuntu.com mirrors, which is + # what stalls, so the retry falls back to the canonical archive. - name: Install build and E2E dependencies + timeout-minutes: 20 run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - build-essential cloud-image-utils cmake curl fuse libfuse2 \ - libfontconfig1-dev libfreetype-dev libgl1-mesa-dev libgnutls28-dev \ - libudev-dev libx11-dev libx11-xcb-dev libxcb-cursor-dev \ - libxcb-glx0-dev libxcb-icccm4-dev libxcb-image0-dev \ - libxcb-keysyms1-dev libxcb-randr0-dev libxcb-render-util0-dev \ - libxcb-shape0-dev libxcb-shm0-dev libxcb-sync-dev libxcb-util-dev \ - libxcb-xfixes0-dev libxcb-xkb-dev libxcb1-dev libxext-dev \ - libxfixes-dev libxi-dev libxkbcommon-dev libxkbcommon-x11-dev \ - libxrender-dev mtools parted qemu-system-x86 qemu-utils xz-utils zip - if [ -e /dev/kvm ]; then sudo chmod 666 /dev/kvm; fi + set -uo pipefail + packages="build-essential cloud-image-utils cmake curl fuse libfuse2 libfontconfig1-dev libfreetype-dev libgl1-mesa-dev libgnutls28-dev libudev-dev libx11-dev libx11-xcb-dev libxcb-cursor-dev libxcb-glx0-dev libxcb-icccm4-dev libxcb-image0-dev libxcb-keysyms1-dev libxcb-randr0-dev libxcb-render-util0-dev libxcb-shape0-dev libxcb-shm0-dev libxcb-sync-dev libxcb-util-dev libxcb-xfixes0-dev libxcb-xkb-dev libxcb1-dev libxext-dev libxfixes-dev libxi-dev libxkbcommon-dev libxkbcommon-x11-dev libxrender-dev mtools parted qemu-system-x86 qemu-utils xz-utils zip" + + apt_try() { + # shellcheck disable=SC2086 # $packages must word-split into arguments + sudo timeout 300 apt-get update && \ + sudo timeout 600 apt-get install -y --no-install-recommends $packages + } + + for attempt in 1 2 3; do + if apt_try; then + echo "apt succeeded on attempt $attempt" + if [ -e /dev/kvm ]; then sudo chmod 666 /dev/kvm; fi + exit 0 + fi + echo "::warning::apt attempt $attempt stalled or failed - retrying" + # A killed apt leaves its locks and a half-applied dpkg state behind. + sudo pkill -9 -f 'apt-get|dpkg' || true + sudo rm -f /var/lib/apt/lists/lock /var/cache/apt/archives/lock /var/lib/dpkg/lock* + sudo dpkg --configure -a || true + if [ "$attempt" = 1 ]; then + echo "Switching off the azure mirrors for the retry" + sudo sed -i 's|//azure.archive.ubuntu.com|//archive.ubuntu.com|g' /etc/apt/sources.list + fi + sleep 15 + done + + echo "::error::apt-get failed after 3 attempts" + exit 1 - name: Install Qt 6.11.1 run: | diff --git a/.gitignore b/.gitignore index bec257ca5..5b0b20646 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ src/build/* src/build/** compile_commands.json src/.cache/clangd -obj-** +/obj-** # IDE Configurations .vscode @@ -11,6 +11,7 @@ obj-** .idea # Debian packaging +debian/release.conf debian/rpi-imager/** debian/rpi-imager-amd64/** debian/rpi-imager-embedded/** @@ -30,28 +31,31 @@ debian/com.raspberrypi.rpi-imager.metainfo.xml *.*swp* # AppImage related +# Patterns for build products at the repository root are anchored with a leading +# slash: unanchored patterns match by basename at every depth, which silently +# swallowed debian/appimage-pack.sh, qt/build-qt*.sh and src/timezones.txt. *.AppImage* *.appimagecraft* -AppDir-* -appimage-* +/AppDir-* +/appimage-tools/ # Embedded .deb build tree (created by create-embedded.sh) -debroot-embedded-* -rpi-imager-embedded*.deb -screenshot.png +/debroot-embedded-* +/rpi-imager-embedded*.deb # Qt related -qt-build** -qt-src** +# build-qt*.sh creates these as $PWD/qt-src and $PWD/qt-build[-suffix], so they +# land wherever the script was run from -- usually qt/, per the READMEs. Hence +# no leading slash. The trailing slash restricts each to directories, which is +# what keeps qt-build* from swallowing the tracked qt/qt-build-common.sh. +qt-src/ +qt-build/ +qt-build-*/ qt/language_filters.json -build -build-** - -# IANA Timezone database generated as part of build -timezones.txt +/build-** # build-cli-only related -test-cli-** +/test-cli-** # clangd related .cache/clangd @@ -62,3 +66,7 @@ test-cli-** # UNRAID: local build output /build/ + +# Debian release tooling (local paths; see debian/release.conf.example) +/.debian/ +/out/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..6cd7820ea --- /dev/null +++ b/.gitmodules @@ -0,0 +1,21 @@ +[submodule "src/dependencies/vendor/xz"] + path = src/dependencies/vendor/xz + url = https://github.com/tukaani-project/xz.git +[submodule "src/dependencies/vendor/zstd"] + path = src/dependencies/vendor/zstd + url = https://github.com/facebook/zstd.git +[submodule "src/dependencies/vendor/zlib"] + path = src/dependencies/vendor/zlib + url = https://github.com/madler/zlib.git +[submodule "src/dependencies/vendor/nghttp2"] + path = src/dependencies/vendor/nghttp2 + url = https://github.com/nghttp2/nghttp2.git +[submodule "src/dependencies/vendor/libarchive"] + path = src/dependencies/vendor/libarchive + url = https://github.com/libarchive/libarchive.git +[submodule "src/dependencies/vendor/curl"] + path = src/dependencies/vendor/curl + url = https://github.com/curl/curl.git +[submodule "src/dependencies/vendor/libusb"] + path = src/dependencies/vendor/libusb + url = https://github.com/libusb/libusb.git diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 77559e658..d0855d910 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,41 +2,74 @@ ### Linux +Linux artifacts are built by one pipeline, driven from `debian/release.sh`. It +builds every architecture — amd64, arm64 and armhf — inside its own rootless +`mmdebstrap` chroot, from a machine of any of those architectures, and needs no +`sudo`. [doc/linux-build.md](./doc/linux-build.md) is the full reference; this is +the short version. + #### Get dependencies -- Install the build dependencies (Debian used as an example): +Only what is needed to drive the pipeline; the actual build dependencies are +installed inside the chroot: ```sh -sudo apt install --no-install-recommends build-essential cmake git libgnutls28-dev +sudo apt install mmdebstrap dpkg-dev git curl file xz-utils ``` +To build an architecture other than your own, also install `qemu-user-static` +and `binfmt-support`. + #### Get the source ```sh -git clone --depth 1 https://github.com/raspberrypi/rpi-imager +git clone https://github.com/raspberrypi/rpi-imager ``` -#### Build Qt +Clone with full history: version strings come from `git describe --tags`, and +the vendored third-party dependencies are git submodules (initialised for you by +`debian/fetch-vendor-deps.sh`). A `--depth 1` clone will not build. + +#### Build the release artifacts ```sh -sudo ./qt/build-qt.sh +debian/release.sh status # what exists, what doesn't — check this first +debian/release.sh appimages amd64 # desktop + CLI AppImages for one architecture +debian/release.sh arch amd64 # ...and the .deb packages that wrap them + +# All three architectures, plus the source package. RELEASE_ARCHES defaults to +# your own architecture alone, so pass it explicitly (or set it in release.conf). +RELEASE_ARCHES="amd64 arm64 armhf" debian/release.sh repo ``` -This will build and install the version of Qt preferred for Raspberry Pi Imager into /opt/Qt/. You must use `sudo` for the installation step to complete. +`repo` is the only command that builds more than one architecture; the others +take exactly one. + +The first run bootstraps a chroot and builds Qt, so it takes a while; both are +cached under `.debian/` afterwards. Finished AppImages land in +`.debian/appimages//` and packages in `out/debian/`. + +#### Build quickly while developing -#### Build the AppImage +To iterate on the app itself, skip the packaging and build against a Qt tree +directly: ```sh -./create-appimage.sh -./Raspberry_Pi_Imager-*.AppImage +debian/ensure-qt.sh amd64 # populates .debian/qt/, or use system qt6-base-dev +cmake -B build -G Ninja src -DQt6_ROOT=$PWD/.debian/qt/amd64//gcc_64 +cmake --build build ``` +`` is whatever `QT_VERSION_DEFAULT` in +[qt/qt-build-common.sh](./qt/qt-build-common.sh) says — the single place the Qt +version is selected. + ### Windows #### Get dependencies - Get the Qt online installer from: https://www.qt.io/download-open-source - - During installation, choose Qt 6.9 with Mingw64 64-bit toolchain. + - During installation, choose the Qt version named by `QT_VERSION_DEFAULT` in [qt/qt-build-common.sh](./qt/qt-build-common.sh), with the Mingw64 64-bit toolchain. Any newer Qt 6 that satisfies the `find_package(Qt6 ...)` minimum in `src/CMakeLists.txt` will also configure. - For building the installer, install Inno Setup scriptable install system: https://jrsoftware.org/isdl.php - Install Visual Studio Code (or a derivative) and the Qt Extension Pack. - It is assumed you already have a valid code signing certificate, and the Windows 10 Kit (SDK) installed. @@ -47,7 +80,7 @@ Building Raspberry Pi Imager on Windows is best done with Visual Studio Code (or - Open Visual Studio Code, and select 'Clone repo'. Give it the git url of this project. - Open the CMake plugin settings, and set the following Configure Args: - - `-DQt6_ROOT=C:\Qt\6.9.0\mingw_64` - or the equivalent path you installed Qt 6.9 to. + - `-DQt6_ROOT=C:\Qt\\mingw_64` - or the equivalent path you installed Qt to. - `-DMINGW64_ROOT=C:\Qt\Tools\mingw1310_64` - or the equivalent path you installed mingw64 to. - `-DENABLE_INNO_INSTALLER=ON` - to enable the Inno Setup installer, rather than the legacy NSIS installer. - `-DIMAGER_SIGNED_APP=ON` - to enable code signing for redistribution. @@ -74,7 +107,7 @@ Building Raspberry Pi Imager on macOS is best done with Visual Studio Code (or a - Open Visual Studio Code, and select 'Clone repo'. Give it the git url of this project. - Open the CMake plugin settings, and set the following Configure Args: - - `-DQt6_ROOT=/opt/Qt/6.9.1/macos` - or the equivalent path you installed Qt 6.9 to. + - `-DQt6_ROOT=/opt/Qt//macos` - or the equivalent path `build-qt-macos.sh` installed Qt to. - `-DIMAGER_SIGNED_APP=ON` - to enable code signing. - `-DIMAGER_SIGNING_IDENTITY=$cn` - to specify the Developer ID Certificate Common Name. - `-DIMAGER_NOTARIZE_APP=ON` - to enable automatic notarization for distribution to others. @@ -87,28 +120,38 @@ Building Raspberry Pi Imager on macOS is best done with Visual Studio Code (or a The Raspberry Pi Network installer (embedded imager) runs inside an operating system created by [pi-gen-micro](https://github.com/raspberrypi/pi-gen-micro/tree/main/configurations/rpi-imager-embedded). -To build the entire system, you must first build our customised embedded qt: +It uses a **dedicated** Qt, distinct from the desktop and CLI release Qt: built +`-no-opengl -no-dbus -qpa linuxfb` by `qt/build-qt-embedded.sh` into its own +cache variant (`gcc_arm64_embedded`). The netboot target image carries no +Mesa/GL, no X11 and no session bus — far too large for a network-loaded image — +so the embedded Qt must not link `libEGL`/`libGL`/`libX11` at all. The build +below produces it automatically on a cache miss. -```sh -./qt/build-qt-embedded.sh -``` - -Then build the embedded AppImage: +The canonical build goes through the release pipeline, which builds inside the +arm64 mmdebstrap chroot: ```sh -./create-embedded.sh +debian/release.sh embedded arm64 ``` -Package the appImage for use with pi-gen-micro and other Debian systems: +This produces `out/debian/rpi-imager-embedded__arm64.deb`. It stages the +vendored `/opt` tree with `create-embedded.sh`, then assembles the `.deb` with +debhelper so that `debian/control` is the single source of the package's +dependencies and metadata (`dh_shlibdeps` is deliberately not used — the package +vendors its libraries, so the external `Depends` are maintained explicitly in +the `rpi-imager-embedded` stanza of `debian/control`). + +To build against a Qt tree you resolved yourself, `create-embedded.sh` can be +run directly: ```sh -dpkg-buildpackage -uc -us --profile=embedded +./create-embedded.sh --arch=aarch64 --qt-root=/path/to/qt ``` -And finally, import your new embedded imager into pi-gen-micro for packaging: +Finally, import the package into pi-gen-micro: ```sh rm ${pi-gen-micro-root}/packages/rpi-imager-embedded*.deb -cp ../rpi-imager-embedded*.deb ${pi-gen-micro-root}/packages/ +cp out/debian/rpi-imager-embedded*.deb ${pi-gen-micro-root}/packages/ pushd ${pi-gen-micro-root}/packages/ && dpkg-scanpackages . /dev/null | gzip -9c > Packages.gz && popd ``` diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..db458498a --- /dev/null +++ b/Makefile @@ -0,0 +1,30 @@ +# Debian release helpers. Configure paths in debian/release.conf (optional). +# +# cp debian/release.conf.example debian/release.conf +# make release # everything (rootless; chroots auto-created) + +.PHONY: release release-status release-source release-arm64 release-amd64 release-armhf release-repo release-appimages-% + +# Build every release target (source + all arch AppImages + binary debs). +release: release-repo + +release-status: + ./debian/release.sh status + +release-source: + ./debian/release.sh source + +release-arm64: + ./debian/release.sh arch arm64 + +release-amd64: + ./debian/release.sh arch amd64 + +release-armhf: + ./debian/release.sh arch armhf + +release-appimages-%: + ./debian/release.sh appimages $* + +release-repo: + RELEASE_ARCHES="$${RELEASE_ARCHES:-amd64 arm64 armhf}" ./debian/release.sh repo diff --git a/README.md b/README.md index 5664ae71d..48f9f8830 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ The fork is deliberately kept small and greppable so upstream releases can be merged rather than re-applied by hand. `git grep "UNRAID:"` enumerates the entire patch surface; the rules are in [PORTING.md](./PORTING.md). +For the Linux release pipeline — the rootless, multi-architecture chroot build that produces the AppImages and `.deb` packages — see [doc/linux-build.md](./doc/linux-build.md). + ## Other notes ### Custom repository diff --git a/create-appimage-cli.sh b/create-appimage-cli.sh index d66b18bbe..7d3b51d0a 100755 --- a/create-appimage-cli.sh +++ b/create-appimage-cli.sh @@ -12,7 +12,7 @@ QT_ROOT_ARG="" usage() { echo "Usage: $0 [options]" echo "Options:" - echo " --arch=ARCH Target architecture (x86_64, aarch64, armv7l)" + echo " --arch=ARCH Target architecture (x86_64, aarch64, armhf)" echo " --qt-root=PATH Path to Qt installation directory" echo " --no-clean Don't clean build directory" echo " -h, --help Show this help message" @@ -36,6 +36,9 @@ for arg in "$@"; do --no-clean) CLEAN_BUILD=0 ;; + --packaging=*) + APPIMAGE_PACKAGING="${arg#*=}" + ;; -h|--help) usage ;; @@ -62,13 +65,29 @@ if [ -n "$QT_ROOT_ARG" ]; then fi fi -# Validate architecture -if [ "$ARCH" != "x86_64" ] && [ "$ARCH" != "aarch64" ] && [ "$ARCH" != "armv7l" ]; then - echo "Error: Architecture must be one of: x86_64, aarch64, armv7l" +# Validate architecture (armv6l/armv7l from uname on 32-bit Pi OS → armhf) +case "$ARCH" in + armv6l|armv7l) ARCH=armhf ;; +esac +if [ "$ARCH" != "x86_64" ] && [ "$ARCH" != "aarch64" ] && [ "$ARCH" != "armhf" ]; then + echo "Error: Architecture must be one of: x86_64, aarch64, armhf" >&2 exit 1 fi -echo "Building CLI-only AppImage for architecture: $ARCH" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TOP="$SCRIPT_DIR" +# shellcheck disable=SC1091 +. "$TOP/debian/lib.sh" +export_cmake_parallel +sh "$TOP/debian/fetch-vendor-deps.sh" + +APPIMAGE_PACKAGING=${APPIMAGE_PACKAGING:-all} +TOOL_ARCH=$(appimage_resolve_tool_arch) + +echo "Building CLI-only AppImage for architecture: $ARCH (packaging tools: $TOOL_ARCH, mode: $APPIMAGE_PACKAGING)" +if [ "$ARCH" != "$TOOL_ARCH" ]; then + echo "create-appimage-cli: cross packaging — build in chroot, pack with $TOOL_ARCH tools on the host" +fi # Extract project information from CMakeLists.txt SOURCE_DIR="src/" @@ -101,6 +120,21 @@ echo "Building $PROJECT_NAME version $GIT_VERSION (numeric: $PROJECT_VERSION) fo QT_VERSION="" QT_DIR="" +# Reject desktop Qt paths — CLI AppImages must use gcc_*_cli builds only. +validate_cli_qt_dir() { + _dir=$1 + case "$_dir" in + */gcc_64_cli|*/gcc_arm64_cli|*/gcc_arm32_cli) return 0 ;; + esac + echo "Error: CLI AppImages require a CLI-only Qt build (gcc_*_cli), not desktop Qt." >&2 + echo " Refusing: $_dir" >&2 + echo " Build one with: ./qt/build-qt-cli.sh" >&2 + exit 1 +} + +# The pack stage only wraps an AppDir built earlier, so it never needs Qt — even +# when target arch == tool arch (host-arch chroot build packed on the host). +if [ "$APPIMAGE_PACKAGING" != pack ]; then # Check if Qt root is specified via command line argument (highest priority) if [ -n "$QT_ROOT_ARG" ]; then echo "Using Qt from command line argument: $QT_ROOT_ARG" @@ -109,47 +143,48 @@ if [ -n "$QT_ROOT_ARG" ]; then elif [ -n "$Qt6_ROOT" ]; then echo "Using Qt from Qt6_ROOT environment variable: $Qt6_ROOT" QT_DIR="$Qt6_ROOT" -# Auto-detect Qt installation in /opt/Qt (look for CLI-specific builds first) +# Auto-detect CLI Qt in QT_CACHE, then /opt/Qt (gcc_*_cli only) else - if [ -d "/opt/Qt" ]; then - echo "Checking for Qt installations in /opt/Qt..." - # Find the newest Qt6 version installed - NEWEST_QT=$(find -L /opt/Qt -maxdepth 1 -type d -name "6.*" | sort -V | tail -n 1) + _qt_search_dirs="" + if [ -n "${QT_CACHE:-}" ] && [ -d "$QT_CACHE" ]; then + case "$ARCH" in + x86_64) _qt_deb_arch=amd64 ;; + aarch64) _qt_deb_arch=arm64 ;; + armhf) _qt_deb_arch=armhf ;; + *) _qt_deb_arch="" ;; + esac + if [ -n "$_qt_deb_arch" ] && [ -d "$QT_CACHE/$_qt_deb_arch" ]; then + _qt_search_dirs="$QT_CACHE/$_qt_deb_arch" + fi + fi + if [ -z "$_qt_search_dirs" ] && [ -d "/opt/Qt" ]; then + _qt_search_dirs="/opt/Qt" + fi + + if [ -n "$_qt_search_dirs" ]; then + echo "Checking for CLI Qt installations in $_qt_search_dirs..." + NEWEST_QT=$(find -L "$_qt_search_dirs" -maxdepth 1 -type d -name "6.*" | sort -V | tail -n 1) if [ -n "$NEWEST_QT" ]; then QT_VERSION=$(basename "$NEWEST_QT") - # Find appropriate compiler directory for the architecture - # Priority: CLI-specific builds, then regular builds if [ "$ARCH" = "x86_64" ]; then if [ -d "$NEWEST_QT/gcc_64_cli" ]; then QT_DIR="$NEWEST_QT/gcc_64_cli" - echo "Found CLI-optimized Qt build" - elif [ -d "$NEWEST_QT/gcc_64" ]; then - QT_DIR="$NEWEST_QT/gcc_64" - echo "Using regular Qt build (consider building CLI-optimized version)" fi elif [ "$ARCH" = "aarch64" ]; then if [ -d "$NEWEST_QT/gcc_arm64_cli" ]; then QT_DIR="$NEWEST_QT/gcc_arm64_cli" - echo "Found CLI-optimized Qt build" - elif [ -d "$NEWEST_QT/gcc_arm64" ]; then - QT_DIR="$NEWEST_QT/gcc_arm64" - echo "Using regular Qt build (consider building CLI-optimized version)" fi - elif [ "$ARCH" = "armv7l" ]; then + elif [ "$ARCH" = "armhf" ]; then if [ -d "$NEWEST_QT/gcc_arm32_cli" ]; then QT_DIR="$NEWEST_QT/gcc_arm32_cli" - echo "Found CLI-optimized Qt build" - elif [ -d "$NEWEST_QT/gcc_arm32" ]; then - QT_DIR="$NEWEST_QT/gcc_arm32" - echo "Using regular Qt build (consider building CLI-optimized version)" fi fi if [ -n "$QT_DIR" ]; then - echo "Found Qt $QT_VERSION for $ARCH at $QT_DIR" - else - echo "Found Qt $QT_VERSION, but no binary directory for $ARCH" + echo "Found CLI Qt $QT_VERSION for $ARCH at $QT_DIR" + elif [ -n "$QT_VERSION" ]; then + echo "Found Qt $QT_VERSION, but no CLI build for $ARCH (gcc_*_cli)" QT_VERSION="" fi fi @@ -158,25 +193,38 @@ fi # If Qt not found, suggest building it if [ -z "$QT_DIR" ]; then - echo "Error: No suitable Qt installation found for $ARCH" + echo "Error: No CLI-only Qt installation found for $ARCH" >&2 + echo "Desktop Qt (gcc_64, gcc_arm64, …) must not be used for CLI AppImages." >&2 if [ -f "./qt/build-qt-cli.sh" ]; then - echo "You can build a CLI-optimized Qt using:" - echo " ./qt/build-qt-cli.sh --version=6.9.1" - echo "Or specify the Qt location with:" - echo " $0 --qt-root=/path/to/qt" + echo "Build a CLI-only Qt with:" >&2 + echo " debian/ensure-qt.sh " >&2 + echo "Or specify the CLI Qt path with:" >&2 + echo " $0 --qt-root=/opt/Qt/VERSION/gcc_*_cli" >&2 else - echo "You can specify the Qt location with:" - echo " $0 --qt-root=/path/to/qt" + echo "Specify the CLI Qt path with:" >&2 + echo " $0 --qt-root=/path/to/gcc_*_cli" >&2 fi exit 1 fi -# Check if Qt Version +validate_cli_qt_dir "$QT_DIR" + +# Check Qt version (do not execute foreign-arch qmake on the build host) if [ -f "$QT_DIR/bin/qmake" ]; then - QT_VERSION=$("$QT_DIR/bin/qmake" -query QT_VERSION) - echo "Qt version: $QT_VERSION" + if [ "$ARCH" = "$TOOL_ARCH" ] || [ -n "${RPI_IMAGER_CHROOT:-}" ]; then + QT_VERSION=$("$QT_DIR/bin/qmake" -query QT_VERSION) + echo "Qt version: $QT_VERSION" + elif [ -z "$QT_VERSION" ]; then + QT_VERSION=$(basename "$(dirname "$QT_DIR")") + echo "Qt version: $QT_VERSION (from cache path; foreign-arch qmake not executed on host)" + fi +fi +fi + +if [ "$APPIMAGE_PACKAGING" = pack ]; then + echo "create-appimage-cli: using AppDir from build stage (pack)" fi # Configuration @@ -193,29 +241,26 @@ mkdir -p "$TOOLS_DIR" # Download linuxdeploy and plugins if they don't exist echo "Ensuring linuxdeploy tools are available..." -# Choose the right linuxdeploy tools based on architecture -if [ "$ARCH" = "x86_64" ]; then - LINUXDEPLOY="$TOOLS_DIR/linuxdeploy-x86_64.AppImage" - - if [ ! -f "$LINUXDEPLOY" ]; then - echo "Downloading linuxdeploy for x86_64..." - curl -L -o "$LINUXDEPLOY" "https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage" - chmod +x "$LINUXDEPLOY" - fi -elif [ "$ARCH" = "aarch64" ]; then - LINUXDEPLOY="$TOOLS_DIR/linuxdeploy-aarch64.AppImage" - - if [ ! -f "$LINUXDEPLOY" ]; then - echo "Downloading linuxdeploy for aarch64..." - curl -L -o "$LINUXDEPLOY" "https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-aarch64.AppImage" - chmod +x "$LINUXDEPLOY" - fi -elif [ "$ARCH" = "armv7l" ]; then - # Note: linuxdeploy may not have armv7l builds, fallback to manual deployment - echo "Warning: linuxdeploy may not support armv7l, attempting manual deployment" - LINUXDEPLOY="" +# Choose packaging tools for the machine running this script (TOOL_ARCH). +LINUXDEPLOY="" +APPIMAGETOOL="" +# linuxdeploy has no armhf build; those targets fall back to appimagetool below. +if [ "$ARCH" = "$TOOL_ARCH" ] && { [ "$TOOL_ARCH" = "x86_64" ] || [ "$TOOL_ARCH" = "aarch64" ]; }; then + LINUXDEPLOY="$TOOLS_DIR/linuxdeploy-$TOOL_ARCH.AppImage" + appimage_download_tool "$LINUXDEPLOY" \ + "https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-$TOOL_ARCH.AppImage" fi +APPIMAGETOOL="$TOOLS_DIR/appimagetool-$TOOL_ARCH.AppImage" +appimage_download_tool "$APPIMAGETOOL" \ + "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-$TOOL_ARCH.AppImage" + +if [ "$APPIMAGE_PACKAGING" = pack ] && [ ! -d "$APPDIR/usr/bin" ]; then + echo "Error: AppDir missing for pack stage: $APPDIR" >&2 + exit 1 +fi + +if [ "$APPIMAGE_PACKAGING" != pack ]; then # Set up build directory BUILD_DIR="build-cli-$ARCH" @@ -238,8 +283,8 @@ if [ "$ARCH" = "aarch64" ] && [ "$(uname -m)" = "x86_64" ]; then # Cross-compiling from x86_64 to aarch64 echo "Cross-compiling from $(uname -m) to $ARCH" CMAKE_EXTRA_FLAGS="-DCMAKE_SYSTEM_NAME=Linux -DCMAKE_SYSTEM_PROCESSOR=aarch64" -elif [ "$ARCH" = "armv7l" ] && [ "$(uname -m)" = "x86_64" ]; then - # Cross-compiling from x86_64 to armv7l +elif [ "$ARCH" = "armhf" ] && [ "$(uname -m)" = "x86_64" ]; then + # Cross-compiling from x86_64 to armhf (Pi 1 / Pi 2 32-bit OS) echo "Cross-compiling from $(uname -m) to $ARCH" CMAKE_EXTRA_FLAGS="-DCMAKE_SYSTEM_NAME=Linux -DCMAKE_SYSTEM_PROCESSOR=arm" fi @@ -251,12 +296,12 @@ CMAKE_EXTRA_FLAGS="$CMAKE_EXTRA_FLAGS -DQt6_ROOT=$QT_DIR" CMAKE_EXTRA_FLAGS="$CMAKE_EXTRA_FLAGS -DBUILD_CLI_ONLY=ON" # shellcheck disable=SC2086 -cmake "../$SOURCE_DIR" -DCMAKE_BUILD_TYPE="$BUILD_TYPE" -DCMAKE_INSTALL_PREFIX=/usr $CMAKE_EXTRA_FLAGS -make -j"$(nproc)" +cmake -G Ninja "../$SOURCE_DIR" -DCMAKE_BUILD_TYPE="$BUILD_TYPE" -DCMAKE_INSTALL_PREFIX=/usr $CMAKE_EXTRA_FLAGS +cmake --build . --parallel "$(cmake_build_jobs)" echo "Creating CLI-only AppDir..." # Install to AppDir -make DESTDIR="$APPDIR" install +DESTDIR="$APPDIR" cmake --install . cd .. # Desktop file and icon are already installed by CMake @@ -311,6 +356,12 @@ rm -f "$APPDIR/usr/lib/libQt6Widgets.so"* 2>/dev/null || true rm -f "$APPDIR/usr/lib/libQt6Svg.so"* 2>/dev/null || true rm -f "$APPDIR/usr/lib/libQt"*"QuickControls"*.so* 2>/dev/null || true +# Bundle the non-host-coupled dependency closure of Qt Core/Network and the TLS +# backends. Without this the CLI relies on the host for ICU, PCRE2, zstd and +# friends; ICU is soname-pinned per Debian release, so leaving it out would tie +# the package to a single distro version. +appimage_deploy_lib_closure "$APPDIR" "$QT_DIR/lib" || exit 1 + # Remove development files to save space find "$APPDIR" -name "*.a" -delete 2>/dev/null || true find "$APPDIR" -name "*.la" -delete 2>/dev/null || true @@ -329,13 +380,20 @@ if [ -n "$SO_FILES" ]; then strip --strip-unneeded $SO_FILES 2>/dev/null || true fi cd "$SAVED_DIR" +fi + +if [ "$APPIMAGE_PACKAGING" = build ]; then + prepare_appdir_for_appimagetool "$APPDIR" com.raspberrypi.rpi-imager-cli + echo "create-appimage-cli: build stage complete (AppDir at $APPDIR)" + exit 0 +fi echo "Creating CLI-only AppImage..." # Remove old symlinks for CLI variant only rm -f "$PWD/rpi-imager-cli.AppImage" rm -f "$PWD/rpi-imager-cli-$ARCH.AppImage" -if [ -n "$LINUXDEPLOY" ] && [ -f "$LINUXDEPLOY" ]; then +if [ -n "$LINUXDEPLOY" ] && [ -f "$LINUXDEPLOY" ] && [ "$ARCH" = "$TOOL_ARCH" ] && [ "$APPIMAGE_PACKAGING" = all ]; then # Create AppImage using linuxdeploy # Explicitly specify the desktop file to ensure correct naming LD_LIBRARY_PATH="$QT_DIR/lib:$LD_LIBRARY_PATH" "$LINUXDEPLOY" --appdir="$APPDIR" \ @@ -370,6 +428,9 @@ if [ -n "$LINUXDEPLOY" ] && [ -f "$LINUXDEPLOY" ]; then echo "Looking for any matching AppImage..." ls -la ./*.AppImage 2>/dev/null || true fi +elif [ -n "${APPIMAGETOOL:-}" ] && [ -f "$APPIMAGETOOL" ]; then + appimage_pack_with_tool "$APPIMAGETOOL" "$APPDIR" "$OUTPUT_FILE" \ + "$ARCH" "$TOOL_ARCH" com.raspberrypi.rpi-imager-cli || exit 1 else # Manual AppImage creation (basic implementation) echo "Creating AppImage manually (basic implementation)..." diff --git a/create-appimage.sh b/create-appimage.sh index 52639298a..92ea58b5e 100755 --- a/create-appimage.sh +++ b/create-appimage.sh @@ -5,12 +5,14 @@ set -e ARCH=$(uname -m) # Default to current architecture CLEAN_BUILD=1 QT_ROOT_ARG="" +TRY_BUILD_QT=0 usage() { echo "Usage: $0 [options]" echo "Options:" - echo " --arch=ARCH Target architecture (x86_64, aarch64)" + echo " --arch=ARCH Target architecture (x86_64, aarch64, armhf)" echo " --qt-root=PATH Path to Qt installation directory" + echo " --try-build-qt Run debian/ensure-qt.sh if Qt is missing (best-effort)" echo " --no-clean Don't clean build directory" echo " -h, --help Show this help message" exit 1 @@ -24,9 +26,15 @@ for arg in "$@"; do --qt-root=*) QT_ROOT_ARG="${arg#*=}" ;; + --try-build-qt) + TRY_BUILD_QT=1 + ;; --no-clean) CLEAN_BUILD=0 ;; + --packaging=*) + APPIMAGE_PACKAGING="${arg#*=}" + ;; -h|--help) usage ;; @@ -53,13 +61,31 @@ if [ -n "$QT_ROOT_ARG" ]; then fi fi -# Validate architecture -if [ "$ARCH" != "x86_64" ] && [ "$ARCH" != "aarch64" ]; then - echo "Error: Architecture must be one of: x86_64, aarch64" +# Normalise 32-bit Pi kernel names to Debian armhf. +case "$ARCH" in + armv6l|armv7l) ARCH=armhf ;; +esac +if [ "$ARCH" != "x86_64" ] && [ "$ARCH" != "aarch64" ] && [ "$ARCH" != "armhf" ]; then + echo "Error: Architecture must be one of: x86_64, aarch64, armhf" >&2 exit 1 fi -echo "Building for architecture: $ARCH" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TOP="$SCRIPT_DIR" +# shellcheck disable=SC1091 +. "$TOP/debian/lib.sh" +export_cmake_parallel +sh "$TOP/debian/fetch-vendor-deps.sh" +# shellcheck source=debian/qt-resolve.sh +. "$TOP/debian/qt-resolve.sh" + +APPIMAGE_PACKAGING=${APPIMAGE_PACKAGING:-all} +TOOL_ARCH=$(appimage_resolve_tool_arch) + +echo "Building for architecture: $ARCH (packaging tools: $TOOL_ARCH, mode: $APPIMAGE_PACKAGING)" +if [ "$ARCH" != "$TOOL_ARCH" ]; then + echo "create-appimage: cross packaging — build target binaries for $ARCH, run linuxdeploy/appimagetool for $TOOL_ARCH on the host" +fi # Extract project information from CMakeLists.txt SOURCE_DIR="src/" @@ -89,78 +115,42 @@ PROJECT_NAME=$(grep "project(" "$CMAKE_FILE" | head -1 | sed 's/project(\([^[:sp echo "Building $PROJECT_NAME version $GIT_VERSION (numeric: $PROJECT_VERSION)" -# Check for Qt installation -# Priority: 1. Command line argument, 2. Environment variable, 3. Auto-detection -QT_VERSION="" +# Resolve Qt (vendored cache, /opt/Qt, or system qmake6). +# The pack stage only wraps an AppDir built earlier, so it never needs Qt — even +# when target arch == tool arch (host-arch chroot build packed on the host). QT_DIR="" - -# Check if Qt root is specified via command line argument (highest priority) -if [ -n "$QT_ROOT_ARG" ]; then - echo "Using Qt from command line argument: $QT_ROOT_ARG" - QT_DIR="$QT_ROOT_ARG" - # Try to determine the version if possible - if [ -f "$QT_DIR/bin/qmake" ]; then - QT_VERSION=$("$QT_DIR/bin/qmake" -query QT_VERSION) - echo "Qt version: $QT_VERSION" - fi -# Check if Qt6_ROOT is explicitly set in environment -elif [ -n "$Qt6_ROOT" ]; then - echo "Using Qt from Qt6_ROOT environment variable: $Qt6_ROOT" - QT_DIR="$Qt6_ROOT" - # Try to determine the version if possible - if [ -f "$QT_DIR/bin/qmake" ]; then - QT_VERSION=$("$QT_DIR/bin/qmake" -query QT_VERSION) - echo "Qt version: $QT_VERSION" - fi -# Auto-detect Qt installation in /opt/Qt -else - if [ -d "/opt/Qt" ]; then - echo "Checking for Qt installations in /opt/Qt..." - # Find the newest Qt6 version installed - NEWEST_QT=$(find -L /opt/Qt -maxdepth 1 -type d -name "6.*" | sort -V | tail -n 1) - if [ -n "$NEWEST_QT" ]; then - QT_VERSION=$(basename "$NEWEST_QT") - - # Find appropriate compiler directory for the architecture - if [ "$ARCH" = "x86_64" ]; then - if [ -d "$NEWEST_QT/gcc_64" ]; then - QT_DIR="$NEWEST_QT/gcc_64" - fi - elif [ "$ARCH" = "aarch64" ]; then - if [ -d "$NEWEST_QT/gcc_arm64" ]; then - QT_DIR="$NEWEST_QT/gcc_arm64" - fi - fi - - if [ -n "$QT_DIR" ]; then - echo "Found Qt $QT_VERSION for $ARCH at $QT_DIR" - else - echo "Found Qt $QT_VERSION, but no binary directory for $ARCH" - QT_VERSION="" - fi +if [ "$APPIMAGE_PACKAGING" != pack ]; then +if ! QT_DIR=$(qt_resolve_desktop_dir "$ARCH"); then + if [ "$TRY_BUILD_QT" -eq 1 ] && [ -x "$SCRIPT_DIR/debian/ensure-qt.sh" ]; then + _deb_arch=$ARCH + case "$ARCH" in + x86_64) _deb_arch=amd64 ;; + aarch64) _deb_arch=arm64 ;; + esac + echo "create-appimage: attempting Qt build via ensure-qt.sh $_deb_arch..." + if "$SCRIPT_DIR/debian/ensure-qt.sh" "$_deb_arch"; then + QT_DIR=$(qt_resolve_desktop_dir "$ARCH") || true fi fi fi -# If Qt not found, suggest running build-qt.sh if [ -z "$QT_DIR" ]; then - echo "Error: No suitable Qt installation found for $ARCH" - - if [ -f "./qt/build-qt.sh" ]; then - echo "You can build Qt using the provided script:" - echo " ./qt/build-qt.sh --version=6.9.1" - echo "Or specify the Qt location with:" - echo " $0 --qt-root=/path/to/qt" - echo " export Qt6_ROOT=/path/to/qt" - else - echo "You can specify the Qt location with:" - echo " $0 --qt-root=/path/to/qt" - echo " export Qt6_ROOT=/path/to/qt" - fi - + echo "Error: No suitable Qt6 installation found for $ARCH" >&2 + echo " Vendored: debian/ensure-qt.sh or ./qt/build-qt.sh" >&2 + echo " System: apt install qt6-base-dev qt6-declarative-dev" >&2 + echo " Or: $0 --qt-root=/path/to/qt6 --try-build-qt" >&2 exit 1 fi +if [ -f "$QT_DIR/bin/qmake" ]; then + QT_VERSION=$("$QT_DIR/bin/qmake" -query QT_VERSION) + echo "Using Qt $QT_VERSION from $QT_DIR (source: ${QT_RESOLVE_SOURCE:-unknown})" +elif _qmake=$(qt6_qmake); then + QT_VERSION=$("$_qmake" -query QT_VERSION) + echo "Using system Qt $QT_VERSION (prefix $QT_DIR)" +fi +fi + # Configuration BUILD_TYPE="MinSizeRel" QML_SOURCES_PATH="$PWD/src/qmlcomponents/" @@ -192,39 +182,33 @@ echo "Ensuring linuxdeploy tools are available..." LINUXDEPLOY_VERSION="1-alpha-20250213-2" LINUXDEPLOY_PLUGIN_QT_VERSION="1-alpha-20250213-1" -# Choose the right linuxdeploy tools based on architecture -if [ "$ARCH" = "x86_64" ]; then - LINUXDEPLOY="$TOOLS_DIR/linuxdeploy-x86_64.AppImage" - LINUXDEPLOY_QT="$TOOLS_DIR/linuxdeploy-plugin-qt-x86_64.AppImage" - - if [ ! -f "$LINUXDEPLOY" ]; then - echo "Downloading linuxdeploy $LINUXDEPLOY_VERSION for x86_64..." - curl -L -o "$LINUXDEPLOY" "https://github.com/linuxdeploy/linuxdeploy/releases/download/$LINUXDEPLOY_VERSION/linuxdeploy-x86_64.AppImage" - chmod +x "$LINUXDEPLOY" - fi - - if [ ! -f "$LINUXDEPLOY_QT" ]; then - echo "Downloading linuxdeploy-plugin-qt $LINUXDEPLOY_PLUGIN_QT_VERSION for x86_64..." - curl -L -o "$LINUXDEPLOY_QT" "https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/$LINUXDEPLOY_PLUGIN_QT_VERSION/linuxdeploy-plugin-qt-x86_64.AppImage" - chmod +x "$LINUXDEPLOY_QT" - fi -elif [ "$ARCH" = "aarch64" ]; then - LINUXDEPLOY="$TOOLS_DIR/linuxdeploy-aarch64.AppImage" - LINUXDEPLOY_QT="$TOOLS_DIR/linuxdeploy-plugin-qt-aarch64.AppImage" - - if [ ! -f "$LINUXDEPLOY" ]; then - echo "Downloading linuxdeploy $LINUXDEPLOY_VERSION for aarch64..." - curl -L -o "$LINUXDEPLOY" "https://github.com/linuxdeploy/linuxdeploy/releases/download/$LINUXDEPLOY_VERSION/linuxdeploy-aarch64.AppImage" - chmod +x "$LINUXDEPLOY" - fi - - if [ ! -f "$LINUXDEPLOY_QT" ]; then - echo "Downloading linuxdeploy-plugin-qt $LINUXDEPLOY_PLUGIN_QT_VERSION for aarch64..." - curl -L -o "$LINUXDEPLOY_QT" "https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/$LINUXDEPLOY_PLUGIN_QT_VERSION/linuxdeploy-plugin-qt-aarch64.AppImage" - chmod +x "$LINUXDEPLOY_QT" - fi +# Choose packaging tools for the machine running this script (TOOL_ARCH), not the +# target AppImage CPU architecture (ARCH). +LINUXDEPLOY="" +LINUXDEPLOY_QT="" +APPIMAGETOOL="" + +# linuxdeploy has no armhf build; those targets fall back to appimagetool below. +if [ "$ARCH" = "$TOOL_ARCH" ] && { [ "$TOOL_ARCH" = "x86_64" ] || [ "$TOOL_ARCH" = "aarch64" ]; }; then + LINUXDEPLOY="$TOOLS_DIR/linuxdeploy-$TOOL_ARCH.AppImage" + LINUXDEPLOY_QT="$TOOLS_DIR/linuxdeploy-plugin-qt-$TOOL_ARCH.AppImage" + appimage_download_tool "$LINUXDEPLOY" \ + "https://github.com/linuxdeploy/linuxdeploy/releases/download/$LINUXDEPLOY_VERSION/linuxdeploy-$TOOL_ARCH.AppImage" + appimage_download_tool "$LINUXDEPLOY_QT" \ + "https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/$LINUXDEPLOY_PLUGIN_QT_VERSION/linuxdeploy-plugin-qt-$TOOL_ARCH.AppImage" fi +APPIMAGETOOL="$TOOLS_DIR/appimagetool-$TOOL_ARCH.AppImage" +appimage_download_tool "$APPIMAGETOOL" \ + "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-$TOOL_ARCH.AppImage" + +if [ "$APPIMAGE_PACKAGING" = pack ] && [ ! -d "$APPDIR/usr/bin" ]; then + echo "Error: AppDir missing for pack stage: $APPDIR" >&2 + echo "Run the build stage first (APPIMAGE_PACKAGING=build)." >&2 + exit 1 +fi + +if [ "$APPIMAGE_PACKAGING" != pack ]; then # Set up build directory BUILD_DIR="build-$ARCH" @@ -244,10 +228,11 @@ cd "$BUILD_DIR" # Set architecture-specific CMake flags CMAKE_EXTRA_FLAGS="" if [ "$ARCH" = "aarch64" ] && [ "$(uname -m)" = "x86_64" ]; then - # Cross-compiling from x86_64 to aarch64 echo "Cross-compiling from $(uname -m) to $ARCH" - # You may need to adjust these flags depending on your cross-compilation setup CMAKE_EXTRA_FLAGS="-DCMAKE_SYSTEM_NAME=Linux -DCMAKE_SYSTEM_PROCESSOR=aarch64" +elif [ "$ARCH" = "armhf" ] && [ "$(uname -m)" = "x86_64" ]; then + echo "Cross-compiling from $(uname -m) to $ARCH" + CMAKE_EXTRA_FLAGS="-DCMAKE_SYSTEM_NAME=Linux -DCMAKE_SYSTEM_PROCESSOR=arm" fi # Add Qt path to CMake flags @@ -261,7 +246,8 @@ if [ -n "${IMAGER_BRAND:-}" ]; then fi # shellcheck disable=SC2086 -cmake "../$SOURCE_DIR" -DCMAKE_BUILD_TYPE="$BUILD_TYPE" -DCMAKE_INSTALL_PREFIX=/usr $CMAKE_EXTRA_FLAGS +# shellcheck disable=SC2086 +cmake -G Ninja "../$SOURCE_DIR" -DCMAKE_BUILD_TYPE="$BUILD_TYPE" -DCMAKE_INSTALL_PREFIX=/usr $CMAKE_EXTRA_FLAGS # UNRAID: adopt the identity the build just computed. This is the single source # of truth -- see src/cmake/branding.env.in. Everything below (AppRun's exec @@ -280,11 +266,11 @@ echo "Branding: ${IMAGER_APP_NAME} (executable '${IMAGER_EXE_NAME}')" # replaced by underscores; IMAGER_APPIMAGE_NAME is derived from the same source. OUTPUT_FILE="$REPO_ROOT/${IMAGER_APPIMAGE_NAME}-${GIT_VERSION}-desktop-${ARCH}.AppImage" -make -j"$(nproc)" +cmake --build . --parallel "$(cmake_build_jobs)" echo "Creating AppDir..." # Install to AppDir -make DESTDIR="$APPDIR" install +DESTDIR="$APPDIR" cmake --install . cd .. # Copy the desktop file from debian directory @@ -376,6 +362,16 @@ exec "${HERE}/usr/bin/${IMAGER_EXE_NAME}" "$@" EOF chmod +x "$APPDIR/AppRun" fi +fi + +# UNRAID: the pack stage runs no cmake, so branding.env was never sourced in +# this process. Read the one the build stage left behind, otherwise everything +# below resolves against an empty IMAGER_EXE_NAME. +if [ "$APPIMAGE_PACKAGING" = pack ] && [ -f "build-$ARCH/branding.env" ]; then + # shellcheck disable=SC1090 + . "build-$ARCH/branding.env" + OUTPUT_FILE="$REPO_ROOT/${IMAGER_APPIMAGE_NAME}-${GIT_VERSION}-desktop-${ARCH}.AppImage" +fi # UNRAID: fail here rather than shipping an AppImage that dies on launch. # @@ -391,20 +387,68 @@ if [ ! -x "$APPDIR/usr/bin/${IMAGER_EXE_NAME}" ]; then exit 1 fi -# Deploy Qt dependencies +# Deploy Qt dependencies (build stage, or native all-in-one). The pack stage +# reuses the AppDir produced by the build stage and only wraps it. +if [ "$APPIMAGE_PACKAGING" = pack ]; then + echo "create-appimage: using AppDir from build stage (pack)" +else echo "Deploying Qt dependencies using $QT_DIR..." export QML_SOURCES_PATHS="$QML_SOURCES_PATH" -# Enable FUSE to run the AppImages without extraction +export LD_LIBRARY_PATH="$QT_DIR/lib:$LD_LIBRARY_PATH" + +if [ -n "$LINUXDEPLOY" ] && [ -f "$LINUXDEPLOY" ] && [ "$ARCH" = "$TOOL_ARCH" ] && [ "$APPIMAGE_PACKAGING" = all ]; then export APPIMAGE_EXTRACT_AND_RUN=1 -# Set Qt path for linuxdeploy-plugin-qt export QMAKE="$QT_DIR/bin/qmake" -# Set library paths to include Qt libraries (both runtime and linker search paths) -export LD_LIBRARY_PATH="$QT_DIR/lib:$LD_LIBRARY_PATH" -# Optimize deployment: exclude translations and unnecessary libraries export LINUXDEPLOY_PLUGIN_QT_IGNORE_GLOB="*/translations/*" -# Exclude libsystemd - it must come from the host system to work correctly with DBus -# Including it causes compatibility issues (see https://github.com/raspberrypi/rpi-imager/issues/1304) "$LINUXDEPLOY" --appdir="$APPDIR" --plugin=qt --exclude-library="libwayland-*" --exclude-library="libsystemd*" --exclude-library="libdbus-*" --exclude-library="libcap*" --verbosity=0 +else + echo "Manual Qt deployment for $ARCH..." + mkdir -p "$APPDIR/usr/lib" "$APPDIR/usr/plugins" "$APPDIR/usr/qml" + cp -d "$QT_DIR/lib/libQt6"*.so* "$APPDIR/usr/lib/" 2>/dev/null || true + cp -d "$QT_DIR/lib/libicu"*.so* "$APPDIR/usr/lib/" 2>/dev/null || true + for _plug in platforms imageformats tls iconengines xcbglintegrations; do + if [ -d "$QT_DIR/plugins/$_plug" ]; then + mkdir -p "$APPDIR/usr/plugins/$_plug" + cp -a "$QT_DIR/plugins/$_plug/." "$APPDIR/usr/plugins/$_plug/" + fi + done + # "Qt" carries Qt.labs.* (e.g. Qt.labs.folderlistmodel used by ImFileDialog.qml); + # the native linuxdeploy path resolves these from QML imports automatically. + for _qml in Qt QtCore QtGui QtQml QtQuick QtQuickControls2 QML; do + if [ -d "$QT_DIR/qml/$_qml" ]; then + mkdir -p "$APPDIR/usr/qml/$_qml" + cp -a "$QT_DIR/qml/$_qml/." "$APPDIR/usr/qml/$_qml/" + fi + done +fi + +# Qt Wayland plugins, deployed for both paths above: linuxdeploy-plugin-qt does +# not ship these directories, and the manual list above does not either. +# +# libQt6WaylandClient looks for a shell integration under +# plugins/wayland-shell-integration; without libxdg-shell.so the "wayland" QPA +# plugin loads, connects to the compositor, then aborts with "Loading shell +# integration failed." Qt then falls back to "xcb", so every session -- Wayland +# included -- ends up on XWayland and needs libxcb-cursor0. On a target without +# that library (Raspberry Pi OS bookworm) both plugins fail and the app does not +# start at all. +# +# These are Qt's own plugins and belong with the bundled Qt, unlike the wayland +# *system* libraries: libwayland-client/-cursor stay host-provided via +# --exclude-library above and libwayland-client0/libwayland-cursor0 in +# debian/control. libxdg-shell.so links only those two plus libQt6WaylandClient, +# which is already bundled, so this adds no new host dependency. +# +# wayland-graphics-integration-client carries libqt-plugin-wayland-egl.so, which +# is what gets GPU-accelerated Wayland rather than software shm buffers. +for _plug in wayland-shell-integration wayland-graphics-integration-client \ + wayland-decoration-client; do + if [ -d "$QT_DIR/plugins/$_plug" ]; then + mkdir -p "$APPDIR/usr/plugins/$_plug" + cp -a "$QT_DIR/plugins/$_plug/." "$APPDIR/usr/plugins/$_plug/" + fi +done +fi # Hook for removing files before AppImage creation echo "Pre-packaging hook - opportunity to remove unwanted files" @@ -421,41 +465,38 @@ rm -rf "$APPDIR/usr/share/doc/libsystemd"* rm -rf "$APPDIR/usr/share/doc/libdbus"* rm -rf "$APPDIR/usr/share/doc/libcap"* -# Remove unused QML Controls themes (size optimization) -rm -rf "$APPDIR/usr/qml/QtQuick/Controls/Universal" -rm -rf "$APPDIR/usr/qml/QtQuick/Controls/Fusion" -rm -rf "$APPDIR/usr/qml/QtQuick/Controls/Imagine" -rm -rf "$APPDIR/usr/qml/QtQuick/Controls/FluentWinUI3" - -# Remove QtWidgets if included (we don't use it) -rm -f "$APPDIR/usr/lib/libQt6Widgets.so"* -rm -f "$APPDIR/usr/lib/libQt"*"Widgets.so"* - -# Remove QML debugging tools (development-only) -rm -rf "$APPDIR/usr/qml/QtTest"* -rm -rf "$APPDIR/usr/plugins/qmltooling" +# Prune the QML tree, style libraries and tooling to what the UI imports. +# Shared with the embedded packaging path -- see prune_qml_to_imports() in +# debian/lib.sh for the import list and how to re-derive it. +prune_qml_to_imports "$APPDIR/usr/qml" "$APPDIR/usr/lib" "$APPDIR/usr/plugins" # Remove Qt translations (we excluded them but remove any that might have slipped through) rm -rf "$APPDIR/usr/translations" rm -rf "$APPDIR/usr/share/qt6/translations" # Remove unnecessary image format plugins (consistency with all platforms) -# Excludes: TIFF, WebP, GIF (less common formats) +# Excludes: TIFF, WebP, GIF, JPEG 2000 (less common formats) # Keeps: JPEG, PNG, SVG (common formats + icons) +# libqjp2 additionally pulls in libjasper, which is not in the build chroot. rm -f "$APPDIR/usr/plugins/imageformats/libqtiff.so" rm -f "$APPDIR/usr/plugins/imageformats/libqwebp.so" rm -f "$APPDIR/usr/plugins/imageformats/libqgif.so" +rm -f "$APPDIR/usr/plugins/imageformats/libqjp2.so" + +# Bundle the non-host-coupled dependency closure of whatever survived pruning. +# The manual deployment branch above copies only Qt itself, so without this the +# AppImage relies on the host for ICU, PCRE2, zstd and friends -- ICU in +# particular is soname-pinned per Debian release (libicu72 on bookworm), which +# would tie the package to a single distro version. +if [ "$APPIMAGE_PACKAGING" != pack ]; then + appimage_deploy_lib_closure "$APPDIR" "$QT_DIR/lib" || exit 1 +fi -# Remove unused Qt Quick Controls 2 style libraries (size optimization) -rm -f "$APPDIR/usr/lib/libQt6QuickControls2Fusion.so"* -rm -f "$APPDIR/usr/lib/libQt6QuickControls2Universal.so"* -rm -f "$APPDIR/usr/lib/libQt6QuickControls2Imagine.so"* -rm -f "$APPDIR/usr/lib/libQt6QuickControls2FluentWinUI3.so"* -rm -f "$APPDIR/usr/lib/libQt6QuickControls2FusionStyleImpl.so"* -rm -f "$APPDIR/usr/lib/libQt6QuickControls2UniversalStyleImpl.so"* -rm -f "$APPDIR/usr/lib/libQt6QuickControls2ImagineStyleImpl.so"* -rm -f "$APPDIR/usr/lib/libQt6QuickControls2FluentWinUI3StyleImpl.so"* -rm -f "$APPDIR/usr/lib/libQt6QuickControls2WindowsStyleImpl.so"* +if [ "$APPIMAGE_PACKAGING" = build ]; then + prepare_appdir_for_appimagetool "$APPDIR" com.raspberrypi.rpi-imager + echo "create-appimage: build stage complete (AppDir at $APPDIR)" + exit 0 +fi # Create the AppImage echo "Creating AppImage..." @@ -463,11 +504,10 @@ echo "Creating AppImage..." rm -f "$PWD/${IMAGER_EXE_NAME}-desktop-$ARCH.AppImage" rm -f "$PWD/${IMAGER_EXE_NAME}-$ARCH.AppImage" # Legacy symlink name -# Ensure LD_LIBRARY_PATH is still set for this call too export LD_LIBRARY_PATH="$QT_DIR/lib:$LD_LIBRARY_PATH" -# Explicitly specify the desktop file to ensure correct naming -# Re-specify --exclude-library flags: linuxdeploy re-resolves dependencies during -# output generation, which would re-bundle excluded libraries. + +if [ -n "$LINUXDEPLOY" ] && [ -f "$LINUXDEPLOY" ] && [ "$ARCH" = "$TOOL_ARCH" ] && [ "$APPIMAGE_PACKAGING" = all ]; then +export APPIMAGE_EXTRACT_AND_RUN=1 "$LINUXDEPLOY" --appdir="$APPDIR" \ --desktop-file="$APPDIR/usr/share/applications/${IMAGER_BUNDLE_ID}.desktop" \ --exclude-library="libsystemd*" \ @@ -487,12 +527,23 @@ elif [ -f "$OUTPUT_FILE" ]; then echo "Output file already exists: $OUTPUT_FILE" else echo "Warning: Expected linuxdeploy output '$LINUXDEPLOY_OUTPUT' not found" - echo "Looking for any matching AppImage..." ls -la ./*.AppImage 2>/dev/null || true fi +elif [ -n "${APPIMAGETOOL:-}" ] && [ -f "$APPIMAGETOOL" ]; then + appimage_pack_with_tool "$APPIMAGETOOL" "$APPDIR" "$OUTPUT_FILE" \ + "$ARCH" "$TOOL_ARCH" com.raspberrypi.rpi-imager || exit 1 +else + echo "Error: no AppImage tooling available for $ARCH" >&2 + exit 1 +fi echo "AppImage created at $OUTPUT_FILE" +if [ ! -f "$OUTPUT_FILE" ]; then + echo "Error: AppImage was not created at $OUTPUT_FILE" >&2 + exit 1 +fi + # Create symlinks for debian packaging and user convenience # Primary symlink matches debian/rpi-imager.install expectations DEBIAN_SYMLINK="$PWD/${IMAGER_EXE_NAME}-$ARCH.AppImage" diff --git a/create-embedded.sh b/create-embedded.sh index bbb3c9d87..2d90052aa 100755 --- a/create-embedded.sh +++ b/create-embedded.sh @@ -7,6 +7,11 @@ set -e # Source common build functions for ICU version detection SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TOP="$SCRIPT_DIR" +# shellcheck disable=SC1091 +. "$TOP/debian/lib.sh" +export_cmake_parallel +sh "$TOP/debian/fetch-vendor-deps.sh" if [ -f "$SCRIPT_DIR/qt/qt-build-common.sh" ]; then . "$SCRIPT_DIR/qt/qt-build-common.sh" fi @@ -20,7 +25,7 @@ INCLUDE_CJK_FONTS=0 # Default: do not include 4MB CJK font usage() { echo "Usage: $0 [options]" echo "Options:" - echo " --arch=ARCH Target architecture (x86_64, aarch64, armv7l)" + echo " --arch=ARCH Target architecture (x86_64, aarch64, armhf)" echo " --qt-root=PATH Path to Qt installation directory" echo " --no-clean Don't clean build directory" echo " --include-cjk-fonts Include DroidSansFallbackFull.ttf for CJK support (+4MB)" @@ -77,9 +82,12 @@ if [ -n "$QT_ROOT_ARG" ]; then fi fi -# Validate architecture -if [ "$ARCH" != "x86_64" ] && [ "$ARCH" != "aarch64" ] && [ "$ARCH" != "armv7l" ]; then - echo "Error: Architecture must be one of: x86_64, aarch64, armv7l" +# Validate architecture (armv6l/armv7l from uname on 32-bit Pi OS → armhf) +case "$ARCH" in + armv6l|armv7l) ARCH=armhf ;; +esac +if [ "$ARCH" != "x86_64" ] && [ "$ARCH" != "aarch64" ] && [ "$ARCH" != "armhf" ]; then + echo "Error: Architecture must be one of: x86_64, aarch64, armhf" >&2 exit 1 fi @@ -93,17 +101,20 @@ CMAKE_FILE="${SOURCE_DIR}CMakeLists.txt" GIT_VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "0.0.0-unknown") # Extract numeric version components for compatibility -# Match versions like: v1.2.3, 1.2.3, v1.2.3-extra, etc. +# Match versions like: v1.2.3, 1.2.3, v1.2.3-extra, v1.2.3.4 (hotfix), etc. +# TWEAK is the optional fourth component and stays empty for ordinary releases. MAJOR=$(echo "$GIT_VERSION" | sed -n 's/^v\{0,1\}\([0-9]\{1,\}\)\.[0-9]\{1,\}\.[0-9]\{1,\}.*/\1/p') MINOR=$(echo "$GIT_VERSION" | sed -n 's/^v\{0,1\}[0-9]\{1,\}\.\([0-9]\{1,\}\)\.[0-9]\{1,\}.*/\1/p') PATCH=$(echo "$GIT_VERSION" | sed -n 's/^v\{0,1\}[0-9]\{1,\}\.[0-9]\{1,\}\.\([0-9]\{1,\}\).*/\1/p') +TWEAK=$(echo "$GIT_VERSION" | sed -n 's/^v\{0,1\}[0-9]\{1,\}\.[0-9]\{1,\}\.[0-9]\{1,\}\.\([0-9]\{1,\}\).*/\1/p') if [ -n "$MAJOR" ] && [ -n "$MINOR" ] && [ -n "$PATCH" ]; then - PROJECT_VERSION="$MAJOR.$MINOR.$PATCH" + PROJECT_VERSION="$MAJOR.$MINOR.$PATCH${TWEAK:+.$TWEAK}" else MAJOR="0" MINOR="0" PATCH="0" + TWEAK="" PROJECT_VERSION="0.0.0" echo "Warning: Could not parse version from git tag: $GIT_VERSION" fi @@ -139,7 +150,7 @@ else if [ -d "$NEWEST_QT/gcc_arm64_embedded" ]; then QT_DIR="$NEWEST_QT/gcc_arm64_embedded" fi - elif [ "$ARCH" = "armv7l" ]; then + elif [ "$ARCH" = "armhf" ]; then if [ -d "$NEWEST_QT/gcc_arm32_embedded" ]; then QT_DIR="$NEWEST_QT/gcc_arm32_embedded" fi @@ -159,9 +170,13 @@ fi if [ -z "$QT_DIR" ]; then echo "Error: No suitable Qt installation found for $ARCH" - if [ -f "./qt/build-qt.sh" ]; then - echo "You can build Qt using:" - echo " ./qt/build-qt.sh --version=6.9.1" + if [ -f "./qt/build-qt-embedded.sh" ]; then + echo "The embedded package needs the dedicated -no-opengl -no-dbus" + echo "linuxfb Qt, which debian/build-embedded.sh builds for you:" + echo " debian/build-embedded.sh arm64" + echo "Or build it directly (version defaults to QT_VERSION_DEFAULT in" + echo "qt/qt-build-common.sh):" + echo " ./qt/build-qt-embedded.sh" echo "Or specify the Qt location with:" echo " $0 --qt-root=/path/to/qt" else @@ -172,42 +187,14 @@ if [ -z "$QT_DIR" ]; then exit 1 fi -# Ensure Qt host tools (qmake, rcc, etc.) can load custom ICU runtime libs. -# This matters for embedded Qt builds linked against ICU 73 from qt/icu/install. -for ICU_RUNTIME_LIB_DIR in \ - "$SCRIPT_DIR/qt/icu/install/lib" \ - "$SCRIPT_DIR/qt/icu/icu4c/source/lib" -do - if [ -d "$ICU_RUNTIME_LIB_DIR" ]; then - case "${LD_LIBRARY_PATH:-}" in - *"$ICU_RUNTIME_LIB_DIR"*) ;; - *) - export LD_LIBRARY_PATH="$ICU_RUNTIME_LIB_DIR${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" - echo "Using ICU runtime libraries from: $ICU_RUNTIME_LIB_DIR" - ;; - esac - break - fi -done - # Check if Qt Version if [ -f "$QT_DIR/bin/qmake" ]; then QT_VERSION=$("$QT_DIR/bin/qmake" -query QT_VERSION) echo "Qt version: $QT_VERSION" fi -# Detect ICU version for this Qt version -# Check if the function exists (was sourced from qt-build-common.sh) -if type get_icu_version_for_qt >/dev/null 2>&1; then - ICU_VERSION=$(get_icu_version_for_qt "$QT_VERSION") - ICU_MAJOR_VERSION="${ICU_VERSION%%.*}" # Extract major version (e.g., 76 from 76.1) - echo "Using ICU version: $ICU_VERSION (major: $ICU_MAJOR_VERSION)" -else - # Fallback if common functions not available - echo "Warning: Could not determine ICU version, using default 73.2" - ICU_VERSION="73.2" - ICU_MAJOR_VERSION="73" -fi +# Qt is built with -no-feature-icu (see qt/features_exclude.list), so nothing +# in the vendored tree links ICU and none is bundled. # Configuration BUILD_TYPE="MinSizeRel" # Optimize for size in embedded systems @@ -220,12 +207,10 @@ OPTDIR="$DEBDIR/opt/rpi-imager-embedded" case "$ARCH" in aarch64) DEB_ARCH="arm64" ;; x86_64) DEB_ARCH="amd64" ;; - armv7l) DEB_ARCH="armhf" ;; + armhf) DEB_ARCH="armhf" ;; *) DEB_ARCH="$ARCH" ;; esac -OUTPUT_FILE="$PWD/rpi-imager-embedded_${PROJECT_VERSION}_${DEB_ARCH}.deb" - # Set up build directory BUILD_DIR="build-embedded-$ARCH" @@ -238,7 +223,6 @@ fi mkdir -p "$OPTDIR/bin" mkdir -p "$OPTDIR/lib" mkdir -p "$DEBDIR/usr/bin" -mkdir -p "$DEBDIR/DEBIAN" mkdir -p "$BUILD_DIR" echo "Building rpi-imager for embedded $ARCH..." @@ -251,8 +235,8 @@ if [ "$ARCH" = "aarch64" ] && [ "$(uname -m)" = "x86_64" ]; then # Cross-compiling from x86_64 to aarch64 echo "Cross-compiling from $(uname -m) to $ARCH" CMAKE_EXTRA_FLAGS="-DCMAKE_SYSTEM_NAME=Linux -DCMAKE_SYSTEM_PROCESSOR=aarch64" -elif [ "$ARCH" = "armv7l" ] && [ "$(uname -m)" = "x86_64" ]; then - # Cross-compiling from x86_64 to armv7l +elif [ "$ARCH" = "armhf" ] && [ "$(uname -m)" = "x86_64" ]; then + # Cross-compiling from x86_64 to armhf (Pi 1 / Pi 2 32-bit OS) echo "Cross-compiling from $(uname -m) to $ARCH" CMAKE_EXTRA_FLAGS="-DCMAKE_SYSTEM_NAME=Linux -DCMAKE_SYSTEM_PROCESSOR=arm" fi @@ -263,9 +247,16 @@ CMAKE_EXTRA_FLAGS="$CMAKE_EXTRA_FLAGS -DQt6_ROOT=$QT_DIR" ## Build embedded version CMAKE_EXTRA_FLAGS="$CMAKE_EXTRA_FLAGS -DBUILD_EMBEDDED=ON" +# Bake the deployed rpath in at build time. cmake's default build rpath points +# at $QT_DIR/lib in the build tree; the binary is copied to /opt as-is (not +# installed), so without this it ships a leaked absolute build-host path that +# also breaks dpkg-shlibdeps. $ORIGIN/../lib matches the bundled layout and the +# launcher's LD_LIBRARY_PATH. +CMAKE_EXTRA_FLAGS="$CMAKE_EXTRA_FLAGS -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON -DCMAKE_INSTALL_RPATH=\$ORIGIN/../lib" + # shellcheck disable=SC2086 -cmake "../$SOURCE_DIR" -DCMAKE_BUILD_TYPE="$BUILD_TYPE" -DCMAKE_INSTALL_PREFIX=/usr $CMAKE_EXTRA_FLAGS -make -j"$(nproc)" +cmake -G Ninja "../$SOURCE_DIR" -DCMAKE_BUILD_TYPE="$BUILD_TYPE" -DCMAKE_INSTALL_PREFIX=/usr $CMAKE_EXTRA_FLAGS +cmake --build . --parallel "$(cmake_build_jobs)" cd .. echo "Populating .deb staging tree..." @@ -324,7 +315,6 @@ echo "Deploying Qt dependencies for embedded systems..." # Copy essential Qt libraries (QtWidgets excluded - not used) cp -d "$QT_DIR/lib/libQt6Core.so"* "$OPTDIR/lib/" cp -d "$QT_DIR/lib/libQt6Gui.so"* "$OPTDIR/lib/" -cp -d "$QT_DIR/lib/libQt6DBus.so"* "$OPTDIR/lib/" # Required by linuxfb plugin cp -d "$QT_DIR/lib/libQt6Quick.so"* "$OPTDIR/lib/" cp -d "$QT_DIR/lib/libQt6Qml.so"* "$OPTDIR/lib/" cp -d "$QT_DIR/lib/libQt6QmlCore.so"* "$OPTDIR/lib/" 2>/dev/null || true @@ -373,7 +363,10 @@ if [ -f "/lib/${ARCH}-linux-gnu/libfontconfig.so.1" ] || [ -f "/usr/lib/${ARCH}- # Copy font configuration files for fontconfig to work properly mkdir -p "$OPTDIR/etc/fonts" if [ -d "/etc/fonts" ]; then - cp -r /etc/fonts/* "$OPTDIR/etc/fonts/" 2>/dev/null || true + # -L dereferences the conf.d/*.conf symlinks (which point at absolute + # /usr/share/fontconfig/conf.avail paths) so the bundle is self-contained + # rather than shipping links that dangle on a target without fontconfig. + cp -rL /etc/fonts/* "$OPTDIR/etc/fonts/" 2>/dev/null || true echo "Copied system font configuration" fi @@ -416,9 +409,9 @@ cp -d "/lib/${ARCH}-linux-gnu"/libgobject-2.0.so* "$OPTDIR/lib/" 2>/dev/null || cp -d "/lib/${ARCH}-linux-gnu"/libgudev-1.0.so* "$OPTDIR/lib/" 2>/dev/null || \ cp -d "/usr/lib/${ARCH}-linux-gnu"/libgudev-1.0.so* "$OPTDIR/lib/" 2>/dev/null || true -# Copy additional system libraries -cp -d "/lib/${ARCH}-linux-gnu"/libdbus-1.so* "$OPTDIR/lib/" 2>/dev/null || \ - cp -d "/usr/lib/${ARCH}-linux-gnu"/libdbus-1.so* "$OPTDIR/lib/" 2>/dev/null || true +# Note: libdbus-1 is intentionally NOT included. The embedded build links no +# QtDBus (see the QT_DBUS_LIB guards in the app), so nothing in the tree needs +# it; bundling it only dragged in libsystemd, which the netboot image lacks. # Note: libsystemd is intentionally NOT included - it must come from the host system # to work correctly with DBus (see https://github.com/raspberrypi/rpi-imager/issues/1304) @@ -473,29 +466,8 @@ cp "$QT_DIR/qml/QtQuick/Controls/Basic/impl/libqtquickcontrols2basicstyleimplplu cp "$QT_DIR/qml/QtQuick/Controls/Basic/libqtquickcontrols2basicstyleplugin.so" "$OPTDIR/qml/QtQuick/Controls/Basic/" 2>/dev/null || true cp "$QT_DIR/qml/QtQuick/Controls/Material/libqtquickcontrols2materialstyleplugin.so" "$OPTDIR/qml/QtQuick/Controls/Material/" 2>/dev/null || true -# Copy ICU libraries (using detected version) -echo "Copying ICU $ICU_VERSION libraries..." -ICU_LIB_DIR="$PWD/qt/icu/install/lib" -if [ ! -d "$ICU_LIB_DIR" ]; then - ICU_LIB_DIR="$PWD/qt/icu/icu4c/source/lib" -fi -if [ -d "$ICU_LIB_DIR" ]; then - cp "$ICU_LIB_DIR/libicudata.so.$ICU_MAJOR_VERSION" "$OPTDIR/lib/" 2>/dev/null || \ - echo "Warning: Could not find libicudata.so.$ICU_MAJOR_VERSION" - cp "$ICU_LIB_DIR/libicui18n.so.$ICU_MAJOR_VERSION" "$OPTDIR/lib/" 2>/dev/null || \ - echo "Warning: Could not find libicui18n.so.$ICU_MAJOR_VERSION" - cp "$ICU_LIB_DIR/libicuuc.so.$ICU_MAJOR_VERSION" "$OPTDIR/lib/" 2>/dev/null || \ - echo "Warning: Could not find libicuuc.so.$ICU_MAJOR_VERSION" - - # Create symlinks without version for compatibility - (cd "$OPTDIR/lib" && \ - [ -f "libicudata.so.$ICU_MAJOR_VERSION" ] && ln -sf "libicudata.so.$ICU_MAJOR_VERSION" "libicudata.so" || true && \ - [ -f "libicui18n.so.$ICU_MAJOR_VERSION" ] && ln -sf "libicui18n.so.$ICU_MAJOR_VERSION" "libicui18n.so" || true && \ - [ -f "libicuuc.so.$ICU_MAJOR_VERSION" ] && ln -sf "libicuuc.so.$ICU_MAJOR_VERSION" "libicuuc.so" || true) -else - echo "Warning: ICU libraries not found at $ICU_LIB_DIR" - echo "You may need to build Qt with ICU support first" -fi +# ICU is intentionally absent: Qt is built with -no-feature-icu, so no ICU +# library is linked or bundled. # Fonts mkdir -p "$OPTDIR/share/fonts/truetype/dejavu" @@ -570,15 +542,11 @@ fi # --------------------------------------------------------------------------- echo "Applying embedded system optimisations..." -# Remove unused QML Controls themes (size optimisation) -rm -rf "$OPTDIR/qml/QtQuick/Controls/Universal" -rm -rf "$OPTDIR/qml/QtQuick/Controls/Fusion" -rm -rf "$OPTDIR/qml/QtQuick/Controls/Imagine" -rm -rf "$OPTDIR/qml/QtQuick/Controls/FluentWinUI3" - -# Remove QML debugging tools -rm -rf "$OPTDIR/qml/QtTest"* 2>/dev/null || true -rm -rf "$OPTDIR/plugins/qmltooling" 2>/dev/null || true +# Prune the QML tree, style libraries and tooling to what the UI imports. The +# `cp -r` of QtQuick/QtQml above is wholesale, so unused modules arrive whether +# or not anything imports them. Shared with the AppImage packaging path -- see +# prune_qml_to_imports() in debian/lib.sh for the import list. +prune_qml_to_imports "$OPTDIR/qml" "$OPTDIR/lib" "$OPTDIR/plugins" # Remove Qt translations (not needed on embedded systems) rm -rf "$OPTDIR/translations" 2>/dev/null || true @@ -589,18 +557,6 @@ rm -f "$OPTDIR/plugins/imageformats/libqtiff.so" 2>/dev/null || true rm -f "$OPTDIR/plugins/imageformats/libqwebp.so" 2>/dev/null || true rm -f "$OPTDIR/plugins/imageformats/libqgif.so" 2>/dev/null || true -# Remove unused Qt Quick Controls 2 style libraries -rm -f "$OPTDIR/lib/libQt6QuickControls2Fusion.so"* 2>/dev/null || true -rm -f "$OPTDIR/lib/libQt6QuickControls2Universal.so"* 2>/dev/null || true -rm -f "$OPTDIR/lib/libQt6QuickControls2Imagine.so"* 2>/dev/null || true -rm -f "$OPTDIR/lib/libQt6QuickControls2FluentWinUI3.so"* 2>/dev/null || true -rm -f "$OPTDIR/lib/libQt6QuickControls2FusionStyleImpl.so"* 2>/dev/null || true -rm -f "$OPTDIR/lib/libQt6QuickControls2UniversalStyleImpl.so"* 2>/dev/null || true -rm -f "$OPTDIR/lib/libQt6QuickControls2ImagineStyleImpl.so"* 2>/dev/null || true -rm -f "$OPTDIR/lib/libQt6QuickControls2FluentWinUI3StyleImpl.so"* 2>/dev/null || true -rm -f "$OPTDIR/lib/libQt6QuickControls2WindowsStyleImpl.so"* 2>/dev/null || true -rm -f "$OPTDIR/lib/libQt6Widgets.so"* 2>/dev/null || true - # Remove desktop-specific libraries that may have been included rm -f "$OPTDIR/lib/libwayland"* 2>/dev/null || true rm -f "$OPTDIR/lib/libX11"* 2>/dev/null || true @@ -613,6 +569,19 @@ find "$OPTDIR" -name "*.a" -delete 2>/dev/null || true find "$OPTDIR" -name "*.la" -delete 2>/dev/null || true find "$OPTDIR" -name "*.prl" -delete 2>/dev/null || true +# The explicit cp -d list above is curated to keep this package small (it is +# often fetched over the network). Keep curating it -- this call is additive and +# only fills in libraries the staged tree actually names in DT_NEEDED, chiefly +# the Qt libraries pulled in by the wholesale `cp -r` of the QML tree that the +# list does not mention. Runs after pruning so removed plugins' dependencies +# are not pulled back in. See embedded_deploy_lib_closure() in debian/lib.sh +# for how to revert to a purely curated list if size regresses. +embedded_deploy_lib_closure "$OPTDIR" "$QT_DIR/lib" || exit 1 + +# The cp -d globs above also match unversioned *.so development symlinks, which +# point at absolute host paths and would ship dangling. +prune_dev_symlinks "$OPTDIR/lib" + # Strip binaries to reduce size find "$OPTDIR" -type f -executable -exec strip {} \; 2>/dev/null || true @@ -624,47 +593,45 @@ if [ -n "$SO_FILES" ]; then fi # --------------------------------------------------------------------------- -# Build the .deb +# Assemble the .deb via debhelper, so debian/control is the single source of +# the package metadata (Depends, version, description) and md5sums are +# generated. dh_shlibdeps is deliberately NOT used: this package vendors Qt and +# its support libraries under /opt, and shlibdeps would demand from the target +# the very libraries bundled to avoid that. The external Depends are therefore +# maintained explicitly in debian/control's rpi-imager-embedded stanza. # --------------------------------------------------------------------------- -echo "Creating embedded .deb package..." - -# Calculate installed size in KiB -INSTALLED_SIZE=$(du -sk "$DEBDIR" | cut -f1) - -cat > "$DEBDIR/DEBIAN/control" << CTRL_EOF -Package: rpi-imager-embedded -Version: ${PROJECT_VERSION} -Architecture: ${DEB_ARCH} -Installed-Size: ${INSTALLED_SIZE} -Maintainer: Raspberry Pi Ltd -Depends: dosfstools, fdisk, util-linux (>= 2.37), libdrm2, libinput10, libudev1 -Suggests: rpi-eeprom, firmware-brcm80211 -Conflicts: rpi-imager -Section: admin -Priority: optional -Homepage: https://www.raspberrypi.com/software -Description: Raspberry Pi Imaging utility for embedded systems - Optimised for embedded systems with vendored Qt6 and dependencies. - Uses linuxfb for direct rendering (no desktop environment required). -CTRL_EOF - -# Remove old output -rm -f "$OUTPUT_FILE" -rm -f "$PWD/rpi-imager-embedded.deb" - -dpkg-deb --build --root-owner-group "$DEBDIR" "$OUTPUT_FILE" - -if [ -f "$OUTPUT_FILE" ]; then - echo "" - echo "Embedded .deb created: $OUTPUT_FILE" - - # Create convenience symlink for debian packaging - ln -sf "$(basename "$OUTPUT_FILE")" "$PWD/rpi-imager-embedded.deb" - +echo "Assembling embedded .deb via debhelper..." + +_pkg=rpi-imager-embedded +_stage="$TOP/debian/$_pkg" + +# Populate the debhelper install tree from the vendored staging root. +rm -rf "$_stage" +mkdir -p "$_stage" +cp -a "$DEBDIR/opt" "$_stage/" +cp -a "$DEBDIR/usr" "$_stage/" + +# dh_gencontrol: version/arch/Depends from debian/control + debian/changelog. +# dh_md5sums: DEBIAN/md5sums. dh_builddeb: the .deb into $TOP. +export DEB_BUILD_PROFILES=embedded +( + cd "$TOP" + dh_gencontrol -p"$_pkg" + dh_md5sums -p"$_pkg" + dh_builddeb -p"$_pkg" --destdir="$TOP" +) + +# Remove the debhelper working files so the tree is left clean. +rm -rf "$_stage" "$TOP/debian/$_pkg.substvars" "$TOP/debian/$_pkg.debhelper.log" \ + "$TOP/debian/files" "$TOP/debian/.debhelper" + +_out=$(ls "$TOP"/${_pkg}_*_"${DEB_ARCH}".deb 2>/dev/null | head -1) +if [ -n "$_out" ] && [ -f "$_out" ]; then echo "" + echo "Embedded .deb created: $_out" echo "Embedded .deb build completed successfully for $ARCH." - echo "Install with: sudo dpkg -i $(basename "$OUTPUT_FILE")" + echo "Install with: sudo dpkg -i $(basename "$_out")" else - echo "Error: .deb creation failed." + echo "Error: .deb creation failed." >&2 exit 1 fi diff --git a/debian/build-appimages.sh b/debian/build-appimages.sh new file mode 100755 index 000000000..25edc1b02 --- /dev/null +++ b/debian/build-appimages.sh @@ -0,0 +1,82 @@ +#!/bin/bash +# Build desktop + CLI AppImages for one Debian architecture. +# +# Usage: +# debian/build-appimages.sh +# +# Cross-arch builds run inside the matching rootless mmdebstrap chroot. +set -eu + +TOP=$(cd "$(dirname "$0")/.." && pwd) +cd "$TOP" +. "$TOP/debian/lib.sh" + +ARCH="${1:?usage: build-appimages.sh }" +IMG_ARCH=$(normalize_image_arch "$(deb_to_image_arch "$ARCH")") +CHROOT=$(chroot_name "$ARCH") + +# Every arch, the host included, builds in its chroot: the AppDir is produced +# inside the chroot and packed into an AppImage on the host. +run_build_env() { + # shellcheck disable=SC2086 + export_cmake_parallel + env QT_CACHE="$QT_CACHE" QT_VERSION="$QT_VERSION" APPIMAGE_ROOT="$APPIMAGE_ROOT" $* +} + +run_in_build_context() { + if ! ensure_chroot "$ARCH"; then + exit 1 + fi + + _backend=$(chroot_backend_for "$ARCH") + if [ "$_backend" != none ]; then + if ! chroot_run "$ARCH" test -d "$TOP"; then + echo "build-appimages: $TOP not visible inside $(chroot_name "$ARCH")" >&2 + echo "build-appimages: re-run: debian/mmdebstrap-ensure-chroot.sh $ARCH" >&2 + exit 1 + fi + chroot_run "$ARCH" bash -lc \ + "cd '$TOP' && export CMAKE_BUILD_PARALLEL_LEVEL='$(cmake_build_jobs)' && QT_CACHE='$QT_CACHE' QT_VERSION='$QT_VERSION' APPIMAGE_ROOT='$APPIMAGE_ROOT' $(printf '%q ' "$@")" + return $? + fi + + if appimage_remote_host "$ARCH"; then + echo "build-appimages: using remote builder $APPIMAGE_REMOTE_HOST for $ARCH" + # shellcheck disable=SC2086 + ssh "$APPIMAGE_REMOTE_HOST" "cd '$APPIMAGE_REMOTE_DIR' && QT_CACHE='$QT_CACHE' QT_VERSION='$QT_VERSION' $(printf '%q ' "$@")" + return $? + fi + + echo "build-appimages: no chroot or remote builder for $ARCH (host: $HOST_ARCH)" >&2 + echo "build-appimages: expected: $(chroot_name "$ARCH") under $CHROOT_ROOT" >&2 + exit 1 +} + +ensure_dirs +sh "$TOP/debian/fetch-vendor-deps.sh" +echo "build-appimages: ensuring Qt for $ARCH..." +run_in_build_context "$TOP/debian/ensure-qt.sh" "$ARCH" + +_pack_on_host() { + run_build_env APPIMAGE_TOOL_ARCH="$HOST_ARCH" APPIMAGE_PACKAGING=pack \ + QT_CACHE="$QT_CACHE" QT_VERSION="$QT_VERSION" "$@" +} + +_build_in_context() { + run_in_build_context env APPIMAGE_TOOL_ARCH="$HOST_ARCH" APPIMAGE_PACKAGING=build "$@" +} + +echo "build-appimages: desktop AppImage ($IMG_ARCH)..." +if _build_in_context "$TOP/create-appimage.sh" "--arch=$IMG_ARCH" "--try-build-qt"; then + _pack_on_host "$TOP/create-appimage.sh" "--arch=$IMG_ARCH" --no-clean || exit 1 +elif [ "$ARCH" = armhf ]; then + echo "build-appimages: desktop AppImage failed for armhf (CLI may still succeed)" >&2 +else + exit 1 +fi + +echo "build-appimages: CLI AppImage ($IMG_ARCH)..." +_build_in_context "$TOP/create-appimage-cli.sh" "--arch=$IMG_ARCH" +_pack_on_host "$TOP/create-appimage-cli.sh" "--arch=$IMG_ARCH" --no-clean + +echo "build-appimages: done for $ARCH" diff --git a/debian/build-binary-chroot.sh b/debian/build-binary-chroot.sh new file mode 100755 index 000000000..15e51d709 --- /dev/null +++ b/debian/build-binary-chroot.sh @@ -0,0 +1,49 @@ +#!/bin/sh +# Build binary .debs inside an mmdebstrap rootfs (rootless cross-build). +# +# Usage: +# debian/build-binary-chroot.sh +set -eu + +TOP=$(cd "$(dirname "$0")/.." && pwd) +cd "$TOP" +. "$TOP/debian/lib.sh" + +ARCH="${1:?usage: build-binary-chroot.sh }" + +if [ "$(chroot_backend_for "$ARCH")" != mmdebstrap ]; then + echo "build-binary-chroot: mmdebstrap chroot $(chroot_name "$ARCH") not found" >&2 + echo "build-binary-chroot: run: debian/mmdebstrap-ensure-chroot.sh $ARCH" >&2 + exit 1 +fi + +ensure_dirs +sh "$TOP/debian/fetch-vendor-deps.sh" +APPIMAGE_DIR="$APPIMAGE_ROOT/$ARCH" DEB_BUILD_ARCH="$ARCH" "$TOP/debian/stage-appimages.sh" all + +_profiles=$(printf '%s' "$DEB_BUILD_PROFILES" | tr ' ' ',') +_parent=$(dirname "$TOP") + +chroot_run "$ARCH" bash -lc \ + "cd '$TOP' && . debian/lib.sh && ensure_debian_build_deps && dpkg-buildpackage -b -uc -us -a'$ARCH' -P'$_profiles'" + +# dpkg-buildpackage writes artifacts to $TOP/.. — but only $TOP (not its parent) +# is bind-mounted, so they land inside the chroot rootfs, not on the host parent. +_chroot_root=$(chroot_mmdebstrap_root "$ARCH") +_deb_src="${_chroot_root}${_parent}" + +for _deb in "$_deb_src"/*_"$ARCH".deb; do + [ -f "$_deb" ] || continue + mv "$_deb" "$OUTPUT_DIR/" +done +for _meta in "$_deb_src"/*_"$ARCH".buildinfo "$_deb_src"/*_"$ARCH".changes; do + [ -f "$_meta" ] || continue + mv "$_meta" "$OUTPUT_DIR/" 2>/dev/null || true +done + +if ! ls "$OUTPUT_DIR"/*_"$ARCH".deb >/dev/null 2>&1; then + echo "build-binary-chroot: no $ARCH .debs collected from $_deb_src" >&2 + exit 1 +fi + +echo "build-binary-chroot: wrote $ARCH packages to $OUTPUT_DIR" diff --git a/debian/build-embedded.sh b/debian/build-embedded.sh new file mode 100755 index 000000000..d43edadd3 --- /dev/null +++ b/debian/build-embedded.sh @@ -0,0 +1,82 @@ +#!/bin/sh +# Build the embedded (linuxfb) rpi-imager .deb for one architecture. +# +# The embedded package vendors Qt + dependencies under /opt and renders with +# linuxfb. It uses a DEDICATED Qt built with -no-opengl -qpa linuxfb (distinct +# from the desktop/cli release Qt): the target image (pi-gen-micro) carries no +# Mesa/GL or X11 -- those are far too large for a network-loaded image -- so the +# embedded Qt must not link libEGL/libGL/libX11 at all. That build is a separate +# cache variant (gcc_arm64_embedded) produced by qt/build-qt-embedded.sh. +# +# arm64 only: it is the only platform the embedded (netboot) installer targets. +# +# Usage: +# debian/build-embedded.sh arm64 +set -eu + +TOP=$(cd "$(dirname "$0")/.." && pwd) +cd "$TOP" +. "$TOP/debian/lib.sh" + +ARCH="${1:?usage: build-embedded.sh arm64}" + +if [ "$ARCH" != arm64 ]; then + echo "build-embedded: embedded is arm64 only (got '$ARCH')" >&2 + exit 1 +fi + +IMG_ARCH=$(normalize_image_arch "$ARCH") +QT_DIR=$(qt_embedded_path "$ARCH") || { + echo "build-embedded: unknown arch: $ARCH" >&2 + exit 1 +} + +ensure_dirs +sh "$TOP/debian/fetch-vendor-deps.sh" + +# Build inside the arch's chroot so the vendored tree links against bookworm's +# libraries rather than whatever the builder happens to run. +_backend=$(chroot_backend_for "$ARCH") +if [ "$_backend" = none ]; then + echo "build-embedded: no chroot for $ARCH (need $(chroot_name "$ARCH"))" >&2 + echo "build-embedded: run: debian/mmdebstrap-ensure-chroot.sh $ARCH" >&2 + exit 1 +fi + +# Build the dedicated -no-opengl embedded Qt on cache miss. Check by file +# presence rather than qt_embedded_ok(): that runs `qmake -query`, but the +# cached qmake is the target arch (arm64) and this orchestrator runs on the +# host, so executing it would always fail and force a needless full rebuild. +if [ ! -x "$QT_DIR/bin/qmake" ] || [ ! -f "$QT_DIR/plugins/platforms/libqlinuxfb.so" ]; then + echo "build-embedded: building -no-opengl embedded Qt $QT_VERSION for $ARCH inside $_backend chroot..." + export_cmake_parallel + chroot_run "$ARCH" bash -lc \ + "cd '$TOP' && export CMAKE_BUILD_PARALLEL_LEVEL='$(cmake_build_jobs)' && sh '$TOP/qt/build-qt-embedded.sh' --version='$QT_VERSION' --prefix='$(qt_version_tree "$ARCH")' --skip-dependencies --unprivileged" +fi + +if [ ! -f "$QT_DIR/plugins/platforms/libqlinuxfb.so" ]; then + echo "build-embedded: $QT_DIR has no linuxfb platform plugin after build" >&2 + exit 1 +fi + +echo "build-embedded: building $ARCH package inside $_backend chroot" +chroot_run "$ARCH" bash -lc \ + "cd '$TOP' && sh '$TOP/create-embedded.sh' --arch='$IMG_ARCH' --qt-root='$QT_DIR'" + +# create-embedded.sh writes the .deb into $TOP (bind-mounted in the chroot, so +# it is visible on the host tree either way). Collect it into OUTPUT_DIR. +_found=0 +for _deb in "$TOP"/rpi-imager-embedded_*_"$ARCH".deb; do + [ -f "$_deb" ] || continue + mv "$_deb" "$OUTPUT_DIR/" + _found=1 +done +# Drop the convenience symlink create-embedded.sh leaves in the tree. +rm -f "$TOP/rpi-imager-embedded.deb" + +if [ "$_found" -ne 1 ]; then + echo "build-embedded: no $ARCH embedded .deb produced" >&2 + exit 1 +fi + +echo "build-embedded: wrote $ARCH embedded package to $OUTPUT_DIR" diff --git a/debian/build-source.sh b/debian/build-source.sh new file mode 100755 index 000000000..cceced285 --- /dev/null +++ b/debian/build-source.sh @@ -0,0 +1,49 @@ +#!/bin/sh +# Build quilt source package (.orig.tar.xz, .debian.tar.xz, .dsc). +# +# Usage: +# debian/build-source.sh [git-ref] +set -eu + +TOP=$(cd "$(dirname "$0")/.." && pwd) +cd "$TOP" +. "$TOP/debian/lib.sh" + +REF="${1:-HEAD}" +ensure_dirs + +"$TOP/debian/mk-orig-tarball.sh" "$REF" + +# Build source package in a temp area, then collect artifacts. +_builddir=$(mktemp -d) +trap 'rm -rf "$_builddir"' EXIT INT HUP TERM + +_orig="$OUTPUT_DIR/${PACKAGE}_${UPSTREAM}.orig.tar.xz" +if [ ! -f "$_orig" ]; then + echo "build-source: missing $_orig" >&2 + exit 1 +fi + +cp "$_orig" "$_builddir/" +tar -C "$_builddir" -xf "$_orig" +_src="${PACKAGE}-${UPSTREAM}" +cp -a "$TOP/debian" "$_builddir/$_src/" + +( + cd "$_builddir/$_src" + dpkg-buildpackage -S -uc -us -d +) + +for _f in \ + "${PACKAGE}_${VERSION}.debian.tar.xz" \ + "${PACKAGE}_${VERSION}.dsc" \ + "${PACKAGE}_${VERSION}_source.buildinfo" \ + "${PACKAGE}_${VERSION}_source.changes" +do + if [ -f "$_builddir/$_f" ]; then + mv "$_builddir/$_f" "$OUTPUT_DIR/" + echo "build-source: wrote $OUTPUT_DIR/$_f" + fi +done + +echo "build-source: complete" diff --git a/debian/changelog b/debian/changelog index 422189772..e3631d4b7 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,102 @@ +rpi-imager (2.0.11.1-1) unstable; urgency=medium + + * Pi Connect: + - Fix the Next button never enabling on the Raspberry Pi Connect + customisation step once the browser sign-in fills in the token. The + sanitised `value` an ImTextField exposes was a binding on `text`, and + QML re-evaluates a binding only after every handler for the same change + signal has run, so the step's own onTextChanged read the previous + (empty) contents and cleared the token it had just been handed. + * Build / packaging: + - Carry an optional fourth version component through the version parser to + the Windows templates, so a hotfix reports its own FILEVERSION, + PRODUCTVERSION and assembly manifest version rather than those of the + release it fixes. Ordinary three-part tags are unaffected. + - Keep the fourth component in the version reported alongside download + statistics, so uptake of a hotfix can be told apart from the release it + fixes. + * Testing: + - Update nine cloud-init generator tests that still asserted the + unconditional baseline payload (manage_resolv_conf, eth0 DHCP) dropped + in 2.0.7; an empty payload is what tells the fastboot and download paths + to skip writing the file at all. + - Update two FileServer tests to the control-transfer message protocol and + the fatal-disconnect handling that came in with CM5 re-provisioning. + - Report Catch2 skips to CTest as skipped rather than as failures, and + keep the FAT partition test's pre-main diagnostics off stdout, where + test discovery had been registering one as a case that could never pass. + + -- Tom Dewey Mon, 17 Aug 2026 11:11:47 +0100 + +rpi-imager (2.0.11-1) unstable; urgency=medium + + * OS Customisation: + - Add the rpi-preseed first-boot customisation format, and list it as a + valid init_format in the schema notes. + - rpi-preseed: always add the account to the 'sudo' group. + - cloud-init: write `sudo: null` when the user declines passwordless sudo. + A singular `user:` block is merged over the distro default_user, which + raspberry-pi-os populates with passwordless sudo, so omitting the key + granted it regardless of the user's choice; `null` suppresses the rules + while keeping 'sudo' group membership, so the account is prompted for one. + - Centralise credential derivation in CustomisationGenerator and hash + credentials in C++ before persisting them. + - Verify the device kept the customisation files after writing. + - UI: keep customisation steps centre-middle; drop the unused ImageOptions + enum; bind SSH key row data; allow query parameters on custom manifest + URLs. + * Fastboot (Compute Module): + - Add an Erase command for 'Format'. + - Verify device identity before treating a target as a Pi. + - Align stage() tests and docs with the download verb. + * Write reliability: + - Support multi-frame .img.zst images (use ZSTD_findDecompressedSize). + - Linux/Windows: take an exclusive lock on drive open. On Windows, hold + volume locks rather than deleting drive letters, only remove removable + mountpoints, retry the exclusive open before falling back to shared, and + flush the filesystem to media before writing the partition table. + - macOS: run DiskArbitration on the main run loop with blocking calls, and + move the nested run loop onto worker threads. + * Embedded (Raspberry Pi network installer): + - Build a dedicated Qt configured -no-opengl -qpa linuxfb -no-dbus + -no-feature-icu. The pi-gen-micro netboot image carries no Mesa or X11 + (far too large for a network-loaded image), no session bus, and none of + the languages that need ICU. The package had drifted onto the desktop Qt + and so linked libEGL/libGL/libX11 and libQt6DBus -- absent from the image + -- and could not load on the target. + - Build the application without QtDBus for embedded; its DBus users (portal + file dialog, NetworkManager WiFi backend, Pi Connect URI handler) are + unusable on a busless image and are compiled out via QT_DBUS_LIB guards. + - Assemble the .deb with debhelper so debian/control is the single source of + dependencies and metadata and md5sums are generated; trim Depends to the + minimal host-provided set (no libegl1/libgl1/libx11-6/libsystemd0, which + apt would otherwise pull -- Mesa and X11 included -- into the image). + - Bake an $ORIGIN-relative rpath at build time (was a leaked build-host + path), dereference the bundled fontconfig config, and drop the stale + vendored ICU handling. + * Build / packaging: + - Rootless multi-arch (amd64/arm64/armhf) release pipeline: every arch, + the host included, builds inside its bookworm mmdebstrap chroot; there is + no host-native build path (a package built against the host's libraries + is not the package it claims to be). + - Desktop and CLI AppImages are now self-contained: bundle the transitive + library closure of the staged AppDir (via readelf, so it also works for + foreign-architecture AppDirs), which was previously taken from the host + and left arm64/armhf incompletely bundled. Declare the genuinely + host-coupled libraries in debian/control. + - Build Qt with -no-feature-icu across desktop/CLI/embedded; drop the + JPEG 2000 image plugin; collect the source .buildinfo. + - liburing: probe for the 64-bit user_data API (added in 2.2) before + enabling io_uring, so an older build environment falls back to + synchronous writes instead of failing to link. + - Vendor curl/libarchive/libusb/nghttp2/xz as git submodules. + - Reproducibility and hygiene: normalise mtimes; generate version files in + an explicit build override; prune qmlls and performance profiles from the + packaged tree; stop deleting the screenshot; ship the fastboot udev rule + inside the .debs; assorted amd64/arm64 packaging fixes. + + -- Tom Dewey Thu, 06 Aug 2026 11:46:27 +0100 + rpi-imager (2.0.10) unstable; urgency=medium * Pi Connect: diff --git a/debian/chroot-apt-install.sh b/debian/chroot-apt-install.sh new file mode 100755 index 000000000..f072ff237 --- /dev/null +++ b/debian/chroot-apt-install.sh @@ -0,0 +1,65 @@ +#!/bin/sh +# apt-get install tuned for rootless mmdebstrap chroots. +# +# Usage (inside chroot): +# debian/chroot-apt-install.sh pkg [pkg...] +set -eu + +export DEBIAN_FRONTEND=noninteractive + +if [ -x /usr/sbin/debconf-set-selections ]; then + echo 'man-db man-db/auto-update boolean false' | debconf-set-selections 2>/dev/null || true +fi + +mkdir -p /var/log/apt + +_APT_COMMON=" +-o APT::Sandbox::User=root +-o APT::Log::TermlogEnable=false +-o Dpkg::Options::=--force-confdef +-o Dpkg::Options::=--force-confold +" + +# shellcheck disable=SC2086 +apt-get $_APT_COMMON update + +# man-db postinst often fails in user-namespace chroots; verify requested packages only. +set +e +# shellcheck disable=SC2086 +apt-get $_APT_COMMON install -y --allow-downgrades --no-install-recommends "$@" +_install_status=$? +set -e + +dpkg --configure -a 2>/dev/null || true +# debhelper Requires man-db; configure in dependency order (rootless chroots). +for _pkg in man-db debhelper dh-exec fakeroot; do + case " $* " in + *" $_pkg "*) ;; + *) continue ;; + esac + dpkg --configure "$_pkg" 2>/dev/null \ + || dpkg --configure --force-depends "$_pkg" 2>/dev/null \ + || true +done + +pkg_is_usable() { + _pkg=$1 + dpkg-query -W -f='${Status}' "$_pkg" 2>/dev/null \ + | grep -qE '^(install ok installed|install ok unpacked|install ok half-configured)$' +} + +_failed= +for _pkg in "$@"; do + if ! pkg_is_usable "$_pkg"; then + _failed="$_failed $_pkg" + fi +done + +if [ -n "$_failed" ]; then + echo "chroot-apt-install: required package(s) not installed:$_failed" >&2 + exit 1 +fi + +if [ "$_install_status" -ne 0 ]; then + echo "chroot-apt-install: apt reported errors (ignored; required packages are installed)" >&2 +fi diff --git a/debian/chroot-apt/debian-bookworm.sources b/debian/chroot-apt/debian-bookworm.sources new file mode 100644 index 000000000..3747b68a6 --- /dev/null +++ b/debian/chroot-apt/debian-bookworm.sources @@ -0,0 +1,12 @@ +# Debian Bookworm (lowest priority in Pi OS-aligned chroots). +Types: deb +URIs: http://deb.debian.org/debian +Suites: bookworm bookworm-updates +Components: main contrib non-free non-free-firmware +Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg + +Types: deb +URIs: http://deb.debian.org/debian-security +Suites: bookworm-security +Components: main contrib non-free non-free-firmware +Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg diff --git a/debian/chroot-apt/debian-trixie.sources b/debian/chroot-apt/debian-trixie.sources new file mode 100644 index 000000000..f6e6cb885 --- /dev/null +++ b/debian/chroot-apt/debian-trixie.sources @@ -0,0 +1,12 @@ +# Debian Trixie (lowest priority in Pi OS-aligned chroots). +Types: deb +URIs: http://deb.debian.org/debian +Suites: trixie trixie-updates +Components: main contrib non-free non-free-firmware +Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg + +Types: deb +URIs: http://deb.debian.org/debian-security +Suites: trixie-security +Components: main contrib non-free non-free-firmware +Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg diff --git a/debian/chroot-apt/preferences-arm64.pref b/debian/chroot-apt/preferences-arm64.pref new file mode 100644 index 000000000..e54a461f0 --- /dev/null +++ b/debian/chroot-apt/preferences-arm64.pref @@ -0,0 +1,8 @@ +# Repository cascade for arm64 chroots: rpi > debian +Package: * +Pin: origin archive.raspberrypi.com +Pin-Priority: 600 + +Package: * +Pin: release o=Debian +Pin-Priority: 500 diff --git a/debian/chroot-apt/preferences-armhf.pref b/debian/chroot-apt/preferences-armhf.pref new file mode 100644 index 000000000..423deb729 --- /dev/null +++ b/debian/chroot-apt/preferences-armhf.pref @@ -0,0 +1,12 @@ +# Repository cascade for armhf chroots: raspbian > rpi > debian +Package: * +Pin: origin raspbian.raspberrypi.com +Pin-Priority: 700 + +Package: * +Pin: origin archive.raspberrypi.com +Pin-Priority: 600 + +Package: * +Pin: release o=Debian +Pin-Priority: 500 diff --git a/debian/chroot-apt/raspbian-bookworm.sources b/debian/chroot-apt/raspbian-bookworm.sources new file mode 100644 index 000000000..c9303e110 --- /dev/null +++ b/debian/chroot-apt/raspbian-bookworm.sources @@ -0,0 +1,7 @@ +# Raspbian archive (raspbian.raspberrypi.com) for 32-bit Raspberry Pi OS armhf. +Types: deb +URIs: http://raspbian.raspberrypi.com/raspbian/ +Suites: bookworm +Components: main contrib non-free rpi +Architectures: armhf +Signed-By: /usr/share/keyrings/raspbian-archive-keyring.gpg diff --git a/debian/chroot-apt/raspbian-trixie.sources b/debian/chroot-apt/raspbian-trixie.sources new file mode 100644 index 000000000..6f9e27544 --- /dev/null +++ b/debian/chroot-apt/raspbian-trixie.sources @@ -0,0 +1,7 @@ +# Raspbian archive (raspbian.raspberrypi.com) for 32-bit Raspberry Pi OS armhf. +Types: deb +URIs: http://raspbian.raspberrypi.com/raspbian/ +Suites: trixie +Components: main contrib non-free rpi +Architectures: armhf +Signed-By: /usr/share/keyrings/raspbian-archive-keyring.gpg diff --git a/debian/chroot-apt/rpi-bookworm.sources b/debian/chroot-apt/rpi-bookworm.sources new file mode 100644 index 000000000..eb5be187b --- /dev/null +++ b/debian/chroot-apt/rpi-bookworm.sources @@ -0,0 +1,6 @@ +# Raspberry Pi archive (archive.raspberrypi.com) — arm64 and armhf. +Types: deb +URIs: http://archive.raspberrypi.com/debian +Suites: bookworm +Components: main +Signed-By: /usr/share/keyrings/raspberrypi-archive-keyring.gpg diff --git a/debian/chroot-apt/rpi-trixie.sources b/debian/chroot-apt/rpi-trixie.sources new file mode 100644 index 000000000..7f5edb097 --- /dev/null +++ b/debian/chroot-apt/rpi-trixie.sources @@ -0,0 +1,6 @@ +# Raspberry Pi archive (archive.raspberrypi.com) — arm64 and armhf. +Types: deb +URIs: http://archive.raspberrypi.com/debian +Suites: trixie +Components: main +Signed-By: /usr/share/keyrings/raspberrypi-archive-keyring.gpg diff --git a/debian/chroot-exec.sh b/debian/chroot-exec.sh new file mode 100755 index 000000000..f5d90cd0d --- /dev/null +++ b/debian/chroot-exec.sh @@ -0,0 +1,113 @@ +#!/bin/bash +# Run a command inside an mmdebstrap rootfs (rootless via user namespaces). +# +# Usage: +# debian/chroot-exec.sh [args...] +set -euo pipefail + +TOP=$(cd "$(dirname "$0")/.." && pwd) +cd "$TOP" +# shellcheck disable=SC1091 +. "$TOP/debian/lib.sh" + +ARCH="${1:?usage: chroot-exec.sh [args...]}" +shift + +if [ "$(chroot_backend_for "$ARCH")" != mmdebstrap ]; then + echo "chroot-exec: mmdebstrap root missing for $ARCH" >&2 + exit 1 +fi + +ROOT=$(chroot_mmdebstrap_root "$ARCH") +NAME=$(chroot_name "$ARCH") +BUILD_JOBS=$(nproc 2>/dev/null || getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4) + +_inner=$(mktemp "${TMPDIR:-/tmp}/rpi-imager-chroot-exec.XXXXXX") +trap 'rm -f "$_inner"' EXIT INT HUP TERM + +{ + printf '%s\n' '#!/bin/bash' + printf '%s\n' 'set -euo pipefail' + printf 'root=%q\n' "$ROOT" + printf 'TOP=%q\n' "$TOP" + printf 'QT_CACHE=%q\n' "$QT_CACHE" + printf 'APPIMAGE_ROOT=%q\n' "$APPIMAGE_ROOT" + printf 'QT_VERSION=%q\n' "$QT_VERSION" + printf 'NAME=%q\n' "$NAME" + printf 'CMAKE_BUILD_PARALLEL_LEVEL=%q\n' "$BUILD_JOBS" + cat <<'EOF' +chroot_mount_pseudo() { + local _root=$1 + mkdir -p "$_root/dev" "$_root/proc" "$_root/sys" "$_root/run" + if ! mountpoint -q "$_root/dev" 2>/dev/null; then + mount --rbind /dev "$_root/dev" + mount --make-rslave "$_root/dev" + fi + if ! mountpoint -q "$_root/proc" 2>/dev/null; then + mount --rbind /proc "$_root/proc" + mount --make-rslave "$_root/proc" + fi + if ! mountpoint -q "$_root/sys" 2>/dev/null; then + mount --rbind /sys "$_root/sys" + mount --make-rslave "$_root/sys" + fi +} + +chroot_umount_all() { + local _root=$1 + for _bind in "$TOP" "$QT_CACHE" "$APPIMAGE_ROOT"; do + [ -d "$_bind" ] || continue + if mountpoint -q "$_root$_bind" 2>/dev/null; then + umount "$_root$_bind" || true + fi + done + for _mp in "$_root/run" "$_root/sys" "$_root/proc" "$_root/dev"; do + if mountpoint -q "$_mp" 2>/dev/null; then + umount -l "$_mp" 2>/dev/null || umount -R "$_mp" 2>/dev/null || true + fi + done +} + +chroot_mount_pseudo "$root" +trap 'chroot_umount_all "$root"' EXIT INT HUP TERM + +for _bind in "$TOP" "$QT_CACHE" "$APPIMAGE_ROOT"; do + [ -d "$_bind" ] || continue + mkdir -p "$root$_bind" + if ! mountpoint -q "$root$_bind" 2>/dev/null; then + mount --bind "$_bind" "$root$_bind" + fi +done +chroot "$root" env \ + RPI_IMAGER_CHROOT=1 \ + QT_CACHE="$QT_CACHE" \ + QT_VERSION="$QT_VERSION" \ + APPIMAGE_ROOT="$APPIMAGE_ROOT" \ + TOP="$TOP" \ + CMAKE_BUILD_PARALLEL_LEVEL="$CMAKE_BUILD_PARALLEL_LEVEL" \ + "$@" +chown -R "$(id -u):$(id -g)" "$root" 2>/dev/null || true +EOF +} >"$_inner" +chmod 0755 "$_inner" + +_run() { + "$_inner" "$@" +} + +case "$(mmdebstrap_run_mode)" in + unshare) + unshare --user --map-root-user --mount --fork --kill-child -- "$_inner" "$@" + ;; + sudo|auto) + if [ "$(id -u)" -eq 0 ]; then + _run "$@" + else + sudo "$_inner" "$@" + fi + ;; + *) + echo "chroot-exec: unsupported mmdebstrap mode: $(mmdebstrap_run_mode)" >&2 + exit 1 + ;; +esac diff --git a/debian/chroot-lib.sh b/debian/chroot-lib.sh new file mode 100644 index 000000000..90ea87976 --- /dev/null +++ b/debian/chroot-lib.sh @@ -0,0 +1,136 @@ +#!/bin/sh +# Rootless chroot helpers: mmdebstrap chroots under CHROOT_ROOT (no sudo). +# +# Source after debian/lib.sh has set TOP and paths. + +if [ -n "${RPI_IMAGER_CHROOT_LIB_LOADED:-}" ]; then + return 0 2>/dev/null || exit 0 +fi +RPI_IMAGER_CHROOT_LIB_LOADED=1 + +CHROOT_ROOT=${CHROOT_ROOT:-.debian/chroots} +CHROOT_ROOT=$(resolve_repo_path "$CHROOT_ROOT") +MMDEBSTRAP_MODE=${MMDEBSTRAP_MODE:-auto} + +chroot_mmdebstrap_root() { + _arch=$1 + printf '%s/%s\n' "$CHROOT_ROOT" "$(chroot_name "$_arch")" +} + +chroot_mmdebstrap_ok() { + _arch=$1 + _root=$(chroot_mmdebstrap_root "$_arch") + [ -f "$_root/.rpi-imager-chroot-ok" ] && [ -x "$_root/usr/bin/dpkg" ] +} + +chroot_rm_mmdebstrap() { + _arch=$1 + _root=$(chroot_mmdebstrap_root "$_arch") + sh "$TOP/debian/chroot-rm.sh" --path "$_root" + rm -f "${_root}.bootstrap.tar" +} + +have_chroot() { + chroot_mmdebstrap_ok "$1" +} + +chroot_backend_for() { + _arch=$1 + if chroot_mmdebstrap_ok "$_arch"; then + printf '%s\n' mmdebstrap + else + printf '%s\n' none + fi +} + +mmdebstrap_run_mode() { + case "$MMDEBSTRAP_MODE" in + auto) + if [ "$(id -u)" -eq 0 ]; then + printf '%s\n' auto + else + printf '%s\n' unshare + fi + ;; + *) + printf '%s\n' "$MMDEBSTRAP_MODE" + ;; + esac +} + +ensure_chroot_dirs() { + install -d -m 0755 "$CHROOT_ROOT" \ + "$OUTPUT_DIR" \ + "$APPIMAGE_ROOT/arm64" "$APPIMAGE_ROOT/amd64" "$APPIMAGE_ROOT/armhf" \ + "$QT_CACHE/arm64" "$QT_CACHE/amd64" "$QT_CACHE/armhf" +} + +run_mmdebstrap_ensure_chroot() { + _arch=$1 + if ! command -v mmdebstrap >/dev/null 2>&1; then + echo "release: mmdebstrap not installed (apt install mmdebstrap)" >&2 + return 1 + fi + sh "$TOP/debian/mmdebstrap-ensure-chroot.sh" "$_arch" +} + +ensure_chroot() { + _arch=$1 + + if have_chroot "$_arch"; then + return 0 + fi + if appimage_remote_host "$_arch" 2>/dev/null; then + return 0 + fi + + case "${CHROOT_AUTO_CREATE:-auto}" in + 0|no|never|false|disabled) + echo "release: missing chroot $(chroot_name "$_arch") (CHROOT_AUTO_CREATE disabled)" >&2 + return 1 + ;; + esac + + ensure_chroot_dirs + run_mmdebstrap_ensure_chroot "$_arch" || return 1 + + if ! have_chroot "$_arch"; then + echo "release: failed to create chroot $(chroot_name "$_arch")" >&2 + return 1 + fi +} + +ensure_release_chroots() { + if [ "$#" -eq 0 ]; then + return 0 + fi + + case "${CHROOT_AUTO_CREATE:-auto}" in + 0|no|never|false|disabled) + echo "release: cross-arch builds require a chroot or APPIMAGE_REMOTE for:$*" >&2 + for _arch in "$@"; do + echo "release: missing: $(chroot_name "$_arch") (or APPIMAGE_REMOTE_${_arch})" >&2 + done + echo "release: run: debian/mmdebstrap-ensure-chroot.sh or set CHROOT_AUTO_CREATE=auto" >&2 + return 1 + ;; + esac + + ensure_chroot_dirs + + for _arch in "$@"; do + ensure_chroot "$_arch" || return 1 + done +} + +chroot_run() { + _arch=$1 + shift + + if chroot_mmdebstrap_ok "$_arch"; then + bash "$TOP/debian/chroot-exec.sh" "$_arch" "$@" + else + echo "chroot-run: no chroot for $_arch" >&2 + return 1 + fi +} diff --git a/debian/chroot-packages b/debian/chroot-packages new file mode 100644 index 000000000..d45d4b5da --- /dev/null +++ b/debian/chroot-packages @@ -0,0 +1,79 @@ +build-essential +perl +python3 +git +cmake +ninja-build +pkg-config +debhelper +dh-exec +fakeroot +bison +flex +gperf +curl +ca-certificates +libgnutls28-dev +libx11-dev +libx11-xcb-dev +libxext-dev +libxfixes-dev +libxi-dev +libxrender-dev +libxcomposite-dev +libxcursor-dev +libxdamage-dev +libxrandr-dev +libxtst-dev +libxcb1-dev +libxcb-cursor-dev +libxcb-glx0-dev +libxcb-icccm4-dev +libxcb-image0-dev +libxcb-keysyms1-dev +libxcb-randr0-dev +libxcb-render-util0-dev +libxcb-shape0-dev +libxcb-shm0-dev +libxcb-sync-dev +libxcb-util-dev +libxcb-xfixes0-dev +libxcb-xinerama0-dev +libxcb-xkb-dev +libinput-dev +libxkbcommon-dev +libxkbcommon-x11-dev +libfontconfig1-dev +libfreetype6-dev +libicu-dev +libdrm-dev +libegl1-mesa-dev +libgbm-dev +libgles2-mesa-dev +libvulkan-dev +libjpeg-dev +libpng-dev +zlib1g-dev +libasound2-dev +libpulse-dev +libnss3-dev +libssl-dev +libdbus-1-dev +libglib2.0-dev +libsqlite3-dev +libdouble-conversion-dev +libpcre2-dev +libatk1.0-dev +libatk-bridge2.0-dev +libcups2-dev +libassimp-dev +libwayland-dev +wayland-protocols +libwayland-cursor0 +libwayland-egl1 +libwayland-server0 +libwayland-client0 +libwayland-bin +fuse +libfuse2 +liburing-dev diff --git a/debian/chroot-rm.sh b/debian/chroot-rm.sh new file mode 100755 index 000000000..78cd59594 --- /dev/null +++ b/debian/chroot-rm.sh @@ -0,0 +1,156 @@ +#!/bin/sh +# Remove mmdebstrap chroot trees (handles subuid/root-owned files from unshare mode). +# +# Usage: +# debian/chroot-rm.sh +# debian/chroot-rm.sh --path /path/to/chroot +# debian/chroot-rm.sh --all +set -eu + +TOP=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) + +if [ -f "$TOP/debian/release.conf" ]; then + # shellcheck disable=SC1091 + . "$TOP/debian/release.conf" +fi + +. "$TOP/debian/lib.sh" + +chroot_rm_mmdebstrap_teardown() { + _path=$1 + _owner=$(id -u):$(id -g) + + command -v mmdebstrap >/dev/null 2>&1 || return 1 + + for _mode in unshare "$(mmdebstrap_run_mode)"; do + [ -n "$_mode" ] || continue + echo "chroot-rm: mmdebstrap teardown (mode=$_mode) for $_path" >&2 + if mmdebstrap --mode="$_mode" --variant=custom \ + --skip=check/empty,setup,update,download,extract,essential,configure,cleanup \ + --customize-hook="chown -R ${_owner} \"\$1\" 2>/dev/null || true" \ + --customize-hook='chmod -R u+rwX "$1" 2>/dev/null || true' \ + --customize-hook='rm -rf "$1"' \ + "$CHROOT_DIST" "$_path" "$DEBIAN_MIRROR"; then + [ -e "$_path" ] || return 0 + fi + done + return 1 +} + +chroot_rm_with_uids() { + _path=$1 + + command -v setpriv >/dev/null 2>&1 || return 1 + + for _uid in $(find "$_path" -mindepth 1 -printf '%u\n' 2>/dev/null | sort -nu); do + _gid=$_uid + echo "chroot-rm: trying setpriv --reuid=$_uid for $_path" >&2 + if setpriv --reuid="$_uid" --regid="$_gid" --clear-groups -- \ + rm -rf "$_path" 2>/dev/null; then + return 0 + fi + done + return 1 +} + +chroot_rm_clear_immutable() { + _path=$1 + command -v lsattr >/dev/null 2>&1 && command -v chattr >/dev/null 2>&1 || return 1 + if find "$_path" -exec lsattr -d {} + 2>/dev/null | grep -q '[i]'; then + echo "chroot-rm: clearing immutable flag on $_path" >&2 + chattr -R -i "$_path" 2>/dev/null || true + fi +} + +chroot_rm_path() { + _path=$1 + + [ -e "$_path" ] || return 0 + + chroot_rm_clear_immutable "$_path" + + if rm -rf "$_path" 2>/dev/null; then + return 0 + fi + + chroot_rm_mmdebstrap_teardown "$_path" || true + [ -e "$_path" ] || return 0 + + chroot_rm_clear_immutable "$_path" + if rm -rf "$_path" 2>/dev/null; then + return 0 + fi + + chroot_rm_with_uids "$_path" || true + [ -e "$_path" ] || return 0 + + if command -v unshare >/dev/null 2>&1; then + echo "chroot-rm: trying unshare --map-root-user for $_path" >&2 + if unshare --user --map-root-user rm -rf "$_path" 2>/dev/null; then + return 0 + fi + fi + + if command -v fakeroot >/dev/null 2>&1; then + echo "chroot-rm: trying fakeroot rm for $_path" >&2 + if fakeroot rm -rf "$_path" 2>/dev/null; then + return 0 + fi + fi + + if [ -e "$_path" ]; then + echo "chroot-rm: could not remove $_path" >&2 + echo "chroot-rm: file owners: $(find "$_path" -mindepth 1 -printf '%u ' 2>/dev/null | tr ' ' '\n' | sort -nu | tr '\n' ' ')" >&2 + echo "chroot-rm: ask an admin to run: sudo rm -rf '$_path'" >&2 + return 1 + fi +} + +chroot_rm_tree() { + _path=$1 + + if [ ! -d "$_path" ]; then + chroot_rm_path "$_path" + return $? + fi + + _failed=0 + for _child in "$_path"/*; do + [ -e "$_child" ] || continue + chroot_rm_path "$_child" || _failed=1 + done + + if [ "$_failed" -eq 0 ]; then + rmdir "$_path" 2>/dev/null || chroot_rm_path "$_path" || _failed=1 + fi + return "$_failed" +} + +case "${1:-}" in + --all) + chroot_rm_tree "$CHROOT_ROOT" + _cache=${KEYRING_CACHE:-.debian/archive-keyrings} + case "$_cache" in + /*) ;; + *) _cache="$TOP/$_cache" ;; + esac + chroot_rm_tree "$_cache" + ;; + --path) + chroot_rm_path "${2:?usage: chroot-rm.sh --path }" + ;; + arm64|amd64|armhf) + chroot_rm_path "$(chroot_mmdebstrap_root "$1")" + ;; + -h|--help|help|"") + cat < + +Removes mmdebstrap chroots, including subuid-owned trees from unshare mode. +EOF + ;; + *) + echo "chroot-rm: unknown argument: $1" >&2 + exit 1 + ;; +esac diff --git a/debian/control b/debian/control index cec1f2b86..1cb9ac0aa 100644 --- a/debian/control +++ b/debian/control @@ -2,16 +2,26 @@ Source: rpi-imager Section: admin Priority: optional Maintainer: Tom Dewey -Build-Depends: debhelper-compat (= 13), cmake, libgnutls28-dev, dh-exec +Build-Depends: debhelper-compat (= 13), cmake, libgnutls28-dev, liburing-dev, dh-exec Standards-Version: 4.1.2 Homepage: https://www.raspberrypi.com/software Package: rpi-imager Build-Profiles: -Architecture: amd64 arm64 +Architecture: amd64 arm64 armhf Depends: ${shlibs:Depends}, ${misc:Depends}, dosfstools, fdisk, fuse, libfuse2, util-linux (>= 2.37), pkexec, - libfontconfig1, libx11-6, libegl1, libgl1 + libstdc++6, libatomic1, + libfontconfig1, libfreetype6, + libx11-6, libx11-xcb1, libxcb1, libxcb-cursor0, libxcb-glx0, + libxcb-icccm4, libxcb-image0, libxcb-keysyms1, libxcb-randr0, + libxcb-render0, libxcb-render-util0, libxcb-shape0, libxcb-shm0, + libxcb-sync1, libxcb-xfixes0, libxcb-xkb1, + libxkbcommon0, libxkbcommon-x11-0, + libwayland-client0, libwayland-cursor0, + libegl1, libgl1, libopengl0, + libdbus-1-3, + libdrm2, libgbm1, libinput10, libudev1 Recommends: udisks2 Conflicts: rpi-imager-embedded Description: Raspberry Pi Imaging utility @@ -20,9 +30,18 @@ Description: Raspberry Pi Imaging utility Package: rpi-imager-embedded Build-Profiles: Architecture: arm64 -Depends: ${shlibs:Depends}, ${misc:Depends}, - dosfstools, fdisk, util-linux (>= 2.37), libdrm2, - libinput10, libudev1 +# The package vendors Qt and its support libraries under /opt for the +# pi-gen-micro netboot image, which carries almost nothing. create-embedded.sh +# assembles the .deb with dh_gencontrol/dh_md5sums/dh_builddeb and runs no +# dh_shlibdeps (it would demand the bundled libraries from the target). This +# list is therefore explicit and minimal -- only what genuinely comes from the +# host. The embedded Qt is built -no-opengl -no-dbus, so there is deliberately +# NO libegl1/libgl1/libx11-6 (Mesa/X11 are far too large for the image) and no +# libsystemd0 (no session bus). Keep it in step with the external sonames from +# embedded_deploy_lib_closure() (debian/lib.sh). +Depends: dosfstools, fdisk, util-linux (>= 2.37), + libc6, libgcc-s1, libstdc++6, + libdrm2, libinput10, libudev1 Suggests: rpi-eeprom, firmware-brcm80211 Conflicts: rpi-imager Description: Raspberry Pi Imaging utility for embedded systems @@ -38,8 +57,9 @@ Description: Raspberry Pi Imaging utility for embedded systems Package: rpi-imager-cli Build-Profiles: Architecture: amd64 arm64 armhf -Depends: ${shlibs:Depends}, ${misc:Depends}, - dosfstools, fdisk, fuse, libfuse2, util-linux (>= 2.37) +Depends: ${shlibs:Depends}, ${misc:Depends}, + dosfstools, fdisk, fuse, libfuse2, util-linux (>= 2.37), + libstdc++6, libatomic1 Conflicts: rpi-imager, rpi-imager-embedded Description: Raspberry Pi Imaging utility (command-line only) A command-line tool for creating bootable media for Raspberry Pi devices. diff --git a/debian/debian-build-packages b/debian/debian-build-packages new file mode 100644 index 000000000..abd7c4ccf --- /dev/null +++ b/debian/debian-build-packages @@ -0,0 +1,4 @@ +man-db +debhelper +dh-exec +fakeroot diff --git a/debian/ensure-qt.sh b/debian/ensure-qt.sh new file mode 100755 index 000000000..5a459d35e --- /dev/null +++ b/debian/ensure-qt.sh @@ -0,0 +1,190 @@ +#!/bin/sh +# Ensure desktop + CLI Qt builds exist in the per-arch cache (QT_CACHE). +# +# Builds on cache miss (QT_BUILD=auto, default). Installs build dependencies +# when needed. Safe to call from the native host or inside a build chroot. +# +# armhf desktop Qt is attempted by default (QT_DESKTOP_BUILD=try). If the +# vendored build fails, system Qt6 from apt is accepted when available. +# +# Usage: +# debian/ensure-qt.sh +set -eu + +TOP=$(cd "$(dirname "$0")/.." && pwd) +cd "$TOP" +. "$TOP/debian/lib.sh" +. "$TOP/debian/qt-resolve.sh" + +ARCH="${1:?usage: ensure-qt.sh }" +ensure_dirs + +qt_prefix() { + printf '%s\n' "$(qt_version_tree "$ARCH")" +} + +import_qt_from_opt() { + [ "$ARCH" = "$HOST_ARCH" ] || return 1 + [ -d /opt/Qt ] || return 1 + _opt_tree="/opt/Qt/$QT_VERSION" + [ -d "$_opt_tree" ] || return 1 + + _dest=$(qt_version_tree "$ARCH") + _desk=$(qt_desktop_gcc_dir "$ARCH") + _cli=$(qt_cli_gcc_dir "$ARCH") + _imported=0 + + mkdir -p "$_dest" + + if [ -d "$_opt_tree/$_desk" ] && [ ! -e "$_dest/$_desk" ]; then + ln -sfn "$_opt_tree/$_desk" "$_dest/$_desk" + _imported=1 + echo "ensure-qt: linked desktop Qt from /opt/Qt into $_dest/$_desk" + fi + if [ -d "$_opt_tree/$_cli" ] && [ ! -e "$_dest/$_cli" ]; then + ln -sfn "$_opt_tree/$_cli" "$_dest/$_cli" + _imported=1 + echo "ensure-qt: linked CLI Qt from /opt/Qt into $_dest/$_cli" + fi + + [ "$_imported" -eq 1 ] +} + +in_chroot() { + [ -n "${RPI_IMAGER_CHROOT:-}" ] +} + +run_apt() { + _sub=$1 + shift + if [ "$_sub" = install ] && in_chroot; then + # Drop apt-get install flags; chroot-apt-install.sh sets its own. + while [ $# -gt 0 ]; do + case "$1" in + -y|--allow-downgrades) shift ;; + *) break ;; + esac + done + sh "$TOP/debian/chroot-apt-install.sh" "$@" + return $? + fi + if in_chroot || [ "$(id -u)" -eq 0 ]; then + apt-get "$_sub" "$@" + else + sudo apt-get "$_sub" "$@" + fi +} + +deps_installed() { + command -v cmake >/dev/null 2>&1 && \ + command -v ninja >/dev/null 2>&1 && \ + dpkg -s libgnutls28-dev >/dev/null 2>&1 && \ + dpkg -s liburing-dev >/dev/null 2>&1 +} + +install_build_deps() { + if deps_installed; then + return 0 + fi + echo "ensure-qt: installing build dependencies..." + run_apt update + # shellcheck disable=SC2046 + run_apt install -y --allow-downgrades $(tr '\n' ' ' <"$TOP/debian/chroot-packages") + # Optional system Qt6 for armhf desktop fallback (best-effort). + if [ "$ARCH" = armhf ] && ! qt_desktop_build_required "$ARCH"; then + run_apt install -y --allow-downgrades qt6-base-dev qt6-declarative-dev 2>/dev/null || \ + echo "ensure-qt: system Qt6 dev packages not available (optional)" >&2 + fi +} + +need_desktop=1 +need_cli=1 + +import_qt_from_opt || true + +if qt_desktop_ok "$ARCH"; then + need_desktop=0 +elif ! qt_desktop_build_required "$ARCH" && system_qt6_desktop_ok; then + echo "ensure-qt: using system Qt6 for desktop ($ARCH)" + need_desktop=0 +fi +if qt_cli_ok "$ARCH"; then + need_cli=0 +fi + +if [ "$need_desktop" -eq 0 ] && [ "$need_cli" -eq 0 ]; then + case "$QT_BUILD" in + always|force) + need_desktop=1 + need_cli=1 + ;; + *) + echo "ensure-qt: cache hit for $ARCH (Qt $QT_VERSION)" + exit 0 + ;; + esac +fi + +case "$QT_BUILD" in + cached|use-cache|never) + echo "ensure-qt: Qt cache incomplete for $ARCH (QT_BUILD=$QT_BUILD)" >&2 + echo "ensure-qt: expected desktop: $(qt_desktop_path "$ARCH") (or system qmake6)" >&2 + echo "ensure-qt: expected cli: $(qt_cli_path "$ARCH")" >&2 + exit 1 + ;; +esac + +if [ "$ARCH" != "$HOST_ARCH" ] && ! in_chroot; then + echo "ensure-qt: $ARCH Qt must be built inside the $ARCH chroot" >&2 + echo "ensure-qt: run via: debian/build-appimages.sh $ARCH" >&2 + exit 1 +fi + +install_build_deps +_prefix=$(qt_prefix) + +if [ "$need_desktop" -eq 1 ]; then + echo "ensure-qt: building desktop Qt $QT_VERSION for $ARCH..." + echo "ensure-qt: install prefix $_prefix" + if ! "$TOP/qt/build-qt.sh" \ + --version="$QT_VERSION" \ + --prefix="$_prefix" \ + --skip-dependencies \ + --unprivileged; then + if qt_desktop_build_required "$ARCH"; then + echo "ensure-qt: desktop Qt build failed (required for $ARCH)" >&2 + exit 1 + fi + echo "ensure-qt: desktop Qt build failed; will use system Qt6 if available" >&2 + if ! system_qt6_desktop_ok; then + echo "ensure-qt: warning: no vendored or system desktop Qt6 for $ARCH" >&2 + echo "ensure-qt: desktop AppImage may fail; CLI builds can still proceed" >&2 + fi + fi +fi + +if [ "$need_cli" -eq 1 ]; then + echo "ensure-qt: building CLI Qt $QT_VERSION for $ARCH..." + "$TOP/qt/build-qt-cli.sh" \ + --version="$QT_VERSION" \ + --prefix="$_prefix" \ + --skip-dependencies \ + --unprivileged +fi + +if ! qt_cli_ok "$ARCH"; then + echo "ensure-qt: CLI Qt build finished but cache check failed for $ARCH" >&2 + exit 1 +fi + +if qt_desktop_build_required "$ARCH" && ! qt_desktop_ok "$ARCH"; then + echo "ensure-qt: desktop Qt cache check failed for $ARCH" >&2 + exit 1 +fi + +if ! qt_desktop_build_required "$ARCH" && ! qt_desktop_ready "$ARCH"; then + echo "ensure-qt: ready for CLI on $ARCH; desktop Qt not available (optional)" >&2 + exit 0 +fi + +echo "ensure-qt: ready for $ARCH at $QT_CACHE/$ARCH/$QT_VERSION" diff --git a/debian/fetch-archive-keyrings.sh b/debian/fetch-archive-keyrings.sh new file mode 100755 index 000000000..ec5907424 --- /dev/null +++ b/debian/fetch-archive-keyrings.sh @@ -0,0 +1,233 @@ +#!/bin/sh +# Fetch archive signing keyrings for mmdebstrap (host-side, not inside the chroot). +# +# Keys are cached under KEYRING_CACHE (default: .debian/archive-keyrings/). +# Host /usr/share/keyrings is reused when present; otherwise keys are downloaded +# from the official Debian / Raspbian / Raspberry Pi archives. +# +# Usage: +# debian/fetch-archive-keyrings.sh [debian|raspbian|raspberrypi|all] +set -eu + +TOP=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) + +if [ -f "$TOP/debian/release.conf" ]; then + # shellcheck disable=SC1091 + . "$TOP/debian/release.conf" +fi + +CHROOT_DIST=${CHROOT_DIST:-bookworm} +DEBIAN_MIRROR=${DEBIAN_MIRROR:-http://deb.debian.org/debian} +RASPBIAN_MIRROR=${RASPBIAN_MIRROR:-http://raspbian.raspberrypi.com/raspbian} +RPI_MIRROR=${RPI_MIRROR:-http://archive.raspberrypi.com/debian} + +KEYRING_CACHE=${KEYRING_CACHE:-.debian/archive-keyrings} +case "$KEYRING_CACHE" in + /*) ;; + *) KEYRING_CACHE="$TOP/$KEYRING_CACHE" ;; +esac + +TARGET=${1:-all} + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || { + echo "fetch-archive-keyrings: required command missing: $1" >&2 + exit 1 + } +} + +need_cmd curl +need_cmd gzip + +install -d "$KEYRING_CACHE" + +_packages_filename() { + _mirror=$1 + _suite=$2 + _pkg=$3 + curl -fsSL "${_mirror%/}/dists/${_suite}/main/binary-all/Packages.gz" \ + | gzip -dc \ + | awk -v pkg="$_pkg" ' + $1 == "Package:" && $2 == pkg { found = 1; next } + found && $1 == "Package:" { exit 1 } + found && $1 == "Filename:" { print $2; exit } + ' +} + +copy_or_use() { + _dest=$1 + _src=$2 + + if [ ! -f "$_src" ]; then + return 1 + fi + if [ "$_src" -ef "$_dest" ]; then + echo "fetch-archive-keyrings: using $_src" >&2 + return 0 + fi + install -m 0644 "$_src" "$_dest" + echo "fetch-archive-keyrings: using $_src" >&2 + return 0 +} + +_fetch_pool_deb_keyring() { + _label=$1 + _dest=$2 + _mirror=$3 + _suite=$4 + _pkg=$5 + _fallback=$6 + + _file=$(_packages_filename "$_mirror" "$_suite" "$_pkg" 2>/dev/null) || _file= + if [ -z "$_file" ]; then + _file=$_fallback + fi + + _tmp=$(mktemp -d "${TMPDIR:-/tmp}/rpi-imager-keyring.XXXXXX") + _deb="$_tmp/pkg.deb" + # shellcheck disable=SC2064 + trap "rm -rf '$_tmp'" EXIT INT HUP TERM + + echo "fetch-archive-keyrings: downloading $_pkg from ${_mirror%/}/$_file" >&2 + curl -fsSL "${_mirror%/}/$_file" -o "$_deb" + + if command -v dpkg-deb >/dev/null 2>&1; then + dpkg-deb -x "$_deb" "$_tmp/root" + else + need_cmd ar + need_cmd tar + ( + cd "$_tmp" + ar x "$_deb" data.tar.* + tar xf data.tar.* + ) + fi + + _gpg="$_tmp/root/usr/share/keyrings/${_pkg}.gpg" + if [ ! -f "$_gpg" ]; then + _gpg="$_tmp/root/usr/share/keyrings/${_pkg}.pgp" + fi + if [ ! -f "$_gpg" ]; then + _gpg=$(find "$_tmp/root/usr/share/keyrings" -maxdepth 1 -type f -name "${_pkg}.*" 2>/dev/null | head -1) + fi + [ -n "$_gpg" ] || { + echo "fetch-archive-keyrings: no keyring inside $_pkg ($_file)" >&2 + return 1 + } + + install -m 0644 "$_gpg" "$_dest" + echo "fetch-archive-keyrings: installed $_label -> $_dest" >&2 +} + +_fetch_ascii_key() { + _label=$1 + _dest=$2 + _url=$3 + + echo "fetch-archive-keyrings: downloading $_label from $_url" >&2 + if command -v gpg >/dev/null 2>&1; then + curl -fsSL "$_url" | gpg --dearmor >"$_dest" + else + echo "fetch-archive-keyrings: gpg required to convert $_url" >&2 + return 1 + fi + chmod 0644 "$_dest" + echo "fetch-archive-keyrings: installed $_label -> $_dest" >&2 +} + +debian_keyring_has_trixie_keys() { + _file=$1 + [ -f "$_file" ] || return 1 + command -v gpg >/dev/null 2>&1 || return 0 + gpg --no-default-keyring --keyring "$_file" --list-keys 78DBA3BC47EF2265 >/dev/null 2>&1 +} + +ensure_keyring() { + _label=$1 + _dest=$2 + _host=$3 + shift 3 + + if [ -f "$_dest" ]; then + case "$_label" in + debian-archive-keyring) + debian_keyring_has_trixie_keys "$_dest" && return 0 + echo "fetch-archive-keyrings: refreshing stale $_dest" >&2 + rm -f "$_dest" + ;; + *) return 0 ;; + esac + fi + case "$_label" in + debian-archive-keyring) + if [ -f "$_host" ] && debian_keyring_has_trixie_keys "$_host"; then + copy_or_use "$_dest" "$_host" && return 0 + elif [ -f "$_host" ]; then + echo "fetch-archive-keyrings: host $_host lacks trixie keys; downloading fresh keyring" >&2 + fi + ;; + *) + copy_or_use "$_dest" "$_host" && return 0 + ;; + esac + "$@" +} + +fetch_debian() { + ensure_keyring debian-archive-keyring \ + "$KEYRING_CACHE/debian-archive-keyring.gpg" \ + /usr/share/keyrings/debian-archive-keyring.gpg \ + _fetch_pool_deb_keyring debian-archive-keyring \ + "$KEYRING_CACHE/debian-archive-keyring.gpg" \ + "$DEBIAN_MIRROR" "$CHROOT_DIST" debian-archive-keyring \ + pool/main/d/debian-archive-keyring/debian-archive-keyring_2025.1_all.deb +} + +fetch_raspbian() { + _rasp_mirror=${RASPBIAN_MIRROR%/} + case "$_rasp_mirror" in + */raspbian) ;; + *) _rasp_mirror="${_rasp_mirror}/raspbian" ;; + esac + + ensure_keyring raspbian-archive-keyring \ + "$KEYRING_CACHE/raspbian-archive-keyring.gpg" \ + /usr/share/keyrings/raspbian-archive-keyring.gpg \ + _fetch_pool_deb_keyring raspbian-archive-keyring \ + "$KEYRING_CACHE/raspbian-archive-keyring.gpg" \ + "$_rasp_mirror" "$CHROOT_DIST" raspbian-archive-keyring \ + pool/main/r/raspbian-archive-keyring/raspbian-archive-keyring_20120528.4_all.deb +} + +fetch_raspberrypi() { + _rpi_org=${RPI_MIRROR%/} + case "$_rpi_org" in + *archive.raspberrypi.org*) _key_url="${_rpi_org%/debian}/debian/raspberrypi.gpg.key" ;; + *) _key_url="http://archive.raspberrypi.org/debian/raspberrypi.gpg.key" ;; + esac + + ensure_keyring raspberrypi-archive-keyring \ + "$KEYRING_CACHE/raspberrypi-archive-keyring.gpg" \ + /usr/share/keyrings/raspberrypi-archive-keyring.gpg \ + _fetch_ascii_key raspberrypi-archive-keyring \ + "$KEYRING_CACHE/raspberrypi-archive-keyring.gpg" \ + "$_key_url" +} + +case "$TARGET" in + debian) fetch_debian ;; + raspbian) fetch_raspbian ;; + raspberrypi) fetch_raspberrypi ;; + all) + fetch_debian + fetch_raspbian + fetch_raspberrypi + ;; + *) + echo "fetch-archive-keyrings: unknown target: $TARGET" >&2 + exit 1 + ;; +esac + +chown -R "$(id -u):$(id -g)" "$KEYRING_CACHE" 2>/dev/null || true +chmod -R a+rX "$KEYRING_CACHE" diff --git a/debian/fetch-vendor-deps.sh b/debian/fetch-vendor-deps.sh new file mode 100755 index 000000000..067d3bd99 --- /dev/null +++ b/debian/fetch-vendor-deps.sh @@ -0,0 +1,67 @@ +#!/bin/sh +# Initialize vendored third-party git submodules for offline CMake builds. +# +# Submodule paths and pinned tags match src/dependencies/*.cmake: +# xz v5.8.3 (LIBLZMA_VERSION) +# zstd v1.5.7 (ZSTD_VERSION) +# zlib v1.3.2 (ZLIB_VERSION) +# nghttp2 v1.69.0 (NGHTTP2_VERSION) +# libarchive v3.8.7 (LIBARCHIVE_VERSION) +# curl curl-8_20_0 (CURL_VERSION 8.20.0) +# libusb v1.0.30 (LIBUSB_VERSION) +# +# Usage: +# debian/fetch-vendor-deps.sh +set -eu + +TOP=$(cd "$(dirname "$0")/.." && pwd) +cd "$TOP" + +if ! git rev-parse --git-dir >/dev/null 2>&1; then + echo "fetch-vendor-deps: not a git checkout" >&2 + exit 1 +fi + +if [ ! -f .gitmodules ]; then + echo "fetch-vendor-deps: .gitmodules not found" >&2 + exit 1 +fi + +echo "fetch-vendor-deps: initializing submodules..." +git submodule sync --recursive +# Depth-1 clones omit tag refs; check_submodule fetches each expected tag below. +git submodule update --init --depth 1 + +check_submodule() { + _path=$1 + _tag=$2 + _marker=$3 + + if [ ! -f "$TOP/$_path/$_marker" ]; then + echo "fetch-vendor-deps: missing $_path/$_marker" >&2 + return 1 + fi + + # Fetch only the pinned tag (not all tags) so the tag ref exists in a + # shallow clone without pulling every upstream release tip. + if ! git -C "$TOP/$_path" rev-parse --verify --quiet "refs/tags/$_tag" >/dev/null; then + git -C "$TOP/$_path" fetch --depth 1 origin tag "$_tag" + fi + + _head=$(git -C "$TOP/$_path" rev-parse HEAD) + _want=$(git -C "$TOP/$_path" rev-parse "$_tag^{commit}") + if [ "$_head" != "$_want" ]; then + echo "fetch-vendor-deps: $_path at $_head, expected $_tag ($_want)" >&2 + return 1 + fi +} + +check_submodule src/dependencies/vendor/xz v5.8.3 CMakeLists.txt +check_submodule src/dependencies/vendor/zstd v1.5.7 build/cmake/CMakeLists.txt +check_submodule src/dependencies/vendor/zlib v1.3.2 CMakeLists.txt +check_submodule src/dependencies/vendor/nghttp2 v1.69.0 lib/CMakeLists.txt +check_submodule src/dependencies/vendor/libarchive v3.8.7 CMakeLists.txt +check_submodule src/dependencies/vendor/curl curl-8_20_0 CMakeLists.txt +check_submodule src/dependencies/vendor/libusb v1.0.30 libusb/libusb.h + +echo "fetch-vendor-deps: ready" diff --git a/debian/gbp.conf b/debian/gbp.conf new file mode 100644 index 000000000..7f2fab6aa --- /dev/null +++ b/debian/gbp.conf @@ -0,0 +1,10 @@ +[buildpackage] +export-dir = .. +compression = xz + +[git-buildpackage] +upstream-branch = main +debian-branch = main + +[git-export] +# Orig tarball is git archive at the release tag, excluding debian/. diff --git a/debian/lib.sh b/debian/lib.sh new file mode 100755 index 000000000..553fc4039 --- /dev/null +++ b/debian/lib.sh @@ -0,0 +1,846 @@ +#!/bin/sh +# Shared path and tooling configuration for debian/release.sh. +# +# Source from other scripts after setting TOP: +# TOP=$(cd "$(dirname "$0")/.." && pwd) +# . "$TOP/debian/lib.sh" +# +# Optional overrides: debian/release.conf (copy from release.conf.example) +# Environment variables with the same names take precedence over the file. + +if [ -n "${RPI_IMAGER_LIB_LOADED:-}" ]; then + return 0 2>/dev/null || exit 0 +fi +RPI_IMAGER_LIB_LOADED=1 + +: "${TOP:?TOP must be set before sourcing debian/lib.sh}" + +if [ -f "$TOP/debian/release.conf" ]; then + # shellcheck disable=SC1091 + . "$TOP/debian/release.conf" +fi + +resolve_repo_path() { + _path=$1 + case "$_path" in + /*) printf '%s\n' "$_path" ;; + *) printf '%s/%s\n' "$TOP" "$_path" ;; + esac +} + +OUTPUT_DIR=${OUTPUT_DIR:-out/debian} +OUTPUT_DIR=$(resolve_repo_path "$OUTPUT_DIR") + +APPIMAGE_ROOT=${APPIMAGE_ROOT:-.debian/appimages} +APPIMAGE_ROOT=$(resolve_repo_path "$APPIMAGE_ROOT") + +QT_CACHE=${QT_CACHE:-.debian/qt} +QT_CACHE=$(resolve_repo_path "$QT_CACHE") +# The Qt version is selected in exactly one place: QT_VERSION_DEFAULT in +# qt/qt-build-common.sh, which the qt/build-qt*.sh scripts this pipeline invokes +# also read. Extract it rather than sourcing that file — it sets ARCH, PLATFORM +# and friends, which would clobber callers of this library. +if [ -z "${QT_VERSION:-}" ]; then + QT_VERSION=$(sed -n 's/^QT_VERSION_DEFAULT="\([^"]*\)".*/\1/p' \ + "$TOP/qt/qt-build-common.sh" 2>/dev/null | head -1) + if [ -z "$QT_VERSION" ]; then + echo "lib.sh: could not read QT_VERSION_DEFAULT from qt/qt-build-common.sh" >&2 + return 1 2>/dev/null || exit 1 + fi +fi +# auto: build Qt on cache miss (default); cached: require pre-built Qt; always: force rebuild +QT_BUILD=${QT_BUILD:-auto} + +# bookworm (glibc 2.36) is the baseline for all arches: modern enough for Qt +# 6.11 / liburing 2.2+ yet old enough that the resulting AppImages/.debs stay +# portable to current Debian/Ubuntu/Raspberry Pi OS releases. +CHROOT_DIST=${CHROOT_DIST:-bookworm} +# Chroot name suffix (dir: --, e.g. bookworm-arm64-rpi-imager). +CHROOT_SUFFIX=${CHROOT_SUFFIX:-rpi-imager} +DEBIAN_MIRROR=${DEBIAN_MIRROR:-http://deb.debian.org/debian} +RASPBIAN_MIRROR=${RASPBIAN_MIRROR:-http://raspbian.raspberrypi.com/raspbian} +RPI_MIRROR=${RPI_MIRROR:-http://archive.raspberrypi.com/debian} +CHROOT_ARCHES=${CHROOT_ARCHES:-arm64 amd64 armhf} +# Every arch, including the host, builds in its bookworm mmdebstrap chroot. +# There is deliberately no host-native build path: a package built against the +# host's libraries is not the package it claims to be. Building on a newer +# glibc than bookworm yields binaries that will not start on bookworm at all, +# and building on an older one silently drops features -- liburing < 2.2 loses +# io_uring, for instance -- while the artifact still gets labelled for +# bookworm. The rootless mmdebstrap chroots need no sudo, so there is no +# environment that cannot use them. +# auto: create missing chroots via mmdebstrap (rootless, default); 0: require manual setup +CHROOT_AUTO_CREATE=${CHROOT_AUTO_CREATE:-auto} +DEB_BUILD_PROFILES=${DEB_BUILD_PROFILES:-desktop cli} +DPUT_HOST=${DPUT_HOST:-} +# always: rebuild AppImages every time (default); cached: sync staged cache only +APPIMAGE_BUILD=${APPIMAGE_BUILD:-always} + +CHANGELOG="$TOP/debian/changelog" +PACKAGE=$(dpkg-parsechangelog -l"$CHANGELOG" -SSource) +VERSION=$(dpkg-parsechangelog -l"$CHANGELOG" -SVersion) +UPSTREAM=${VERSION%%-*} +HOST_ARCH=$(dpkg --print-architecture) + +chroot_name() { + printf '%s-%s-%s\n' "$CHROOT_DIST" "$1" "$CHROOT_SUFFIX" +} + +# Put the native host arch first so AppImage/Qt builds can run on the host. +release_arch_order() { + _native=$HOST_ARCH + _out="" + + for _arch in $RELEASE_ARCHES; do + if [ "$_arch" = "$_native" ]; then + _out="$_arch" + break + fi + done + + for _arch in $RELEASE_ARCHES; do + if [ "$_arch" = "$_native" ]; then + continue + fi + _out="$_out $_arch" + done + + # shellcheck disable=SC2086 + set -- $_out + printf '%s\n' "$@" +} + +# Every arch needs a rootless mmdebstrap chroot or APPIMAGE_REMOTE_, +# the host arch included. CHROOT_AUTO_CREATE=auto (default) creates missing +# chroots when possible. Returns a space-separated list of RELEASE_ARCHES +# missing a chroot/remote. +missing_release_chroots() { + _missing="" + for _arch in $RELEASE_ARCHES; do + if have_chroot "$_arch"; then + continue + fi + if appimage_remote_host "$_arch" 2>/dev/null; then + continue + fi + _missing="$_missing $_arch" + done + printf '%s' "$_missing" +} + +deb_to_image_arch() { + case "$1" in + amd64) printf '%s\n' x86_64 ;; + arm64) printf '%s\n' aarch64 ;; + armhf) printf '%s\n' armhf ;; + *) printf '%s\n' "$1" ;; + esac +} + +# Normalise kernel uname values on 32-bit Raspberry Pi OS to Debian armhf. +normalize_image_arch() { + case "$1" in + amd64) printf '%s\n' x86_64 ;; + arm64) printf '%s\n' aarch64 ;; + armhf|armv6l|armv7l) printf '%s\n' armhf ;; + x86_64|aarch64) printf '%s\n' "$1" ;; + *) printf '%s\n' "$1" ;; + esac +} + +qt_desktop_gcc_dir() { + _arch=$1 + case "$_arch" in + amd64) printf '%s\n' gcc_64 ;; + arm64) printf '%s\n' gcc_arm64 ;; + armhf) printf '%s\n' gcc_arm32 ;; + *) return 1 ;; + esac +} + +qt_cli_gcc_dir() { + _arch=$1 + case "$_arch" in + amd64) printf '%s\n' gcc_64_cli ;; + arm64) printf '%s\n' gcc_arm64_cli ;; + armhf) printf '%s\n' gcc_arm32_cli ;; + *) return 1 ;; + esac +} + +qt_embedded_gcc_dir() { + _arch=$1 + case "$_arch" in + amd64) printf '%s\n' gcc_64_embedded ;; + arm64) printf '%s\n' gcc_arm64_embedded ;; + armhf) printf '%s\n' gcc_arm32_embedded ;; + *) return 1 ;; + esac +} + +qt_version_tree() { + _arch=$1 + printf '%s/%s/%s\n' "$QT_CACHE" "$_arch" "$QT_VERSION" +} + +qt_embedded_path() { + _arch=$1 + _gcc=$(qt_embedded_gcc_dir "$_arch") || return 1 + printf '%s/%s\n' "$(qt_version_tree "$_arch")" "$_gcc" +} + +qt_embedded_ok() { + qt_qmake_ok "$(qt_embedded_path "$1")" +} + +qt_desktop_path() { + _arch=$1 + _gcc=$(qt_desktop_gcc_dir "$_arch") || return 1 + printf '%s/%s\n' "$(qt_version_tree "$_arch")" "$_gcc" +} + +qt_cli_path() { + _arch=$1 + _gcc=$(qt_cli_gcc_dir "$_arch") || return 1 + printf '%s/%s\n' "$(qt_version_tree "$_arch")" "$_gcc" +} + +qt_qmake_ok() { + _dir=$1 + [ -x "$_dir/bin/qmake" ] || return 1 + _built=$("$_dir/bin/qmake" -query QT_VERSION 2>/dev/null) || return 1 + [ "$_built" = "$QT_VERSION" ] +} + +qt_desktop_ok() { + qt_qmake_ok "$(qt_desktop_path "$1")" +} + +qt_cli_ok() { + qt_qmake_ok "$(qt_cli_path "$1")" +} + +system_qt6_qmake() { + if command -v qmake6 >/dev/null 2>&1; then + command -v qmake6 + return 0 + fi + if command -v qmake >/dev/null 2>&1; then + _ver=$(qmake -query QT_VERSION 2>/dev/null) || return 1 + case "$_ver" in 6.*) command -v qmake; return 0 ;; esac + fi + return 1 +} + +# Minimum Qt accepted from a system install. Read from the find_package(Qt6 ...) +# call in src/CMakeLists.txt, which is what actually enforces it, so the two +# cannot drift. +if [ -z "${QT_MIN_VERSION:-}" ]; then + QT_MIN_VERSION=$(sed -n \ + 's/.*find_package(Qt6 \([0-9][0-9.]*\).*/\1/p' \ + "$TOP/src/CMakeLists.txt" 2>/dev/null | head -1) + QT_MIN_VERSION=${QT_MIN_VERSION:-6.9} +fi + +# True when version $1 is >= QT_MIN_VERSION (major.minor compare). +qt_version_ge_min() { + _v=$1 + [ -n "$_v" ] || return 1 + _vmaj=${_v%%.*} + _vrest=${_v#*.} + _vmin=${_vrest%%.*} + _minmaj=${QT_MIN_VERSION%%.*} + _minrest=${QT_MIN_VERSION#*.} + _minmin=${_minrest%%.*} + case "$_vmaj$_vmin$_minmaj$_minmin" in + *[!0-9]*) return 1 ;; + esac + [ "$_vmaj" -gt "$_minmaj" ] && return 0 + [ "$_vmaj" -eq "$_minmaj" ] && [ "$_vmin" -ge "$_minmin" ] && return 0 + return 1 +} + +system_qt6_desktop_ok() { + _qmake=$(system_qt6_qmake) || return 1 + _ver=$("$_qmake" -query QT_VERSION 2>/dev/null) || return 1 + qt_version_ge_min "$_ver" +} + +# Vendored desktop Qt and/or system Qt6 (qmake6 from apt). +qt_desktop_ready() { + _arch=$1 + qt_desktop_ok "$_arch" || system_qt6_desktop_ok +} + +qt_desktop_build_required() { + _arch=$1 + case "$_arch" in + armhf) + case "${QT_DESKTOP_BUILD:-try}" in + try|optional|best-effort) return 1 ;; + *) return 0 ;; + esac + ;; + *) return 0 ;; + esac +} + +qt_cache_ok() { + _arch=$1 + qt_cli_ok "$_arch" || return 1 + if qt_desktop_build_required "$_arch"; then + qt_desktop_ok "$_arch" + else + qt_desktop_ready "$_arch" + fi +} + +ensure_dirs() { + mkdir -p "$OUTPUT_DIR" "$APPIMAGE_ROOT" \ + "$QT_CACHE/arm64" "$QT_CACHE/amd64" "$QT_CACHE/armhf" +} + +appimage_cache_ok() { + _arch=$1 + _img_arch=$(deb_to_image_arch "$_arch") + _dir="$APPIMAGE_ROOT/$_arch" + test -e "$_dir/rpi-imager-${_img_arch}.AppImage" && \ + test -e "$_dir/rpi-imager-cli-${_img_arch}.AppImage" +} + +# Verify an AppImage's embedded runtime ELF matches the target image arch. +# appimagetool can silently embed the wrong (host) runtime on cross-packs, so +# we inspect the executable header with file(1). Returns 0 if unverifiable +# (file missing) to avoid false negatives. +appimage_elf_arch_ok() { + _file=$1 + _img_arch=$(normalize_image_arch "$2") + [ -f "$_file" ] || return 1 + command -v file >/dev/null 2>&1 || return 0 + _desc=$(file -Lb "$_file" 2>/dev/null) || return 1 + case "$_img_arch" in + x86_64) case "$_desc" in *x86-64*) return 0 ;; esac ;; + aarch64) case "$_desc" in *"ARM aarch64"*) return 0 ;; esac ;; + armhf) case "$_desc" in *"ELF 32-bit"*ARM*) return 0 ;; esac ;; + esac + return 1 +} + +# True when both staged AppImages exist and their runtime ELF matches the arch. +appimage_cache_arch_ok() { + _arch=$1 + _img_arch=$(deb_to_image_arch "$_arch") + _dir="$APPIMAGE_ROOT/$_arch" + appimage_elf_arch_ok "$_dir/rpi-imager-${_img_arch}.AppImage" "$_img_arch" || return 1 + appimage_elf_arch_ok "$_dir/rpi-imager-cli-${_img_arch}.AppImage" "$_img_arch" || return 1 + return 0 +} + +# Parse APPIMAGE_REMOTE_ into APPIMAGE_REMOTE_HOST and APPIMAGE_REMOTE_DIR. +# Formats: user@host or user@host:/path/to/rpi-imager +# APPIMAGE_REMOTE_DIR_ overrides the path when host-only form is used. +appimage_remote_host() { + _arch=$1 + _spec_var="APPIMAGE_REMOTE_${_arch}" + _dir_var="APPIMAGE_REMOTE_DIR_${_arch}" + + eval "_spec=\${$_spec_var:-}" + eval "_dir_override=\${$_dir_var:-}" + + APPIMAGE_REMOTE_HOST= + APPIMAGE_REMOTE_DIR= + + [ -n "$_spec" ] || return 1 + + case "$_spec" in + *:*) + APPIMAGE_REMOTE_HOST=${_spec%%:*} + APPIMAGE_REMOTE_DIR=${_spec#*:} + ;; + *) + APPIMAGE_REMOTE_HOST=$_spec + APPIMAGE_REMOTE_DIR=${_dir_override:-$TOP} + ;; + esac + + [ -n "$APPIMAGE_REMOTE_HOST" ] || return 1 + return 0 +} + +# Parallelism for cmake --build (including FetchContent sub-builds during configure). +cmake_build_jobs() { + if [ -n "${CMAKE_BUILD_PARALLEL_LEVEL:-}" ]; then + printf '%s\n' "$CMAKE_BUILD_PARALLEL_LEVEL" + return 0 + fi + nproc 2>/dev/null || getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4 +} + +export_cmake_parallel() { + export CMAKE_BUILD_PARALLEL_LEVEL="$(cmake_build_jobs)" +} + +debian_build_deps_installed() { + if [ -f "${TOP:-}/debian/control" ]; then + (cd "$TOP" && dpkg-checkbuilddeps >/dev/null 2>&1) + return $? + fi + dpkg-query -W -f='${Status}' debhelper 2>/dev/null \ + | grep -qE '^(install ok installed|install ok unpacked|install ok half-configured)$' && \ + dpkg-query -W -f='${Status}' dh-exec 2>/dev/null \ + | grep -qE '^(install ok installed|install ok unpacked|install ok half-configured)$' && \ + command -v fakeroot >/dev/null 2>&1 +} + +# debhelper/dh-exec for dpkg-buildpackage (.deb builds, not AppImage/Qt). +ensure_debian_build_deps() { + if debian_build_deps_installed; then + return 0 + fi + echo "ensure-debian-build-deps: installing packaging tools..." + if [ -n "${RPI_IMAGER_CHROOT:-}" ]; then + # shellcheck disable=SC2046 + sh "$TOP/debian/chroot-apt-install.sh" $(tr '\n' ' ' <"$TOP/debian/debian-build-packages") + elif [ "$(id -u)" -eq 0 ]; then + apt-get update + # shellcheck disable=SC2046 + apt-get install -y $(tr '\n' ' ' <"$TOP/debian/debian-build-packages") + elif command -v sudo >/dev/null 2>&1; then + sudo apt-get update + # shellcheck disable=SC2046 + sudo apt-get install -y $(tr '\n' ' ' <"$TOP/debian/debian-build-packages") + else + echo "ensure-debian-build-deps: root or sudo required" >&2 + return 1 + fi + if ! debian_build_deps_installed; then + echo "ensure-debian-build-deps: build dependencies still unsatisfied:" >&2 + if [ -f "$TOP/debian/control" ]; then + (cd "$TOP" && dpkg-checkbuilddeps) 2>&1 | head -5 >&2 || true + fi + return 1 + fi +} + +# AppImage type2 runtime for a target image arch (x86_64|aarch64|armhf). +# appimagetool embeds a runtime as the AppImage's ELF header. The ARCH env var +# alone is unreliable for cross-arch packing (it falls back to the tool's own +# x86_64 runtime), so we pass an explicit --runtime-file. Cached under +# appimage-tools/ and fetched from the type2-runtime project on cache miss. +appimage_runtime_file() { + _img_arch=$(normalize_image_arch "$1") + _tools="${APPIMAGE_TOOLS_DIR:-$TOP/appimage-tools}" + _dest="$_tools/runtime-$_img_arch" + + if [ -f "$_dest" ] && [ -s "$_dest" ]; then + printf '%s\n' "$_dest" + return 0 + fi + + mkdir -p "$_tools" + _url="https://github.com/AppImage/type2-runtime/releases/download/continuous/runtime-$_img_arch" + if command -v curl >/dev/null 2>&1; then + curl -fL -o "$_dest" "$_url" || { rm -f "$_dest"; return 1; } + elif command -v wget >/dev/null 2>&1; then + wget -O "$_dest" "$_url" || { rm -f "$_dest"; return 1; } + else + echo "appimage-runtime: neither curl nor wget available" >&2 + return 1 + fi + + if [ ! -s "$_dest" ]; then + rm -f "$_dest" + return 1 + fi + chmod +x "$_dest" 2>/dev/null || true + printf '%s\n' "$_dest" +} + +# Host/tool arch for linuxdeploy and appimagetool (AppImage arch of the builder machine). +# Pass APPIMAGE_TOOL_ARCH as a Debian arch (amd64) or image arch (x86_64) when the +# target AppImage arch differs, e.g. cross-builds via chroot on an amd64 host. +appimage_resolve_tool_arch() { + if [ -n "${APPIMAGE_TOOL_ARCH:-}" ]; then + normalize_image_arch "$(deb_to_image_arch "$APPIMAGE_TOOL_ARCH")" + else + normalize_image_arch "$(uname -m)" + fi +} + +# appimagetool requires a .desktop file and matching icon at the AppDir root. +# linuxdeploy creates these; cross-pack (build in chroot, pack on host) must add them. +prepare_appdir_for_appimagetool() { + _appdir=$1 + _desktop_id=$2 + + _desktop_src="$_appdir/usr/share/applications/${_desktop_id}.desktop" + if [ ! -f "$_desktop_src" ]; then + echo "prepare_appdir_for_appimagetool: missing $_desktop_src" >&2 + return 1 + fi + + _desktop_root="$_appdir/${_desktop_id}.desktop" + if [ ! -e "$_desktop_root" ]; then + ln -sf "usr/share/applications/${_desktop_id}.desktop" "$_desktop_root" + fi + + _icon=$(grep '^Icon=' "$_desktop_src" | head -1 | cut -d= -f2-) + if [ -z "$_icon" ]; then + echo "prepare_appdir_for_appimagetool: Icon= missing in $_desktop_src" >&2 + return 1 + fi + + if [ -e "$_appdir/${_icon}.png" ] || [ -e "$_appdir/${_icon}.svg" ] || [ -e "$_appdir/${_icon}.xpm" ]; then + return 0 + fi + + for _candidate in \ + "$_appdir/usr/share/icons/hicolor/scalable/apps/${_icon}.svg" \ + "$_appdir/usr/share/icons/hicolor/256x256/apps/${_icon}.png" \ + "$_appdir/usr/share/icons/hicolor/128x128/apps/${_icon}.png" \ + "$_appdir/usr/share/icons/hicolor/48x48/apps/${_icon}.png"; do + if [ -f "$_candidate" ]; then + _ext=${_candidate##*.} + ln -sf "${_candidate#$_appdir/}" "$_appdir/${_icon}.${_ext}" + return 0 + fi + done + + echo "prepare_appdir_for_appimagetool: icon $_icon not found under $_appdir" >&2 + return 1 +} + +# Download an AppImage helper tool to $1 (dest) from $2 (url) if missing. +appimage_download_tool() { + _dest=$1 + _url=$2 + [ -f "$_dest" ] && return 0 + echo "Downloading $(basename "$_dest")..." + curl -L -o "$_dest" "$_url" || { rm -f "$_dest"; return 1; } + chmod +x "$_dest" +} + +# Pack an AppDir into an AppImage with appimagetool, embedding the target-arch +# runtime (mandatory for cross-pack; ARCH alone falls back to the tool's own). +# Args: +appimage_pack_with_tool() { + _tool=$1 + _appdir=$2 + _out=$3 + _tarch=$4 + _toolarch=$5 + _desktop=$6 + + export APPIMAGE_EXTRACT_AND_RUN=1 + prepare_appdir_for_appimagetool "$_appdir" "$_desktop" || return 1 + echo "Creating AppImage with appimagetool ($_toolarch) for target $_tarch..." + if _runtime=$(appimage_runtime_file "$_tarch"); then + echo "Using AppImage runtime for $_tarch: $_runtime" + ARCH="$_tarch" "$_tool" --runtime-file "$_runtime" "$_appdir" "$_out" + elif [ "$_tarch" = "$_toolarch" ]; then + echo "Warning: no runtime file for $_tarch; using appimagetool default (native arch)" >&2 + ARCH="$_tarch" "$_tool" "$_appdir" "$_out" + else + echo "Error: could not obtain AppImage runtime for $_tarch (required for cross-pack)" >&2 + echo " Place it at appimage-tools/runtime-$_tarch or enable network access." >&2 + return 1 + fi +} + +# Libraries that must always come from the host system and are never bundled. +# Bundling these couples the AppImage to the build host's C library, graphics +# stack or session bus; libsystemd/libdbus in particular broke lsblk and udisks +# integration (#1304, #1577). Callers must declare the corresponding Debian +# packages in debian/control instead. +appimage_lib_excluded() { + case "$1" in + # glibc and the dynamic loader + ld-linux*|libc.so.*|libm.so.*|libdl.so.*|libpthread.so.*|librt.so.*) return 0 ;; + libresolv.so.*|libnsl.so.*|libutil.so.*|libcrypt.so.*|libanl.so.*) return 0 ;; + libthread_db.so.*|libnss_*|libBrokenLocale.so.*) return 0 ;; + # compiler runtimes: must match the host libstdc++ ABI + libstdc++.so.*|libgcc_s.so.*|libatomic.so.*) return 0 ;; + # GPU stack: must match the host's drivers + libGL.so.*|libGLX.so.*|libEGL.so.*|libOpenGL.so.*|libGLdispatch.so.*) return 0 ;; + libglapi.so.*|libgbm.so.*|libdrm.so.*|libvulkan.so.*) return 0 ;; + # display server and input: must match the running X/Wayland session + libX11-xcb.so.*|libxcb*.so.*|libX*.so.*) return 0 ;; + libxkbcommon.so.*|libxkbcommon-x11.so.*|libwayland-*.so.*) return 0 ;; + libinput.so.*|libmtdev.so.*|libevdev.so.*|libwacom.so.*) return 0 ;; + # session and device integration (#1304, #1577) + libsystemd.so.*|libdbus-1.so.*|libcap.so.*|libudev.so.*|libselinux.so.*) return 0 ;; + # font stack: must match the host's fontconfig cache and configuration + libfontconfig.so.*|libfreetype.so.*|libexpat.so.*) return 0 ;; + esac + return 1 +} + +# Multiarch triplet used to locate target-architecture system libraries. The +# build stage runs inside the target-arch chroot, so the local triplet is the +# one we want. +appimage_multiarch_triplet() { + _t="" + if command -v dpkg-architecture >/dev/null 2>&1; then + _t=$(dpkg-architecture -qDEB_HOST_MULTIARCH 2>/dev/null || true) + fi + if [ -z "$_t" ] && command -v gcc >/dev/null 2>&1; then + _t=$(gcc -print-multiarch 2>/dev/null || true) + fi + if [ -z "$_t" ]; then + for _d in /usr/lib/*-linux-gnu*; do + [ -d "$_d" ] || continue + _t=$(basename "$_d") + break + done + fi + [ -n "$_t" ] || return 1 + printf '%s\n' "$_t" +} + +appimage_list_elf_objects() { + find "$1/usr/bin" "$1/usr/lib" "$1/usr/plugins" "$1/usr/qml" \ + -type f \( -name '*.so' -o -name '*.so.*' -o -perm -u+x \) 2>/dev/null +} + +embedded_list_elf_objects() { + find "$1/bin" "$1/lib" "$1/plugins" "$1/qml" \ + -type f \( -name '*.so' -o -name '*.so.*' -o -perm -u+x \) 2>/dev/null +} + +# Libraries the embedded package takes from the host. Much narrower than +# appimage_lib_excluded(): the embedded tree is deliberately self-contained +# under /opt, so only the C library, the compiler runtime, the GPU stack and +# libsystemd stay external. libsystemd in particular must come from the host to +# work with DBus (#1304). +embedded_lib_excluded() { + case "$1" in + ld-linux*|libc.so.*|libm.so.*|libdl.so.*|libpthread.so.*|librt.so.*) return 0 ;; + libresolv.so.*|libnsl.so.*|libutil.so.*|libcrypt.so.*|libanl.so.*) return 0 ;; + libthread_db.so.*|libnss_*|libBrokenLocale.so.*) return 0 ;; + libstdc++.so.*|libgcc_s.so.*) return 0 ;; + libGL.so.*|libGLX.so.*|libEGL.so.*|libOpenGL.so.*|libGLdispatch.so.*) return 0 ;; + libglapi.so.*|libvulkan.so.*) return 0 ;; + libX11.so.*|libX11-xcb.so.*|libxcb*.so.*|libwayland-*.so.*) return 0 ;; + libsystemd.so.*) return 0 ;; + esac + return 1 +} + +# Shared worker for the two closure helpers. Completes the transitive +# DT_NEEDED closure of the objects listed by $2 into $1, skipping +# sonames rejected by the predicate $3 and resolving against $4. Reads ELF +# headers with readelf rather than shelling out to ldd, so it works unchanged +# on a foreign-architecture tree. Call after pruning unwanted plugins, so +# their dependencies are not dragged in. +deploy_lib_closure_core() { + _libdir=$1 + _lister=$2 + _root=$3 + _excl=$4 + _searchdirs=$5 + mkdir -p "$_libdir" + + _round=0 + _missing="" + while [ "$_round" -lt 16 ]; do + _round=$((_round + 1)) + _added=0 + # shellcheck disable=SC2046 + for _obj in $("$_lister" "$_root"); do + # shellcheck disable=SC2046 + for _need in $(readelf -d "$_obj" 2>/dev/null | \ + sed -n 's/.*(NEEDED)[^[]*\[\([^]]*\)\].*/\1/p'); do + [ -n "$_need" ] || continue + [ -e "$_libdir/$_need" ] && continue + "$_excl" "$_need" && continue + _found="" + for _dir in $_searchdirs; do + [ -f "$_dir/$_need" ] || continue + cp -L "$_dir/$_need" "$_libdir/$_need" + echo " bundled $_need (from $_dir)" + _found=1 + _added=1 + break + done + if [ -z "$_found" ]; then + case " $_missing " in + *" $_need "*) ;; + *) _missing="$_missing $_need" ;; + esac + fi + done + done + [ "$_added" -eq 0 ] && break + done + + if [ "$_round" -ge 16 ]; then + echo "Warning: library closure did not converge after 16 rounds" >&2 + fi + if [ -n "$_missing" ]; then + echo "Error: unresolved libraries, not excluded and not found on the build system:" >&2 + for _m in $_missing; do + echo " $_m" >&2 + done + echo " Add the providing package to debian/chroot-packages, or add the" >&2 + echo " soname to the exclusion predicate and declare it in debian/control." >&2 + return 1 + fi +} + +# Search path for resolving DT_NEEDED against the target's system libraries. +deploy_lib_search_dirs() { + _qtlib=${1:-} + _triplet=$(appimage_multiarch_triplet || true) + _dirs="" + [ -n "$_qtlib" ] && _dirs="$_qtlib" + if [ -n "$_triplet" ]; then + _dirs="$_dirs /usr/lib/$_triplet /lib/$_triplet" + fi + printf '%s /usr/lib /lib\n' "$_dirs" +} + +appimage_deploy_lib_closure() { + _appdir=$1 + echo "Bundling library closure (triplet: $(appimage_multiarch_triplet || echo unknown))..." + deploy_lib_closure_core "$_appdir/usr/lib" appimage_list_elf_objects \ + "$_appdir" appimage_lib_excluded "$(deploy_lib_search_dirs "${2:-}")" +} + +# Complete the closure of the vendored /opt tree. +# +# The explicit cp -d list in create-embedded.sh is deliberately curated: this +# package is often fetched over the network, so payload size matters. This does +# not replace that curation -- it is purely additive, and only copies libraries +# something still in the tree actually names in DT_NEEDED. Because it runs +# after the pruning step, dependencies of removed plugins are not pulled back +# in, so the result stays close to the curated set: it adds the Qt libraries +# the wholesale `cp -r` of the QML tree references but the list forgot. +# +# If this ever regresses size unacceptably, the way back is to drop the +# embedded_deploy_lib_closure call from create-embedded.sh and go back to +# curating by hand -- but then prune the QML tree to match, or the plugins will +# reference libraries that are not there. Compare with: +# debian/lib.sh: embedded_list_elf_objects + readelf -d +# to see what the tree actually needs before trimming. +# +# For reference, the curated Qt set at the point this closure was introduced -- +# 17 `cp -d` lines in create-embedded.sh, which remain the authoritative copy +# unless someone deletes them: +# +# libQt6Core libQt6Gui libQt6DBus (linuxfb plugin) +# libQt6Quick libQt6Qml libQt6QmlCore +# libQt6QmlMeta libQt6Network libQt6Svg +# libQt6QuickTemplates2 libQt6QuickLayouts +# libQt6QuickDialogs2 libQt6LabsFolderListModel +# libQt6QuickControls2Basic libQt6QuickControls2Impl +# libQt6QuickControls2Material libQt6QuickControls2MaterialStyleImpl +# +# What this closure adds on top is small, because create-embedded.sh now also +# prunes the QML modules the UI never imports. What remains are libraries the +# curated list genuinely forgot: +# +# libQt6QuickControls2 QtQuick.Controls is imported by ~29 QML +# files; the list had the Basic/Material +# styles but not Controls2 itself +# libQt6QuickControls2BasicStyleImpl needed by the Basic style fallback +# libQt6QmlModels NEEDED by libQt6Quick itself +# libQt6OpenGL NEEDED by libQt6Quick itself +# libQt6QuickDialogs2QuickImpl needed by the (curated) Dialogs module +# libQt6QuickDialogs2Utils +# +# So the pre-existing bug was real: libQt6Quick could not have resolved +# libQt6QmlModels or libQt6OpenGL, and the QtQuick.Controls plugin could not +# have resolved libQt6QuickControls2. Do not "fix" a size regression by +# dropping these -- prune QML modules instead, which removes both the plugin +# and its library together. +embedded_deploy_lib_closure() { + _optdir=$1 + echo "Completing embedded library closure (triplet: $(appimage_multiarch_triplet || echo unknown))..." + deploy_lib_closure_core "$_optdir/lib" embedded_list_elf_objects \ + "$_optdir" embedded_lib_excluded "$(deploy_lib_search_dirs "${2:-}")" +} + +# Prune the deployed Qt QML tree, its style libraries and the QML tooling down +# to what the UI actually imports. Shared by the AppImage and embedded packaging +# paths so both ship the same QML surface -- they deploy Qt differently +# (linuxdeploy/manual copy vs a curated /opt tree) but there is no reason for +# them to disagree about which modules the app needs. +# +# Imager's QML imports, and nothing more, are: +# QtQuick, QtQuick.Controls, QtQuick.Controls.Material, QtQuick.Layouts, +# QtQuick.Window, QtCore, QtQml, Qt.labs.folderlistmodel +# Re-derive with: +# grep -rhoE '^\s*import\s+[A-Za-z0-9_.]+' --include='*.qml' src/ | sort -u +# +# Every module removed here also removes the need to ship the Qt library its +# plugin links against (QtQuick/Effects -> libQt6QuickEffects, and so on), so +# prune modules rather than trimming libraries by hand: dropping a library +# whose plugin is still deployed leaves the plugin unable to load. +# +# $1 qml dir, $2 lib dir, $3 plugins dir +prune_qml_to_imports() { + _qmldir=$1 + _libdir=$2 + _plugindir=$3 + + # Control styles: Material is the one in use, Basic is the fallback. + for _style in Universal Fusion Imagine FluentWinUI3; do + rm -rf "$_qmldir/QtQuick/Controls/$_style" + rm -f "$_libdir/libQt6QuickControls2$_style.so"* + rm -f "$_libdir/libQt6QuickControls2${_style}StyleImpl.so"* + done + rm -f "$_libdir/libQt6QuickControls2WindowsStyleImpl.so"* + + # Modules the UI never imports. + for _mod in QtQuick/Effects QtQuick/Particles QtQuick/Shapes \ + QtQuick/Timeline QtQuick/VectorImage \ + QtQml/WorkerScript QtQml/XmlListModel + do + rm -rf "$_qmldir/$_mod" + done + + # ...and the libraries only those modules' plugins linked against. The + # AppImage path copies libQt6*.so* wholesale, so removing the module alone + # leaves the library behind; the embedded path never copies them, so these + # are no-ops there. Verified to have no remaining referrer with: + # readelf -d | grep libQt6 + # + # libQt6QmlWorkerScript is deliberately NOT in this list: libQt6QmlMeta + # links it directly, so it is required even though QtQml/WorkerScript is + # not imported. Re-run the check above before adding anything here. + for _lib in QuickEffects QuickParticles \ + QuickShapes QuickShapesDesignHelpers \ + QuickTimeline QuickTimelineBlendTrees \ + QuickVectorImage QuickVectorImageGenerator QuickVectorImageHelpers \ + QmlXmlListModel + do + rm -f "$_libdir/libQt6$_lib.so"* + done + + # Development-only tooling and test modules. + rm -rf "$_qmldir/QtTest"* + rm -rf "$_qmldir/QtQuick/tooling" + [ -n "$_plugindir" ] && rm -rf "$_plugindir/qmltooling" + + # QtWidgets is not used by the QML UI. + rm -f "$_libdir/libQt6Widgets.so"* + rm -f "$_libdir"/libQt*Widgets.so* + + return 0 +} + +# Drop unversioned *.so development symlinks copied in from the build system. +# Only versioned sonames are used at runtime, and these links point at absolute +# host paths, so they ship as dangling files. +prune_dev_symlinks() { + _libdir=$1 + for _l in "$_libdir"/*.so; do + [ -L "$_l" ] || continue + case "$(readlink "$_l")" in + /*) rm -f "$_l"; echo " pruned dev symlink $(basename "$_l")" ;; + esac + done +} + +# shellcheck disable=SC1091 +. "$TOP/debian/chroot-lib.sh" diff --git a/debian/mirrors.sh b/debian/mirrors.sh new file mode 100644 index 000000000..1a1cd0213 --- /dev/null +++ b/debian/mirrors.sh @@ -0,0 +1,222 @@ +#!/bin/sh +# mmdebstrap mirror selection for Raspberry Pi OS-aligned chroots. +# +# Source after TOP (and optional debian/release.conf) are set: +# . "$TOP/debian/mirrors.sh" +# +# Apt repository cascade (via preferences.d pinning): +# armhf raspbian > rpi > debian +# arm64 rpi > debian +# amd64 debian only + +DEBIAN_MIRROR=${DEBIAN_MIRROR:-http://deb.debian.org/debian} +RASPBIAN_MIRROR=${RASPBIAN_MIRROR:-http://raspbian.raspberrypi.com/raspbian} +RPI_MIRROR=${RPI_MIRROR:-http://archive.raspberrypi.com/debian} +CHROOT_ARCHES=${CHROOT_ARCHES:-arm64 amd64 armhf} +# Keep in sync with lib.sh; the mmdebstrap hooks run from a staged /tmp tree +# without lib.sh, so this default (and the exported CHROOT_DIST) must match. +CHROOT_DIST=${CHROOT_DIST:-bookworm} + +CHROOT_APT_DIR=${CHROOT_APT_DIR:-$TOP/debian/chroot-apt} + +# Configure the apt repository cascade in an mmdebstrap rootfs directory. +chroot_configure_apt_dir() { + arch=$1 + root=$2 + + [ -n "$root" ] && [ -d "$root" ] || { + echo "mirrors: invalid chroot root: $root" >&2 + return 1 + } + + chroot_normalize_apt_list "$root" + chroot_install_keyrings "$root" "$arch" + + case "$arch" in + amd64) + install_apt_sources_into_chroot "$root" \ + "$CHROOT_APT_DIR/debian-${CHROOT_DIST}.sources" debian.sources + ;; + arm64) + install_apt_sources_into_chroot "$root" \ + "$CHROOT_APT_DIR/debian-${CHROOT_DIST}.sources" debian.sources + install_apt_sources_into_chroot "$root" \ + "$CHROOT_APT_DIR/rpi-${CHROOT_DIST}.sources" raspberrypi.sources + install_apt_preferences_into_chroot "$root" \ + "$CHROOT_APT_DIR/preferences-arm64.pref" 10-rpi-imager-cascade.pref + ;; + armhf) + install_apt_sources_into_chroot "$root" \ + "$CHROOT_APT_DIR/raspbian-${CHROOT_DIST}.sources" raspbian.sources + install_apt_sources_into_chroot "$root" \ + "$CHROOT_APT_DIR/rpi-${CHROOT_DIST}.sources" raspberrypi.sources + install_apt_sources_into_chroot "$root" \ + "$CHROOT_APT_DIR/debian-${CHROOT_DIST}.sources" debian.sources + install_apt_preferences_into_chroot "$root" \ + "$CHROOT_APT_DIR/preferences-armhf.pref" 10-rpi-imager-cascade.pref + ;; + *) + echo "mirrors: unsupported arch for apt configuration: $arch" >&2 + return 1 + ;; + esac +} + +# Host-side keyring cache for mmdebstrap (--keyring, unshare namespace — not in chroot). +keyring_cache_dir() { + _cache=${KEYRING_CACHE:-.debian/archive-keyrings} + case "$_cache" in + /*) printf '%s\n' "$_cache" ;; + *) printf '%s/%s\n' "$TOP" "$_cache" ;; + esac +} + +fetch_archive_keyrings() { + _which=${1:-all} + sh "$TOP/debian/fetch-archive-keyrings.sh" "$_which" +} + +keyring_abs_path() { + _file=$1 + _dir=$(CDPATH= cd -- "$(dirname "$_file")" && pwd) + printf '%s/%s\n' "$_dir" "$(basename "$_file")" +} + +# Primary archive key for mmdebstrap (cached under KEYRING_CACHE). +mmdebstrap_keyring_cached() { + _arch=$1 + _cache=$(keyring_cache_dir) + + install -d "$_cache" + fetch_archive_keyrings all + chown -R "$(id -u):$(id -g)" "$_cache" 2>/dev/null || true + chmod -R a+rX "$_cache" + + case "$_arch" in + armhf) _name=raspbian-archive-keyring.gpg ;; + arm64|amd64) _name=debian-archive-keyring.gpg ;; + *) + echo "mirrors: unsupported arch: $_arch" >&2 + return 1 + ;; + esac + + _file="$_cache/$_name" + [ -f "$_file" ] || { + echo "mirrors: missing keyring $_file (run fetch-archive-keyrings.sh)" >&2 + return 1 + } + + keyring_abs_path "$_file" +} + +# mmdebstrap --mode=unshare runs hooks and apt as a subuid user that cannot +# traverse $HOME (typically mode 700). Stage scripts and keyrings under /tmp. +mmdebstrap_stage_hook_tree() { + _dest=$(mktemp -d "${TMPDIR:-/tmp}/rpi-imager-mmdebstrap-hooks.XXXXXX") + chmod 0755 "$_dest" + mkdir -p "$_dest/debian" + for _f in mmdebstrap-setup-hook.sh mmdebstrap-configure-apt.sh chroot-apt-install.sh mirrors.sh; do + install -m 0755 "$TOP/debian/$_f" "$_dest/debian/$_f" + done + cp -a "$TOP/debian/chroot-apt" "$_dest/debian/" + chmod -R a+rX "$_dest" + printf '%s\n' "$_dest" +} + +mmdebstrap_stage_keyring() { + _arch=$1 + _cached=$(mmdebstrap_keyring_cached "$_arch") || return 1 + _basename=$(basename "$_cached") + _stage=$(mktemp -d "${TMPDIR:-/tmp}/rpi-imager-mmdebstrap-keys.XXXXXX") + chmod 0755 "$_stage" + install -m 0644 "$_cached" "$_stage/$_basename" + printf '%s\n' "$_stage/$_basename" +} + +# Bootstrap mirror URI passed to mmdebstrap (setup-hook replaces sources.list). +mmdebstrap_bootstrap_uri() { + _arch=$1 + case "$_arch" in + armhf) + _mirror=${RASPBIAN_MIRROR%/} + case "$_mirror" in + */raspbian) ;; + *) _mirror="${_mirror}/raspbian" ;; + esac + printf '%s\n' "$_mirror" + ;; + arm64|amd64) + printf '%s\n' "${DEBIAN_MIRROR%/}" + ;; + *) + return 1 + ;; + esac +} + +install_keyring_into_chroot() { + _root=$1 + _host_key=$2 + _dest_name=$3 + _cache=$(keyring_cache_dir) + _cached="$_cache/$_dest_name" + + if [ -f "$_cached" ]; then + _host_key=$_cached + elif [ ! -f "$_host_key" ]; then + return 0 + fi + install -d "$_root/usr/share/keyrings" + install -m 0644 "$_host_key" "$_root/usr/share/keyrings/$_dest_name" +} + +install_apt_sources_into_chroot() { + _root=$1 + _src=$2 + _dest=$3 + + install -d "$_root/etc/apt/sources.list.d" + install -m 0644 "$_src" "$_root/etc/apt/sources.list.d/$_dest" +} + +install_apt_preferences_into_chroot() { + _root=$1 + _src=$2 + _dest=$3 + + install -d "$_root/etc/apt/preferences.d" + install -m 0644 "$_src" "$_root/etc/apt/preferences.d/$_dest" +} + +chroot_normalize_apt_list() { + _root=$1 + cat >"$_root/etc/apt/sources.list" <<'EOF' +# Managed by debian/mirrors.sh — repositories live in sources.list.d/ +EOF +} + +chroot_install_keyrings() { + _root=$1 + _arch=$2 + + install_keyring_into_chroot "$_root" \ + /usr/share/keyrings/debian-archive-keyring.gpg \ + debian-archive-keyring.gpg + + case "$_arch" in + arm64|armhf) + install_keyring_into_chroot "$_root" \ + /usr/share/keyrings/raspberrypi-archive-keyring.gpg \ + raspberrypi-archive-keyring.gpg + ;; + esac + + case "$_arch" in + armhf) + install_keyring_into_chroot "$_root" \ + /usr/share/keyrings/raspbian-archive-keyring.gpg \ + raspbian-archive-keyring.gpg + ;; + esac +} diff --git a/debian/mk-orig-tarball.sh b/debian/mk-orig-tarball.sh new file mode 100755 index 000000000..e51f96017 --- /dev/null +++ b/debian/mk-orig-tarball.sh @@ -0,0 +1,25 @@ +#!/bin/sh +# Create the upstream orig tarball for 3.0 (quilt) source packages. +# +# Usage: +# debian/mk-orig-tarball.sh [git-ref] +set -eu + +TOP=$(cd "$(dirname "$0")/.." && pwd) +. "$TOP/debian/lib.sh" + +REF="${1:-HEAD}" +OUTPUT="$OUTPUT_DIR/${PACKAGE}_${UPSTREAM}.orig.tar.xz" +PREFIX="${PACKAGE}-${UPSTREAM}/" + +if ! git -C "$TOP" cat-file -e "${REF}^{commit}" 2>/dev/null; then + echo "mk-orig-tarball: invalid git ref: $REF" >&2 + exit 1 +fi + +ensure_dirs + +git -C "$TOP" archive --format=tar --prefix="$PREFIX" "$REF" \ + -- . ':(exclude)debian' | xz -c > "$OUTPUT" + +echo "mk-orig-tarball: wrote $OUTPUT" diff --git a/debian/mmdebstrap-configure-apt.sh b/debian/mmdebstrap-configure-apt.sh new file mode 100755 index 000000000..2a76aedd4 --- /dev/null +++ b/debian/mmdebstrap-configure-apt.sh @@ -0,0 +1,32 @@ +#!/bin/sh +# Configure Pi-aligned apt sources inside an mmdebstrap rootfs. +# +# Usage: mmdebstrap-configure-apt.sh +set -eu + +ROOT=${1:?root directory required} +ARCH=${2:?arch required} + +TOP=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) + +if [ -f "$TOP/debian/release.conf" ]; then + # shellcheck disable=SC1091 + . "$TOP/debian/release.conf" +fi + +. "$TOP/debian/mirrors.sh" + +rm -f "$ROOT/etc/apt/sources.list.d/bootstrap.sources" +case "$ARCH" in + arm64|armhf) + # archive.raspberrypi.com keys still use SHA1 certifications; on + # sequoia-based apt (e.g. trixie) sqv rejects those after 2026-02-01 + # unless policy is extended. Harmless no-op on gpgv-based apt (bookworm). + install -d "$ROOT/etc/crypto-policies/back-ends" + cat >"$ROOT/etc/crypto-policies/back-ends/apt-sequoia.config" <<'EOF' +[hash_algorithms] +sha1.second_preimage_resistance = 2030-01-01 +EOF + ;; +esac +chroot_configure_apt_dir "$ARCH" "$ROOT" diff --git a/debian/mmdebstrap-ensure-chroot.sh b/debian/mmdebstrap-ensure-chroot.sh new file mode 100755 index 000000000..75f4f6a04 --- /dev/null +++ b/debian/mmdebstrap-ensure-chroot.sh @@ -0,0 +1,173 @@ +#!/bin/sh +# Create a rootless cross-build chroot with mmdebstrap (no sudo required). +# +# Bootstrap writes a tarball in unshare mode, then extracts it as the current +# user so the tree is always removable without sudo. +# +# Usage: +# debian/mmdebstrap-ensure-chroot.sh +# +# Chroots live under CHROOT_ROOT (default: .debian/chroots/). +set -eu + +TOP=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) + +if [ -f "$TOP/debian/release.conf" ]; then + # shellcheck disable=SC1091 + . "$TOP/debian/release.conf" +fi + +. "$TOP/debian/lib.sh" + +ARCH="${1:?usage: mmdebstrap-ensure-chroot.sh }" +NAME=$(chroot_name "$ARCH") +ROOT=$(chroot_mmdebstrap_root "$ARCH") +MODE=$(mmdebstrap_run_mode) +_TAR="${ROOT}.bootstrap.tar" +_OWNER_UID=$(id -u) +_OWNER_GID=$(id -g) + +if chroot_mmdebstrap_ok "$ARCH"; then + echo "mmdebstrap-ensure: $NAME already exists at $ROOT" + exit 0 +fi + +if [ -d "$ROOT" ] || [ -f "$_TAR" ]; then + echo "mmdebstrap-ensure: removing incomplete $NAME..." + chroot_rm_mmdebstrap "$ARCH" || true + rm -f "$_TAR" +fi + +if ! command -v mmdebstrap >/dev/null 2>&1; then + echo "mmdebstrap-ensure: install mmdebstrap (apt install mmdebstrap)" >&2 + exit 1 +fi + +if [ "$ARCH" != "$HOST_ARCH" ]; then + for _pkg in qemu-user-static binfmt-support; do + if ! dpkg -s "$_pkg" >/dev/null 2>&1; then + echo "mmdebstrap-ensure: install $_pkg on the host for $ARCH builds" >&2 + exit 1 + fi + done +fi + +ensure_chroot_dirs + +case "$ARCH" in + armhf) _COMPONENTS=main,contrib,non-free,rpi ;; + arm64|amd64) _COMPONENTS=main,contrib,non-free,non-free-firmware ;; + *) + echo "mmdebstrap-ensure: unsupported arch: $ARCH" >&2 + exit 1 + ;; +esac + +# apt is included explicitly: --variant=minbase resolves to mmdebstrap's +# 'required' set (?priority(required)), and Raspbian bookworm ships apt as +# Priority: important (Debian bookworm and Raspbian trixie both mark it +# required), so minbase alone leaves the armhf chroot without apt-get. +_INCLUDE=apt,git,ca-certificates +if [ "$ARCH" != "$HOST_ARCH" ]; then + _INCLUDE="$_INCLUDE,qemu-user-static,binfmt-support" +fi + +_SKIP_QEMU= +if [ "$ARCH" != "$HOST_ARCH" ] && ! command -v arch-test >/dev/null 2>&1; then + _SKIP_QEMU=check/qemu +fi + +. "$TOP/debian/mirrors.sh" + +_KEYRING=$(mmdebstrap_stage_keyring "$ARCH") || exit 1 +_KEYRING_STAGE=$(dirname "$_KEYRING") +_KEYRING_CACHE_ABS=$(keyring_cache_dir) +_HOOK_ROOT=$(mmdebstrap_stage_hook_tree) || exit 1 +_BOOTSTRAP_URI=$(mmdebstrap_bootstrap_uri "$ARCH") || exit 1 + +_SETUP_HOOK=$(mktemp "${TMPDIR:-/tmp}/rpi-imager-mmdebstrap-setup.XXXXXX") +_CUSTOMIZE_HOOK=$(mktemp "${TMPDIR:-/tmp}/rpi-imager-mmdebstrap-customize.XXXXXX") +trap 'rm -rf "$_KEYRING_STAGE" "$_HOOK_ROOT" "$_SETUP_HOOK" "$_CUSTOMIZE_HOOK"' EXIT INT HUP TERM + +cat >"$_SETUP_HOOK" <"$_CUSTOMIZE_HOOK" <' resolves that path inside the chroot, where it does not exist. +# Copy the tree in, run it, then remove it. +cp -a '$_HOOK_ROOT/debian' "\$root/tmp/rpi-imager-hooks" +chroot "\$root" sh /tmp/rpi-imager-hooks/chroot-apt-install.sh $(tr '\n' ' ' <"$TOP/debian/chroot-packages") +rm -rf "\$root/tmp/rpi-imager-hooks" +touch "\$root/.rpi-imager-chroot-ok" +EOF +chmod 0755 "$_CUSTOMIZE_HOOK" + +echo "mmdebstrap-ensure: creating $NAME (mode=$MODE, tar -> $ROOT)..." +echo "mmdebstrap-ensure: bootstrap keyring: $_KEYRING" +echo "mmdebstrap-ensure: bootstrap mirror: $_BOOTSTRAP_URI" + +_cleanup_partial() { + if [ -f "$_TAR" ]; then + rm -f "$_TAR" + fi + if [ -d "$ROOT" ] && ! chroot_mmdebstrap_ok "$ARCH"; then + echo "mmdebstrap-ensure: cleaning up failed $NAME at $ROOT..." >&2 + chroot_rm_mmdebstrap "$ARCH" || true + fi +} +trap _cleanup_partial EXIT INT HUP TERM + +# setup-hook copies the keyring into \$1 and writes Signed-By there; hooks and +# keyrings must live under /tmp because unshare subuids cannot read \$HOME. +# shellcheck disable=SC2086 +mmdebstrap --mode="$MODE" --format=tar --variant=minbase \ + --arch="$ARCH" \ + --keyring="$_KEYRING" \ + --skip=check/signed-by \ + --setup-hook="$_SETUP_HOOK \"\$1\"" \ + --aptopt='APT::Sandbox::User "root"' \ + --components="$_COMPONENTS" \ + --include="$_INCLUDE" \ + ${_SKIP_QEMU:+--skip="$_SKIP_QEMU"} \ + --customize-hook="$_CUSTOMIZE_HOOK \"\$1\"" \ + "$CHROOT_DIST" "$_TAR" "$_BOOTSTRAP_URI" + +rm -rf "$_KEYRING_STAGE" "$_HOOK_ROOT" "$_SETUP_HOOK" "$_CUSTOMIZE_HOOK" +trap - EXIT INT HUP TERM + +rm -rf "$ROOT" +mkdir -p "$ROOT" +_tar_extract_flags="--no-same-owner --no-same-permissions" +if tar --help 2>&1 | grep -q 'no-same-devices'; then + _tar_extract_flags="$_tar_extract_flags --no-same-devices" +else + _tar_extract_flags="$_tar_extract_flags --exclude=./dev/*" +fi +# shellcheck disable=SC2086 +tar $_tar_extract_flags -xf "$_TAR" -C "$ROOT" +rm -f "$_TAR" +chown -R "$_OWNER_UID:$_OWNER_GID" "$ROOT" + +trap - EXIT INT HUP TERM +echo "mmdebstrap-ensure: $NAME ready at $ROOT" diff --git a/debian/mmdebstrap-setup-hook.sh b/debian/mmdebstrap-setup-hook.sh new file mode 100755 index 000000000..47235f75b --- /dev/null +++ b/debian/mmdebstrap-setup-hook.sh @@ -0,0 +1,79 @@ +#!/bin/sh +# mmdebstrap --setup-hook: install archive keyring + apt sources inside $1. +# +# Apt during bootstrap uses Dir=$1; Signed-By must reference keyrings under +# that root, not host paths under $HOME or /tmp. +# +# Usage (from mmdebstrap): +# sh debian/mmdebstrap-setup-hook.sh +set -eu + +ROOT=${1:?rootdir required} +ARCH=${2:?arch required} +KEY_SRC=${3:?host keyring file required} + +TOP=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) + +if [ -f "$TOP/debian/release.conf" ]; then + # shellcheck disable=SC1091 + . "$TOP/debian/release.conf" +fi + +CHROOT_DIST=${CHROOT_DIST:-bookworm} +DEBIAN_MIRROR=${DEBIAN_MIRROR:-http://deb.debian.org/debian} +RASPBIAN_MIRROR=${RASPBIAN_MIRROR:-http://raspbian.raspberrypi.com/raspbian} + +case "$ARCH" in + armhf) + _key_name=raspbian-archive-keyring.gpg + _key_dest="$ROOT/usr/share/keyrings/$_key_name" + _key_signed_by="$_key_dest" + _mirror=${RASPBIAN_MIRROR%/} + case "$_mirror" in + */raspbian) ;; + *) _mirror="${_mirror}/raspbian" ;; + esac + install -d "$ROOT/usr/share/keyrings" "$ROOT/etc/apt/sources.list.d" + install -m 0644 "$KEY_SRC" "$_key_dest" + cat >"$ROOT/etc/apt/sources.list" <"$ROOT/etc/apt/sources.list.d/bootstrap.sources" <"$ROOT/etc/apt/sources.list" <"$ROOT/etc/apt/sources.list.d/bootstrap.sources" <&2 + exit 1 + ;; +esac diff --git a/debian/qt-resolve.sh b/debian/qt-resolve.sh new file mode 100755 index 000000000..9491f8be2 --- /dev/null +++ b/debian/qt-resolve.sh @@ -0,0 +1,115 @@ +#!/bin/sh +# Shared Qt6 discovery for release AppImage builds. +# +# Vendored Qt in QT_CACHE is preferred; system Qt6 (qmake6) is a fallback, +# especially for armhf desktop where building Qt may be skipped or fail. + +# shellcheck disable=SC2034 +QT_RESOLVE_SOURCE= + +qt6_qmake() { + if [ -n "${QT_RESOLVE_QMAKE:-}" ] && [ -x "$QT_RESOLVE_QMAKE" ]; then + printf '%s\n' "$QT_RESOLVE_QMAKE" + return 0 + fi + if command -v qmake6 >/dev/null 2>&1; then + command -v qmake6 + return 0 + fi + if command -v qmake >/dev/null 2>&1; then + _ver=$(qmake -query QT_VERSION 2>/dev/null) || return 1 + case "$_ver" in 6.*) command -v qmake; return 0 ;; esac + fi + return 1 +} + +qt6_version_ok() { + _ver=$1 + [ -n "$_ver" ] && [ "$_ver" = "$QT_VERSION" ] +} + +qt6_query() { + _qmake=$1 + _key=$2 + "$_qmake" -query "$_key" 2>/dev/null +} + +# Resolve desktop Qt directory for an AppImage image arch (x86_64, aarch64, armhf). +# Sets QT_RESOLVE_DIR and QT_RESOLVE_SOURCE (cache|env|system). +qt_resolve_desktop_dir() { + _image_arch=$1 + _qt_dir="" + _source="" + + case "$_image_arch" in + x86_64) _deb_arch=amd64; _gcc=gcc_64 ;; + aarch64) _deb_arch=arm64; _gcc=gcc_arm64 ;; + armhf|armv6l|armv7l) _deb_arch=armhf; _gcc=gcc_arm32 ;; + *) + echo "qt-resolve: unsupported image arch: $_image_arch" >&2 + return 1 + ;; + esac + + if [ -n "${QT_ROOT_ARG:-}" ]; then + _qt_dir=$QT_ROOT_ARG + _source=arg + elif [ -n "${Qt6_ROOT:-}" ]; then + _qt_dir=$Qt6_ROOT + _source=env + else + if [ -n "${QT_CACHE:-}" ] && [ -d "$QT_CACHE/$_deb_arch" ]; then + _tree=$(find -L "$QT_CACHE/$_deb_arch" -maxdepth 1 -type d -name "6.*" | sort -V | tail -n 1) + if [ -n "$_tree" ] && [ -d "$_tree/$_gcc" ]; then + _qt_dir="$_tree/$_gcc" + _source=cache + fi + fi + if [ -z "$_qt_dir" ] && [ -d /opt/Qt ]; then + _tree=$(find -L /opt/Qt -maxdepth 1 -type d -name "6.*" | sort -V | tail -n 1) + if [ -n "$_tree" ] && [ -d "$_tree/$_gcc" ]; then + _qt_dir="$_tree/$_gcc" + _source=opt + fi + fi + fi + + if [ -z "$_qt_dir" ]; then + _qmake=$(qt6_qmake) || return 1 + _prefix=$(qt6_query "$_qmake" QT_INSTALL_PREFIX) || return 1 + _ver=$(qt6_query "$_qmake" QT_VERSION) || return 1 + # Must satisfy the app minimum (src/CMakeLists.txt: find_package(Qt6 6.9)). + if command -v qt_version_ge_min >/dev/null 2>&1; then + qt_version_ge_min "$_ver" || return 1 + else + case "$_ver" in 6.*) ;; *) return 1 ;; esac + fi + _qt_dir=$_prefix + _source=system + fi + + if [ -f "$_qt_dir/bin/qmake" ]; then + : + elif qt6_qmake >/dev/null 2>&1; then + : + else + return 1 + fi + + QT_RESOLVE_DIR=$_qt_dir + QT_RESOLVE_SOURCE=$_source + printf '%s\n' "$_qt_dir" +} + +qt_desktop_build_required() { + _arch=$1 + case "$_arch" in + armhf) + case "${QT_DESKTOP_BUILD:-try}" in + try|optional|best-effort) return 1 ;; + *) return 0 ;; + esac + ;; + *) return 0 ;; + esac +} diff --git a/debian/release.conf.example b/debian/release.conf.example new file mode 100644 index 000000000..f3dfb1021 --- /dev/null +++ b/debian/release.conf.example @@ -0,0 +1,61 @@ +# Copy to debian/release.conf and adjust for your machine. +# Paths may be absolute or relative to the repository root. +# +# Where .dsc, .tar.xz and .deb files are written. +OUTPUT_DIR=out/debian + +# Per-arch AppImage cache used by stage-appimages.sh and binary builds. +APPIMAGE_ROOT=.debian/appimages + +# Per-arch Qt cache (desktop + CLI trees). Built automatically on first use. +QT_CACHE=.debian/qt +# The Qt version is selected by QT_VERSION_DEFAULT in qt/qt-build-common.sh. +# Uncomment only to override it for this tree. +# QT_VERSION= + +# Qt build policy: auto (default, build on cache miss), cached, or always. +# QT_BUILD=auto +# armhf desktop Qt: try (default) builds vendored Qt but accepts system qmake6 on failure. +# QT_DESKTOP_BUILD=try +# QT_DESKTOP_BUILD=required + +# Rootless cross-build chroot settings (mmdebstrap under CHROOT_ROOT, no sudo). +# bookworm is the baseline for every arch (portable glibc 2.36; new enough for the +# Qt we ship and liburing 2.2+). +CHROOT_DIST=bookworm +CHROOT_SUFFIX=rpi-imager +# CHROOT_ROOT=.debian/chroots +# KEYRING_CACHE=.debian/archive-keyrings # fetched from archives for mmdebstrap --keyring +# MMDEBSTRAP_MODE=auto # auto uses unshare when not root +# Chroot apt repository cascade (see debian/chroot-apt/preferences-*.pref): +# armhf — raspbian > rpi > debian +# arm64 — rpi > debian +# amd64 — debian only +# CHROOT_ARCHES="arm64 amd64 armhf" +# DEBIAN_MIRROR=http://deb.debian.org/debian +# RASPBIAN_MIRROR=http://raspbian.raspberrypi.com/raspbian +# RPI_MIRROR=http://archive.raspberrypi.com/debian + +# Every arch, the host included, builds in its bookworm mmdebstrap chroot. +# There is no host-native build path: see debian/lib.sh for why. +# auto (default): create missing chroots via mmdebstrap (rootless, no sudo) +# CHROOT_AUTO_CREATE=auto +# CHROOT_AUTO_CREATE=0 + +# Default profiles for desktop/cli packages. +DEB_BUILD_PROFILES=desktop cli + +# AppImage build policy: always (default) or cached (sync staged files only). +APPIMAGE_BUILD=always + +# Cross-arch AppImage builders (SSH fallback when no local chroot exists). +# APPIMAGE_REMOTE_arm64=tdewey@pi5 +# APPIMAGE_REMOTE_DIR_arm64=/home/tdewey/rpi-imager +# Or combined: +# APPIMAGE_REMOTE_amd64=builder@amd64-vm:/home/builder/rpi-imager + +# Architectures for: debian/release.sh repo +RELEASE_ARCHES="amd64 arm64 armhf" + +# Optional upload target for: debian/release.sh repo +# DPUT_HOST=pi-internal diff --git a/debian/release.sh b/debian/release.sh new file mode 100755 index 000000000..a4a1344ab --- /dev/null +++ b/debian/release.sh @@ -0,0 +1,230 @@ +#!/bin/sh +# Unified Debian release workflow for rpi-imager. +# +# Usage: +# debian/release.sh status +# debian/release.sh source [git-ref] +# debian/release.sh appimages [--use-cache] +# debian/release.sh arch [--use-cache] +# debian/release.sh repo +# +# Configuration (first match wins): +# 1. Environment variables +# 2. debian/release.conf (copy from debian/release.conf.example) +# +# Every architecture, the host included, builds in its rootless mmdebstrap +# chroot; there is no host-native build path (see debian/lib.sh). +set -eu + +TOP=$(cd "$(dirname "$0")/.." && pwd) +cd "$TOP" +. "$TOP/debian/lib.sh" + +RELEASE_ARCHES=${RELEASE_ARCHES:-$HOST_ARCH} + +usage() { + cat < [args] + +Configuration file: $TOP/debian/release.conf (optional) + OUTPUT_DIR=$OUTPUT_DIR + APPIMAGE_ROOT=$APPIMAGE_ROOT + QT_CACHE=$QT_CACHE + QT_VERSION=$QT_VERSION + APPIMAGE_BUILD=$APPIMAGE_BUILD + QT_BUILD=$QT_BUILD + +Commands: + status show version, artifacts, AppImages, builder + source [git-ref] build quilt source package (default: HEAD) + appimages build AppImages and sync to cache + --use-cache to skip build and sync staged files only + binary build binary packages (mmdebstrap chroot or local) + embedded build embedded (linuxfb) .deb (chroot for foreign arch) + arch [--use-cache] appimages + binary + repo source, then arch for each arch in RELEASE_ARCHES + +AppImage build (APPIMAGE_BUILD=$APPIMAGE_BUILD): + always rebuild before every sync (default) + cached sync only; use with pre-staged AppImages + +Cross-arch AppImages (foreign host): rootless mmdebstrap chroot, or +APPIMAGE_REMOTE_ as fallback: + APPIMAGE_REMOTE_arm64=user@pi5:/home/tdewey/rpi-imager + +Examples: + cp debian/release.conf.example debian/release.conf + debian/release.sh status + debian/release.sh arch arm64 + debian/release.sh appimages amd64 --use-cache + RELEASE_ARCHES="arm64 amd64" debian/release.sh repo +EOF +} + +artifact_ok() { + appimage_cache_ok "$1" +} + +should_build_appimages() { + _explicit=${1:-} + + case "$_explicit" in + --use-cache) return 1 ;; + esac + + case "$APPIMAGE_BUILD" in + cached|use-cache|never) return 1 ;; + esac + return 0 +} + +cmd_status() { + echo "version: $VERSION (upstream $UPSTREAM)" + echo "host arch: $HOST_ARCH" + echo "tree: $TOP" + echo "output dir: $OUTPUT_DIR" + echo "appimage root: $APPIMAGE_ROOT" + echo "qt cache: $QT_CACHE (Qt $QT_VERSION, QT_BUILD=$QT_BUILD)" + echo "builder: rootless mmdebstrap chroot (all arches)" + echo "appimage build: $APPIMAGE_BUILD" + if git -C "$TOP" diff --quiet && git -C "$TOP" diff --cached --quiet; then + echo "git: clean" + else + echo "git: dirty (commit before tagging a release)" + fi + if [ -f "$TOP/debian/release.conf" ]; then + echo "config: debian/release.conf" + else + echo "config: defaults (copy debian/release.conf.example to customise)" + fi + echo + echo "source artifacts:" + for f in \ + "${PACKAGE}_${UPSTREAM}.orig.tar.xz" \ + "${PACKAGE}_${VERSION}.debian.tar.xz" \ + "${PACKAGE}_${VERSION}.dsc" \ + "${PACKAGE}_${VERSION}_source.changes" + do + if [ -f "$OUTPUT_DIR/$f" ]; then + echo " ok $OUTPUT_DIR/$f" + else + echo " -- $OUTPUT_DIR/$f" + fi + done + echo + echo "AppImage cache:" + for arch in arm64 amd64 armhf; do + dir="$APPIMAGE_ROOT/$arch" + if appimage_cache_ok "$arch"; then + if appimage_cache_arch_ok "$arch"; then + echo " ok $arch ($dir)" + else + echo " !! $arch (WRONG runtime arch — rebuild; check: file $dir/*.AppImage)" + fi + elif [ -d "$dir" ]; then + echo " ?? $arch (incomplete, $dir)" + else + echo " -- $arch" + fi + if appimage_remote_host "$arch" 2>/dev/null; then + echo " remote: $APPIMAGE_REMOTE_HOST ($APPIMAGE_REMOTE_DIR)" + fi + done + echo + echo "Qt cache (desktop + cli, version $QT_VERSION):" + for arch in arm64 amd64 armhf; do + if qt_cache_ok "$arch"; then + echo " ok $arch ($QT_CACHE/$arch/$QT_VERSION)" + elif [ -d "$QT_CACHE/$arch" ]; then + echo " ?? $arch (incomplete, $QT_CACHE/$arch)" + else + echo " -- $arch" + fi + done + echo + echo "build chroots:" + for arch in $CHROOT_ARCHES; do + if [ "$(chroot_backend_for "$arch")" = mmdebstrap ]; then + echo " ok $(chroot_name "$arch") (mmdebstrap, $(chroot_mmdebstrap_root "$arch"))" + else + echo " -- $(chroot_name "$arch")" + fi + done + if ! command -v mmdebstrap >/dev/null 2>&1; then + echo " note: apt install mmdebstrap for rootless chroots" + fi +} + +cmd_source() { + ref="${1:-HEAD}" + sh "$TOP/debian/build-source.sh" "$ref" +} + +cmd_appimages() { + arch=$1 + shift + explicit=${1:-} + + if should_build_appimages "$explicit"; then + bash "$TOP/debian/build-appimages.sh" "$arch" + fi + + "$TOP/debian/sync-appimages.sh" "$arch" +} + +cmd_binary() { + arch=$1 + echo "release: building $arch in its chroot" + sh "$TOP/debian/build-binary-chroot.sh" "$arch" +} + +cmd_embedded() { + arch=$1 + sh "$TOP/debian/build-embedded.sh" "$arch" +} + +cmd_arch() { + arch=$1 + shift + cmd_appimages "$arch" "$@" + cmd_binary "$arch" +} + +cmd_repo() { + _missing=$(missing_release_chroots) + if [ -n "$_missing" ]; then + # shellcheck disable=SC2086 + ensure_release_chroots $_missing || exit 1 + fi + cmd_source + # shellcheck disable=SC2046 + for arch in $(release_arch_order); do + cmd_arch "$arch" + done + if [ -n "$DPUT_HOST" ]; then + for changes in "$OUTPUT_DIR/${PACKAGE}_${VERSION}"*.changes; do + [ -f "$changes" ] || continue + echo "release: dput $DPUT_HOST $changes" + dput "$DPUT_HOST" "$changes" + done + fi +} + +command=${1:-} +shift || true + +case "$command" in + status) cmd_status ;; + source) cmd_source "$@" ;; + appimages) cmd_appimages "${1:?arch required}" "${2:-}" ;; + binary) cmd_binary "${1:?arch required}" ;; + embedded) cmd_embedded "${1:?arch required}" ;; + arch) cmd_arch "${1:?arch required}" "${2:-}" ;; + repo) cmd_repo ;; + -h|--help|help|"") usage ;; + *) + echo "release: unknown command: $command" >&2 + usage >&2 + exit 1 + ;; +esac diff --git a/debian/rpi-imager.install b/debian/rpi-imager.install index ac0f3e87c..b1b00a78f 100755 --- a/debian/rpi-imager.install +++ b/debian/rpi-imager.install @@ -3,6 +3,7 @@ # Install AppImage directly [linux-amd64] rpi-imager-x86_64.AppImage => /usr/bin/rpi-imager [linux-arm64] rpi-imager-aarch64.AppImage => /usr/bin/rpi-imager +[linux-armhf] rpi-imager-armhf.AppImage => /usr/bin/rpi-imager # Install PolicyKit policy for privilege escalation debian/com.raspberrypi.rpi-imager.policy /usr/share/polkit-1/actions/ diff --git a/debian/rules b/debian/rules index 5e9b7c75b..556a062f7 100755 --- a/debian/rules +++ b/debian/rules @@ -1,53 +1,55 @@ #!/usr/bin/make -f export DH_VERBOSE = 1 - -# Reproducible source tarballs: dpkg-source clamps mtimes newer than this -# value, but older checkout timestamps still differ across builders unless -# we normalise tracked files here (clean runs immediately before dpkg-source). export SOURCE_DATE_EPOCH := $(shell dpkg-parsechangelog -STimestamp 2>/dev/null) -# Get the list of packages that should be built based on build profiles PACKAGES := $(shell dh_listpackages) VERSION_DIR := $(CURDIR)/debian/tmp/version CONFIGURE_VERSIONED := $(CURDIR)/src/cmake/ConfigureVersionedFile.cmake GENERATE_VERSION := $(CURDIR)/src/cmake/GenerateVersion.cmake +STAGE_APPIMAGES := $(CURDIR)/debian/stage-appimages.sh %: dh $@ -.PHONY: override_dh_strip -override_dh_strip: - dh_strip --exclude=rpi-imager +.PHONY: clean prune-build-tree +clean: + @$(MAKE) -f debian/rules prune-build-tree -.PHONY: override_dh_clean -override_dh_clean: - dh_clean +prune-build-tree: rm -rf debian/rpi-imager debian/rpi-imager-cli debian/rpi-imager-embedded \ debian/rpi-imager-amd64 debian/.debhelper debian/tmp \ debian/com.raspberrypi.rpi-imager.metainfo.xml rm -f debian/files debian/*.substvars debian/*.debhelper.log \ debian/debhelper-build-stamp debian/*debhelper* + rm -rf build build-* obj-* rm -f rpi-imager-embedded*.deb qt/language_filters.json \ src/.qmlls.ini rm -f doc/performance/*.html - @if [ -n "$(SOURCE_DATE_EPOCH)" ]; then \ - git ls-files -z | xargs -0 -r touch -hcd @$(SOURCE_DATE_EPOCH); \ - git ls-files | awk -F/ '{ \ - p=""; \ - for (i=1; i1 ? p "/" : "") $$i; \ - dirs[p]; \ - } \ - } END { for (d in dirs) print d }' | sort -u | \ - while IFS= read -r d; do \ - touch -hcd @$(SOURCE_DATE_EPOCH) "$$d"; \ - done; \ - touch -hcd @$(SOURCE_DATE_EPOCH) .; \ - fi + @parent="$$(cd .. && pwd)"; \ + rm -rf "$$parent"/$$(basename "$(CURDIR)").orig.* + +.PHONY: override_dh_strip +override_dh_strip: + dh_strip --exclude=rpi-imager + +# Desktop/cli packages ship self-contained AppImages; skip shlib scanning +# (also avoids needing cross-arch objdump when packaging amd64 on arm64). +.PHONY: override_dh_makeshlibs override_dh_shlibdeps +override_dh_makeshlibs: +ifneq (,$(filter rpi-imager-embedded,$(PACKAGES))) + dh_makeshlibs +endif + +override_dh_shlibdeps: +ifneq (,$(filter rpi-imager-embedded,$(PACKAGES))) + dh_shlibdeps +endif + +.PHONY: override_dh_clean +override_dh_clean: + @$(MAKE) -f debian/rules prune-build-tree -# Desktop .deb installs pre-built AppImages and does not run a full CMake -# build, but still needs metainfo.xml configured with the git version. .PHONY: override_dh_auto_build override_dh_auto_build: ifneq (,$(filter rpi-imager,$(PACKAGES))) @@ -59,10 +61,12 @@ ifneq (,$(filter rpi-imager,$(PACKAGES))) -DINPUT=$(CURDIR)/debian/com.raspberrypi.rpi-imager.metainfo.xml.in \ -DOUTPUT=$(CURDIR)/debian/com.raspberrypi.rpi-imager.metainfo.xml \ -P $(CONFIGURE_VERSIONED) + $(STAGE_APPIMAGES) desktop +endif +ifneq (,$(filter rpi-imager-cli,$(PACKAGES))) + $(STAGE_APPIMAGES) cli endif -# For the embedded package, install the vendored directory tree produced by -# create-embedded.sh into the package staging area. override_dh_auto_install: dh_auto_install ifneq (,$(filter rpi-imager-embedded,$(PACKAGES))) diff --git a/debian/source/format b/debian/source/format index 89ae9db8f..163aaf8d8 100644 --- a/debian/source/format +++ b/debian/source/format @@ -1 +1 @@ -3.0 (native) +3.0 (quilt) diff --git a/debian/source/options b/debian/source/options deleted file mode 100644 index 4abcd6222..000000000 --- a/debian/source/options +++ /dev/null @@ -1,35 +0,0 @@ -tar-ignore = "*.AppImage" -tar-ignore = "ci" -tar-ignore = ".git" -tar-ignore = "build" -tar-ignore = "squashfs-root" -tar-ignore = "AppDir-*" -tar-ignore = "appdir-tools" -tar-ignore = "build-*" -tar-ignore = "qt-build*" -tar-ignore = "qt-src*" -tar-ignore = "qt/icu" -tar-ignore = "qt/language_filters.json" -tar-ignore = "appimage-tools" -tar-ignore = ".DS_Store" -tar-ignore = "*.DS_Store" -tar-ignore = "obj-*" -tar-ignore = "*.qmlls.ini" -tar-ignore = "compile_commands.json" -tar-ignore = "debroot-embedded-*" -tar-ignore = ".venv" -tar-ignore = "doc/performance/.venv" -tar-ignore = "doc/performance/*.html" -tar-ignore = "*.deb" -tar-ignore = "screenshot.png" -tar-ignore = "debian/tmp" -tar-ignore = "debian/rpi-imager" -tar-ignore = "debian/rpi-imager-cli" -tar-ignore = "debian/rpi-imager-embedded" -tar-ignore = "debian/rpi-imager-amd64" -tar-ignore = "debian/.debhelper" -tar-ignore = "debian/*debhelper*" -tar-ignore = "debian/files" -tar-ignore = "debian/*.substvars" -tar-ignore = "debian/debhelper-build-stamp" -tar-ignore = "debian/com.raspberrypi.rpi-imager.metainfo.xml" diff --git a/debian/stage-appimages.sh b/debian/stage-appimages.sh new file mode 100755 index 000000000..833424248 --- /dev/null +++ b/debian/stage-appimages.sh @@ -0,0 +1,141 @@ +#!/bin/sh +# Stage pre-built AppImages for desktop/cli .deb profiles. +# +# AppImages are not part of the quilt orig tarball. Before a binary build, +# place artifacts in APPIMAGE_DIR (default: package root) under the names +# expected by debian/*.install, or use the Raspberry_Pi_Imager-* naming +# from create-appimage.sh / create-appimage-cli.sh. +# +# Usage: +# APPIMAGE_DIR=../artifacts debian/stage-appimages.sh desktop +# APPIMAGE_DIR=../artifacts debian/stage-appimages.sh cli +# debian/stage-appimages.sh all +set -eu + +TOP="$(cd "$(dirname "$0")/.." && pwd)" +APPIMAGE_DIR="${APPIMAGE_DIR:-$TOP}" +PROFILE="${1:-all}" + +deb_arch_to_image_arch() { + case "$1" in + amd64) echo x86_64 ;; + arm64) echo aarch64 ;; + armhf) echo armhf ;; + x86_64|aarch64|armhf) echo "$1" ;; + *) + echo "stage-appimages: unknown Debian arch: $1" >&2 + return 1 + ;; + esac +} + +usage() { + echo "Usage: $0 [desktop|cli|all]" >&2 + exit 1 +} + +target_deb_arch() { + if [ -n "${DEB_BUILD_ARCH:-}" ]; then + printf '%s\n' "$DEB_BUILD_ARCH" + return 0 + fi + if _arch=$(dpkg-architecture -qDEB_BUILD_ARCH 2>/dev/null); then + printf '%s\n' "$_arch" + return 0 + fi + dpkg-architecture -qDEB_HOST_ARCH +} + +resolve_artifact() { + canonical="$1" + glob_pattern="$2" + + # Prefer explicit build outputs over pre-existing packaging symlinks. + # shellcheck disable=SC2086 + matches=$(ls -1t $APPIMAGE_DIR/$glob_pattern 2>/dev/null || true) + count=0 + match="" + for candidate in $matches; do + if [ "$count" -eq 0 ]; then + match=$candidate + fi + count=$((count + 1)) + done + + if [ "$count" -ge 1 ]; then + if [ "$count" -gt 1 ]; then + echo "stage-appimages: multiple matches for $glob_pattern; using newest: $match" >&2 + fi + printf '%s\n' "$match" + return 0 + fi + + if [ -f "$APPIMAGE_DIR/$canonical" ]; then + printf '%s\n' "$APPIMAGE_DIR/$canonical" + return 0 + fi + + if [ -L "$APPIMAGE_DIR/$canonical" ]; then + target=$(readlink "$APPIMAGE_DIR/$canonical") + case "$target" in + /*) resolved=$target ;; + *) resolved="$APPIMAGE_DIR/$target" ;; + esac + if [ -f "$resolved" ]; then + printf '%s\n' "$resolved" + return 0 + fi + fi + + echo "stage-appimages: missing $canonical (tried $APPIMAGE_DIR/$glob_pattern)" >&2 + return 1 +} + +link_artifact() { + dest_name="$1" + src="$2" + dest="$TOP/$dest_name" + + if [ -e "$dest" ]; then + current=$(readlink -f "$dest" 2>/dev/null || true) + new=$(readlink -f "$src" 2>/dev/null || true) + if [ -n "$current" ] && [ "$current" = "$new" ]; then + echo "stage-appimages: $dest_name already staged" + return 0 + fi + fi + + rm -f "$dest" + if [ "$(dirname "$src")" = "$TOP" ]; then + ln -s "$(basename "$src")" "$dest" + else + ln -s "$src" "$dest" + fi + echo "stage-appimages: $dest_name -> $src" +} + +stage_desktop() { + img_arch=$(deb_arch_to_image_arch "$(target_deb_arch)") + canonical="rpi-imager-${img_arch}.AppImage" + src=$(resolve_artifact "$canonical" "Raspberry_Pi_Imager-*-desktop-${img_arch}.AppImage") + link_artifact "$canonical" "$src" +} + +stage_cli() { + img_arch=$(deb_arch_to_image_arch "$(target_deb_arch)") + canonical="rpi-imager-cli-${img_arch}.AppImage" + src=$(resolve_artifact "$canonical" "Raspberry_Pi_Imager-*-cli-${img_arch}.AppImage") + link_artifact "$canonical" "$src" +} + +case "$PROFILE" in + desktop) stage_desktop ;; + cli) stage_cli ;; + all) + stage_desktop + stage_cli + ;; + *) + usage + ;; +esac diff --git a/debian/sync-appimages.sh b/debian/sync-appimages.sh new file mode 100755 index 000000000..81b4c57b6 --- /dev/null +++ b/debian/sync-appimages.sh @@ -0,0 +1,64 @@ +#!/bin/sh +# Sync locally built AppImages into the configured per-arch cache. +# +# Usage: +# debian/sync-appimages.sh +# +# SRC_DIR defaults to the repository root. Override APPIMAGE_ROOT in +# debian/release.conf. +set -eu + +TOP=$(cd "$(dirname "$0")/.." && pwd) +. "$TOP/debian/lib.sh" + +ARCH="${1:?usage: sync-appimages.sh }" +SRC_DIR=${SRC_DIR:-$TOP} +DEST="$APPIMAGE_ROOT/$ARCH" + +pick_in_dir() { + _dir=$1 + _pattern=$2 + # shellcheck disable=SC2086 + ls -1t $_dir/$_pattern 2>/dev/null | head -1 +} + +IMG_ARCH=$(deb_to_image_arch "$ARCH") +ensure_dirs +install -d "$DEST" + +copy_one() { + label=$1 + src=$2 + dest_name=$3 + + if [ -z "$src" ] || [ ! -e "$src" ]; then + echo "sync-appimages: missing $label for $ARCH (tried $SRC_DIR and $DEST)" >&2 + return 1 + fi + + if [ "$(dirname "$src")" = "$DEST" ]; then + ln -sfn "$(basename "$src")" "$DEST/$dest_name" + echo "sync-appimages: $dest_name -> $DEST/$(basename "$src")" + return 0 + fi + + cp -f "$src" "$DEST/$(basename "$src")" + ln -sfn "$(basename "$src")" "$DEST/$dest_name" + echo "sync-appimages: $dest_name -> $DEST/$(basename "$src")" +} + +DESKTOP=$(pick_in_dir "$SRC_DIR" "Raspberry_Pi_Imager-*-desktop-${IMG_ARCH}.AppImage") +CLI=$(pick_in_dir "$SRC_DIR" "Raspberry_Pi_Imager-*-cli-${IMG_ARCH}.AppImage") + +# Fall back to files already placed in the cache directory. +if [ -z "$DESKTOP" ]; then + DESKTOP=$(pick_in_dir "$DEST" "Raspberry_Pi_Imager-*-desktop-${IMG_ARCH}.AppImage") +fi +if [ -z "$CLI" ]; then + CLI=$(pick_in_dir "$DEST" "Raspberry_Pi_Imager-*-cli-${IMG_ARCH}.AppImage") +fi + +copy_one desktop "$DESKTOP" "rpi-imager-${IMG_ARCH}.AppImage" +copy_one cli "$CLI" "rpi-imager-cli-${IMG_ARCH}.AppImage" + +echo "sync-appimages: ready in $DEST" diff --git a/doc/linux-build.md b/doc/linux-build.md new file mode 100644 index 000000000..06f015af8 --- /dev/null +++ b/doc/linux-build.md @@ -0,0 +1,399 @@ +# Linux release build + +Every Linux artifact — the desktop and CLI AppImages, the `.deb` packages that +wrap them, and the embedded (netboot) package — is produced by one pipeline +driven from `debian/release.sh`. It builds each architecture inside its own +rootless `mmdebstrap` chroot, needs no `sudo`, and produces amd64, arm64 and +armhf output from a single machine of any of those three architectures. + +This document is the reference for that pipeline. For the app itself, see +[CONTRIBUTING.md](../CONTRIBUTING.md); for the Qt builds it drives, see +[qt/README-qt-build.md](../qt/README-qt-build.md). + +## The one rule + +**There is no host-native build path.** Every architecture, the host's own +included, builds in a Debian **bookworm** chroot. This is deliberate, and +`debian/lib.sh` states why: a package built against the host's libraries is not +the package it claims to be. Build on a newer glibc than bookworm and the +binaries will not start on bookworm at all; build on an older one and features +silently vanish — `liburing` < 2.2 loses `io_uring` — while the artifact still +gets labelled for bookworm. + +bookworm (glibc 2.36) is the baseline because it is new enough for the Qt version +we ship and `liburing` 2.2+, yet old enough that the resulting AppImages and +`.deb`s stay portable to current Debian, Ubuntu and Raspberry Pi OS releases. + +## Prerequisites + +On the build host: + +```sh +sudo apt install mmdebstrap dpkg-dev git curl file xz-utils +``` + +(`dput` as well, if you set `DPUT_HOST`. Everything else the build needs is +installed *inside* the chroot from `debian/chroot-packages`.) + +For any architecture that is not the host's, also: + +```sh +sudo apt install qemu-user-static binfmt-support +``` + +`debian/mmdebstrap-ensure-chroot.sh` refuses to bootstrap a foreign-arch chroot +without both. `arch-test` is optional — when absent, mmdebstrap's `check/qemu` +step is skipped rather than failed. + +Unprivileged user namespaces must be available (`MMDEBSTRAP_MODE=auto` picks +`unshare` whenever you are not root). Nothing in the pipeline calls `sudo` on +the host. + +Clone with full history and no `--depth 1`: AppImage version strings come from +`git describe --tags --always --dirty`, and `debian/fetch-vendor-deps.sh` +initialises the vendored third-party submodules under `src/dependencies/vendor/` +and verifies each is at its pinned tag. + +## Quick start + +```sh +cp debian/release.conf.example debian/release.conf # optional; edit to taste +debian/release.sh status # what exists, what doesn't +RELEASE_ARCHES="amd64 arm64 armhf" debian/release.sh repo +``` + +`status` is the first thing to run and the first thing to check when something +looks wrong. It prints the resolved version, every configured path, and the +state of each cache — chroots, Qt trees, staged AppImages — including whether a +staged AppImage's embedded runtime is the *right* architecture. + +`repo` creates any missing chroots, builds the source package, then builds +AppImages and `.deb`s for each architecture in `RELEASE_ARCHES`, host +architecture first. If `DPUT_HOST` is set it uploads each `.changes` at the end. + +## Building more than one architecture + +`repo` is the only command that takes more than one architecture, and it reads +them from `RELEASE_ARCHES` rather than from its arguments. Everything else — +`appimages`, `binary`, `arch`, `embedded` — is strictly one architecture per +invocation. + +**`RELEASE_ARCHES` defaults to the host architecture alone**, so a bare +`debian/release.sh repo` builds one architecture, not three. Set it explicitly. +Either inline, for a single run: + +```sh +RELEASE_ARCHES="amd64 arm64 armhf" debian/release.sh repo +``` + +or persistently, in `debian/release.conf`: + +```sh +RELEASE_ARCHES="amd64 arm64 armhf" +``` + +`debian/release.conf.example` already sets all three, so copying it gives you +multi-architecture builds by default — which is why the same `repo` command +behaves differently on a tree with a `release.conf` and one without. `status` +prints nothing about `RELEASE_ARCHES`; check the config or pass it explicitly if +you are unsure. + +The host architecture is always built first (`release_arch_order()` in +`debian/lib.sh`), so the run that is most likely to reveal a problem fails +fastest. Architectures are built sequentially, not in parallel, and each one +compiles Qt on a cache miss — expect the first full three-architecture run to +take hours, and subsequent ones to be much quicker. + +To build several architectures without the source package or the `dput` upload, +loop over `arch` yourself: + +```sh +for a in amd64 arm64 armhf; do debian/release.sh arch "$a" || break; done +``` + +## Commands + +| Command | What it does | +| --- | --- | +| `release.sh status` | Version, paths, and the state of every cache. Read this first. | +| `release.sh source [git-ref]` | Quilt source package (`.orig.tar.xz`, `.debian.tar.xz`, `.dsc`) from `git-ref` (default `HEAD`). | +| `release.sh appimages [--use-cache]` | Build desktop + CLI AppImages, then sync them into the per-arch cache. `--use-cache` syncs already-staged files without building. One arch. | +| `release.sh binary ` | Binary `.deb`s for `arch`, always inside that arch's chroot. One arch. | +| `release.sh embedded ` | The embedded (linuxfb) `.deb`. arm64 only. | +| `release.sh arch [--use-cache]` | `appimages` then `binary`. One arch. | +| `release.sh repo` | `source`, then `arch` for every entry in `RELEASE_ARCHES` (default: host arch only). The only multi-arch command — see above. | + +`repo` does **not** build the embedded package: it is not in the default +`DEB_BUILD_PROFILES` (`desktop cli`) and has its own command. Run +`debian/release.sh embedded arm64` separately. + +Lower-level scripts can be called directly and are useful when debugging one +stage: `mmdebstrap-ensure-chroot.sh`, `chroot-exec.sh`, `ensure-qt.sh`, +`build-appimages.sh`, `build-binary-chroot.sh`, `build-embedded.sh`, +`chroot-rm.sh`. + +## Where things live + +Everything the pipeline generates stays inside the working tree, under two +git-ignored directories: + +``` +.debian/ + chroots/bookworm--rpi-imager/ build chroots (mmdebstrap rootfs) + qt///gcc_*/ per-arch Qt trees + appimages// staged AppImages, per architecture + archive-keyrings/ archive signing keys for mmdebstrap +out/debian/ .dsc, .tar.xz, .deb, .changes, .buildinfo +``` + +All four paths are configurable — `CHROOT_ROOT`, `QT_CACHE`, `APPIMAGE_ROOT`, +`KEYRING_CACHE`, `OUTPUT_DIR` — via `debian/release.conf` or the environment. +Relative paths resolve against the repository root. + +## How a build runs + +### 1. Keyrings + +`debian/fetch-archive-keyrings.sh` populates `.debian/archive-keyrings/` with +the Debian, Raspbian and Raspberry Pi archive keys. Host copies from +`/usr/share/keyrings` are reused when present; otherwise the keyring `.deb` is +downloaded from the archive and unpacked, or the ASCII key is dearmoured. The +Debian keyring is additionally checked for the trixie signing key and refreshed +if stale. + +Keys are staged into `/tmp` before use, not read from the cache in place: +mmdebstrap's `unshare` mode runs apt and hooks as a subuid user that cannot +traverse a mode-700 `$HOME`. + +### 2. Chroot + +`debian/mmdebstrap-ensure-chroot.sh ` bootstraps +`.debian/chroots/bookworm--rpi-imager`. It writes a tarball in `unshare` +mode and then extracts it as your own user, so the tree is always removable +without `sudo`. A failed bootstrap cleans up after itself; a chroot is only +considered usable once `.rpi-imager-chroot-ok` exists. + +Two mmdebstrap hooks do the configuration: + +- **setup hook** (`mmdebstrap-setup-hook.sh`) installs the bootstrap keyring + and `sources.list.d/bootstrap.sources` *inside* the target root, because apt + runs with `Dir=$root` during bootstrap and `Signed-By` must point at a path + under that root. +- **customize hook** (`mmdebstrap-configure-apt.sh` → `mirrors.sh`) replaces the + bootstrap sources with the final per-arch repository cascade, then runs + `chroot-apt-install.sh` over `debian/chroot-packages`. + +The apt cascade, pinned via `debian/chroot-apt/preferences-*.pref`: + +| Arch | Repositories, highest priority first | +| --- | --- | +| armhf | raspbian.raspberrypi.com (700) > archive.raspberrypi.com (600) > Debian (500) | +| arm64 | archive.raspberrypi.com (600) > Debian (500) | +| amd64 | Debian only | + +Two details worth knowing. `apt` is named explicitly in `--include` because +`--variant=minbase` resolves to `?priority(required)` and Raspbian bookworm +ships apt as `Priority: important` — minbase alone leaves the armhf chroot with +no `apt-get`. And `chroot-apt-install.sh` tolerates apt exiting non-zero +(`man-db`'s postinst routinely fails in a user-namespace chroot) but then +verifies every requested package really is installed, so genuine failures still +stop the build. + +### 3. Qt + +`debian/ensure-qt.sh ` fills the per-arch Qt cache, building on a miss +(`QT_BUILD=auto`). Three variants exist, all under +`.debian/qt///`: + +| Variant | Directory | Built by | Notes | +| --- | --- | --- | --- | +| desktop | `gcc_64`, `gcc_arm64`, `gcc_arm32` | `qt/build-qt.sh` | Full Qt Quick UI | +| CLI | `gcc_*_cli` | `qt/build-qt-cli.sh` | `-no-gui -no-widgets -no-opengl -no-xcb` … | +| embedded | `gcc_*_embedded` | `qt/build-qt-embedded.sh` | `-no-opengl -no-dbus -qpa linuxfb` | + +The version itself is selected in exactly one place — `QT_VERSION_DEFAULT` in +`qt/qt-build-common.sh` — which `debian/lib.sh` reads to set `QT_VERSION`. Set +`QT_VERSION` in the environment or `release.conf` to override for a single run. + +Cache hits are decided by running the cached `qmake -query QT_VERSION` and +comparing against `QT_VERSION` — which is also why foreign-arch Qt can only be +validated from inside that arch's chroot. + +armhf desktop Qt is best-effort: `QT_DESKTOP_BUILD=try` (the default) attempts +the vendored build but accepts a system `qmake6` if it fails, and the armhf +desktop AppImage is allowed to fail without failing the run — the CLI AppImage +still gets built. Set `QT_DESKTOP_BUILD=required` to make it fatal. + +A host-arch Qt already installed under `/opt/Qt/` is symlinked into the +cache rather than rebuilt. + +### 4. AppImages — build in the chroot, pack on the host + +`debian/build-appimages.sh ` splits each AppImage into two stages, and +this split is the part most worth understanding: + +- **build** (`APPIMAGE_PACKAGING=build`, inside the target-arch chroot) compiles + the app, installs it into `AppDir-/`, deploys Qt, prunes, and + completes the library closure. +- **pack** (`APPIMAGE_PACKAGING=pack`, on the host) wraps that AppDir into an + AppImage with `appimagetool`. + +Packing runs on the host because `linuxdeploy` and `appimagetool` are themselves +AppImages and must match the machine executing them, not the target. Hence +`APPIMAGE_TOOL_ARCH=$HOST_ARCH` while `--arch` names the target. + +The cross-pack trap: **`appimagetool` silently embeds its own (host) runtime +when `ARCH` is all you pass.** `appimage_pack_with_tool()` therefore always +passes an explicit `--runtime-file`, fetched from the AppImage `type2-runtime` +project and cached in `appimage-tools/runtime-`. If the runtime cannot be +obtained for a cross-pack, the build fails rather than producing a mislabelled +AppImage. `release.sh status` re-checks staged AppImages with `file(1)` and +flags any whose ELF header is the wrong architecture. + +`linuxdeploy` has no armhf build, so armhf always goes through `appimagetool`. + +`debian/sync-appimages.sh` then copies the versioned +`Raspberry_Pi_Imager--{desktop,cli}-.AppImage` into +`.debian/appimages//` and points the stable `rpi-imager-.AppImage` +symlinks at them. + +### 5. Binary packages + +`debian/build-binary-chroot.sh ` stages the AppImages into the tree under +the names `debian/*.install` expects, then runs +`dpkg-buildpackage -b -a -P` inside the chroot. + +`debian/chroot-exec.sh` provides the chroot environment: it bind-mounts `$TOP`, +`$QT_CACHE` and `$APPIMAGE_ROOT`, rbind-mounts `/dev`, `/proc` and `/sys`, and +runs the command under `unshare --user --map-root-user` when not root, then +restores file ownership on the way out. + +One consequence is easy to trip over: `dpkg-buildpackage` writes its output to +`$TOP/..`, and only `$TOP` is bind-mounted — so the artifacts land inside the +chroot's rootfs, not on the host's parent directory. `build-binary-chroot.sh` +moves them out of `//` into `out/debian/` afterwards. + +`debian/rules` skips `dh_shlibdeps` and `dh_makeshlibs` for the desktop and CLI +packages: they ship self-contained AppImages, so there is nothing to scan, and +skipping it also avoids needing a cross-arch `objdump`. Their runtime +dependencies are therefore maintained by hand in `debian/control`. + +### The embedded package + +`debian/build-embedded.sh arm64` builds the dedicated `-no-opengl -no-dbus +-qpa linuxfb` Qt if missing, runs `create-embedded.sh` inside the arm64 chroot +to assemble the vendored `/opt` tree, and collects +`rpi-imager-embedded__arm64.deb` into `out/debian/`. + +The dedicated Qt is not optional: the netboot target image (pi-gen-micro) +carries no Mesa/GL and no X11 — far too large for a network-loaded image — so +the embedded Qt must not link `libEGL`/`libGL`/`libX11` at all. arm64 is the +only architecture the embedded installer targets. + +## What gets bundled, and what must not + +Two exclusion predicates in `debian/lib.sh` decide this, and they are not the +same list. + +`appimage_lib_excluded()` keeps glibc and the dynamic loader, the compiler +runtimes, the whole GPU stack, X11/Wayland/input, the font stack, and +`libsystemd`/`libdbus`/`libcap`/`libudev` **out** of the AppImage. Bundling any +of these couples the AppImage to the build host's C library, graphics stack or +session bus — bundled `libsystemd`/`libdbus` is what broke `lsblk` and udisks +integration in #1304 and #1577. Anything excluded here must be declared as a +`Depends` in `debian/control` instead. + +`embedded_lib_excluded()` is much narrower, because the embedded tree is +deliberately self-contained under `/opt`: only the C library, compiler runtime, +GPU stack and `libsystemd` stay external. + +`deploy_lib_closure_core()` then completes the transitive `DT_NEEDED` closure of +whatever survived. It reads ELF headers with `readelf` rather than shelling out +to `ldd`, so it works unchanged on a foreign-architecture tree, and it runs +*after* pruning so removed plugins do not drag their dependencies back in. An +unresolved soname that is neither excluded nor found is a hard error, with the +fix spelled out: add the providing package to `debian/chroot-packages`, or +exclude the soname and declare it in `debian/control`. + +`prune_qml_to_imports()` trims the deployed QML tree to the modules the UI +actually imports, and is shared by the AppImage and embedded paths so the two +cannot drift. Prune **modules**, not libraries: dropping a library whose plugin +is still deployed leaves the plugin unable to load. Re-derive the import list +with: + +```sh +grep -rhoE '^\s*import\s+[A-Za-z0-9_.]+' --include='*.qml' src/ | sort -u +``` + +## Configuration + +`debian/release.conf` (git-ignored; copy from `debian/release.conf.example`) or +the environment. Environment wins. + +| Variable | Default | Purpose | +| --- | --- | --- | +| `OUTPUT_DIR` | `out/debian` | Where `.dsc`/`.tar.xz`/`.deb` land | +| `APPIMAGE_ROOT` | `.debian/appimages` | Per-arch AppImage cache | +| `QT_CACHE` | `.debian/qt` | Per-arch Qt trees | +| `QT_VERSION` | `QT_VERSION_DEFAULT` from `qt/qt-build-common.sh` | Qt version to build and require | +| `QT_BUILD` | `auto` | `auto` build on miss, `cached` require, `always` rebuild | +| `QT_DESKTOP_BUILD` | `try` | armhf desktop Qt: `try` or `required` | +| `CHROOT_ROOT` | `.debian/chroots` | Chroot trees | +| `CHROOT_DIST` | `bookworm` | Chroot suite (see “The one rule”) | +| `CHROOT_SUFFIX` | `rpi-imager` | Chroot name suffix | +| `CHROOT_ARCHES` | `arm64 amd64 armhf` | Architectures chroots exist for | +| `CHROOT_AUTO_CREATE` | `auto` | `0` to require manual chroot setup | +| `MMDEBSTRAP_MODE` | `auto` | `unshare` when not root | +| `KEYRING_CACHE` | `.debian/archive-keyrings` | Archive keys for mmdebstrap | +| `DEBIAN_MIRROR` | `deb.debian.org/debian` | Debian mirror | +| `RASPBIAN_MIRROR` | `raspbian.raspberrypi.com/raspbian` | Raspbian mirror (armhf) | +| `RPI_MIRROR` | `archive.raspberrypi.com/debian` | Raspberry Pi archive | +| `DEB_BUILD_PROFILES` | `desktop cli` | Build profiles; `embedded` is separate | +| `APPIMAGE_BUILD` | `always` | `cached` to sync pre-staged AppImages only | +| `RELEASE_ARCHES` | host arch only | Architectures `release.sh repo` covers; `release.conf.example` sets all three | +| `DPUT_HOST` | unset | Upload target for `release.sh repo` | +| `APPIMAGE_REMOTE_` | unset | SSH builder fallback, `user@host[:/path]` | + +`APPIMAGE_REMOTE_` is a fallback for architectures with no local chroot. +With rootless chroots working there is rarely a reason to use it. + +## Troubleshooting + +**`release.sh status` shows `!!` against a staged AppImage.** Its embedded +runtime is the wrong architecture. Confirm with `file .debian/appimages//*.AppImage` +and rebuild; do not ship it. Check that `appimage-tools/runtime-` exists +and is non-empty. + +**`mmdebstrap not installed`.** `sudo apt install mmdebstrap`. + +**`install qemu-user-static on the host for builds`.** Install +`qemu-user-static` and `binfmt-support`; foreign-arch chroots cannot be +bootstrapped without them. + +**Unresolved libraries at the end of a closure pass.** Read the list it prints. +Either the providing `-dev`/runtime package is missing from +`debian/chroot-packages`, or the soname genuinely belongs to the host and needs +adding to the exclusion predicate *and* to `debian/control`. + +**`$TOP not visible inside `.** The bind mounts did not take. Recreate +the chroot: `debian/chroot-rm.sh && debian/mmdebstrap-ensure-chroot.sh `. + +**A chroot will not delete.** Use `debian/chroot-rm.sh` rather than `rm -rf`; it +escalates through mmdebstrap teardown, `setpriv` per owning uid, +`unshare --map-root-user` and `fakeroot` to deal with subuid-owned files, and +tells you what to ask an admin for if all of those fail. + +**`ensure-qt: Qt must be built inside the chroot`.** You invoked +`ensure-qt.sh` directly for a foreign architecture. Go through +`debian/build-appimages.sh `, which enters the chroot first. + +## Cleaning up + +```sh +debian/chroot-rm.sh # one chroot +debian/chroot-rm.sh --all # every chroot and the keyring cache +rm -rf out .debian/appimages # artifacts and staged AppImages +``` + +The Qt cache under `.debian/qt/` is the expensive one; keep it unless +`QT_VERSION` changes. diff --git a/qt/README-qt-build-armhf.md b/qt/README-qt-build-armhf.md index a2fd492a7..b80cc8ac3 100644 --- a/qt/README-qt-build-armhf.md +++ b/qt/README-qt-build-armhf.md @@ -1,15 +1,26 @@ # Qt Cross-Compilation for armhf (ARM Hard-Float) -This document describes how to cross-compile Qt for the armhf architecture, enabling cross-compilation of Raspberry Pi Imager for older Raspberry Pi models and other armhf-based systems. +This document describes how to cross-compile Qt for the armhf architecture, enabling cross-compilation of Raspberry Pi Imager for Raspberry Pi 1, Pi 2, and other armhf-based systems. + +> **Which Qt version?** Set in one place only: `QT_VERSION_DEFAULT` in +> [qt-build-common.sh](./qt-build-common.sh). Every `build-qt*.sh` script reads +> it, as does the Linux release pipeline. `` below stands for whatever +> that says; deliberately not repeated here, so it cannot go stale. + +> **Note:** for release builds you do not need this script. The Linux release +> pipeline builds armhf Qt inside an armhf chroot via `debian/ensure-qt.sh` — +> see [../doc/linux-build.md](../doc/linux-build.md). ## Overview -The `build-qt-armhf.sh` script provides automated cross-compilation of Qt 6.9.3 for the armhf (ARM hard-float) architecture. This is particularly useful for: +The `build-qt-armhf.sh` script provides automated cross-compilation of Qt for the armhf (ARM hard-float) architecture. This is particularly useful for: -- Raspberry Pi 2, 3, and 4 (32-bit mode) -- Other ARM-based systems using hard-float ABI +- Raspberry Pi 1 and Raspberry Pi 2 (32-bit Raspberry Pi OS, Debian `armhf`) +- Other ARM-based systems using the hard-float ABI - Cross-compiling applications from x86_64 Linux hosts +On 32-bit Raspberry Pi OS, `uname -m` reports `armv6l` (Pi 1) or `armv7l` (Pi 2); the Debian architecture for both is **armhf**. + ## Prerequisites ### Host System Requirements @@ -140,8 +151,8 @@ cd /path/to/rpi-imager/qt ./build-qt-armhf.sh [options] Options: - --version=VERSION Qt version to build (default: 6.9.3) - --prefix=PREFIX Installation prefix (default: /opt/Qt/6.9.3) + --version=VERSION Qt version to build (default: QT_VERSION_DEFAULT from qt-build-common.sh) + --prefix=PREFIX Installation prefix (default: /opt/Qt/{VERSION}) --cores=CORES Number of CPU cores to use (default: all available) --sysroot=PATH Path to armhf sysroot (REQUIRED) --toolchain=PREFIX Cross-compiler prefix (default: arm-linux-gnueabihf-) @@ -191,7 +202,7 @@ After successful compilation, the script creates environment setup files: ```bash # Source the environment -source /opt/Qt/6.9.3/gcc_armhf/bin/qtenv-armhf.sh +source /opt/Qt//gcc_armhf/bin/qtenv-armhf.sh # Verify Qt installation qmake -query @@ -201,7 +212,7 @@ qmake -query ```bash # Use the generated toolchain file -cmake -DCMAKE_TOOLCHAIN_FILE=/opt/Qt/6.9.3/gcc_armhf/qt6-armhf-toolchain.cmake \ +cmake -DCMAKE_TOOLCHAIN_FILE=/opt/Qt//gcc_armhf/qt6-armhf-toolchain.cmake \ -DCMAKE_BUILD_TYPE=Release \ /path/to/your/project @@ -214,14 +225,14 @@ Once Qt is cross-compiled, you can build Raspberry Pi Imager: ```bash # Set up environment -source /opt/Qt/6.9.3/gcc_armhf/bin/qtenv-armhf.sh +source /opt/Qt//gcc_armhf/bin/qtenv-armhf.sh # Create build directory mkdir -p ~/rpi-imager-build-armhf cd ~/rpi-imager-build-armhf # Configure with CMake -cmake -DCMAKE_TOOLCHAIN_FILE=/opt/Qt/6.9.3/gcc_armhf/qt6-armhf-toolchain.cmake \ +cmake -DCMAKE_TOOLCHAIN_FILE=/opt/Qt//gcc_armhf/qt6-armhf-toolchain.cmake \ -DCMAKE_BUILD_TYPE=Release \ /path/to/rpi-imager/src @@ -271,7 +282,7 @@ make -j$(nproc) After successful compilation, the Qt installation will have this structure: ``` -/opt/Qt/6.9.3/gcc_armhf/ +/opt/Qt//gcc_armhf/ ├── bin/ # Qt tools and utilities │ ├── qmake # Qt build system │ ├── qtenv-armhf.sh # Environment setup script @@ -309,7 +320,7 @@ For automated builds, you can use the script in CI environments: - name: Build Raspberry Pi Imager run: | - source /home/runner/Qt/6.9.3/gcc_armhf/bin/qtenv-armhf.sh + source /home/runner/Qt//gcc_armhf/bin/qtenv-armhf.sh mkdir build && cd build cmake -DCMAKE_TOOLCHAIN_FILE=$CMAKE_TOOLCHAIN_FILE .. make -j$(nproc) diff --git a/qt/README-qt-build-cli.md b/qt/README-qt-build-cli.md index 1cfa2e562..cfe8de50b 100644 --- a/qt/README-qt-build-cli.md +++ b/qt/README-qt-build-cli.md @@ -2,6 +2,11 @@ This guide covers building a minimal Qt installation specifically for CLI-only applications like rpi-imager-cli. +> **Which Qt version?** Set in one place only: `QT_VERSION_DEFAULT` in +> [qt-build-common.sh](./qt-build-common.sh). Every `build-qt*.sh` script reads +> it, as does the Linux release pipeline. `` below stands for whatever +> that says; deliberately not repeated here, so it cannot go stale. + ## Overview The CLI-only Qt build is designed to be as minimal as possible, excluding all GUI, multimedia, and graphics components. This results in: @@ -13,9 +18,9 @@ The CLI-only Qt build is designed to be as minimal as possible, excluding all GU ```bash # Build minimal Qt for CLI applications -./build-qt-cli.sh --version=6.9.1 +./build-qt-cli.sh --version= -# This installs to /opt/Qt/6.9.1/gcc_64_cli (separate from GUI Qt) +# This installs to /opt/Qt//gcc_64_cli (separate from GUI Qt) ``` ## CLI-Specific Exclude Lists @@ -61,10 +66,10 @@ The CLI-only build includes only essential components: ```bash # Build CLI-optimized Qt -./build-qt-cli.sh --version=6.9.1 --cores=4 +./build-qt-cli.sh --version= --cores=4 # Use with rpi-imager CLI build -export Qt6_ROOT="/opt/Qt/6.9.1/gcc_64_cli" +export Qt6_ROOT="/opt/Qt//gcc_64_cli" mkdir build && cd build cmake ../src -DBUILD_CLI_ONLY=ON make -j$(nproc) diff --git a/qt/README-qt-build-macos.md b/qt/README-qt-build-macos.md index 575b450f1..ed392a67a 100644 --- a/qt/README-qt-build-macos.md +++ b/qt/README-qt-build-macos.md @@ -2,6 +2,11 @@ This document explains how to build Qt from source with minimal configuration for macOS using the `build-qt-macos.sh` script. +> **Which Qt version?** Set in one place only: `QT_VERSION_DEFAULT` in +> [qt-build-common.sh](./qt-build-common.sh). Every `build-qt*.sh` script reads +> it. `` below stands for whatever that says; deliberately not repeated +> here, so it cannot go stale. + ## Overview The `build-qt-macos.sh` script automates the process of: @@ -36,8 +41,8 @@ cd qt This will: -- Build Qt 6.9.3 -- Install it to `/opt/Qt/6.9.3/macos` (works for both Intel and Apple Silicon) +- Build the Qt version pinned in `qt-build-common.sh` (`QT_VERSION_DEFAULT`) +- Install it to `/opt/Qt//macos` (works for both Intel and Apple Silicon) - Use all available CPU cores - Apply macOS-specific optimizations - Exclude unnecessary modules and features for minimal footprint @@ -47,7 +52,7 @@ This will: The script supports the following options: ``` ---version=VERSION Qt version to build (default: 6.9.3) +--version=VERSION Qt version to build (default: QT_VERSION_DEFAULT from qt-build-common.sh) --prefix=PREFIX Installation prefix (default: /opt/Qt/{VERSION}) --cores=CORES Number of CPU cores to use (default: all) --no-clean Don't clean the build directory @@ -66,13 +71,13 @@ The script supports the following options: Build a specific Qt version: ```bash -./build-qt-macos.sh --version=6.8.0 +./build-qt-macos.sh --version= ``` Install to a custom location: ```bash -./build-qt-macos.sh --prefix=/Users/$(whoami)/Qt/6.9.3 +./build-qt-macos.sh --prefix=/Users/$(whoami)/Qt/ ``` Limit CPU usage (useful for background builds): @@ -90,7 +95,7 @@ Build single-architecture (host only): Run without sudo (requires pre-installed dependencies): ```bash -./build-qt-macos.sh --unprivileged --prefix=$HOME/Qt/6.9.3 +./build-qt-macos.sh --unprivileged --prefix=$HOME/Qt/ ``` ## Minimal Build Configuration @@ -132,7 +137,7 @@ By default, the script creates fat binaries containing both Intel and Apple Sili - **Intel optimizations**: `-march=x86-64-v2 -mtune=intel` for better x86_64 performance - **Apple Silicon optimizations**: `-march=armv8.4-a+crypto -mtune=apple-a14` for M-series chips -- **Installation path**: `/opt/Qt/6.11.1/macos` (same as single-architecture builds) +- **Installation path**: `/opt/Qt//macos` (same as single-architecture builds) - **CMake integration**: Toolchain file automatically sets `CMAKE_OSX_ARCHITECTURES="x86_64;arm64"` **When to use universal builds:** @@ -206,7 +211,7 @@ After building Qt, the script creates several helper files: To set up your environment for using the built Qt: ```bash -source /opt/Qt/6.9.3/macos/bin/qtenv.sh +source /opt/Qt//macos/bin/qtenv.sh ``` This sets the needed environment variables for Qt on macOS. @@ -216,7 +221,7 @@ This sets the needed environment variables for Qt on macOS. To use this Qt build with CMake projects (like rpi-imager): ```bash -cmake -DCMAKE_TOOLCHAIN_FILE=/opt/Qt/6.9.3/macos/qt6-toolchain.cmake \ +cmake -DCMAKE_TOOLCHAIN_FILE=/opt/Qt//macos/qt6-toolchain.cmake \ -S /path/to/source -B build ``` @@ -226,12 +231,12 @@ To build rpi-imager with your custom Qt: ```bash # Set up Qt environment -source /opt/Qt/6.9.3/macos/bin/qtenv.sh +source /opt/Qt//macos/bin/qtenv.sh # Configure and build rpi-imager cd /path/to/rpi-imager mkdir build && cd build -cmake -DCMAKE_TOOLCHAIN_FILE=/opt/Qt/6.9.3/macos/qt6-toolchain.cmake ../src +cmake -DCMAKE_TOOLCHAIN_FILE=/opt/Qt//macos/qt6-toolchain.cmake ../src make -j$(sysctl -n hw.ncpu) ``` ## Troubleshooting @@ -271,10 +276,10 @@ If CMake can't find your Qt installation: ```bash # Make sure Qt is in your path -export CMAKE_PREFIX_PATH="/opt/Qt/6.9.3/macos:$CMAKE_PREFIX_PATH" +export CMAKE_PREFIX_PATH="/opt/Qt//macos:$CMAKE_PREFIX_PATH" # Or use the toolchain file -cmake -DCMAKE_TOOLCHAIN_FILE=/opt/Qt/6.9.3/macos/qt6-toolchain.cmake ... +cmake -DCMAKE_TOOLCHAIN_FILE=/opt/Qt//macos/qt6-toolchain.cmake ... ``` ## Integration with rpi-imager Build System diff --git a/qt/README-qt-build.md b/qt/README-qt-build.md index 78c6d4535..fc048d143 100644 --- a/qt/README-qt-build.md +++ b/qt/README-qt-build.md @@ -2,6 +2,11 @@ This document explains how to build Qt from source with Debian-like configuration for Raspberry Pi OS using the `build-qt.sh` script. +> **Which Qt version?** Set in one place only: `QT_VERSION_DEFAULT` in +> [qt-build-common.sh](./qt-build-common.sh). Every `build-qt*.sh` script reads +> it, as does the Linux release pipeline. `` below stands for whatever +> that says; deliberately not repeated here, so it cannot go stale. + ## Overview The `build-qt.sh` script automates the process of: @@ -30,7 +35,7 @@ To build Qt with default options: ``` This will: -- Build Qt 6.9.1 +- Build the Qt version pinned in `qt-build-common.sh` (`QT_VERSION_DEFAULT`) - Install it to `/opt/qt6` - Use all available CPU cores - Configure for the Wayland desktop environment @@ -40,7 +45,7 @@ This will: The script supports the following options: ``` ---version=VERSION Qt version to build (default: 6.9.1) +--version=VERSION Qt version to build (default: QT_VERSION_DEFAULT from qt-build-common.sh) --prefix=PREFIX Installation prefix (default: /opt/qt6) --cores=CORES Number of CPU cores to use (default: all) --no-clean Don't clean the build directory @@ -53,7 +58,7 @@ The script supports the following options: Build a specific Qt version: ```bash -./build-qt.sh --version=6.9.1 +./build-qt.sh --version= ``` Install to a custom location: @@ -131,5 +136,5 @@ If you're building Qt specifically for the Raspberry Pi Imager: ```bash # Example: Add to create-appimage.sh -export Qt6_ROOT="/opt/Qt/6.9.1/gcc_arm64" +export Qt6_ROOT="/opt/Qt//gcc_arm64" ``` \ No newline at end of file diff --git a/qt/build-qt-cli.sh b/qt/build-qt-cli.sh index cca737b14..5c23095c6 100755 --- a/qt/build-qt-cli.sh +++ b/qt/build-qt-cli.sh @@ -61,7 +61,7 @@ if [ "$SKIP_DEPENDENCIES" -eq 0 ]; then install_linux_basic_deps sudo apt-get install -y \ - libfontconfig1-dev libfreetype6-dev libicu-dev \ + libfontconfig1-dev libfreetype6-dev \ libnss3-dev libssl-dev \ libdbus-1-dev libglib2.0-dev libsqlite3-dev \ libdouble-conversion-dev libpcre2-dev \ diff --git a/qt/build-qt-embedded.sh b/qt/build-qt-embedded.sh index b2d99ee77..dd6b91b20 100755 --- a/qt/build-qt-embedded.sh +++ b/qt/build-qt-embedded.sh @@ -62,7 +62,7 @@ if [ "$SKIP_DEPENDENCIES" -eq 0 ]; then echo "Installing Qt embedded dependencies..." sudo apt-get install -y \ libinput-dev libxkbcommon-dev \ - libfontconfig1-dev libfreetype6-dev libicu-dev \ + libfontconfig1-dev libfreetype6-dev \ libjpeg-dev libpng-dev zlib1g-dev \ libnss3-dev libssl-dev \ libdbus-1-dev libglib2.0-dev libsqlite3-dev \ @@ -81,91 +81,9 @@ download_qt_source # Clean build directory if requested clean_build_directory -# Build custom ICU if needed (installed to icu/install — isolated from host libicu-dev) -ICU_INSTALL="$BASE_DIR/icu/install" - -if [ -d "$ICU_INSTALL/lib" ] && ls "$ICU_INSTALL/lib"/libicuuc.so* >/dev/null 2>&1; then - echo "ICU already installed to $ICU_INSTALL" -else - echo "Building custom ICU..." - - ICU_VERSION=$(get_icu_version_for_qt "$QT_VERSION") - ICU_TAG=$(icu_version_to_tag "$ICU_VERSION") - ICU_DATA_ZIP=$(icu_version_to_data_package "$ICU_VERSION") - - echo "Using ICU version $ICU_VERSION (tag: $ICU_TAG) for Qt $QT_VERSION" - - cd "$BASE_DIR" - LANG_DIR="$PROJECT_ROOT/src/i18n" - - if [ ! -d "$BASE_DIR/icu" ]; then - git clone https://github.com/unicode-org/icu.git - fi - cd "$BASE_DIR/icu/icu4c/source" - - echo "Checking out ICU $ICU_TAG..." - git fetch --tags 2>/dev/null || true - git checkout "$ICU_TAG" 2>/dev/null || { - echo "Warning: Could not checkout $ICU_TAG, trying latest stable..." - git checkout "$(git describe --tags --abbrev=0)" 2>/dev/null || true - } - - if [ ! -d "data" ]; then - echo "Downloading ICU data package $ICU_DATA_ZIP..." - wget "https://github.com/unicode-org/icu/releases/download/$ICU_TAG/$ICU_DATA_ZIP" || { - echo "Warning: Could not download $ICU_DATA_ZIP, using default data" - } - if [ -f "$ICU_DATA_ZIP" ]; then - unzip -q "$ICU_DATA_ZIP" - fi - fi - - if [ -d "$LANG_DIR" ]; then - cd "$LANG_DIR" - JSON_INCLUDELIST="" - for tsfile in rpi-imager_*.ts; do - lang=$(echo "$tsfile" | sed 's/rpi-imager_\([^.]*\)\.ts/\1/') - if [ -n "$lang" ] && [ "$lang" != "$tsfile" ]; then - if [ -z "$JSON_INCLUDELIST" ]; then - JSON_INCLUDELIST="\"$lang\"" - else - JSON_INCLUDELIST="$JSON_INCLUDELIST, \"$lang\"" - fi - fi - done - cd "$BASE_DIR" - - cat > "$BASE_DIR/language_filters.json" << EOF -{ -"localeFilter": { - "filterType": "language", - "includelist": [ - $JSON_INCLUDELIST - ] -}, -"featureFilters": { - "locales_tree": "exclude", - "brkitr_dictionaries": "exclude", - "translit": "exclude", - "region_tree": "exclude", - "lang_tree": "exclude", - "curr_tree": "exclude", - "coll_tree": "exclude", - "conversion_mappings": "exclude" -} -} -EOF - echo "Language filters: $JSON_INCLUDELIST" - cd "$BASE_DIR/icu/icu4c/source" - ICU_DATA_FILTER_FILE="$BASE_DIR/language_filters.json" ./runConfigureICU Linux --prefix="$ICU_INSTALL" - else - echo "Warning: Language directory not found at $LANG_DIR, building ICU without language filters" - ./runConfigureICU Linux --prefix="$ICU_INSTALL" - fi - - make -j"$CORES" - make install -fi +# ICU is disabled for the embedded build (icu is in features_exclude.embedded.list), +# so no custom ICU is built or linked -- rpi-imager does not use any ICU-backed +# Qt feature. This keeps the embedded image free of the ~35 MB ICU libraries. # Configure and build Qt cd "$BUILD_DIR" @@ -176,8 +94,13 @@ echo "Configuring Qt for embedded systems..." CONFIG_OPTS="$(get_base_config_opts) $(get_common_skip_opts)" CONFIG_OPTS="$CONFIG_OPTS $(get_build_type_opts)" -# Embedded platform-specific configuration -CONFIG_OPTS="$CONFIG_OPTS -no-opengl -qpa linuxfb" +# Embedded platform-specific configuration. +# -no-opengl: the netboot image has no Mesa (far too large); linuxfb renders in +# software, so Qt must not link libEGL/libGL/libX11. +# -no-dbus: the image has no session bus (SYSTEMD=0); the app is built without +# QtDBus for embedded, so building it into Qt would only add an unused +# libQt6DBus -> libdbus -> libsystemd chain. +CONFIG_OPTS="$CONFIG_OPTS -no-opengl -no-dbus -qpa linuxfb" # Apply embedded-specific exclusions apply_exclusions "$BASE_DIR/features_exclude.embedded.list" "$BASE_DIR/modules_exclude.embedded.list" @@ -186,31 +109,6 @@ CONFIG_OPTS="$CONFIG_OPTS $EXCLUSION_OPTS" # Add CMake-specific options CONFIG_OPTS="$CONFIG_OPTS $(get_cmake_opts)" -# Custom ICU: prepend -I so headers beat host /usr/include/unicode from libicu-dev -if [ -d "$ICU_INSTALL/include" ] && [ -d "$ICU_INSTALL/lib" ]; then - export CPPFLAGS="-I$ICU_INSTALL/include ${CPPFLAGS:-}" - export CFLAGS="-I$ICU_INSTALL/include ${CFLAGS:-}" - export CXXFLAGS="-I$ICU_INSTALL/include ${CXXFLAGS:-}" - export LDFLAGS="-L$ICU_INSTALL/lib -Wl,-rpath-link,$ICU_INSTALL/lib -Wl,-rpath,$ICU_INSTALL/lib ${LDFLAGS:-}" - export LD_LIBRARY_PATH="$ICU_INSTALL/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" - CONFIG_OPTS="$CONFIG_OPTS -DICU_ROOT=\"$ICU_INSTALL\"" - CONFIG_OPTS="$CONFIG_OPTS -DICU_INCLUDE_DIR=\"$ICU_INSTALL/include\"" - CONFIG_OPTS="$CONFIG_OPTS -DICU_I18N_LIBRARY_RELEASE=\"$ICU_INSTALL/lib/libicui18n.so\"" - CONFIG_OPTS="$CONFIG_OPTS -DICU_UC_LIBRARY_RELEASE=\"$ICU_INSTALL/lib/libicuuc.so\"" - CONFIG_OPTS="$CONFIG_OPTS -DICU_DATA_LIBRARY_RELEASE=\"$ICU_INSTALL/lib/libicudata.so\"" - CONFIG_OPTS="$CONFIG_OPTS -DCMAKE_BUILD_RPATH=\"$ICU_INSTALL/lib\"" - CONFIG_OPTS="$CONFIG_OPTS -DCMAKE_INSTALL_RPATH=\"$ICU_INSTALL/lib\"" - CONFIG_OPTS="$CONFIG_OPTS -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON" -fi - -embedded_icu_env_vars() { - cat << EOF - -# Custom ICU used by this Qt build (until libs are vendored into the image) -export LD_LIBRARY_PATH="$ICU_INSTALL/lib:\${LD_LIBRARY_PATH}" -EOF -} - # Run Qt configure run_qt_configure "$CONFIG_OPTS" @@ -221,7 +119,7 @@ build_qt install_qt # Create environment and toolchain files -create_qt_env_script "embedded" embedded_icu_env_vars +create_qt_env_script "embedded" create_cmake_toolchain "embedded" # Print final usage instructions diff --git a/qt/build-qt.sh b/qt/build-qt.sh index 824f319fc..b6f782b53 100755 --- a/qt/build-qt.sh +++ b/qt/build-qt.sh @@ -63,7 +63,7 @@ if [ "$SKIP_DEPENDENCIES" -eq 0 ]; then libxcb-sync-dev libxcb-util-dev libxcb-xfixes0-dev \ libxcb-xinerama0-dev libxcb-xkb-dev \ libinput-dev libxkbcommon-dev libxkbcommon-x11-dev \ - libfontconfig1-dev libfreetype6-dev libicu-dev \ + libfontconfig1-dev libfreetype6-dev \ libdrm-dev libegl1-mesa-dev libgbm-dev libgles2-mesa-dev \ libvulkan-dev \ libjpeg-dev libpng-dev zlib1g-dev \ diff --git a/qt/features_exclude.cli.list b/qt/features_exclude.cli.list index 4dbfd0966..8088f8bae 100644 --- a/qt/features_exclude.cli.list +++ b/qt/features_exclude.cli.list @@ -83,6 +83,10 @@ wasm-simd128 # Domain validation topleveldomain +# ICU: see features_exclude.list — unused by rpi-imager, and excluding it +# keeps the CLI package small (libicudata alone is ~30 MB). +icu + # Misc poll-exit-on-error socks5 diff --git a/qt/features_exclude.embedded.list b/qt/features_exclude.embedded.list index be008a2ad..eeb172334 100644 --- a/qt/features_exclude.embedded.list +++ b/qt/features_exclude.embedded.list @@ -1,3 +1,8 @@ +# ICU: unused by rpi-imager (UTF-8 only, no QCollator, timezones handled as +# IANA identifier strings). Excluding it keeps the embedded package free of the +# ~35 MB ICU libraries and matches the desktop/cli builds. +icu + androiddeployqt appstore-compliant calendarwidget diff --git a/qt/features_exclude.list b/qt/features_exclude.list index d3f1ab7cc..f22911908 100644 --- a/qt/features_exclude.list +++ b/qt/features_exclude.list @@ -51,3 +51,11 @@ wasm-jspi wasm-simd128 wayland-server windeployqt + +# ICU: Qt only uses it for extended QStringConverter codecs, QCollator +# collation and localised timezone display names. rpi-imager uses none of +# these (UTF-8 only, no collation, timezones handled as IANA ID strings), and +# Qt has non-ICU fallbacks for all three. Excluding it drops ~35 MB of +# libicudata/libicui18n/libicuuc per package and removes the per-release +# soname pin (libicu72 on bookworm, libicu76 on trixie). +icu diff --git a/qt/patches/qt6-qtestsupport-chrono-atomic-32bit.patch b/qt/patches/qt6-qtestsupport-chrono-atomic-32bit.patch new file mode 100644 index 000000000..af9fe7ab0 --- /dev/null +++ b/qt/patches/qt6-qtestsupport-chrono-atomic-32bit.patch @@ -0,0 +1,11 @@ +--- a/qtbase/src/corelib/kernel/qtestsupport_core.cpp ++++ b/qtbase/src/corelib/kernel/qtestsupport_core.cpp +@@ -10,6 +10,8 @@ using namespace std::chrono_literals; + + // Assert that this instantiation of std::atomic is always lock-free so we + // know that no code will execute on destruction. ++#if __SIZEOF_POINTER__ >= 8 + static_assert(std::atomic::is_always_lock_free); ++#endif + + QT_BEGIN_NAMESPACE diff --git a/qt/qt-build-common.sh b/qt/qt-build-common.sh index 7478f2d1a..85ec283c1 100755 --- a/qt/qt-build-common.sh +++ b/qt/qt-build-common.sh @@ -87,6 +87,7 @@ init_common_variables() { # Export for use in subprocesses export QT_VERSION QT_MAJOR_VERSION PREFIX CORES BUILD_TYPE BASE_DIR + export CMAKE_BUILD_PARALLEL_LEVEL="${CMAKE_BUILD_PARALLEL_LEVEL:-$CORES}" } # Function to parse common command line arguments @@ -264,6 +265,49 @@ download_qt_source() { fi cd "$_orig_dir" || return 1 + + apply_qt_patches +} + +# Apply distro patches to extracted Qt sources (idempotent). +apply_qt_patches() { + _src="$DOWNLOAD_DIR/qt-everywhere-src-$QT_VERSION" + _patch_dir="$BASE_DIR/patches" + _target="$_src/qtbase/src/corelib/kernel/qtestsupport_core.cpp" + + if [ ! -d "$_src" ]; then + return 0 + fi + + # 32-bit hosts: std::atomic is not always lock-free. + case "$ARCH" in + arm|armv6l|armv7l|armhf) + if [ -f "$_target" ] && ! grep -q '__SIZEOF_POINTER__ >= 8' "$_target"; then + echo "Applying Qt arm32 patch: qtestsupport chrono atomic static_assert" + if [ -f "$_patch_dir/qt6-qtestsupport-chrono-atomic-32bit.patch" ]; then + (cd "$_src" && patch -p1 -N -i "$_patch_dir/qt6-qtestsupport-chrono-atomic-32bit.patch") || true + else + sed -i '/static_assert(std::atomic::is_always_lock_free);/i\ +#if __SIZEOF_POINTER__ >= 8' "$_target" + sed -i '/static_assert(std::atomic::is_always_lock_free);/a\ +#endif' "$_target" + fi + fi + ;; + esac + + if [ -d "$_patch_dir" ]; then + for _patch in "$_patch_dir"/*.patch; do + [ -f "$_patch" ] || continue + case "$(basename "$_patch")" in + qt6-qtestsupport-chrono-atomic-32bit.patch) + continue + ;; + esac + echo "Applying Qt patch: $(basename "$_patch")" + (cd "$_src" && patch -p1 -N -i "$_patch") || true + done + fi } # Function to clean build directory if requested @@ -308,7 +352,13 @@ get_build_type_opts() { # build_examples: ON or OFF (default: OFF) get_cmake_opts() { build_examples="${1:-OFF}" - echo "-- -DQT_BUILD_TESTS=OFF -DQT_BUILD_EXAMPLES=$build_examples" + _link_extra="" + case "$ARCH" in + arm|armv6l|armv7l|armhf) + _link_extra="-DCMAKE_EXE_LINKER_FLAGS=-latomic -DCMAKE_SHARED_LINKER_FLAGS=-latomic" + ;; + esac + echo "-- -DQT_BUILD_TESTS=OFF -DQT_BUILD_EXAMPLES=$build_examples -DCMAKE_BUILD_PARALLEL_LEVEL=$CORES $_link_extra" } # Function to get common module skip options @@ -482,6 +532,7 @@ run_qt_configure() { config_opts="$1" echo "Configuring Qt with options: $config_opts" + echo "Note: configure is single-threaded; compile will use $CORES jobs (CMAKE_BUILD_PARALLEL_LEVEL=$CORES)" if [ "$VERBOSE_BUILD" -eq 1 ]; then eval "\"$DOWNLOAD_DIR/qt-everywhere-src-$QT_VERSION/configure\" $config_opts -verbose" diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index c81a303f2..b6a39d912 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -160,6 +160,7 @@ set(BUILD_STATIC_LIBS ON) set(BUILD_SHARED_LIBS OFF) include(FetchContent) +include(dependencies/fetch-vendor.cmake) # Bundled liblzma include(dependencies/xz.cmake) @@ -288,8 +289,12 @@ if(BUILD_CLI_ONLY) find_package(Qt6 6.9 COMPONENTS Core Network OPTIONAL_COMPONENTS LinguistTools) else() # Regular GUI build: need Core, Quick, Svg, Network, Gui - # DBus is required on Linux for suspend inhibitor and native file dialogs (portals) - if(UNIX AND NOT APPLE) + # DBus is used on Linux for the suspend inhibitor, portal file dialogs, + # NetworkManager WiFi credentials and the Pi Connect URI handler. The + # embedded (linuxfb netboot) target has no session bus and must not pull + # libdbus/libsystemd, so it is built without DBus; the QT_DBUS_LIB guards in + # the platform code then compile those paths out (see linux/Platform.cmake). + if(UNIX AND NOT APPLE AND NOT BUILD_EMBEDDED) find_package(Qt6 6.9 COMPONENTS Core Gui Quick Svg Network DBus OPTIONAL_COMPONENTS LinguistTools) else() find_package(Qt6 6.9 COMPONENTS Core Gui Quick Svg Network OPTIONAL_COMPONENTS LinguistTools) @@ -564,8 +569,9 @@ if(BUILD_CLI_ONLY) target_link_libraries(${PROJECT_NAME} PRIVATE ${QT}::Core ${QT}::Network ${CURL_LIBRARIES} ${LibArchive_LIBRARIES} ${ZSTD_LIBRARIES} ${LIBLZMA_LIBRARIES} ${ZLIB_LIBRARIES} ${YESCRYPT_LIBRARIES} ${LIBDRM_LIBRARIES} ${ATOMIC_LIBRARY} ${RPIBOOT_LIBS} ${EXTRALIBS}) else() # Regular GUI build: link all GUI components - # Add DBus on Linux for suspend inhibitor and native file dialogs (portals) - if(UNIX AND NOT APPLE) + # DBus on Linux for suspend inhibitor, portal file dialogs and URI handler; + # excluded for embedded (no session bus on the netboot image). + if(UNIX AND NOT APPLE AND NOT BUILD_EMBEDDED) target_link_libraries(${PROJECT_NAME} PRIVATE ${QT}::Core ${QT}::Gui ${QT}::Quick ${QT}::Svg ${QT}::Network ${QT}::DBus ${CURL_LIBRARIES} ${LibArchive_LIBRARIES} ${ZSTD_LIBRARIES} ${LIBLZMA_LIBRARIES} ${ZLIB_LIBRARIES} ${YESCRYPT_LIBRARIES} ${LIBDRM_LIBRARIES} ${ATOMIC_LIBRARY} ${RPIBOOT_LIBS} ${EXTRALIBS}) else() target_link_libraries(${PROJECT_NAME} PRIVATE ${QT}::Core ${QT}::Gui ${QT}::Quick ${QT}::Svg ${QT}::Network ${CURL_LIBRARIES} ${LibArchive_LIBRARIES} ${ZSTD_LIBRARIES} ${LIBLZMA_LIBRARIES} ${ZLIB_LIBRARIES} ${YESCRYPT_LIBRARIES} ${LIBDRM_LIBRARIES} ${ATOMIC_LIBRARY} ${RPIBOOT_LIBS} ${EXTRALIBS}) diff --git a/src/block_batcher.h b/src/block_batcher.h index 46bea7610..2cf63414f 100644 --- a/src/block_batcher.h +++ b/src/block_batcher.h @@ -2,7 +2,7 @@ * SPDX-License-Identifier: Apache-2.0 * Copyright (C) 2026 Lime Technology, Inc. * - * UNRAID: coalesces contiguous data blocks into larger writes. + * Coalesces contiguous data blocks into larger writes. * * libarchive hands extraction one data block at a time and archive_write_data_block() * charges meaningful per-call overhead, so many small blocks are far slower than a few diff --git a/src/cmake/GenerateVersion.cmake b/src/cmake/GenerateVersion.cmake index 29a346407..2c1eef452 100644 --- a/src/cmake/GenerateVersion.cmake +++ b/src/cmake/GenerateVersion.cmake @@ -24,16 +24,32 @@ if(GIT_EXECUTABLE) endif() endif() -# Parse numeric version components (e.g. v2.0.0-rc4-60-geac7c2f0 → 2, 0, 0) -string(REGEX MATCH "^v?([0-9]+)\\.([0-9]+)\\.([0-9]+)" _match "${VERSION_STR}") +# Parse numeric version components (e.g. v2.0.0-rc4-60-geac7c2f0 → 2, 0, 0, 0) +# +# The fourth component is optional and exists for hotfix tags such as v2.0.11.1. +# It must be carried through: Windows FILEVERSION and the assembly manifest are +# built from these numbers, so truncating to three would make a hotfix report +# the same file version as the release it fixes, and anything keyed on that +# (winget, SCCM, inventory tooling, crash triage) could not tell them apart. +string(REGEX MATCH "^v?([0-9]+)\\.([0-9]+)\\.([0-9]+)(\\.([0-9]+))?" _match "${VERSION_STR}") if(_match) set(VERSION_MAJOR "${CMAKE_MATCH_1}") set(VERSION_MINOR "${CMAKE_MATCH_2}") set(VERSION_PATCH "${CMAKE_MATCH_3}") + # CMAKE_MATCH_4 is the optional ".N" wrapper; CMAKE_MATCH_5 is the N itself. + # Quote the reference: a group that did not participate leaves CMAKE_MATCH_5 + # unset, and an unquoted `if(VAR STREQUAL "")` would then compare the literal + # name rather than the value, silently yielding an empty tweak. + if("${CMAKE_MATCH_5}" STREQUAL "") + set(VERSION_TWEAK 0) + else() + set(VERSION_TWEAK "${CMAKE_MATCH_5}") + endif() else() set(VERSION_MAJOR 0) set(VERSION_MINOR 0) set(VERSION_PATCH 0) + set(VERSION_TWEAK 0) endif() # Helper: write file only when content actually changed, so downstream build @@ -64,6 +80,7 @@ set(IMAGER_VERSION_STR \"${VERSION_STR}\") set(IMAGER_VERSION_MAJOR ${VERSION_MAJOR}) set(IMAGER_VERSION_MINOR ${VERSION_MINOR}) set(IMAGER_VERSION_PATCH ${VERSION_PATCH}) +set(IMAGER_VERSION_TWEAK ${VERSION_TWEAK}) ") write_if_changed("${OUTPUT_DIR}/imager_version_vars.cmake" "${CMAKE_CONTENT}") diff --git a/src/customization_generator.cpp b/src/customization_generator.cpp index 2fcfd5d59..bccd93cb9 100644 --- a/src/customization_generator.cpp +++ b/src/customization_generator.cpp @@ -113,6 +113,12 @@ QString CustomisationGenerator::pbkdf2(const QByteArray& password, const QByteAr return QPasswordDigestor::deriveKeyPbkdf2(QCryptographicHash::Sha1, password, ssid, 4096, 32).toHex(); } +QString CustomisationGenerator::stripLineTerminators(const QString& secret) { + QString cleaned = secret; + cleaned.removeIf([](QChar c) { return c == u'\r' || c == u'\n'; }); + return cleaned; +} + QString CustomisationGenerator::cryptPassword(const QByteArray& passwordInput, const QString& osReleaseDate) { // Strip CR/LF before hashing. Pasted clipboard content can carry a trailing // newline (single-line text fields do not sanitise pasted text), and PAM @@ -189,7 +195,13 @@ QString CustomisationGenerator::resolveWifiPskCrypt(const QVariantMap& settings, if (!crypted.isEmpty()) return crypted; - const QString plain = settings.value(QStringLiteral("wifiPassword")).toString(); + // Strip CR/LF before the length test, not just before derivation. A pasted + // trailing newline would otherwise push a 63-character passphrase to 64 and + // flip the branch below, passing the plaintext through as though it were a + // pre-computed PMK; a 7-character one would likewise be inflated to a valid + // passphrase length. See stripLineTerminators() and issue #1627. + const QString plain = stripLineTerminators( + settings.value(QStringLiteral("wifiPassword")).toString()); if (plain.isEmpty()) return {}; @@ -625,6 +637,17 @@ QByteArray CustomisationGenerator::generateCloudInitUserData(const QVariantMap& } if (passwordlessSudo) { push(QStringLiteral(" sudo: ALL=(ALL) NOPASSWD:ALL"), cloud); + } else { + // Must be explicit: singular `user:` is merged *over* the distro's + // default_user from /etc/cloud/cloud.cfg, which upstream populates + // with `sudo: ["ALL=(ALL) NOPASSWD:ALL"]` for every variant - + // including raspberry-pi-os. Omitting the key therefore inherits + // passwordless sudo (written to /etc/sudoers.d/90-cloud-init-users) + // regardless of the user's choice. `null` suppresses the rules + // while still inheriting the default user's groups, so the account + // keeps `sudo` group membership and is simply prompted for a + // password. `false` behaves the same but is deprecated since 22.2. + push(QStringLiteral(" sudo: null"), cloud); } push(QString(), cloud); // blank line } @@ -984,7 +1007,7 @@ QByteArray CustomisationGenerator::generateRpiPreseedToml(const QVariantMap& s, if (!psk.isEmpty()) { pskEncrypted = true; } else { - const QString legacy = s.value("wifiPassword").toString(); + const QString legacy = stripLineTerminators(s.value("wifiPassword").toString()); if (!legacy.isEmpty()) { psk = legacy; // A 64-hex value is a raw PMK; anything shorter is a passphrase diff --git a/src/customization_generator.h b/src/customization_generator.h index 03781707b..372788566 100644 --- a/src/customization_generator.h +++ b/src/customization_generator.h @@ -118,6 +118,16 @@ class CustomisationGenerator { */ static QString cryptPassword(const QByteArray& password, const QString& osReleaseDate); + /** + * @brief Remove CR/LF from a secret typed or pasted into a single-line field. + * + * The QByteArray equivalent is inlined in cryptPassword(). Line terminators + * can never form part of a usable password or Wi-Fi passphrase, but Qt's + * single-line fields insert pasted text verbatim, so clipboard content + * copied from a browser arrives with a trailing newline (issue #1627). + */ + static QString stripLineTerminators(const QString& secret); + /** * @brief Derive a WPA PSK from a passphrase (PBKDF2-HMAC-SHA1, 4096 iters). * diff --git a/src/dependencies/curl.cmake b/src/dependencies/curl.cmake index 09f05db6a..2de9fc723 100644 --- a/src/dependencies/curl.cmake +++ b/src/dependencies/curl.cmake @@ -2,10 +2,11 @@ set(CURL_VERSION "8.20.0") string(REPLACE "." "_" CURL_TAG ${CURL_VERSION}) -FetchContent_Declare(curl +rpi_imager_fetch_git_or_vendor(curl + VENDOR_DIR curl + VENDOR_MARKER CMakeLists.txt GIT_REPOSITORY https://github.com/curl/curl.git GIT_TAG curl-${CURL_TAG} - ${USE_OVERRIDE_FIND_PACKAGE} ) set(BUILD_CURL_EXE OFF CACHE BOOL "" FORCE) set(BUILD_LIBCURL_DOCS OFF CACHE BOOL "" FORCE) diff --git a/src/dependencies/fetch-vendor.cmake b/src/dependencies/fetch-vendor.cmake new file mode 100644 index 000000000..5344bc236 --- /dev/null +++ b/src/dependencies/fetch-vendor.cmake @@ -0,0 +1,112 @@ +# Prefer vendored git submodules under dependencies/vendor/ for offline builds. +# Falls back to FetchContent git clone when a vendor tree is missing. +# +# Usage: +# rpi_imager_fetch_git_or_vendor( +# VENDOR_DIR +# VENDOR_MARKER +# GIT_REPOSITORY +# GIT_TAG +# [SOURCE_SUBDIR ] +# [PATCH_COMMAND ] +# ) + +function(rpi_imager_fetch_git_or_vendor _name) + set(_options "") + set(_oneValueArgs VENDOR_DIR VENDOR_MARKER GIT_REPOSITORY GIT_TAG SOURCE_SUBDIR) + set(_multiValueArgs PATCH_COMMAND) + cmake_parse_arguments(_arg "${_options}" "${_oneValueArgs}" "${_multiValueArgs}" ${ARGN}) + + if(NOT _arg_VENDOR_DIR OR NOT _arg_VENDOR_MARKER OR NOT _arg_GIT_REPOSITORY OR NOT _arg_GIT_TAG) + message(FATAL_ERROR "rpi_imager_fetch_git_or_vendor: missing required arguments") + endif() + + set(_vendor "${CMAKE_CURRENT_LIST_DIR}/vendor/${_arg_VENDOR_DIR}") + set(_use_vendor FALSE) + if(EXISTS "${_vendor}/${_arg_VENDOR_MARKER}") + set(_use_vendor TRUE) + endif() + + set(_declare_args "") + if(_arg_SOURCE_SUBDIR) + list(APPEND _declare_args SOURCE_SUBDIR "${_arg_SOURCE_SUBDIR}") + endif() + if(_arg_PATCH_COMMAND) + list(APPEND _declare_args PATCH_COMMAND ${_arg_PATCH_COMMAND}) + endif() + + if(_use_vendor) + message(STATUS "Using vendored ${_name} from ${_vendor}") + FetchContent_Declare(${_name} + SOURCE_DIR "${_vendor}" + ${_declare_args} + ${USE_OVERRIDE_FIND_PACKAGE} + ) + else() + FetchContent_Declare(${_name} + GIT_REPOSITORY ${_arg_GIT_REPOSITORY} + GIT_TAG ${_arg_GIT_TAG} + ${_declare_args} + ${USE_OVERRIDE_FIND_PACKAGE} + ) + endif() +endfunction() + +function(rpi_imager_patch_libarchive source_dir) + if(NOT EXISTS "${source_dir}/CMakeLists.txt") + message(FATAL_ERROR "rpi_imager_patch_libarchive: missing ${source_dir}/CMakeLists.txt") + endif() + + # Replacement for libarchive's "Find Zstd" tail. We hand it a static zstd that + # is built in-tree, so the library file does not exist yet at configure time + # and the upstream CHECK_FUNCTION_EXISTS probes would fail; assume the + # features our pinned zstd provides instead. + # + # Bracket syntax keeps the ${ZSTD_*} references literal so that libarchive's + # own CMakeLists.txt expands them when it is configured. + set(_zstd_section [==[IF(ZSTD_FOUND) + SET(HAVE_ZSTD_H 1) + INCLUDE_DIRECTORIES(${ZSTD_INCLUDE_DIR}) + LIST(APPEND ADDITIONAL_LIBS ${ZSTD_LIBRARY}) + get_property(ZSTD_LIB_IS_CACHE CACHE ZSTD_LIBRARY PROPERTY TYPE) + get_property(ZSTD_INC_IS_CACHE CACHE ZSTD_INCLUDE_DIR PROPERTY TYPE) + if(ZSTD_LIB_IS_CACHE AND ZSTD_INC_IS_CACHE) + message(STATUS "Using provided ZSTD library: ${ZSTD_LIBRARY}") + SET(HAVE_LIBZSTD 1) + SET(HAVE_ZSTD_compressStream 1) + SET(HAVE_ZSTD_minCLevel 1) + else() + CMAKE_PUSH_CHECK_STATE() + SET(CMAKE_REQUIRED_LIBRARIES ${ZSTD_LIBRARY}) + SET(CMAKE_REQUIRED_INCLUDES ${ZSTD_INCLUDE_DIR}) + CHECK_FUNCTION_EXISTS(ZSTD_decompressStream HAVE_LIBZSTD) + CHECK_FUNCTION_EXISTS(ZSTD_compressStream HAVE_ZSTD_compressStream) + CHECK_FUNCTION_EXISTS(ZSTD_minCLevel HAVE_ZSTD_minCLevel) + CMAKE_POP_CHECK_STATE() + endif() +ENDIF(ZSTD_FOUND) +MARK_AS_ADVANCED(CLEAR ZSTD_INCLUDE_DIR)]==]) + + set(_end_marker "MARK_AS_ADVANCED(CLEAR ZSTD_INCLUDE_DIR)") + file(READ "${source_dir}/CMakeLists.txt" _content) + string(FIND "${_content}" "IF(ZSTD_FOUND)" _start) + string(FIND "${_content}" "${_end_marker}" _end) + if(_start LESS 0 OR _end LESS 0) + message(WARNING "Could not find ZSTD section in libarchive CMakeLists.txt") + return() + endif() + + string(LENGTH "${_end_marker}" _end_marker_length) + math(EXPR _end "${_end} + ${_end_marker_length}") + string(SUBSTRING "${_content}" 0 ${_start} _before) + string(SUBSTRING "${_content}" ${_end} -1 _after) + + set(_patched "${_before}${_zstd_section}${_after}") + if("${_patched}" STREQUAL "${_content}") + # Already patched; leave the mtime alone so we do not force a rebuild. + return() + endif() + + file(WRITE "${source_dir}/CMakeLists.txt" "${_patched}") + message(STATUS "Patched libarchive CMakeLists.txt for static ZSTD support") +endfunction() diff --git a/src/dependencies/libarchive.cmake b/src/dependencies/libarchive.cmake index 5ca3bb70a..033c7bfe7 100644 --- a/src/dependencies/libarchive.cmake +++ b/src/dependencies/libarchive.cmake @@ -27,59 +27,16 @@ set(ENABLE_ZSTD ON CACHE BOOL "") set(POSIX_REGEX_LIB "libc" CACHE STRING "" FORCE) set(LIBARCHIVE_VERSION "3.8.7") -# Patch to improve ZSTD static detection -set(LIBARCHIVE_PATCH_FILE "${CMAKE_CURRENT_BINARY_DIR}/libarchive_zstd_patch.cmake") -file(WRITE ${LIBARCHIVE_PATCH_FILE} " -# Read the original CMakeLists.txt -file(READ \"${CMAKE_CURRENT_SOURCE_DIR}/CMakeLists.txt\" CONTENT) - -# Find the start and end of the ZSTD section -string(FIND \"${CONTENT}\" \"IF(ZSTD_FOUND)\" ZSTD_START) -string(FIND \"${CONTENT}\" \"MARK_AS_ADVANCED(CLEAR ZSTD_INCLUDE_DIR)\" ZSTD_END) - -if(ZSTD_START GREATER -1 AND ZSTD_END GREATER -1) - math(EXPR ZSTD_END \"${ZSTD_END} + 40\") - string(SUBSTRING \"${CONTENT}\" 0 ${ZSTD_START} BEFORE_ZSTD) - string(SUBSTRING \"${CONTENT}\" ${ZSTD_END} -1 AFTER_ZSTD) - set(NEW_ZSTD_SECTION \"IF(ZSTD_FOUND) - SET(HAVE_ZSTD_H 1) - INCLUDE_DIRECTORIES(${ZSTD_INCLUDE_DIR}) - LIST(APPEND ADDITIONAL_LIBS ${ZSTD_LIBRARY}) - get_property(ZSTD_LIB_IS_CACHE CACHE ZSTD_LIBRARY PROPERTY TYPE) - get_property(ZSTD_INC_IS_CACHE CACHE ZSTD_INCLUDE_DIR PROPERTY TYPE) - if(ZSTD_LIB_IS_CACHE AND ZSTD_INC_IS_CACHE) - message(STATUS \\\"Using provided ZSTD library: ${ZSTD_LIBRARY}\\\") - SET(HAVE_LIBZSTD 1) - SET(HAVE_ZSTD_compressStream 1) - SET(HAVE_ZSTD_minCLevel 1) - else() - CMAKE_PUSH_CHECK_STATE() - SET(CMAKE_REQUIRED_LIBRARIES ${ZSTD_LIBRARY}) - SET(CMAKE_REQUIRED_INCLUDES ${ZSTD_INCLUDE_DIR}) - CHECK_FUNCTION_EXISTS(ZSTD_decompressStream HAVE_LIBZSTD) - CHECK_FUNCTION_EXISTS(ZSTD_compressStream HAVE_ZSTD_compressStream) - CHECK_FUNCTION_EXISTS(ZSTD_minCLevel HAVE_ZSTD_minCLevel) - CMAKE_POP_CHECK_STATE() - endif() -ENDIF(ZSTD_FOUND) -MARK_AS_ADVANCED(CLEAR ZSTD_INCLUDE_DIR)\") - set(NEW_CONTENT \"${BEFORE_ZSTD}${NEW_ZSTD_SECTION}\") - file(WRITE \"${CMAKE_CURRENT_SOURCE_DIR}/CMakeLists.txt\" \"${NEW_CONTENT}${AFTER_ZSTD}\") - message(STATUS \"Patched libarchive CMakeLists.txt for static ZSTD support\") -else() - message(WARNING \"Could not find ZSTD section in libarchive CMakeLists.txt\") -endif() -") - -FetchContent_Declare(libarchive +rpi_imager_fetch_git_or_vendor(libarchive + VENDOR_DIR libarchive + VENDOR_MARKER CMakeLists.txt GIT_REPOSITORY https://github.com/libarchive/libarchive.git GIT_TAG v${LIBARCHIVE_VERSION} - PATCH_COMMAND ${CMAKE_COMMAND} -P ${LIBARCHIVE_PATCH_FILE} - ${USE_OVERRIDE_FIND_PACKAGE} ) FetchContent_GetProperties(libarchive) if(NOT libarchive_POPULATED) FetchContent_Populate(libarchive) + rpi_imager_patch_libarchive("${libarchive_SOURCE_DIR}") add_subdirectory(${libarchive_SOURCE_DIR} ${libarchive_BINARY_DIR} EXCLUDE_FROM_ALL) endif() @@ -115,4 +72,3 @@ set(LibArchive_LIBRARIES archive_static CACHE FILEPATH "" FORCE) set(LibArchive_INCLUDE_DIR ${libarchive_SOURCE_DIR}/libarchive CACHE PATH "" FORCE) set(LibArchive_INCLUDE_DIRS ${libarchive_SOURCE_DIR}/libarchive CACHE PATH "" FORCE) - diff --git a/src/dependencies/libusb.cmake b/src/dependencies/libusb.cmake index 5e7fc6cfd..f2359b4fb 100644 --- a/src/dependencies/libusb.cmake +++ b/src/dependencies/libusb.cmake @@ -1,7 +1,9 @@ # Bundled libusb for rpiboot USB communication set(LIBUSB_VERSION "1.0.30") -FetchContent_Declare(libusb +rpi_imager_fetch_git_or_vendor(libusb + VENDOR_DIR libusb + VENDOR_MARKER libusb/libusb.h GIT_REPOSITORY https://github.com/libusb/libusb.git GIT_TAG v${LIBUSB_VERSION} ) diff --git a/src/dependencies/nghttp2.cmake b/src/dependencies/nghttp2.cmake index 4c6b80ea9..e15b4dace 100644 --- a/src/dependencies/nghttp2.cmake +++ b/src/dependencies/nghttp2.cmake @@ -1,10 +1,11 @@ # Remote nghttp2 set(NGHTTP2_VERSION "1.69.0") -FetchContent_Declare(nghttp2 +rpi_imager_fetch_git_or_vendor(nghttp2 + VENDOR_DIR nghttp2 + VENDOR_MARKER lib/CMakeLists.txt GIT_REPOSITORY https://github.com/nghttp2/nghttp2.git - GIT_TAG v${NGHTTP2_VERSION} - ${USE_OVERRIDE_FIND_PACKAGE} + GIT_TAG v${NGHTTP2_VERSION} ) set(BUILD_EXAMPLES OFF) set(ENABLE_LIB_ONLY ON) @@ -23,4 +24,3 @@ set(NGHTTP2_INCLUDE_DIR ${nghttp2_SOURCE_DIR}/lib CACHE PATH "" FORCE) set(NGHTTP2_INCLUDE_DIRS ${nghttp2_SOURCE_DIR}/lib CACHE PATH "" FORCE) set(NGHTTP2_FOUND true CACHE BOOL "" FORCE) - diff --git a/src/dependencies/vendor/curl b/src/dependencies/vendor/curl new file mode 160000 index 000000000..a05f34973 --- /dev/null +++ b/src/dependencies/vendor/curl @@ -0,0 +1 @@ +Subproject commit a05f34973e6c4bb629d018f7cb51487be1c904d8 diff --git a/src/dependencies/vendor/libarchive b/src/dependencies/vendor/libarchive new file mode 160000 index 000000000..ded82291a --- /dev/null +++ b/src/dependencies/vendor/libarchive @@ -0,0 +1 @@ +Subproject commit ded82291ab41d5e355831b96b0e1ff49e24d8939 diff --git a/src/dependencies/vendor/libusb b/src/dependencies/vendor/libusb new file mode 160000 index 000000000..87a55632d --- /dev/null +++ b/src/dependencies/vendor/libusb @@ -0,0 +1 @@ +Subproject commit 87a55632db62c9bdc58cd31d3ccfa673f1bb017f diff --git a/src/dependencies/vendor/nghttp2 b/src/dependencies/vendor/nghttp2 new file mode 160000 index 000000000..68cb6900f --- /dev/null +++ b/src/dependencies/vendor/nghttp2 @@ -0,0 +1 @@ +Subproject commit 68cb6900fde14c77f0cd7add0e094a862960eb99 diff --git a/src/dependencies/vendor/xz b/src/dependencies/vendor/xz new file mode 160000 index 000000000..4b73f2ec1 --- /dev/null +++ b/src/dependencies/vendor/xz @@ -0,0 +1 @@ +Subproject commit 4b73f2ec19a99ef465282fbce633e8deb33691b3 diff --git a/src/dependencies/vendor/zlib b/src/dependencies/vendor/zlib new file mode 160000 index 000000000..da607da73 --- /dev/null +++ b/src/dependencies/vendor/zlib @@ -0,0 +1 @@ +Subproject commit da607da739fa6047df13e66a2af6b8bec7c2a498 diff --git a/src/dependencies/vendor/zstd b/src/dependencies/vendor/zstd new file mode 160000 index 000000000..f8745da6f --- /dev/null +++ b/src/dependencies/vendor/zstd @@ -0,0 +1 @@ +Subproject commit f8745da6ff1ad1e7bab384bd1f9d742439278e99 diff --git a/src/dependencies/xz.cmake b/src/dependencies/xz.cmake index d4c7f2165..28ae7034e 100644 --- a/src/dependencies/xz.cmake +++ b/src/dependencies/xz.cmake @@ -1,10 +1,11 @@ # Bundled liblzma (xz) set(LIBLZMA_VERSION "5.8.3") -FetchContent_Declare(xz +rpi_imager_fetch_git_or_vendor(xz + VENDOR_DIR xz + VENDOR_MARKER CMakeLists.txt GIT_REPOSITORY https://github.com/tukaani-project/xz.git GIT_TAG v${LIBLZMA_VERSION} - ${USE_OVERRIDE_FIND_PACKAGE} ) set(XZ_MICROLZMA_DECODER OFF CACHE BOOL "" FORCE) set(XZ_MICROLZMA_ENCODER OFF CACHE BOOL "" FORCE) @@ -46,4 +47,3 @@ set(LIBLZMA_HAS_AUTO_DECODER true CACHE BOOL "" FORCE) set(LIBLZMA_HAS_EASY_ENCODER true CACHE BOOL "" FORCE) set(LIBLZMA_HAS_LZMA_PRESET true CACHE BOOL "" FORCE) - diff --git a/src/dependencies/zlib.cmake b/src/dependencies/zlib.cmake index 6ad225bcb..f4716adb2 100644 --- a/src/dependencies/zlib.cmake +++ b/src/dependencies/zlib.cmake @@ -6,10 +6,11 @@ set(ZLIB_BUILD_SHARED OFF CACHE BOOL "" FORCE) set(ZLIB_BUILD_STATIC ON CACHE BOOL "" FORCE) set(ZLIB_BUILD_TESTS OFF CACHE BOOL "" FORCE) set(SKIP_INSTALL_ALL ON CACHE BOOL "" FORCE) -FetchContent_Declare(zlib +rpi_imager_fetch_git_or_vendor(zlib + VENDOR_DIR zlib + VENDOR_MARKER CMakeLists.txt GIT_REPOSITORY https://github.com/madler/zlib.git GIT_TAG v${ZLIB_VERSION} - ${USE_OVERRIDE_FIND_PACKAGE} ) FetchContent_GetProperties(zlib) if(NOT zlib_POPULATED) @@ -63,4 +64,3 @@ else() add_dependencies(ZLIB::ZLIB zlibstatic) endif() - diff --git a/src/dependencies/zstd.cmake b/src/dependencies/zstd.cmake index 28a0f3814..0a84d3956 100644 --- a/src/dependencies/zstd.cmake +++ b/src/dependencies/zstd.cmake @@ -1,11 +1,12 @@ # Bundled zstd set(ZSTD_VERSION "1.5.7") -FetchContent_Declare(zstd +rpi_imager_fetch_git_or_vendor(zstd + VENDOR_DIR zstd + VENDOR_MARKER build/cmake/CMakeLists.txt GIT_REPOSITORY https://github.com/facebook/zstd.git GIT_TAG v${ZSTD_VERSION} SOURCE_SUBDIR build/cmake - ${USE_OVERRIDE_FIND_PACKAGE} ) set(ZSTD_BUILD_PROGRAMS OFF CACHE BOOL "" FORCE) set(ZSTD_BUILD_SHARED OFF CACHE BOOL "" FORCE) @@ -24,12 +25,11 @@ unset(ZSTD_BUILD_TESTS) unset(ZSTD_BUILD_DICTBUILDER) set(ZSTD_FOUND true CACHE BOOL "" FORCE) set(Zstd_VERSION ${ZSTD_VERSION} CACHE STRING "" FORCE) -set(Zstd_INCLUDE_DIR ${CMAKE_CURRENT_BINARY_DIR}/_deps/zstd-src/lib CACHE PATH "" FORCE) -set(ZSTD_INCLUDE_DIR ${CMAKE_CURRENT_BINARY_DIR}/_deps/zstd-src/lib CACHE PATH "" FORCE) -set(Zstd_INCLUDE_DIRS ${CMAKE_CURRENT_BINARY_DIR}/_deps/zstd-src/lib CACHE PATH "" FORCE) -set(ZSTD_INCLUDE_DIRS ${CMAKE_CURRENT_BINARY_DIR}/_deps/zstd-src/lib CACHE PATH "" FORCE) +set(Zstd_INCLUDE_DIR ${zstd_SOURCE_DIR}/lib CACHE PATH "" FORCE) +set(ZSTD_INCLUDE_DIR ${zstd_SOURCE_DIR}/lib CACHE PATH "" FORCE) +set(Zstd_INCLUDE_DIRS ${zstd_SOURCE_DIR}/lib CACHE PATH "" FORCE) +set(ZSTD_INCLUDE_DIRS ${zstd_SOURCE_DIR}/lib CACHE PATH "" FORCE) set(Zstd_LIBRARIES libzstd_static CACHE FILEPATH "" FORCE) set(ZSTD_LIBRARIES libzstd_static CACHE FILEPATH "" FORCE) -set(ZSTD_LIBRARY ${CMAKE_CURRENT_BINARY_DIR}/_deps/zstd-build/lib/libzstd.a CACHE FILEPATH "" FORCE) - +set(ZSTD_LIBRARY ${zstd_BINARY_DIR}/lib/libzstd.a CACHE FILEPATH "" FORCE) diff --git a/src/downloadextractthread.cpp b/src/downloadextractthread.cpp index 4b5786ced..15cd10c92 100644 --- a/src/downloadextractthread.cpp +++ b/src/downloadextractthread.cpp @@ -4,7 +4,7 @@ */ #include "downloadextractthread.h" -#include "block_batcher.h" // UNRAID: coalesces libarchive data blocks into large writes +#include "block_batcher.h" #include "unraid/archive_write_result.h" // UNRAID: write warnings are terminal for boot media #include "unraid/unraid_postwrite.h" // UNRAID: post-extract customisation #include "config.h" @@ -923,7 +923,7 @@ void DownloadExtractThread::extractMultiFileRun() // Log the compression filter(s) being used _logCompressionFilters(a); - // UNRAID: coalesce libarchive's data blocks into large sequential writes. + // Coalesce libarchive's data blocks into large sequential writes. // // archive_write_data_block() issues one write per block libarchive hands // us, and its per-call overhead dominates when the blocks are small. This @@ -1018,6 +1018,13 @@ void DownloadExtractThread::extractMultiFileRun() << kExtractWriteBufferSize << "byte writes"; } + // Record what libarchive handed us so batching can be assessed from logs. + if (blockCount > 0) { + qDebug() << "Extraction:" << blockCount << "data blocks," + << (blockBytes / blockCount) << "bytes average, batched into" + << kExtractWriteBufferSize << "byte writes"; + } + QByteArray computedHash = _inputHash.result().toHex(); qDebug() << "Hash of compressed multi-file zip:" << computedHash; if (!_cancelled && !_expectedHash.isEmpty() && _expectedHash != computedHash) diff --git a/src/downloadstatstelemetry.cpp b/src/downloadstatstelemetry.cpp index 1b72bd743..c5f976742 100644 --- a/src/downloadstatstelemetry.cpp +++ b/src/downloadstatstelemetry.cpp @@ -20,10 +20,13 @@ DownloadStatsTelemetry::DownloadStatsTelemetry(const QByteArray &url, const QByt { QLocale locale; - // Extract clean numeric version (X.Y.Z) from IMAGER_VERSION_STR for telemetry - // Handles formats like: v2.0.0, v2.0.0-rc4-60-geac7c2f0, 2.0.0, etc. + // Extract clean numeric version (X.Y.Z, or X.Y.Z.W for a hotfix release) from + // IMAGER_VERSION_STR for telemetry. + // Handles formats like: v2.0.0, v2.0.0-rc4-60-geac7c2f0, 2.0.0, v2.0.11.1, etc. + // The fourth component must be kept: without it a hotfix is indistinguishable + // from the release it fixes, so uptake of the fix cannot be measured. QString versionStr(IMAGER_VERSION_STR); - static QRegularExpression versionRx("^v?([0-9]+\\.[0-9]+\\.[0-9]+)"); + static QRegularExpression versionRx("^v?([0-9]+\\.[0-9]+\\.[0-9]+(?:\\.[0-9]+)?)"); QRegularExpressionMatch versionMatch = versionRx.match(versionStr); QByteArray cleanVersion = versionMatch.hasMatch() ? versionMatch.captured(1).toLatin1() @@ -39,16 +42,17 @@ DownloadStatsTelemetry::DownloadStatsTelemetry(const QByteArray &url, const QByt +"&imagerLocale="+QUrl::toPercentEncoding(embedded ? imagerLang : locale.name()); #ifdef Q_OS_LINUX QFile f("/proc/cpuinfo"); - f.open(f.ReadOnly); - QByteArray cpuinfo = f.readAll(); - f.close(); + if (f.open(f.ReadOnly)) { + QByteArray cpuinfo = f.readAll(); + f.close(); - if (cpuinfo.contains("Raspberry Pi")) { - static QRegularExpression rx("Revision[ \t]*: ([0-9a-f]+)"); - QRegularExpressionMatch m = rx.match(cpuinfo); - if (m.hasMatch()) - { - _postfields += "&imagerPiRevision="+QUrl::toPercentEncoding(m.captured(1)); + if (cpuinfo.contains("Raspberry Pi")) { + static QRegularExpression rx("Revision[ \t]*: ([0-9a-f]+)"); + QRegularExpressionMatch m = rx.match(cpuinfo); + if (m.hasMatch()) + { + _postfields += "&imagerPiRevision="+QUrl::toPercentEncoding(m.captured(1)); + } } } #endif diff --git a/src/downloadthread.cpp b/src/downloadthread.cpp index 296b78edb..dbdc3f06b 100644 --- a/src/downloadthread.cpp +++ b/src/downloadthread.cpp @@ -2069,13 +2069,7 @@ void DownloadThread::_performEject() QString ejectPath = PlatformQuirks::getEjectDevicePath(_filename); PlatformQuirks::DiskResult result = PlatformQuirks::ejectDisk(ejectPath); - bool succeeded = (result == PlatformQuirks::DiskResult::Success); -#ifdef Q_OS_WIN - // The legacy Windows implementation walks every drive letter, so its - // result can carry an unrelated volume's failure even when the target - // drive ejected fine. Only report a definite miss. - succeeded = (result != PlatformQuirks::DiskResult::InvalidDrive); -#endif + const bool succeeded = (result == PlatformQuirks::DiskResult::Success); qDebug() << "Background eject finished for" << ejectPath << "succeeded:" << succeeded; emit ejectFinished(succeeded); diff --git a/src/driveformatthread.cpp b/src/driveformatthread.cpp index b21bc373f..fe1698206 100644 --- a/src/driveformatthread.cpp +++ b/src/driveformatthread.cpp @@ -112,12 +112,29 @@ void DriveFormatThread::run() QElapsedTimer formatTimer; formatTimer.start(); - rpi_imager::DiskFormatter formatter; - // UNRAID: Unraid images must land on a volume labelled UNRAID. - if (!_volumeLabel.isEmpty()) { - formatter.SetVolumeLabelOverride(_volumeLabel.toStdString()); // UNRAID - } - auto formatResult = formatter.FormatDrive(_device.toStdString()); + // UNRAID: scoped so the formatter is destroyed -- and the physical drive + // handle with it -- before the post-format rescan below. + // + // FormatDrive() opens the device and never closes it; the handle lives until + // ~DiskFormatter destroys file_ops_. That was harmless while physical drives + // opened write-shared, but the upstream 2.0.11-rc2 rework opens them with + // FILE_SHARE_READ and no FILE_SHARE_WRITE, so a second opener asking for + // write access is refused. rescanDisk() asks for GENERIC_READ | GENERIC_WRITE, + // so with the formatter still alive Windows answers ERROR_SHARING_VIOLATION + // (32), IOCTL_DISK_UPDATE_PROPERTIES never runs, and the FAT32 volume we just + // wrote never appears -- the write then dies at "No volume found on disk N". + // + // Upstream keeps this same order at its own refreshDiskView() call site in + // DownloadThread::_onDownloadError(): "Drop the device handle before asking + // the OS to refresh its view of the disk." + auto formatResult = [this]() { + rpi_imager::DiskFormatter formatter; + // UNRAID: Unraid images must land on a volume labelled UNRAID. + if (!_volumeLabel.isEmpty()) { + formatter.SetVolumeLabelOverride(_volumeLabel.toStdString()); // UNRAID + } + return formatter.FormatDrive(_device.toStdString()); + }(); quint32 formatDurationMs = static_cast(formatTimer.elapsed()); diff --git a/src/fat_partition_test.cpp b/src/fat_partition_test.cpp index 706e93a1e..290707ed2 100644 --- a/src/fat_partition_test.cpp +++ b/src/fat_partition_test.cpp @@ -34,55 +34,63 @@ static std::shared_ptr g_shared_device_wrapper; static int g_partition_num = 1; static std::string g_test_device_path; -// Initialize the shared device wrapper once +// Initialize the shared device wrapper once. +// +// This runs from a static constructor, i.e. before main(), so it also runs when +// the binary is merely listing its test cases. catch_discover_tests() parses +// that listing from stdout a line at a time, so anything written to stdout here +// is registered as a test case name — "WARNING: No test device path available" +// became a phantom CTest entry that could never pass. Every diagnostic on this +// path therefore goes to stderr, which discovery ignores. static void initializeSharedDevice() { if (g_shared_device_wrapper) { return; // Already initialized } - + std::string mount_path = getTestMountPath(); if (mount_path.empty()) { - std::cout << "WARNING: No test device path available" << std::endl; + std::cerr << "WARNING: No test device path available" << std::endl; return; } - + g_test_device_path = mount_path; - + // Convert partition to whole disk std::string disk_path = getWholeDiskPath(mount_path); g_partition_num = getPartitionNumber(mount_path); - - std::cout << "=========================================" << std::endl; - std::cout << "Opening test device ONCE for all tests" << std::endl; - std::cout << "Partition: " << mount_path << std::endl; - std::cout << "Whole disk: " << disk_path << std::endl; - std::cout << "Partition #: " << g_partition_num << std::endl; - std::cout << "=========================================" << std::endl; - + + std::cerr << "=========================================" << std::endl; + std::cerr << "Opening test device ONCE for all tests" << std::endl; + std::cerr << "Partition: " << mount_path << std::endl; + std::cerr << "Whole disk: " << disk_path << std::endl; + std::cerr << "Partition #: " << g_partition_num << std::endl; + std::cerr << "=========================================" << std::endl; + auto file_ops = rpi_imager::FileOperations::Create(); auto result = file_ops->OpenDevice(disk_path); - + if (result != rpi_imager::FileError::kSuccess) { - std::cout << "ERROR: Failed to open device: " << disk_path << std::endl; + std::cerr << "ERROR: Failed to open device: " << disk_path << std::endl; return; } - + auto device_wrapper = std::make_unique(file_ops.get()); - + // Convert to shared_ptr for global sharing g_shared_file_ops = std::shared_ptr(std::move(file_ops)); g_shared_device_wrapper = std::shared_ptr(std::move(device_wrapper)); - - std::cout << "✅ Device opened successfully and will be reused by all tests" << std::endl; - std::cout << "=========================================" << std::endl; + + std::cerr << "✅ Device opened successfully and will be reused by all tests" << std::endl; + std::cerr << "=========================================" << std::endl; } -// Cleanup the shared device wrapper +// Cleanup the shared device wrapper. Runs from a static destructor — see the +// note above on why these go to stderr. static void cleanupSharedDevice() { if (g_shared_device_wrapper) { - std::cout << "=========================================" << std::endl; - std::cout << "Closing shared test device" << std::endl; - std::cout << "=========================================" << std::endl; + std::cerr << "=========================================" << std::endl; + std::cerr << "Closing shared test device" << std::endl; + std::cerr << "=========================================" << std::endl; g_shared_device_wrapper.reset(); g_shared_file_ops.reset(); } diff --git a/src/imagewriter.cpp b/src/imagewriter.cpp index d553d144c..9eb55de16 100644 --- a/src/imagewriter.cpp +++ b/src/imagewriter.cpp @@ -168,10 +168,21 @@ ImageWriter::ImageWriter(QObject *parent) _debugIPv4Only = false; // Use both IPv4 and IPv6 by default _debugSkipEndOfDevice = false; // Normal behavior; enable for counterfeit cards _debugIgnoreDeviceLimits = false; // Use device-reported I/O limits by default - _debugRpiboot = false; // Rpiboot/fastboot support disabled by default + // Rpiboot/fastboot support is off by default, but sticky once turned on: + // the people who need it (CM provisioning) want it every session, and + // re-entering the secret menu on every launch is needless friction. + _debugRpiboot = _settings.value(QStringLiteral("debug_rpiboot"), false).toBool(); _debugForceSecureBoot = false; // No UI override; CLI flag still wins _debugSignFastbootGadget = false; // CM5 special-reprovision-device (SBR then fastboot) - + + // Propagate a restored rpiboot setting to the poll thread; these only set + // atomics, so it is safe before polling starts. + if (_debugRpiboot) { + _drivelist.setRpibootEnabled(true); + _drivelist.setFastbootScanEnabled(true); + qDebug() << "Debug: Rpiboot/fastboot support enabled from saved settings"; + } + // Calculate optimal async queue depth based on system memory _debugAsyncQueueDepth = SystemMemoryManager::instance().getOptimalAsyncQueueDepth(); @@ -2751,12 +2762,7 @@ void ImageWriter::ejectDrive() QThread *thread = QThread::create([self, device]() { PlatformQuirks::DiskResult result = PlatformQuirks::ejectDisk(PlatformQuirks::getEjectDevicePath(device)); - bool succeeded = (result == PlatformQuirks::DiskResult::Success); -#ifdef Q_OS_WIN - // The legacy Windows result can carry an unrelated volume's failure - // even when the target drive ejected fine; only report a definite miss. - succeeded = (result != PlatformQuirks::DiskResult::InvalidDrive); -#endif + const bool succeeded = (result == PlatformQuirks::DiskResult::Success); QMetaObject::invokeMethod(QCoreApplication::instance(), [self, succeeded]() { if (self) self->onEjectFinished(succeeded); @@ -3898,6 +3904,9 @@ void ImageWriter::setDebugRpiboot(bool enabled) _debugRpiboot = enabled; _drivelist.setRpibootEnabled(enabled); _drivelist.setFastbootScanEnabled(enabled); + // Sticky across runs — restored in the constructor. + _settings.setValue(QStringLiteral("debug_rpiboot"), enabled); + _settings.sync(); qDebug() << "Debug: Rpiboot/fastboot support" << (enabled ? "enabled" : "disabled"); } } @@ -4184,14 +4193,20 @@ bool ImageWriter::savedUserPasswordUsableWithCurrentOs(const QString &cryptHash) QString ImageWriter::deriveWifiPsk(const QString &ssid, const QString &plaintext) { - if (plaintext.isEmpty()) + // Strip CR/LF before measuring the length. ImTextField scrubs control + // characters out of the field, so the UI should never send them, but this is + // the same trust boundary cryptPassword() guards: a trailing newline would + // push a 63-character passphrase to 64 and flip the branch below, returning + // the plaintext verbatim as though it were a pre-computed PSK. + const QString password = rpi_imager::CustomisationGenerator::stripLineTerminators(plaintext); + if (password.isEmpty()) return QString(); // Passphrase length per WPA spec is 8..63; anything else is taken to be a // pre-computed PSK and returned verbatim. - const bool isPassphrase = (plaintext.length() >= 8 && plaintext.length() < 64); + const bool isPassphrase = (password.length() >= 8 && password.length() < 64); return isPassphrase - ? rpi_imager::CustomisationGenerator::pbkdf2(plaintext.toUtf8(), ssid.toUtf8()) - : plaintext; + ? rpi_imager::CustomisationGenerator::pbkdf2(password.toUtf8(), ssid.toUtf8()) + : password; } QString ImageWriter::wifiSsidOctetsBase64(const QString &ssid) const @@ -4508,8 +4523,16 @@ bool ImageWriter::isValidRepoUrl(const QString &url) const // a SAS token: ".../manifest.json?sv=...&sig=...") are accepted. The path // portion excludes '?' and '#' so the extension must appear before any // query/fragment rather than merely somewhere in the URL. + // + // Anchored with \A..\z rather than ^..$: PCRE2 lets '$' match immediately + // before a newline at the end of the subject, so "...repo.json\n" matched + // despite '\n' being excluded from every character class above. That is + // exactly what a URL copied out of a browser looks like, and it reached + // refreshOsListFrom() as a %0A-suffixed URL (issue #1687). This also guards + // the deep-link "repo=" path, which never passes through a text field. static const QRegularExpression repoUrlRe( - QStringLiteral("^https?://[^ \\t\\r\\n?#]+\\.(json|" MANIFEST_EXTENSION ")([?#][^ \\t\\r\\n]*)?$"), + QRegularExpression::anchoredPattern( + QStringLiteral("https?://[^ \\t\\r\\n?#]+\\.(json|" MANIFEST_EXTENSION ")([?#][^ \\t\\r\\n]*)?")), QRegularExpression::CaseInsensitiveOption); return repoUrlRe.match(url).hasMatch(); } diff --git a/src/linux/Platform.cmake b/src/linux/Platform.cmake index 45df2ca4b..7abef7cf8 100644 --- a/src/linux/Platform.cmake +++ b/src/linux/Platform.cmake @@ -7,9 +7,33 @@ find_package(GnuTLS REQUIRED) find_package(PkgConfig REQUIRED) pkg_check_modules(LIBURING liburing) +# file_operations_linux.cpp uses the 64-bit user_data helpers +# (io_uring_sqe_set_data64 / io_uring_prep_cancel64), added in liburing 2.2. +# Some distros ship an older liburing (e.g. Ubuntu 22.04's 2.1) that provides +# the library but not these helpers, so probe for them before enabling io_uring. +set(LIBURING_USABLE FALSE) if(LIBURING_FOUND) - message(STATUS "Found liburing: ${LIBURING_VERSION}") + include(CheckCXXSourceCompiles) + set(CMAKE_REQUIRED_INCLUDES ${LIBURING_INCLUDE_DIRS}) + set(CMAKE_REQUIRED_LIBRARIES ${LIBURING_LIBRARIES}) + check_cxx_source_compiles(" +#include +int main() { + struct io_uring_sqe *sqe = 0; + io_uring_sqe_set_data64(sqe, 0); + io_uring_prep_cancel64(sqe, 0, 0); + return 0; +}" LIBURING_HAS_DATA64) + unset(CMAKE_REQUIRED_INCLUDES) + unset(CMAKE_REQUIRED_LIBRARIES) + set(LIBURING_USABLE ${LIBURING_HAS_DATA64}) +endif() + +if(LIBURING_USABLE) + message(STATUS "Found liburing: ${LIBURING_VERSION} (async io_uring enabled)") add_definitions(-DHAVE_LIBURING) +elseif(LIBURING_FOUND) + message(WARNING "liburing ${LIBURING_VERSION} lacks the 64-bit user_data API (need >= 2.2); async io_uring disabled, falling back to synchronous writes") else() message(WARNING "liburing not found - async I/O will be disabled. Install with: sudo apt install liburing-dev") endif() @@ -25,8 +49,27 @@ set(PLATFORM_SOURCES linux/platformquirks_linux.cpp ) -# Only include DBus-dependent and GUI components for non-CLI builds -if(NOT BUILD_CLI_ONLY) +# DBus-backed components. The embedded (linuxfb netboot) build has no session +# bus and is built without QtDBus, so it uses the same stubs as the CLI build +# for the WiFi-credential and suspend-inhibitor backends, and drops the +# NetworkManager and Pi Connect URI-handler sources entirely. It keeps the +# GUI file dialog (nativefiledialog_linux.cpp), whose DBus portal path is +# QT_DBUS_LIB-guarded and compiles to a QML-only fallback without DBus. +if(BUILD_CLI_ONLY) + # CLI: no GUI, no DBus. + list(APPEND PLATFORM_SOURCES + linux/suspend_inhibitor_stub.cpp + linux/wlancredentials_stub.cpp + ) +elseif(BUILD_EMBEDDED) + # Embedded GUI: file dialog kept (QML-only without DBus), rest stubbed. + list(APPEND PLATFORM_SOURCES + linux/nativefiledialog_linux.cpp + linux/suspend_inhibitor_stub.cpp + linux/wlancredentials_stub.cpp + ) +else() + # Desktop GUI: full DBus-backed components. list(APPEND PLATFORM_SOURCES linux/linux_suspend_inhibitor.cpp linux/networkmanagerapi.h @@ -35,18 +78,12 @@ if(NOT BUILD_CLI_ONLY) linux/urihandler_dbus.h linux/urihandler_dbus.cpp ) -else() - # Use stub implementations for CLI builds (no DBus dependency) - list(APPEND PLATFORM_SOURCES - linux/suspend_inhibitor_stub.cpp - linux/wlancredentials_stub.cpp - ) endif() set(EXTRALIBS ${EXTRALIBS} GnuTLS::GnuTLS idn2 nettle) -# Add liburing if available -if(LIBURING_FOUND) +# Add liburing if usable (see the API probe above) +if(LIBURING_USABLE) set(EXTRALIBS ${EXTRALIBS} ${LIBURING_LIBRARIES}) include_directories(${LIBURING_INCLUDE_DIRS}) endif() diff --git a/src/linux/nativefiledialog_linux.cpp b/src/linux/nativefiledialog_linux.cpp index 5769071b5..5547a291b 100644 --- a/src/linux/nativefiledialog_linux.cpp +++ b/src/linux/nativefiledialog_linux.cpp @@ -4,6 +4,7 @@ */ #include "../nativefiledialog.h" +#ifdef QT_DBUS_LIB #include #include #include @@ -335,3 +336,19 @@ bool NativeFileDialog::areNativeDialogsAvailablePlatform() } #include "nativefiledialog_linux.moc" +#else // QT_DBUS_LIB +// Built without QtDBus (embedded/linuxfb netboot): there is no XDG desktop +// portal. In this configuration getOpenFileName/getSaveFileName already return +// empty (embedded mode / forced QML dialogs), so these platform hooks are never +// reached at runtime; they exist only to satisfy the link. +QString NativeFileDialog::getFileNameNative(const QString &, const QString &, + const QString &, bool, void *) +{ + return QString(); +} + +bool NativeFileDialog::areNativeDialogsAvailablePlatform() +{ + return false; +} +#endif // QT_DBUS_LIB diff --git a/src/main.cpp b/src/main.cpp index 2222ad280..4d82babb7 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -52,7 +52,7 @@ #include #endif #ifndef CLI_ONLY_BUILD -#ifdef Q_OS_LINUX +#if defined(Q_OS_LINUX) && defined(QT_DBUS_LIB) #include #include #include @@ -492,7 +492,7 @@ int main(int argc, char *argv[]) } } -#ifdef Q_OS_LINUX +#if defined(Q_OS_LINUX) && defined(QT_DBUS_LIB) // Check if another instance is already running via D-Bus // If so, send the callback URL to it and exit if (!callbackUrl.isEmpty()) @@ -552,7 +552,7 @@ int main(int argc, char *argv[]) qWarning() << "TCP listen failed:" << server.errorString(); } #endif -#ifdef Q_OS_LINUX +#if defined(Q_OS_LINUX) && defined(QT_DBUS_LIB) // D-Bus callback service for URI handling QDBusConnection bus = QDBusConnection::sessionBus(); if (bus.isConnected()) diff --git a/src/qmlcomponents/ImFileDialog.qml b/src/qmlcomponents/ImFileDialog.qml index 948fd7cd7..7deb0a83d 100644 --- a/src/qmlcomponents/ImFileDialog.qml +++ b/src/qmlcomponents/ImFileDialog.qml @@ -300,28 +300,29 @@ BaseDialog { color: Style.formLabelColor } - TextField { + // ImTextField rather than a bare TextField so that a path pasted with a + // trailing newline is scrubbed on the way in (see issues #1627, #1687); + // it also supplies the font, focus and accessibility defaults this field + // previously set by hand. + ImTextField { id: pathField Layout.fillWidth: true - font.family: Style.fontFamily - font.pointSize: Style.fontSizeInput text: dialog._toDisplayPath(dialog.currentFolder) placeholderText: dialog.isSaveDialog ? qsTr("Enter path or URL\u2026") : qsTr("Enter folder or file path\u2026") - activeFocusOnTab: true - focusPolicy: Qt.TabFocus + trimWhitespace: true onTextChanged: { if (!dialog.isSaveDialog) { - dialog._pathFieldFile = dialog._looksLikeFilePath(text) - ? dialog._toFileUrl(text) : "" + dialog._pathFieldFile = dialog._looksLikeFilePath(pathField.value) + ? dialog._toFileUrl(pathField.value) : "" } } onAccepted: { - var newUrl = dialog._toFileUrl(text) + var newUrl = dialog._toFileUrl(pathField.value) if (!newUrl || newUrl.length === 0) return - if (!dialog.isSaveDialog && dialog._looksLikeFilePath(text)) { + if (!dialog.isSaveDialog && dialog._looksLikeFilePath(pathField.value)) { dialog.selectedFile = newUrl dialog.close() dialog.accepted() @@ -346,19 +347,18 @@ BaseDialog { color: Style.formLabelColor } - TextField { + ImTextField { id: filenameField Layout.fillWidth: true - font.family: Style.fontFamily - font.pointSize: Style.fontSizeInput text: dialog._currentFilename placeholderText: qsTr("Enter filename…") activeFocusOnTab: dialog.isSaveDialog + trimWhitespace: true onTextChanged: { dialog._currentFilename = text } onAccepted: { - if (text.trim().length > 0) { + if (filenameField.value.length > 0) { dialog.selectedFile = dialog._toFileUrl(dialog._buildFilePath()) dialog.close() dialog.accepted() diff --git a/src/qmlcomponents/ImPasswordField.qml b/src/qmlcomponents/ImPasswordField.qml index 05288f17a..c6b03b349 100644 --- a/src/qmlcomponents/ImPasswordField.qml +++ b/src/qmlcomponents/ImPasswordField.qml @@ -20,6 +20,11 @@ Item { // Public API (forwards from internal TextField) property alias text: textField.text + // Control characters are always scrubbed by ImTextField; trimWhitespace stays + // at its default of false here, since surrounding spaces are a legitimate part + // of a secret. Callers can still read `value` for symmetry with ImTextField. + property alias trimWhitespace: textField.trimWhitespace + readonly property string value: textField.value property alias placeholderText: textField.placeholderText property alias font: textField.font property alias enabled: textField.enabled diff --git a/src/qmlcomponents/ImTextField.qml b/src/qmlcomponents/ImTextField.qml index d3c455fc8..584b8019a 100644 --- a/src/qmlcomponents/ImTextField.qml +++ b/src/qmlcomponents/ImTextField.qml @@ -15,6 +15,65 @@ TextField { font.family: Style.fontFamily font.pointSize: Style.fontSizeInput + // Whether surrounding whitespace is meaningful for this field. Fields where it + // never is (URLs, hostnames, usernames, SSIDs, tokens) set this to true and + // consumers read `value`. Left false for passwords, where a leading or + // trailing space is a legitimate part of the secret and silently discarding + // it would change what the user actually set. + property bool trimWhitespace: false + onTrimWhitespaceChanged: _syncValue() + + // The sanitised field contents. Read this rather than `text`: it is always + // free of control characters, and of surrounding whitespace when the field + // opts in above. Trimming happens here rather than in the scrubber below so + // that typing a space mid-phrase is not swallowed as you type. + readonly property string value: _value + + // `value` cannot be bound directly to `text`. QML re-evaluates a binding + // only after every handler connected to the same change signal has run, so + // an `onTextChanged` at the use site would read the *previous* contents. + // That silently discarded the Connect token the browser fills in, because + // the field's own handler saw an empty `value` and cleared the token it had + // just been given (issue: Next stays disabled on the Pi Connect step). The + // scrubber below runs before any use-site handler, so refreshing the + // backing property from there keeps `value` in step with `text` for every + // reader. Bindings *on* `value` still work: assigning `_value` notifies + // synchronously. + property string _value: "" + function _syncValue() { + _value = trimWhitespace ? text.trim() : text + } + Component.onCompleted: _syncValue() + + // A single-line field must never hold control characters, but Qt inserts + // pasted text verbatim (QQuickTextInputPrivate::paste() is just + // insert(clip), and internalInsert() does no filtering). Text copied from a + // browser therefore arrives with a trailing newline, which has silently + // corrupted a password hash (issue #1627) and a repository URL (issue + // #1687). A validator cannot be used for this: QRegularExpressionValidator + // returns Intermediate rather than Invalid, so Qt keeps the offending text + // and merely clears acceptableInput. Nor can paste() be overridden here, + // because the Ctrl/Cmd+V key handler calls the private d->paste() directly. + // Scrubbing on change is what catches every route in, so that no consumer + // has to remember to sanitise. These characters cannot be typed, so removing + // them never disturbs editing, and doing so is lossless even for passwords. + property bool _scrubbing: false + onTextChanged: { + // Ahead of the scrub so that the re-entrant pass below leaves `value` + // holding the cleaned text by the time any use-site handler runs. + _syncValue() + if (_scrubbing) + return + var cleaned = text.replace(/[\x00-\x1F\x7F]/g, "") + if (cleaned === text) + return + _scrubbing = true + var restoreCursor = Math.min(cursorPosition, cleaned.length) + text = cleaned + cursorPosition = restoreCursor + _scrubbing = false + } + // Sensible defaults to ensure consistent behavior across the app activeFocusOnPress: true activeFocusOnTab: true diff --git a/src/test/CMakeLists.txt b/src/test/CMakeLists.txt index 106a05726..8b6dc27d3 100644 --- a/src/test/CMakeLists.txt +++ b/src/test/CMakeLists.txt @@ -17,6 +17,19 @@ FetchContent_MakeAvailable(Catch2) list(APPEND CMAKE_MODULE_PATH ${catch2_SOURCE_DIR}/extras) include(Catch) +# Register a Catch2 binary's cases with CTest. +# +# Wraps catch_discover_tests purely to pin SKIP_RETURN_CODE. Catch2 exits 4 when +# every selected case was skipped, and because discovery registers one CTest test +# per case, a case that calls SKIP() is always the only one selected — so without +# this CTest reports every skip as a failure. Tests that skip when optional +# fixtures or hardware are absent (the pieeprom and eeprom_signer ones) were +# accounting for six phantom failures. Use this in place of +# catch_discover_tests() so a new test binary cannot reintroduce them. +function(rpi_discover_tests target) + catch_discover_tests(${target} PROPERTIES SKIP_RETURN_CODE 4) +endfunction() + # Add the customization generator test executable # Includes the bundled crypto sources the generator now depends on for # credential derivation (yescrypt/sha256crypt password hashing). @@ -49,7 +62,7 @@ target_compile_options(customization_generator_test PRIVATE ) # Register with CTest for automatic test discovery -catch_discover_tests(customization_generator_test) +rpi_discover_tests(customization_generator_test) # Also add a custom target for manual running add_custom_target(test_customization_generator @@ -130,7 +143,7 @@ target_compile_options(fat_partition_test PRIVATE # Note: These tests are marked with [!mayfail] and [.destructive] tags # Run with: ctest -C Debug --output-on-failure # Or with environment variable: FAT_TEST_MOUNT_PATH="/Volumes/bootfs 1" ctest -catch_discover_tests(fat_partition_test) +rpi_discover_tests(fat_partition_test) # Custom target for manual running with mount path argument # Usage: FAT_TEST_MOUNT_PATH="/Volumes/bootfs 1" cmake --build . --target test_fat_partition @@ -198,7 +211,7 @@ target_compile_options(drivelist_test PRIVATE ) # Register with CTest for automatic test discovery -catch_discover_tests(drivelist_test) +rpi_discover_tests(drivelist_test) # Custom target for manual running add_custom_target(test_drivelist @@ -271,7 +284,7 @@ target_compile_options(platformquirks_test PRIVATE ) # Register with CTest for automatic test discovery -catch_discover_tests(platformquirks_test) +rpi_discover_tests(platformquirks_test) # Custom target for manual running add_custom_target(test_platformquirks @@ -323,7 +336,7 @@ target_compile_options(rpiboot_protocol_test PRIVATE -Wall -Wextra -Wpedantic $<$:-g -O0> ) -catch_discover_tests(rpiboot_protocol_test) +rpi_discover_tests(rpiboot_protocol_test) add_custom_target(test_rpiboot_protocol COMMAND rpiboot_protocol_test @@ -361,7 +374,7 @@ target_compile_options(rpiboot_bootfiles_test PRIVATE -Wall -Wextra -Wpedantic $<$:-g -O0> ) -catch_discover_tests(rpiboot_bootfiles_test) +rpi_discover_tests(rpiboot_bootfiles_test) add_custom_target(test_rpiboot_bootfiles COMMAND rpiboot_bootfiles_test @@ -394,7 +407,7 @@ target_compile_options(fastboot_protocol_test PRIVATE -Wall -Wextra -Wpedantic $<$:-g -O0> ) -catch_discover_tests(fastboot_protocol_test) +rpi_discover_tests(fastboot_protocol_test) add_custom_target(test_fastboot_protocol COMMAND fastboot_protocol_test @@ -423,7 +436,7 @@ target_compile_options(sparse_encoder_test PRIVATE -Wall -Wextra -Wpedantic $<$:-g -O0> ) -catch_discover_tests(sparse_encoder_test) +rpi_discover_tests(sparse_encoder_test) add_custom_target(test_sparse_encoder COMMAND sparse_encoder_test @@ -450,7 +463,7 @@ target_compile_options(pieeprom_test PRIVATE -Wall -Wextra -Wpedantic $<$:-g -O0> ) -catch_discover_tests(pieeprom_test) +rpi_discover_tests(pieeprom_test) add_custom_target(test_pieeprom COMMAND pieeprom_test @@ -477,7 +490,7 @@ target_compile_options(bootloader_image_test PRIVATE -Wall -Wextra -Wpedantic $<$:-g -O0> ) -catch_discover_tests(bootloader_image_test) +rpi_discover_tests(bootloader_image_test) add_custom_target(test_bootloader_image COMMAND bootloader_image_test @@ -507,7 +520,7 @@ target_compile_options(fastboot_eeprom_wire_test PRIVATE -Wall -Wextra -Wpedantic $<$:-g -O0> ) -catch_discover_tests(fastboot_eeprom_wire_test) +rpi_discover_tests(fastboot_eeprom_wire_test) add_custom_target(test_fastboot_eeprom_wire COMMAND fastboot_eeprom_wire_test @@ -559,7 +572,7 @@ target_compile_options(eeprom_signer_test PRIVATE -Wall -Wextra -Wpedantic $<$:-g -O0> ) -catch_discover_tests(eeprom_signer_test) +rpi_discover_tests(eeprom_signer_test) add_custom_target(test_eeprom_signer COMMAND eeprom_signer_test @@ -615,7 +628,7 @@ target_compile_options(rpiboot_integration_test PRIVATE -Wall -Wextra -Wpedantic $<$:-g -O0> ) -catch_discover_tests(rpiboot_integration_test) +rpi_discover_tests(rpiboot_integration_test) add_custom_target(test_rpiboot_integration COMMAND rpiboot_integration_test @@ -714,7 +727,7 @@ endif() target_compile_features(device_io_limits_test PRIVATE cxx_std_20) -catch_discover_tests(device_io_limits_test) +rpi_discover_tests(device_io_limits_test) add_custom_target(test_device_io_limits COMMAND device_io_limits_test @@ -746,7 +759,7 @@ target_include_directories(unraid_guid_test PRIVATE target_compile_features(unraid_guid_test PRIVATE cxx_std_20) -catch_discover_tests(unraid_guid_test) +rpi_discover_tests(unraid_guid_test) add_custom_target(test_unraid_guid COMMAND unraid_guid_test @@ -755,7 +768,30 @@ add_custom_target(test_unraid_guid COMMENT "Running Unraid flash GUID derivation tests" ) -# UNRAID: BlockBatcher coalesces libarchive data blocks into larger writes during +# UNRAID: libarchive reports some real device-write failures as warning-class +# results. Boot media must treat all negative write statuses as terminal. +add_executable(archive_write_result_test + ${CMAKE_CURRENT_SOURCE_DIR}/../unraid/archive_write_result.h + archive_write_result_test.cpp +) + +target_link_libraries(archive_write_result_test PRIVATE + Catch2::Catch2WithMain +) + +target_include_directories(archive_write_result_test PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/.. +) + +target_compile_features(archive_write_result_test PRIVATE cxx_std_20) +target_compile_options(archive_write_result_test PRIVATE + -Wall -Wextra -Wpedantic + $<$:-g -O0> +) + +rpi_discover_tests(archive_write_result_test) + +# BlockBatcher coalesces libarchive data blocks into larger writes during # multi-file extraction. Header-only and free of Qt/libarchive, so it tests without # a download, a ring buffer or a device attached. add_executable(block_batcher_test @@ -777,7 +813,7 @@ target_compile_options(block_batcher_test PRIVATE $<$:-g -O0> ) -catch_discover_tests(block_batcher_test) +rpi_discover_tests(block_batcher_test) add_custom_target(test_block_batcher COMMAND block_batcher_test @@ -785,26 +821,3 @@ add_custom_target(test_block_batcher WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} COMMENT "Running block batcher tests" ) - -# UNRAID: libarchive reports some real device-write failures as warning-class -# results. Boot media must treat all negative write statuses as terminal. -add_executable(archive_write_result_test - ${CMAKE_CURRENT_SOURCE_DIR}/../unraid/archive_write_result.h - archive_write_result_test.cpp -) - -target_link_libraries(archive_write_result_test PRIVATE - Catch2::Catch2WithMain -) - -target_include_directories(archive_write_result_test PRIVATE - ${CMAKE_CURRENT_SOURCE_DIR}/.. -) - -target_compile_features(archive_write_result_test PRIVATE cxx_std_20) -target_compile_options(archive_write_result_test PRIVATE - -Wall -Wextra -Wpedantic - $<$:-g -O0> -) - -catch_discover_tests(archive_write_result_test) diff --git a/src/test/block_batcher_test.cpp b/src/test/block_batcher_test.cpp index b3f2ad618..64d3f7242 100644 --- a/src/test/block_batcher_test.cpp +++ b/src/test/block_batcher_test.cpp @@ -2,8 +2,8 @@ * SPDX-License-Identifier: Apache-2.0 * Copyright (C) 2026 Lime Technology, Inc. * - * UNRAID: tests for BlockBatcher, which coalesces libarchive's data blocks into - * larger writes during multi-file extraction. + * Tests for BlockBatcher, which coalesces libarchive's data blocks into larger + * writes during multi-file extraction. * * The risk being covered is silent corruption: batching must never change which * bytes land at which offset. Sparse files are the dangerous case -- a gap in diff --git a/src/test/customization_generator_test.cpp b/src/test/customization_generator_test.cpp index 31bafb67c..d83231d20 100644 --- a/src/test/customization_generator_test.cpp +++ b/src/test/customization_generator_test.cpp @@ -7,12 +7,14 @@ #include #include "customization_generator.h" #include "dependencies/sha256crypt/sha256crypt.h" +#include "dependencies/yescrypt/yescrypt_wrapper.h" #include #include #include #include #include #include +#include using namespace rpi_imager; using Catch::Matchers::ContainsSubstring; @@ -113,6 +115,117 @@ TEST_CASE("CustomisationGenerator handles yescrypt password format", "[customiza REQUIRE_THAT(scriptStr.toStdString(), ContainsSubstring("echo \"$FIRSTUSER:$y$j9T$")); } +// Regression test for issue #1627. A password pasted from a browser or password +// manager arrives with a trailing newline, because Qt's single-line text fields +// insert clipboard content verbatim. PAM discards the line terminator when +// reading a password, so hashing the raw value yields a hash that can never be +// matched at login. cryptPassword() must therefore strip CR/LF before hashing. +// +// Verified the way PAM would: re-derive the hash from the *clean* password using +// the stored hash as the salt setting, and require that it reproduces the hash +// that was generated from the newline-bearing input. +TEST_CASE("cryptPassword strips CR/LF so pasted passwords still authenticate", + "[customization][password]") { + const QByteArray clean = "correct horse battery staple"; + + SECTION("sha256crypt (pre-2023 OS)") { + const QString releaseDate = QStringLiteral("2022-09-22"); + REQUIRE_FALSE(CustomisationGenerator::osUsesYescrypt(releaseDate)); + + for (const QByteArray &suffix : {QByteArray("\n"), QByteArray("\r\n"), QByteArray("\r")}) { + const QString hash = CustomisationGenerator::cryptPassword(clean + suffix, releaseDate); + REQUIRE(hash.startsWith(QStringLiteral("$5$"))); + const QByteArray setting = hash.toUtf8(); + REQUIRE(QString::fromUtf8(sha256_crypt(clean.constData(), setting.constData())) == hash); + } + } + + SECTION("yescrypt (2023+ OS)") { + const QString releaseDate = QStringLiteral("2024-03-15"); + REQUIRE(CustomisationGenerator::osUsesYescrypt(releaseDate)); + + for (const QByteArray &suffix : {QByteArray("\n"), QByteArray("\r\n"), QByteArray("\r")}) { + const QString hash = CustomisationGenerator::cryptPassword(clean + suffix, releaseDate); + REQUIRE(CustomisationGenerator::isYescryptHash(hash)); + const QByteArray setting = hash.toUtf8(); + REQUIRE(QString::fromUtf8(yescrypt_crypt(clean.constData(), setting.constData())) == hash); + } + } + + SECTION("a genuinely different password still does not authenticate") { + const QString releaseDate = QStringLiteral("2024-03-15"); + const QString hash = CustomisationGenerator::cryptPassword(clean + "\n", releaseDate); + const QByteArray setting = hash.toUtf8(); + REQUIRE(QString::fromUtf8(yescrypt_crypt("wrong password", setting.constData())) != hash); + } + + SECTION("interior CR/LF is removed too, not just a trailing terminator") { + // A multi-line clipboard paste collapses to a single line rather than + // being silently truncated at the first newline. + const QString releaseDate = QStringLiteral("2024-03-15"); + const QString hash = CustomisationGenerator::cryptPassword("ab\ncd", releaseDate); + const QByteArray setting = hash.toUtf8(); + REQUIRE(QString::fromUtf8(yescrypt_crypt("abcd", setting.constData())) == hash); + REQUIRE(QString::fromUtf8(yescrypt_crypt("ab", setting.constData())) != hash); + } +} + +// Companion to the test above, for the Wi-Fi passphrase rather than the account +// password. Here a stray newline does more than corrupt the derivation: the +// 8..63 passphrase-length test decides whether the value is treated as a +// passphrase to hash or as an already-computed 64-hex PMK to pass through, so a +// single extra character can flip the branch and emit the user's plaintext where +// a PMK is expected. +TEST_CASE("resolveWifiPskCrypt strips CR/LF before classifying by length", + "[customization][wifi][password]") { + const QByteArray ssid = "TestNet"; + + // resolveWifiPskCrypt is private, so drive it through generateSystemdScript + // and read back the PSK it emits into the wpa_supplicant stanza. + auto pskFor = [&](const QString &plaintext) { + QVariantMap settings; + settings["wifiConfigured"] = true; + settings["wifiSSID"] = QString::fromUtf8(ssid); + settings["wifiPassword"] = plaintext; + const QString script = QString::fromUtf8(CustomisationGenerator::generateSystemdScript(settings)); + static const QRegularExpression pskRe(QStringLiteral("(?m)^\\s*psk=(\\S*)\\s*$")); + const QRegularExpressionMatch m = pskRe.match(script); + REQUIRE(m.hasMatch()); + return m.captured(1); + }; + + SECTION("a trailing newline does not change the derived PSK") { + const QString expected = pskFor(QStringLiteral("hunter2hunter2")); + REQUIRE_FALSE(expected.isEmpty()); + REQUIRE(pskFor(QStringLiteral("hunter2hunter2\n")) == expected); + REQUIRE(pskFor(QStringLiteral("hunter2hunter2\r\n")) == expected); + } + + SECTION("a 63-character passphrase is still hashed, not passed through") { + const QString maxLen(63, QLatin1Char('a')); + const QString expected = pskFor(maxLen); + // A derived PSK is 32 bytes rendered as hex; the plaintext must not survive. + REQUIRE(expected.length() == 64); + REQUIRE(expected != maxLen); + // Without stripping, 63 + 1 == 64 would take the pass-through branch. + REQUIRE(pskFor(maxLen + "\n") == expected); + } + + SECTION("a too-short passphrase is not inflated into a valid length") { + const QString tooShort(7, QLatin1Char('a')); + // 7 chars is below the WPA minimum, so it is passed through unchanged + // rather than hashed. Adding a newline must not make it look like 8. + REQUIRE(pskFor(tooShort) == tooShort); + REQUIRE(pskFor(tooShort + "\n") == tooShort); + } + + SECTION("a real 64-hex PMK is still passed through untouched") { + const QString pmk(64, QLatin1Char('a')); + REQUIRE(pskFor(pmk) == pmk); + REQUIRE(pskFor(pmk + "\n") == pmk); + } +} + TEST_CASE("CustomisationGenerator handles sha256crypt password format", "[customization][password]") { QVariantMap settings; settings["sshUserName"] = "testuser"; @@ -876,6 +989,7 @@ TEST_CASE("CustomisationGenerator cloud-init handles SSH public key only (no use REQUIRE_THAT(yaml.toStdString(), ContainsSubstring("lock_passwd: true")); // SSH keys alone should NOT grant passwordless sudo — requires explicit opt-in REQUIRE_THAT(yaml.toStdString(), !ContainsSubstring("sudo: ALL=(ALL) NOPASSWD:ALL")); + REQUIRE_THAT(yaml.toStdString(), ContainsSubstring(" sudo: null")); } TEST_CASE("CustomisationGenerator handles multiple SSH keys in .pub file", "[customization][ssh]") { @@ -1091,6 +1205,7 @@ TEST_CASE("CustomisationGenerator generates cloud-init user-data with SSH keys", REQUIRE_THAT(yaml.toStdString(), ContainsSubstring("lock_passwd: true")); // SSH keys alone should NOT grant passwordless sudo — requires explicit opt-in REQUIRE_THAT(yaml.toStdString(), !ContainsSubstring("sudo: ALL=(ALL) NOPASSWD:ALL")); + REQUIRE_THAT(yaml.toStdString(), ContainsSubstring(" sudo: null")); // Password authentication should be explicitly disabled when using public-key auth REQUIRE_THAT(yaml.toStdString(), ContainsSubstring("ssh_pwauth: false")); } @@ -1112,6 +1227,8 @@ TEST_CASE("CustomisationGenerator cloud-init passwordless sudo when explicitly e REQUIRE_THAT(yaml.toStdString(), ContainsSubstring("testuser ALL=(ALL) NOPASSWD:ALL")); REQUIRE_THAT(yaml.toStdString(), ContainsSubstring("/etc/sudoers.d/010_testuser-nopasswd")); REQUIRE_THAT(yaml.toStdString(), ContainsSubstring("chmod")); + // The opt-in must not also emit the suppressing key + REQUIRE_THAT(yaml.toStdString(), !ContainsSubstring("sudo: null")); } TEST_CASE("CustomisationGenerator cloud-init no passwordless sudo by default", "[cloudinit][userdata][sudo]") { @@ -1124,6 +1241,13 @@ TEST_CASE("CustomisationGenerator cloud-init no passwordless sudo by default", " REQUIRE_THAT(yaml.toStdString(), ContainsSubstring(" name: testuser")); REQUIRE_THAT(yaml.toStdString(), !ContainsSubstring("sudo: ALL=(ALL) NOPASSWD:ALL")); + // Regression test: the singular `user:` block is merged over the distro's + // default_user from /etc/cloud/cloud.cfg, which carries + // `sudo: ["ALL=(ALL) NOPASSWD:ALL"]` on every variant including + // raspberry-pi-os. Silence alone therefore inherits passwordless sudo (via + // /etc/sudoers.d/90-cloud-init-users), so the key must be set to null. + REQUIRE_THAT(yaml.toStdString(), ContainsSubstring(" sudo: null")); + REQUIRE_THAT(yaml.toStdString(), !ContainsSubstring("/etc/sudoers.d/010_testuser-nopasswd")); } TEST_CASE("CustomisationGenerator systemd script passwordless sudo", "[customization][sudo]") { @@ -1338,19 +1462,12 @@ TEST_CASE("CustomisationGenerator cloud-init WiFi country only (no SSID)", "[clo REQUIRE_THAT(yaml.toStdString(), ContainsSubstring("rfkill, unblock, wifi")); REQUIRE_THAT(yaml.toStdString(), ContainsSubstring("/var/lib/systemd/rfkill/*:wlan")); - // Network config should include eth0 for DHCP but no WiFi when there's no SSID - // The regulatory domain is set via cmdline parameter (cfg80211.ieee80211_regdom) instead. + // A country code alone cannot produce a wifis: block — cloud-init requires at + // least one access-point — and without one there is nothing to write, so no + // network-config is emitted. The regulatory domain is applied via the cmdline + // parameter (cfg80211.ieee80211_regdom) instead. QByteArray netcfg = CustomisationGenerator::generateCloudInitNetworkConfig(settings, false); - QString netcfgYaml = QString::fromUtf8(netcfg); - - // Should have eth0 configuration with DHCP v4 and v6 - REQUIRE_THAT(netcfgYaml.toStdString(), ContainsSubstring("ethernets:")); - REQUIRE_THAT(netcfgYaml.toStdString(), ContainsSubstring("eth0:")); - REQUIRE_THAT(netcfgYaml.toStdString(), ContainsSubstring("dhcp4: true")); - REQUIRE_THAT(netcfgYaml.toStdString(), ContainsSubstring("dhcp6: true")); - - // Should NOT have wifis section (no SSID configured) - REQUIRE_THAT(netcfgYaml.toStdString(), !ContainsSubstring("wifis:")); + REQUIRE(netcfg.isEmpty()); } TEST_CASE("CustomisationGenerator generates cloud-init network-config with special characters in SSID", "[cloudinit][network][negative]") { @@ -1451,13 +1568,10 @@ TEST_CASE("Independent step: Hostname only", "[cloudinit][independent][hostname] REQUIRE_THAT(yaml.toStdString(), !ContainsSubstring("keyboard:")); REQUIRE_THAT(yaml.toStdString(), !ContainsSubstring("rpi:")); - // Network config has eth0 with DHCP but no WiFi - QString netcfgYaml = QString::fromUtf8(netcfg); - REQUIRE_THAT(netcfgYaml.toStdString(), ContainsSubstring("ethernets:")); - REQUIRE_THAT(netcfgYaml.toStdString(), ContainsSubstring("eth0:")); - REQUIRE_THAT(netcfgYaml.toStdString(), ContainsSubstring("dhcp4: true")); - REQUIRE_THAT(netcfgYaml.toStdString(), ContainsSubstring("dhcp6: true")); - REQUIRE_THAT(netcfgYaml.toStdString(), !ContainsSubstring("wifis:")); + // No Wi-Fi here, so no network-config is emitted at all. eth0 DHCP is only + // written alongside a wifis: block, because a network-config file replaces + // the distro default and would otherwise take wired ethernet with it. + REQUIRE(netcfg.isEmpty()); } TEST_CASE("Independent step: Timezone only", "[cloudinit][independent][locale]") { @@ -1479,13 +1593,10 @@ TEST_CASE("Independent step: Timezone only", "[cloudinit][independent][locale]") REQUIRE_THAT(yaml.toStdString(), !ContainsSubstring("keyboard:")); REQUIRE_THAT(yaml.toStdString(), !ContainsSubstring("rpi:")); - // Network config has eth0 with DHCP but no WiFi - QString netcfgYaml = QString::fromUtf8(netcfg); - REQUIRE_THAT(netcfgYaml.toStdString(), ContainsSubstring("ethernets:")); - REQUIRE_THAT(netcfgYaml.toStdString(), ContainsSubstring("eth0:")); - REQUIRE_THAT(netcfgYaml.toStdString(), ContainsSubstring("dhcp4: true")); - REQUIRE_THAT(netcfgYaml.toStdString(), ContainsSubstring("dhcp6: true")); - REQUIRE_THAT(netcfgYaml.toStdString(), !ContainsSubstring("wifis:")); + // No Wi-Fi here, so no network-config is emitted at all. eth0 DHCP is only + // written alongside a wifis: block, because a network-config file replaces + // the distro default and would otherwise take wired ethernet with it. + REQUIRE(netcfg.isEmpty()); } TEST_CASE("Independent step: Keyboard only", "[cloudinit][independent][locale]") { @@ -1509,13 +1620,10 @@ TEST_CASE("Independent step: Keyboard only", "[cloudinit][independent][locale]") REQUIRE_THAT(yaml.toStdString(), !ContainsSubstring("timezone:")); REQUIRE_THAT(yaml.toStdString(), !ContainsSubstring("rpi:")); - // Network config has eth0 with DHCP but no WiFi - QString netcfgYaml = QString::fromUtf8(netcfg); - REQUIRE_THAT(netcfgYaml.toStdString(), ContainsSubstring("ethernets:")); - REQUIRE_THAT(netcfgYaml.toStdString(), ContainsSubstring("eth0:")); - REQUIRE_THAT(netcfgYaml.toStdString(), ContainsSubstring("dhcp4: true")); - REQUIRE_THAT(netcfgYaml.toStdString(), ContainsSubstring("dhcp6: true")); - REQUIRE_THAT(netcfgYaml.toStdString(), !ContainsSubstring("wifis:")); + // No Wi-Fi here, so no network-config is emitted at all. eth0 DHCP is only + // written alongside a wifis: block, because a network-config file replaces + // the distro default and would otherwise take wired ethernet with it. + REQUIRE(netcfg.isEmpty()); } TEST_CASE("Independent step: Locale (timezone + keyboard)", "[cloudinit][independent][locale]") { @@ -1567,13 +1675,10 @@ TEST_CASE("Independent step: User credentials only (no SSH)", "[cloudinit][indep REQUIRE_THAT(yaml.toStdString(), !ContainsSubstring("keyboard:")); REQUIRE_THAT(yaml.toStdString(), !ContainsSubstring("rpi:")); - // Network config has eth0 with DHCP but no WiFi - QString netcfgYaml = QString::fromUtf8(netcfg); - REQUIRE_THAT(netcfgYaml.toStdString(), ContainsSubstring("ethernets:")); - REQUIRE_THAT(netcfgYaml.toStdString(), ContainsSubstring("eth0:")); - REQUIRE_THAT(netcfgYaml.toStdString(), ContainsSubstring("dhcp4: true")); - REQUIRE_THAT(netcfgYaml.toStdString(), ContainsSubstring("dhcp6: true")); - REQUIRE_THAT(netcfgYaml.toStdString(), !ContainsSubstring("wifis:")); + // No Wi-Fi here, so no network-config is emitted at all. eth0 DHCP is only + // written alongside a wifis: block, because a network-config file replaces + // the distro default and would otherwise take wired ethernet with it. + REQUIRE(netcfg.isEmpty()); } TEST_CASE("Independent step: WiFi only", "[cloudinit][independent][wifi]") { @@ -1635,13 +1740,10 @@ TEST_CASE("Independent step: SSH with password auth only", "[cloudinit][independ REQUIRE_THAT(yaml.toStdString(), !ContainsSubstring("keyboard:")); REQUIRE_THAT(yaml.toStdString(), !ContainsSubstring("rpi:")); - // Network config has eth0 with DHCP but no WiFi - QString netcfgYaml = QString::fromUtf8(netcfg); - REQUIRE_THAT(netcfgYaml.toStdString(), ContainsSubstring("ethernets:")); - REQUIRE_THAT(netcfgYaml.toStdString(), ContainsSubstring("eth0:")); - REQUIRE_THAT(netcfgYaml.toStdString(), ContainsSubstring("dhcp4: true")); - REQUIRE_THAT(netcfgYaml.toStdString(), ContainsSubstring("dhcp6: true")); - REQUIRE_THAT(netcfgYaml.toStdString(), !ContainsSubstring("wifis:")); + // No Wi-Fi here, so no network-config is emitted at all. eth0 DHCP is only + // written alongside a wifis: block, because a network-config file replaces + // the distro default and would otherwise take wired ethernet with it. + REQUIRE(netcfg.isEmpty()); } TEST_CASE("Independent step: SSH with public keys only", "[cloudinit][independent][ssh]") { @@ -1692,13 +1794,10 @@ TEST_CASE("Independent step: Interfaces only (I2C)", "[cloudinit][independent][i REQUIRE_THAT(yaml.toStdString(), !ContainsSubstring("timezone:")); REQUIRE_THAT(yaml.toStdString(), !ContainsSubstring("keyboard:")); - // Network config has eth0 with DHCP but no WiFi - QString netcfgYaml = QString::fromUtf8(netcfg); - REQUIRE_THAT(netcfgYaml.toStdString(), ContainsSubstring("ethernets:")); - REQUIRE_THAT(netcfgYaml.toStdString(), ContainsSubstring("eth0:")); - REQUIRE_THAT(netcfgYaml.toStdString(), ContainsSubstring("dhcp4: true")); - REQUIRE_THAT(netcfgYaml.toStdString(), ContainsSubstring("dhcp6: true")); - REQUIRE_THAT(netcfgYaml.toStdString(), !ContainsSubstring("wifis:")); + // No Wi-Fi here, so no network-config is emitted at all. eth0 DHCP is only + // written alongside a wifis: block, because a network-config file replaces + // the distro default and would otherwise take wired ethernet with it. + REQUIRE(netcfg.isEmpty()); } TEST_CASE("Independent step: Interfaces only (SPI)", "[cloudinit][independent][interfaces]") { @@ -1794,13 +1893,10 @@ TEST_CASE("Independent step: Pi Connect only (with required user)", "[cloudinit] REQUIRE_THAT(yaml.toStdString(), !ContainsSubstring("keyboard:")); REQUIRE_THAT(yaml.toStdString(), !ContainsSubstring("rpi:")); - // Network config has eth0 with DHCP but no WiFi - QString netcfgYaml = QString::fromUtf8(netcfg); - REQUIRE_THAT(netcfgYaml.toStdString(), ContainsSubstring("ethernets:")); - REQUIRE_THAT(netcfgYaml.toStdString(), ContainsSubstring("eth0:")); - REQUIRE_THAT(netcfgYaml.toStdString(), ContainsSubstring("dhcp4: true")); - REQUIRE_THAT(netcfgYaml.toStdString(), ContainsSubstring("dhcp6: true")); - REQUIRE_THAT(netcfgYaml.toStdString(), !ContainsSubstring("wifis:")); + // No Wi-Fi here, so no network-config is emitted at all. eth0 DHCP is only + // written alongside a wifis: block, because a network-config file replaces + // the distro default and would otherwise take wired ethernet with it. + REQUIRE(netcfg.isEmpty()); } // ============================================================================= @@ -1981,26 +2077,14 @@ TEST_CASE("CustomisationGenerator handles empty cloud-init settings gracefully", QByteArray userdata = CustomisationGenerator::generateCloudInitUserData(settings); QByteArray netcfg = CustomisationGenerator::generateCloudInitNetworkConfig(settings); - QString userdataYaml = QString::fromUtf8(userdata); - - // User data should only have the always-present manage_resolv_conf setting - REQUIRE_THAT(userdataYaml.toStdString(), ContainsSubstring("manage_resolv_conf: false")); - // Should NOT have any user-specific configuration - REQUIRE_THAT(userdataYaml.toStdString(), !ContainsSubstring("hostname:")); - REQUIRE_THAT(userdataYaml.toStdString(), !ContainsSubstring("user:")); - REQUIRE_THAT(userdataYaml.toStdString(), !ContainsSubstring("enable_ssh:")); - REQUIRE_THAT(userdataYaml.toStdString(), !ContainsSubstring("timezone:")); - REQUIRE_THAT(userdataYaml.toStdString(), !ContainsSubstring("keyboard:")); - REQUIRE_THAT(userdataYaml.toStdString(), !ContainsSubstring("rpi:")); - - // Network config should still have eth0 with DHCP (always generated) - QString netcfgYaml = QString::fromUtf8(netcfg); - REQUIRE_THAT(netcfgYaml.toStdString(), ContainsSubstring("network:")); - REQUIRE_THAT(netcfgYaml.toStdString(), ContainsSubstring("ethernets:")); - REQUIRE_THAT(netcfgYaml.toStdString(), ContainsSubstring("eth0:")); - REQUIRE_THAT(netcfgYaml.toStdString(), ContainsSubstring("dhcp4: true")); - REQUIRE_THAT(netcfgYaml.toStdString(), ContainsSubstring("dhcp6: true")); - REQUIRE_THAT(netcfgYaml.toStdString(), !ContainsSubstring("wifis:")); + + // Nothing configured means nothing written. Both generators used to emit a + // baseline (manage_resolv_conf, and eth0 DHCP) unconditionally, which made + // the fastboot and download paths write meta-data/network-config even when + // the user had skipped customisation — and older fastboot gadgets failed on + // that write. An empty payload is what tells those paths to skip the file. + REQUIRE(userdata.isEmpty()); + REQUIRE(netcfg.isEmpty()); } TEST_CASE("CustomisationGenerator cloud-init handles empty Pi Connect token", "[cloudinit][negative]") { diff --git a/src/test/rpiboot_protocol_test.cpp b/src/test/rpiboot_protocol_test.cpp index 08ff61779..820fbaace 100644 --- a/src/test/rpiboot_protocol_test.cpp +++ b/src/test/rpiboot_protocol_test.cpp @@ -166,15 +166,24 @@ TEST_CASE("FileServer handles GetFileSize request", "[rpiboot][fileserver]") REQUIRE(server.run(mock, fw.path(), nullptr, cancelled)); - // Should have sent one control transfer with the file size - // Size is encoded in wValue (low 16 bits) / wIndex (high 16 bits), no data payload - REQUIRE(mock.capturedControlTransfers().size() == 1); - auto& ct = mock.capturedControlTransfers()[0]; + // Two control transfers: the file size, then the zero-length acknowledgement + // the server sends for the empty filename that signals "done" — an empty name + // is the device's end-of-transfer marker whatever command accompanies it, and + // acknowledging it matches upstream rpiboot. + REQUIRE(mock.capturedControlTransfers().size() == 2); + + // Size is encoded in wValue (low 16 bits) / wIndex (high 16 bits), no data + // payload. "enable_uart=1\n" is 14 bytes. + auto& sizeReply = mock.capturedControlTransfers()[0]; + CHECK(sizeReply.data.empty()); + CHECK(sizeReply.wValue == 14); + CHECK(sizeReply.wIndex == 0); - // Size of "enable_uart=1\n" = 14 bytes, encoded in wValue - CHECK(ct.data.empty()); - CHECK(ct.wValue == 14); - CHECK(ct.wIndex == 0); + // The done acknowledgement carries no payload and no size. + auto& doneAck = mock.capturedControlTransfers()[1]; + CHECK(doneAck.data.empty()); + CHECK(doneAck.wValue == 0); + CHECK(doneAck.wIndex == 0); } TEST_CASE("FileServer handles ReadFile request", "[rpiboot][fileserver]") @@ -447,16 +456,23 @@ TEST_CASE("FileServer handles missing file gracefully", "[rpiboot][fileserver]") CHECK(mock.capturedControlTransfers()[0].data.empty()); } -TEST_CASE("FileServer returns false on short bulk read", "[rpiboot][fileserver][negative]") +TEST_CASE("FileServer treats a failed message read as a fatal disconnect", "[rpiboot][fileserver][negative]") { MockUsbTransport mock; - // No responses queued — bulkRead returns -1 (short read) + // No responses queued, so controlTransferIn returns -1 (LIBUSB_ERROR_IO). + // FileMessages arrive over control IN rather than bulk IN, matching upstream + // rpiboot's ep_read(). std::atomic cancelled{false}; FileServer server; + // IO (-1) and NO_DEVICE (-4) mean the device has gone, so the server fails + // immediately instead of spending its retry budget — again matching upstream, + // which breaks out of its loop on both. Other error codes are retried; that + // path reports "Failed to read FileMessage" once the retries are exhausted. CHECK_FALSE(server.run(mock, std::filesystem::temp_directory_path(), nullptr, cancelled)); - CHECK_THAT(server.lastError(), Catch::Matchers::ContainsSubstring("Failed to read FileMessage")); + CHECK_THAT(server.lastError(), + Catch::Matchers::ContainsSubstring("Device disconnected (libusb error -1)")); } TEST_CASE("FileServer fails when control transfer fails on ReadFile size header", "[rpiboot][fileserver][negative]") diff --git a/src/windows/rpi-imager.manifest.in b/src/windows/rpi-imager.manifest.in index 81db02d18..f5f14daef 100644 --- a/src/windows/rpi-imager.manifest.in +++ b/src/windows/rpi-imager.manifest.in @@ -1,6 +1,6 @@ - + Raspberry Pi Imager diff --git a/src/windows/rpi-imager.rc.in b/src/windows/rpi-imager.rc.in index 4ce50ddd8..8cea6c170 100644 --- a/src/windows/rpi-imager.rc.in +++ b/src/windows/rpi-imager.rc.in @@ -4,8 +4,8 @@ IDI_ICON1 ICON DISCARDABLE "../src/icons/rpi-imager.ico" CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "rpi-imager.manifest" 1 VERSIONINFO -FILEVERSION @IMAGER_VERSION_MAJOR@,@IMAGER_VERSION_MINOR@,@IMAGER_VERSION_PATCH@,0 -PRODUCTVERSION @IMAGER_VERSION_MAJOR@,@IMAGER_VERSION_MINOR@,@IMAGER_VERSION_PATCH@,0 +FILEVERSION @IMAGER_VERSION_MAJOR@,@IMAGER_VERSION_MINOR@,@IMAGER_VERSION_PATCH@,@IMAGER_VERSION_TWEAK@ +PRODUCTVERSION @IMAGER_VERSION_MAJOR@,@IMAGER_VERSION_MINOR@,@IMAGER_VERSION_PATCH@,@IMAGER_VERSION_TWEAK@ { BLOCK "StringFileInfo" { diff --git a/src/wizard/DoneStep.qml b/src/wizard/DoneStep.qml index b1b1887e3..601de030d 100644 --- a/src/wizard/DoneStep.qml +++ b/src/wizard/DoneStep.qml @@ -246,7 +246,7 @@ WizardStepBase { if (root.ejectState === ImageWriterSingleton.EjectInProgress) return qsTr("Ejecting the storage device — do not remove it yet…") if (root.ejectState === ImageWriterSingleton.EjectSucceeded) - return qsTr("The storage device was ejected. You can now remove it safely.") + return qsTr("The storage device was ejected automatically. You can now remove it safely.") if (root.ejectState === ImageWriterSingleton.EjectFailed) return qsTr("The storage device could not be ejected. Close any application still using it, then press Eject.") // EjectIdle: no eject ran for this write, so never claim one diff --git a/src/wizard/HostnameCustomizationStep.qml b/src/wizard/HostnameCustomizationStep.qml index 15e58ecda..172ad8e48 100644 --- a/src/wizard/HostnameCustomizationStep.qml +++ b/src/wizard/HostnameCustomizationStep.qml @@ -78,7 +78,8 @@ WizardStepBase { placeholderText: qsTr("Enter your server name") // UNRAID font.pointSize: Style.fontSizeInput Accessible.description: root.serverNameHelp // UNRAID - + trimWhitespace: true + validator: RegularExpressionValidator { regularExpression: /^[a-zA-Z0-9][a-zA-Z0-9-]{0,62}$/ } @@ -95,7 +96,7 @@ WizardStepBase { // Save settings when moving to next step onNextClicked: { - var hostnameText = fieldHostname.text ? fieldHostname.text.trim() : "" + var hostnameText = fieldHostname.value // Update conserved customization settings (runtime state) if (hostnameText.length > 0) { diff --git a/src/wizard/PiConnectCustomizationStep.qml b/src/wizard/PiConnectCustomizationStep.qml index 3bac3ca34..408f8ca67 100644 --- a/src/wizard/PiConnectCustomizationStep.qml +++ b/src/wizard/PiConnectCustomizationStep.qml @@ -89,6 +89,7 @@ WizardStepBase { ? qsTr("Saved — type to replace") : qsTr("Paste organisation API key") accessibleDescription: qsTr("Raspberry Pi Connect organisation API key. Once saved the value is never redisplayed.") + trimWhitespace: true onTextChanged: root.orgKeyDirty = true } @@ -107,6 +108,7 @@ WizardStepBase { Layout.fillWidth: true font.pointSize: Style.fontSizeInput placeholderText: qsTr("e.g. Factory-A") + trimWhitespace: true text: root.wizardContainer ? root.wizardContainer.connectOrgDescription : "" onTextChanged: { if (root.wizardContainer) @@ -188,6 +190,7 @@ WizardStepBase { enabled: root.tokenFieldEnabled persistentSelection: true mouseSelectionMode: TextInput.SelectCharacters + trimWhitespace: true placeholderText: { if (root.connectTokenReceived) { return qsTr("Token received from browser") @@ -199,7 +202,7 @@ WizardStepBase { } text: root.connectToken onTextChanged: { - var token = text.trim() + var token = fieldConnectToken.value if (token && token.length > 0) { root.connectToken = token countdownTimer.stop() @@ -237,7 +240,7 @@ WizardStepBase { // AND we have a valid token (from browser or typed). nextButtonEnabled: { if (root.orgModeEnabled) { - if (fieldOrgApiKey.text.trim().length > 0) { + if (fieldOrgApiKey.value.length > 0) { return true } return root.hasStoredOrgKey @@ -408,8 +411,8 @@ WizardStepBase { // to write the key to the image, so the device joins the // organisation on first boot. if (root.orgModeEnabled) { - var typedKey = fieldOrgApiKey.text.trim() - var desc = fieldOrgDescription.text.trim() + var typedKey = fieldOrgApiKey.value + var desc = fieldOrgDescription.value if (typedKey.length > 0) { // Replace both key and description. ImageWriterSingleton.setConnectOrgRegistration(typedKey, desc) diff --git a/src/wizard/UserCustomizationStep.qml b/src/wizard/UserCustomizationStep.qml index d60db7276..681350268 100644 --- a/src/wizard/UserCustomizationStep.qml +++ b/src/wizard/UserCustomizationStep.qml @@ -57,7 +57,8 @@ WizardStepBase { Layout.fillWidth: true placeholderText: qsTr("Enter your username") font.pointSize: Style.fontSizeInput - + trimWhitespace: true + validator: RegularExpressionValidator { regularExpression: /^[a-z_][a-z0-9_-]*$/ } @@ -213,7 +214,7 @@ WizardStepBase { // Save settings when moving to next step onNextClicked: { - var usernameText = fieldUsername.text ? fieldUsername.text.trim() : "" + var usernameText = fieldUsername.value var hasPasswords = fieldPassword.text.length > 0 && fieldPassword.text === fieldPasswordConfirm.text // Update conserved customization settings (runtime state) diff --git a/src/wizard/WifiCustomizationStep.qml b/src/wizard/WifiCustomizationStep.qml index c8f3fcdb9..3baa3e670 100644 --- a/src/wizard/WifiCustomizationStep.qml +++ b/src/wizard/WifiCustomizationStep.qml @@ -67,7 +67,7 @@ WizardStepBase { } // If not saved, try to auto-detect the current SSID from the system - if (!fieldWifiSSID.text || fieldWifiSSID.text.length === 0) { + if (fieldWifiSSID.value.length === 0) { var detectedSsid = ImageWriterSingleton.getSSID() console.log("WifiCustomizationStep: detected SSID:", detectedSsid) if (detectedSsid && detectedSsid.length > 0) { @@ -89,10 +89,10 @@ WizardStepBase { // IMPORTANT: Only attempt PSK retrieval if we have an SSID (either saved or detected) // Pass the SSID to getPSKForSSID() to avoid race condition where SSID detection // might fail during the keychain permission dialog on macOS - if (!hadSavedCrypt && fieldWifiSSID.text && fieldWifiSSID.text.length > 0) { + if (!hadSavedCrypt && fieldWifiSSID.value.length > 0) { // Auto-populate WiFi password from system keychain when available // Only when no crypted password is already saved - var psk = ImageWriterSingleton.getPSKForSSID(fieldWifiSSID.text) + var psk = ImageWriterSingleton.getPSKForSSID(fieldWifiSSID.value) if (psk && psk.length > 0) { fieldWifiPassword.text = psk fieldWifiPasswordConfirm.text = psk @@ -117,7 +117,7 @@ WizardStepBase { function onLocationPermissionGranted() { console.log("WifiCustomizationStep: Location permission granted, retrying SSID detection") // Only retry if SSID field is still empty (user hasn't manually entered one) - if (!fieldWifiSSID.text || fieldWifiSSID.text.length === 0) { + if (fieldWifiSSID.value.length === 0) { var detectedSsid = ImageWriterSingleton.getSSID() console.log("WifiCustomizationStep: re-detected SSID:", detectedSsid) if (detectedSsid && detectedSsid.length > 0) { @@ -138,7 +138,7 @@ WizardStepBase { } function updatePasswordFieldUI() { - var ssid = (fieldWifiSSID.text || "").trim() + var ssid = fieldWifiSSID.value var prevSSID = originalSavedSSID if (wifiMode === "open") { @@ -162,7 +162,7 @@ WizardStepBase { if (!showPw) return " "; // Gather state - var ssidNow = (fieldWifiSSID.text || "").trim(); + var ssidNow = fieldWifiSSID.value; var canKeep = hadSavedCrypt && ssidUnchanged(ssidNow, originalSavedSSID); var pwd = fieldWifiPassword.text || ""; var conf = fieldWifiPasswordConfirm.text || ""; @@ -298,6 +298,7 @@ WizardStepBase { id: fieldWifiSSID Layout.fillWidth: true font.pointSize: Style.fontSizeInput + trimWhitespace: true onTextChanged: updatePasswordFieldUI() onActiveFocusChanged: { if (activeFocus) @@ -310,7 +311,7 @@ WizardStepBase { text: CommonStrings.password visible: root.showPw accessibleDescription: { - var canKeep = root.hadSavedCrypt && ssidUnchanged((fieldWifiSSID.text || "").trim(), root.originalSavedSSID) + var canKeep = root.hadSavedCrypt && ssidUnchanged(fieldWifiSSID.value, root.originalSavedSSID) return canKeep ? qsTr("Enter a new Wi-Fi password, or leave blank to keep the previously saved password. Must be 8-63 characters or a 64-character hexadecimal key.") : qsTr("Enter your Wi-Fi network password. Must be 8-63 characters or a 64-character hexadecimal key. You will need to re-enter it in the next field to confirm.") @@ -339,7 +340,7 @@ WizardStepBase { text: qsTr("Confirm password:") visible: root.showPw accessibleDescription: { - var canKeep = root.hadSavedCrypt && ssidUnchanged((fieldWifiSSID.text || "").trim(), root.originalSavedSSID) + var canKeep = root.hadSavedCrypt && ssidUnchanged(fieldWifiSSID.value, root.originalSavedSSID) return canKeep ? qsTr("Re-enter the new Wi-Fi password to confirm, or leave blank to keep the previously saved password.") : qsTr("Re-enter the Wi-Fi password to confirm it matches.") @@ -351,7 +352,7 @@ WizardStepBase { Layout.fillWidth: true font.pointSize: Style.fontSizeInput placeholderText: { - var canKeep = root.hadSavedCrypt && ssidUnchanged((fieldWifiSSID.text || "").trim(), root.originalSavedSSID) + var canKeep = root.hadSavedCrypt && ssidUnchanged(fieldWifiSSID.value, root.originalSavedSSID) return canKeep ? qsTr("Re-enter to change password") : qsTr("Re-enter password") } visible: root.showPw @@ -444,13 +445,13 @@ WizardStepBase { // - SSID entered and either new PSK provided or a saved crypt exists; or // - all WiFi fields are empty (skip) nextButtonEnabled: (function(){ - var haveSSID = fieldWifiSSID.text && fieldWifiSSID.text.trim().length > 0 + var haveSSID = fieldWifiSSID.value.length > 0 if (!haveSSID) return true // allow skipping by leaving fields empty if (wifiMode === "open") return true // secure / closed mode - var ssidNow = fieldWifiSSID.text.trim() + var ssidNow = fieldWifiSSID.value var canKeep = hadSavedCrypt && ssidUnchanged(ssidNow, originalSavedSSID) var pwd = fieldWifiPassword.text || "" @@ -470,7 +471,7 @@ WizardStepBase { // Save settings when moving to next step onNextClicked: { - var ssid = fieldWifiSSID.text ? fieldWifiSSID.text.trim() : "" + var ssid = fieldWifiSSID.value var pwd = fieldWifiPassword.text var prevSSID = wizardContainer.customizationSettings.wifiSSID || "" var hidden = chkWifiHidden.checked diff --git a/src/wizard/components/SshKeyManager.qml b/src/wizard/components/SshKeyManager.qml index c999a3d4c..10a97a3ca 100644 --- a/src/wizard/components/SshKeyManager.qml +++ b/src/wizard/components/SshKeyManager.qml @@ -268,9 +268,10 @@ ColumnLayout { font.pointSize: Style.fontSizeInput Accessible.name: qsTr("SSH public key input") Accessible.description: qsTr("Paste an SSH public key here or use the browse button to select a key file") + trimWhitespace: true onAccepted: { - if (text.trim().length > 0) { - root.addKey(text) + if (addKeyField.value.length > 0) { + root.addKey(addKeyField.value) text = "" } } @@ -278,11 +279,11 @@ ColumnLayout { ImButton { id: addOrBrowseButton - text: addKeyField.text.trim().length > 0 ? qsTr("Add") : CommonStrings.browse + text: addKeyField.value.length > 0 ? qsTr("Add") : CommonStrings.browse Layout.minimumWidth: 80 onClicked: { - if (addKeyField.text.trim().length > 0) { - root.addKey(addKeyField.text) + if (addKeyField.value.length > 0) { + root.addKey(addKeyField.value) addKeyField.text = "" } else { // Browse for file @@ -301,7 +302,7 @@ ColumnLayout { } } } - accessibleDescription: addKeyField.text.trim().length > 0 + accessibleDescription: addKeyField.value.length > 0 ? qsTr("Add the entered SSH key") : qsTr("Select an SSH public key file to add") } diff --git a/src/wizard/dialogs/RepositoryDialog.qml b/src/wizard/dialogs/RepositoryDialog.qml index c3ac91dd0..687de2268 100644 --- a/src/wizard/dialogs/RepositoryDialog.qml +++ b/src/wizard/dialogs/RepositoryDialog.qml @@ -176,9 +176,10 @@ BaseDialog { font.pointSize: Style.fontSizeInput activeFocusOnTab: true inputMethodHints: Qt.ImhUrlCharactersOnly + trimWhitespace: true // Use ImageWriter's validation method for consistency - property bool isValid: ImageWriterSingleton && ImageWriterSingleton.isValidRepoUrl(text) + property bool isValid: ImageWriterSingleton && ImageWriterSingleton.isValidRepoUrl(fieldCustomUri.value) } } } @@ -304,9 +305,9 @@ BaseDialog { ImageWriterSingleton.refreshOsListFrom(selectedRepo) // reset wizard to device selection because the repository changed wizardContainer.resetWizard() - } else if (radioCustomUri.checked && originalRepo !== fieldCustomUri.text) { + } else if (radioCustomUri.checked && originalRepo !== fieldCustomUri.value) { // QML auto-converts string to QUrl for C++ method - ImageWriterSingleton.refreshOsListFrom(fieldCustomUri.text) + ImageWriterSingleton.refreshOsListFrom(fieldCustomUri.value) // reset wizard to device selection because the repository changed wizardContainer.resetWizard() }