#!/bin/sh # # Virtual Jaguar -- installer / updater for the RetroArch core and the app. # # https://github.com/libretro/virtualjaguar-libretro # # POSIX sh. Runs under sh, bash, zsh, dash, busybox ash. Safe to pipe: # # curl -fsSL https://raw.githubusercontent.com/libretro/virtualjaguar-libretro/master/tools/install.sh | sh # # Piping into a shell means stdin is the script, so prompts read /dev/tty # instead. Where there is no tty (CI, a pipe with no terminal) the script goes # non-interactive and uses defaults rather than hanging on a read. # # Read it before you run it -- sensible for anything off the internet, and the # core install may ask for sudo. `--dry-run` prints the whole plan and changes # nothing. # # Usage: # ./install.sh interactive when a terminal is present # ./install.sh --yes no prompts, all defaults # ./install.sh --what core|app|both what to install or update (default: ask; # no terminal -> the core, plus the app if # this script installed it before) # ./install.sh --core | --app | --both same, shorter # ./install.sh --channel nightly # ./install.sh --channel v3.5.1 # ./install.sh --dest /path/to/cores core: skip frontend discovery # ./install.sh --app-dest /path app: Applications dir (macOS) or bin dir (Linux) # ./install.sh --app-format F app: zip (macOS), appimage|deb (Linux) # ./install.sh --allow-unsigned macOS: allow clearing quarantine on an # app Gatekeeper rejects (see below) # ./install.sh --force reinstall even if already up to date # ./install.sh --list show what was found, install nothing # ./install.sh --dry-run # ./install.sh --auto-update weekly install a background updater # ./install.sh --auto-update off remove it # # Environment: # VJ_INSTALL_BASE_URL release host, default https://github.com//releases # (the layout is /latest/download/ and # /download//; file:// works) # VJ_INSTALL_RETROARCH path to the RetroArch binary, skips the search # VJ_INSTALL_NO_TTY 1 = behave as if there were no terminal (tests, CI) # # THE APP # macOS Virtual Jaguar.app from the zip into /Applications, or # ~/Applications when that is not writable. Updated where it already is. # Linux the AppImage into ~/.local/bin plus a .desktop entry; or the .deb # through apt/dpkg with --app-format deb (that needs sudo). x86_64 and # aarch64 builds only, chosen from the USERLAND, not the kernel. # Every download is checked against the release's SHA256SUMS.txt first. # Windows is install.ps1. # # Gatekeeper: files fetched with curl carry no quarantine flag, so a signed and # notarized app opens without a prompt and this script never touches its # protections. The one exception: if the app is rejected by `spctl` AND carries # a quarantine flag, the flag is cleared only after you say yes (or pass # --allow-unsigned). --yes alone never does it. # # --------------------------------------------------------------------------- # THE MISTAKE THIS EXISTS TO PREVENT # # Release builds are named by board AND word size: `rpi4` is 32-bit armhf, # `rpi4_64` is 64-bit aarch64. Picking by `uname -m` gets this wrong on the # many Raspberry Pi setups that run a 64-bit KERNEL with a 32-bit USERLAND -- # uname says `aarch64` while every binary is 32-bit. Install the 64-bit core # there and RetroArch says only "Failed to open libretro core". # # The core must match RETROARCH, not the kernel. So the word size is read out # of the RetroArch binary itself, and a mismatch aborts before anything is # written. The app's Linux builds follow the same rule: they are matched to # the userland (read from /bin/sh), never to the kernel. # --------------------------------------------------------------------------- set -eu REPO="libretro/virtualjaguar-libretro" # master = the released installer; --auto-update re-fetches this URL. RAW="https://raw.githubusercontent.com/$REPO/master/tools/install.sh" BASE="${VJ_INSTALL_BASE_URL:-https://github.com/$REPO/releases}" BASE="${BASE%/}" CORE_NAME="virtualjaguar_libretro" MAC_APP_NAME="Virtual Jaguar.app" APPIMAGE_NAME="VirtualJaguar.AppImage" DEB_PKG="virtualjaguar" CHANNEL="latest" DEST="" APP_DEST="" APP_FORMAT="" WHAT="" ASSUME_YES=0 DRY_RUN=0 LIST_ONLY=0 FORCE=0 ALLOW_UNSIGNED=0 AUTO_UPDATE="" CORE_SOFT=0 # -------------------------------------------------------------------------- # output helpers # -------------------------------------------------------------------------- if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then B=$(printf '\033[1m'); D=$(printf '\033[2m'); R=$(printf '\033[0m') GRN=$(printf '\033[32m'); YLW=$(printf '\033[33m'); RED=$(printf '\033[31m') else B=''; D=''; R=''; GRN=''; YLW=''; RED='' fi say() { printf '%s\n' "$*"; } step() { printf '\n%s==>%s %s%s%s\n' "$GRN" "$R" "$B" "$*" "$R"; } ok() { printf ' %s+%s %s\n' "$GRN" "$R" "$*"; } note() { printf ' %s-%s %s\n' "$D" "$R" "$*"; } warn() { printf ' %s!%s %s\n' "$YLW" "$R" "$*" >&2; } die() { printf '\n%sERROR%s %s\n' "$RED" "$R" "$*" >&2; exit 1; } # Run a file-mutating command, elevating only when we actually need to. # # `command` on the non-sudo path bypasses any alias or shell function named # rm/cp/mv -- the interactive `-i` variants are a common default, and a prompt # with nothing attached to answer it blocks forever rather than failing. # The sudo path needs no such guard (sudo execs the binary, never a shell # alias) and could not take one anyway, since `command` is a shell builtin. # $SUDO is set just before the install step; empty means "we can write here". as_root() { if [ -n "${SUDO:-}" ]; then sudo "$@"; else command "$@"; fi } # -------------------------------------------------------------------------- # interactivity # # When piped, stdin is the script text, so reading it would consume the script # or block. /dev/tty is the user's terminal regardless of redirection -- if it # is not openable there is genuinely no one to ask. # -------------------------------------------------------------------------- TTY="" # VJ_INSTALL_NO_TTY=1 forces the no-terminal path (the test suite runs from terminals). if [ -z "${VJ_INSTALL_NO_TTY:-}" ] && [ -r /dev/tty ] && [ -w /dev/tty ] 2>/dev/null; then TTY=/dev/tty; fi INTERACTIVE=0 [ -n "$TTY" ] && INTERACTIVE=1 ask() { # ask "" "" -> echoes the answer _p="$1"; _d="$2" if [ "$INTERACTIVE" -eq 0 ] || [ "$ASSUME_YES" -eq 1 ]; then printf '%s' "$_d"; return 0 fi printf '%s [%s]: ' "$_p" "$_d" > "$TTY" IFS= read -r _a < "$TTY" || _a="" [ -n "$_a" ] || _a="$_d" printf '%s' "$_a" } confirm() { # confirm "" -> 0 for yes [ "$ASSUME_YES" -eq 1 ] && return 0 [ "$INTERACTIVE" -eq 0 ] && return 0 printf '%s [Y/n]: ' "$1" > "$TTY" IFS= read -r _a < "$TTY" || _a="" case "$_a" in [nN]*) return 1 ;; *) return 0 ;; esac } confirm_no() { # confirm_no "" -> 0 only on an explicit yes; default NO, and NO # whenever nobody can be asked. For the one decision that weakens a protection. [ "$INTERACTIVE" -eq 0 ] && return 1 [ "$ASSUME_YES" -eq 1 ] && return 1 printf '%s [y/N]: ' "$1" > "$TTY" IFS= read -r _a < "$TTY" || _a="" case "$_a" in [yY]*) return 0 ;; *) return 1 ;; esac } # How to tell the user to re-run us. When piped into a shell, $0 is the shell # name and not a file, so neither `sed "$0"` nor a `$0 --flag` hint works. if [ -r "$0" ] && [ "$0" != "sh" ] && [ "$0" != "-sh" ] && [ "$0" != "bash" ]; then SELF="$0"; SELF_IS_FILE=1 else SELF="curl -fsSL $RAW | sh -s --"; SELF_IS_FILE=0 fi usage() { if [ "$SELF_IS_FILE" -eq 1 ]; then sed -n '3,/^# ---/{/^# ---/!p;}' "$SELF" | sed 's/^#\{1,\} \{0,1\}//' else cat <<'USAGE' Virtual Jaguar -- installer / updater for the RetroArch core and the app. https://github.com/libretro/virtualjaguar-libretro --what core|app|both what to install or update (--core --app --both) --channel latest|nightly| which build (default: latest) --dest core: install here, skip frontend discovery --app-dest app: Applications dir (macOS) / bin dir (Linux) --app-format zip|appimage|deb app package (default: zip / appimage) --allow-unsigned macOS: may clear quarantine on a rejected app --force reinstall even if already up to date --list show what was found, install nothing --dry-run print the plan, change nothing --yes no prompts, take every default --auto-update daily|weekly|off background updater --help Piped into a shell, prompts read /dev/tty; with no tty it takes defaults. Windows: use install.ps1. Download this script and read it first if you like: curl -fLO https://raw.githubusercontent.com/libretro/virtualjaguar-libretro/master/tools/install.sh USAGE fi exit 0 } while [ $# -gt 0 ]; do case "$1" in --channel) CHANNEL="${2:-}"; [ -n "$CHANNEL" ] || die "--channel needs a value"; shift 2 ;; --dest) DEST="${2:-}"; [ -n "$DEST" ] || die "--dest needs a path"; shift 2 ;; --app-dest) APP_DEST="${2:-}"; [ -n "$APP_DEST" ] || die "--app-dest needs a path"; shift 2 ;; --app-format) APP_FORMAT="${2:-}"; [ -n "$APP_FORMAT" ] || die "--app-format needs a value"; shift 2 ;; --what) WHAT="${2:-}"; [ -n "$WHAT" ] || die "--what needs core, app or both"; shift 2 ;; --core) WHAT=core; shift ;; --app) WHAT=app; shift ;; --both) WHAT=both; shift ;; --auto-update) AUTO_UPDATE="${2:-}"; [ -n "$AUTO_UPDATE" ] || die "--auto-update needs daily|weekly|off"; shift 2 ;; --yes|-y) ASSUME_YES=1; shift ;; --dry-run) DRY_RUN=1; shift ;; --list) LIST_ONLY=1; shift ;; --force) FORCE=1; shift ;; --allow-unsigned) ALLOW_UNSIGNED=1; shift ;; -h|--help) usage ;; *) die "unknown option: $1 (try --help)" ;; esac done case "$AUTO_UPDATE" in ''|daily|weekly|off) ;; *) die "--auto-update takes daily, weekly or off (got '$AUTO_UPDATE')" ;; esac case "$WHAT" in ''|core|app|both) ;; *) die "--what takes core, app or both (got '$WHAT')" ;; esac case "$APP_FORMAT" in ''|zip|appimage|deb) ;; *) die "--app-format takes zip, appimage or deb (got '$APP_FORMAT'; macOS ships a zip only)" ;; esac command -v curl >/dev/null 2>&1 || die "curl is required. Debian/RetroPie: sudo apt install -y curl" # Scratch space for the whole run. Parts that run in a subshell (see main) share # it through files, and one trap removes it. WORK=$(mktemp -d 2>/dev/null || mktemp -d -t vjinstall) || die "could not create a temp directory" trap 'command rm -rf "$WORK"' EXIT INT TERM # -------------------------------------------------------------------------- # release access: URLs, SHA256SUMS.txt, hashing # -------------------------------------------------------------------------- release_url() { # release_url -> URL of that file on the selected channel case "$CHANNEL" in latest) printf '%s/latest/download/%s' "$BASE" "$1" ;; nightly) printf '%s/download/nightly/%s' "$BASE" "$1" ;; *) printf '%s/download/%s/%s' "$BASE" "$CHANNEL" "$1" ;; esac } # Fetch SHA256SUMS.txt once per run. Returns 1 when the channel has none. ensure_sums() { [ -s "$WORK/SHA256SUMS.txt" ] && return 0 curl -fsSL --retry 3 -o "$WORK/SHA256SUMS.txt" "$(release_url SHA256SUMS.txt)" 2>/dev/null || { command rm -f "$WORK/SHA256SUMS.txt"; return 1 } [ -s "$WORK/SHA256SUMS.txt" ] } # First SHA256SUMS entry named *. Prefix/suffix are matched # literally; a glob in a variable is NOT portable (zsh does not treat an # unquoted expansion in a case pattern as a pattern). sums_find() { while read -r _h _n; do _n=${_n#\*} case "$_n" in "$1"*"$2") printf '%s' "$_n"; return 0 ;; esac done < "$WORK/SHA256SUMS.txt" return 1 } sums_hash() { while read -r _h _n; do _n=${_n#\*} [ "$_n" = "$1" ] && { printf '%s' "$_h" | tr 'A-F' 'a-f'; return 0; } done < "$WORK/SHA256SUMS.txt" return 1 } sha256_of() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1 elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | cut -d' ' -f1 elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 "$1" | sed 's/.*= *//' else return 1 fi } # verify_sum -> 0 verified; 1 mismatch / unlisted (hard); # 2 no way to check (no SHA256SUMS, or no hashing tool). Callers decide. verify_sum() { ensure_sums || return 2 _want=$(sums_hash "$2") || return 1 [ -n "$_want" ] || return 1 _got=$(sha256_of "$1") || return 2 [ "$_got" = "$_want" ] || { printf 'expected %s\n got %s' "$_want" "$_got" >&2; return 1; } return 0 } # -------------------------------------------------------------------------- # binary inspection # # ELF: byte 4 is the class (1=32-bit, 2=64-bit), bytes 18-19 the machine. # Mach-O: read the arch with `file`, which macOS always has. # `od` is used rather than `file` on Linux because `file` is NOT installed on a # stock RetroPie or Lakka image, while coreutils always is. # -------------------------------------------------------------------------- elf_class() { od -An -t u1 -j 4 -N 1 "$1" 2>/dev/null | tr -d ' '; } elf_machine() { od -An -t u1 -j 18 -N 1 "$1" 2>/dev/null | tr -d ' '; } # Prints e.g. "64 aarch64" / "32 arm" / "64 x86_64" / "32 i386", or "" if unreadable. binary_arch() { _f="$1" [ -f "$_f" ] || { printf ''; return; } case "$(uname -s)" in Darwin) # macOS frontends are usually UNIVERSAL binaries carrying both slices, so # "does it contain arm64" is the wrong question -- RetroArch.app contains # both, and on an Intel Mac the x86_64 slice is the one that runs. Which # slice runs is the host architecture, so when there is more than one, # that is what decides. _archs="" if command -v lipo >/dev/null 2>&1; then _archs=$(lipo -archs "$_f" 2>/dev/null || true) fi if [ -z "$_archs" ]; then # No developer tools: parse `file`, which lists every slice. _fo=$(file -b "$_f" 2>/dev/null || true) case "$_fo" in *arm64*) _archs="arm64" ;; esac case "$_fo" in *x86_64*) _archs="$_archs x86_64" ;; esac fi _host=$(uname -m) # Substring match rather than `for _a in $_archs`: zsh does NOT word-split # unquoted variables, so that loop sees "x86_64 arm64" as ONE item, never # matches, and silently falls through to the first slice -- picking the # x86_64 core on an Apple Silicon Mac. zsh is macOS's default shell, so # this is the likely invocation, not an exotic one. `case` is split-free # and behaves identically in every shell. case " $_archs " in *" $_host "*) _pick="$_host" ;; *) _pick=${_archs%% *} ;; esac case "$_pick" in arm64) printf '64 aarch64' ;; x86_64) printf '64 x86_64' ;; *) printf '' ;; esac ;; *) _c=$(elf_class "$_f"); _m=$(elf_machine "$_f") case "$_c" in 1) _bits=32 ;; 2) _bits=64 ;; *) printf ''; return ;; esac # EM_386=3 EM_ARM=40 EM_X86_64=62 EM_AARCH64=183 case "$_m" in 3) printf '%s i386' "$_bits" ;; 40) printf '%s arm' "$_bits" ;; 62) printf '%s x86_64' "$_bits" ;; 183) printf '%s aarch64' "$_bits" ;; *) printf '' ;; esac ;; esac } # ========================================================================== # THE CORE # # Runs in a subshell (see main), so a die here ends only this part and `both` # can still go on to the app. Shared results go through files in $WORK. # ========================================================================== CANDIDATES="" # newline-separated: