#!/usr/bin/env bash # Stable installer; select latest.json and download the RAW from R2. set -Eeuo pipefail R2_BASE=https://movies.zimaos.com RAW_NAME=zimaos-movies.raw SERVICE=zimaos-movies.service target_dir=/DATA/.extensions raw_path="$target_dir/$RAW_NAME" sha_path="$raw_path.sha256" legacy_path="$target_dir/icewhale_movies.raw" work_dir="" staged_raw="" transaction_started=0 enable_attempted=0 was_active=0 was_enabled=0 had_raw=0 had_sha=0 had_legacy=0 rollback_failed=0 allow_downgrade=0 usage() { cat <<'EOF' Usage: install-movies.sh [--allow-downgrade] [--help] Install or upgrade Movies to the latest published release on ZimaOS >= 1.6.2 (x86-64). Reads latest.json, then downloads that version's RAW from: https://movies.zimaos.com Displays a progress bar while downloading the RAW. Verifies the declared size and SHA-256 before changing the extension or service. Requires jq or python3 to validate the release descriptor. Reinstalling the same version is supported. A downgrade requires the explicit --allow-downgrade flag; application database migrations are not rolled back. Adds the install type and previous/target version to RAW download URLs and User-Agent for download statistics. These tags do not confirm installation success. Run as root. Existing Movies data and configuration are kept by the app. On failure, the installer attempts to restore the previous extension and service state. This does not roll back application database migrations. EOF } while (($#)); do case $1 in --help|-h) usage; exit 0 ;; --allow-downgrade) allow_downgrade=1 ;; *) usage >&2; exit 2 ;; esac shift done fail() { printf '%s\n' "$*" >&2; exit 1; } # Read metadata as data, never as shell code. Missing/invalid evidence must not # turn an existing installation into a reported new installation. classify_download() { local release_file line candidate existing=0 install_type=unknown from_version=unknown for release_file in /usr/lib/extension-release.d/extension-release.zimaos-movies \ /usr/lib/extension-release.d/extension-release.icewhale_movies; do [[ -f $release_file ]] || continue existing=1 if [[ -r $release_file ]]; then while IFS= read -r line || [[ -n $line ]]; do [[ $line == SYSEXT_VERSION_ID=* ]] || continue candidate=${line#SYSEXT_VERSION_ID=} candidate=${candidate%$'\r'} if [[ $candidate == \"*\" || $candidate == \'*\' ]]; then candidate=${candidate:1:-1} fi if [[ $candidate =~ ^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$ ]]; then from_version=$candidate fi break done 2>/dev/null < "$release_file" || from_version=unknown fi break done if [[ -f $raw_path || -f $legacy_path || -d /media/ZimaOS-HD/AppData/movies \ || -d /DATA/AppData/movies ]] || systemctl cat "$SERVICE" >/dev/null 2>&1; then existing=1 fi if ((!existing)); then install_type=install from_version=none elif [[ $from_version != unknown ]]; then if [[ $from_version == "$VERSION" ]]; then install_type=reinstall elif systemd-analyze compare-versions "$from_version" lt "$VERSION" >/dev/null 2>&1; then install_type=upgrade elif systemd-analyze compare-versions "$from_version" gt "$VERSION" >/dev/null 2>&1; then install_type=downgrade fi fi return 0 } [[ $(id -u) == 0 ]] || fail 'Run as root: sudo bash install-movies.sh' for tool in curl sha256sum systemctl systemd-sysext systemd-analyze flock \ mktemp install cp mv rm chmod uname mkdir stat; do command -v "$tool" >/dev/null || fail "Required command not found: $tool" done if command -v jq >/dev/null; then json_parser=jq elif command -v python3 >/dev/null; then json_parser=python3 else fail 'Required JSON parser not found: install jq or python3 before running this installer.' fi case $(uname -m) in x86_64|amd64) ;; *) fail 'Movies requires an x86-64 ZimaOS host.' ;; esac systemd-analyze condition 'ConditionOSRelease=ID=zimaos VERSION_ID>=1.6.2' \ >/dev/null 2>&1 || fail 'Movies requires ZimaOS 1.6.2 or newer.' [[ ! -L $target_dir ]] || fail "Extension directory must not be a symlink: $target_dir" for path in "$raw_path" "$sha_path" "$legacy_path"; do [[ ! -L $path && ( ! -e $path || -f $path ) ]] || fail "Expected a regular file: $path" done # Validate the descriptor as JSON data. Only these three validated scalar fields # leave the parser; remote URLs and shell syntax can never control downloads. parse_release() { local descriptor=$1 [[ $(stat -c %s -- "$descriptor") -le 65536 ]] || return 1 if [[ $json_parser == jq ]]; then jq --slurp --exit-status --raw-output ' select(length == 1) | .[0] | select(type == "object") | select(.schema_version == 1) | select(.version | type == "string") | select(.raw_sha256 | type == "string") | select(.raw_bytes | type == "number") | select(.raw_bytes > 0 and (.raw_bytes | floor) == .raw_bytes) | [.version, .raw_sha256, (.raw_bytes | floor | tostring)] | @tsv ' "$descriptor" else python3 - "$descriptor" <<'PY' import json import re import sys from decimal import Decimal def reject_constant(value): raise ValueError('Invalid JSON constant: ' + value) try: with open(sys.argv[1], encoding='utf-8') as descriptor: release = json.load(descriptor, parse_float=Decimal, parse_constant=reject_constant) if not isinstance(release, dict) or isinstance(release.get('schema_version'), bool) or release.get('schema_version') != 1: raise ValueError('Unsupported descriptor schema') version, checksum, size = (release.get(field) for field in ('version', 'raw_sha256', 'raw_bytes')) if not isinstance(version, str) or re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._+-]{0,127}', version) is None: raise ValueError('Invalid release version') if not isinstance(checksum, str) or re.fullmatch(r'[a-f0-9]{64}', checksum) is None: raise ValueError('Invalid RAW checksum') if type(size) not in (int, Decimal) or size <= 0 or size != int(size): raise ValueError('Invalid RAW size') print(version, checksum, int(size), sep='\t') except (OSError, ValueError, TypeError, ArithmeticError) as error: print('Invalid Movies release descriptor: ' + str(error), file=sys.stderr) sys.exit(1) PY fi } fetch_release() { local descriptor="$work_dir/latest.json" fields curl "${curl_options[@]}" --silent --max-time 30 --max-filesize 65536 \ --output "$descriptor" "$1" || return 1 fields=$(parse_release "$descriptor") || return 1 IFS=$'\t' read -r VERSION RAW_SHA256 RAW_BYTES <<< "$fields" # jq's TSV encoding escapes embedded control characters. These ASCII-only # checks also work when the host's jq has no optional regex library. [[ $VERSION =~ ^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$ \ && $RAW_SHA256 =~ ^[a-f0-9]{64}$ && $RAW_BYTES =~ ^[1-9][0-9]*$ ]] } # Restore through a temporary file in the extension directory. Never overwrite # the inode of an image that systemd-sysext might still have mounted. restore_file() { local destination=$1 backup=$2 existed=$3 if ((existed)); then cp -a -- "$backup" "$staged_raw" && mv -f -- "$staged_raw" "$destination" else rm -f -- "$destination" fi } rollback() { printf 'Installation failed; restoring the previous extension and service state.\n' >&2 if systemctl cat "$SERVICE" >/dev/null 2>&1 || systemctl is-active --quiet "$SERVICE"; then systemctl stop "$SERVICE" || rollback_failed=1 fi # Restore boot enablement before removing a newly installed unit. if ((enable_attempted)); then if ((was_enabled)); then systemctl enable "$SERVICE" || rollback_failed=1 else systemctl disable "$SERVICE" || rollback_failed=1 fi fi restore_file "$raw_path" "$work_dir/previous.raw" "$had_raw" || rollback_failed=1 restore_file "$sha_path" "$work_dir/previous.sha256" "$had_sha" || rollback_failed=1 restore_file "$legacy_path" "$work_dir/legacy.raw" "$had_legacy" || rollback_failed=1 systemd-sysext refresh || rollback_failed=1 systemctl daemon-reload || rollback_failed=1 if ((was_active && !rollback_failed)); then systemctl start "$SERVICE" || rollback_failed=1 systemctl is-active --quiet "$SERVICE" || rollback_failed=1 fi } cleanup() { local status=$? trap - EXIT HUP INT TERM set +e if ((transaction_started)); then # Also recover if a streamed script ends between complete commands. if ((status == 0)); then status=1; fi rollback fi if [[ -n $staged_raw ]]; then rm -f -- "$staged_raw" "$staged_raw.sha256"; fi if ((rollback_failed)); then printf 'Rollback did not fully succeed. Recovery files: %s\n' "$work_dir" >&2 elif [[ -n $work_dir ]]; then rm -rf -- "$work_dir" fi exit "$status" } trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' HUP TERM umask 077 mkdir -p /run/lock exec 9>/run/lock/zimaos-movies-install.lock flock -n 9 || fail 'Another Movies installation is running.' work_dir=$(mktemp -d /DATA/.movies-install.XXXXXX) curl_options=(--fail --show-error --location --retry 2 --retry-delay 1 --connect-timeout 10 --proto '=https' --proto-redir '=https') printf 'Checking the latest Movies release ...\n' fetch_release "$R2_BASE/latest.json" || fail 'Unable to read a valid Movies release descriptor from Cloudflare R2.' # Resolve latest exactly once. A RAW retry must use this same immutable release. readonly VERSION RAW_SHA256 RAW_BYTES RAW_URL="$R2_BASE/$VERSION/$RAW_NAME" readonly RAW_URL classify_download if [[ $install_type == downgrade ]] && ((!allow_downgrade)); then fail "Refusing to downgrade Movies from $from_version to $VERSION. Use --allow-downgrade to proceed." fi # Version characters are restricted to URL-safe ASCII above; '+' needs encoding # because log query parsers interpret a literal '+' as a space. download_tags="x-install-type=$install_type&x-from-version=${from_version//+/%2B}&x-to-version=${VERSION//+/%2B}" # R2 access logs retain the User-Agent but omit URL query parameters. download_user_agent="MoviesInstaller/1 install_type=$install_type from_version=$from_version to_version=$VERSION" printf 'Downloading Movies %s ...\n' "$VERSION" printf 'Download type: %s (previous version: %s).\n' "$install_type" "$from_version" curl "${curl_options[@]}" --user-agent "$download_user_agent" --progress-bar --max-time 300 --output "$work_dir/$RAW_NAME" "$RAW_URL?$download_tags" \ || fail 'Unable to download the Movies RAW from Cloudflare R2.' [[ $(stat -c %s -- "$work_dir/$RAW_NAME") == "$RAW_BYTES" ]] || fail 'Downloaded Movies RAW size does not match the release descriptor.' printf '%s %s\n' "$RAW_SHA256" "$work_dir/$RAW_NAME" | sha256sum -c - systemctl is-active --quiet "$SERVICE" && was_active=1 || true systemctl is-enabled --quiet "$SERVICE" && was_enabled=1 || true if [[ -f $raw_path ]]; then cp -a -- "$raw_path" "$work_dir/previous.raw"; had_raw=1; fi if [[ -f $sha_path ]]; then cp -a -- "$sha_path" "$work_dir/previous.sha256"; had_sha=1; fi if [[ -f $legacy_path ]]; then cp -a -- "$legacy_path" "$work_dir/legacy.raw"; had_legacy=1; fi install -d -m 0755 "$target_dir" staged_raw=$(mktemp "$target_dir/.zimaos-movies.raw.XXXXXX") install -m 0644 "$work_dir/$RAW_NAME" "$staged_raw" printf '%s %s\n' "$RAW_SHA256" "$RAW_NAME" > "$staged_raw.sha256" chmod 0644 "$staged_raw.sha256" transaction_started=1 if ((was_active)) || systemctl cat "$SERVICE" >/dev/null 2>&1; then # Stop inactive/restarting units too, to cancel scheduled automatic restarts. printf 'Stopping Movies ...\n' systemctl stop "$SERVICE" fi mv -f -- "$staged_raw" "$raw_path" mv -f -- "$staged_raw.sha256" "$sha_path" rm -f -- "$legacy_path" printf 'Applying the Movies system extension ...\n' systemd-sysext refresh printf 'System extension applied. Reloading service manager ...\n' systemctl daemon-reload enable_attempted=1 systemctl enable "$SERVICE" printf 'Starting Movies; waiting for Gateway readiness ...\n' systemctl start "$SERVICE" # Type=notify start waits for the app's Gateway readiness check. systemctl is-active --quiet "$SERVICE" systemctl is-enabled --quiet "$SERVICE" transaction_started=0 printf 'Movies %s is installed, running, and enabled at boot.\nOpen Movies from the ZimaOS desktop.\n' "$VERSION"