diff --git a/.update/patches b/.update/patches index 4d3d256d44..4c20bbac91 100755 --- a/.update/patches +++ b/.update/patches @@ -2493,6 +2493,53 @@ Patch_10_6() # Assure "mount" is not autoremoved on Forky, just install/mark as manual on all versions, as it is required even for systemd mount units, but degraded to recommendation on Forky G_AGI mount + # dietpi-banner migrations + if [[ -f '/boot/dietpi/.dietpi-banner' ]] + then + local i mapping userdata name command + + G_DIETPI-NOTIFY 2 'Migrating /boot/dietpi/.dietpi-banner to new named arrays and options' + + # Migrate legacy numeric-indexed aENABLED to named form, skipping disk usage (7 and 8) + mapping=( + device_model uptime cpu_temp hostname nis_domainname lan_ip wan_ip '' '' + weather custom_commands dietpi_commands motd vpn_status large_hostname + credits letsencrypt ram_usage load_average word_wrap kernel + ) + for i in {0..6} {9..20} + do + G_EXEC sed --follow-symlinks -i "s/^[[:blank:]]*aENABLED\[$i\]=/aENABLED[${mapping[i]}]=/" /boot/dietpi/.dietpi-banner + done + + # Migrate legacy disk usage options + G_EXEC sed --follow-symlinks -i '\|^[[:blank:]]*aENABLED\[7\]=1|c\aDISK_SPACE_MOUNTS+=(/)' /boot/dietpi/.dietpi-banner + userdata=$(findmnt -Ufvnro TARGET -T /mnt/dietpi_userdata) + [[ $userdata == '/' ]] || G_EXEC sed --follow-symlinks -i "\|^[[:blank:]]*aENABLED\[8\]=1|c\aDISK_SPACE_MOUNTS+=('${userdata//\'/\'\\\'\'}')" /boot/dietpi/.dietpi-banner + grep -q '^aDISK_SPACE_MOUNTS+=' /boot/dietpi/.dietpi-banner && G_CONFIG_INJECT 'aENABLED\[disk_usage\]=' 'aENABLED[disk_usage]=1' /boot/dietpi/.dietpi-banner + + # Migrate legacy custom command file to aCUSTOM_COMMANDS array if present + if [[ -f '/boot/dietpi/.dietpi-banner_custom' ]] + then + if grep -q '^aENABLED\[custom_commands\]=1' /boot/dietpi/.dietpi-banner + then + name=$(sed -n '/^[[:blank:]]*aDESCRIPTION\[10\]=/{s/^[^=]*=//p;q}' /boot/dietpi/.dietpi-banner) + read -r command < /boot/dietpi/.dietpi-banner_custom + GCI_PRESERVE=1 G_CONFIG_INJECT "aCUSTOM_COMMANDS\[${name:-migrated}\]=" "aCUSTOM_COMMANDS[${name:-migrated}]='${command//\'/\'\\\'\'}'" /boot/dietpi/.dietpi-banner + fi + G_EXEC rm /boot/dietpi/.dietpi-banner_custom + fi + + # Migrate legacy numeric-indexed aCOLOUR to named form + mapping=(accent strong weak alert) + for i in {0..3} + do + G_EXEC sed --follow-symlinks -i "s/^[[:blank:]]*aCOLOUR\[$i\]=/aCOLOUR[${mapping[i]}]=/" /boot/dietpi/.dietpi-banner + done + + # Remove left invalid entries + G_EXEC sed --follow-symlinks -Ei -e '/^[[:blank:]]*a(ENABLED|COLOUR|DESCRIPTION)\[[0-9]+\]=/d' /boot/dietpi/.dietpi-banner + fi + # Software updates, migrations and patches if [[ -f '/boot/dietpi/.installed' ]] then diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 34da9a7fd9..e9e8345743 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,13 +55,13 @@ Guidance: ### Core concepts - Globals: scripts source `dietpi/func/dietpi-globals` for shared helpers and - environment setup (`G_INIT`, `G_EXIT`, color vars, etc.). Read this file + environment setup (`G_INIT()`, `G_EXIT()`, color vars, etc.). Read this file first to understand helper semantics and cancel/error behavior. - UI: prefer `G_WHIP_*` dialog helpers for menus, input and confirmations to maintain a consistent user experience across scripts. -- Error-handling: use `G_EXEC` to wrap any command call, so DietPi's error +- Error-handling: use `G_EXEC()` to wrap any command call, so DietPi's error handler and consistent console output apply. Validate root permissions and - write access using `G_CHECK_ROOT_USER` / `G_CHECK_ROOTFS_RW` where required. + write access using `G_CHECK_ROOT_USER()` / `G_CHECK_ROOTFS_RW()` where required. - Persistence: Write arrays or variables into a preference file `/boot/dietpi/.`. Persist arrays as indexed assignments (e.g. `aARRAY[index]=1`). Convert ESC bytes to `\e` when saving text if needed. Load the preferences with `. "/boot/dietpi/."` @@ -76,65 +76,49 @@ DietPi provides many `G_` prefixed helpers in `dietpi/func/dietpi-globals`. Usag consistent and reduce reviewer friction. Below are the most useful ones for contributors and how to use them safely. -- `G_INIT` — initialize script runtime, sets up the working directory, exit +- `G_INIT()` — initialize script runtime, sets up the working directory, exit traps, consistent locale for parsing external command outputs, and handles concurrent execution checks. Call early after sourcing `dietpi-globals`. -- `G_EXEC` — robust command executor with built-in retries and an interactive +- `G_EXEC()` — robust command executor with built-in retries and an interactive error handler. Use instead of direct `rm`/`systemctl` in scripts so failures are presented to the user and logged consistently. Optional env vars: `$G_EXEC_DESC`, `$G_EXEC_RETRIES`, `$G_EXEC_OUTPUT`. -- `G_CONFIG_INJECT` — targeted config-file editing helper. Use to atomically - replace, uncomment, or add config lines using predictable patterns rather - than ad-hoc `sed` calls. - -- `G_WHIP_*` family — dialog and UI helpers: (`G_WHIP_MSG`, `G_WHIP_YESNO`, - `G_WHIP_MENU`, `G_WHIP_CHECKLIST`, `G_WHIP_INPUTBOX`, `G_WHIP_PASSWORD`, - `G_WHIP_VIEWFILE`). - Prefer these for user interaction to maintain consistent UX and behavior. - -- `G_CHECK_ROOT_USER`, `G_CHECK_ROOTFS_RW` — validate that the script runs +- `G_CHECK_ROOT_USER()`, `G_CHECK_ROOTFS_RW()` — validate that the script runs with necessary privileges and writable rootfs before performing writes. Using `G_CHECK_ROOT_USER "$@"`, if the script does not have root permissions, re-executes with `sudo`. "$@" passes all CLI arguments to the `sudo-ed` script. -- `G_GET_NET`, `G_GET_WAN_IP` — network helpers that return standardized +- `G_CONFIG_INJECT()` — targeted config-file editing helper. Use to atomically + replace, uncomment, or add config lines using predictable patterns rather + than ad-hoc `sed` calls. + +- `G_GET_NET()`, `G_GET_WAN_IP()` — network helpers that return standardized values; use `-q` to hide error messages. -- `G_DIETPI-NOTIFY` / `G_BUG_REPORT` — helpers to generate formatted bug +- `G_DIETPI-NOTIFY()` / `G_BUG_REPORT()` — helpers to generate formatted bug reports and diagnostics. Use when capturing logs for PRs / issues. -#### G_WHIP specifics - -These details are commonly needed when implementing menus and input boxes. - -- `G_WHIP_INPUTBOX`: - - Use `$G_WHIP_INPUTBOX_REGEX` to provide an optional validation regex and - `$G_WHIP_INPUTBOX_REGEX_TEXT` to describe allowed input (human-friendly). - By default, any non-empty input is allowed. - - `$G_WHIP_DEFAULT_ITEM` pre-fills the input field. The helper loops until - input matches the regex or the user cancels (`|| return`). - - On success the entered value is returned in `$G_WHIP_RETURNED_VALUE`. - -- `G_WHIP_YESNO`: - - Presents a Yes/No dialog. Exit status `0` indicates Yes/Ok; non-zero is - No/Cancel. - - Use for confirmations before destructive actions. Combine with - `G_EXEC` for safe command execution on confirmation. - -- `$G_WHIP_DEFAULT_ITEM`: - - Controls the pre-selected menu item or prefilled input box value. - - When using `G_WHIP_MENU`, set it to a label matching one of the menu - entries to pre-select that entry (exact match is used). - -- `$G_WHIP_SIZE_X_MAX`: - - Optional integer to limit dialog width (chars). Useful for dialogs with - very few content or checklists, where too wide dialog boxes may look weird. - By default, all `G_WHIP` dialogs are max 120 characters wide, capped by the - terminal width. - - Set it before calling a `G_WHIP_*` helper; the helper respects it when - calculating `$WHIP_SIZE_X`. +- `G_WHIP_*` family — dialog and UI helpers: (`G_WHIP_MSG()`, `G_WHIP_YESNO()`, + `G_WHIP_MENU()`, `G_WHIP_CHECKLIST()`, `G_WHIP_INPUTBOX()`, `G_WHIP_PASSWORD()`, + `G_WHIP_VIEWFILE()`). Prefer these for user interaction to maintain + consistent UX and behavior. Quick notes: + + - **Return value**: `$G_WHIP_RETURNED_VALUE` holds the helper result — a + single value for inputbox and menus, or an array of enabled indices for checklists. + - **Default item**: `$G_WHIP_DEFAULT_ITEM` sets the pre-selected value. For + `G_WHIP_MENU()` it must exactly match a menu label. + - **Checklist structure**: `$G_WHIP_CHECKLIST_ARRAY` entries are triples: + `'tag' 'Description' 'on/off'`. Use safe tags (letters, digits, underscore) + to avoid parsing issues. + - **Enumerated checklists**: set `G_WHIP_CHECKLIST_ENUM=1` to display numeric + indices in the UI to avoid long, complex, or non-safe keys. + - **Input validation**: `G_WHIP_INPUTBOX()` supports `$G_WHIP_INPUTBOX_REGEX` + and `$G_WHIP_INPUTBOX_REGEX_TEXT` to validate and describe allowed input. + The helper loops until input matches the regex or the user cancels (`|| return`). + - **Dialog sizing**: use `$G_WHIP_SIZE_X_MAX` to limit dialog width; helpers + respect terminal width and default to a max of 120 chars. ### Menu extension pattern (safe, minimal) @@ -159,7 +143,7 @@ Below are minimal, copy-paste-ready examples that follow DietPi conventions. G_INIT ``` -- `G_WHIP_MENU` (single choice): +- `G_WHIP_MENU()` a menu of items the user can scroll through (choose one): ``` G_WHIP_MENU_ARRAY=( 'Start' 'Start the service' 'Stop' 'Stop the service' ) G_WHIP_DEFAULT_ITEM='Start' @@ -170,60 +154,75 @@ Below are minimal, copy-paste-ready examples that follow DietPi conventions. esac ``` -- `$G_WHIP_CHECKLIST_ARRAY` (multi-select): +- `G_WHIP_CHECKLIST()` a list of items the user can enabled/disabled (multi-select): ``` G_WHIP_CHECKLIST_ARRAY=() G_WHIP_CHECKLIST_ARRAY+=( '5' 'Enable Foo' "${aENABLED[5]:=0}" ) G_WHIP_CHECKLIST_ARRAY+=( '6' 'Enable Bar' "${aENABLED[6]:=0}" ) + # Set G_WHIP_CHECKLIST_ENUM=1 to display numeric indices in the UI G_WHIP_CHECKLIST 'Choose features to enable:' || return for i in $G_WHIP_RETURNED_VALUE; do aENABLED[$i]=1; done Save > "$FP_SAVEFILE" ``` -- `G_WHIP_INPUTBOX` (validated input): +- `G_WHIP_INPUTBOX()` a text entry box (validated input): ``` G_WHIP_INPUTBOX_REGEX='^[0-9]+$' G_WHIP_INPUTBOX_REGEX_TEXT='a number' G_WHIP_DEFAULT_ITEM=10 G_WHIP_INPUTBOX 'Set retry count:' || return RETRIES=$G_WHIP_RETURNED_VALUE ``` -- `G_WHIP_YESNO` (confirmation): +- `G_WHIP_YESNO()` Yes/No prompt (confirmation): ``` if G_WHIP_YESNO 'Delete backup?'; then G_EXEC rm -rf "$TARGET" fi ``` -- `G_WHIP_VIEWFILE` (show a logfile): +- `G_WHIP_VIEWFILE()` displays a file that can be scrolled: ``` log=1 G_WHIP_VIEWFILE "$FP_LOG" || return ``` -- `Save()` persistence pattern (follow dietpi-banner conventions): +- `G_TRUNCATE_MID()` shorten long strings by squishing the middle characters: + ``` + G_TRUNCATE_MID "Long text to be shortened by removing the middle" 26 + # -> "Long text to... the middle" + + # And `G_TRUNCATE_MID "alphabetical" N` returns the below, as N decreases + # alp...al + # al...al + # alphab + # alpha + # alph + # alp + ``` + +- `Save()` example persistence pattern (beware of escape sequences): ``` Save(){ - # `echo` text to be evaluated when re-loading - # Call with `Save > "preference/filepath"` + # `echo` text to be eval-ed when re-loading + # Call with `Save > "preference/file/path"` echo "aDESCRIPTION[10]='${aDESCRIPTION[10]}'" for i in "${!aENABLED[@]}"; do echo "aENABLED[$i]=${aENABLED[$i]}"; done for i in {0..6}; do val="${aCOLOUR[$i]}"; esc=$(printf '%s' "$val" | sed $'s/\x1b/\\e/g'); esc=${esc//\'/\\\'}; echo "aCOLOUR[$i]='$esc'"; done } ``` -### Banner extension pattern (example) +### DietPi-Banner extension pattern -When adding banner items (e.g. `dietpi-banner`), follow this minimal pattern: +When adding banner items, follow this minimal pattern: - Describe: add the label to `aDESCRIPTION[index]` and a default to - `aENABLED[index]` during initialization. + `aENABLED[index]` during initialization if relevant (the standard default is disabled=0). + If the item needs to show in the main menu checklist, add `index` to MENU_ITEMS. -- Output: implement `Print_()` or add a guarded line in `Print_Banner_raw()`: +- Output: implement `Get_()` and add a guarded line in `Print_Banner_raw()`: ``` - (( ${aENABLED[index]} )) && echo -e "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[index]} $GREEN_SEPARATOR $(Print_)" + (( ${aENABLED[index]} )) && Print_Item_State "${aDESCRIPTION[index]}" "$(Get_Shortname 2>&1)" ``` -- Persist: ensure `Save()` writes `aENABLED[index]=...` and any custom - `aDESCRIPTION[...]` lines so the state survives restarts. +- Persist: ensure `Save()` writes `aENABLED[index]=...` so the state survives restarts. ### Nested / multi-page menu pattern diff --git a/dietpi/func/dietpi-banner b/dietpi/func/dietpi-banner index 2da3f34cfb..6da5432591 100755 --- a/dietpi/func/dietpi-banner +++ b/dietpi/func/dietpi-banner @@ -5,6 +5,7 @@ # #//////////////////////////////////// # Created by Daniel Knight / daniel.knight@dietpi.com / dietpi.com + # Updated July 2026 by Tim Olson / timjolson@users.noreply.github.com # #//////////////////////////////////// # @@ -39,63 +40,97 @@ # Globals #///////////////////////////////////////////////////////////////////////////////////// readonly FP_SAVEFILE='/boot/dietpi/.dietpi-banner' - readonly FP_CUSTOM='/boot/dietpi/.dietpi-banner_custom' readonly FP_BANNERWRAP_AWK='/boot/dietpi/func/dietpi-banner-wrap.awk' - aDESCRIPTION=( - - 'Device model' - 'Uptime' - 'CPU temp' - 'FQDN/hostname' - 'NIS domainname' - 'LAN IP' - 'WAN IP' - 'Disk usage (RootFS)' - 'Disk usage (userdata)' - 'Weather (wttr.in)' - 'Custom banner entry' - 'Display DietPi useful commands?' - 'MOTD' - 'VPN status' - 'Large hostname' - 'Print credits' - 'Let'\''s Encrypt cert status' - 'RAM usage' - 'Load average' - 'Word-wrap lines on small screens' - 'Kernel' + # Use associative arrays keyed by short names derived from the description + declare -Ar aDESCRIPTION=( + [device_model]='Device model' + [uptime]='Uptime' + [cpu_temp]='CPU temp' + [hostname]='FQDN/hostname' + [nis_domainname]='NIS domainname' + [lan_ip]='LAN IP' + [wan_ip]='WAN IP' + [weather]='Weather (wttr.in)' + [custom_commands]='Custom commands' + [dietpi_commands]='Display useful DietPi commands?' + [motd]='MOTD' + [vpn_status]='VPN status' + [large_hostname]='Large hostname' + [credits]='Print credits' + [letsencrypt]='Let'\''s Encrypt cert status' + [ram_usage]='RAM usage' + [load_average]='Load average' + [word_wrap]='Word-wrap lines on small screens' + [kernel]='Kernel' + [network_usage]='Network usage' + [disk_usage]='Disk usage' + [disk_patterns]='does not show in the menu. allows disk patterns to be enabled/disabled' + [systemd]='systemd status' + [fail2ban]='Fail2Ban status' + ) + # Set the array order for the menu, items must be listed here to show up + readonly MENU_ITEMS=( + large_hostname device_model uptime kernel cpu_temp ram_usage load_average hostname + nis_domainname lan_ip wan_ip vpn_status weather letsencrypt systemd fail2ban motd + network_usage disk_usage custom_commands dietpi_commands credits word_wrap ) - # Set defaults: Disable CPU temp by default in VMs - if (( $G_HW_MODEL == 20 )) - then - aENABLED=(1 0 0 0 0 1 0 1 0 0 0 1 1 0 0 1 0 0 0 0 0) - else - aENABLED=(1 0 1 0 0 1 0 0 0 0 0 1 1 0 0 1 0 0 0 0 0) - fi + # Initialize aENABLED from aDESCRIPTION keys + declare -A aENABLED + for k in "${!aDESCRIPTION[@]}" + do + aENABLED[$k]=0 + done - COLOUR_RESET='\e[0m' - aCOLOUR=( + # Set default options + aENABLED[device_model]=1 + aENABLED[lan_ip]=1 + aENABLED[disk_usage]=1 + aENABLED[dietpi_commands]=1 + aENABLED[motd]=1 + aENABLED[credits]=1 + # Enable CPU temp by default except in VMs + (( $G_HW_MODEL != 20 )) && aENABLED[cpu_temp]=1 - '\e[38;5;154m' # DietPi green | Lines, bullets and separators - '\e[1m' # Bold white | Main descriptions - '\e[90m' # Grey | Credits - '\e[91m' # Red | Update notifications + # Default colours + readonly COLOUR_RESET='\e[0m' # Reset formatting to default (do not override this) + # Colour and format references: + # - https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit + # - https://invisible-island.net/xterm/ctlseqs/ctlseqs.html under section "Character Attributes (SGR)" + # - https://www.ditig.com/256-colors-cheat-sheet + # - https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_(Select_Graphic_Rendition)_parameters + declare -A aCOLOUR=( + [accent]='\e[38;5;154m' # DietPi green | Lines, bullets and separators + [strong]='\e[1m' # Bold white | Main descriptions + [weak]='\e[90m' # Grey | Subdued text + [alert]='\e[91m' # Red | Update notifications / Bad state + [good]='\e[1;32m' # Green | Good state + [highlight]='\e[1;33m' # Yellow | Warning state + [alt]='\e[1;36m' # Blue | Dynamic match ) # Folding type (colon, dash, fixed), and fixed indent BW_INDENT_TYPE='colon' BW_INDENT_FIXED=3 - # Load settings here, to have chosen ${aCOLOUR[0]} applied to below strings + # Custom commands + declare -A aCUSTOM_COMMANDS=() + + # Default disk mounts (explicit paths) and patterns (globs/regex) + aDISK_SPACE_MOUNTS=() + aDISK_SPACE_PATTERNS=() + + # Load settings here, to have chosen custom CLI colours applied # shellcheck disable=SC1090 [[ -f $FP_SAVEFILE ]] && . "$FP_SAVEFILE" - GREEN_LINE=" ${aCOLOUR[0]}─────────────────────────────────────────────────────$COLOUR_RESET" - GREEN_BULLET=" ${aCOLOUR[0]}-$COLOUR_RESET" - GREEN_SEPARATOR="${aCOLOUR[0]}:$COLOUR_RESET" + # Derived convenience strings + readonly CLI_LINE=" ${aCOLOUR[accent]}─────────────────────────────────────────────────────$COLOUR_RESET" + readonly CLI_BULLET=" ${aCOLOUR[accent]}-$COLOUR_RESET" + readonly CLI_SEPARATOR="${aCOLOUR[accent]}:$COLOUR_RESET" + # DietPi version string DIETPI_VERSION="$G_DIETPI_VERSION_CORE.$G_DIETPI_VERSION_SUB.$G_DIETPI_VERSION_RC" if [[ $G_GITOWNER != 'MichaIng' ]] then @@ -154,19 +189,54 @@ Save() { - # Custom entry description - echo "aDESCRIPTION[10]='${aDESCRIPTION[10]}'" - + # Persist associative aENABLED as simple assignments (one per line) for i in "${!aDESCRIPTION[@]}" do echo "aENABLED[$i]=${aENABLED[$i]}" done + # Persist only the primary colour slots. Convert any actual + # ESC bytes to the two-character sequence \e so the saved file is + # portable when sourced in different shells. for i in "${!aCOLOUR[@]}" do - echo "aCOLOUR[$i]='${aCOLOUR[$i]}'" + val=${aCOLOUR[$i]} + # Replace actual ESC bytes (0x1b) with literal \e + esc=$(printf '%s' "$val" | sed $'s/\x1b/\\e/g') + # Escape single quotes for safe embedding + esc=${esc//\'/\'\\\'\'} + echo "aCOLOUR[$i]='$esc'" + done + + # Persist aCUSTOM_COMMANDS (associative indices) + for key in "${!aCUSTOM_COMMANDS[@]}" + do + # Skip empty commands + [[ ${aCUSTOM_COMMANDS[$key]} ]] || continue + # Escape single quotes in the key and value for safe embedding + key=${key//\'/\'\\\'\'} + cmd=${aCUSTOM_COMMANDS[$key]} + cmd=${cmd//\'/\'\\\'\'} + echo "aCUSTOM_COMMANDS['$key']='$cmd'" done + # Persist aDISK_SPACE_MOUNTS (explicit mount paths, indices 0..N) + for i in "${!aDISK_SPACE_MOUNTS[@]}" + do + mount=${aDISK_SPACE_MOUNTS[$i]} + mount=${mount//\'/\'\\\'\'} + echo "aDISK_SPACE_MOUNTS[$i]='$mount'" + done + + # Persist aDISK_SPACE_PATTERNS (glob/regex patterns, indices 0..N) + for i in "${!aDISK_SPACE_PATTERNS[@]}" + do + patt=${aDISK_SPACE_PATTERNS[$i]} + patt=${patt//\'/\'\\\'\'} + echo "aDISK_SPACE_PATTERNS[$i]='$patt'" + done + + # Persist BW indent settings echo "BW_INDENT_TYPE='$BW_INDENT_TYPE'" echo "BW_INDENT_FIXED=$BW_INDENT_FIXED" } @@ -176,57 +246,678 @@ # DietPi update available? if Check_DietPi_Update then - local text_update_available_date="${aCOLOUR[3]}Update available" + local text_update_available_date="${aCOLOUR[highlight]}Update available" # New DietPi live patches available? elif Check_DietPi_Live_Patches then - local text_update_available_date="${aCOLOUR[3]}New live patches available" + local text_update_available_date="${aCOLOUR[highlight]}New live patches available" # APT update available? elif Check_APT_Updates then - local text_update_available_date="${aCOLOUR[3]}$PACKAGE_COUNT APT updates available" + local text_update_available_date="${aCOLOUR[highlight]}$PACKAGE_COUNT APT updates available" # Reboot required to finalise kernel upgrade? elif Check_Reboot then - local text_update_available_date="${aCOLOUR[3]}Reboot required" + local text_update_available_date="${aCOLOUR[alert]}Reboot required" else local locale=$(sed -n '/^[[:blank:]]*AUTO_SETUP_LOCALE=/{s/^[^=]*=//p;q}' /boot/dietpi.txt) local text_update_available_date=$(LC_ALL=${locale:-C.UTF-8} date '+%R - %a %x') fi - echo -e "$GREEN_LINE - ${aCOLOUR[1]}DietPi v$DIETPI_VERSION$COLOUR_RESET $GREEN_SEPARATOR $text_update_available_date$COLOUR_RESET -$GREEN_LINE" + echo -e "$CLI_LINE + ${aCOLOUR[strong]}DietPi v$DIETPI_VERSION$COLOUR_RESET $CLI_SEPARATOR $text_update_available_date$COLOUR_RESET +$CLI_LINE" } - Print_Local_Ip() + Print_Subheader() + { + # Output green line separator with subheader title "$1" + echo -e "$CLI_LINE + ${aCOLOUR[strong]}$1 $CLI_SEPARATOR" + } + + Get_Local_Ip() { - (( ${aENABLED[5]} )) || return 0 local iface=$(G_GET_NET -q iface) local ip=$(G_GET_NET -q ip) - echo -e "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[5]} $GREEN_SEPARATOR ${ip:-Use dietpi-config to setup a connection} (${iface:-NONE})" + echo "${ip:-Use dietpi-config to setup a connection} (${iface:-NONE})" + } + + Get_Cert_Status() + { + Get_Cert_Status_Raw() + { + # helper function so that we can run it with sudo in non-root mode without needing to export all the above functions and variables. + # Keep it dash-compatible + local i + for i in /etc/letsencrypt/live/*/cert.pem + do + # shellcheck disable=SC2292 + [ -f "$i" ] || continue + openssl x509 -enddate -noout -in "$i" | mawk '/notAfter=/{print "Valid until "$4"-"substr($1,10)"-"$2" "$3}' + return 0 + done + echo 'No certificate found' + } + + local stat code + if [[ $EUID == 0 ]] + then + # Running as root + stat=$(Get_Cert_Status_Raw 2>&1) + code=$? + else + # Running as non-root: Fail silently without NOPASSWD to avoid password prompt + stat=$(sudo -n dash -c "$(declare -f Get_Cert_Status_Raw); Get_Cert_Status_Raw 2>&1" 2> /dev/null) + code=$? + fi + + case $code in + 0) + # Get_Cert_Status_Raw executed, process output + case $stat in + 'Valid until'*) :;; # valid cert, print line with default color + 'No certificate found') stat="${aCOLOUR[highlight]}$stat";; # highlight if there is no cert + *) stat="${aCOLOUR[alert]}$stat";; # alert if cert expired or there was another error + esac + ;; + *) + # sudo failed, print meaningful error message + stat="${aCOLOUR[highlight]}NOPASSWD sudo required to obtain cert status" + ;; + esac + + echo "$stat" + } + + Get_Systemd_Status() + { + # Print systemd overall status based on "systemctl --failed" + FAILED_CNT=$(systemctl --failed --no-legend --no-pager | wc -l 2> /dev/null) + if [[ ! $FAILED_CNT || $FAILED_CNT == 0 ]] + then + echo 'No services failed' + else + echo "${aCOLOUR[alert]}$FAILED_CNT service(s) failed" + fi + } + + Get_Fail2Ban_Status() + { + Get_Fail2Ban_Status_Raw() + { + if command -v fail2ban-client > /dev/null + then + # IP address detection (very sloppy for IPv6) - avoid {m,n} to support mawk on Debian Bookworm + local IP_re='^([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+|[0-9a-f:]*:[0-9a-f:]*)$' + if [[ $EUID == 0 ]] + then + fail2ban-client banned 2> /dev/null | mawk -F\' -v ip="$IP_re" '{for(i=2;i /dev/null | mawk -F\' -v ip="$IP_re" '{for(i=2;i 20 )) + then + # high number of bans + state="${aCOLOUR[alert]}$count_banned_ips IP(s) banned" + else + # low number of bans + state="$count_banned_ips IP(s) banned" + fi + ;; + 1) state="${aCOLOUR[alert]}fail2ban-client not available";; + 2) state="${aCOLOUR[highlight]}NOPASSWD sudo required to obtain Fail2Ban status";; + *) state="${aCOLOUR[alert]}Unknown state, code: $code count: $count_banned_ips";; + esac + + echo "$state" + } + + # Convert bytes to GiB (IEC) with one decimal — returns numeric string like "62.8" + Bytes_To_GiB() + { + local bytes=$1 + if [[ $bytes =~ ^[0-9]+$ ]] + then + # Use mawk for consistent floating-point formatting + mawk -v v="$bytes" 'BEGIN{printf "%.1f", v/(1024*1024*1024)}' + else + echo '???' + fi + } + + Print_Network_Usage() + { + # Print usage stats for namespace $1 (default if empty) + Print_Data_Usage() + { + local netns=$1 + local display_ns=$(G_TRUNCATE_MID "${netns:-default}" 24) + + # Use an array prefix to run commands either in a netns, + # with sudo if needed, or the default namespace + local ns_cmd_prefix=() + if [[ $netns ]] + then + # add sudo if not root + (( $EUID )) && ns_cmd_prefix=(sudo -n) + # add netns prefix + ns_cmd_prefix+=(ip netns exec "$netns") + fi + + # Obtain public IP and GeoIP info + local ip=$("${ns_cmd_prefix[@]}" curl -sSfLm 1 'https://dietpi.com/geoip' 2>&1) + + # Match all IPv4 addresses, but IPv6 addresses only from globally routable 2000::/3 block + if [[ $ip =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+|^[23][0-9a-f]{0,3}:[0-9a-f:]+ ]] + then + # Valid IP detected, keep as-is and print with namespace + echo -e "${aCOLOUR[alt]} $ip [$display_ns]" + + elif [[ ! $ip ]] + then + # If the IP is empty, it likely means the namespace is disconnected or has no Internet access. Print status message. + echo -e "${aCOLOUR[alert]} Disconnected $CLI_SEPARATOR ${aCOLOUR[strong]}$display_ns" + + else + # Print curl or sudo errors as is + echo -e "${aCOLOUR[highlight]} $ip [$display_ns]" + fi + + # Query interface list once inside the (optional) namespace and match names - skip loopback + local ifaces iface + mapfile -t ifaces < <("${ns_cmd_prefix[@]}" ip -br link 2> /dev/null | mawk '$1!="lo" {print $1}') + for iface in "${ifaces[@]}" + do + # strip kernel-style peer suffix (e.g. eth0@if8) and use base name + iface=${iface%%@*} + + case $iface in + # wired / predictable names + eth*|en*|lan*) :;; + # wireless + wlan*|wl*) :;; + # vpn/tunnel/virtual + tun*|wg*|vpn*) :;; + *) continue;; + esac + + local RX='N/A' TX='N/A' rx tx ip_addr=' - No IP - ' ip_addr_cidr + local rx_path="/sys/class/net/$iface/statistics/rx_bytes" + local tx_path="/sys/class/net/$iface/statistics/tx_bytes" + + if [[ $netns ]]; + then + # If a netns is specified, use the prefix to read the RX/TX statistics files. + rx=$("${ns_cmd_prefix[@]}" cat "$rx_path" 2> /dev/null) + tx=$("${ns_cmd_prefix[@]}" cat "$tx_path" 2> /dev/null) + else + # Else, read the RX/TX statistics files directly. + read -r rx < "$rx_path" + read -r tx < "$tx_path" + fi + + # Process the RX/TX values to convert to GiB if they are numeric, otherwise leave as 'N/A' + [[ $rx =~ ^[0-9]+$ ]] && RX="$(Bytes_To_GiB "$rx") GiB" + [[ $tx =~ ^[0-9]+$ ]] && TX="$(Bytes_To_GiB "$tx") GiB" + + # Get first IP address (3rd field) for this interface in this namespace. If none, leave "- No IP -". + ip_addr_cidr=$("${ns_cmd_prefix[@]}" ip -br addr show dev "$iface" scope global 2> /dev/null | mawk '$3!="" {print $3}') + [[ $ip_addr_cidr ]] && ip_addr=${ip_addr_cidr%%/*} + + [[ $RX == 'N/A' && $TX == 'N/A' ]] && continue + printf '%b %-23s %b TX=%11s RX=%11s\n' "$CLI_BULLET" "$ip_addr ($iface)" "$CLI_SEPARATOR" "$TX" "$RX" + done + } + + local namespaces=() ns + mapfile -t namespaces < <(ip netns ls | mawk 'NF>1 {print $1}') + # Prepend an empty entry so the default (root) namespace can be handled in the loop + for ns in '' "${namespaces[@]}" + do + Print_Data_Usage "$ns" + done + } + + Menu_Disk_Mounts() + { + pattern_hint='The matching mount "target"s returned by the "findmnt" command will be reported. +To use regex, start the pattern with "re:", otherwise shell globbing is used. +For example: "/mnt/*" or "re:^/mnt/.*/*$"' + + # Build mount list from findmnt (preserve findmnt order) + local -a available_mounts + mapfile -t available_mounts < <(findmnt -nro TARGET --real 2>/dev/null) + # Build quick lookup maps used to determine which mounts are present + # and which are configured by the user: + # - `avail_map`: mounts currently detected on the system + # - `cfg_map` : mounts the user has previously configured to track + declare -A avail_map=() cfg_map=() + for m in "${available_mounts[@]}"; do avail_map["$m"]=1; done + for p in "${aDISK_SPACE_MOUNTS[@]}"; do cfg_map["$p"]=1; done + + # Prepare the checklist array and a mapping from generated tags back to + # mount paths. Entries are added in this order: + # 1) configured-but-missing mounts (so the user notices them), + # 2) a 'Custom matching' toggle, and + # 3) currently available mounts. + G_WHIP_CHECKLIST_ARRAY=() + declare -A tag2mount=() + local idx=0 tag desc default + + # Add configured-but-missing mounts at the top. + # Tags are created to be safe for G_WHIP_CHECKLIST (no spaces, no special characters). + # The tag is used to map back to the mount path. + for p in "${aDISK_SPACE_MOUNTS[@]}" + do + [[ $p && ! ${avail_map[$p]} ]] || continue + tag="m$idx" + desc="[not found] $p" + default='on' + G_WHIP_CHECKLIST_ARRAY+=("$tag" "$desc" "$default") + tag2mount["$tag"]=$p + ((idx++)) + done + + # Add Custom matching option to allow adding user patterns. + local custom_tag='CUSTOM_MATCHING' + if (( ${aENABLED[disk_patterns]} )); then default='on'; else default='off'; fi + G_WHIP_CHECKLIST_ARRAY+=("$custom_tag" 'Custom matching (globs/regex patterns)' "$default") + + # Add available mounts from `findmnt` to the list. + for m in "${available_mounts[@]}" + do + tag="m$idx" + desc=$m + [[ ${cfg_map[$m]} ]] && default='on' || default='off' + G_WHIP_CHECKLIST_ARRAY+=("$tag" "$desc" "$default") + tag2mount["$tag"]=$m + ((idx++)) + done + + # Display checklist (enumerated) + G_WHIP_CHECKLIST_ENUM=1 + G_WHIP_CHECKLIST 'Select mounts to track. Use "Custom matching" to manage globs/regex patterns.' + + local sel + aENABLED[disk_patterns]=0 # Manage_Disk_Patterns sets to 1 if applicable + aDISK_SPACE_MOUNTS=() # Reset the mount list, it will be re-filled based on user selection below + + # Process selections: G_WHIP_RETURNED_VALUE contains selected tags. + # Rebuild mounts array and call Manage_Disk_Patterns if the user selected the custom matching option. + for sel in $G_WHIP_RETURNED_VALUE + do + [[ $sel == "$custom_tag" ]] && { Menu_Disk_Patterns; continue; } + [[ ${tag2mount[$sel]} ]] && aDISK_SPACE_MOUNTS+=("${tag2mount[$sel]}") + done } - Print_Cert_Status() + # Manage disk space pattern list (globs / regex) + Menu_Disk_Patterns() { - # Let's Encrypt cert status - MUST be run as root - local i - for i in /etc/letsencrypt/live/*/cert.pem + local pattern_hint sel idx + + # Multi-line help text for the patterns submenu + pattern_hint='To use regex, start the pattern with "re:", otherwise shell globbing is used. +For example: "/mnt/*" or "re:^/mnt/.*/*$"' + + while : do - # shellcheck disable=SC2292 - [ -f "$i" ] || continue - openssl x509 -enddate -noout -in "$i" | mawk '/notAfter=/{print "Valid until "$4"-"substr($1,10)"-"$2" "$3}' + # Build menu: Add first, then each active pattern, then Done + G_WHIP_MENU_ARRAY=() + G_WHIP_MENU_ARRAY+=('Add' 'Add a new pattern') + for idx in "${!aDISK_SPACE_PATTERNS[@]}" + do + G_WHIP_MENU_ARRAY+=("$idx" "${aDISK_SPACE_PATTERNS[$idx]}") + done + G_WHIP_MENU_ARRAY+=('Done' 'Finish pattern configuration') + G_WHIP_DEFAULT_ITEM='Done' + G_WHIP_MENU "Manage disk space match patterns. The matching mounts returned by 'findmnt' will be reported.\n$pattern_hint" || break + + sel=$G_WHIP_RETURNED_VALUE + case $sel in + 'Add') + G_WHIP_DEFAULT_ITEM= + G_WHIP_INPUTBOX_REGEX='.*' # Allow empty input to clear a pattern + G_WHIP_INPUTBOX_REGEX_TEXT='may be empty (clear to remove entry)' + G_WHIP_INPUTBOX "$pattern_hint\n\nEdit pattern (empty removes it):" || continue + aDISK_SPACE_PATTERNS+=("$G_WHIP_RETURNED_VALUE") + ;; + 'Done') break;; + *) + # Selected an existing pattern index -> edit in-place + if [[ $sel =~ ^[0-9]+$ && ${aDISK_SPACE_PATTERNS[$sel]} ]] + then + G_WHIP_DEFAULT_ITEM=${aDISK_SPACE_PATTERNS[$sel]} + G_WHIP_INPUTBOX_REGEX='.*' # Allow empty input to clear a pattern + G_WHIP_INPUTBOX_REGEX_TEXT='may be empty (clear to remove entry)' + G_WHIP_INPUTBOX "$pattern_hint\n\nEdit pattern (empty removes it):" + aDISK_SPACE_PATTERNS[$sel]=$G_WHIP_RETURNED_VALUE + else + # Unknown selection, ignore + continue + fi + ;; + esac + + for idx in "${!aDISK_SPACE_PATTERNS[@]}" + do + # Remove any empty entries (cleared by user) + [[ ${aDISK_SPACE_PATTERNS[$idx]} ]] || unset -v 'aDISK_SPACE_PATTERNS[$idx]' + done + done + + # Re-index the array to remove gaps caused by unset + aDISK_SPACE_PATTERNS=("${aDISK_SPACE_PATTERNS[@]}") + + # If any patterns are configured, enable the disk_patterns option + (( ${#aDISK_SPACE_PATTERNS[@]} > 0 )) && aENABLED[disk_patterns]=1 + } + + Print_Disk_Usage() + { + local results=() + local -A seen=() + + if (( ( ${#aDISK_SPACE_PATTERNS[@]} == 0 ) || ( ${aENABLED[disk_patterns]} == 0 ) )) && (( ${#aDISK_SPACE_MOUNTS[@]} == 0 )) + then + echo -e "${aCOLOUR[highlight]}No disk patterns provided, nothing to display." + return 0 + fi + + local -a mount_order=() mount_lines + declare -A mount_used mount_size mount_perc mount_name + local line mnt_target used size perc name + + # Pull all mounts once (-D -n -r) in bytes (-b). Rebuild TARGET safely + # (handles spaces) and compute USE% with one decimal using mawk from USED/SIZE. + # Use ASCII Unit Separator (0x1F) as a safe field delimiter to avoid collisions with mount names + mapfile -t mount_lines < <(findmnt -b -Dnro TARGET,USED,SIZE --real | mawk -v SEP="$(printf '\037')" '{used=$(NF-1); size=$NF; target=$1; for(i=2;i<=NF-2;i++) target=target" "$i; perc=(size>0? used/size*100 : 0); printf "%s%s%s%s%s%s%s\n", target, SEP, used, SEP, size, SEP, sprintf("%.1f%%", perc)}') + + # Keep mount_order[] to preserve findmnt's (consistent) order and aDISK_SPACE_PATTERNS' order. + for line in "${mount_lines[@]}" + do + # Each line fields are separated by ASCII Unit Separator (0x1F): TARGETUSEDSIZEUSE% + IFS=$'\x1f' read -r mnt_target used size perc <<< "$line" + mount_used["$mnt_target"]=$used + mount_size["$mnt_target"]=$size + mount_perc["$mnt_target"]=$perc + mount_name["$mnt_target"]=$(basename "$mnt_target") + mount_order+=("$mnt_target") + done + + # Iterate mounts in the original findmnt order and match against patterns. + for mnt_target in "${mount_order[@]}" + do + local matched=0 + + # Test against explicit exact-match patterns (highest precedence) + for p in "${aDISK_SPACE_MOUNTS[@]}" + do + [[ $mnt_target == "$p" ]] && { matched=1; break; } + done + + # If not matched yet, test globs and regex patterns from match_patterns if enabled + if (( $matched == 0 && ${aENABLED[disk_patterns]} == 1 )) + then + for pattern in "${aDISK_SPACE_PATTERNS[@]}" + do + if [[ $pattern == 're:'* ]] + then + re=${pattern#re:} + if [[ $mnt_target =~ $re ]] + then + matched=1; break + fi + else + # shellcheck disable=SC2254 + case $mnt_target in + $pattern) matched=1; break;; + *) :;; + esac + fi + done + fi + + if (( $matched )) + then + used=${mount_used[$mnt_target]} + size=${mount_size[$mnt_target]} + perc=${mount_perc[$mnt_target]} + name=${mount_name[$mnt_target]} + + # Convert raw bytes to GiB (IEC) numeric strings for display + used_fmt=$(Bytes_To_GiB "$used") + size_fmt=$(Bytes_To_GiB "$size") + + # Mark seen and collect formatted result (fields separated by ASCII Unit Separator) + seen[$mnt_target]=1 + results+=("$mnt_target"$'\x1f'"$used_fmt"$'\x1f'"$size_fmt"$'\x1f'"$perc"$'\x1f'"$name") + fi + done + + if (( ${#results[@]} == 0 )) + then + echo -e "${aCOLOUR[highlight]}No mounts were matched, check your configuration." return 0 + fi + + # For explicit mounts configured but not present in findmnt, add placeholder entries + # at the bottom of the list. + for p in "${aDISK_SPACE_MOUNTS[@]}" + do + [[ $p && ! ${seen[$p]} ]] || continue + used_fmt='???' + size_fmt='???' + perc='???' + name=$(basename "$p") + results+=("$p"$'\x1f'"$used_fmt"$'\x1f'"$size_fmt"$'\x1f'"$perc"$'\x1f'"$name") + done + + # Disambiguate duplicate basenames: if multiple mounts share the same + # basename (e.g. /tmp and /mnt/tmp), show the full path for those entries + # so the user can tell them apart. Compute display widths accordingly. + declare -A name_count=() + for entry in "${results[@]}" + do + IFS=$'\x1f' read -r mnt_target used size perc name <<< "$entry" + ((name_count["$name"]++)) + done + + local length=0 + for i in "${!results[@]}" + do + entry=${results[$i]} + IFS=$'\x1f' read -r mnt_target used size perc name <<< "$entry" + if (( name_count["$name"] > 1 )) + then + dname=$(G_TRUNCATE_MID "$mnt_target" 30) + else + dname=$(G_TRUNCATE_MID "$name" 30) + fi + (( ${#dname} > $length )) && length=${#dname} + results[$i]="$mnt_target"$'\x1f'"$used"$'\x1f'"$size"$'\x1f'"$perc"$'\x1f'"$dname" + done + + # Print results + for entry in "${results[@]}" + do + IFS=$'\x1f' read -r mnt_target used size perc name <<< "$entry" + # Print aligned columns: {name} : {percent used} : {used} of {size} + printf "%b %b%-${length}s %b%b %6s %b %7s GiB of %7s GiB\n" "$CLI_BULLET" "${aCOLOUR[alt]}" "$name" "$CLI_SEPARATOR" "${aCOLOUR[strong]}" "$perc" "$CLI_SEPARATOR" "$used" "$size" + done + } + + Get_VPN_Status() + { + local state=$(/boot/dietpi/dietpi-vpn status 2>&1) + case $state in + Connected*) state="$state${COLOUR_RESET}";; + Disconnected*) state="${aCOLOUR[alert]}$state${COLOUR_RESET}";; + *) state="${aCOLOUR[highlight]}$state${COLOUR_RESET}";; + esac + echo "$state" + } + + Menu_Custom_Commands() + { + local hint sel key cmd + + # Help text + # shellcheck disable=SC2016 + hint='Example:\necho -e "${aCOLOUR[good]}Hello World! :)"' + name_hint='Enter a name for the command. It must be unique, safe, and not empty.' + + while : + do + # Build menu: `Add` first, then each active command, then `Done` + G_WHIP_MENU_ARRAY=() + G_WHIP_MENU_ARRAY+=('Add' 'Add a new command') + for key in "${!aCUSTOM_COMMANDS[@]}" + do + G_WHIP_MENU_ARRAY+=("$key" "${aCUSTOM_COMMANDS[$key]}") + done + G_WHIP_MENU_ARRAY+=('Done' 'Finish commands configuration') + G_WHIP_DEFAULT_ITEM='Done' + G_WHIP_MENU "Manage custom commands. Each command gets a name and a corresponding shell command.\n$hint" || break + + sel=$G_WHIP_RETURNED_VALUE + case $sel in + 'Add') + # Add a new command + G_WHIP_DEFAULT_ITEM='' + G_WHIP_INPUTBOX_REGEX='.*' # Allow empty input to clear a command + G_WHIP_INPUTBOX_REGEX_TEXT='may be empty (clear to remove entry)' + + G_WHIP_INPUTBOX "$hint\n\nEdit command (empty removes it):" || continue + cmd=$G_WHIP_RETURNED_VALUE + + if [[ ! $cmd ]] + then + # Empty command, do not add to array + continue + else + G_WHIP_DEFAULT_ITEM='' + G_WHIP_INPUTBOX_REGEX='.+' # Non-empty + G_WHIP_INPUTBOX_REGEX_TEXT='must be unique, safe, and must not be empty' + + while : + do + # New command entered, prompt for a name + G_WHIP_INPUTBOX "$name_hint" || continue + key=$G_WHIP_RETURNED_VALUE + if [[ ${aCUSTOM_COMMANDS[$key]} ]] + then + # Key already exists, prompt again + continue + else + # Key is new and valid, accept it + break + fi + done + aCUSTOM_COMMANDS+=([$key]=$cmd) + fi + ;; # End adding of command + 'Done') break;; + *) + # Edit a command + if [[ $sel ]] + then + G_WHIP_DEFAULT_ITEM=${aCUSTOM_COMMANDS[$sel]} + G_WHIP_INPUTBOX_REGEX='.*' # Allow empty input to clear a command + G_WHIP_INPUTBOX_REGEX_TEXT='may be empty (clear to remove entry)' + G_WHIP_INPUTBOX "$hint\n\nEdit command (empty removes it):" + cmd=$G_WHIP_RETURNED_VALUE + + if [[ ! $cmd ]] + then + # Empty command, remove from array + unset -v "aCUSTOM_COMMANDS[$sel]" + continue + else + # New command entered, prompt for a name + G_WHIP_DEFAULT_ITEM=$sel + G_WHIP_INPUTBOX_REGEX='.+' # Non-empty + G_WHIP_INPUTBOX_REGEX_TEXT='must be unique, safe, and must not be empty' + + while :; + do + G_WHIP_INPUTBOX "$name_hint" || continue + key=$G_WHIP_RETURNED_VALUE + if [[ $key == "$sel" ]] + then + # Key unchanged, just update the command + aCUSTOM_COMMANDS[$key]=$cmd + break + + elif [[ ${aCUSTOM_COMMANDS[$key]} && $key != "$sel" ]] + then + # Key changed to an existing key, prompt again + continue + else + # Key is new and valid, accept it + unset -v "aCUSTOM_COMMANDS[$sel]" + + # Add the new key and command + aCUSTOM_COMMANDS+=([$key]=$cmd) + break + fi + done + fi + else + # Unknown selection, ignore + continue + fi + ;; # End editing of command + esac # End case "$sel" + done # End command menu loop + + # If any commands are configured, enable the custom_commands option + (( ${#aCUSTOM_COMMANDS[@]} )) && aENABLED[custom_commands]=1 + } + + Print_Custom_Commands() + { + local key output status + for key in "${!aCUSTOM_COMMANDS[@]}" + do + [[ ${aCUSTOM_COMMANDS[$key]} ]] || continue + # Capture output and exit code + output=$(eval "${aCUSTOM_COMMANDS[$key]}" 2>&1) + status=$? + if (( $status )) + then + Print_Item_State "$key" "${aCOLOUR[alert]}Command failed (code $status): $output${COLOUR_RESET}" + else + Print_Item_State "$key" "$output" + fi done - echo 'No certificate found' } Print_Credits() { - echo -e " ${aCOLOUR[2]}DietPi Team : https://github.com/MichaIng/DietPi#the-dietpi-project-team" + echo -e " ${aCOLOUR[weak]}DietPi Team : https://github.com/MichaIng/DietPi#the-dietpi-project-team" [[ -f '/boot/dietpi/.prep_info' ]] && mawk 'NR==1 {sub(/^0$/,"DietPi Core Team");a=$0} NR==2 {print " Image by : "a" (pre-image: "$0")"}' /boot/dietpi/.prep_info @@ -241,32 +932,40 @@ $GREEN_LINE" # DietPi update available? if [[ $AVAILABLE_UPDATE ]] then - echo -e " ${aCOLOUR[1]}dietpi-update$COLOUR_RESET $GREEN_SEPARATOR ${aCOLOUR[3]}Run now to update DietPi from v$DIETPI_VERSION to v$AVAILABLE_UPDATE$COLOUR_RESET\n" + echo -e " ${aCOLOUR[strong]}dietpi-update$COLOUR_RESET $CLI_SEPARATOR ${aCOLOUR[highlight]}Run now to update DietPi from v$DIETPI_VERSION to v$AVAILABLE_UPDATE$COLOUR_RESET\n" # New DietPi live patches available? elif (( $LIVE_PATCHES )) then - echo -e " ${aCOLOUR[1]}dietpi-update$COLOUR_RESET $GREEN_SEPARATOR ${aCOLOUR[3]}Run now to check out new available DietPi live patches$COLOUR_RESET\n" + echo -e " ${aCOLOUR[strong]}dietpi-update$COLOUR_RESET $CLI_SEPARATOR ${aCOLOUR[highlight]}Run now to check out new available DietPi live patches$COLOUR_RESET\n" # APT updates available? elif (( $PACKAGE_COUNT )) then - echo -e " ${aCOLOUR[1]}apt upgrade$COLOUR_RESET $GREEN_SEPARATOR ${aCOLOUR[3]}Run now to apply $PACKAGE_COUNT available APT package upgrades$COLOUR_RESET\n" + echo -e " ${aCOLOUR[strong]}apt upgrade$COLOUR_RESET $CLI_SEPARATOR ${aCOLOUR[highlight]}Run now to apply $PACKAGE_COUNT available APT package upgrades$COLOUR_RESET\n" # Reboot required to finalise kernel upgrade? elif (( $REBOOT_REQUIRED )) then - echo -e " ${aCOLOUR[1]}reboot$COLOUR_RESET $GREEN_SEPARATOR ${aCOLOUR[3]}A reboot is required to finalise a recent kernel upgrade$COLOUR_RESET\n" + echo -e " ${aCOLOUR[strong]}reboot$COLOUR_RESET $CLI_SEPARATOR ${aCOLOUR[alert]}A reboot is required to finalise a recent kernel upgrade$COLOUR_RESET\n" fi } Print_Useful_Commands() { - echo -e " ${aCOLOUR[1]}dietpi-launcher$COLOUR_RESET $GREEN_SEPARATOR All the DietPi programs in one place - ${aCOLOUR[1]}dietpi-config$COLOUR_RESET $GREEN_SEPARATOR Feature rich configuration tool for your device - ${aCOLOUR[1]}dietpi-software$COLOUR_RESET $GREEN_SEPARATOR Select optimised software for installation - ${aCOLOUR[1]}htop$COLOUR_RESET $GREEN_SEPARATOR Resource monitor - ${aCOLOUR[1]}cpu$COLOUR_RESET $GREEN_SEPARATOR Shows CPU information and stats\n" + echo -e " ${aCOLOUR[strong]}dietpi-launcher$COLOUR_RESET $CLI_SEPARATOR All the DietPi programs in one place + ${aCOLOUR[strong]}dietpi-config$COLOUR_RESET $CLI_SEPARATOR Feature rich configuration tool for your device + ${aCOLOUR[strong]}dietpi-software$COLOUR_RESET $CLI_SEPARATOR Select optimised software for installation + ${aCOLOUR[strong]}htop$COLOUR_RESET $CLI_SEPARATOR Resource monitor + ${aCOLOUR[strong]}cpu$COLOUR_RESET $CLI_SEPARATOR Shows CPU information and stats\n" + } + + Print_Item_State() + { + # Print a single line item with green bullet, description "$1", green separator and state "$2" + # state should contain color and format codes as needed + local subtitle=$1 state=$2 + echo -e "$CLI_BULLET${aCOLOUR[strong]} $subtitle $CLI_SEPARATOR $state$COLOUR_RESET" } Print_Banner_raw() @@ -275,63 +974,59 @@ $GREEN_LINE" # Large Format Hostname # shellcheck disable=SC1091 - (( ${aENABLED[14]} )) && . /boot/dietpi/func/dietpi-print_large "$(&1)" + (( ${aENABLED[uptime]} )) && Print_Item_State "${aDESCRIPTION[uptime]}" "$(uptime -p 2>&1)" # Linux kernel version - (( ${aENABLED[20]} )) && echo -e "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[20]} $GREEN_SEPARATOR $(uname -r 2>&1)" + (( ${aENABLED[kernel]} )) && Print_Item_State "${aDESCRIPTION[kernel]}" "$(uname -r 2>&1)" # CPU temp - (( ${aENABLED[2]} )) && echo -e "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[2]} $GREEN_SEPARATOR $(print_full_info=1 G_OBTAIN_CPU_TEMP 2>&1)" + (( ${aENABLED[cpu_temp]} )) && Print_Item_State "${aDESCRIPTION[cpu_temp]}" "$(print_full_info=1 G_OBTAIN_CPU_TEMP 2>&1)" # RAM usage - (( ${aENABLED[17]} )) && echo -e "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[17]} $GREEN_SEPARATOR $(free -b | mawk 'NR==2 {CONVFMT="%.0f"; print $3/1024^2" of "$2/1024^2" MiB ("$3/$2*100"%)"}')" + (( ${aENABLED[ram_usage]} )) && Print_Item_State "${aDESCRIPTION[ram_usage]}" "$(free -b | mawk 'NR==2 {CONVFMT="%.0f"; print $3/1024^2" of "$2/1024^2" MiB ("$3/$2*100"%)"}')" # Load average - (( ${aENABLED[18]} )) && echo -e "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[18]} $GREEN_SEPARATOR $(mawk '{print $1 ", " $2 ", " $3}' /proc/loadavg) ($(nproc) cores)" + (( ${aENABLED[load_average]} )) && Print_Item_State "${aDESCRIPTION[load_average]}" "$(mawk '{print $1 ", " $2 ", " $3}' /proc/loadavg) ($(nproc) cores)" # Hostname - (( ${aENABLED[3]} && ! ${aENABLED[14]} )) && echo -e "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[3]} $GREEN_SEPARATOR $(&1)" + (( ${aENABLED[nis_domainname]} )) && Print_Item_State "${aDESCRIPTION[nis_domainname]}" "$(hostname -y 2>&1)" # LAN IP - Print_Local_Ip + (( ${aENABLED[lan_ip]} )) && Print_Item_State "${aDESCRIPTION[lan_ip]}" "$(Get_Local_Ip 2>&1)" # WAN IP + location info - (( ${aENABLED[6]} )) && echo -e "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[6]} $GREEN_SEPARATOR $(G_GET_WAN_IP 2>&1)" + (( ${aENABLED[wan_ip]} )) && Print_Item_State "${aDESCRIPTION[wan_ip]}" "$(G_GET_WAN_IP 2>&1)" # DietPi-VPN connection status - (( ${aENABLED[13]} )) && echo -e "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[13]} $GREEN_SEPARATOR $(/boot/dietpi/dietpi-vpn status 2>&1)" - # Disk usage (RootFS) - (( ${aENABLED[7]} )) && echo -e "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[7]} $GREEN_SEPARATOR $(df -h --output=used,size,pcent / | mawk 'NR==2 {print $1" of "$2" ("$3")"}' 2>&1)" - # Disk usage (DietPi userdata) - (( ${aENABLED[8]} )) && echo -e "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[8]} $GREEN_SEPARATOR $(df -h --output=used,size,pcent /mnt/dietpi_userdata | mawk 'NR==2 {print $1" of "$2" ("$3")"}' 2>&1)" + (( ${aENABLED[vpn_status]} )) && Print_Item_State "${aDESCRIPTION[vpn_status]}" "$(Get_VPN_Status 2>&1)" # Weather - (( ${aENABLED[9]} )) && echo -e "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[9]} $GREEN_SEPARATOR $(curl -sSfLm 3 'https://wttr.in/?format=4' 2>&1)" + (( ${aENABLED[weather]} )) && Print_Item_State "${aDESCRIPTION[weather]}" "$(curl -sSfLm 3 'https://wttr.in/?format=4' 2>&1)" # Let's Encrypt cert status - if (( ${aENABLED[16]} )) - then - echo -en "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[16]} $GREEN_SEPARATOR " - if [[ $EUID == 0 ]] - then - # Running as root - Print_Cert_Status 2>&1 - else - # Running as non-root: Fail silently without NOPASSWD to avoid password prompt, but print info instead - sudo -n dash -c "$(declare -f Print_Cert_Status); Print_Cert_Status 2>&1" 2> /dev/null || echo 'NOPASSWD sudo required to obtain cert status' - fi - fi - # Custom - (( ${aENABLED[10]} )) && echo -e "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[10]} $GREEN_SEPARATOR $(bash "$FP_CUSTOM" 2>&1)" + (( ${aENABLED[letsencrypt]} )) && Print_Item_State "Let's Encrypt Cert" "$(Get_Cert_Status 2>&1)" + # Systemd status + (( ${aENABLED[systemd]} )) && Print_Item_State "${aDESCRIPTION[systemd]}" "$(Get_Systemd_Status 2>&1)" + # Fail2Ban status + (( ${aENABLED[fail2ban]} )) && Print_Item_State "${aDESCRIPTION[fail2ban]}" "$(Get_Fail2Ban_Status 2>&1)" # MOTD - if (( ${aENABLED[12]} )) + if (( ${aENABLED[motd]} )) then local motd fp_motd='/run/dietpi/.dietpi_motd' [[ -f $fp_motd ]] || curl -sSfLm 3 'https://dietpi.com/motd' -o "$fp_motd" # shellcheck disable=SC1090 - [[ -f $fp_motd ]] && . "$fp_motd" &> /dev/null && [[ $motd ]] && echo -e "$GREEN_BULLET ${aCOLOUR[1]}${aDESCRIPTION[12]} $GREEN_SEPARATOR $motd" + [[ -f $fp_motd ]] && . "$fp_motd" &> /dev/null && [[ $motd ]] && Print_Item_State "${aDESCRIPTION[motd]}" "$motd" fi - echo -e "$GREEN_LINE\n" - (( ${aENABLED[15]} )) && Print_Credits + # Multi-line blocks with separate CLI_LINE and header + # - Network usage by namespace + (( ${aENABLED[network_usage]} )) && Print_Subheader 'Network Traffic' && Print_Network_Usage + # - Disk usage + (( ${aENABLED[disk_usage]} )) && Print_Subheader 'Disk Usage' && Print_Disk_Usage + # - Custom commands + (( ${aENABLED[custom_commands]} )) && Print_Subheader 'Custom Commands' && Print_Custom_Commands + # Final closing CLI_LINE and empty line before credits, update notifications, dietpi-* commands + echo -e "$CLI_LINE\n" + + (( ${aENABLED[credits]} )) && Print_Credits Print_Updates - (( ${aENABLED[11]} )) && Print_Useful_Commands + (( ${aENABLED[dietpi_commands]} )) && Print_Useful_Commands } Print_Banner() @@ -339,7 +1034,7 @@ $GREEN_LINE" G_TERM_CLEAR # Pipe to awk script for word-wrap if enabled - if (( ${aENABLED[19]} )) + if (( ${aENABLED[word_wrap]} )) then Print_Banner_raw | mawk -v "MAXCOL=$(tput cols)" -v "INDENT_TYPE=$BW_INDENT_TYPE" -v "INDENT_FIXED=$BW_INDENT_FIXED" -f "$FP_BANNERWRAP_AWK" else @@ -350,58 +1045,60 @@ $GREEN_LINE" Menu_Main() { G_WHIP_CHECKLIST_ARRAY=() - for i in "${!aDESCRIPTION[@]}" + for k in "${MENU_ITEMS[@]}" do local state='off' - (( ${aENABLED[$i]:=0} == 1 )) && state='on' - G_WHIP_CHECKLIST_ARRAY+=("$i" "${aDESCRIPTION[$i]}" "$state") + (( ${aENABLED[$k]:=0} == 1 )) && state='on' + G_WHIP_CHECKLIST_ARRAY+=("$k" "${aDESCRIPTION[$k]}" "$state") done + G_WHIP_CHECKLIST_ENUM=1 # set the menu to use integer indices for the display G_WHIP_CHECKLIST "Please (de)select options via spacebar to be shown in the $G_PROGRAM_NAME:" || return 0 - for i in "${!aDESCRIPTION[@]}" + for k in "${MENU_ITEMS[@]}" do - aENABLED[$i]=0 + aENABLED[$k]=0 done - for i in $G_WHIP_RETURNED_VALUE + for selection in $G_WHIP_RETURNED_VALUE do - aENABLED[$i]=1 - - if (( $i == 10 )) - then - # Custom entry - [[ -f $FP_CUSTOM ]] && read -r G_WHIP_DEFAULT_ITEM < "$FP_CUSTOM" || G_WHIP_DEFAULT_ITEM='echo '\''Hello World!'\' - G_WHIP_INPUTBOX 'You have chosen to show a custom entry in the banner. -Please enter the desired command here.\n -NB: It is executed as bash script, so it needs to be in bash compatible syntax. - For multi-line or non-bash scripts, keep it separate and only add the script call here.' || continue + # Enable options (the typical action for the checklist) + aENABLED[$selection]=1 - echo "$G_WHIP_RETURNED_VALUE" > "$FP_CUSTOM" - - G_WHIP_DEFAULT_ITEM=${aDESCRIPTION[10]} - G_WHIP_INPUTBOX 'Please enter a meaningful name to be shown in front of your custom command output:' && aDESCRIPTION[10]=$G_WHIP_RETURNED_VALUE - - elif (( $i == 19 )) - then - # Banner word-wrap options - G_WHIP_MENU_ARRAY=( - 'colon' 'Indent to the green colon if present, else to the green dash' - 'dash' 'Indent to the green dash character' - 'fixed' 'Indent to a fixed offset' - ) - G_WHIP_DEFAULT_ITEM=$BW_INDENT_TYPE - G_WHIP_MENU 'Choose how word-wrapped lines shall be indented:' || continue - BW_INDENT_TYPE=$G_WHIP_RETURNED_VALUE - [[ $BW_INDENT_TYPE == 'fixed' ]] || continue - G_WHIP_DEFAULT_ITEM=$BW_INDENT_FIXED - G_WHIP_INPUTBOX_REGEX='^[1-9][0-9]*$' G_WHIP_INPUTBOX_REGEX_TEXT='be a number' - G_WHIP_INPUTBOX 'Please set the fixed offset column to indent word-wrapped lines to:' && BW_INDENT_FIXED=$G_WHIP_RETURNED_VALUE - fi + # Special actions for some items (like submenus) that require additional configuration + case $selection in + 'custom_commands') + Menu_Custom_Commands + # If no commands are configured, disable the custom commands option + (( ${#aCUSTOM_COMMANDS[@]} )) || aENABLED[custom_commands]=0 + ;; + 'word_wrap') + # Banner word-wrap options + G_WHIP_MENU_ARRAY=( + 'colon' 'Indent to the green colon if present, else to the green dash' + 'dash' 'Indent to the green dash character' + 'fixed' 'Indent to a fixed offset' + ) + G_WHIP_DEFAULT_ITEM=$BW_INDENT_TYPE + G_WHIP_MENU 'Choose how word-wrapped lines shall be indented:' || continue + BW_INDENT_TYPE=$G_WHIP_RETURNED_VALUE + [[ $BW_INDENT_TYPE == 'fixed' ]] || continue + G_WHIP_DEFAULT_ITEM=$BW_INDENT_FIXED + G_WHIP_INPUTBOX_REGEX='^[1-9][0-9]*$' G_WHIP_INPUTBOX_REGEX_TEXT='be a number' + G_WHIP_INPUTBOX 'Please set the fixed offset column to indent word-wrapped lines to:' && BW_INDENT_FIXED=$G_WHIP_RETURNED_VALUE + ;; + 'disk_usage') + Menu_Disk_Mounts + # If no disks are configured, disable the disk usage option + if (( ${#aDISK_SPACE_PATTERNS[@]} == 0 || ${aENABLED[disk_patterns]} == 0 )) + then + (( ${#aDISK_SPACE_MOUNTS[@]} == 0 )) && aENABLED[disk_usage]=0 + fi + ;; + *) :;; + esac done - [[ -f $FP_CUSTOM ]] || aENABLED[10]=0 - Save > "$FP_SAVEFILE" } @@ -409,7 +1106,7 @@ NB: It is executed as bash script, so it needs to be in bash compatible syntax. # Main Loop #///////////////////////////////////////////////////////////////////////////////////// case $INPUT in - 0) Print_Header; Print_Local_Ip;; + 0) Print_Header; Print_Item_State "${aDESCRIPTION[lan_ip]}" "$(Get_Local_Ip 2>&1)";; 1) Print_Banner;; '') Menu_Main; Print_Banner;; *) G_DIETPI-NOTIFY 1 "Invalid input \"$*\"\n\nUsage:$USAGE"; exit 1;; diff --git a/dietpi/func/dietpi-banner-wrap.awk b/dietpi/func/dietpi-banner-wrap.awk index ce60a7870c..56a1d5d82e 100644 --- a/dietpi/func/dietpi-banner-wrap.awk +++ b/dietpi/func/dietpi-banner-wrap.awk @@ -35,7 +35,7 @@ BEGIN { { ## ASCII ART: Skip or Hide - if (match($0, /^[^a-zA-Z0-9─]+$/)) { + if (match($0, /^[^a-zA-UW-Z0-9─]+$/)) { if (MAXCOL > (RSTART + RLENGTH)) {print $0} next } diff --git a/dietpi/func/dietpi-globals b/dietpi/func/dietpi-globals index b84cf27827..faf03d04ae 100644 --- a/dietpi/func/dietpi-globals +++ b/dietpi/func/dietpi-globals @@ -171,6 +171,40 @@ $(ps f -eo pid,user,tty,cmd | grep -i 'dietpi')" && continue } + # Truncate a string in the middle to a maximum length, inserting "..." + # - If the string is shorter than or equal to the maximum length, it is returned unchanged. + # - If the maximum length is less than 7, the string is truncated to the maximum length without adding "...". + # - E.g. + # For example: + # - `G_TRUNCATE_MID "Long text to be shortened by removing the middle" 26` returns "Long text to... the middle" + # - `G_TRUNCATE_MID "G_TRUNCATE_MID "alphabetical" N` returns the below, as N decreases + # alp...al + # al...al + # alphab + # alpha + # ... and so on + + G_TRUNCATE_MID() + { + local s=$1 max=$2 + local len=${#s} + if (( $max >= $len )) + then + echo "$s" + return 0 + + elif (( $max < 7 )) + then + echo "${s:0:$max}" + return 0 + fi + local keep=$(( $max - 3 )) + local pre=$(( ( $keep + 1 ) / 2 )) + local suf=$(( $keep / 2 )) + local start=$(( $len - $suf )) + echo "${s:0:pre}...${s:start}" + } + # DietPi-Notify # $1: # -2 = Processing @@ -460,11 +494,12 @@ $grey───────────────────────── # - G_WHIP_NOCANCEL=1 | Optional, hide the cancel button on inputbox, menu and checkbox dialogues # - G_WHIP_MENU_ARRAY | Required for G_WHIP_MENU to set available menu entries, 2 array indices per line: ('item' 'description') # - G_WHIP_CHECKLIST_ARRAY | Required for G_WHIP_CHECKLIST set available checklist options, 3 array indices per line: ('item' 'description' 'on'/'off') + # - G_WHIP_CHECKLIST_ENUM=1 | Optional, if set, the checklist will be displayed enumerated with numeric tags rather than the provided tags # Output: # - G_WHIP_RETURNED_VALUE | Returned value from inputbox/menu/checklist based whiptail items # G_WHIP_DESTROY | Clear vars after run of whiptail - G_WHIP_DESTROY(){ unset -v G_WHIP_DEFAULT_ITEM G_WHIP_SIZE_X_MAX G_WHIP_BUTTON_OK_TEXT G_WHIP_BUTTON_CANCEL_TEXT G_WHIP_NOCANCEL G_WHIP_MENU_ARRAY G_WHIP_CHECKLIST_ARRAY G_WHIP_INPUTBOX_REGEX G_WHIP_INPUTBOX_REGEX_TEXT; } + G_WHIP_DESTROY(){ unset -v G_WHIP_DEFAULT_ITEM G_WHIP_SIZE_X_MAX G_WHIP_BUTTON_OK_TEXT G_WHIP_BUTTON_CANCEL_TEXT G_WHIP_NOCANCEL G_WHIP_MENU_ARRAY G_WHIP_CHECKLIST_ARRAY G_WHIP_INPUTBOX_REGEX G_WHIP_INPUTBOX_REGEX_TEXT G_WHIP_CHECKLIST_ENUM; } # Run once, to be failsafe in case any exported/environment variables are left from originating shell G_WHIP_DESTROY @@ -805,6 +840,7 @@ $grey───────────────────────── # G_WHIP_CHECKLIST "message" # - Prompt user to select multiple options from G_WHIP_CHECKLIST_ARRAY and save choice to G_WHIP_RETURNED_VALUE # - Exit code: 0=selection done, else=user cancelled or noninteractive + # - G_WHIP_CHECKLIST_ENUM=1: Optional, if set, the checklist will be displayed enumerated with numeric tags rather than the provided tags G_WHIP_CHECKLIST() { local result=1 @@ -815,10 +851,36 @@ $grey───────────────────────── local WHIP_ERROR WHIP_BACKTITLE WHIP_SCROLLTEXT WHIP_SIZE_X WHIP_SIZE_Y WHIP_SIZE_Z WHIP_MESSAGE=$* NOCANCEL=() [[ $G_WHIP_NOCANCEL == 1 ]] && NOCANCEL=('--nocancel') G_WHIP_BUTTON_OK_TEXT=${G_WHIP_BUTTON_OK_TEXT:-Confirm} + + # If G_WHIP_CHECKLIST_ENUM=1 is set, map and replace original tags and default with numeric ones + if (( $G_WHIP_CHECKLIST_ENUM )) + then + local map=() idx=0 + for ((i=0;i<${#G_WHIP_CHECKLIST_ARRAY[@]};i+=3)) + do + [[ $G_WHIP_DEFAULT_ITEM == "${G_WHIP_CHECKLIST_ARRAY[$i]}" ]] && G_WHIP_DEFAULT_ITEM=$idx + map[$idx]=${G_WHIP_CHECKLIST_ARRAY[$i]} + G_WHIP_CHECKLIST_ARRAY[$i]=$idx + ((idx++)) + done + fi + G_WHIP_INIT 3 # shellcheck disable=SC2086 G_WHIP_RETURNED_VALUE=$(whiptail ${G_PROGRAM_NAME:+--title "$G_PROGRAM_NAME"} --backtitle "$WHIP_BACKTITLE | Use spacebar to toggle selection" --checklist "$WHIP_MESSAGE" --separate-output --ok-button "$G_WHIP_BUTTON_OK_TEXT" --cancel-button "$G_WHIP_BUTTON_CANCEL_TEXT" "${NOCANCEL[@]}" --default-item "$G_WHIP_DEFAULT_ITEM" $WHIP_SCROLLTEXT "$WHIP_SIZE_Y" "$WHIP_SIZE_X" "$WHIP_SIZE_Z" -- "${G_WHIP_CHECKLIST_ARRAY[@]}" 3>&1 1>&2 2>&3-) result=$? + + # If G_WHIP_CHECKLIST_ENUM=1 is set, replace numeric selections back to original tags + if (( $G_WHIP_CHECKLIST_ENUM )) + then + local mapped=() + for idx in $G_WHIP_RETURNED_VALUE + do + mapped+=("${map[$idx]}") + done + G_WHIP_RETURNED_VALUE=${mapped[*]} + fi + G_WHIP_RETURNED_VALUE=$(echo -e "$G_WHIP_RETURNED_VALUE" | tr '\n' ' ') else G_WHIP_RETURNED_VALUE=$G_WHIP_DEFAULT_ITEM