#!/usr/bin/env bash # menu.sh — Ubuntu/Debian Setup Manager # # Interactive menu that fetches and runs the hardened install scripts in this # Opengist. Each script is downloaded to a temp file (not blindly piped to bash) # and executed with the appropriate privilege level for that script: # # - "sudo" mode for system-wide installers (apt, /etc, /usr/local/bin) # - "user" mode for per-user installers that must NOT run as root # (gsettings, ~/.local, JetBrains Toolbox, fonts) # # Usage: # bash -c "$(curl -fsSL https://opengist.resetrix.work/weehong/2de15ba0106a475fa41215159203a63b/raw/HEAD/menu.sh)" # # Or save and run locally: # curl -fsSLO https://opengist.resetrix.work/weehong/2de15ba0106a475fa41215159203a63b/raw/HEAD/menu.sh # bash menu.sh # Note: NOT using `set -e` because we want the menu loop to survive a failed # sub-script. We do use -u and pipefail to catch real bugs in this file. set -uo pipefail IFS=$'\n\t' readonly SCRIPT_NAME="${0##*/}" readonly BASE_URL='https://opengist.resetrix.work/weehong/2de15ba0106a475fa41215159203a63b/raw/HEAD' log() { printf '\033[1;34m[menu]\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m[menu] WARN:\033[0m %s\n' "$*" >&2; } err() { printf '\033[1;31m[menu] ERROR:\033[0m %s\n' "$*" >&2; } hr() { printf '%s\n' "------------------------------------------------------"; } # Refuse to run as root: per-user scripts (jetbrains, fonts, ibus) need a real # desktop user. Sub-scripts elevate via sudo on their own. if (( EUID == 0 )); then err "Don't run $SCRIPT_NAME as root. Run as your normal user — it will call sudo for installers that need it." exit 1 fi # Sanity-check tools we depend on. for tool in curl bash mktemp; do command -v "$tool" >/dev/null 2>&1 || { err "Missing required tool: $tool"; exit 1; } done # Distro check (warn-only; sub-scripts enforce strictly). if [[ -r /etc/os-release ]]; then # shellcheck disable=SC1091 . /etc/os-release case "${ID:-}:${ID_LIKE:-}" in *ubuntu*|*debian*) : ;; *) warn "Detected ${PRETTY_NAME:-unknown}. These installers target Debian/Ubuntu; some will refuse to run." ;; esac fi # Cache sudo credentials up-front so sub-scripts that elevate don't keep # re-prompting in the middle of a multi-task run. prime_sudo() { if ! sudo -n true 2>/dev/null; then log "Caching sudo credentials (you may be prompted)..." sudo -v || { err "sudo authentication failed."; return 1; } fi # Keep sudo timestamp refreshed in the background while the menu runs. ( while true; do sudo -n true 2>/dev/null; sleep 60; done ) & SUDO_KEEPALIVE_PID=$! trap 'kill "${SUDO_KEEPALIVE_PID:-}" 2>/dev/null || true' EXIT } # run_script # mode: "sudo" (run as root via sudo) or "user" (run as current user) # Returns: 0 on success, non-zero on failure (does NOT exit the menu). run_script() { local name="$1" mode="$2" local url="$BASE_URL/$name" local tmp tmp="$(mktemp -t "${name%.sh}.XXXXXX.sh")" || { err "mktemp failed"; return 1; } log "Fetching $name..." if ! curl -fsSL --max-time 60 --retry 2 "$url" -o "$tmp"; then err "Download failed: $url" rm -f "$tmp" return 1 fi if [[ ! -s "$tmp" ]]; then err "Downloaded $name is empty." rm -f "$tmp" return 1 fi # Cheap sanity: confirm it looks like a shell script. if ! head -n1 "$tmp" | grep -qE '^#!.*sh'; then warn "$name doesn't start with a shebang; proceeding anyway." fi chmod +x "$tmp" local rc=0 if [[ "$mode" == "sudo" ]]; then sudo bash "$tmp" || rc=$? else bash "$tmp" || rc=$? fi rm -f "$tmp" if (( rc != 0 )); then err "$name exited with status $rc" fi return "$rc" } # Catalog: number | category | label | script-filename | mode | include-in-all # (Edit here to add/remove options — the menu loop is data-driven.) OPTIONS=( "1|Browsers & Mail|Install Google Chrome|install-chrome.sh|sudo|yes" "2|Browsers & Mail|Install Firefox (Mozilla APT)|install-firefox.sh|sudo|yes" "3|Browsers & Mail|Install Thunderbird|install-thunderbird.sh|sudo|yes" "4|Communication|Install Telegram Desktop|install-telegram.sh|sudo|yes" "5|Communication|Install Discord|install-discord.sh|sudo|yes" "6|Files, Downloads & Storage|Install LocalSend|install-localsend.sh|sudo|yes" "7|Files, Downloads & Storage|Install qBittorrent (latest AppImage)|install-qbittorrent.sh|sudo|yes" "8|Files, Downloads & Storage|Mount Synology Network Drive|install-network_drive.sh|sudo|yes" "9|Productivity & Security|Install LibreOffice (latest stable .deb)|install-libreoffice.sh|sudo|yes" "10|Productivity & Security|Install Obsidian|install-obsidian.sh|sudo|yes" "11|Productivity & Security|Install MarkText (Markdown editor)|install-marktext.sh|sudo|yes" "12|Productivity & Security|Install draw.io Desktop|install-drawio.sh|sudo|yes" "13|Productivity & Security|Install Espanso (text expander)|install-espanso.sh|sudo|yes" "14|Productivity & Security|Install 1Password|install-1password.sh|sudo|yes" "15|Development Tools|Install Visual Studio Code|install-vscode.sh|sudo|yes" "16|Development Tools|Install JetBrains Toolbox|install-jetbrains-toolbox.sh|user|yes" "17|Development Tools|Install Bruno (API client)|install-bruno.sh|sudo|yes" "18|Development Tools|Install IPATool|install-ipatool.sh|sudo|yes" "19|Desktop & Localization|Install IBus Intelligent Pinyin|install-ibus-pinyin.sh|user|yes" "20|Desktop & Localization|Install Nerd Fonts|install-font.sh|user|yes" "21|System & Maintenance|Install Screenshots Cleanup Cron|install-screenshot-cleanup-cron.sh|user|yes" "22|System & Maintenance|Fix Dual-Boot Time (RTC to UTC)|timedatectl-fix.sh|sudo|yes" "23|System & Maintenance|Disable GRUB Countdown Timer|disable-grub-countdown.sh|sudo|yes" "24|Backup|Install Hourly Thunderbird Synology Backup|install-thunderbird-backup-cron.sh|user|no" "26|System & Maintenance|Update Installed Software|update-all|group|no" ) # Software installers that are safe to re-run when their software is already # installed. Configuration, maintenance, backup, and sync tasks are excluded. SOFTWARE_NUMS=(1 2 3 4 5 6 7 9 10 11 12 13 14 15 16 17 18 19 20) package_is_installed() { [[ "$(dpkg-query -W -f='${db:Status-Abbrev}' "$1" 2>/dev/null || true)" == "ii " ]] } package_pattern_is_installed() { local status while IFS= read -r status; do [[ "$status" == "ii " ]] && return 0 done < <(dpkg-query -W -f='${db:Status-Abbrev}\n' "$1" 2>/dev/null || true) return 1 } software_is_installed() { local choice="$1" case "$choice" in 1) package_is_installed google-chrome-stable ;; 2) package_is_installed firefox ;; 3) package_is_installed thunderbird ;; 4) [[ -x /opt/Telegram/Telegram ]] ;; 5) package_is_installed discord ;; 6) command -v localsend_app >/dev/null 2>&1 ;; 7) [[ -x /opt/qbittorrent/qbittorrent ]] ;; 9) package_pattern_is_installed 'libreoffice*' ;; 10) command -v obsidian >/dev/null 2>&1 ;; 11) command -v marktext >/dev/null 2>&1 ;; 12) command -v drawio >/dev/null 2>&1 ;; 13) command -v espanso >/dev/null 2>&1 ;; 14) package_is_installed 1password ;; 15) package_is_installed code ;; 16) [[ -x "$HOME/.local/share/JetBrains/Toolbox/bin/jetbrains-toolbox" ]] ;; 17) package_is_installed bruno ;; 18) [[ -x /usr/local/bin/ipatool ]] ;; 19) package_is_installed ibus-libpinyin ;; 20) compgen -G "$HOME/.local/share/fonts/UbuntuSans/*.[ot]tf" >/dev/null && compgen -G "$HOME/.local/share/fonts/JetBrainsMonoNerdFont/*.[ot]tf" >/dev/null ;; *) return 1 ;; esac } print_menu() { local entry num category label filename mode include_in_all local last_category="" note display_label hr echo " Ubuntu / Debian Setup Manager" hr for entry in "${OPTIONS[@]}"; do IFS='|' read -r num category label filename mode include_in_all <<< "$entry" if [[ "$category" != "$last_category" ]]; then printf '%s\n' "--- [ $category ] ---" last_category="$category" fi note="" if [[ "$mode" == "group" ]]; then note="updates installed software from options 1-7 and 9-20" elif [[ "$mode" == "user" ]]; then note="runs as you, not root" fi if [[ "$include_in_all" != "yes" && "$mode" != "group" ]]; then [[ -n "$note" ]] && note+="; " note+="not in ALL" fi display_label="$label" [[ -n "$note" ]] && display_label+=" ($note)" printf '%4s) %s\n' "$num" "$display_label" done hr echo " 0) Run ALL setup options (1-23)" echo " -1) Exit" hr } # Resolve a numeric choice to its catalog entry; print "name|mode" to stdout. resolve_choice() { local want="$1" entry num category label filename mode include_in_all for entry in "${OPTIONS[@]}"; do IFS='|' read -r num category label filename mode include_in_all <<< "$entry" if [[ "$num" == "$want" ]]; then printf '%s|%s' "$filename" "$mode" return 0 fi done return 1 } ALL_NUMS=() for entry in "${OPTIONS[@]}"; do IFS='|' read -r num category label filename mode include_in_all <<< "$entry" [[ "$include_in_all" == "yes" ]] && ALL_NUMS+=("$num") done prime_sudo || exit 1 while true; do print_menu read -rp "Enter choices separated by spaces (e.g., 1 7 12), 0 for ALL, -1 to exit: " choices /dev/null; then bad+=" $c" fi done if [[ -n "$bad" ]]; then err "Invalid option(s):$bad" sleep 1 continue fi failures=() for c in "${selected_choices[@]}"; do if [[ "$c" == "26" ]]; then hr log "[26] Updating installed software..." for software_choice in "${SOFTWARE_NUMS[@]}"; do spec="$(resolve_choice "$software_choice")" name="${spec%|*}" mode="${spec##*|}" if ! software_is_installed "$software_choice"; then log "[$software_choice] Skipping $name (not installed)." continue fi hr log "[$software_choice] Running $name ($mode)..." if ! run_script "$name" "$mode"; then failures+=("$software_choice:$name") fi done continue fi spec="$(resolve_choice "$c")" name="${spec%|*}" mode="${spec##*|}" hr log "[$c] Running $name ($mode)..." if ! run_script "$name" "$mode"; then failures+=("$c:$name") fi done hr if (( ${#failures[@]} == 0 )); then log "All selected tasks completed." else warn "Completed with ${#failures[@]} failure(s): ${failures[*]}" fi echo done