#!/bin/sh # Foxora Studio: verified, data-preserving install and update for macOS and Linux. # # curl -fsSL https://foxora.ai/install.sh | sh # # Every step runs from main, called on the last line, so a download that is cut # short runs nothing. Accounts, device keys, permissions and projects are never # touched: only the application itself is installed or replaced. set -eu # ---- Release pins: the one block to change on release day ------------------ # comps/_tools/foxora-ai-only/download-redirects.json (installScriptsToServe, # /install.sh) points here. Each expected_sha is that file's line in the # release's SHA256SUMS.txt; every asset="..." line is followed by its # expected_sha="..." line, the shape foxora-web's scripts/check-downloads.mjs # parses and checks against the published digests. PUBKEY is the in-app # updater's minisign key, which signs the Linux packages (.sig). VERSION="5.0.10" REPO="foxora-ai/releases" TAG="foxora-studio-v$VERSION" PUBKEY="RWQ59t2aMf4WcPwY6Bpd5JA50vmYLZI4XT8yEo0GO5sQ6/Uo1EW/Av/F" MAC_TEAM_ID="A65TNJAWCQ" PACKAGE="foxora-studio" pin() { case "$1" in mac-arm64) asset="Foxora-Studio_${VERSION}_aarch64.dmg" expected_sha="d438a8c98cf62e45a5dd3bea2143b499fca1c67a28c392b0084b63a82bdaa23b" ;; mac-intel) asset="Foxora-Studio_${VERSION}_universal.dmg" expected_sha="ebfdf7cf17c461f35d4f3899b517a37842dbcc74b2dbaad6f6652d9167baee0b" ;; linux-deb) asset="Foxora-Studio_${VERSION}_amd64.deb" expected_sha="9398ebda4c95a163a364ad452452cfe9f8101fe323afbb7e5059e808671e082f" ;; linux-rpm) asset="Foxora-Studio-${VERSION}-1.x86_64.rpm" expected_sha="3ac0da110f34eac797a9fe99318d84fb4b3cc07b7ec0671ba24dda1292e4c595" ;; esac } # ----------------------------------------------------------------------------- COMMAND="curl -fsSL https://foxora.ai/install.sh |" usage() { cat <&2; exit 1; } need() { command -v "$1" >/dev/null 2>&1 || die "$1 is required but was not found." "${2:-Install it, then run this command again.}"; } # Compare dotted versions numerically; prints 1 (a > b), 0 (equal) or -1 (a < b). # Anything after -, + or ~ (a build or package release) is ignored. vercmp() { awk -v a="$1" -v b="$2" 'BEGIN { sub(/[-+~].*/, "", a); sub(/[-+~].*/, "", b) na = split(a, x, "."); nb = split(b, y, "."); n = na > nb ? na : nb for (i = 1; i <= n; i++) { p = x[i] + 0; q = y[i] + 0 if (p > q) { print 1; exit } if (p < q) { print -1; exit } } print 0 }' } # Decide what to do about the version already installed ($1, empty if none). # Refuses a downgrade and stops when this version is already there, unless told otherwise. mode=install check_installed() { [ -n "$1" ] || return 0 case "$(vercmp "$1" "$VERSION")" in 1) if [ "${FOXORA_ALLOW_DOWNGRADE:-0}" = 1 ]; then say "replacing Foxora Studio $1 with the older $VERSION (FOXORA_ALLOW_DOWNGRADE=1)." mode=downgrade return 0 fi die "Foxora Studio $1 is installed ($2). That is newer than $VERSION, the version this installer provides." \ "Nothing was changed. To replace it with $VERSION anyway, run:" \ " $COMMAND FOXORA_ALLOW_DOWNGRADE=1 sh" ;; 0) if [ "${FOXORA_REINSTALL:-0}" = 1 ]; then say "reinstalling Foxora Studio $VERSION (FOXORA_REINSTALL=1)." mode=reinstall return 0 fi say "Foxora Studio $VERSION is already installed ($2). Nothing to do." \ "To install it again, run: $COMMAND FOXORA_REINSTALL=1 sh" exit 0 ;; *) say "updating Foxora Studio $1 to $VERSION." mode=update ;; esac } sudo_noted=0 as_root() { if [ "$(id -u)" -eq 0 ]; then "$@"; return; fi command -v sudo >/dev/null 2>&1 || die "this step needs administrator rights, and sudo is not installed." \ "Run the command again as root, or install sudo and give your user access to it." if [ "$sudo_noted" -eq 0 ]; then say "this step needs administrator rights; sudo may ask for your password."; sudo_noted=1; fi sudo "$@" } tmp="" mounted=0 cleanup() { # A macOS swap that was interrupted halfway puts the previous app back. if [ -n "${backup:-}" ] && [ -e "$backup" ] && [ ! -e "$APP_PATH" ]; then ${priv:-} mv "$backup" "$APP_PATH" 2>/dev/null || true; fi if [ -n "${stage:-}" ] && [ -e "$stage" ]; then ${priv:-} rm -rf "$stage" 2>/dev/null || true; fi if [ "$mounted" -eq 1 ]; then hdiutil detach "$tmp/mount" -quiet >/dev/null 2>&1 || true; fi if [ -n "$tmp" ]; then rm -rf "$tmp"; fi } trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM # Large files show curl's progress meter. A transfer is abandoned only when it # stalls (under 1 KB/s for a minute), never because a slow link takes long. fetch() { curl --proto '=https' --tlsv1.2 -fL --retry 3 --connect-timeout 15 --speed-limit 1024 --speed-time 60 "$1" -o "$2"; } fetch_quiet() { curl --proto '=https' --tlsv1.2 -fsSL --retry 3 --connect-timeout 15 --max-time 120 "$1" -o "$2"; } sha256_of() { if command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}' else sha256sum "$1" | awk '{print $1}'; fi } download_verified() { command -v shasum >/dev/null 2>&1 || need sha256sum url="https://github.com/$REPO/releases/download/$TAG/$asset" tmp="$(mktemp -d "${TMPDIR:-/tmp}/foxora-install.XXXXXX")" if [ "$os" = Darwin ] && [ -n "${FOXORA_DMG_PATH:-}" ]; then cp "$FOXORA_DMG_PATH" "$tmp/$asset" else say "downloading $asset" fetch "$url" "$tmp/$asset" || die "could not download $url" "Check your connection and run this command again; nothing was changed." fi [ "$(sha256_of "$tmp/$asset")" = "$expected_sha" ] || die "checksum mismatch for $asset; the download is damaged or was altered." \ "Nothing was installed and no application or user data was changed." say "SHA-256 verified." } # ---- Linux ------------------------------------------------------------------ linux_running() { # Process names live in /proc//comm; this needs no procps (pgrep). grep -qx foxora-desktop /proc/[0-9]*/comm 2>/dev/null } assert_linux_idle() { if linux_running; then die "Foxora is running. Quit it from the tray or menu, then run this command again; active tasks will not be interrupted." fi } linux_installed_version() { case "$manager" in apt-get) dpkg-query -W -f='${Status} ${Version}\n' "$PACKAGE" 2>/dev/null | awk '$3 == "installed" { print $4; exit }' || true ;; dnf) rpm -q --qf '%{VERSION}\n' "$PACKAGE" 2>/dev/null | awk '/^[0-9]/ { print; exit }' || true ;; esac } # A fresh Ubuntu or Debian often has unattended-upgrades holding the dpkg lock; # wait up to five minutes for it instead of failing at once. apt_get() { as_root env DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=300 "$@"; } # Bytes $2.. ($3 of them) of file $1. slice() { dd if="$1" bs=1 skip="$2" count="$3" 2>/dev/null; } openssl_can_verify() { command -v openssl >/dev/null 2>&1 \ && openssl pkeyutl -help 2>&1 | grep -q -- -rawin \ && openssl dgst -blake2b512 -binary /dev/null 2>&1 } # Check a minisign signature ($2) of file $1 against key $3 with OpenSSL 3, which # has Ed25519 and BLAKE2b; for distributions with no minisign package (Ubuntu # 22.04, RHEL 9 without EPEL). Same checks as minisign -V: algorithm, key id, # the file signature, and the global signature over the trusted comment. minisign_verify_openssl() { v="$tmp/verify"; mkdir -p "$v" printf '%s' "$3" | base64 -d > "$v/key" 2>/dev/null || return 1 sed -n 2p "$2" | base64 -d > "$v/sig" 2>/dev/null || return 1 sed -n 4p "$2" | base64 -d > "$v/global.sig" 2>/dev/null || return 1 { [ "$(wc -c < "$v/key")" -eq 42 ] && [ "$(wc -c < "$v/sig")" -eq 74 ] && [ "$(wc -c < "$v/global.sig")" -eq 64 ]; } || return 1 [ "$(slice "$v/key" 0 2)" = Ed ] || return 1 [ "$(slice "$v/key" 2 8 | od -An -tx1)" = "$(slice "$v/sig" 2 8 | od -An -tx1)" ] || return 1 # An Ed25519 SubjectPublicKeyInfo is a fixed 12-byte DER prefix and the 32-byte key. { printf '\060\052\060\005\006\003\053\145\160\003\041\000'; slice "$v/key" 10 32; } > "$v/key.der" slice "$v/sig" 10 64 > "$v/sig.bin" # "ED" signs the BLAKE2b-512 of the file (minisign's default); legacy "Ed" signs the file. case "$(slice "$v/sig" 0 2)" in ED) openssl dgst -blake2b512 -binary "$1" > "$v/prehash" || return 1; signed="$v/prehash" ;; Ed) signed="$1" ;; *) return 1 ;; esac openssl pkeyutl -verify -pubin -keyform DER -inkey "$v/key.der" -rawin -in "$signed" -sigfile "$v/sig.bin" >/dev/null 2>&1 || return 1 comment="$(sed -n 3p "$2")" case "$comment" in "trusted comment: "*) ;; *) return 1 ;; esac { cat "$v/sig.bin"; printf '%s' "${comment#trusted comment: }"; } > "$v/global" openssl pkeyutl -verify -pubin -keyform DER -inkey "$v/key.der" -rawin -in "$v/global" -sigfile "$v/global.sig" >/dev/null 2>&1 } # Verify with minisign when it is installed, else with OpenSSL 3, else install # minisign from the distribution. verify_signature() { if command -v minisign >/dev/null 2>&1; then minisign -Vm "$tmp/$asset" -x "$tmp/package.minisig" -P "$PUBKEY" >/dev/null elif openssl_can_verify; then minisign_verify_openssl "$tmp/$asset" "$tmp/package.minisig" "$PUBKEY" else say "installing minisign from your distribution to verify the package signature." if [ "$manager" = apt-get ]; then apt_get install -y -qq minisign; else as_root dnf install -y -q minisign; fi \ || die "could not install minisign, which checks the package signature." "Install minisign or OpenSSL 3 with your package manager, then run this command again." minisign -Vm "$tmp/$asset" -x "$tmp/package.minisig" -P "$PUBKEY" >/dev/null fi } linux_main() { case "$arch" in x86_64 | amd64) ;; aarch64 | arm64) die "Foxora Studio is not available for Linux arm64 yet; this release is for Linux x64 (x86_64) only." "See https://foxora.ai/download" ;; *) die "Foxora Studio does not support Linux $arch; this release is for Linux x64 (x86_64) only." "See https://foxora.ai/download" ;; esac libc="$(getconf GNU_LIBC_VERSION 2>/dev/null || true)" printf '%s\n' "$libc" | awk '{ split($2, v, "."); exit !($1 == "glibc" && (v[1] > 2 || (v[1] == 2 && v[2] >= 34))) }' \ || die "Linux with glibc 2.34 or newer is required; musl-based systems such as Alpine are not supported." "See https://foxora.ai/download" if command -v apt-get >/dev/null 2>&1 && command -v dpkg >/dev/null 2>&1; then manager=apt-get pin linux-deb dpkg_arch="$(dpkg --print-architecture 2>/dev/null || true)" [ "$dpkg_arch" = amd64 ] || die "this system installs $dpkg_arch packages; Foxora Studio for Linux is amd64 (x64) only." elif command -v dnf >/dev/null 2>&1 && command -v rpm >/dev/null 2>&1; then manager=dnf pin linux-rpm else die "this installer needs an apt (Debian, Ubuntu) or dnf (Fedora, RHEL) system with GTK 3 and WebKitGTK 4.1." "See https://foxora.ai/download" fi [ "$PRINT_TARGET" -eq 0 ] || { printf '%s\n%s\n' "$asset" "$expected_sha"; exit 0; } check_installed "$(linux_installed_version)" "package $PACKAGE" assert_linux_idle need curl need base64 if [ "$(id -u)" -ne 0 ]; then need sudo "Installing a system package needs administrator rights: run this command again as root, or install sudo and give your user access to it." # Ask once, before the download, so a user without sudo rights learns it now. say "installing a system package needs administrator rights; sudo may ask for your password." sudo_noted=1 sudo -v || die "sudo did not grant administrator rights, so nothing was downloaded or changed." \ "Run this command again as root, or as a user who can use sudo." fi download_verified # Verify the same detached minisign signature the in-app updater trusts. fetch_quiet "$url.sig" "$tmp/signature.base64" || die "could not download the package signature ($url.sig)." base64 -d < "$tmp/signature.base64" > "$tmp/package.minisig" 2>/dev/null || die "the package signature is not valid base64." if [ "$manager" = apt-get ]; then say "refreshing package lists (apt-get update)." apt_get update -qq || say "apt-get update reported errors; continuing with the package lists already on this system." fi verify_signature || die "the package signature did not verify; nothing was installed." say "signature verified." # The package manager's own policies (repository signatures, apt's sandbox) stay on: # the package is readable by apt's download user, and nothing is forced. chmod 755 "$tmp"; chmod 644 "$tmp/$asset" assert_linux_idle say "installing $asset and its dependencies." case "$manager-$mode" in apt-get-downgrade) apt_get install -y --allow-downgrades "$tmp/$asset" ;; apt-get-reinstall) apt_get install -y --reinstall "$tmp/$asset" ;; apt-get-*) apt_get install -y "$tmp/$asset" ;; dnf-downgrade) as_root dnf downgrade -y "$tmp/$asset" ;; dnf-reinstall) as_root dnf reinstall -y "$tmp/$asset" ;; dnf-*) as_root dnf install -y "$tmp/$asset" ;; esac || die "$manager could not install $asset; its messages are above." \ "Fix the problem $manager reports, then run this command again. Accounts and projects are not affected." installed="$(linux_installed_version)" [ "$installed" = "$VERSION" ] || die "the package manager finished, but $PACKAGE reports version ${installed:-none} instead of $VERSION." say "Foxora Studio $VERSION installed. Open Foxora Studio from your applications menu." } # ---- macOS ------------------------------------------------------------------ mac_running() { # Any process started from inside the bundle being replaced. ps output is read # into a variable first so the check never matches itself. procs="$(ps -axo command= 2>/dev/null || true)" case "$procs" in *"$APP_PATH/Contents/MacOS/"*) return 0 ;; esac return 1 } assert_mac_idle() { if mac_running; then die "Foxora Studio is running from $APP_PATH. Quit it from the menu bar, then run this command again; active tasks will not be interrupted." fi } mac_bundle_version() { /usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$1/Contents/Info.plist" 2>/dev/null || true } mac_main() { # uname reports x86_64 under Rosetta; ask the hardware before choosing. silicon="$(sysctl -n hw.optional.arm64 2>/dev/null || true)" translated="$(sysctl -n sysctl.proc_translated 2>/dev/null || true)" if [ "$arch" = arm64 ] || [ "$silicon" = 1 ] || [ "$translated" = 1 ]; then pin mac-arm64 elif [ "$arch" = x86_64 ]; then pin mac-intel else die "unsupported Mac architecture: $arch" fi mac_version="$(sw_vers -productVersion)" printf '%s\n' "$mac_version" | awk -F. '{ exit !($1 > 13 || ($1 == 13 && $2 >= 5)) }' \ || die "macOS 13.5 or newer is required; this Mac runs macOS $mac_version." [ "$PRINT_TARGET" -eq 0 ] || { printf '%s\n%s\n' "$asset" "$expected_sha"; exit 0; } APP_DIR="${FOXORA_APP_DIR:-/Applications}" case "$APP_DIR" in /*) ;; *) die "FOXORA_APP_DIR must be an absolute path." ;; esac # Spell the folder the way running processes report it (no trailing slash, no # symlinks), or the running-app check below would miss an open Foxora. while [ "$APP_DIR" != / ] && [ "${APP_DIR%/}" != "$APP_DIR" ]; do APP_DIR="${APP_DIR%/}"; done if [ -d "$APP_DIR" ]; then APP_DIR="$(cd -P "$APP_DIR" && pwd -P)" || die "cannot open $APP_DIR."; fi APP_PATH="$APP_DIR/Foxora Studio.app" [ ! -L "$APP_PATH" ] || die "refusing to replace $APP_PATH because it is a symbolic link." if [ -d "$APP_PATH" ]; then check_installed "$(mac_bundle_version "$APP_PATH")" "$APP_PATH" fi assert_mac_idle for tool in curl hdiutil ditto spctl codesign; do need "$tool"; done download_verified spctl -a -t open --context context:primary-signature "$tmp/$asset" >/dev/null 2>&1 || die "Gatekeeper rejected the DMG; nothing was installed." mkdir "$tmp/mount" hdiutil attach "$tmp/$asset" -mountpoint "$tmp/mount" -nobrowse -readonly -noautoopen -quiet mounted=1 source_app="$tmp/mount/Foxora Studio.app" [ -d "$source_app" ] || die "the DMG does not contain Foxora Studio.app." bundle_version="$(mac_bundle_version "$source_app")" [ "$bundle_version" = "$VERSION" ] || die "the DMG holds version ${bundle_version:-unknown}, not $VERSION." codesign --verify --deep --strict "$source_app" || die "the app's code signature did not verify; nothing was installed." team="$(codesign -dv --verbose=4 "$source_app" 2>&1 | sed -n 's/^TeamIdentifier=//p')" [ "$team" = "$MAC_TEAM_ID" ] || die "the app is signed by an unexpected Apple Developer ID (${team:-none}); nothing was installed." spctl -a -t execute "$source_app" >/dev/null 2>&1 || die "Gatekeeper rejected the app; nothing was installed." say "signature, Developer ID and Gatekeeper checks passed." { [ ! -e "$APP_DIR/.Foxora-Studio-install.$$" ] && [ ! -e "$APP_DIR/.Foxora-Studio-backup.$$" ]; } \ || die "an install staging path already exists in $APP_DIR." stage="$APP_DIR/.Foxora-Studio-install.$$" backup="$APP_DIR/.Foxora-Studio-backup.$$" if [ ! -d "$APP_DIR" ]; then mkdir -p "$APP_DIR" 2>/dev/null || as_root mkdir -p "$APP_DIR"; fi # Current-user permissions where they suffice; only a protected folder needs sudo. if [ -w "$APP_DIR" ]; then priv=""; else priv=as_root; fi $priv ditto "$source_app" "$stage" assert_mac_idle if [ -e "$APP_PATH" ]; then $priv mv "$APP_PATH" "$backup" || die "could not move the current app aside; nothing was changed." fi if ! $priv mv "$stage" "$APP_PATH"; then if [ -e "$backup" ]; then $priv mv "$backup" "$APP_PATH"; fi $priv rm -rf "$stage" 2>/dev/null || true die "installation failed; the previous app was restored." fi # The new app is in place. A previous copy installed by another user or by root # may need sudo to delete; failing to delete it does not undo the install. if [ -e "$backup" ]; then $priv rm -rf "$backup" 2>/dev/null || as_root rm -rf "$backup" \ || say "could not delete the previous copy at $backup; remove it yourself when convenient." fi installed="$(mac_bundle_version "$APP_PATH")" [ "$installed" = "$VERSION" ] || die "$APP_PATH reports version ${installed:-unknown} after installing $VERSION." say "Foxora Studio $VERSION installed at $APP_PATH. Accounts, device keys, permissions and projects were preserved." if [ "${FOXORA_NO_LAUNCH:-0}" != 1 ]; then open "$APP_PATH"; fi } main() { PRINT_TARGET=0 for arg in "$@"; do case "$arg" in --install | install | --update | update) ;; --print-target) PRINT_TARGET=1 ;; -h | --help) usage; exit 0 ;; *) die "unknown option $arg (see --help)" ;; esac done os="$(uname -s)"; arch="$(uname -m)" case "$os" in Darwin) mac_main ;; Linux) linux_main ;; *) die "this installer is for macOS and Linux. For $os, see https://foxora.ai/download" ;; esac } main "$@"