Files
libretro/ports/shared/tui.txt
T
Abdessamad Derraz 9646a9f429 feat: offer the other card in offline launchers
Without a network, muOS and ArkOS launchers ask to copy the BIOS files from the card the system does not read (muOS bind.sh, dArkOS roms2), then install with --from and the pack. CI checks that shellcheck is present, and the test fails under CI without it.
2026-10-23 16:56:28 +02:00

568 lines
18 KiB
Plaintext

#!/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