#!/usr/bin/env bash # lib/installers.sh — Download and install arbitrary DMG / PKG / ZIP installers. # # Config file: ~/.config/ssr/extra-installers.txt # Backed up to /BACKUP//extra-installers.txt during `ssr sync`. # Read from there during `ssr restore`. # # File format (one entry per line): # [optional-name ] # Lines starting with # and blank lines are ignored. # # Supported installer types: # .dmg — mounted; .app inside → cp to /Applications # .pkg inside → sudo installer # .pkg — sudo installer -pkg # .zip — unzipped; .app inside → cp to /Applications # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- # Download url to a temp file; echoes the temp path on success. _ssr_inst_download() { local url="$1" name="$2" local ext="${url##*.}" ext="${ext%%\?*}" # strip query string if present local tmp tmp="$(mktemp "/tmp/ssr_installer_XXXXXX.${ext}")" ssr::log " ↓ $name ($url)" if curl -fsSL --retry 3 --retry-delay 2 -o "$tmp" "$url"; then printf '%s' "$tmp" else rm -f "$tmp" return 1 fi } # Install a .dmg: mount → detect .app or .pkg → install → unmount. _ssr_inst_dmg() { local dmg="$1" name="$2" local mountpoint mountpoint="$(hdiutil attach -nobrowse -quiet -noverify "$dmg" \ | awk '/Volumes/ {for(i=3;i<=NF;i++) printf $i" "; print ""}' \ | sed 's/ *$//')" if [[ -z "$mountpoint" ]]; then ssr::warn " Could not mount DMG: $dmg" return 1 fi ssr::log " Mounted: $mountpoint" local installed=false # 1. .app bundles at root of volume → copy to /Applications local app while IFS= read -r app; do local app_name="${app##*/}" ssr::log " Installing $app_name → /Applications" cp -af "$app" "/Applications/$app_name" \ && ssr::ok " $app_name installed" \ && installed=true \ || ssr::warn " Failed to copy $app_name (needs sudo?)" done < <(find "$mountpoint" -maxdepth 2 -name '*.app' -type d 2>/dev/null) # 2. .pkg files at root of volume → run installer if [[ "$installed" == "false" ]]; then local pkg while IFS= read -r pkg; do ssr::log " Running pkg installer: ${pkg##*/}" sudo installer -pkg "$pkg" -target / \ && ssr::ok " ${pkg##*/} installed" \ && installed=true \ || ssr::warn " pkg install failed: ${pkg##*/}" done < <(find "$mountpoint" -maxdepth 2 -name '*.pkg' 2>/dev/null) fi hdiutil detach -quiet "$mountpoint" 2>/dev/null || true [[ "$installed" == "true" ]] } # Install a .pkg directly. _ssr_inst_pkg() { local pkg="$1" sudo installer -pkg "$pkg" -target / \ && ssr::ok " ${pkg##*/} installed" \ || { ssr::warn " pkg install failed: ${pkg##*/}"; return 1; } } # Install a .zip: unzip → find .app → copy to /Applications. _ssr_inst_zip() { local zip="$1" local tmpdir tmpdir="$(mktemp -d /tmp/ssr_zip_XXXXXX)" unzip -q "$zip" -d "$tmpdir" 2>/dev/null || true local installed=false app while IFS= read -r app; do local app_name="${app##*/}" ssr::log " Installing $app_name → /Applications" cp -af "$app" "/Applications/$app_name" \ && ssr::ok " $app_name installed" \ && installed=true \ || ssr::warn " Failed to copy $app_name" done < <(find "$tmpdir" -maxdepth 4 -name '*.app' -type d 2>/dev/null | head -20) rm -rf "$tmpdir" [[ "$installed" == "true" ]] } # Parse a single entry line into name + url. # Echoes two lines: name, then url. _ssr_inst_parse_line() { local line="$1" local name url if [[ "$line" =~ ^[^[:space:]]+[[:space:]]+https?:// ]]; then # "name URL" format name="${line%% *}" url="${line#* }" else # URL only — derive name from filename url="$line" name="${url##*/}" name="${name%%\?*}" # Strip version noise: EasyMCP-Desktop-3.0.3-arm64.dmg → EasyMCP Desktop name="${name%-arm64.*}" name="${name%-x64.*}" name="${name%.dmg}"; name="${name%.pkg}"; name="${name%.zip}" name="${name//-/ }" fi printf '%s\n%s\n' "$name" "$url" } # Check if an app is already installed (looks in /Applications and ~/Applications). _ssr_inst_already_installed() { local name="$1" # Normalise: strip version numbers and spaces for a loose match local base base="$(printf '%s' "$name" | sed 's/ [0-9].*//' | tr '[:upper:]' '[:lower:]')" find /Applications "$HOME/Applications" -maxdepth 1 -name '*.app' 2>/dev/null \ | while IFS= read -r app; do local a="${app##*/}"; a="${a%.app}" local al; al="$(printf '%s' "$a" | sed 's/ [0-9].*//' | tr '[:upper:]' '[:lower:]')" [[ "$al" == *"$base"* || "$base" == *"$al"* ]] && { printf 'found'; return 0; } done | grep -q 'found' } # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- # Parse and install every URL in a given installer list file. # Non-fatal: logs warnings on failure and continues with the next entry. ssr::installers::install_from_file() { local list_file="$1" [[ -f "$list_file" ]] || { ssr::log "No extra-installers file: $list_file (skipping)"; return 0; } local count=0 ok=0 skip=0 fail=0 name url ext tmp while IFS= read -r raw; do # Skip comments and blank lines. [[ "$raw" =~ ^[[:space:]]*# ]] && continue [[ -z "${raw// }" ]] && continue count=$((count + 1)) # Parse name + url. name="$(printf '%s' "$raw" | { _ssr_inst_parse_line "$raw"; } | head -1)" url="$( printf '%s' "$raw" | { _ssr_inst_parse_line "$raw"; } | tail -1)" # Check if already installed. if _ssr_inst_already_installed "$name"; then ssr::log " already installed: $name (skip)" skip=$((skip + 1)) continue fi # Determine extension. ext="${url##*.}"; ext="${ext%%\?*}"; ext="${ext,,}" # Download. if ! tmp="$(_ssr_inst_download "$url" "$name")"; then ssr::warn " Download failed: $url" fail=$((fail + 1)) continue fi # Install based on type. local result=0 case "$ext" in dmg) _ssr_inst_dmg "$tmp" "$name" || result=$? ;; pkg) _ssr_inst_pkg "$tmp" || result=$? ;; zip) _ssr_inst_zip "$tmp" || result=$? ;; *) ssr::warn " Unsupported installer type: .$ext ($url)" result=1 ;; esac rm -f "$tmp" if [[ $result -eq 0 ]]; then ok=$((ok + 1)) else fail=$((fail + 1)) fi done < "$list_file" ssr::ok "Extra installers: $count total — $ok installed, $skip skipped (already present), $fail failed" [[ $fail -eq 0 ]] # non-zero exit if any failed → tracked by ssr::rs::run } # During `ssr sync`: copy the local list file to the backup directory so it # travels to new machines and is versioned alongside the Brewfile. ssr::installers::sync_list() { local backup_dir="$1" local src="${SSR_CONFIG_DIR:-$HOME/.config/ssr}/extra-installers.txt" if [[ -f "$src" ]]; then cp "$src" "$backup_dir/extra-installers.txt" ssr::ok "extra-installers.txt → $backup_dir/extra-installers.txt" else ssr::log "No extra-installers.txt found at $src (skipping)" fi }