#!/bin/bash # shellcheck shell=bash # RetroBIOS on a handheld: runs install.py --tui on a terminal the player can # see, with the controller mapped to the keys the interface reads. # # Sourced by every launcher (the PortMaster port, the Batocera and Knulli # port, the muOS app, the Tools entry), each of which sets the inputs below # and calls rb_select_python, then rb_launch. Run as `bash tui.txt here`, it # is the part that runs inside a terminal app the launcher opened. # # Inputs, all optional: # RB_ROUTES where to look for a terminal, in order: here (this # script's own stdin and stdout), app (foot or xterm in the # graphical session), vt (a Linux console). Default "app vt". # RB_CONSOLE the console of the vt route (default /dev/tty0) # RB_VT_SWITCH a console number to switch to when a graphical session # covers the current one (Batocera, Knulli) # RB_MAPPER the command that starts gptokeyb, kill flag included # ("$GPTOKEYB" in a PortMaster port), empty for none # RB_GPTK its mapping (default retrobios.gptk next to this file) # RB_PACK_PATH folders searched for a pack when there is no network, # separated by ":", the first one named to the player # RB_OTHER_CARDS BIOS folders the firmware keeps on each of its cards, # separated by ":", as its own code names them # RB_BIOS_DEST the one of them the firmware reads, never offered as the # other card # RB_FALLBACK 1 when the launcher has screens of its own to fall back to # ESUDO sudo where the launcher needs it, as PortMaster sets it RB_LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")" RB_DIR="${RB_DIR:-$(dirname "$RB_LIB")}" RB_INSTALL="${RB_INSTALL:-$RB_DIR/install.py}" RB_EVENTS="${RB_EVENTS:-$RB_DIR/rbevents.py}" RB_GPTK="${RB_GPTK:-$RB_DIR/retrobios.gptk}" RB_STATE="${RB_STATE:-$RB_DIR/run}" RB_LOG="${RB_LOG:-$RB_DIR/log.txt}" RB_ROUTES="${RB_ROUTES:-app vt}" RB_CONSOLE="${RB_CONSOLE:-/dev/tty0}" RB_PACK_PATH="${RB_PACK_PATH:-$RB_DIR}" RB_OTHER_CARDS="${RB_OTHER_CARDS:-}" RB_BIOS_DEST="${RB_BIOS_DEST:-}" RB_FONTS="${RB_FONTS:-/usr/share/consolefonts}" RB_FB="${RB_FB:-/sys/class/graphics/fb0/virtual_size}" RB_ROUTE_TABLE="${RB_ROUTE_TABLE:-/proc/net/route}" RB_VT_ACTIVE="${RB_VT_ACTIVE:-/sys/class/tty/tty0/active}" RB_PROBE_URL="${RETROBIOS_BASE_URL:-https://raw.githubusercontent.com/Abdess/retrobios/}" RB_RUNTIME="python_3.11" RB_RELEASES="github.com/Abdess/retrobios/releases/latest" RB_PC_ROUTE="On a computer, with this SD card mounted, the same installer fills the card: install.py --platform NAME --dest BIOS-FOLDER-ON-THE-CARD. Instructions: abdess.github.io/retrobios/wiki/installer" RB_PYTHON="${RB_PYTHON:-}" RB_PYTHONHOME="${RB_PYTHONHOME:-}" RB_PYTHON_PROBLEM="${RB_PYTHON_PROBLEM:-}" RB_MOUNTED="" RB_MAPPER_PID="" RB_TTY="" RB_STTY="" RB_FONT_BACK="" RB_VT_BACK="" RB_ROUTE="" RB_TERMAPP=() rb_arch() { uname -m } rb_py() { if [ -n "$RB_PYTHONHOME" ]; then PYTHONHOME="$RB_PYTHONHOME" "$RB_PYTHON" "$@" else "$RB_PYTHON" "$@" fi } # --- Python --------------------------------------------------------------- rb_python_ok() { "$@" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 8) else 1)' >/dev/null 2>&1 } # The OS python3 when it is 3.8 or newer, else PortMaster's python_3.11 # runtime on aarch64, mounted as PortMaster's own Python ports do. A launcher # that can download the runtime defines rb_fetch_runtime. Sets # RB_PYTHON_PROBLEM when neither can run install.py. rb_select_python() { if command -v python3 >/dev/null 2>&1 && rb_python_ok python3; then RB_PYTHON="python3" return 0 fi if [ "$(rb_arch)" != "aarch64" ]; then RB_PYTHON_PROBLEM="old" return 1 fi rb_runtime } rb_runtime() { local python_dir="$HOME/$RB_RUNTIME" local squashfs="${controlfolder:-/nonexistent}/libs/$RB_RUNTIME.squashfs" if [ ! -f "$squashfs" ]; then if ! declare -F rb_fetch_runtime >/dev/null; then RB_PYTHON_PROBLEM="runtime_missing" return 1 fi rb_fetch_runtime "$squashfs" || return 1 fi mkdir -p "$python_dir" if [ "${PM_CAN_MOUNT:-Y}" != "N" ]; then $ESUDO umount "$python_dir" >/dev/null 2>&1 fi if ! $ESUDO mount "$squashfs" "$python_dir"; then RB_PYTHON_PROBLEM="runtime_failed" return 1 fi RB_MOUNTED="$python_dir" RB_PYTHON="$python_dir/bin/python3" RB_PYTHONHOME="$python_dir" if ! PYTHONHOME="$python_dir" rb_python_ok "$RB_PYTHON"; then RB_PYTHON="" RB_PYTHON_PROBLEM="runtime_failed" return 1 fi return 0 } rb_release_python() { if [ -n "$RB_MOUNTED" ] && [ "${PM_CAN_MOUNT:-Y}" != "N" ]; then $ESUDO umount "$RB_MOUNTED" >/dev/null 2>&1 fi RB_MOUNTED="" } rb_python_text() { local found found=$(python3 --version 2>&1 | awk '{print $2}') case "$RB_PYTHON_PROBLEM" in old) printf '%s\n' "Python 3.8 or newer is needed. This system has ${found:-none}, and PortMaster's Python runtime exists for aarch64 only, and this device is $(rb_arch). $RB_PC_ROUTE" ;; runtime_offline) printf '%s\n' "Python 3.8 or newer is needed. This system has ${found:-none}, and PortMaster's $RB_RUNTIME runtime needs a connection to download. $RB_PC_ROUTE" ;; runtime_missing) printf '%s\n' "Python 3.8 or newer is needed. This system has ${found:-none}. The RetroBIOS port from PortMaster downloads PortMaster's $RB_RUNTIME runtime, which this entry then uses too. $RB_PC_ROUTE" ;; *) printf '%s\n' "Python 3.8 or newer is needed. This system has ${found:-none}, and PortMaster's $RB_RUNTIME runtime could not be installed or run. $RB_PC_ROUTE" ;; esac } # --- Network and packs ---------------------------------------------------- # Whether the file list can be reached. Only a failure to resolve or to # connect counts as offline: a certificate refused by an old curl still means # a network, and install.py names that problem itself. rb_online() { local code if command -v curl >/dev/null 2>&1; then curl -sSI --max-time 8 "$RB_PROBE_URL" >/dev/null 2>&1 code=$? case "$code" in 5|6|7|28) return 1 ;; *) return 0 ;; esac fi if command -v wget >/dev/null 2>&1; then wget -q --spider -T 8 "$RB_PROBE_URL" >/dev/null 2>&1 [ $? -ne 4 ] return fi # Neither tool: a default route is the best guess there is. awk 'NR > 1 && $2 == "00000000" { found = 1 } END { exit !found }' "$RB_ROUTE_TABLE" 2>/dev/null } # The system and BIOS folder install.py finds here, read without a network: # detection is local, and the run stops where the file list would download. rb_detect() { RB_PLATFORM="" RB_PLATFORM_NAME="" RB_BIOS_DIR="" mkdir -p "$RB_STATE" rb_py "$RB_INSTALL" --check --events > "$RB_STATE/detect.jsonl" 2>> "$RB_LOG" local kind a b c while IFS=$'\t' read -r kind a b c; do if [ "$kind" = "platform" ]; then RB_PLATFORM="$a" RB_PLATFORM_NAME="$b" RB_BIOS_DIR="$c" [ "$RB_BIOS_DIR" = "-" ] && RB_BIOS_DIR="" break fi done < <(rb_py "$RB_EVENTS" status "$RB_STATE/detect.jsonl" 2>> "$RB_LOG") } # The first pack in RB_PACK_PATH, one whose name spells this system first: # install.py reads the platform from the same spelling (Name_With_Underscores # between two underscores). rb_find_pack() { local dir file base stem first="" want="" if [ -n "${RB_PLATFORM_NAME:-}" ]; then want="_${RB_PLATFORM_NAME// /_}_" fi local IFS=":" for dir in $RB_PACK_PATH; do [ -d "$dir" ] || continue for file in "$dir"/*_BIOS_Pack.zip "$dir"/*_BIOS_Pack.part1of*.zip; do [ -f "$file" ] || continue base="${file##*/}" stem="_${base%%_BIOS_Pack*}_" if [ -n "$want" ] && [[ "$stem" == *"$want"* ]]; then printf '%s\n' "$file" return 0 fi [ -z "$first" ] && first="$file" done done [ -n "$first" ] && printf '%s\n' "$first" return 0 } rb_same_dir() { [ -n "$1" ] && [ -n "$2" ] || return 1 [ "$(stat -L -c '%d:%i' "$1" 2>/dev/null)" = "$(stat -L -c '%d:%i' "$2" 2>/dev/null)" ] } # The BIOS folder on the firmware's other card, when it holds a file: a # two-card device reads one card (muOS binds MUOS/bios from SD2 when it has # one, else from SD1), and the files bought for it often sit on the other. rb_other_card() { local dir local IFS=":" for dir in ${RB_OTHER_CARDS:-}; do [ -d "$dir" ] || continue if rb_same_dir "$dir" "${RB_BIOS_DEST:-}" || rb_same_dir "$dir" "${RB_BIOS_DIR:-}"; then continue fi if [ -n "$(find "$dir" -type f -print 2>/dev/null | head -n 1)" ]; then printf '%s\n' "$dir" return 0 fi done return 0 } rb_pack_text() { local dir="${RB_PACK_PATH%%:*}" pack if [ -n "${RB_PLATFORM_NAME:-}" ]; then pack="the BIOS pack whose name contains ${RB_PLATFORM_NAME// /_}" else pack="the BIOS pack for this system (a file whose name ends in _BIOS_Pack.zip)" fi printf '%s\n' "No network connection. To install without one, download $pack from $RB_RELEASES on a computer, copy it into $dir on the SD card, then start RetroBIOS again. Or connect to Wi-Fi and start again." } # --- Terminal ------------------------------------------------------------- rb_graphical() { [ -n "${WAYLAND_DISPLAY:-}" ] || [ -n "${DISPLAY:-}" ] \ || pgrep -x 'sway|weston|labwc|cage|gamescope|Xorg|X' >/dev/null 2>&1 } # A terminal app the graphical session can show, full screen. A launcher # that knows its own (muOS's muxterm) sets RB_TERMAPP after sourcing this. rb_terminal_app() { if [ "${#RB_TERMAPP[@]}" -gt 0 ]; then return 0 fi if [ -z "${WAYLAND_DISPLAY:-}" ] && [ -z "${DISPLAY:-}" ]; then return 1 fi if command -v foot >/dev/null 2>&1; then RB_TERMAPP=(foot -F --) elif command -v xterm >/dev/null 2>&1; then RB_TERMAPP=(xterm -fullscreen -e) else return 1 fi } # Sets RB_ROUTE to the first of RB_ROUTES that can show text: here, app, # vt, or none. rb_route() { local route RB_ROUTE="none" for route in $RB_ROUTES; do case "$route" in here) if [ -t 0 ] && [ -t 1 ]; then RB_ROUTE="here" return 0 fi ;; app) if rb_terminal_app; then RB_ROUTE="app" return 0 fi ;; vt) # A compositor or X server holds the console it runs on: text # written there is never seen, so the console needs a switch. if rb_graphical; then if [ -n "${RB_VT_SWITCH:-}" ] && command -v chvt >/dev/null 2>&1 \ && [ -n "$(rb_active_vt)" ]; then RB_ROUTE="vt" return 0 fi elif [ -c "$RB_CONSOLE" ]; then RB_ROUTE="vt" return 0 fi ;; esac done return 1 } # The largest Terminus font that keeps 56 columns and 20 rows, the way # dArkOS's own tools pick one per screen (Lat7-Terminus16 on 480x320, # TerminusBold22x11 on 640x480, 32x16 on 1024x768). Only changed when the # font in place can be put back: saved with setfont -O, or the # Lat7-Terminus20x10 ArkOS tools restore. rb_font_set() { command -v setfont >/dev/null 2>&1 || return 0 local size width height spec fh fw name file="" fallback size=$(cat "$RB_FB" 2>/dev/null) || return 0 width="${size%%,*}" height="${size##*,}" case "$width$height" in ''|*[!0-9]*) return 0 ;; esac for spec in "32 16 Lat7-TerminusBold32x16" "28 14 Lat7-TerminusBold28x14" \ "22 11 Lat7-TerminusBold22x11" "20 10 Lat7-TerminusBold20x10" \ "16 8 Lat7-Terminus16"; do read -r fh fw name <<< "$spec" if [ $((width / fw)) -ge 56 ] && [ $((height / fh)) -ge 20 ] \ && [ -f "$RB_FONTS/$name.psf.gz" ]; then file="$RB_FONTS/$name.psf.gz" break fi done if [ -z "$file" ] && [ $((width / 8)) -ge 40 ] && [ $((height / 16)) -ge 12 ] \ && [ -f "$RB_FONTS/Lat7-Terminus16.psf.gz" ]; then file="$RB_FONTS/Lat7-Terminus16.psf.gz" fi [ -n "$file" ] || return 0 if $ESUDO setfont -C "$RB_TTY" -O "$RB_STATE/font.psf" "$file" >> "$RB_LOG" 2>&1 \ && [ -s "$RB_STATE/font.psf" ]; then RB_FONT_BACK="$RB_STATE/font.psf" return 0 fi fallback="$RB_FONTS/Lat7-Terminus20x10.psf.gz" if [ -f "$fallback" ] && $ESUDO setfont -C "$RB_TTY" "$file" >> "$RB_LOG" 2>&1; then RB_FONT_BACK="$fallback" fi } rb_font_restore() { if [ -n "$RB_FONT_BACK" ]; then $ESUDO setfont -C "$RB_TTY" "$RB_FONT_BACK" >> "$RB_LOG" 2>&1 fi RB_FONT_BACK="" } # The number of the console in front, which a switch has to come back to. rb_active_vt() { local active active=$(cat "$RB_VT_ACTIVE" 2>/dev/null) active="${active#tty}" case "$active" in ''|*[!0-9]*) active=$(fgconsole 2>/dev/null) ;; esac case "$active" in ''|*[!0-9]*) return 1 ;; esac printf '%s\n' "$active" } rb_vt_enter() { RB_TTY="$RB_CONSOLE" if rb_graphical && [ -n "${RB_VT_SWITCH:-}" ]; then RB_VT_BACK=$(rb_active_vt) RB_TTY="${RB_VT_PREFIX:-/dev/tty}$RB_VT_SWITCH" $ESUDO chvt "$RB_VT_SWITCH" >> "$RB_LOG" 2>&1 fi if [ ! -r "$RB_TTY" ] || [ ! -w "$RB_TTY" ]; then $ESUDO chmod 666 "$RB_TTY" >> "$RB_LOG" 2>&1 fi RB_STTY=$(stty -g < "$RB_TTY" 2>/dev/null) printf '\033c' > "$RB_TTY" rb_font_set } rb_vt_leave() { [ -n "$RB_TTY" ] || return 0 rb_font_restore printf '\033c' > "$RB_TTY" 2>/dev/null if [ -n "$RB_STTY" ]; then stty "$RB_STTY" < "$RB_TTY" 2>/dev/null fi if [ -n "$RB_VT_BACK" ]; then $ESUDO chvt "$RB_VT_BACK" >> "$RB_LOG" 2>&1 fi RB_TTY="" RB_STTY="" RB_VT_BACK="" } # gptokeyb on a name only this installer's processes carry: kill mode runs # pkill -f on it, and the path of install.py is in the command line of the # interface and of every run it starts, never in python3's alone. rb_mapper_start() { [ -n "${RB_MAPPER:-}" ] || return 0 # shellcheck disable=SC2086 # RB_MAPPER is a command and its flags, as $GPTOKEYB is $RB_MAPPER "$RB_INSTALL" -c "$RB_GPTK" >> "$RB_LOG" 2>&1 & RB_MAPPER_PID=$! } rb_mapper_stop() { [ -n "$RB_MAPPER_PID" ] || return 0 $ESUDO kill "$RB_MAPPER_PID" >/dev/null 2>&1 wait "$RB_MAPPER_PID" 2>/dev/null RB_MAPPER_PID="" } # A screen of text on the current terminal, closed by any button. rb_message() { local cols cols=$(stty size 2>/dev/null | awk '{print $2}') case "$cols" in ''|*[!0-9]*) cols=40 ;; esac [ "$cols" -ge 20 ] || cols=40 printf '\033[2J\033[H' printf 'RetroBIOS\n\n' printf '%s\n' "$1" | fold -s -w $((cols - 1)) printf '\n%s\n' "Press A or Enter to close." read -r -s -n 1 -t "${RB_MESSAGE_WAIT:-300}" _ printf '\033[2J\033[H' } # A question on the current terminal: true for A or Enter, false for B, Esc, # any other button or no answer. rb_ask() { local cols key cols=$(stty size 2>/dev/null | awk '{print $2}') case "$cols" in ''|*[!0-9]*) cols=40 ;; esac [ "$cols" -ge 20 ] || cols=40 printf '\033[2J\033[H' printf 'RetroBIOS\n\n' printf '%s\n' "$1" | fold -s -w $((cols - 1)) printf '\n%s\n' "A or Enter: yes. B or Esc: no." if ! IFS= read -r -s -n 1 -t "${RB_MESSAGE_WAIT:-300}" key; then printf '\033[2J\033[H' return 1 fi # The rest of an escape sequence (an arrow) is not a second answer. read -r -s -t 0.05 -n 8 _ 2>/dev/null printf '\033[2J\033[H' [ -z "$key" ] || [ "$key" = " " ] } # --- The run -------------------------------------------------------------- # The interface on the current terminal: install.py --tui, from a pack on the # card when there is no network. The status also goes to RB_STATE/status, # since a terminal app does not always pass its child's on. rb_here() { local status pack args=() mkdir -p "$RB_STATE" rm -f "$RB_STATE/status" case "${TERM:-}" in ''|dumb|unknown) export TERM=xterm ;; esac if [ -z "$RB_PYTHON" ]; then rb_message "$(rb_python_text)" printf '1\n' > "$RB_STATE/status" return 1 fi local other if ! rb_online; then # Detection also names the BIOS folder the other card must not be. rb_detect other=$(rb_other_card) if [ -n "$other" ] && rb_ask "Copy the BIOS files from your other card? $other holds files. RetroBIOS copies the ones this system needs, each checked by its hash, and leaves that card as it is."; then args=(--from "$other") fi pack=$(rb_find_pack) if [ -z "$pack" ]; then if [ "${#args[@]}" -gt 0 ]; then # --from needs the list of files, which comes from the network or a pack. rb_message "$(rb_pack_text) The pack carries the list of files: with it, the files on your other card are copied too." else rb_message "$(rb_pack_text)" fi printf '1\n' > "$RB_STATE/status" return 1 fi args+=(--from-pack "$pack") if [ -n "$RB_PLATFORM" ]; then args+=(--platform "$RB_PLATFORM") fi fi rb_py "$RB_INSTALL" --tui "${args[@]}" "$@" 2> "$RB_STATE/tui.err" status=$? cat "$RB_STATE/tui.err" >> "$RB_LOG" 2>/dev/null if [ "$status" = "2" ] && grep -q -- "--tui cannot open" "$RB_STATE/tui.err"; then status=125 if [ "${RB_FALLBACK:-0}" != "1" ]; then rb_message "$(tail -n 1 "$RB_STATE/tui.err") The messages are in $RB_LOG." fi elif [ "$status" != "0" ] && [ -s "$RB_STATE/tui.err" ]; then rb_message "$(tail -n 1 "$RB_STATE/tui.err") The messages are in $RB_LOG." fi printf '%s\n' "$status" > "$RB_STATE/status" return "$status" } rb_cleanup() { rb_vt_leave rb_mapper_stop } # The whole session: the terminal, the mapper, the interface, then the # console, its font and the mapper put back. Returns the interface's status, # 125 when no terminal could show it. rb_launch() { local status mkdir -p "$RB_STATE" if ! rb_route; then printf '%s\n' "RetroBIOS: no terminal to show the interface on (routes: $RB_ROUTES)" >> "$RB_LOG" return 125 fi export RB_DIR RB_INSTALL RB_EVENTS RB_STATE RB_LOG RB_PACK_PATH RB_FALLBACK export RB_OTHER_CARDS RB_BIOS_DEST export RB_PYTHON RB_PYTHONHOME RB_PYTHON_PROBLEM RB_MESSAGE_WAIT RB_ROUTE_TABLE trap 'rb_cleanup; exit 143' TERM HUP rb_mapper_start case "$RB_ROUTE" in here) rb_here "$@" status=$? ;; app) "${RB_TERMAPP[@]}" bash "$RB_LIB" here "$@" 2>> "$RB_LOG" status=$(cat "$RB_STATE/status" 2>/dev/null) ;; vt) rb_vt_enter export TERM=linux # shellcheck disable=SC2094 # a console is read and written at once by design rb_here "$@" < "$RB_TTY" > "$RB_TTY" 2>> "$RB_LOG" status=$? ;; esac rb_cleanup trap - TERM HUP return "${status:-1}" } if [ "${BASH_SOURCE[0]}" = "$0" ]; then case "${1:-}" in here) shift rb_here "$@" exit $? ;; *) printf '%s\n' "usage: bash tui.txt here [install.py options]" >&2 exit 2 ;; esac fi