Files
libretro/ports/shared/tui.txt
T
Abdessamad Derraz 801a0150e6 feat: run the handheld launchers in the tui
The PortMaster port now starts install.py --tui on the console (or in
foot or xterm under a compositor), with gptokeyb in kill mode on the
installer's path, retrobios.gptk, TERM=linux and a Terminus font, and
puts the console, font and mapper back on exit. pugwash screens stay as
the fallback where no terminal can show, and start in no-check mode so
PortMaster's A/B fix applies. Without a network, every launcher installs
from a pack found on the card or names the pack and the folder.

Adds launchers for Batocera and Knulli (Ports, evmapy .keys), muOS
(.muxapp in muxterm with gptokeyb2) and the Tools menu of ArkOS,
dArkOS, ROCKNIX, JELOS and AmberELEC, built by build_port.py with the
pinned installer and tested on a simulated device.
2026-10-23 16:26:47 +02:00

503 lines
16 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_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_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_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'
}
# --- 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
if ! rb_online; then
rb_detect
pack=$(rb_find_pack)
if [ -z "$pack" ]; then
rb_message "$(rb_pack_text)"
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_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