mirror of
https://github.com/oSoWoSo/DistroHopper.git
synced 2026-06-14 09:32:21 +00:00
952 lines
27 KiB
Bash
952 lines
27 KiB
Bash
#!/usr/bin/env bash
|
|
#
|
|
# lib.sh — shared functions for web-create and web-deploy
|
|
#
|
|
# Author: zenobit <zen@duck.com>
|
|
# License: MIT
|
|
#
|
|
|
|
RED='\033[0;31m'
|
|
GREEN='\033[0;32m'
|
|
YELLOW='\033[1;33m'
|
|
BLUE='\033[0;34m'
|
|
NC='\033[0m'
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Colored output — gum if available, ANSI fallback
|
|
# Bordered: _r _re _g _b _y (messages, errors)
|
|
# Inline: _red _green _blue _yel (for use inside printf/echo)
|
|
# ---------------------------------------------------------------------------
|
|
if command -v gum &>/dev/null; then
|
|
_r() {
|
|
gum style --border rounded --border-foreground "#e50203" --padding "0 1" "${@}"
|
|
}
|
|
_re() {
|
|
gum style --border rounded --border-foreground "#e50203" --padding "0 1" "${@}"
|
|
exit 1
|
|
}
|
|
_g() {
|
|
gum style --border rounded --border-foreground "#14a113" --padding "0 1" "$@"
|
|
}
|
|
_b() {
|
|
gum style --border rounded --border-foreground "#004EFB" --padding "0 1" "$@"
|
|
}
|
|
_y() {
|
|
gum style --border rounded --border-foreground "#fdde13" --padding "0 1" "$@"
|
|
}
|
|
_header() {
|
|
local text green red yellow
|
|
text="${1}"
|
|
red=$(gum style --border rounded --border-foreground "#e50203" --padding "0 1" "$text")
|
|
yellow=$(gum style --border rounded --border-foreground "#fdde13" --padding "0 1" "$red")
|
|
green=$(gum style --border rounded --border-foreground "#14a113" --padding "0 1" "$yellow")
|
|
echo "$green"
|
|
}
|
|
_footer() {
|
|
local text green red yellow
|
|
text="${1}"
|
|
green=$(gum style --border rounded --border-foreground "#14a113" --padding "0 1" "$text")
|
|
yellow=$(gum style --border rounded --border-foreground "#fdde13" --padding "0 1" "$green")
|
|
red=$(gum style --border rounded --border-foreground "#e50203" --padding "0 1" "$yellow")
|
|
echo "$red"
|
|
}
|
|
_red() { gum style --foreground "#e50203" "$1"; }
|
|
_green() { gum style --foreground "#14a113" "$1"; }
|
|
_blue() { gum style --foreground "#004EFB" "$1"; }
|
|
_yel() { gum style --foreground "#fdde13" "$1"; }
|
|
else
|
|
_g() { echo -e "${GREEN}${1}${NC}"; }
|
|
_r() { echo -e "${RED}${1}${NC}"; }
|
|
_re() {
|
|
echo -e "${RED}${1}${NC}" >&2
|
|
exit 1
|
|
}
|
|
_b() { echo -e "${BLUE}${1}${NC}"; }
|
|
_y() { echo -e "${YELLOW}${1}${NC}"; }
|
|
_header() { echo -e "\n${RED}${1}${NC}\n"; }
|
|
_footer() { echo -e "\n${RED}${1}${NC}\n"; }
|
|
_red() { printf "${RED}%s${NC}" "$1"; }
|
|
_green() { printf "${GREEN}%s${NC}" "$1"; }
|
|
_blue() { printf "${BLUE}%s${NC}" "$1"; }
|
|
_yel() { printf "${YELLOW}%s${NC}" "$1"; }
|
|
fi
|
|
|
|
export -f _r _re _g _b _y _red _green _blue _yel
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config file helpers — single KEY=value file, bash-sourceable
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# config_set KEY VALUE FILE — upsert a key in a bash-sourceable config file
|
|
config_set() {
|
|
local key="$1" val="$2" file="$3"
|
|
mkdir -p "$(dirname "$file")"
|
|
if grep -q "^${key}=" "$file" 2>/dev/null; then
|
|
sed -i "s|^${key}=.*|${key}=${val}|" "$file"
|
|
else
|
|
echo "${key}=${val}" >>"$file"
|
|
fi
|
|
}
|
|
|
|
export -f config_set
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Interactive wrappers — gum if available, plain read fallback
|
|
# ---------------------------------------------------------------------------
|
|
_confirm() {
|
|
if command -v gum &>/dev/null; then
|
|
gum confirm "$@"
|
|
else
|
|
echo -e "${YELLOW}${1}${NC}"
|
|
echo "Press Enter to continue, anything else to quit..."
|
|
read -r -n 1 -s key
|
|
[[ "$key" == '' ]]
|
|
fi
|
|
}
|
|
|
|
_input() {
|
|
if command -v gum &>/dev/null; then
|
|
gum input --placeholder "$1"
|
|
else
|
|
echo -e "${YELLOW}${1}${NC}"
|
|
read -r value
|
|
echo "$value"
|
|
fi
|
|
}
|
|
|
|
_write() {
|
|
if command -v gum &>/dev/null; then
|
|
gum write --placeholder "$1"
|
|
else
|
|
echo -e "${YELLOW}${1} (finish with empty line):${NC}"
|
|
local lines=""
|
|
while IFS= read -r line; do
|
|
[ -z "$line" ] && break
|
|
lines+="$line"$'\n'
|
|
done
|
|
echo "$lines"
|
|
fi
|
|
}
|
|
|
|
_choose() {
|
|
if command -v gum &>/dev/null; then
|
|
gum choose "$@"
|
|
else
|
|
echo -e "${YELLOW}Choose action:${NC}"
|
|
local i=1
|
|
for opt in "$@"; do
|
|
echo " $i) $opt"
|
|
((i++))
|
|
done
|
|
read -r choice
|
|
echo "${@:$choice:1}"
|
|
fi
|
|
}
|
|
|
|
# qget functions
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Error functions
|
|
# ---------------------------------------------------------------------------
|
|
error_specify_os() {
|
|
_re "Please specify an OS argument."
|
|
echo "Usage: qget <os> [<release>] [<edition>]"
|
|
echo "Run 'qget --list' to see available OSes."
|
|
}
|
|
|
|
error_specify_release() {
|
|
_re "Please specify a release for ${1}."
|
|
echo "Usage: qget ${1} <release> [<edition>]"
|
|
echo "Run 'qget --list ${1}' to see available releases."
|
|
}
|
|
|
|
error_not_supported_release() {
|
|
_re "Release '${2}' is not supported for ${1}."
|
|
echo "Run 'qget --list ${1}' to see available releases."
|
|
}
|
|
|
|
error_not_supported_lang() {
|
|
_re "Language '${1}' is not supported."
|
|
echo "Run 'qget --list' to see supported languages."
|
|
}
|
|
|
|
error_not_supported_argument() {
|
|
_re "Unknown argument: ${1}"
|
|
echo "Run 'qget --help' to see available options."
|
|
}
|
|
|
|
error_unable_to_create_dir() {
|
|
_re "Unable to create directory: ${1}"
|
|
echo "Reason: ${2}"
|
|
}
|
|
|
|
error_not_supported_image() {
|
|
_re "Image format '${1}' is not supported."
|
|
echo "Supported formats: qcow2, raw, vdi"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Web functions
|
|
# ---------------------------------------------------------------------------
|
|
function web_pipe() {
|
|
curl --disable --silent --location "${1}"
|
|
}
|
|
|
|
function web_pipe_json() {
|
|
curl --disable --silent --location --header "Accept: application/json" "${1}"
|
|
}
|
|
|
|
# Download a file from the web
|
|
function web_get() {
|
|
local CHECK=""
|
|
local HEADERS=()
|
|
local URL="${1}"
|
|
local DIR="${2}"
|
|
local FILE=""
|
|
local USER_AGENT="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36"
|
|
|
|
if [ -n "${3}" ]; then
|
|
FILE="${3}"
|
|
else
|
|
FILE="${URL##*/}"
|
|
fi
|
|
|
|
# Process any URL redirections after the file name has been extracted
|
|
URL=$(web_redirect "${URL}")
|
|
|
|
# Process any headers
|
|
while (( "$#" )); do
|
|
if [ "${1}" == "--header" ]; then
|
|
HEADERS+=("${1}" "${2}")
|
|
shift 2
|
|
else
|
|
shift
|
|
fi
|
|
done
|
|
|
|
# Test mode for ISO
|
|
if [ "${OPERATION}" == "show" ]; then
|
|
test_result "${OS}" "${RELEASE}" "${EDITION}" "${URL}"
|
|
exit 0
|
|
elif [ "${OPERATION}" == "test" ]; then
|
|
CHECK=$(web_check "${URL}" && echo "PASS" || echo "FAIL")
|
|
test_result "${OS}" "${RELEASE}" "${EDITION}" "${URL}" "${CHECK}"
|
|
exit 0
|
|
elif [ "${OPERATION}" == "download" ]; then
|
|
DIR="$(pwd)"
|
|
fi
|
|
|
|
if [ "${DIR}" != "$(pwd)" ] && ! mkdir -p "${DIR}" 2>/dev/null; then
|
|
error_unable_to_create_dir
|
|
fi
|
|
|
|
if [[ ${OS} != windows && ${OS} != macos && ${OS} != windows-server ]]; then
|
|
echo $"Downloading ${PRETTY} ${RELEASE} ${EDITION}"
|
|
echo $"- URL: ${URL}"
|
|
echo "- PATH: ${PWD}/${DIR}/${FILE}"
|
|
fi
|
|
|
|
if ! curl --disable --progress-bar --location --output "${DIR}/${FILE}" --continue-at - --user-agent "${USER_AGENT}" "${HEADERS[@]}" -- "${URL}"; then
|
|
echo $"ERROR! Failed to download ${URL} with curl."
|
|
rm -f "${DIR}/${FILE}"
|
|
fi
|
|
}
|
|
|
|
# Check if a URL needs to be redirected and return the final URL
|
|
function web_redirect() {
|
|
local REDIRECT_URL=""
|
|
local URL="${1}"
|
|
REDIRECT_URL=$(curl --silent --location --head --max-time 10 --write-out '%{url_effective}' --output /dev/null "${URL}" 2>/dev/null)
|
|
if [ -n "${REDIRECT_URL}" ] && [ "${REDIRECT_URL}" != "${URL}" ]; then
|
|
echo "${REDIRECT_URL}"
|
|
else
|
|
echo "${URL}"
|
|
fi
|
|
}
|
|
|
|
# Check if a URL is reachable
|
|
function web_check() {
|
|
local HEADERS=()
|
|
local URL="${1}"
|
|
while (( "$#" )); do
|
|
if [ "${1}" == "--header" ]; then
|
|
HEADERS+=("${1}" "${2}")
|
|
shift 2
|
|
else
|
|
shift
|
|
fi
|
|
done
|
|
curl --disable --silent --location --head --output /dev/null --fail --connect-timeout 30 --max-time 30 --retry 3 "${HEADERS[@]}" "${URL}"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Utility functions
|
|
# ---------------------------------------------------------------------------
|
|
cleanup() {
|
|
rm -f "${1}" || return $?
|
|
}
|
|
|
|
is_valid_language() {
|
|
local lang="${1}"
|
|
[[ "$lang" =~ ^(en|cs|de|es|fr|hu|it|ja|ko|nl|pl|pt|ru|sv|tr|zh)$ ]]
|
|
}
|
|
|
|
handle_missing() {
|
|
if [ -z "${1}" ]; then
|
|
_re "Missing required value for ${2}"
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
validate_release() {
|
|
local os="${1}"
|
|
local release="${2}"
|
|
local valid_releases
|
|
valid_releases=$(qget --list "$os" 2>/dev/null | grep -oP "^\d+\.\d+|^\d+")
|
|
echo "$valid_releases" | grep -qx "$release"
|
|
}
|
|
|
|
test_result() {
|
|
if [ $? -eq 0 ]; then
|
|
_g "OK"
|
|
else
|
|
_r "FAILED"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
test_all() {
|
|
local total=0
|
|
local passed=0
|
|
local failed=0
|
|
for test in "$@"; do
|
|
((total++))
|
|
if eval "$test" >/dev/null 2>&1; then
|
|
((passed++))
|
|
else
|
|
((failed++))
|
|
fi
|
|
done
|
|
echo "Results: $passed/$total passed, $failed/$total failed"
|
|
[ $failed -eq 0 ]
|
|
}
|
|
|
|
function check_hash() {
|
|
local iso=""
|
|
local hash=""
|
|
local hash_algo=""
|
|
local sig="${3:-}"
|
|
local key_id="${4:-}"
|
|
if [ "${OPERATION}" == "download" ]; then
|
|
iso="${1}"
|
|
else
|
|
iso="${VM_PATH}/${1}"
|
|
fi
|
|
if [[ -n "$sig" && ( -n "$key_id" || -n "${GPG:-}" ) ]]; then
|
|
check_signature "$iso" "$sig" "${key_id:-${GPG:-}}"
|
|
elif [[ -n "${GPG:-}" && -n "${SUMS_URL:-}" ]]; then
|
|
local auto_sig
|
|
auto_sig=$(mktemp)
|
|
if curl --disable --silent --location --fail -o "${auto_sig}" "${SUMS_URL}.asc" 2>/dev/null \
|
|
|| curl --disable --silent --location --fail -o "${auto_sig}" "${SUMS_URL}.gpg" 2>/dev/null; then
|
|
check_signature "${iso}" "${auto_sig}" "${GPG}"
|
|
else
|
|
echo $"WARNING! GPG set but no .asc/.gpg signature found at ${SUMS_URL}"
|
|
fi
|
|
rm -f "${auto_sig}"
|
|
fi
|
|
hash="${2}"
|
|
case ${#hash} in
|
|
32) hash_algo=md5sum;;
|
|
40) hash_algo=sha1sum;;
|
|
64) hash_algo=sha256sum;;
|
|
128) hash_algo=sha512sum;;
|
|
*) echo $"WARNING! Can't guess hash algorithm, not checking ${iso} hash."
|
|
return;;
|
|
esac
|
|
echo -n $"Checking ${iso} with ${hash_algo}... "
|
|
if ! echo "${hash} ${iso}" | ${hash_algo} --check --status; then
|
|
status="FAIL"
|
|
echo $"ERROR!"
|
|
echo $"${iso} doesn't match ${hash}. Try running 'qget' again."
|
|
table_add_row "$OS" "amd64" "$RELEASE" "$EDITION" "$iso" "$status"
|
|
exit 1
|
|
else
|
|
status="PASS"
|
|
echo $"Good!"
|
|
table_add_row "$OS" "amd64" "$RELEASE" "$EDITION" "$iso" "$status"
|
|
fi
|
|
}
|
|
|
|
function zsync_get() {
|
|
local CHECK=""
|
|
local DIR="${2}"
|
|
local FILE="${1##*/}"
|
|
local OUT=""
|
|
local URL="${1}"
|
|
if [ "${OPERATION}" == "show" ]; then
|
|
test_result "${OS}" "${RELEASE}" "${EDITION}" "${URL}"
|
|
exit 0
|
|
elif [ "${OPERATION}" == "test" ]; then
|
|
CHECK=$(web_check "${URL}" && echo "PASS" || echo "FAIL")
|
|
test_result "${OS}" "${RELEASE}" "${EDITION}" "${URL}" "${CHECK}"
|
|
exit 0
|
|
elif command -v zsync &>/dev/null; then
|
|
if [ -n "${3}" ]; then
|
|
OUT="${3}"
|
|
else
|
|
OUT="${FILE}"
|
|
fi
|
|
if ! mkdir -p "${DIR}" 2>/dev/null; then
|
|
error_unable_to_create_dir
|
|
fi
|
|
echo $"Downloading ${PRETTY} ${RELEASE} ${EDITION} from ${URL}"
|
|
if ! zsync "${URL/https/http}.zsync" -i "${DIR}/${OUT}" -o "${DIR}/${OUT}" 2>/dev/null; then
|
|
echo $"ERROR! Failed to download ${URL/https/http}.zsync"
|
|
exit 1
|
|
fi
|
|
if [ -e "${DIR}/${OUT}.zs-old" ]; then
|
|
rm "${DIR}/${OUT}.zs-old"
|
|
fi
|
|
else
|
|
echo $"INFO: zsync not found, falling back to curl"
|
|
if [ -n "${3}" ]; then
|
|
web_get "${1}" "${2}" "${3}"
|
|
else
|
|
web_get "${1}" "${2}"
|
|
fi
|
|
fi
|
|
}
|
|
|
|
function make_vm_config() {
|
|
local CONF_FILE=""
|
|
local IMAGE_FILE=""
|
|
local ISO_FILE=""
|
|
if [ "${OPERATION}" == "download" ]; then
|
|
exit 0
|
|
fi
|
|
IMAGE_FILE="${1}"
|
|
ISO_FILE="${2}"
|
|
|
|
if [ "${OS}" == 'custom' ]; then
|
|
GUEST="${CUSTOM_OS}"
|
|
IMAGE_TYPE="${CUSTOM_IMAGE_TYPE}"
|
|
fi
|
|
if [ -z "$GUEST" ]; then
|
|
GUEST="linux"
|
|
fi
|
|
if [ -z "${IMAGE_TYPE}" ]; then
|
|
IMAGE_TYPE="iso"
|
|
fi
|
|
|
|
CONF_FILE="${VM_PATH}.conf"
|
|
|
|
if [ ! -e "${CONF_FILE}" ]; then
|
|
echo $"Making ${CONF_FILE}"
|
|
cat << EOF > "${CONF_FILE}"
|
|
#!${QUICKEMU} --vm
|
|
guest_os="${GUEST}"
|
|
disk_img="${VM_PATH}/disk.qcow2"
|
|
${IMAGE_TYPE}="${VM_PATH}/${IMAGE_FILE}"
|
|
EOF
|
|
echo $" - Setting ${CONF_FILE} executable"
|
|
chmod u+x "${CONF_FILE}"
|
|
if [ -n "${ISO_FILE}" ]; then
|
|
echo "fixed_iso=\"${VM_PATH}/${ISO_FILE}\"" >> "${CONF_FILE}"
|
|
fi
|
|
if declare -F config_ &>/dev/null; then
|
|
config_
|
|
fi
|
|
if [ "${OPERATION}" == ui ]; then
|
|
echo
|
|
gum confirm $"Run new ${OS} VM?" && quickemu --vm "${CONF_FILE}"
|
|
fi
|
|
fi
|
|
echo -e $"\nTo start your ${PRETTY} virtual machine run:"
|
|
if [ "${OS}" == "slint" ]; then
|
|
echo -e $" quickemu --vm ${CONF_FILE}\nTo start Slint with braille support run:\n quickemu --vm --braille --display sdl ${CONF_FILE}"
|
|
else
|
|
echo " quickemu --vm ${CONF_FILE}"
|
|
fi
|
|
echo
|
|
unset GUEST IMAGE_TYPE
|
|
}
|
|
|
|
os_support() {
|
|
local os="${1}"
|
|
qget --list "$os" 2>/dev/null | grep -q "$release"
|
|
}
|
|
|
|
# qget wrappers for TUI scripts
|
|
qget_list_os() {
|
|
"${SCRIPT_DIR}/qget" --list-os "${@}" 2>/dev/null
|
|
}
|
|
|
|
qget_show() {
|
|
local os="${1}"
|
|
"${SCRIPT_DIR}/qget" --show "$os" 2>/dev/null
|
|
}
|
|
|
|
qget_download() {
|
|
local os="${1}"
|
|
local release="${2}"
|
|
local edition="${3:-}"
|
|
local arch="${4:-x86_64}"
|
|
if [ -n "$edition" ]; then
|
|
"${SCRIPT_DIR}/qget" --arch "$arch" "$os" "$release" "$edition"
|
|
else
|
|
"${SCRIPT_DIR}/qget" --arch "$arch" "$os" "$release"
|
|
fi
|
|
}
|
|
|
|
# Optional fallback to system quickget if installed
|
|
qget_or_quickget() {
|
|
if command -v quickget >/dev/null 2>&1; then
|
|
quickget "${@}"
|
|
elif [ -x "${SCRIPT_DIR}/qget" ]; then
|
|
"${SCRIPT_DIR}/qget" "${@}"
|
|
else
|
|
_re "Neither qget nor quickget found"
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
# Shortcut for qget (uses lib.sh or quickget fallback)
|
|
qget() {
|
|
qget_or_quickget "${@}"
|
|
}
|
|
|
|
# Public directory for OS info files
|
|
PUBLIC_DIR="${PUBLIC_DIR:-${SCRIPT_DIR:-.}/public}"
|
|
export PUBLIC_DIR
|
|
|
|
export -f error_specify_os error_specify_release error_not_supported_release \
|
|
error_not_supported_lang error_not_supported_argument \
|
|
error_unable_to_create_dir error_not_supported_image \
|
|
web_pipe web_pipe_json web_get web_redirect web_check \
|
|
cleanup is_valid_language handle_missing validate_release \
|
|
test_result test_all check_hash zsync_get make_vm_config os_support \
|
|
qget_list_os qget_show qget_download qget_or_quickget
|
|
|
|
# List all OS with releases and editions
|
|
#qget --list
|
|
|
|
detect_capabilities() {
|
|
HAS_CHAFA=0
|
|
HAS_NERD_FONT=0
|
|
command -v chafa >/dev/null && HAS_CHAFA=1
|
|
if command -v fc-match &>/dev/null; then
|
|
fc-match monospace 2>/dev/null | grep -qi 'nerd\|NF' && HAS_NERD_FONT=1
|
|
fi
|
|
# Kitty and WezTerm bundle nerd fonts
|
|
[[ -n "${KITTY_WINDOW_ID}" || "${TERM}" == "xterm-kitty" || -n "${WEZTERM_PANE}" ]] && HAS_NERD_FONT=1
|
|
export HAS_CHAFA HAS_NERD_FONT
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# VM management helpers — shared by q and any future VM UI scripts
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Populate global array $vms with only valid quickemu .conf files
|
|
load_vm_confs() {
|
|
vms=()
|
|
shopt -s nullglob
|
|
for _c in *.conf; do
|
|
grep -ql 'guest_os=\|quickemu --vm' "$_c" 2>/dev/null && vms+=("$_c")
|
|
done
|
|
shopt -u nullglob
|
|
}
|
|
|
|
# Print basenames (without .conf) of valid quickemu configs in cwd
|
|
list_vm_conf_names() {
|
|
shopt -s nullglob
|
|
for _c in *.conf; do
|
|
grep -ql 'guest_os=\|quickemu --vm' "$_c" 2>/dev/null && echo "${_c%.conf}"
|
|
done
|
|
shopt -u nullglob
|
|
}
|
|
|
|
# Echo path to the primary disk image for the VM named $chosen
|
|
get_vm_disk() {
|
|
local conf="${chosen}.conf"
|
|
local disk
|
|
disk=$(grep '^disk_img=' "$conf" 2>/dev/null | head -1 | cut -d'"' -f2)
|
|
[ -z "$disk" ] && disk="${chosen}/disk.qcow2"
|
|
echo "$disk"
|
|
}
|
|
|
|
# Show qemu-img info for the selected VM (uses gum; sets $chosen via gum_choose_VM)
|
|
disk_info() {
|
|
gum_choose_VM || return
|
|
local disk
|
|
disk=$(get_vm_disk)
|
|
if [ ! -f "$disk" ]; then
|
|
gum style --foreground 1 "No disk found: $disk"
|
|
return
|
|
fi
|
|
gum style --border rounded "Disk: $disk"
|
|
qemu-img info "$disk"
|
|
echo
|
|
printf "[enter] to continue "
|
|
read -r
|
|
}
|
|
|
|
# List snapshots on $1 (disk path)
|
|
snapshot_list() {
|
|
qemu-img snapshot -l "$1" 2>/dev/null
|
|
}
|
|
|
|
# Interactive snapshot create/delete UI for the selected VM
|
|
snapshot_manage() {
|
|
gum_choose_VM || return
|
|
local disk
|
|
disk=$(get_vm_disk)
|
|
if [ ! -f "$disk" ]; then
|
|
gum style --foreground 1 "No disk found: $disk"
|
|
return
|
|
fi
|
|
while true; do
|
|
gum style --border rounded "Snapshots: $chosen"
|
|
snapshot_list "$disk"
|
|
echo
|
|
local action
|
|
action=$(printf 'create\ndelete\nback' | gum choose --header="Snapshot action")
|
|
case "$action" in
|
|
create)
|
|
local tag
|
|
tag=$(gum input --placeholder "snapshot name (empty = timestamp)")
|
|
[ -z "$tag" ] && tag=$(date +%Y%m%d-%H%M%S)
|
|
if qemu-img snapshot -c "$tag" "$disk"; then
|
|
gum style --foreground 2 "Snapshot '$tag' created."
|
|
else
|
|
gum style --foreground 1 "Failed to create snapshot."
|
|
fi
|
|
;;
|
|
delete)
|
|
local snaps snap
|
|
snaps=$(qemu-img snapshot -l "$disk" 2>/dev/null | awk 'NR>2 && NF{print $2}')
|
|
if [ -z "$snaps" ]; then
|
|
gum style --foreground 1 "No snapshots to delete."
|
|
continue
|
|
fi
|
|
snap=$(echo "$snaps" | gum choose --header="Choose snapshot to delete")
|
|
[ -z "$snap" ] && continue
|
|
gum confirm "Delete snapshot '$snap'?" &&
|
|
qemu-img snapshot -d "$snap" "$disk" &&
|
|
gum style --foreground 2 "Deleted '$snap'."
|
|
;;
|
|
back | '') break ;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
# Compact disk (convert to new qcow2, keeping a timestamped backup)
|
|
disk_compact() {
|
|
gum_choose_VM || return
|
|
local disk
|
|
disk=$(get_vm_disk)
|
|
if [ ! -f "$disk" ]; then
|
|
gum style --foreground 1 "No disk found: $disk"
|
|
return
|
|
fi
|
|
local free_kb disk_kb
|
|
free_kb=$(df -k . 2>/dev/null | awk 'NR==2{print $4}')
|
|
disk_kb=$(du -k "$disk" 2>/dev/null | cut -f1)
|
|
if [[ -n "$free_kb" && -n "$disk_kb" && "$free_kb" -lt "$disk_kb" ]]; then
|
|
gum style --foreground 1 "Not enough free space for backup (need ~${disk_kb}K, have ${free_kb}K)."
|
|
return
|
|
fi
|
|
gum confirm "Compact '$disk'? A backup (.bak.qcow2) will be created." || return
|
|
local backup="${disk}.$(date +%Y%m%d-%H%M%S).bak.qcow2"
|
|
if mv "$disk" "$backup"; then
|
|
gum style --foreground 3 "Backup: $backup"
|
|
printf "Compacting... "
|
|
if qemu-img convert -O qcow2 "$backup" "$disk"; then
|
|
gum style --foreground 2 "Done. Disk compacted."
|
|
else
|
|
gum style --foreground 1 "Compact failed — restore: mv '$backup' '$disk'"
|
|
fi
|
|
else
|
|
gum style --foreground 1 "Failed to create backup."
|
|
fi
|
|
}
|
|
|
|
# Disk management sub-menu (disk info / snapshots / compact)
|
|
show_menu_disk() {
|
|
while true; do
|
|
local action
|
|
action=$(printf 'disk info\nsnapshots\ncompact disk\nback' | gum choose --header="Disk management")
|
|
case "$action" in
|
|
'disk info') disk_info ;;
|
|
'snapshots') snapshot_manage ;;
|
|
'compact disk') disk_compact ;;
|
|
back | '') break ;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
# Prevent two instances of the same script from running simultaneously
|
|
# Usage: multi_instance_check (uses $tmpdir or /tmp, $USER, and $0 basename)
|
|
multi_instance_check() {
|
|
local lockfile="${tmpdir:-/tmp}/$(basename "$0")-${USER}.lock"
|
|
if [ -f "$lockfile" ]; then
|
|
local pid
|
|
pid=$(cat "$lockfile" 2>/dev/null)
|
|
if kill -0 "$pid" 2>/dev/null; then
|
|
gum style --foreground 3 "Warning: another instance of $(basename "$0") is already running (PID $pid)"
|
|
gum confirm "Continue anyway?" || exit 1
|
|
fi
|
|
fi
|
|
echo $$ >"$lockfile"
|
|
trap 'rm -f "$lockfile"' EXIT
|
|
}
|
|
|
|
export -f load_vm_confs list_vm_conf_names get_vm_disk disk_info snapshot_list \
|
|
snapshot_manage disk_compact show_menu_disk multi_instance_check
|
|
|
|
TABLE_ROWS=()
|
|
|
|
table_clear() {
|
|
TABLE_ROWS=()
|
|
}
|
|
|
|
table_add_row() {
|
|
local os="$1" arch="$2" release="$3" edition="$4" url="$5" status="$6"
|
|
local row
|
|
|
|
row=$(printf "| %-12s | %-6s | %-10s | %-16s | %-40s | %-6s |" "$os" "$arch" "$release" "$edition" "$url" "$status")
|
|
|
|
for i in "${!TABLE_ROWS[@]}"; do
|
|
if [[ "${TABLE_ROWS[$i]}" == *"$url"* ]]; then
|
|
TABLE_ROWS[$i]="$row"
|
|
return
|
|
fi
|
|
done
|
|
|
|
TABLE_ROWS+=("$row")
|
|
}
|
|
|
|
table_write() {
|
|
local output="${1:-./TODO/all}"
|
|
local rows_count="${#TABLE_ROWS[@]}"
|
|
|
|
if [ "$rows_count" -eq 0 ]; then
|
|
return
|
|
fi
|
|
|
|
{
|
|
echo "### $(date '+%Y-%m-%d')"
|
|
printf "| %-12s | %-6s | %-10s | %-16s | %-40s | %-6s |\n" "OS" "Arch" "Release" "Edition" "URL" "Status"
|
|
printf "|-------------|------|-----------|------------------|----------------------------------------|--------|\n"
|
|
|
|
for row in "${TABLE_ROWS[@]}"; do
|
|
echo "$row"
|
|
done
|
|
|
|
echo
|
|
} >>"$output"
|
|
|
|
TABLE_ROWS=()
|
|
}
|
|
|
|
function table_summary() {
|
|
local os="${1}"
|
|
local pass=0 fail=0
|
|
for row in "${TABLE_ROWS[@]}"; do
|
|
[[ "$row" == *"$os"* ]] || continue
|
|
[[ "$row" == *"| PASS"* ]] && ((pass++)) || ((fail++))
|
|
done
|
|
if [ "$fail" -gt 0 ]; then
|
|
printf "AVAILABILITY: %s — %d PASS, %d FAIL\n" "$os" "$pass" "$fail"
|
|
fi
|
|
}
|
|
|
|
export -f table_clear table_add_row table_write table_summary
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# OS iteration dispatch — default + template overrides
|
|
# ---------------------------------------------------------------------------
|
|
function _emit_entry() {
|
|
local OS="$1" ARCH="$2" RELEASE="$3" EDITION="$4" URL="$5" OPERATION="$6" PUBLIC_DIR="$7"
|
|
local STATUS="OK"
|
|
if [ "${OPERATION}" == "test" ]; then
|
|
STATUS=$(web_check "${URL}" && echo "PASS" || echo "FAIL")
|
|
table_add_row "$OS" "$ARCH" "$RELEASE" "$EDITION" "$URL" "$STATUS"
|
|
fi
|
|
|
|
# Human-readable progress line
|
|
printf "%-5s %-13s %-10s %-10s %-16s %-30s\n" \
|
|
"${STATUS}" "${OS}" "${ARCH}" "${RELEASE}" "${EDITION}" "${URL}" \
|
|
| tee -a "${PUBLIC_DIR}/tmp_${OS}"
|
|
|
|
# Structured data (consumed by write_output).
|
|
# Empty EDITION → "-" so bash `read` doesn't collapse adjacent tabs.
|
|
printf '%s\t%s\t%s\t%s\t%s\n' \
|
|
"${ARCH}" "${RELEASE}" "${EDITION:--}" "${URL}" "${STATUS}" \
|
|
>> "${PUBLIC_DIR}/tmp_${OS}.dat"
|
|
}
|
|
|
|
function get_entries_() {
|
|
local OS="$1" ARCH="$2" RELEASE="$3" OPERATION="$4" PUBLIC_DIR="$5" TEMPLATES_DIR="$6"
|
|
if [[ $(type -t "editions_") == function ]]; then
|
|
for EDITION in $(editions_); do
|
|
. "${TEMPLATES_DIR}/${OS}"
|
|
local URL
|
|
URL=$(get_ | cut -d' ' -f1 | head -n 1)
|
|
_emit_entry "${OS}" "${ARCH}" "${RELEASE}" "${EDITION}" "${URL}" "${OPERATION}" "${PUBLIC_DIR}"
|
|
done
|
|
else
|
|
local URL
|
|
URL=$(get_ | cut -d' ' -f1 | head -n 1)
|
|
_emit_entry "${OS}" "${ARCH}" "${RELEASE}" "" "${URL}" "${OPERATION}" "${PUBLIC_DIR}"
|
|
fi
|
|
}
|
|
export -f _emit_entry get_entries_
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Architecture name mapping
|
|
# Templates use Debian-style names (amd64, arm64, i386, ppc64el, s390x,
|
|
# riscv64, armv7, armhf, x86, i686, i586, loongarch64). Upstream URLs often
|
|
# need the qemu/kernel-style name (x86_64, aarch64, ppc64le, ...).
|
|
# Pass dh-style ARCH and get the upstream name back.
|
|
# ---------------------------------------------------------------------------
|
|
_qarch_to_upstream() {
|
|
case "${1}" in
|
|
amd64) echo x86_64 ;;
|
|
arm64) echo aarch64 ;;
|
|
ppc64el) echo ppc64le ;;
|
|
x86) echo x86 ;;
|
|
i386|i686|i586|armv7|armhf|riscv64|s390x|loongarch64) echo "${1}" ;;
|
|
*) echo "${1}" ;;
|
|
esac
|
|
}
|
|
export -f _qarch_to_upstream
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# HTTP cache — memoize web_pipe / web_check for the duration of one OS run.
|
|
# Cache lives in $_WEB_CACHE_DIR (set by _iterate_os); when unset, no caching.
|
|
# Keys: sha256 of "${METHOD}\t${URL}". Values: response body (web_pipe) or
|
|
# exit status file (web_check). The cache survives across worker subshells
|
|
# because it's a filesystem directory, but is wiped between OSes.
|
|
# ---------------------------------------------------------------------------
|
|
_web_cache_key() {
|
|
printf '%s\t%s' "${1}" "${2}" | sha256sum | cut -d' ' -f1
|
|
}
|
|
|
|
_web_cache_get_body() {
|
|
local key="${1}"
|
|
[ -n "${_WEB_CACHE_DIR}" ] || return 1
|
|
local f="${_WEB_CACHE_DIR}/${key}.body"
|
|
[ -s "${f}" ] || return 1
|
|
cat "${f}"
|
|
}
|
|
|
|
_web_cache_put_body() {
|
|
local key="${1}"
|
|
[ -n "${_WEB_CACHE_DIR}" ] || return 0
|
|
cat >"${_WEB_CACHE_DIR}/${key}.body"
|
|
}
|
|
|
|
_web_cache_get_status() {
|
|
local key="${1}"
|
|
[ -n "${_WEB_CACHE_DIR}" ] || return 2
|
|
local f="${_WEB_CACHE_DIR}/${key}.status"
|
|
[ -f "${f}" ] || return 2
|
|
return "$(cat "${f}")"
|
|
}
|
|
|
|
_web_cache_put_status() {
|
|
local key="${1}" rc="${2}"
|
|
[ -n "${_WEB_CACHE_DIR}" ] || return 0
|
|
echo "${rc}" >"${_WEB_CACHE_DIR}/${key}.status"
|
|
}
|
|
export -f _web_cache_key _web_cache_get_body _web_cache_put_body \
|
|
_web_cache_get_status _web_cache_put_status
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Torrent / aria2 support
|
|
# ---------------------------------------------------------------------------
|
|
function torrent_get() {
|
|
local MAGNET="${1}"
|
|
local DIR="${2:-.}"
|
|
if ! command -v aria2c &>/dev/null; then
|
|
echo "INFO: aria2c not found, use 'web_get' fallback"
|
|
return 1
|
|
fi
|
|
echo "Downloading via magnet link..."
|
|
aria2c --dir="${DIR}" --seed-time=0 "${MAGNET}"
|
|
}
|
|
export -f torrent_get
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Rosette: Package manager info display
|
|
# ---------------------------------------------------------------------------
|
|
rosette_info() {
|
|
local osname="${1}"
|
|
if [ -z "$osname" ]; then
|
|
echo "Usage: rosette_info <osname>"
|
|
return 1
|
|
fi
|
|
|
|
local template_file="${TEMPLATES_DIR}/${osname}"
|
|
if [ ! -f "$template_file" ]; then
|
|
echo "Template not found: $osname"
|
|
return 1
|
|
fi
|
|
|
|
# Source the template to get BASEDOF
|
|
# shellcheck disable=SC1090
|
|
. "$template_file"
|
|
|
|
# Map BASEDOF to rosette ID
|
|
local rosette_id=""
|
|
local basedof_lower
|
|
basedof_lower=$(echo "${BASEDOF:-}" | tr '[:upper:]' '[:lower:]')
|
|
|
|
case "$basedof_lower" in
|
|
*arch*) rosette_id="arch" ;;
|
|
*debian*|*ubuntu*) rosette_id="debian" ;;
|
|
*void*) rosette_id="void" ;;
|
|
*fedora*|*rhel*|*"red hat"*) rosette_id="fedora" ;;
|
|
*gentoo*) rosette_id="gentoo" ;;
|
|
*alpine*) rosette_id="alpine" ;;
|
|
*opensuse*|*suse*) rosette_id="opensuse" ;;
|
|
*slackware*) rosette_id="slackware" ;;
|
|
*nix*) rosette_id="nix" ;;
|
|
*)
|
|
echo "No package manager info available for $osname (based on: ${BASEDOF:-unknown})"
|
|
return 0
|
|
;;
|
|
esac
|
|
|
|
local rosette_file="${SCRIPT_DIR}/data/rosette/${rosette_id}.yaml"
|
|
if [ ! -f "$rosette_file" ]; then
|
|
echo "Rosette data not found: $rosette_id"
|
|
return 1
|
|
fi
|
|
|
|
# Parse YAML with grep/sed (no external deps)
|
|
local pkg_manager init_system
|
|
pkg_manager=$(grep "^package_manager:" "$rosette_file" | sed 's/^package_manager: *//' | tr -d '"')
|
|
init_system=$(grep "^init:" "$rosette_file" | sed 's/^init: *//' | tr -d '"')
|
|
|
|
# Extract commands
|
|
local install_cmd remove_cmd update_cmd search_cmd
|
|
install_cmd=$(sed -n '/^ install:/,/^ [a-z]/p' "$rosette_file" | grep "cmd:" | sed 's/.*cmd: *//' | tr -d '"')
|
|
remove_cmd=$(sed -n '/^ remove:/,/^ [a-z]/p' "$rosette_file" | grep "cmd:" | sed 's/.*cmd: *//' | tr -d '"')
|
|
update_cmd=$(sed -n '/^ update:/,/^ [a-z]/p' "$rosette_file" | grep "cmd:" | sed 's/.*cmd: *//' | tr -d '"')
|
|
search_cmd=$(sed -n '/^ search:/,/^ [a-z]/p' "$rosette_file" | grep "cmd:" | sed 's/.*cmd: *//' | tr -d '"')
|
|
|
|
# Display formatted output
|
|
echo ""
|
|
_g "📦 Package Manager: $pkg_manager"
|
|
[ -n "$init_system" ] && echo " Init system: $init_system"
|
|
echo ""
|
|
[ -n "$install_cmd" ] && printf " ${GREEN}install${NC} %s\n" "$install_cmd"
|
|
[ -n "$remove_cmd" ] && printf " ${GREEN}remove${NC} %s\n" "$remove_cmd"
|
|
[ -n "$update_cmd" ] && printf " ${GREEN}update${NC} %s\n" "$update_cmd"
|
|
[ -n "$search_cmd" ] && printf " ${GREEN}search${NC} %s\n" "$search_cmd"
|
|
echo ""
|
|
}
|
|
export -f rosette_info
|
|
|