Files
Adopt/install-app
2026-10-10 12:24:13 +03:00

311 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
# install-app — install executables (AppImage/ELF/script) to PATH with .desktop integration
# Usage: install-app [OPTIONS] <executable>
set -euo pipefail
# ── Colors ────────────────────────────────────────────────────────────────────
RED='\033[0;31m'
GRN='\033[0;32m'
YLW='\033[1;33m'
BLU='\033[0;34m'
BLD='\033[1m'
RST='\033[0m'
info() { echo -e "${BLU}::${RST} $*"; }
success() { echo -e "${GRN}✓${RST} $*"; }
warn() { echo -e "${YLW}⚠${RST} $*"; }
error() { echo -e "${RED}✗${RST} $*" >&2; }
die() { error "$*"; exit 1; }
# ── Defaults ──────────────────────────────────────────────────────────────────
GLOBAL=false
ICON=""
APP_NAME=""
CATEGORY="Utility"
UPDATE_FILE=""
FORCE=false
UNINSTALL=false
# ── Help ──────────────────────────────────────────────────────────────────────
usage() {
cat <<EOF
${BLD}install-app${RST} — install executables with .desktop menu integration
${BLD}USAGE${RST}
install-app [OPTIONS] <executable>
${BLD}OPTIONS${RST}
-g, --global Install system-wide (/usr/local/bin, /usr/share/)
Requires sudo / root
-i, --icon <file> Icon file (png/svg/xpm)
-n, --name <name> Display name (default: derived from filename)
-c, --category <cat> .desktop category (default: Utility)
--update <new_file> Replace binary with new version (keeps .desktop & icon)
--uninstall Remove binary, icon and .desktop entry
--force Overwrite/remove without confirmation
-h, --help Show this help
${BLD}EXAMPLES${RST}
install-app ~/Downloads/MyApp.AppImage
install-app -g -i ~/icon.png -n "My App" ~/Downloads/MyApp.AppImage
install-app --update ~/Downloads/MyApp-v2.AppImage -n "My App"
install-app --uninstall -n "My App"
sudo install-app -g --uninstall -n "My App"
EOF
}
# ── Argument parsing ──────────────────────────────────────────────────────────
POSITIONAL=()
while [[ $# -gt 0 ]]; do
case "$1" in
-g|--global) GLOBAL=true; shift ;;
-i|--icon) ICON="$2"; shift 2 ;;
-n|--name) APP_NAME="$2"; shift 2 ;;
-c|--category) CATEGORY="$2"; shift 2 ;;
--update) UPDATE_FILE="$2"; shift 2 ;;
--uninstall) UNINSTALL=true; shift ;;
--force) FORCE=true; shift ;;
-h|--help) usage; exit 0 ;;
-*) die "Unknown option: $1. Use -h for help." ;;
*) POSITIONAL+=("$1"); shift ;;
esac
done
# ── Resolve executable ────────────────────────────────────────────────────────
if $UNINSTALL; then
# uninstall: -n required OR positional arg for name derivation
if [[ -z "$APP_NAME" ]]; then
if [[ ${#POSITIONAL[@]} -gt 0 ]]; then
SRC_FILE="$(realpath "${POSITIONAL[0]}" 2>/dev/null || echo "${POSITIONAL[0]}")"
else
die "Provide app name via -n <name> or pass the original executable as argument."
fi
else
SRC_FILE=""
fi
elif [[ -n "$UPDATE_FILE" ]]; then
SRC_FILE="$UPDATE_FILE"
SRC_FILE="$(realpath "$SRC_FILE")"
[[ -f "$SRC_FILE" ]] || die "File not found: $SRC_FILE"
else
[[ ${#POSITIONAL[@]} -eq 0 ]] && { usage; exit 1; }
SRC_FILE="${POSITIONAL[0]}"
SRC_FILE="$(realpath "$SRC_FILE")"
[[ -f "$SRC_FILE" ]] || die "File not found: $SRC_FILE"
fi
# ── Derive app name ───────────────────────────────────────────────────────────
if [[ -z "$APP_NAME" ]]; then
BASENAME="$(basename "${SRC_FILE:-}")"
# Strip known extensions: .AppImage, .appimage, .sh, .py, .bin, .elf
APP_NAME="${BASENAME%%.AppImage}"
APP_NAME="${APP_NAME%%.appimage}"
APP_NAME="${APP_NAME%%.sh}"
APP_NAME="${APP_NAME%%.py}"
APP_NAME="${APP_NAME%%.bin}"
APP_NAME="${APP_NAME%%.elf}"
# Strip version suffixes like -x86_64, -1.2.3, _amd64
APP_NAME="$(echo "$APP_NAME" | sed -E 's/[-_][vV]?[0-9]+[._0-9]*[-_]?(x86_64|amd64|arm64|aarch64)?$//')"
APP_NAME="$(echo "$APP_NAME" | sed -E 's/[-_](x86_64|amd64|arm64|aarch64)$//')"
fi
DISPLAY_NAME="$APP_NAME"
BIN_NAME="$(echo "$APP_NAME" | tr '[:upper:]' '[:lower:]' | tr ' ' '-')"
DESKTOP_NAME="$(echo "$BIN_NAME" | tr '[:upper:]' '[:lower:]')"
# ── Paths ─────────────────────────────────────────────────────────────────────
if $GLOBAL; then
BIN_DIR="/usr/local/bin"
ICON_DIR="/usr/share/icons/hicolor/256x256/apps"
DESKTOP_DIR="/usr/share/applications"
DESKTOP_DB_DIR="/usr/share/applications"
else
BIN_DIR="$HOME/.local/bin"
ICON_DIR="$HOME/.local/share/icons/hicolor/256x256/apps"
DESKTOP_DIR="$HOME/.local/share/applications"
DESKTOP_DB_DIR="$HOME/.local/share/applications"
fi
DEST_BIN="$BIN_DIR/$BIN_NAME"
DESKTOP_FILE="$DESKTOP_DIR/${DESKTOP_NAME}.desktop"
# ── Sudo check ────────────────────────────────────────────────────────────────
if $GLOBAL && [[ $EUID -ne 0 ]]; then
die "System-wide install requires root. Run: sudo install-app -g $*"
fi
# ── UNINSTALL MODE ────────────────────────────────────────────────────────────
if $UNINSTALL; then
info "Uninstalling ${BLD}$DISPLAY_NAME${RST}"
FOUND=false
# Check both scopes
USER_BIN="$HOME/.local/bin/$BIN_NAME"
SYS_BIN="/usr/local/bin/$BIN_NAME"
if $GLOBAL; then
BINS_TO_CHECK=("$SYS_BIN")
ICON_DIRS_TO_CHECK=("/usr/share/icons/hicolor")
DESKTOP_FILES_TO_CHECK=("/usr/share/applications/${DESKTOP_NAME}.desktop")
DB_DIR="/usr/share/applications"
else
BINS_TO_CHECK=("$USER_BIN")
ICON_DIRS_TO_CHECK=("$HOME/.local/share/icons/hicolor")
DESKTOP_FILES_TO_CHECK=("$HOME/.local/share/applications/${DESKTOP_NAME}.desktop")
DB_DIR="$HOME/.local/share/applications"
fi
# Sudo check for global
if $GLOBAL && [[ $EUID -ne 0 ]]; then
die "System-wide uninstall requires root. Run: sudo install-app -g --uninstall -n \"$DISPLAY_NAME\""
fi
if ! $FORCE; then
warn "About to remove:"
for b in "${BINS_TO_CHECK[@]}"; do [[ -f "$b" ]] && echo " $b"; done
for d in "${DESKTOP_FILES_TO_CHECK[@]}"; do [[ -f "$d" ]] && echo " $d"; done
for idir in "${ICON_DIRS_TO_CHECK[@]}"; do
find "$idir" -name "${DESKTOP_NAME}.*" 2>/dev/null | while read -r f; do echo " $f"; done
done
read -rp " Continue? [y/N] " yn
[[ "${yn,,}" == "y" ]] || die "Aborted."
fi
# Remove binary
for b in "${BINS_TO_CHECK[@]}"; do
if [[ -f "$b" ]]; then
rm -f "$b"
success "Removed binary: $b"
FOUND=true
fi
done
# Remove .desktop
for d in "${DESKTOP_FILES_TO_CHECK[@]}"; do
if [[ -f "$d" ]]; then
rm -f "$d"
success "Removed .desktop: $d"
FOUND=true
fi
done
# Remove icon(s)
for idir in "${ICON_DIRS_TO_CHECK[@]}"; do
while IFS= read -r iconfile; do
rm -f "$iconfile"
success "Removed icon: $iconfile"
FOUND=true
done < <(find "$idir" -name "${DESKTOP_NAME}.*" 2>/dev/null)
done
# Update desktop db
if command -v update-desktop-database &>/dev/null; then
update-desktop-database "$DB_DIR" 2>/dev/null || true
fi
$FOUND || warn "Nothing found to remove for '$BIN_NAME'"
exit 0
fi
# ── UPDATE MODE ───────────────────────────────────────────────────────────────
if [[ -n "$UPDATE_FILE" ]]; then
info "Update mode for: ${BLD}$DISPLAY_NAME${RST}"
# Find installed binary
if [[ ! -f "$DEST_BIN" ]]; then
# Try the other scope
ALT_BIN=""
if $GLOBAL; then
ALT_BIN="$HOME/.local/bin/$BIN_NAME"
else
ALT_BIN="/usr/local/bin/$BIN_NAME"
fi
if [[ -f "$ALT_BIN" ]]; then
DEST_BIN="$ALT_BIN"
warn "Found binary in alternate location: $DEST_BIN"
else
die "No installed binary found for '$BIN_NAME'. Install it first."
fi
fi
info "Replacing: $DEST_BIN"
cp "$SRC_FILE" "$DEST_BIN"
chmod +x "$DEST_BIN"
success "Binary updated: $DEST_BIN"
exit 0
fi
# ── INSTALL MODE ──────────────────────────────────────────────────────────────
info "Installing ${BLD}$DISPLAY_NAME${RST} $(if $GLOBAL; then echo '(system-wide)'; else echo "(user: $USER)"; fi)"
# Confirm overwrite
if [[ -f "$DEST_BIN" ]] && ! $FORCE; then
warn "Binary already exists: $DEST_BIN"
read -rp " Overwrite? [y/N] " yn
[[ "${yn,,}" == "y" ]] || die "Aborted."
fi
# Create directories
mkdir -p "$BIN_DIR" "$ICON_DIR" "$DESKTOP_DIR"
# ── Copy binary ───────────────────────────────────────────────────────────────
cp "$SRC_FILE" "$DEST_BIN"
chmod +x "$DEST_BIN"
success "Binary installed: $DEST_BIN"
# ── Handle icon ───────────────────────────────────────────────────────────────
ICON_VALUE="application-x-executable"
if [[ -n "$ICON" ]]; then
ICON="$(realpath "$ICON")"
[[ -f "$ICON" ]] || die "Icon file not found: $ICON"
ICON_EXT="${ICON##*.}"
DEST_ICON="$ICON_DIR/${DESKTOP_NAME}.${ICON_EXT}"
cp "$ICON" "$DEST_ICON"
ICON_VALUE="$DEST_ICON"
success "Icon installed: $DEST_ICON"
else
info "No icon provided — using default: application-x-executable"
fi
# ── Create .desktop file ──────────────────────────────────────────────────────
cat > "$DESKTOP_FILE" <<EOF
[Desktop Entry]
Type=Application
Name=$DISPLAY_NAME
Exec=$DEST_BIN %F
Icon=$ICON_VALUE
Categories=$CATEGORY;
Terminal=false
StartupNotify=true
EOF
success ".desktop created: $DESKTOP_FILE"
# ── Update desktop database ───────────────────────────────────────────────────
if command -v update-desktop-database &>/dev/null; then
update-desktop-database "$DESKTOP_DB_DIR" 2>/dev/null && \
success "Desktop database updated"
fi
# Update icon cache (non-critical)
if command -v gtk-update-icon-cache &>/dev/null; then
ICON_THEME_DIR="$(dirname "$(dirname "$(dirname "$ICON_DIR")")")"
gtk-update-icon-cache -q "$ICON_THEME_DIR" 2>/dev/null || true
fi
# ── Summary ───────────────────────────────────────────────────────────────────
echo ""
echo -e "${BLD}── Summary ──────────────────────────────${RST}"
echo -e " Name : ${BLD}$DISPLAY_NAME${RST}"
echo -e " Binary : $DEST_BIN"
echo -e " .desktop : $DESKTOP_FILE"
echo -e " Icon : $ICON_VALUE"
echo -e " Scope : $(if $GLOBAL; then echo 'system-wide'; else echo "user ($USER)"; fi)"
echo ""
echo -e " Run with : ${BLD}$BIN_NAME${RST}"
echo -e "${BLD}─────────────────────────────────────────${RST}"