#!/usr/bin/env bash # install-qbittorrent.sh — Install qBittorrent from the latest official GitHub release. # Hardened: arch-aware, GitHub API token support, SHA-256 verification from the # release asset digest, idempotent, --dry-run. set -euo pipefail IFS=$'\n\t' readonly SCRIPT_NAME="${0##*/}" DRY_RUN=0 REPO="qbittorrent/qBittorrent" usage() { cat <&2; } die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; } run() { if (( DRY_RUN )); then printf ' DRY-RUN: %s\n' "$*"; else eval "$@"; fi; } trap 'rc=$?; (( rc )) && printf "\033[1;31m[%s] failed at line %s (exit %d)\033[0m\n" "${SCRIPT_NAME%.sh}" "$LINENO" "$rc" >&2' ERR while (( $# )); do case "$1" in --dry-run) DRY_RUN=1 ;; -h|--help) usage; exit 0 ;; *) die "Unknown argument: $1 (try --help)" ;; esac shift done (( EUID == 0 )) || die "Must run as root. Try: sudo $SCRIPT_NAME" [[ -r /etc/os-release ]] || die "/etc/os-release not found." # shellcheck disable=SC1091 . /etc/os-release case "${ID:-}:${ID_LIKE:-}" in *ubuntu*|*debian*) : ;; *) die "Unsupported distro: ${PRETTY_NAME:-unknown}." ;; esac ARCH="$(dpkg --print-architecture)" [[ "$ARCH" == "amd64" ]] || die "qBittorrent publishes official Linux AppImages for x86_64/amd64 only (detected: $ARCH)." log "Detected: ${PRETTY_NAME:-unknown}, arch: $ARCH" export DEBIAN_FRONTEND=noninteractive log "Installing prerequisites..." run "apt-get update -qq" run "apt-get install -y curl jq ca-certificates desktop-file-utils shared-mime-info hicolor-icon-theme" GH_HDRS=(-H "Accept: application/vnd.github+json") [[ -n "${GITHUB_TOKEN:-}" ]] && GH_HDRS+=(-H "Authorization: Bearer ${GITHUB_TOKEN}") log "Querying GitHub API for latest release of $REPO..." RELEASE_JSON="$(curl -fsSL "${GH_HDRS[@]}" "https://api.github.com/repos/${REPO}/releases/latest")" TAG="$(jq -r '.tag_name // empty' <<<"$RELEASE_JSON")" [[ -n "$TAG" ]] || die "Could not parse latest release tag (rate limited? set GITHUB_TOKEN)." VERSION="${TAG#release-}" log "Latest release: $TAG" ASSET_REGEX='^qbittorrent-[0-9][^/]*_x86_64\.AppImage$' DOWNLOAD_URL="$(jq -r --arg re "$ASSET_REGEX" '.assets[] | select(.name | test($re)) | select(.name | test("_lt20_") | not) | .browser_download_url' <<<"$RELEASE_JSON" | head -n1)" ASSET_NAME="$(jq -r --arg re "$ASSET_REGEX" '.assets[] | select(.name | test($re)) | select(.name | test("_lt20_") | not) | .name' <<<"$RELEASE_JSON" | head -n1)" EXPECTED_SHA="$(jq -r --arg re "$ASSET_REGEX" '.assets[] | select(.name | test($re)) | select(.name | test("_lt20_") | not) | (.digest // "") | sub("^sha256:"; "")' <<<"$RELEASE_JSON" | head -n1)" [[ "$DOWNLOAD_URL" =~ ^https:// ]] || die "No standard x86_64 AppImage asset found in latest qBittorrent release." STAGE="$(mktemp -d -t qbittorrent.XXXXXX)" trap 'rm -rf "$STAGE"' EXIT APPIMAGE="$STAGE/$ASSET_NAME" log "Downloading $ASSET_NAME..." run "curl -fL -o '$APPIMAGE' '$DOWNLOAD_URL'" if (( DRY_RUN )); then [[ -n "$EXPECTED_SHA" && "$EXPECTED_SHA" != "null" ]] \ && printf ' DRY-RUN: verify SHA-256 %s\n' "$EXPECTED_SHA" \ || printf ' DRY-RUN: skip SHA-256 verification; no GitHub asset digest found\n' printf ' DRY-RUN: install AppImage to /opt/qbittorrent/qbittorrent\n' printf ' DRY-RUN: create /usr/local/bin/qbittorrent symlink\n' printf ' DRY-RUN: install desktop launcher, icons, and MIME handlers\n' log "Done." exit 0 fi if [[ -n "$EXPECTED_SHA" && "$EXPECTED_SHA" != "null" ]]; then log "Verifying SHA-256..." ACTUAL_SHA="$(sha256sum "$APPIMAGE" | awk '{print $1}')" [[ "$EXPECTED_SHA" == "$ACTUAL_SHA" ]] || die "SHA-256 mismatch: expected=$EXPECTED_SHA actual=$ACTUAL_SHA" log "SHA-256 ok." else warn "No SHA-256 asset digest in GitHub release metadata; skipping verification." fi log "Installing qBittorrent AppImage..." run "install -d -m 0755 /opt/qbittorrent" run "install -m 0755 '$APPIMAGE' /opt/qbittorrent/qbittorrent" run "ln -sfn /opt/qbittorrent/qbittorrent /usr/local/bin/qbittorrent" log "Extracting desktop metadata and icons..." EXTRACT_DIR="$STAGE/extract" run "install -d -m 0755 '$EXTRACT_DIR'" ( cd "$EXTRACT_DIR" /opt/qbittorrent/qbittorrent --appimage-extract >/dev/null ) run "install -d -m 0755 /usr/share/icons/hicolor/scalable/apps" run "install -m 0644 '$EXTRACT_DIR/squashfs-root/usr/share/icons/hicolor/scalable/apps/qbittorrent.svg' /usr/share/icons/hicolor/scalable/apps/qbittorrent.svg" for size in 16 22 24 32 36 48 64 72 96 128 192; do src="$EXTRACT_DIR/squashfs-root/usr/share/icons/hicolor/${size}x${size}/apps/qbittorrent.png" [[ -f "$src" ]] || continue run "install -d -m 0755 '/usr/share/icons/hicolor/${size}x${size}/apps'" run "install -m 0644 '$src' '/usr/share/icons/hicolor/${size}x${size}/apps/qbittorrent.png'" done log "Installing desktop launcher..." cat >/usr/share/applications/org.qbittorrent.qBittorrent.desktop </dev/null 2>&1 || true" if command -v xdg-mime >/dev/null 2>&1; then run "xdg-mime default org.qbittorrent.qBittorrent.desktop application/x-bittorrent || true" run "xdg-mime default org.qbittorrent.qBittorrent.desktop x-scheme-handler/magnet || true" fi if command -v qbittorrent >/dev/null 2>&1; then log "Installed: $(qbittorrent --version 2>/dev/null || echo "$VERSION")" fi log "Done."