#!/usr/bin/env bash # # SWAT+ Toolbox installer / updater for Linux. # # curl -fsSL https://install.swat.tamu.edu/spt.sh | bash # # Installs the compiled application directory (the dotnet publish layout, bins + # editable assets/config) to ~/.local/share/SWATPlus/SWATPlusToolbox and wires it # into the desktop. Re-running it updates in place using a file manifest: # # * a file the package didn't change -> left alone (your edits survive) # * a file the package changed -> downloaded fresh in full (replaced) # * a file you added (not in manifest) -> never touched # * a file the package removed -> deleted # # install.sh install or update # install.sh --uninstall remove the app, launcher, desktop entry and icon # set -euo pipefail # Download mirrors, tried in order: install.swat.tamu.edu, then install.swatplus.link. BASES=( "https://install.swat.tamu.edu/downloads" "https://install.swatplus.link/downloads" ) RELFEED="sptoolbox/releases" # feed path under each base APP_NAME="SWAT+ Toolbox" MAIN_EXE="swatplusToolboX" INSTALL_DIR="$HOME/.local/share/SWATPlus/SWATPlusToolbox" MANIFEST="$INSTALL_DIR/.manifest.txt" BIN_DIR="$HOME/.local/bin" LAUNCHER="$BIN_DIR/swatplus-toolbox" DESKTOP_DIR="$HOME/.local/share/applications" DESKTOP_FILE="$DESKTOP_DIR/swatplus-toolbox.desktop" ICON_DIR="$HOME/.local/share/icons/hicolor/256x256/apps" ICON_FILE="$ICON_DIR/swatplus-toolbox.png" HICOLOR="$HOME/.local/share/icons/hicolor" MIME_DIR="$HOME/.local/share/mime" MIME_TYPE="application/x-swatplus-project" # must match FileAssociationService.cs MIME_ICON="application-x-swatplus-project" # freedesktop icon name for the MIME type MIME_XML="$MIME_DIR/packages/swatplus-project.xml" say() { printf '\033[1;36m==>\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m!! \033[0m %s\n' "$*" >&2; } die() { printf '\033[1;31mxx \033[0m %s\n' "$*" >&2; exit 1; } GOOD_BASE="" # the mirror that last served a valid file; tried first next time # Portable sha256 (Linux: sha256sum; macOS: shasum). sha256() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1 else shasum -a 256 "$1" | cut -d' ' -f1; fi } # _looks_valid [expected-sha] # Some mirrors answer 200 with an HTML page for missing files (soft-404), so a # 200 is not enough: validate the content matches what we asked for. _looks_valid() { local kind="$1" f="$2" exp="${3:-}" [ -s "$f" ] || return 1 case "$kind" in gz) head -c2 "$f" | od -An -tx1 | tr -d ' \n' | grep -qi '^1f8b' ;; manifest) grep -qE '^[0-9a-f]{64}'$'\t' "$f" ;; version) grep -qE '^v?[0-9]+\.[0-9]+' "$f" ;; sha) [ "$(sha256 "$f")" = "$exp" ] ;; *) return 0 ;; esac } # Human-readable byte count (e.g. 180M). hbytes() { awk -v b="${1:-0}" 'BEGIN{ if (b>=1073741824) printf "%.1fG", b/1073741824; else if (b>=1048576) printf "%.0fM", b/1048576; else if (b>=1024) printf "%.0fK", b/1024; else printf "%dB", b; }' } # Portable file size in bytes (GNU stat, then BSD/macOS stat). _fsize() { stat -c %s "$1" 2>/dev/null || stat -f %z "$1" 2>/dev/null || echo 0; } # Compact, terminal-width-adaptive download progress. The bar is capped to a # short width and only drawn when a terminal is attached; piped/logged runs get # a single quiet line instead of thousands of carriage-return updates. _bar_width() { local cols w cols="$(tput cols 2>/dev/null || echo "${COLUMNS:-80}")" w=$(( cols - 22 )) [ "$w" -gt 28 ] && w=28 [ "$w" -lt 8 ] && w=8 printf '%s' "$w" } _draw_bar() { # local done="$1" total="$2" w pct fill bar w="$(_bar_width)" pct=$(( done * 100 / total )); [ "$pct" -gt 100 ] && pct=100 fill=$(( done * w / total )); [ "$fill" -gt "$w" ] && fill="$w" bar="$(printf '%*s' "$fill" '' | tr ' ' '#')" printf '\r [%-*s] %3d%% %s ' "$w" "$bar" "$pct" "$(hbytes "$done")" >&2 } download_show() { # local url="$1" out="$2" total pid rc total="$(curl -fsSLI --connect-timeout 8 --proto '=https' --tlsv1.2 "$url" 2>/dev/null \ | awk 'tolower($1)=="content-length:"{v=$2} END{gsub(/[\r ]/,"",v); print v}')" if { [ -t 2 ] || [ -t 1 ]; } && [ -n "$total" ] && [ "$total" -ge 1 ] 2>/dev/null; then curl -fSL -s --connect-timeout 8 --retry 3 --retry-delay 2 --proto '=https' --tlsv1.2 "$url" -o "$out" & pid=$! while kill -0 "$pid" 2>/dev/null; do _draw_bar "$(_fsize "$out")" "$total" sleep 0.2 done if wait "$pid"; then rc=0; else rc=$?; fi [ "$rc" -eq 0 ] && _draw_bar "$total" "$total" printf '\n' >&2 return "$rc" fi printf ' downloading %s (%s) ...\n' "$(basename "$url")" "$(hbytes "${total:-0}")" >&2 curl -fSL -s --connect-timeout 8 --retry 3 --retry-delay 2 --proto '=https' --tlsv1.2 "$url" -o "$out" } # fetch [expected-sha] [progress] # Tries each mirror; accepts the first that serves a *valid* file. fetch() { local rel="$1" out="$2" kind="$3" exp="${4:-}" prog="${5:-}" base order=() if [ -n "$GOOD_BASE" ]; then order=("$GOOD_BASE"); for base in "${BASES[@]}"; do [ "$base" != "$GOOD_BASE" ] && order+=("$base"); done else order=("${BASES[@]}") fi for base in "${order[@]}"; do if [ "$prog" = progress ]; then download_show "$base/$rel" "$out" || continue else curl -fsSL --connect-timeout 8 --retry 3 --retry-delay 2 --proto '=https' --tlsv1.2 "$base/$rel" -o "$out" 2>/dev/null || continue fi if _looks_valid "$kind" "$out" "$exp"; then GOOD_BASE="$base"; return 0; fi done return 1 } TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT command -v curl >/dev/null 2>&1 || die "curl is required but not installed." command -v tar >/dev/null 2>&1 || die "tar is required but not installed." # ============================ macOS (Apple Silicon) ============================ # Installs SWATPlusToolbox.app into ~/Applications, ad-hoc signs it, strips the # quarantine xattr, and registers it with Launch Services. Re-running updates in # place via the manifest (same skip-unchanged / full-if-changed model as Linux). # Avoids bash 4 features (associative arrays) since macOS ships bash 3.2. if [ "$(uname -s)" = "Darwin" ]; then MAC_CHANNEL="osx-arm64" MAC_TARBALL="SWATPlusToolbox-osx-arm64.tar.gz" # Bundle name is what Launchpad/Spotlight/Finder display (macOS shows the on-disk name, # not CFBundleDisplayName), so install it as "SWAT+ Toolbox.app". The tarball already # contains a top-level dir of this name, so extraction creates it as-is. APP="$HOME/Applications/SWAT+ Toolbox.app" MAC_MANIFEST="$HOME/Applications/.SWATPlusToolbox.manifest.txt" # sidecar, outside the bundle LSREGISTER="/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister" mac_register() { # Editing files inside a bundle invalidates its signature, so re-sign (ad-hoc), # drop any quarantine, and force Launch Services to index it. codesign --force --deep -s - "$APP" 2>/dev/null || warn "codesign failed (app may still run)" xattr -dr com.apple.quarantine "$APP" 2>/dev/null || true [ -x "$LSREGISTER" ] && "$LSREGISTER" -f "$APP" 2>/dev/null || true } if [ "${1:-}" = "--uninstall" ]; then say "Removing $APP_NAME" [ -x "$LSREGISTER" ] && "$LSREGISTER" -u "$APP" 2>/dev/null || true rm -rf "$APP" "$MAC_MANIFEST" say "Done. $APP_NAME has been removed." exit 0 fi [ "$(uname -m)" = "arm64" ] || die "Unsupported macOS architecture: $(uname -m). Apple Silicon only." mkdir -p "$HOME/Applications" if [ ! -d "$APP" ] || [ ! -f "$MAC_MANIFEST" ]; then say "Installing $APP_NAME (first install)" fetch "$MAC_TARBALL" "$TMP/app.tgz" gz "" progress || die "Could not download $MAC_TARBALL from any mirror." rm -rf "$APP" tar -xzf "$TMP/app.tgz" -C "$HOME/Applications" fetch "$RELFEED/$MAC_CHANNEL/manifest.txt" "$MAC_MANIFEST" manifest \ || warn "manifest not recorded; the next run will re-download the full app" else say "Updating $APP_NAME (syncing changed files)" fetch "$RELFEED/$MAC_CHANNEL/manifest.txt" "$TMP/manifest.txt" manifest \ || die "Could not fetch manifest from any mirror." changed=0; kept=0 while IFS=$'\t' read -r sha mode rel; do [ -n "${rel:-}" ] || continue oldsha="$(awk -F'\t' -v r="$rel" '$3==r{print $1; exit}' "$MAC_MANIFEST")" if [ "$oldsha" = "$sha" ] && [ -f "$APP/$rel" ]; then kept=$((kept+1)); continue; fi mkdir -p "$APP/$(dirname "$rel")" fetch "$RELFEED/$MAC_CHANNEL/current/$rel" "$APP/$rel" sha "$sha" \ || die "Could not download '$rel' from any mirror." chmod "$mode" "$APP/$rel" 2>/dev/null || true changed=$((changed+1)) done < "$TMP/manifest.txt" # Remove files the package no longer ships (never user-added ones). while IFS=$'\t' read -r _sha _mode rel; do [ -n "${rel:-}" ] || continue awk -F'\t' -v r="$rel" '$3==r{f=1} END{exit !f}' "$TMP/manifest.txt" \ || { [ -f "$APP/$rel" ] && rm -f "$APP/$rel"; } done < "$MAC_MANIFEST" cp -f "$TMP/manifest.txt" "$MAC_MANIFEST" say "Updated: $changed replaced, $kept unchanged" fi say "Signing and registering with Launch Services" mac_register say "Installed: $APP" say "Find 'SWAT+ Toolbox' in Launchpad or Spotlight." exit 0 fi # ============================ Linux (folder install) =========================== uninstall() { say "Removing $APP_NAME" rm -rf "$INSTALL_DIR" rm -f "$LAUNCHER" "$DESKTOP_FILE" "$ICON_FILE" \ "$HICOLOR/scalable/apps/swatplus-toolbox.svg" \ "$HICOLOR/scalable/mimetypes/$MIME_ICON.svg" \ "$HICOLOR/256x256/mimetypes/$MIME_ICON.png" \ "$MIME_XML" command -v update-mime-database >/dev/null 2>&1 && update-mime-database "$MIME_DIR" 2>/dev/null || true command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database "$DESKTOP_DIR" 2>/dev/null || true command -v gtk-update-icon-cache >/dev/null 2>&1 && gtk-update-icon-cache -f "$HICOLOR" 2>/dev/null || true say "Done. $APP_NAME has been removed (custom files under $INSTALL_DIR are gone too)." exit 0 } [ "${1:-}" = "--uninstall" ] && uninstall # Architecture -> channel + tarball name (channels match the app's update channels). case "$(uname -m)" in x86_64|amd64) CHANNEL=linux-amd64; TARBALL=SWATPlusToolbox-linux-x64.tar.gz ;; aarch64|arm64) CHANNEL=linux-arm64; TARBALL=SWATPlusToolbox-linux-arm64.tar.gz ;; *) die "Unsupported architecture: $(uname -m). Supported: x86_64, aarch64." ;; esac mkdir -p "$INSTALL_DIR" "$BIN_DIR" "$DESKTOP_DIR" "$ICON_DIR" apply_modes_and_manifest() { # Re-apply exec bits from the manifest (tar preserves them on first install, but # per-file updates need them set explicitly) and record the new manifest. while IFS=$'\t' read -r _sha mode rel; do [ -n "${rel:-}" ] || continue [ -f "$INSTALL_DIR/$rel" ] && chmod "$mode" "$INSTALL_DIR/$rel" 2>/dev/null || true done < "$TMP/manifest.txt" cp -f "$TMP/manifest.txt" "$MANIFEST" chmod +x "$INSTALL_DIR/$MAIN_EXE" 2>/dev/null || true } say "Fetching manifest for $CHANNEL" fetch "$RELFEED/$CHANNEL/manifest.txt" "$TMP/manifest.txt" manifest \ || die "Could not fetch manifest from any mirror. Is $CHANNEL published yet?" if [ ! -f "$MANIFEST" ]; then # ---- Fresh install: pull the whole tarball (fast), then record the manifest. say "Installing $APP_NAME (first install)" fetch "$TARBALL" "$TMP/app.tar.gz" gz "" progress || die "Could not download $TARBALL from any mirror." tar -xzf "$TMP/app.tar.gz" -C "$INSTALL_DIR" apply_modes_and_manifest else # ---- Update: download only files whose package hash changed. say "Updating $APP_NAME (syncing changed files)" declare -A OLD=() while IFS=$'\t' read -r sha _mode rel; do [ -n "${rel:-}" ] && OLD["$rel"]="$sha" done < "$MANIFEST" changed=0; kept=0 while IFS=$'\t' read -r sha mode rel; do [ -n "${rel:-}" ] || continue if [ "${OLD[$rel]:-}" = "$sha" ] && [ -f "$INSTALL_DIR/$rel" ]; then kept=$((kept+1)); continue # unchanged in package -> keep (your edits survive) fi mkdir -p "$INSTALL_DIR/$(dirname "$rel")" fetch "$RELFEED/$CHANNEL/current/$rel" "$INSTALL_DIR/$rel" sha "$sha" \ || die "Could not download '$rel' from any mirror." chmod "$mode" "$INSTALL_DIR/$rel" 2>/dev/null || true changed=$((changed+1)) done < "$TMP/manifest.txt" # Remove files the package used to own but no longer ships (never user-added ones). declare -A NEW=() while IFS=$'\t' read -r _sha _mode rel; do [ -n "${rel:-}" ] && NEW["$rel"]=1 done < "$TMP/manifest.txt" removed=0 for rel in "${!OLD[@]}"; do if [ -z "${NEW[$rel]:-}" ] && [ -f "$INSTALL_DIR/$rel" ]; then rm -f "$INSTALL_DIR/$rel"; removed=$((removed+1)) fi done apply_modes_and_manifest say "Updated: $changed replaced, $kept unchanged, $removed removed" fi say "Installing icons and .spt file association" # Install icons from the bundled assets (app icon + project-file/MIME icon). install_icon() { # local src="$INSTALL_DIR/assets/icons/$1" name="$2" section="$3" [ -f "$src.svg" ] && { mkdir -p "$HICOLOR/scalable/$section"; cp -f "$src.svg" "$HICOLOR/scalable/$section/$name.svg"; } [ -f "$src.png" ] && { mkdir -p "$HICOLOR/256x256/$section"; cp -f "$src.png" "$HICOLOR/256x256/$section/$name.png"; } } install_icon swatplus-toolbox swatplus-toolbox apps install_icon spt-project "$MIME_ICON" mimetypes # .spt MIME type so the file manager shows the project icon and double-click opens the toolbox. mkdir -p "$MIME_DIR/packages" cat > "$MIME_XML" < SWAT+ Toolbox project MIMEEOF say "Creating launcher: $LAUNCHER" cat > "$LAUNCHER" < "$DESKTOP_FILE" </dev/null 2>&1 && update-mime-database "$MIME_DIR" 2>/dev/null || true command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database "$DESKTOP_DIR" 2>/dev/null || true command -v gtk-update-icon-cache >/dev/null 2>&1 && gtk-update-icon-cache -f "$HICOLOR" 2>/dev/null || true command -v xdg-mime >/dev/null 2>&1 && xdg-mime default swatplus-toolbox.desktop "$MIME_TYPE" 2>/dev/null || true say "Installed to $INSTALL_DIR" case ":$PATH:" in *":$BIN_DIR:"*) say "Launch with: swatplus-toolbox (or from your application menu)" ;; *) say "Launch from your application menu, or run: $LAUNCHER" warn "$BIN_DIR is not on PATH; add it to use the 'swatplus-toolbox' command." ;; esac