#!/usr/bin/env bash # Build and pack the Windows GUI (app/cst-agent-app.exe) from Linux. # # scripts/package_windows_app.sh (run it from the repo root) # nix-shell shell-win.nix --run scripts/package_windows_app.sh # --run works from anywhere; the script re-enters the shell itself when the # mingw toolchain is not on PATH. # # The toolchain is the same one the TUI cross build uses (shell-win.nix: mingw # UCRT, x86_64-w64-mingw32). Qt comes from MSYS2, prebuilt for exactly that # target, and the host (Linux) tools come from nixpkgs' native Qt6: both are # 6.11.2, which is what Qt's own version check wants. # # Why not nixpkgs' mingw Qt: it does not build in this nixpkgs. bash 5.3 fails # to cross-compile for mingw (`sigset_t` is not in the mingw headers), which # takes tcl down, which sqlite needs, which qtbase needs; before that, giflib's # mingw patch no longer applies to its own source. Three separate overlays got # as far as qtbase before that wall. MSYS2 has the same Qt prebuilt. # # MSYS2's Qt ships *Windows* tools (moc.exe, rcc.exe, qmlcachegen.exe, ...); # the build runs on Linux, so each one is replaced by a one-line shim that # calls the host tool. windeployqt cannot run here either, so the deployment # half of this script copies the DLLs, plugins and QML modules by hand: the # DLLs by walking the exe's imports, the rest from the layout Qt expects. # # Output: dist/windows-x86_64-app/ and # dist/cst-agent-app-VERSION-windows-x86_64.zip (+ .sha256) set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" MIRROR="${CST_MSYS_MIRROR:-https://mirror.msys2.org/mingw/ucrt64}" PKGS="$ROOT/third_party/msys-qt6" # MSYS2 packages, extracted PREFIX="$PKGS/ucrt64" # what CMAKE_PREFIX_PATH points at HOST="${CST_QT6_HOST:-$ROOT/third_party/prefix-qt6-host}" BUILD="$ROOT/build-win-app" OUT="$ROOT/dist/windows-x86_64-app" if ! command -v x86_64-w64-mingw32-gcc >/dev/null && [[ -z "${IN_NIX_SHELL:-}" ]] && command -v nix-shell >/dev/null; then exec nix-shell "$ROOT/shell-win.nix" --run "$(printf '%q ' "$0" "$@")" fi command -v x86_64-w64-mingw32-gcc >/dev/null || { echo "need the mingw UCRT toolchain (run through shell-win.nix)" >&2 exit 1 } OBJDUMP="$(command -v x86_64-w64-mingw32-objdump || echo objdump)" STRIP="$(command -v x86_64-w64-mingw32-strip || echo strip)" # --- the MSYS2 packages ---------------------------------------------------- # The Qt modules the app links, plus every package that provides a DLL those # modules load (found with x86_64-w64-mingw32-objdump -p, then against # ucrt64.files from the same mirror). Versions are taken from the mirror # listing, so a new Qt6 release only means a new run of this script. MSYS_PKGS=( mingw-w64-ucrt-x86_64-qt6-base mingw-w64-ucrt-x86_64-qt6-declarative mingw-w64-ucrt-x86_64-qt6-svg mingw-w64-ucrt-x86_64-libgcc mingw-w64-ucrt-x86_64-libstdc++ mingw-w64-ucrt-x86_64-libwinpthread mingw-w64-ucrt-x86_64-libb2 mingw-w64-ucrt-x86_64-double-conversion mingw-w64-ucrt-x86_64-icu mingw-w64-ucrt-x86_64-pcre2 mingw-w64-ucrt-x86_64-zlib mingw-w64-ucrt-x86_64-zstd mingw-w64-ucrt-x86_64-freetype mingw-w64-ucrt-x86_64-harfbuzz mingw-w64-ucrt-x86_64-md4c mingw-w64-ucrt-x86_64-libpng mingw-w64-ucrt-x86_64-brotli mingw-w64-ucrt-x86_64-bzip2 # harfbuzz reaches for these (and glib2 for libffi/gettext below): they are # not what the app links directly, but the shaper the font engine loads. mingw-w64-ucrt-x86_64-glib2 mingw-w64-ucrt-x86_64-graphite2 mingw-w64-ucrt-x86_64-libffi mingw-w64-ucrt-x86_64-gettext-runtime mingw-w64-ucrt-x86_64-libiconv ) listing="$PKGS/listing.html" mkdir -p "$PKGS" if [[ ! -s "$listing" || -n "${CST_MSYS_REFRESH:-}" ]]; then echo "==> mirror listing" curl -fsSL "$MIRROR/" -o "$listing" fi # The newest file of a package (no -debug, no .sig): the mirror keeps every # revision, and the listing is one HTTP page, so this is the whole lookup. The # names come out of the links (the text of a link is elided with "..") and are # matched literally, because a package name may hold regex characters # ("libstdc++", which the listing writes as %2B and the link as a plus). allPkgs="$(grep -oE 'href="[^"]+\.pkg\.tar\.zst"' "$listing" | sed 's/^href="//; s/"$//; s/%2[bB]/+/g' | grep -v -- '-debug-' | sort -V -u)" newest() { # newest PKGNAME # ... and the name has to end there: "harfbuzz-" also prefixes # "harfbuzz-utils", "brotli-" prefixes "brotli-testdata", "zlib-" prefixes # "zlib-ng-compat". What follows the name is a version, which starts with a # digit. printf '%s\n' "$allPkgs" | awk -v pre="$1-" ' index($0, pre) == 1 && substr($0, length(pre) + 1) ~ /^[0-9]/' | sort -V | tail -1 } for p in "${MSYS_PKGS[@]}"; do f="$(newest "$p")" [[ -n "$f" ]] || { echo "no package $p in the mirror listing" >&2; exit 1; } if [[ ! -f "$PKGS/$f" ]]; then echo "==> $f" curl -fsSL "$MIRROR/$f" -o "$PKGS/$f" fi # A package that is already extracted leaves its files in place: tar is # happy to overwrite, so this just refreshes whatever changed. tar --zstd -xf "$PKGS/$f" -C "$PKGS" done [[ -f "$PREFIX/lib/cmake/Qt6/Qt6Config.cmake" ]] || { echo "$PREFIX does not look like a Qt6 install" >&2 exit 1 } # --- the host tools -------------------------------------------------------- # nixpkgs' native Qt 6, in one prefix: moc, rcc, qmlcachegen, qmltyperegistrar, # qmlimportscanner, qtpaths and their CMake packages (see scripts/qt6-host.nix). if [[ ! -x "$HOST/libexec/moc" ]]; then echo "==> host Qt6 tools" nix build --impure -f "$ROOT/scripts/qt6-host.nix" --out-link "$HOST" fi # Every .exe MSYS2 ships is a Windows program: replace each with a shim that # runs the host tool of the same name. Done in the extracted tree, so the # Qt6*Tools CMake packages keep pointing at the paths they record. shim() { # shim FILE HOSTTOOL printf '#!/bin/sh\nexec %s "$@"\n' "$2" >"$1" chmod +x "$1" } shimmed=0 for f in "$PREFIX"/share/qt6/bin/*.exe "$PREFIX"/bin/*.exe; do [[ -e "$f" ]] || continue n="$(basename "$f" .exe)" for cand in "$HOST/libexec/$n" "$HOST/bin/$n"; do if [[ -x "$cand" ]]; then shim "$f" "$cand" shimmed=$((shimmed + 1)) break fi done done echo "==> $shimmed Qt tools point at the host ones" # --- build ----------------------------------------------------------------- echo "==> cmake (Windows, GUI)" cmake -S "$ROOT" -B "$BUILD" -G Ninja \ -DCMAKE_TOOLCHAIN_FILE="$ROOT/cmake/mingw-w64-x86_64.cmake" \ -DCMAKE_BUILD_TYPE=Release \ -DCST_BUILD_TUI=OFF -DCST_BUILD_APP=ON \ -DCMAKE_PREFIX_PATH="$PREFIX" \ -DQT_HOST_PATH="$HOST" # Only the app: the Win32 test targets are host-unrunnable and some of them are # POSIX (tests/test_tools_ext.c calls mkdir with two arguments), exactly as in # the TUI cross build. cmake --build "$BUILD" -j"$(nproc)" --target cst-agent-app EXE="$BUILD/app/cst-agent-app.exe" [[ -f "$EXE" ]] || { echo "no cst-agent-app.exe" >&2; exit 1; } # --- the package ----------------------------------------------------------- ver="$(sed -n 's/^#define CST_VERSION_\(MAJOR\|MINOR\|PATCH\) \([0-9]*\)$/\2/p' \ "$ROOT/include/cst_version.h" | paste -sd.)" rm -rf "$OUT" mkdir -p "$OUT" cp -f "$EXE" "$OUT/cst-agent-app.exe" "$STRIP" "$OUT/cst-agent-app.exe" 2>/dev/null || true # Which DLLs the app needs: the exe's imports, then theirs, and so on. What # Windows itself provides is skipped; anything else has to come out of the # extracted packages, which is what decided the list of packages above. missing=0 isSystem() { # isSystem DLLNAME case "${1,,}" in api-ms-win-*|kernel32.dll|user32.dll|advapi32.dll|shell32.dll|ws2_32.dll|\ iphlpapi.dll|bcrypt.dll|crypt32.dll|secur32.dll|ole32.dll|oleaut32.dll|\ gdi32.dll|comdlg32.dll|dwmapi.dll|uxtheme.dll|winmm.dll|dnsapi.dll|\ version.dll|shlwapi.dll|netapi32.dll|userenv.dll|mpr.dll|ntdll.dll|\ authz.dll|d3d11.dll|d3d12.dll|dwrite.dll|dxgi.dll|winhttp.dll|\ rpcrt4.dll|usp10.dll) return 0 ;; esac return 1 } copyDeps() { # copyDeps FILE... local queue=("$@") f n src while ((${#queue[@]})); do f="${queue[0]}" queue=("${queue[@]:1}") while read -r n; do [[ -n "$n" ]] || continue isSystem "$n" && continue [[ -e "$OUT/$n" ]] && continue src="" for c in "$PREFIX/bin/$n" "$PREFIX/lib/$n"; do [[ -e "$c" ]] && src="$c" && break done if [[ -z "$src" ]]; then echo "missing DLL: $n (needed by $(basename "$f"))" >&2 missing=1 continue fi cp -f "$src" "$OUT/$n" queue+=("$OUT/$n") done < <("$OBJDUMP" -p "$f" | awk '/DLL Name/{print $3}') done } copyDeps "$OUT/cst-agent-app.exe" # The plugins Qt loads by name: the window itself (and the offscreen one, so a # headless run is possible), the image formats a transcript can carry, the SVG # icon engine (the app's icons are .svg through QIcon). mkdir -p "$OUT/plugins" for p in platforms/qwindows.dll platforms/qoffscreen.dll \ iconengines/qsvgicon.dll; do [[ -f "$PREFIX/share/qt6/plugins/$p" ]] || { echo "no plugin $p" >&2 missing=1 continue } mkdir -p "$OUT/plugins/$(dirname "$p")" cp -f "$PREFIX/share/qt6/plugins/$p" "$OUT/plugins/$p" done for p in imageformats/*.dll; do [[ -e "$PREFIX/share/qt6/plugins/$p" ]] || continue mkdir -p "$OUT/plugins/imageformats" cp -f "$PREFIX/share/qt6/plugins/$p" "$OUT/plugins/imageformats/" done # The QML modules the window imports (QtQuick, QtQuick.Controls and its Basic # style, Layouts, Window, ...): the whole tree, which is what a deployed Qt app # has next to its exe, and what the QML import path picks up from /qml. echo "==> QML modules" cp -r "$PREFIX/share/qt6/qml" "$OUT/qml" # ... and the plugins those QML modules load (a style, a helper, an image # provider: each is a DLL with its own imports). while IFS= read -r d; do copyDeps "$d" done < <(find "$OUT/qml" -name '*.dll') # What the engine's shell tool wants beside it, exactly as the TUI package # ships it: the static busybox for Windows is the shell the engine runs. if [[ -f "$ROOT/third_party/busybox/busybox.exe" ]]; then cp -f "$ROOT/third_party/busybox/busybox.exe" "$OUT/busybox.exe" cp -f "$ROOT/third_party/busybox/LICENSE.busybox" "$OUT/" 2>/dev/null || true fi # Qt finds its plugins and QML imports relative to the exe through this file. cat >"$OUT/qt.conf" <<'EOF' [Paths] Prefix = . Plugins = plugins Qml2Imports = qml EOF echo "$ver" >"$OUT/VERSION" if [[ "$missing" != 0 ]]; then echo "the package is incomplete (see the missing entries above)" >&2 exit 1 fi zip="$ROOT/dist/cst-agent-app-$ver-windows-x86_64.zip" rm -f "$zip" (cd "$ROOT/dist" && zip -qr "$zip" windows-x86_64-app) (cd "$ROOT/dist" && sha256sum "$(basename "$zip")" >"$(basename "$zip").sha256") du -sh "$OUT" ls -l "$zip" echo "$zip"