#!/usr/bin/env bash # Build a Debian package (.deb) for rcalc. # # By default the release binary is compiled inside Ubuntu 22.04 (glibc 2.35) — # the same baseline and the same cargo cache (target-appimage) as # scripts/build-appimage.sh — so the package installs on Ubuntu 22.04 / 24.04 # and not only on this rolling host distro. # # Package layout (FHS): # /usr/bin/rcalc stripped release binary # /usr/share/applications/rcalc.desktop launcher (Exec=rcalc) # /usr/share/icons/hicolor/*x*/apps/rcalc.png icons from ico/ (or assets/) # /usr/share/metainfo/rcalc.appdata.xml AppStream metadata # /usr/share/doc/rcalc/README.md # # Env switches: # DEB_REVISION packaging revision appended to the Cargo.toml version # (0.1.28 + 1 → "Version: 0.1.28-1"); bump it for updates that # change only the package, not the app # REPO=1 also generate dist/Packages(.gz) → dist/ usable as an apt repo # SKIP_DOCKER=1 build on the host instead of Docker (fast, but the deb will # then require this host's glibc — see DEPENDS below) # PREBUILT=1 do not compile, package the existing dist/rcalc as-is # NO_STRIP=1 keep symbols in the binary (much bigger package) # KEEP_STAGE=1 leave dist/deb-root/ around for inspection # DOCKER_IMAGE build container image (default: ubuntu:22.04) # DEB_MAINTAINER "Name " (default: git user.name/user.email) set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" PKG="rcalc" OUT_DIR="${OUT_DIR:-${ROOT}/dist}" META_DIR="${ROOT}/packaging/deb" STAGE="${OUT_DIR}/deb-root" ARCH="$(dpkg --print-architecture 2>/dev/null || uname -m)" VERSION="$(grep -m1 '^version' "${ROOT}/Cargo.toml" | sed 's/.*"\(.*\)".*/\1/')" # Debian version = upstream (Cargo.toml) + packaging revision: "0.1.28-1". # The revision lets you ship an update (fixed desktop file, new icon, rebuilt # binary) WITHOUT touching Cargo.toml; dpkg compares 0.1.28-1 < 0.1.28-2 < # 0.1.29-1 correctly. DEB_REVISION is the knob for that. DEB_REVISION="${DEB_REVISION:-1}" DEB_VERSION="${VERSION}-${DEB_REVISION}" DEB_FILE="${OUT_DIR}/${PKG}_${DEB_VERSION}_${ARCH}.deb" DOCKER_IMAGE="${DOCKER_IMAGE:-ubuntu:22.04}" SKIP_DOCKER="${SKIP_DOCKER:-0}" PREBUILT="${PREBUILT:-0}" NO_STRIP="${NO_STRIP:-0}" KEEP_STAGE="${KEEP_STAGE:-0}" # Nested $( ) with quotes inside ${:- } is parsed unreliably by bash — resolve first. GIT_NAME="$(git -C "${ROOT}" config user.name 2>/dev/null || true)" GIT_MAIL="$(git -C "${ROOT}" config user.email 2>/dev/null || true)" MAINTAINER="${DEB_MAINTAINER:-${GIT_NAME:-${PKG}-packaging} <${GIT_MAIL:-localhost}>}" need_cmd() { command -v "$1" >/dev/null 2>&1 || { echo "error: required command not found: $1" >&2 exit 1 } } # ---------------------------------------------------------------- binary ---- build_binary_docker() { need_cmd docker echo "Building release binary in ${DOCKER_IMAGE} (glibc 2.35 baseline)…" mkdir -p "${HOME}/.cargo/registry" "${HOME}/.cargo/git" docker run --rm \ -v "${ROOT}:/src" \ -v "${HOME}/.cargo/registry:/root/.cargo/registry" \ -v "${HOME}/.cargo/git:/root/.cargo/git" \ -w /src \ "${DOCKER_IMAGE}" \ bash -lc ' set -euo pipefail export DEBIAN_FRONTEND=noninteractive apt-get update -qq apt-get install -y -qq curl build-essential pkg-config libfontconfig1-dev ca-certificates curl --proto "=https" --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal # shellcheck disable=SC1091 source "$HOME/.cargo/env" export CARGO_TARGET_DIR=/src/target-appimage cargo build --release install -D -m755 /src/target-appimage/release/rcalc /src/dist/rcalc ldd --version | head -1 > /src/dist/.glibc-build.txt # Make artifacts writable by the host user chown -R "$(id -u):$(id -g)" /src/dist/rcalc /src/dist/.glibc-build.txt /src/target-appimage || true ' } build_binary_host() { echo "Building release binary on the host ($(ldd --version | head -1))…" (cd "${ROOT}" && cargo build --release) install -D -m755 "${ROOT}/target/release/${PKG}" "${OUT_DIR}/${PKG}" ldd --version | head -1 > "${OUT_DIR}/.glibc-build.txt" || true } ensure_binary() { mkdir -p "${OUT_DIR}" if [[ "${PREBUILT}" == "1" ]]; then echo "PREBUILT=1: packaging ${OUT_DIR}/${PKG} as-is" elif [[ "${SKIP_DOCKER}" == "1" ]]; then build_binary_host else build_binary_docker fi if [[ ! -x "${OUT_DIR}/${PKG}" ]]; then echo "error: ${OUT_DIR}/${PKG} missing — run without PREBUILT=1" >&2 exit 1 fi } # ---------------------------------------------------------------- staging --- stage_tree() { rm -rf "${STAGE}" mkdir -p \ "${STAGE}/DEBIAN" \ "${STAGE}/usr/bin" \ "${STAGE}/usr/share/applications" \ "${STAGE}/usr/share/metainfo" \ "${STAGE}/usr/share/doc/${PKG}" install -m755 "${OUT_DIR}/${PKG}" "${STAGE}/usr/bin/${PKG}" if [[ "${NO_STRIP}" != "1" ]] && command -v strip >/dev/null 2>&1; then strip --strip-unneeded "${STAGE}/usr/bin/${PKG}" fi install -m644 "${META_DIR}/${PKG}.desktop" \ "${STAGE}/usr/share/applications/${PKG}.desktop" sed -e "s|@VERSION@|${VERSION}|" \ -e "s|@RELEASE_DATE@|$(date -u +%Y-%m-%d)|" \ "${META_DIR}/${PKG}.appdata.xml.in" \ > "${STAGE}/usr/share/metainfo/${PKG}.appdata.xml" gzip -9 -c "${ROOT}/README.md" > "${STAGE}/usr/share/doc/${PKG}/README.md.gz" echo "Binary: $(file -b "${STAGE}/usr/bin/${PKG}" | cut -d, -f1-3)" } # Icons for the desktop entry: /usr/share/icons/hicolor/x/apps/rcalc.png # # Source of truth, in order: # 1. ico/*.png — ready-made sizes dropped in by hand. The REAL pixel size of # each file decides which hicolor slot it fills (file names are ignored), # and ico/ wins for the whole family: mixing ico/ artwork with assets/ # artwork would show a different picture depending on the icon size. # 2. assets/icon.png resized to the standard sizes (needs ImageMagick). # 3. the three PNGs bundled in assets/, as-is. stage_icons() { local dst="${STAGE}/usr/share/icons/hicolor" local prepared=0 master="" master_size=0 size local magick="" for tool in magick convert; do if command -v "${tool}" >/dev/null 2>&1; then magick="${tool}" break fi done if [[ -d "${ROOT}/ico" ]] && [[ -n "${magick}" ]]; then for f in "${ROOT}"/ico/*.png; do [[ -f "${f}" ]] || continue local w h read -r w h <<<"$(identify -format '%w %h' "${f}" 2>/dev/null || echo '0 0')" || true [[ "${w}" == "${h}" ]] && [[ "${w}" -gt 0 ]] || continue if (( w > master_size )); then master="${f}" master_size="${w}" fi local slot="${dst}/${w}x${w}/apps/${PKG}.png" if [[ ! -e "${slot}" ]]; then mkdir -p "${dst}/${w}x${w}/apps" install -m644 "${f}" "${slot}" prepared=$((prepared + 1)) fi done fi if (( prepared > 0 )); then # Fill the remaining standard sizes from the biggest ico/ file — never # upscale, so a 512px source does not produce a blurry 1024px icon. if [[ -n "${magick}" ]]; then for size in 16 24 32 48 64 96 128 256 512; do [[ -e "${dst}/${size}x${size}/apps/${PKG}.png" ]] && continue (( size <= master_size )) || continue mkdir -p "${dst}/${size}x${size}/apps" "${magick}" "${master}" -resize "${size}x${size}" \ "${dst}/${size}x${size}/apps/${PKG}.png" done fi echo "Icons: ${prepared} ready-made from ico/ (master ${master_size}px)" return 0 fi if [[ -n "${magick}" ]]; then for size in 16 24 32 48 64 128 256 512 1024; do mkdir -p "${dst}/${size}x${size}/apps" "${magick}" "${ROOT}/assets/icon.png" -resize "${size}x${size}" \ "${dst}/${size}x${size}/apps/${PKG}.png" done echo "Icons: resized from assets/icon.png (1024px master)" else install -D -m644 "${ROOT}/assets/icon-64.png" "${dst}/64x64/apps/${PKG}.png" install -D -m644 "${ROOT}/assets/icon-256.png" "${dst}/256x256/apps/${PKG}.png" install -D -m644 "${ROOT}/assets/icon.png" "${dst}/1024x1024/apps/${PKG}.png" echo "Icons: assets/*.png as-is (ImageMagick not found)" fi } # ------------------------------------------------------------ dependencies -- # Real library dependencies, read from the ELF itself. GL / X11 / Wayland # libraries are dlopen()ed by winit at runtime, so they never show up in # NEEDED — they are listed in Recommends in control.in instead. compute_depends() { local depends="" if [[ "${USE_SHLIBDEPS:-1}" == "1" ]] && command -v dpkg-shlibdeps >/dev/null 2>&1; then local tmp tmp="$(mktemp -d)" mkdir -p "${tmp}/debian" # dpkg-shlibdeps insists on reading a debian/control next to it. cat > "${tmp}/debian/control" < "${tmp}/debian/${PKG}.substvars" (cd "${tmp}" && dpkg-shlibdeps -T"debian/${PKG}.substvars" "${STAGE}/usr/bin/${PKG}") \ >/dev/null 2>&1 || true depends="$(sed -n 's/^shlibs:Depends=//p' "${tmp}/debian/${PKG}.substvars")" rm -rf "${tmp}" fi if [[ -z "${depends}" ]]; then echo "warning: dpkg-shlibdeps gave nothing, using a hand-written Depends list" >&2 depends='libc6, libfontconfig1, libgcc-s1' fi printf '%s' "${depends}" } # ---------------------------------------------------------------- control --- stage_control() { local installed_size depends installed_size="$(du -s -k --exclude="${STAGE}/DEBIAN" "${STAGE}" | awk '{print $1}')" depends="$(compute_depends)" sed -e "s|@VERSION@|${DEB_VERSION}|" \ -e "s|@ARCHITECTURE@|${ARCH}|" \ -e "s|@MAINTAINER@|${MAINTAINER}|" \ -e "s|@INSTALLED_SIZE@|${installed_size}|" \ -e "s|@DEPENDS@|${depends}|" \ "${META_DIR}/control.in" > "${STAGE}/DEBIAN/control" # Maintainer scripts must be executables owned by root in the archive. install -m755 "${META_DIR}/postinst" "${STAGE}/DEBIAN/postinst" install -m755 "${META_DIR}/postrm" "${STAGE}/DEBIAN/postrm" (cd "${STAGE}" && find . -path ./DEBIAN -prune -o -type f -print \ | sed 's|^\./||' | sort | xargs -r md5sum) > "${STAGE}/DEBIAN/md5sums" echo "Version: ${DEB_VERSION} (upstream ${VERSION} + revision ${DEB_REVISION})" echo "Architecture: ${ARCH}" echo "Maintainer: ${MAINTAINER}" echo "Installed-Size: ${installed_size} KB" echo "Depends: ${depends}" } # ------------------------------------------------------- version / update --- # What "apt upgrade" needs: a version that is strictly greater than the one # already installed, and a package name that does not change. Both are checked # here so a release is never silently unpromotable. check_version() { case "${VERSION}" in [0-9]*) ;; *) echo "error: upstream version '${VERSION}' must start with a digit (dpkg rule)" >&2; exit 1 ;; esac if [[ ! "${VERSION}" =~ ^[0-9A-Za-z.+-~:]+$ || ! "${DEB_REVISION}" =~ ^[0-9A-Za-z.+-~]+$ ]]; then echo "error: bad version '${DEB_VERSION}' — allowed chars are letters, digits and . + - ~ :" >&2 exit 1 fi local installed installed="$(dpkg-query -W -f='${Version}' "${PKG}" 2>/dev/null || true)" if [[ -n "${installed}" ]]; then if dpkg --compare-versions "${installed}" ge "${DEB_VERSION}"; then echo "warning: ${PKG} ${installed} is ALREADY installed and is not older than ${DEB_VERSION}" >&2 echo " → 'apt upgrade' will do nothing. Bump Cargo.toml or DEB_REVISION." >&2 else echo "Upgrade path: ${installed} → ${DEB_VERSION} (apt upgrade will pick this up)" fi else echo "Upgrade path: fresh install of ${DEB_VERSION}" fi # scripts/install-desktop.sh links ~/.local/bin/rcalc → dist/rcalc, and # ~/.local/bin comes before /usr/bin in PATH: the packaged binary would be # shadowed by an old copy even after a successful upgrade. if [[ -e "${HOME}/.local/bin/${PKG}" ]]; then echo "warning: ${HOME}/.local/bin/${PKG} exists and shadows /usr/bin/${PKG}" >&2 echo " (installed by scripts/install-desktop.sh — remove it after switching to the .deb)" >&2 fi } # /usr/share/doc/rcalc/changelog.Debian.gz — Debian policy expects a changelog, # and it is the human-readable history of what every version changed. # packaging/deb/changelog (kept in git) wins; otherwise one entry is generated # from the current commit so the package is never changelog-less. stage_changelog() { local doc="${STAGE}/usr/share/doc/${PKG}" mkdir -p "${doc}" local plain="${doc}/changelog.Debian" # gzip → changelog.Debian.gz (Debian-side history) if [[ -f "${META_DIR}/changelog" ]]; then cp "${META_DIR}/changelog" "${plain}" echo "Changelog: packaging/deb/changelog" else local githash gitsubject githash="$(git -C "${ROOT}" rev-parse --short HEAD 2>/dev/null || echo unknown)" gitsubject="$(git -C "${ROOT}" log -1 --pretty=%s 2>/dev/null || echo packaging)" { printf '%s (%s) unstable; urgency=medium\n\n' "${PKG}" "${DEB_VERSION}" printf ' * Upstream %s, packaged as %s.\n' "${VERSION}" "${DEB_VERSION}" if [[ -f "${OUT_DIR}/.glibc-build.txt" ]]; then printf ' * Built against %s\n' "$(cat "${OUT_DIR}/.glibc-build.txt")" fi printf ' * git %s: %s\n\n' "${githash}" "${gitsubject}" printf ' -- %s %s\n' "${MAINTAINER}" "$(date -R)" } > "${plain}" echo "Changelog: generated (add packaging/deb/changelog to keep real history)" fi gzip -9n "${plain}" # -n: no timestamp → reproducible .gz rm -f "${plain}" } # REPO=1 → turn dist/ into a flat apt repository, so updates can be installed # with plain `apt update && apt upgrade` instead of pointing at a .deb file. make_repo() { [[ "${REPO:-0}" == "1" ]] || return 0 need_cmd dpkg-scanpackages ( cd "${OUT_DIR}" # One pass: Packages is the index, Packages.gz the compressed copy apt reads. # Keep both files: apt reads Packages.gz, humans diff/read Packages. dpkg-scanpackages --multiversion . /dev/null > Packages 2>/dev/null gzip -9n -c Packages > Packages.gz ) echo "Repo index: ${OUT_DIR}/Packages.gz ($(grep -c '^Package:' "${OUT_DIR}/Packages") entries)" echo " apt one-liner: echo 'deb [trusted=yes] file:${OUT_DIR} ./' \\" echo " | sudo tee /etc/apt/sources.list.d/${PKG}.list" echo " then: sudo apt update && sudo apt install ${PKG}" } # ------------------------------------------------------------------ build --- build_package() { need_cmd dpkg-deb rm -f "${DEB_FILE}" # The stage tree inherits the build user's umask (often 002 → group-writable). # Debian expects 755 dirs / 644 data files, plus the exec bits on the binary # and the maintainer scripts. find "${STAGE}" -type d -exec chmod 0755 {} + find "${STAGE}" -type f -exec chmod 0644 {} + chmod 0755 "${STAGE}/usr/bin/${PKG}" \ "${STAGE}/DEBIAN/postinst" "${STAGE}/DEBIAN/postrm" # --root-owner-group: the stage tree is owned by the build user, the archive # must not be (dpkg rejects foreign uids unless forced). dpkg-deb --root-owner-group --build "${STAGE}" "${DEB_FILE}" } verify_package() { local ok=1 # Fields straight from the archive (dpkg-deb --info output is localised). dpkg-deb --field "${DEB_FILE}" if command -v desktop-file-validate >/dev/null 2>&1; then if desktop-file-validate "${STAGE}/usr/share/applications/${PKG}.desktop"; then echo "desktop-file-validate: OK" else echo "warning: the desktop file did not validate" >&2 ok=0 fi fi # dist/rcalc can be stale: build.rs embeds APP_VERSION from Cargo.toml only at # compile time, and the AppImage packaging had exactly that bug (old binary in # a new image). Anything newer than the packaged binary means "rebuild it". local stale stale="$(find "${ROOT}/src" "${ROOT}/ui" "${ROOT}/build.rs" "${ROOT}/Cargo.toml" \ -newer "${OUT_DIR}/${PKG}" -print -quit 2>/dev/null || true)" if [[ -n "${stale}" ]]; then echo "warning: ${stale} is newer than the packaged binary" >&2 echo " (PREBUILT=1 / SKIP_DOCKER=1 reused an old dist/${PKG} — rebuild to be safe)" >&2 ok=0 fi echo "Files in package: $(dpkg-deb -c "${DEB_FILE}" | grep -c '^-')" if [[ "${KEEP_STAGE}" != "1" ]]; then rm -rf "${STAGE}" fi echo ls -l "${DEB_FILE}" [[ "${ok}" == "1" ]] || echo "(check the warnings above)" } check_version ensure_binary stage_tree stage_icons stage_changelog stage_control build_package make_repo verify_package echo echo "OK: ${DEB_FILE}" echo "Version: ${DEB_VERSION} (upstream ${VERSION} + revision ${DEB_REVISION})" echo "Install: sudo apt install '${DEB_FILE}'" echo "Next release: bump version in Cargo.toml (app changes) — or just" echo " DEB_REVISION=2 ./scripts/build-deb.sh (packaging/icon only)," echo " then install the new .deb: apt replaces the old one in place." echo "Run: rcalc (or start it from the application menu)" echo "Remove: sudo apt remove ${PKG}"