From 5db889f53833c0ca7f66da49cee07bd35649abf7 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:21:45 +0200 Subject: [PATCH 1/9] fix: end handheld runs that lose their input Start + Select now kills a sentinel the launcher owns, and the launcher asks the interface to stop, so a running install still writes its result. A mapper that dies, a chvt the compositor refuses, a terminal app that cannot open and a read-only card each end the run or hand over to the next route instead of a console waiting for a button. muOS starts gptokeyb2 with -X and keeps muxterm off the pad so Y opens the options. --- ports/RetroBIOS/RetroBIOS.sh | 30 +- ports/batocera/README.md | 5 +- ports/batocera/RetroBIOS.sh | 12 +- ports/muos/README.md | 14 +- ports/muos/mux_launch.sh | 29 +- ports/muos/muxterm.gptk | 10 - ports/shared/tui.txt | 529 +++++++++++++++++---- ports/tools/README.md | 3 +- ports/tools/RetroBIOS.sh | 28 +- scripts/build_port.py | 1 - tests/test_handheld_launchers.py | 772 ++++++++++++++++++++++++++++--- 11 files changed, 1228 insertions(+), 205 deletions(-) delete mode 100644 ports/muos/muxterm.gptk diff --git a/ports/RetroBIOS/RetroBIOS.sh b/ports/RetroBIOS/RetroBIOS.sh index d7ea21ff..3c819573 100755 --- a/ports/RetroBIOS/RetroBIOS.sh +++ b/ports/RetroBIOS/RetroBIOS.sh @@ -28,8 +28,10 @@ GAMEDIR="/$directory/ports/retrobios" cd "$GAMEDIR" || exit 1 # Appended to, as the launcher and install.py also write this file: a tee -# writing from its own offset would overwrite their lines. -: > "$GAMEDIR/log.txt" && exec > >(tee -a "$GAMEDIR/log.txt") 2>&1 +# writing from its own offset would overwrite their lines. The previous run's +# is kept, for a player who starts again before reading it. +[ -s "$GAMEDIR/log.txt" ] && mv -f "$GAMEDIR/log.txt" "$GAMEDIR/log.prev.txt" 2>/dev/null +{ : > "$GAMEDIR/log.txt"; } 2>/dev/null && exec > >(tee -a "$GAMEDIR/log.txt") 2>&1 # PortMasterDialog.txt has shipped with PortMaster since before 2024.02.26, # and the 8.5.23 copy JELOS and KOLOS preinstall has no funcs.txt to source @@ -48,17 +50,33 @@ RB_CONSOLE="${CUR_TTY:-/dev/tty0}" RB_MAPPER="$GPTOKEYB" RB_FALLBACK=1 RB_PACK_PATH="$GAMEDIR:/$directory/ports:/$directory" -# ArkOS and dArkOS, as control.txt tells them apart (its plymouth theme): the -# games card is /$directory, roms2 when fstab mounts a second card, and its -# bios folder is the one RetroArch reads. The other card's is offered. +# ArkOS and dArkOS, as control.txt tells them apart (its plymouth theme): +# their two card folders are offered to each other, and the destination is +# the one RetroArch reads. control.txt names the card PortMaster runs from +# roms2 whenever fstab mounts a second card, even when RetroArch still reads +# /roms/bios, so RetroArch's own setting decides, as in the Tools entry. if [ -e "${RB_ARK_MARKER:-/usr/share/plymouth/themes/text.plymouth}" ]; then RB_OTHER_CARDS="${RB_OTHER_CARDS:-/roms/bios:/roms2/bios}" - RB_BIOS_DEST="/$directory/bios" + RB_BIOS_DEST=$(awk -F'"' '/^system_directory *=/ { print $2; exit }' \ + "${RB_RETROARCH_CFG:-/home/ark/.config/retroarch/retroarch.cfg}" 2>/dev/null) + RB_BIOS_DEST="${RB_BIOS_DEST:-/$directory/bios}" fi # shellcheck source=../shared/tui.txt source "$GAMEDIR/tui.txt" # shellcheck source=retrobios/screens.txt source "$GAMEDIR/screens.txt" +rb_open_log keep + +# muOS: PortMaster's mod_muOS.txt points CUR_TTY at /dev/null, so there is no +# console to draw on. muxterm, the terminal muOS's Terminal core runs, shows +# the interface instead. It is told to leave the pad alone (an SDL hint) so +# that gptokeyb sends every button once, with this port's mapping: muxterm +# sends Y as Space, where the interface reads Y as Options. The hint allows +# one vendor and product id, 0001:0001, which no pad carries. +RB_MUXTERM="${RB_MUXTERM:-/opt/muos/frontend/muxterm}" +if [ -x "$RB_MUXTERM" ] && [ -n "$RB_MAPPER" ]; then + RB_TERMAPP=(env SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT=0x0001/0x0001 "$RB_MUXTERM" --gl --) +fi export SDL_GAMECONTROLLERCONFIG="$sdl_controllerconfig" diff --git a/ports/batocera/README.md b/ports/batocera/README.md index 37264e1c..c1e3ca7e 100644 --- a/ports/batocera/README.md +++ b/ports/batocera/README.md @@ -17,7 +17,8 @@ options, L1 and R1 to page. B on the first screen closes it. EmulationStation covers the text console on Batocera and Knulli, and no terminal for a script is documented there. RetroBIOS opens xterm (the X11 builds) or foot when either is installed, else switches to console 4, runs -there, and switches back. Which of the two shows up on a given build is not -verified. If the screen stays black, `retrobios/log.txt` says which route +there, and switches back. When console 4 does not come to the front within +3 s, it switches back and stops. Which of the two shows up on a given build +is not verified. If nothing shows, `retrobios/log.txt` says which route was taken, and the computer route always works: with the card mounted, run the one-line command from abdess.github.io/retrobios/wiki/installer. diff --git a/ports/batocera/RetroBIOS.sh b/ports/batocera/RetroBIOS.sh index aa4d27ba..b296b640 100755 --- a/ports/batocera/RetroBIOS.sh +++ b/ports/batocera/RetroBIOS.sh @@ -18,14 +18,20 @@ RB_PACK_PATH="$RB_DIR:$HERE:/userdata" RB_LOG="$RB_DIR/log.txt" # The X11 builds start EmulationStation on display :0, which a script run -# from it does not always inherit. -if [ -z "${DISPLAY:-}" ] && [ -z "${WAYLAND_DISPLAY:-}" ] && [ -S /tmp/.X11-unix/X0 ]; then +# from it does not always inherit, with the cookie S31emulationstation +# exports: xterm is refused the display without it. +if [ -z "${DISPLAY:-}" ] && [ -z "${WAYLAND_DISPLAY:-}" ] \ + && [ -S "${RB_X_SOCKET:-/tmp/.X11-unix/X0}" ]; then export DISPLAY=:0 fi +RB_XAUTH="${RB_XAUTH:-/var/lib/.Xauthority}" +if [ -n "${DISPLAY:-}" ] && [ -z "${XAUTHORITY:-}" ] && [ -f "$RB_XAUTH" ]; then + export XAUTHORITY="$RB_XAUTH" +fi # shellcheck source=../shared/tui.txt source "$RB_DIR/tui.txt" -: > "$RB_LOG" +rb_open_log fresh rb_select_python # A launcher's own arguments are not install.py's (muOS passes the app folder). # shellcheck disable=SC2119 diff --git a/ports/muos/README.md b/ports/muos/README.md index 47e4f1d7..b9200a76 100644 --- a/ports/muos/README.md +++ b/ports/muos/README.md @@ -7,10 +7,12 @@ It checks the BIOS folder (`/run/muos/storage/bios`) against the files its emulators read and installs the missing ones: downloaded with a network connection, or from a pack copied to the root of the card without one. -It runs in muxterm, muOS's terminal. muxterm already sends the D-pad as -arrows, Start as Enter, B as Backspace (back) and Y as Space (tick). With -PortMaster installed, gptokeyb2 adds A (choose), X (tick) and L1/R1 (page). -Menu closes it. +It runs in muxterm, muOS's terminal. With PortMaster installed, gptokeyb2 +turns every button into a key and muxterm is told to leave the pad alone, so +each button is sent once: D-pad to move, A or Start to choose, B or Select to +go back, X to tick a system, Y for the options, L1 and R1 to page. Hold +Select, then press Start to quit. Without PortMaster, muxterm reads the pad +itself (Y then ticks a system) and Menu closes it. -Not tried on a device yet: the icon, and whether muxterm and gptokeyb2 share -the pad as described. +Not tried on a device yet: the icon, and whether muxterm honours SDL's +controller allow-list as SDL 2.30 does. diff --git a/ports/muos/mux_launch.sh b/ports/muos/mux_launch.sh index 650fe862..f0706c14 100755 --- a/ports/muos/mux_launch.sh +++ b/ports/muos/mux_launch.sh @@ -5,8 +5,8 @@ # RetroBIOS for muOS, from Applications: the installer's full-screen # interface in muxterm, the terminal muOS's own Terminal core runs, with -# PortMaster's gptokeyb2 adding the keys muxterm leaves unmapped. Without -# muxterm it runs on the console with the full mapping. +# PortMaster's gptokeyb2 turning the buttons into keys. Without muxterm it +# runs on the console. MUOS="${RB_MUOS:-/opt/muos}" # shellcheck source=/dev/null @@ -19,12 +19,12 @@ PM_DIR="$ROM_MOUNT/MUOS/PortMaster" MUXTERM="$MUOS/frontend/muxterm" RB_DIR="$APP_DIR" -RB_PACK_PATH="$APP_DIR:$ROM_MOUNT" RB_LOG="$APP_DIR/log.txt" # muOS binds MUOS/bios from SD2 when that card has one, else from SD1, onto # /run/muos/storage/bios (script/device/bind.sh:10-11,30-52): the other card's # MUOS/bios is the one offered to copy from. SD2_MOUNT="$(GET_VAR "device" "storage/sdcard/mount")" +RB_PACK_PATH="$APP_DIR:$ROM_MOUNT:$SD2_MOUNT" RB_OTHER_CARDS="$ROM_MOUNT/MUOS/bios:$SD2_MOUNT/MUOS/bios" RB_BIOS_DEST="${RB_BIOS_DEST:-/run/muos/storage/bios}" # PortMaster keeps its runtimes here: the Python runtime is used when @@ -33,21 +33,32 @@ controlfolder="$PM_DIR" # shellcheck source=../shared/tui.txt source "$APP_DIR/tui.txt" -: > "$RB_LOG" +rb_open_log fresh +RB_GPTK="$APP_DIR/retrobios.gptk" if [ -x "$PM_DIR/gptokeyb2" ]; then - # As muOS's own launchers start it (script/launch/ext-xroar.sh). + # As muOS's own launchers start it (script/launch/ext-xroar.sh). gptokeyb2 + # kills only with SIGKILL: by process name by default, which never names + # this run, and quits all the same, or with -X by command line, which is + # what reaches the Start + Select sentinel (gptokeyb2 src/util.c). ln -sf "$PM_DIR/libinterpose.aarch64.so" "/usr/lib/libinterpose.aarch64.so" >/dev/null 2>&1 - RB_MAPPER="$PM_DIR/gptokeyb2" + RB_MAPPER="$PM_DIR/gptokeyb2 -X" fi if [ -x "$MUXTERM" ]; then RB_ROUTES="app" - RB_TERMAPP=("$MUXTERM" --gl --) - RB_GPTK="$APP_DIR/muxterm.gptk" + if [ -n "${RB_MAPPER:-}" ]; then + # muxterm reads the pad itself and sends Y as Space (MustardOS/terminal + # input.c), where the interface reads Y as Options. The SDL hint allows + # only vendor and product 0001:0001, which no pad carries, so muxterm + # leaves the pad to gptokeyb2 and every button is sent once. + RB_TERMAPP=(env SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT=0x0001/0x0001 "$MUXTERM" --gl --) + else + # No gptokeyb2: muxterm's own buttons are all there is. + RB_TERMAPP=("$MUXTERM" --gl --) + fi else RB_ROUTES="vt" RB_CONSOLE="${RB_CONSOLE:-/dev/tty0}" - RB_GPTK="$APP_DIR/retrobios.gptk" fi rb_select_python diff --git a/ports/muos/muxterm.gptk b/ports/muos/muxterm.gptk deleted file mode 100644 index c0699f04..00000000 --- a/ports/muos/muxterm.gptk +++ /dev/null @@ -1,10 +0,0 @@ -# RetroBIOS inside muxterm (gptokeyb2). muxterm already sends the D-pad as -# arrows, Start as Enter, B as Backspace and Y as Space to the program it -# runs (MustardOS/terminal input.c and osk.c), so mapping those here too -# would press each key twice. Only what muxterm leaves unmapped is added. -[controls] -overlay = clear -a = enter -x = space -l1 = pageup -r1 = pagedown diff --git a/ports/shared/tui.txt b/ports/shared/tui.txt index 6de50f46..bacc19b7 100644 --- a/ports/shared/tui.txt +++ b/ports/shared/tui.txt @@ -5,18 +5,23 @@ # # 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. +# and calls rb_open_log, 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". +# A route that fails to show the interface hands over to the +# next one. # 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_MAPPER_NEEDED 0 when the terminal reads the pad itself, so the +# interface still has buttons without the mapper # 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 @@ -44,33 +49,140 @@ 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/}" +# Seconds: the interface's time to write its result once asked to stop, the +# wait for a button when the mapper is gone, the offline detection run. +RB_STOP_GRACE="${RB_STOP_GRACE:-10}" +RB_DEAD_WAIT="${RB_DEAD_WAIT:-10}" +RB_DETECT_LIMIT="${RB_DETECT_LIMIT:-20}" +RB_MAPPER_NEEDED="${RB_MAPPER_NEEDED:-1}" 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_MAPPER_PID="${RB_MAPPER_PID:-}" +RB_STOP_PID="${RB_STOP_PID:-}" +RB_STOP_TOKEN="${RB_STOP_TOKEN:-}" +RB_STATE_PROBLEM="" RB_MOUNTED="" -RB_MAPPER_PID="" +RB_UI_PID="" +RB_APP_PID="" +RB_STOPPED="" RB_TTY="" RB_STTY="" RB_FONT_BACK="" RB_VT_BACK="" RB_ROUTE="" +RB_ROUTE_FAILED="" +RB_NAP_FD="" +RB_KEY="" RB_TERMAPP=() +RB_PYCMD=() rb_arch() { uname -m } -rb_py() { +rb_say() { + printf '%s\n' "RetroBIOS: $*" >> "$RB_LOG" 2>/dev/null +} + +# The interpreter as one command, so it can be started in the background and +# signalled itself: a function run with & is a subshell whose python child a +# kill does not reach. +rb_pycmd() { if [ -n "$RB_PYTHONHOME" ]; then - PYTHONHOME="$RB_PYTHONHOME" "$RB_PYTHON" "$@" + RB_PYCMD=(env "PYTHONHOME=$RB_PYTHONHOME" "$RB_PYTHON") else - "$RB_PYTHON" "$@" + RB_PYCMD=("$RB_PYTHON") fi } +rb_py() { + rb_pycmd + "${RB_PYCMD[@]}" "$@" +} + +# A pause without a child process, in fractions of a second, which busybox +# sleep does not always take: a read that times out on a pipe this shell +# holds both ends of. +rb_nap() { + if [ -z "$RB_NAP_FD" ]; then + exec {RB_NAP_FD}<> <(:) || RB_NAP_FD="none" + fi + if [ "$RB_NAP_FD" = "none" ]; then + sleep 1 + else + read -r -t "$1" -u "$RB_NAP_FD" _ + fi + return 0 +} + +# Whether a process runs. /proc, not kill -0: the mapper may run as root +# under sudo, and kill -0 on it fails for a user. A zombie is not running. +rb_alive() { + local stat + [ -n "${1:-}" ] || return 1 + { read -r stat < "/proc/$1/stat"; } 2>/dev/null || return 1 + stat="${stat##*) }" + [ "${stat%% *}" != "Z" ] +} + +# Runs a command for at most $1 seconds: status 124 when it was stopped. +rb_bounded() { + local limit="$1" pid waited=0 + shift + "$@" & + pid=$! + while rb_alive "$pid"; do + if [ "$waited" -ge $((limit * 4)) ]; then + kill "$pid" 2>/dev/null + wait "$pid" 2>/dev/null + return 124 + fi + rb_nap 0.25 + waited=$((waited + 1)) + done + wait "$pid" +} + +# --- State and log -------------------------------------------------------- + +rb_can_write() { + mkdir -p "$1" 2>/dev/null || return 1 + { printf 'x' > "$1/.probe"; } 2>/dev/null || return 1 + rm -f "$1/.probe" +} + +# The folder the run keeps its state in, and its log, started afresh with the +# previous one kept as log.prev.txt. A read-only or full card moves both to +# XDG_RUNTIME_DIR or /tmp: the interface then runs and says itself what it +# cannot write. "fresh" starts the log, "keep" leaves it as the launcher +# opened it. +rb_open_log() { + local spare="${XDG_RUNTIME_DIR:-/tmp}/retrobios" moved="" + if ! rb_can_write "$RB_STATE" || ! { : >> "$RB_LOG"; } 2>/dev/null; then + moved="$RB_DIR" + RB_STATE="$spare/run" + RB_LOG="$spare/log.txt" + if ! rb_can_write "$RB_STATE"; then + RB_STATE_PROBLEM="RetroBIOS cannot write to $moved or to $spare: the SD card may be read-only or full." + return 1 + fi + fi + if [ "${1:-}" != "keep" ] || [ -n "$moved" ]; then + if [ -s "$RB_LOG" ]; then + mv -f "$RB_LOG" "${RB_LOG%.txt}.prev.txt" 2>/dev/null + fi + : > "$RB_LOG" + fi + if [ -n "$moved" ]; then + rb_say "$moved cannot be written (read-only or full card): the log and the run's files are in $spare" + fi + return 0 +} + # --- Python --------------------------------------------------------------- rb_python_ok() { @@ -144,20 +256,22 @@ rb_python_text() { # 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. +# a network, and install.py names that problem itself. Bounded as a whole: +# GNU wget retries a timeout 20 times. 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 + rb_bounded 8 curl -sSI --connect-timeout 5 --max-time 7 "$RB_PROBE_URL" >/dev/null 2>&1 code=$? case "$code" in - 5|6|7|28) return 1 ;; + 5|6|7|28|124) 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 ] + rb_bounded 8 wget -q --spider -T 7 "$RB_PROBE_URL" >/dev/null 2>&1 + code=$? + [ "$code" -ne 4 ] && [ "$code" -ne 124 ] return fi # Neither tool: a default route is the best guess there is. @@ -166,12 +280,18 @@ rb_online() { # 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. +# That download is sent to a loopback port nothing listens on, so it fails +# at once instead of retrying three times 30 s on a network already found +# missing. 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" + rb_pycmd + RETROBIOS_BASE_URL="http://127.0.0.1:9" rb_bounded "$RB_DETECT_LIMIT" \ + "${RB_PYCMD[@]}" "$RB_INSTALL" --check --events > "$RB_STATE/detect.jsonl" 2>> "$RB_LOG" + [ $? = 124 ] && rb_say "the detection run took over $RB_DETECT_LIMIT s and was stopped" local kind a b c while IFS=$'\t' read -r kind a b c; do if [ "$kind" = "platform" ]; then @@ -269,44 +389,50 @@ rb_terminal_app() { 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 +# A console a terminal can be read from. PortMaster's mod_muOS.txt sets +# CUR_TTY to /dev/null, a character device that is no console at all. A +# console this user cannot open yet (rb_vt_enter opens it up) is judged by +# its name. +rb_is_console() { + [ -c "$1" ] || return 1 + if [ -r "$1" ] && [ -w "$1" ]; then + { [ -t 0 ]; } < "$1" 2>/dev/null + return + fi + case "$1" in + /dev/tty[0-9]*|/dev/console|/dev/pts/*) return 0 ;; + esac return 1 } +# Whether a route can show text here. A compositor or X server holds the +# console it runs on: text written there is never seen, so the console needs +# a switch. +rb_route_ready() { + case "$1" in + here) + [ -t 0 ] && [ -t 1 ] + ;; + app) + rb_terminal_app + ;; + vt) + if rb_graphical; then + [ -n "${RB_VT_SWITCH:-}" ] && command -v chvt >/dev/null 2>&1 \ + && [ -n "$(rb_active_vt)" ] + elif rb_is_console "$RB_CONSOLE"; then + return 0 + else + rb_say "$RB_CONSOLE is not a console: no route on it" + return 1 + fi + ;; + *) + return 1 + ;; + esac +} + # 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 @@ -368,12 +494,34 @@ rb_active_vt() { printf '%s\n' "$active" } +# chvt, and whether the console really came to the front. chvt waits for the +# switch (VT_WAITACTIVE): a compositor that keeps the display would hold it +# forever, so it gets 3 s. +rb_chvt() { + local pid waited=0 + $ESUDO chvt "$1" >> "$RB_LOG" 2>&1 & + pid=$! + while [ "$waited" -lt 12 ]; do + [ "$(rb_active_vt)" = "$1" ] && return 0 + rb_nap 0.25 + waited=$((waited + 1)) + done + $ESUDO kill "$pid" >/dev/null 2>&1 + return 1 +} + rb_vt_enter() { RB_TTY="$RB_CONSOLE" if rb_graphical && [ -n "${RB_VT_SWITCH:-}" ]; then RB_VT_BACK=$(rb_active_vt) + if ! rb_chvt "$RB_VT_SWITCH"; then + rb_say "console $RB_VT_SWITCH did not come to the front (console ${RB_VT_BACK:-?} still is)" + [ -n "$RB_VT_BACK" ] && rb_chvt "$RB_VT_BACK" + RB_TTY="" + RB_VT_BACK="" + return 1 + fi 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 @@ -391,32 +539,98 @@ rb_vt_leave() { stty "$RB_STTY" < "$RB_TTY" 2>/dev/null fi if [ -n "$RB_VT_BACK" ]; then - $ESUDO chvt "$RB_VT_BACK" >> "$RB_LOG" 2>&1 + rb_chvt "$RB_VT_BACK" 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. +# --- Controller and Start + Select ---------------------------------------- + +# Start + Select is the mapper's kill mode: gptokeyb runs pkill -f on its +# target, then pkill -9 3 s later, and gptokeyb2 -X runs kill -9 at once. +# Either would reach the interface's engine too, whose command line carries +# the installer's path, and SIGKILL leaves no time to say what an install +# wrote. So the target is a sentinel, a process this launcher owns whose +# command line alone carries the token: when it dies, the launcher asks the +# interface to stop, and the interface gives a running install its 2 s. +rb_stop_start() { + RB_STOP_TOKEN="retrobios-stop-${$}x" + bash -c 'exec {f}<> <(:); while :; do read -r -t 3600 -u "$f" _; done' \ + "$RB_STOP_TOKEN" > /dev/null 2>&1 & + RB_STOP_PID=$! +} + +rb_stop_requested() { + [ -n "$RB_STOP_PID" ] && ! rb_alive "$RB_STOP_PID" +} + +rb_stop_end() { + [ -n "$RB_STOP_PID" ] || return 0 + kill "$RB_STOP_PID" >/dev/null 2>&1 + RB_STOP_PID="" +} + +# The mapper, checked half a second after it starts: where it is the only +# input, a mapper that exits at once leaves an interface nothing can move. rb_mapper_start() { [ -n "${RB_MAPPER:-}" ] || return 0 + [ -n "$RB_MAPPER_PID" ] && return 0 + rb_stop_start # 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 "$RB_STOP_TOKEN" -c "$RB_GPTK" >> "$RB_LOG" 2>&1 & RB_MAPPER_PID=$! + rb_nap 0.5 + if ! rb_alive "$RB_MAPPER_PID"; then + rb_say "the controller mapper ($RB_MAPPER) stopped as soon as it started" + fi +} + +# The mapper was started, is needed, and is no longer running. +rb_mapper_gone() { + [ -n "${RB_MAPPER:-}" ] && [ -n "$RB_MAPPER_PID" ] && [ "$RB_MAPPER_NEEDED" != "0" ] \ + && ! rb_alive "$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 + if [ -n "$RB_MAPPER_PID" ]; then + $ESUDO kill "$RB_MAPPER_PID" >/dev/null 2>&1 + wait "$RB_MAPPER_PID" 2>/dev/null + fi RB_MAPPER_PID="" + rb_stop_end } -# A screen of text on the current terminal, closed by any button. -rb_message() { +rb_mapper_text() { + printf '%s\n' "The controller cannot be read: gptokeyb, which turns the buttons into keys, stopped as soon as it started. What it said is in $RB_LOG. $RB_PC_ROUTE" +} + +# --- Messages ------------------------------------------------------------- + +# One key into RB_KEY. False when none came: the wait ran out, Start + Select +# was pressed, or the mapper is gone and only a keyboard is left, which gets +# RB_DEAD_WAIT seconds. +rb_read_key() { + local limit="${RB_MESSAGE_WAIT:-300}" waited=0 code + RB_KEY="" + while [ "$waited" -lt "$limit" ]; do + rb_stop_requested && return 1 + if rb_mapper_gone && [ "$limit" -gt "$RB_DEAD_WAIT" ]; then + limit="$RB_DEAD_WAIT" + [ "$waited" -lt "$limit" ] || return 1 + fi + IFS= read -r -s -n 1 -t 1 RB_KEY + code=$? + [ "$code" = 0 ] && return 0 + # The end of the input, not a timeout. + [ "$code" -gt 128 ] || return 1 + waited=$((waited + 1)) + done + return 1 +} + +rb_screen() { local cols cols=$(stty size 2>/dev/null | awk '{print $2}') case "$cols" in @@ -426,53 +640,116 @@ rb_message() { 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 '\n%s\n' "$2" +} + +# A screen of text on the current terminal, closed by any button. +rb_message() { + rb_screen "$1" "Press A or Enter to close." + rb_read_key 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 + rb_screen "$1" "A or Enter: yes. B or Esc: no." + if ! rb_read_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" = " " ] + [ -z "$RB_KEY" ] || [ "$RB_KEY" = " " ] } # --- The run -------------------------------------------------------------- +# The interface itself, watched: Start + Select or a mapper gone (nothing +# moves it any more) asks it to stop with SIGTERM, then SIGKILL after +# RB_STOP_GRACE seconds. Its stdin is named, since a background command in a +# script reads /dev/null otherwise. +rb_interface() { + local status naps=0 + rb_pycmd + # A Ctrl-C typed while the terminal still sends signals reaches the + # interface on its own: this shell keeps watching. + trap ':' INT + "${RB_PYCMD[@]}" "$RB_INSTALL" --tui "$@" <&0 2> "$RB_STATE/tui.err" & + RB_UI_PID=$! + while rb_alive "$RB_UI_PID"; do + if [ -z "$RB_STOPPED" ]; then + if rb_stop_requested; then + RB_STOPPED="start_select" + rb_say "Start + Select: the interface is asked to stop" + elif rb_mapper_gone; then + RB_STOPPED="mapper" + rb_say "the controller mapper stopped: the interface is asked to stop" + fi + [ -n "$RB_STOPPED" ] && kill -TERM "$RB_UI_PID" 2>/dev/null + else + naps=$((naps + 1)) + if [ "$naps" -ge $((RB_STOP_GRACE * 4)) ]; then + rb_say "the interface did not stop within $RB_STOP_GRACE s" + kill -KILL "$RB_UI_PID" 2>/dev/null + rb_kill_engines + fi + fi + rb_nap 0.25 + done + wait "$RB_UI_PID" + status=$? + trap - INT + RB_UI_PID="" + return "$status" +} + +# An engine the interface started survives it when the interface is killed: +# it runs in its own session. +rb_kill_engines() { + command -v pkill >/dev/null 2>&1 || return 0 + pkill -KILL -f "$RB_INSTALL" >/dev/null 2>&1 +} + +rb_ui_stop() { + local naps=0 + rb_alive "$RB_UI_PID" || return 0 + kill -TERM "$RB_UI_PID" 2>/dev/null + while rb_alive "$RB_UI_PID" && [ "$naps" -lt $((RB_STOP_GRACE * 4)) ]; do + rb_nap 0.25 + naps=$((naps + 1)) + done + if rb_alive "$RB_UI_PID"; then + kill -KILL "$RB_UI_PID" 2>/dev/null + rb_kill_engines + fi + RB_UI_PID="" +} + # 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=() + local status pack args=() other mkdir -p "$RB_STATE" rm -f "$RB_STATE/status" case "${TERM:-}" in ''|dumb|unknown) export TERM=xterm ;; esac + if rb_mapper_gone; then + rb_message "$(rb_mapper_text)" + printf '1\n' > "$RB_STATE/status" + return 1 + fi if [ -z "$RB_PYTHON" ]; then rb_message "$(rb_python_text)" printf '1\n' > "$RB_STATE/status" return 1 fi - local other + printf '\033[2J\033[HRetroBIOS\n\nChecking the connection...\n' if ! rb_online; then + printf 'No connection. Looking for the BIOS folder and a pack on the card...\n' # Detection also names the BIOS folder the other card must not be. rb_detect other=$(rb_other_card) @@ -480,6 +757,10 @@ rb_here() { args=(--from "$other") fi pack=$(rb_find_pack) + if rb_stop_requested; then + printf '143\n' > "$RB_STATE/status" + return 143 + fi if [ -z "$pack" ]; then if [ "${#args[@]}" -gt 0 ]; then # --from needs the list of files, which comes from the network or a pack. @@ -495,7 +776,7 @@ rb_here() { args+=(--platform "$RB_PLATFORM") fi fi - rb_py "$RB_INSTALL" --tui "${args[@]}" "$@" 2> "$RB_STATE/tui.err" + rb_interface "${args[@]}" "$@" status=$? cat "$RB_STATE/tui.err" >> "$RB_LOG" 2>/dev/null if [ "$status" = "2" ] && grep -q -- "--tui cannot open" "$RB_STATE/tui.err"; then @@ -503,59 +784,117 @@ rb_here() { if [ "${RB_FALLBACK:-0}" != "1" ]; then rb_message "$(tail -n 1 "$RB_STATE/tui.err") The messages are in $RB_LOG." fi + elif [ -n "$RB_STOPPED" ] || [ "$status" -ge 128 ]; then + # Ended by Start + Select or a signal: the player asked to leave, and no + # button may be left to close a message. + : elif [ "$status" != "0" ] && [ -s "$RB_STATE/tui.err" ]; then - rb_message "$(tail -n 1 "$RB_STATE/tui.err") The messages are in $RB_LOG." + # The first line of the farewell: the error screen it closes was read. + rb_message "$(head -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_ui_stop + if rb_alive "$RB_APP_PID"; then + kill -TERM "$RB_APP_PID" 2>/dev/null + wait "$RB_APP_PID" 2>/dev/null + fi + RB_APP_PID="" 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 +# One route: sets RB_ROUTE_FAILED when it could not show the interface, so +# the next route is tried. +rb_route_run() { + local status code + RB_ROUTE_FAILED="" 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) + rm -f "$RB_STATE/status" + "${RB_TERMAPP[@]}" bash "$RB_LIB" here "$@" 2>> "$RB_LOG" & + RB_APP_PID=$! + wait "$RB_APP_PID" + code=$? + RB_APP_PID="" + if [ -f "$RB_STATE/status" ]; then + status=$(cat "$RB_STATE/status") + else + # foot or xterm refused to open (no display, no XAUTHORITY): the + # interface never ran. + rb_say "${RB_TERMAPP[0]} ended with status $code before the interface ran" + RB_ROUTE_FAILED=1 + return 1 + fi ;; vt) - rb_vt_enter + if ! rb_vt_enter; then + RB_ROUTE_FAILED=1 + return 1 + fi 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=$? + rb_vt_leave ;; esac + return "${status:-1}" +} + +# 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="" route + RB_STOPPED="" + if [ -n "$RB_STATE_PROBLEM" ]; then + printf '%s\n' "$RB_STATE_PROBLEM" >&2 + if rb_is_console "$RB_CONSOLE"; then + printf '\r\n%s\r\n' "$RB_STATE_PROBLEM" > "$RB_CONSOLE" 2>/dev/null + fi + return 1 + fi + mkdir -p "$RB_STATE" + export RB_DIR RB_INSTALL RB_EVENTS RB_STATE RB_LOG RB_PACK_PATH RB_FALLBACK + export RB_OTHER_CARDS RB_BIOS_DEST RB_STOP_GRACE RB_DEAD_WAIT RB_DETECT_LIMIT + export RB_PYTHON RB_PYTHONHOME RB_PYTHON_PROBLEM RB_MESSAGE_WAIT RB_ROUTE_TABLE + trap 'rb_cleanup; exit 143' TERM HUP + for route in $RB_ROUTES; do + rb_route_ready "$route" || continue + rb_mapper_start + export RB_MAPPER RB_MAPPER_PID RB_MAPPER_NEEDED RB_STOP_PID + RB_ROUTE="$route" + rb_say "showing the interface by the $route route" + rb_route_run "$@" + status=$? + [ -z "$RB_ROUTE_FAILED" ] && break + status="" + done rb_cleanup trap - TERM HUP - return "${status:-1}" + if [ -z "$status" ]; then + RB_ROUTE="none" + rb_say "no terminal to show the interface on (routes: $RB_ROUTES)" + return 125 + fi + return "$status" } if [ "${BASH_SOURCE[0]}" = "$0" ]; then case "${1:-}" in here) shift + # The terminal app closed or the launcher signalled: the interface + # gets its time to stop. + trap 'rb_ui_stop; exit 143' TERM HUP rb_here "$@" exit $? ;; diff --git a/ports/tools/README.md b/ports/tools/README.md index 394a14cb..09dc1720 100644 --- a/ports/tools/README.md +++ b/ports/tools/README.md @@ -13,7 +13,8 @@ the missing ones: downloaded with a network connection, or from a pack copied into the `retrobios` folder without one. Controls: D-pad to move, A or Start to choose, B or Select to go back, X to -tick a system, Y for the options, L1 and R1 to page, Start + Select to quit. +tick a system, Y for the options, L1 and R1 to page. To quit, hold Select, then press Start: Start pressed +first would also choose the line in focus. ArkOS has Python 3.7, older than the installer needs: RetroBIOS uses the Python runtime the RetroBIOS PortMaster port downloads, so start that port diff --git a/ports/tools/RetroBIOS.sh b/ports/tools/RetroBIOS.sh index 01682e04..ef197e94 100755 --- a/ports/tools/RetroBIOS.sh +++ b/ports/tools/RetroBIOS.sh @@ -15,6 +15,8 @@ ROOT="${RB_ROOT:-}" RB_DIR="$HERE/retrobios" RB_LOG="$RB_DIR/log.txt" ESUDO="" +MAPPER_SUDO="" +kill_flag="-1" OS_NAME="" if [ -f "$ROOT/etc/os-release" ]; then OS_NAME=$(awk -F= '$1 == "NAME" { gsub(/"/, "", $2); print $2 }' "$ROOT/etc/os-release") @@ -49,6 +51,10 @@ if [ -d "$ROOT/opt/system" ] && [ -d "$ROOT/opt/inttools" ]; then mapper=$(rb_gptokeyb "$ROOT/opt/inttools/gptokeyb" "$controlfolder/gptokeyb") [ -f "$ROOT/opt/inttools/gamecontrollerdb.txt" ] \ && export SDL_GAMECONTROLLERCONFIG_FILE="$ROOT/opt/inttools/gamecontrollerdb.txt" + # sudo's env_reset drops the controller list exported above, and a pad + # SDL does not know then sends nothing: PortMaster keeps it the same way + # on ArkOS (control.txt, ESUDO). + [ -n "$ESUDO" ] && MAPPER_SUDO="sudo --preserve-env=SDL_GAMECONTROLLERCONFIG_FILE" elif [ "$OS_NAME" = "AmberELEC" ]; then RB_ROUTES="vt" RB_CONSOLE="${RB_CONSOLE:-/dev/console}" @@ -65,15 +71,29 @@ else RB_PACK_PATH="$RB_DIR:$HERE:/storage/roms" controlfolder="/storage/roms/ports/PortMaster" mapper=$(rb_gptokeyb "$ROOT/usr/bin/gptokeyb" "$controlfolder/gptokeyb") + # ROCKNIX's own tools load the pad's controller list first (fex-emu's + # Start FEXConfig.sh: control-gen_init.sh, control.ini, get_controls), and + # start the GPTOKEYB it names. control.ini also sets ESUDO and + # controlfolder, which this launcher keeps as they were. + control="$ROOT/storage/.config/gptokeyb/control.ini" + command -v control-gen_init.sh >/dev/null 2>&1 && control-gen_init.sh >/dev/null 2>&1 + if [ -f "$control" ]; then + saved_esudo="$ESUDO" saved_folder="$controlfolder" + # shellcheck source=/dev/null + source "$control" + declare -F get_controls >/dev/null && get_controls + # It carries its own kill flag (-sudokill). + [ -n "${GPTOKEYB:-}" ] && mapper="$GPTOKEYB" kill_flag="" + ESUDO="$saved_esudo" controlfolder="$saved_folder" + fi fi -# Kill mode on the installer's own path: Start + Select closes the -# interface, never another program. +# Kill mode: Start + Select closes the interface, never another program. RB_MAPPER="" -[ -n "$mapper" ] && RB_MAPPER="$ESUDO $mapper -1" +[ -n "$mapper" ] && RB_MAPPER="${MAPPER_SUDO:-$ESUDO} $mapper $kill_flag" # shellcheck source=../shared/tui.txt source "$RB_DIR/tui.txt" -: > "$RB_LOG" +rb_open_log fresh rb_select_python # A launcher's own arguments are not install.py's (muOS passes the app folder). # shellcheck disable=SC2119 diff --git a/scripts/build_port.py b/scripts/build_port.py index ca2894d9..db703544 100644 --- a/scripts/build_port.py +++ b/scripts/build_port.py @@ -103,7 +103,6 @@ def archive_members(name: str, root: Path = REPO_ROOT) -> list[tuple[str, Path]] source = ports / "muos" members = [ ("RetroBIOS/mux_launch.sh", source / "mux_launch.sh"), - ("RetroBIOS/muxterm.gptk", source / "muxterm.gptk"), ("RetroBIOS/README.md", source / "README.md"), *_engine("RetroBIOS", root), ] diff --git a/tests/test_handheld_launchers.py b/tests/test_handheld_launchers.py index d1077fe4..a387dc04 100644 --- a/tests/test_handheld_launchers.py +++ b/tests/test_handheld_launchers.py @@ -23,6 +23,7 @@ import subprocess import sys import tempfile import termios +import time import unittest import zipfile from pathlib import Path @@ -163,16 +164,22 @@ class ControllerMappings(unittest.TestCase): self.assertEqual(actions[f"joystick1{stick}"]["target"], [f"KEY_{stick.upper()}"]) self.assertEqual(len(actions), len(data["actions_player1"])) - def test_muxterm_mapping_adds_only_what_muxterm_leaves(self): - """muxterm writes the D-pad, Start, B and Y to the program itself - (MustardOS/terminal input.c, osk.c): mapping them again would press - each twice.""" - text = (PORTS / "muos" / "muxterm.gptk").read_text() - self.assertIn("[controls]\noverlay = clear\n", text) - mapping = read_gptk(PORTS / "muos" / "muxterm.gptk") - mapping.pop("overlay") - self.assertEqual(mapping, {"a": ["enter"], "x": ["space"], - "l1": ["pageup"], "r1": ["pagedown"]}) + def test_x_ticks_and_y_opens_the_options_on_every_route(self): + """The interface reads Space as Tick and Tab as Options: a mapping + that sends Y as Space activates the focused row instead.""" + self.assertEqual(self._decodes("space"), "char") + self.assertEqual(self._decodes("tab"), "tab") + gptk = read_gptk(PORTS / "shared" / "retrobios.gptk") + self.assertEqual((gptk["x"], gptk["y"]), (["space"], ["tab"])) + keys = json.loads((PORTS / "batocera" / "RetroBIOS.sh.keys").read_text()) + targets = {a["trigger"]: a["target"] for a in keys["actions_player1"]} + self.assertEqual((targets["x"], targets["y"]), (["KEY_SPACE"], ["KEY_TAB"])) + # muxterm sends Y as Space on its own (MustardOS/terminal input.c): + # where gptokeyb runs beside it, muxterm is told to leave the pad. + for launcher in (PORTS / "muos" / "mux_launch.sh", PORTS / "RetroBIOS" / "RetroBIOS.sh"): + text = launcher.read_text() + self.assertIn("SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT=0x0001/0x0001", text) + self.assertFalse((PORTS / "muos" / "muxterm.gptk").exists()) class TheArchives(unittest.TestCase): @@ -211,7 +218,6 @@ class TheArchives(unittest.TestCase): self.assertIn(f"{folder}/retrobios.gptk", names) else: self.assertEqual(top, []) - self.assertIn("RetroBIOS/muxterm.gptk", names) self.assertIn("RetroBIOS/retrobios.gptk", names) def test_each_carries_the_pinned_installer(self): @@ -247,8 +253,9 @@ class TheArchives(unittest.TestCase): FAKE_INSTALL = r''' -import json, os, sys +import json, os, signal, subprocess, sys, time calls = os.environ["FAKE_CALLS"] +state = os.environ["FAKE_STATE"] def tty(fd): @@ -258,11 +265,36 @@ def tty(fd): return "" +def note(name, text=""): + with open(os.path.join(state, name), "a") as fh: + fh.write(text + "\n") + + +if "--engine" in sys.argv: + # The engine the interface starts in its own session: it writes its + # summary on SIGINT, as install.py does, and records any other signal. + def summary(signum, _frame): + note("engine.log", "summary after SIGINT") + sys.exit(130) + + def other(signum, _frame): + note("engine.log", f"signal {signum}") + sys.exit(128 + signum) + + signal.signal(signal.SIGINT, summary) + signal.signal(signal.SIGTERM, other) + note("engine.ready") + time.sleep(60) + sys.exit(0) + with open(calls, "a") as fh: fh.write(json.dumps({"argv0": sys.argv[0], "args": sys.argv[1:], "stdin": tty(0), "stdout": tty(1), "term": os.environ.get("TERM", ""), - "pythonhome": os.environ.get("PYTHONHOME", "")}) + "\n") + "pythonhome": os.environ.get("PYTHONHOME", ""), + "base_url": os.environ.get("RETROBIOS_BASE_URL", "")}) + "\n") if "--check" in sys.argv: + if os.environ.get("FAKE_DETECT_HANG"): + time.sleep(60) print(json.dumps({"event": "detect", "os": "linux", "os_name": "Fake OS", "platforms": [{"platform": "retroarch", "name": "RetroArch", "bios_dir": "/roms/bios"}]})) @@ -277,37 +309,153 @@ if os.environ.get("FAKE_REFUSE"): if os.environ.get("FAKE_RAW") and os.isatty(0): import tty as _tty _tty.setraw(0) +if os.environ.get("FAKE_HOLD"): + # The interface up and waiting for a key until a signal ends it. With + # FAKE_ENGINE an install runs, and SIGTERM gives it 2 s to say what it + # wrote, as install.py's run_tui does. FAKE_NO_HANDLER is a signal that + # comes before run_tui installs its handlers. + engine = None + if os.environ.get("FAKE_ENGINE"): + engine = subprocess.Popen([sys.executable, sys.argv[0], "--events", "--engine"], + start_new_session=True) + while not os.path.exists(os.path.join(state, "engine.ready")): + time.sleep(0.05) + + def stop(signum, _frame): + note("ui.log", f"signal {signum}") + if engine is not None: + engine.send_signal(signal.SIGINT) + try: + engine.wait(2) + except subprocess.TimeoutExpired: + engine.kill() + print("RetroBIOS: the install was stopped, 2 files written.") + if os.environ.get("FAKE_TERM_ERROR"): + print("Error: " + os.environ["FAKE_TERM_ERROR"], file=sys.stderr) + sys.exit(128 + signum) + + if not os.environ.get("FAKE_NO_HANDLER"): + signal.signal(signal.SIGTERM, stop) + signal.signal(signal.SIGHUP, stop) + note("ui.ready") + time.sleep(60) + sys.exit(0) if os.environ.get("FAKE_ERROR"): print("Error: " + os.environ["FAKE_ERROR"], file=sys.stderr) sys.exit(int(os.environ.get("FAKE_CODE", "0"))) ''' -# Records its arguments and stays up until it is told to stop, as gptokeyb -# does, and records that it was. -FAKE_MAPPER = '''#!/bin/sh +# The real pgrep and pkill: Device puts a pgrep on PATH that answers for the +# compositor check. +PGREP = shutil.which("pgrep", path="/usr/bin:/bin") or "pgrep" +PKILL = shutil.which("pkill", path="/usr/bin:/bin") or "pkill" + +# gptokeyb as PortsMaster/gptokeyb runs it: kill mode takes the argument +# after the flag (`-1`, `-k` or `-sudokill`) as its target. Start + Select (FAKE_PRESS, once the +# interface is up) is doKillMode in src/util.cpp: pkill -f on the target, +# which also matches gptokeyb's own command line (SDL turns that SIGTERM +# into an event it does not read there), 3 s, then pkill -9 -f and exit. +# Stopped otherwise by a SIGTERM, which it records. FAKE_MAPPER_DIES is a +# mapper that cannot start (no /dev/uinput). +FAKE_MAPPER = f'''#!/bin/bash printf '%s\\n' "$@" > "$FAKE_STATE/mapper.args" echo $$ > "$FAKE_STATE/mapper.pid" -trap 'echo stopped > "$FAKE_STATE/mapper.stopped"; kill $child 2>/dev/null; exit 0' TERM -sleep 30 & -child=$! -wait $child +printf '%s\\n' "${{SDL_GAMECONTROLLERCONFIG_FILE:-}}" > "$FAKE_STATE/mapper.db" +if [ -n "${{FAKE_MAPPER_DIES:-}}" ]; then + echo "gptokeyb: cannot open /dev/uinput" >&2 + exit 1 +fi +name="" +while [ $# -gt 0 ]; do + case "$1" in + -1|-k|-sudokill) name="$2"; shift 2 ;; + -c) shift 2 ;; + *) shift ;; + esac +done +killing="" +trap '[ -n "$killing" ] || {{ echo stopped > "$FAKE_STATE/mapper.stopped"; exit 0; }}' TERM +until [ -n "${{FAKE_PRESS:-}}" ] && [ -e "$FAKE_STATE/ui.ready" ]; do sleep 0.1; done +killing=1 +echo "kill $name" >> "$FAKE_STATE/mapper.log" +{PKILL} -f "$name" +sleep 3 +if {PGREP} -f "$name" > /dev/null; then {PKILL} -9 -f "$name"; fi +exit 0 ''' -FAKE_RECORDER = '''#!/bin/sh +# gptokeyb2 as PortsMaster/gptokeyb2 src/util.c kills: SIGKILL only, by +# process name (pkill -9 NAME) unless -X, then pgrep -f and kill -9 on every +# match but its own ancestry and children. It quits after a kill, and keeps +# running when it has no process name. +FAKE_GPTOKEYB2 = f'''#!/bin/bash +printf '%s\\n' "$@" > "$FAKE_STATE/mapper.args" +echo $$ > "$FAKE_STATE/mapper.pid" +want_kill="" name="" +while [ $# -gt 0 ]; do + case "$1" in + -X) want_kill=1; shift ;; + -c) shift 2 ;; + -*) shift ;; + *) [ -z "$name" ] && name="$1"; shift ;; + esac +done +trap 'echo stopped > "$FAKE_STATE/mapper.stopped"; exit 0' TERM +until [ -n "${{FAKE_PRESS:-}}" ] && [ -e "$FAKE_STATE/ui.ready" ]; do sleep 0.1; done +echo "kill $name" >> "$FAKE_STATE/mapper.log" +if [ -z "$name" ]; then + while :; do sleep 0.1; done +fi +if [ -n "$want_kill" ]; then + ours=" $$ " + p=$$ + while [ "$p" -gt 1 ]; do + p=$(awk '{{print $4}}' "/proc/$p/stat" 2>/dev/null) + [ -n "$p" ] || break + ours="$ours$p " + done + for pid in $({PGREP} -f "$name"); do + case "$ours" in *" $pid "*) continue ;; esac + [ "$(awk '{{print $4}}' "/proc/$pid/stat" 2>/dev/null)" = "$$" ] && continue + kill -9 "$pid" 2>/dev/null + done +else + {PKILL} -9 "$name" +fi +exit 0 +''' + +FAKE_RECORDER = """#!/bin/sh printf '%s\\n' "$(basename "$0") $*" >> "$FAKE_STATE/tools.log" -''' +""" -FAKE_SETFONT = '''#!/bin/sh +# chvt brings the console to the front, so RB_VT_ACTIVE then names it. A +# compositor that keeps the display (FAKE_CHVT_REFUSED) leaves chvt waiting +# in VT_WAITACTIVE and the console in front unchanged. +FAKE_CHVT = """#!/bin/sh +printf '%s\\n' "chvt $*" >> "$FAKE_STATE/tools.log" +[ -n "$FAKE_CHVT_REFUSED" ] && exec sleep 30 +[ -n "$RB_VT_ACTIVE" ] && printf 'tty%s\\n' "$1" > "$RB_VT_ACTIVE" +exit 0 +""" + +FAKE_SETFONT = """#!/bin/sh printf '%s\\n' "setfont $*" >> "$FAKE_STATE/tools.log" while [ $# -gt 0 ]; do [ "$1" = "-O" ] && { [ -n "$FAKE_NO_SAVE" ] && exit 1; printf 'font' > "$2"; } shift done -''' +""" -# A terminal app: records how it was started, then runs its command. -FAKE_TERMAPP = '''#!/bin/sh +# A terminal app: records how it was started and the SDL hint it was given, +# then runs its command. FAKE_TERM_FAILS is one that cannot open its window. +FAKE_TERMAPP = """#!/bin/sh printf '%s\\n' "$(basename "$0") $*" >> "$FAKE_STATE/tools.log" +printf '%s\\n' "${SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT:-}" > "$FAKE_STATE/termapp.sdl" +if [ -n "$FAKE_TERM_FAILS" ]; then + echo "$(basename "$0"): Can't open display" >&2 + exit 1 +fi while [ $# -gt 0 ]; do case "$1" in --|-e) shift; break ;; @@ -315,7 +463,26 @@ while [ $# -gt 0 ]; do esac done exec "$@" -''' +""" + +# sudo with env_reset: the environment is dropped but for the variables +# --preserve-env names. +FAKE_SUDO = """#!/bin/bash +printf '%s\\n' "sudo $*" >> "$FAKE_STATE/tools.log" +keep="" +while [ $# -gt 0 ]; do + case "$1" in + --preserve-env=*) keep="${1#--preserve-env=}"; shift ;; + -*) shift ;; + *) break ;; + esac +done +case ",$keep," in + *,SDL_GAMECONTROLLERCONFIG_FILE,*) ;; + *) unset SDL_GAMECONTROLLERCONFIG_FILE ;; +esac +exec "$@" +""" class Device: @@ -341,7 +508,7 @@ class Device: (self.bin / "python3").symlink_to(shutil.which("python3.8") or sys.executable) self.tool("gptokeyb", FAKE_MAPPER) self.tool("setfont", FAKE_SETFONT) - self.tool("chvt", FAKE_RECORDER) + self.tool("chvt", FAKE_CHVT) # A probe that cannot connect is offline (curl exit 7). self.tool("curl", '#!/bin/sh\nexit "${FAKE_CURL:-0}"\n') self.tool("pgrep", '#!/bin/sh\nexit "${FAKE_PGREP:-1}"\n') @@ -352,11 +519,13 @@ class Device: (fonts / f"{name}.psf.gz").write_bytes(b"") (self.root / "fb").write_text("640,480\n") environ = {k: v for k, v in os.environ.items() - if k not in ("DISPLAY", "WAYLAND_DISPLAY", "RETROBIOS_BASE_URL")} + if k not in ("DISPLAY", "WAYLAND_DISPLAY", "RETROBIOS_BASE_URL", + "XAUTHORITY", "XDG_RUNTIME_DIR")} self.env = { **environ, "PATH": f"{self.bin}{os.pathsep}{os.environ['PATH']}", "HOME": str(self.root), + "XDG_RUNTIME_DIR": str(self.root / "runtime"), "FAKE_STATE": str(self.state), "FAKE_CALLS": str(self.state / "calls.jsonl"), "RB_FONTS": str(fonts), @@ -365,6 +534,7 @@ class Device: "TERM": "dumb", **env, } + test.addCleanup(self._reap) def tool(self, name: str, text: str) -> Path: path = self.bin / name @@ -397,11 +567,25 @@ class Device: os.write(self.master, keys.encode()) stdin = self.slave if stdio_tty else subprocess.DEVNULL stdout = self.slave if stdio_tty else subprocess.PIPE + start = time.monotonic() proc = subprocess.run(argv, env={**self.env, **env}, stdin=stdin, stdout=stdout, stderr=subprocess.PIPE, timeout=120, check=False) + self.elapsed = time.monotonic() - start self.screen = self._drain() return proc + def spawn(self, argv: list[str], **env: str) -> subprocess.Popen: + proc = subprocess.Popen(argv, env={**self.env, **env}, stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + self.test.addCleanup(lambda: proc.poll() is None and proc.kill()) + return proc + + def wait_for(self, name: str, timeout: float = 20) -> None: + end = time.monotonic() + timeout + while not (self.state / name).exists(): + self.test.assertLess(time.monotonic(), end, f"{name} never appeared") + time.sleep(0.05) + def _drain(self) -> str: chunks = [] os.set_blocking(self.master, False) @@ -415,6 +599,16 @@ class Device: chunks.append(chunk) return b"".join(chunks).decode("utf-8", "replace") + def _reap(self) -> None: + """A fake the launcher failed to stop must not outlive the test.""" + for name in ("mapper.pid",): + path = self.state / name + if path.exists(): + try: + os.kill(int(path.read_text()), signal.SIGKILL) + except (ProcessLookupError, ValueError): + pass + def calls(self) -> list[dict]: path = self.state / "calls.jsonl" if not path.exists(): @@ -424,13 +618,15 @@ class Device: def tui_calls(self) -> list[dict]: return [c for c in self.calls() if "--tui" in c["args"]] - def tools(self) -> list[str]: - path = self.state / "tools.log" + def lines(self, name: str) -> list[str]: + path = self.state / name return path.read_text().splitlines() if path.exists() else [] + def tools(self) -> list[str]: + return self.lines("tools.log") + def mapper_args(self) -> list[str]: - path = self.state / "mapper.args" - return path.read_text().splitlines() if path.exists() else [] + return self.lines("mapper.args") def assert_mapper_stopped(self) -> None: """The launcher stopped gptokeyb itself, before it exited.""" @@ -443,6 +639,14 @@ class Device: pass self.test.assertTrue(stopped, "gptokeyb was left running") + def assert_mapper_gone(self) -> None: + """gptokeyb ended, by its own kill mode or by the launcher.""" + pid = int((self.state / "mapper.pid").read_text()) + end = time.monotonic() + 5 + while Path(f"/proc/{pid}").exists() and time.monotonic() < end: + time.sleep(0.05) + self.test.assertFalse(Path(f"/proc/{pid}").exists(), "gptokeyb was left running") + def assert_console_restored(self) -> None: self.test.assertEqual(termios.tcgetattr(self.slave), self.before) @@ -465,29 +669,32 @@ PortMasterDialogInit() { trace "INIT $*"; } PortMasterDialogExit() { trace EXIT; } PortMasterDialog() { trace "CMD $*"; } PortMasterDialogResult() { trace "ASK $*"; echo quit; } +pm_finish() { trace FINISH; } ''' class ThePortMasterPort(unittest.TestCase): """RetroBIOS.sh from the PortMaster archive, on a fake PortMaster.""" - def device(self, **env: str) -> Device: + def device(self, console: str | None = None, **env: str) -> Device: device = Device(self, **env) roms = device.root / "roms" self.gamedir = device.unpack(build_port.PORT_ZIP, roms / "ports") / "retrobios" control = device.root / "xdg" / "PortMaster" control.mkdir(parents=True) + self.control = control (control / "control.txt").write_text( - CONTROL_TXT.format(roms=str(roms).lstrip("/"), console=device.console)) + CONTROL_TXT.format(roms=str(roms).lstrip("/"), console=console or device.console)) (control / "PortMasterDialog.txt").write_text(DIALOG_TXT) shutil.copy(device.bin / "gptokeyb", control / "gptokeyb") device.env["XDG_DATA_HOME"] = str(device.root / "xdg") + # No muxterm on this device unless a test puts one there. + device.env["RB_MUXTERM"] = str(device.root / "no-muxterm") self.script = str(roms / "ports" / "RetroBIOS.sh") return device def dialog(self, device: Device) -> list[str]: - path = device.state / "dialog.log" - return path.read_text().splitlines() if path.exists() else [] + return device.lines("dialog.log") def test_online_on_the_console(self): device = self.device() @@ -497,22 +704,28 @@ class ThePortMasterPort(unittest.TestCase): self.assertEqual(call["args"], ["--tui"]) self.assertEqual((call["stdin"], call["stdout"]), (device.console, device.console)) self.assertEqual(call["term"], "linux") - # Kill mode on the installer's own path, which the interface's command - # line carries: never python3. installer = str(self.gamedir / "install.py") - self.assertEqual(device.mapper_args(), - ["-1", installer, "-c", str(self.gamedir / "retrobios.gptk")]) self.assertEqual(call["argv0"], installer) + # Kill mode on the Start + Select sentinel, never on the installer's + # path, which the engine's command line carries too. + args = device.mapper_args() + self.assertEqual(args[0], "-1") + self.assertRegex(args[1], r"^retrobios-stop-\d+x$") + self.assertEqual(args[2:], ["-c", str(self.gamedir / "retrobios.gptk")]) device.assert_mapper_stopped() device.assert_console_restored() - self.assertIn("\x1bc", device.screen) + # Reset when the console is taken and again when it is handed back. + self.assertEqual(device.screen.count("\x1bc"), 2) + self.assertLess(device.screen.index("Checking the connection"), + device.screen.rindex("\x1bc")) saved = str(self.gamedir / "run" / "font.psf") fonts = device.env["RB_FONTS"] self.assertEqual(device.tools(), [ f"setfont -C {device.console} -O {saved} {fonts}/Lat7-TerminusBold22x11.psf.gz", f"setfont -C {device.console} {saved}", ]) - self.assertEqual(self.dialog(device), []) + # PortMaster's own end of a port, after the interface, no screens. + self.assertEqual(self.dialog(device), ["FINISH"]) def test_the_interface_status_is_the_port_status(self): device = self.device() @@ -555,6 +768,26 @@ class ThePortMasterPort(unittest.TestCase): device.assert_mapper_stopped() device.assert_console_restored() + def test_offline_says_so_at_once_and_bounds_the_detection(self): + """The file-list download is three tries of 30 s: the detection run + is pointed at a closed loopback port and stopped after + RB_DETECT_LIMIT, with a line on the console from the start.""" + device = self.device(FAKE_CURL="28") + pack = self.gamedir / "RetroArch_Lakka_v1.22.2_BIOS_Pack.zip" + pack.write_bytes(b"") + proc = device.run(["bash", self.script], FAKE_DETECT_HANG="1", RB_DETECT_LIMIT="2") + self.assertEqual(proc.returncode, 0, proc.stderr.decode()) + self.assertLess(device.elapsed, 15) + screen = device.screen + self.assertIn("Checking the connection", screen) + self.assertIn("No connection. Looking for the BIOS folder", screen) + [check] = [c for c in device.calls() if "--check" in c["args"]] + self.assertEqual(check["base_url"], "http://127.0.0.1:9") + [call] = device.tui_calls() + # The detection was stopped: no platform, the first pack. + self.assertEqual(call["args"], ["--tui", "--from-pack", str(pack)]) + self.assertEqual(call["base_url"], "") + def test_a_certificate_failure_is_not_offline(self): # curl 60: the network answered, install.py reports the certificate. device = self.device(FAKE_CURL="60") @@ -574,6 +807,31 @@ class ThePortMasterPort(unittest.TestCase): self.assertIn("--dest", screen) self.assertEqual(device.calls(), []) + def test_the_runtime_is_mounted_then_unmounted(self): + """ArkOS's Python 3.7: PortMaster's python_3.11 runtime, mounted for + the run and unmounted after it.""" + device = self.device() + real = shutil.which("python3.8") or sys.executable + device.tool("python3", '#!/bin/sh\n[ "$1" = "--version" ] && echo "Python 3.7.3"\nexit 1\n') + device.tool("uname", "#!/bin/sh\necho aarch64\n") + # The runtime's python, a stand-in that ignores the PYTHONHOME the + # launcher sets for the real one. + device.tool("mount", '#!/bin/sh\nprintf "mount %s\\n" "$*" >> "$FAKE_STATE/tools.log"\n' + 'mkdir -p "$2/bin"\n' + f'printf \'#!/bin/sh\\nunset PYTHONHOME\\nexec {real} "$@"\\n\' > "$2/bin/python3"\n' + 'chmod +x "$2/bin/python3"\n') + device.tool("umount", FAKE_RECORDER) + (self.control / "libs").mkdir() + squashfs = self.control / "libs" / "python_3.11.squashfs" + squashfs.write_bytes(b"") + proc = device.run(["bash", self.script]) + self.assertEqual(proc.returncode, 0, proc.stderr.decode()) + self.assertEqual(len(device.tui_calls()), 1) + runtime = str(device.root / "python_3.11") + mounts = [t for t in device.tools() if t.startswith(("mount", "umount"))] + self.assertEqual(mounts, [f"umount {runtime}", f"mount {squashfs} {runtime}", + f"umount {runtime}"]) + def test_a_refused_console_falls_back_to_portmaster_screens(self): device = self.device() device.run(["bash", self.script], FAKE_REFUSE="1") @@ -585,6 +843,18 @@ class ThePortMasterPort(unittest.TestCase): device.assert_mapper_stopped() device.assert_console_restored() + def test_a_console_this_user_cannot_open_is_opened_up(self): + """PortMaster's control.txt chmods the consoles only under sudo: the + launcher does it for the one it draws on.""" + device = self.device() + os.chmod(device.console, 0o000) + self.addCleanup(os.chmod, device.console, 0o620) + proc = device.run(["bash", self.script]) + self.assertEqual(proc.returncode, 0, proc.stderr.decode()) + [call] = device.tui_calls() + self.assertEqual(call["stdin"], device.console) + self.assertEqual(os.stat(device.console).st_mode & 0o777, 0o666) + def test_under_a_compositor_without_a_terminal_app_the_screens_open(self): device = self.device(FAKE_PGREP="0") device.run(["bash", self.script]) @@ -605,11 +875,32 @@ class ThePortMasterPort(unittest.TestCase): pack = self.gamedir / "RetroArch_Lakka_v1.22.2_BIOS_Pack.zip" pack.write_bytes(b"") device.run(["bash", self.script], keys="\r", RB_ARK_MARKER=str(marker), - RB_OTHER_CARDS=f"{roms}:{other}") + RB_OTHER_CARDS=f"{roms}:{other}", + RB_RETROARCH_CFG=str(device.root / "none.cfg")) [call] = device.tui_calls() # The port's games card is roms here: its bios is the destination. self.assertEqual(call["args"][:3], ["--tui", "--from", str(other)]) + def test_arkos_destination_is_the_folder_retroarch_reads(self): + """control.txt names roms2 as the port's card whenever a second card + is mounted: RetroArch's system_directory decides.""" + device = self.device(FAKE_CURL="7") + marker = device.root / "text.plymouth" + marker.write_text("") + roms = device.root / "roms" / "bios" + other = device.root / "roms2" / "bios" + roms.mkdir(parents=True) + other.mkdir(parents=True) + (roms / "a.bin").write_bytes(b"x") + (other / "b.bin").write_bytes(b"y") + cfg = device.root / "retroarch.cfg" + cfg.write_text(f'system_directory = "{other}"\n') + (self.gamedir / "RetroArch_Lakka_v1.22.2_BIOS_Pack.zip").write_bytes(b"") + device.run(["bash", self.script], keys="\r", RB_ARK_MARKER=str(marker), + RB_OTHER_CARDS=f"{roms}:{other}", RB_RETROARCH_CFG=str(cfg)) + [call] = device.tui_calls() + self.assertEqual(call["args"][:3], ["--tui", "--from", str(roms)]) + def test_under_a_compositor_foot_shows_the_interface(self): device = self.device(WAYLAND_DISPLAY="wayland-1") device.tool("foot", FAKE_TERMAPP) @@ -618,9 +909,173 @@ class ThePortMasterPort(unittest.TestCase): [call] = device.tui_calls() self.assertEqual(call["args"], ["--tui"]) self.assertEqual(device.tools(), [f"foot -F -- bash {self.gamedir}/tui.txt here"]) - self.assertEqual(device.mapper_args()[1], str(self.gamedir / "install.py")) + self.assertRegex(device.mapper_args()[1], r"^retrobios-stop-\d+x$") device.assert_mapper_stopped() + def test_a_terminal_app_that_cannot_open_hands_over(self): + """foot refused the display: the interface never ran, and the port + opens PortMaster's screens instead of ending on a status of 1.""" + device = self.device(WAYLAND_DISPLAY="wayland-1") + device.tool("foot", FAKE_TERMAPP) + proc = device.run(["bash", self.script], FAKE_TERM_FAILS="1") + self.assertEqual(device.tui_calls(), []) + self.assertEqual(self.dialog(device)[0], "INIT no-check", proc.stderr.decode()) + log = (self.gamedir / "log.txt").read_text() + self.assertIn("foot ended with status 1 before the interface ran", log) + device.assert_mapper_stopped() + + def test_on_muos_the_interface_opens_in_muxterm(self): + """mod_muOS.txt sets CUR_TTY to /dev/null: muxterm shows the + interface, told to leave the pad to gptokeyb.""" + device = self.device(console="/dev/null") + muxterm = device.tool("muxterm", FAKE_TERMAPP) + proc = device.run(["bash", self.script], RB_MUXTERM=str(muxterm)) + self.assertEqual(proc.returncode, 0, proc.stderr.decode()) + [call] = device.tui_calls() + self.assertEqual(call["args"], ["--tui"]) + self.assertEqual(device.tools(), [f"muxterm --gl -- bash {self.gamedir}/tui.txt here"]) + self.assertEqual(device.lines("termapp.sdl"), ["0x0001/0x0001"]) + self.assertEqual(device.mapper_args()[2:], ["-c", str(self.gamedir / "retrobios.gptk")]) + self.assertEqual(self.dialog(device), ["FINISH"]) + + def test_on_muos_without_muxterm_the_log_says_why_the_screens_open(self): + device = self.device(console="/dev/null") + device.run(["bash", self.script]) + self.assertEqual(device.tui_calls(), []) + self.assertEqual(self.dialog(device)[0], "INIT no-check") + log = (self.gamedir / "log.txt").read_text() + self.assertIn("/dev/null is not a console", log) + + def test_start_select_lets_the_install_say_what_it_wrote(self): + """gptokeyb's kill reaches the sentinel only: the interface is asked + to stop, gives the running install its SIGINT and 2 s, and writes + its result. The engine never sees gptokeyb's SIGTERM or SIGKILL.""" + device = self.device() + proc = device.run(["bash", self.script], FAKE_HOLD="1", FAKE_ENGINE="1", + FAKE_PRESS="1", RB_MESSAGE_WAIT="300") + self.assertEqual(proc.returncode, 143, proc.stderr.decode()) + self.assertLess(device.elapsed, 15) + self.assertEqual(device.lines("ui.log"), ["signal 15"]) + self.assertEqual(device.lines("engine.log"), ["summary after SIGINT"]) + self.assertIn("RetroBIOS: the install was stopped, 2 files written.", device.screen) + self.assertNotIn("Press A", device.screen) + device.assert_mapper_gone() + device.assert_console_restored() + self.assertEqual(self.dialog(device), ["FINISH"]) + + def test_start_select_on_an_error_screen_returns_at_once(self): + """The interface leaves an error screen with its farewell on stderr, + and gptokeyb is gone: no message may wait for a button.""" + device = self.device() + proc = device.run(["bash", self.script], FAKE_HOLD="1", FAKE_PRESS="1", + FAKE_TERM_ERROR="could not reach the file list", + RB_MESSAGE_WAIT="300") + self.assertEqual(proc.returncode, 143) + self.assertLess(device.elapsed, 10) + self.assertNotIn("Press A", device.screen) + device.assert_mapper_gone() + device.assert_console_restored() + + def test_start_select_before_the_interface_handles_signals(self): + """A SIGTERM before run_tui installs its handlers ends Python on the + default action, status 143.""" + device = self.device() + proc = device.run(["bash", self.script], FAKE_HOLD="1", FAKE_PRESS="1", + FAKE_NO_HANDLER="1", RB_MESSAGE_WAIT="300") + self.assertEqual(proc.returncode, 143) + self.assertLess(device.elapsed, 10) + self.assertNotIn("Press A", device.screen) + device.assert_console_restored() + + def test_a_mapper_that_dies_at_once_starts_no_interface(self): + device = self.device() + proc = device.run(["bash", self.script], FAKE_MAPPER_DIES="1", RB_MESSAGE_WAIT="300", + RB_DEAD_WAIT="1") + self.assertEqual(proc.returncode, 1) + self.assertLess(device.elapsed, 10) + self.assertEqual(device.tui_calls(), []) + screen = " ".join(device.screen.split()) + self.assertIn("The controller cannot be read", screen) + self.assertIn("log.txt", screen) + log = (self.gamedir / "log.txt").read_text() + self.assertIn("cannot open /dev/uinput", log) + device.assert_console_restored() + + def test_a_mapper_that_dies_later_ends_the_interface(self): + """Nothing moves the interface once gptokeyb is gone: it is asked to + stop instead of waiting for a key forever.""" + device = self.device() + proc = device.spawn(["bash", self.script], FAKE_HOLD="1") + device.wait_for("ui.ready") + os.kill(int((device.state / "mapper.pid").read_text()), signal.SIGKILL) + self.assertEqual(proc.wait(timeout=20), 143) + self.assertEqual(device.lines("ui.log"), ["signal 15"]) + device.assert_console_restored() + + def test_sigterm_to_the_launcher_stops_the_interface_and_restores(self): + """A firmware that signals the launcher alone: the trap stops the + interface, the mapper and puts the console back.""" + device = self.device() + proc = device.spawn(["bash", self.script], FAKE_HOLD="1", FAKE_RAW="1") + device.wait_for("ui.ready") + proc.send_signal(signal.SIGTERM) + self.assertEqual(proc.wait(timeout=20), 143) + self.assertEqual(device.lines("ui.log"), ["signal 15"]) + device.assert_mapper_stopped() + device.assert_console_restored() + + def test_the_font_is_put_back_without_setfont_o(self): + """busybox setfont has no -O: the font is changed only when the + Lat7-Terminus20x10 ArkOS tools restore exists, and that one is put + back.""" + device = self.device(FAKE_NO_SAVE="1") + fonts = device.env["RB_FONTS"] + (Path(fonts) / "Lat7-Terminus20x10.psf.gz").write_bytes(b"") + device.run(["bash", self.script]) + chosen = f"{fonts}/Lat7-TerminusBold22x11.psf.gz" + saved = str(self.gamedir / "run" / "font.psf") + self.assertEqual(device.tools(), [ + f"setfont -C {device.console} -O {saved} {chosen}", + f"setfont -C {device.console} {chosen}", + f"setfont -C {device.console} {fonts}/Lat7-Terminus20x10.psf.gz", + ]) + + def test_without_the_fallback_font_the_font_is_left(self): + device = self.device(FAKE_NO_SAVE="1") + device.run(["bash", self.script]) + self.assertEqual(len([t for t in device.tools() if t.startswith("setfont")]), 1) + + def test_a_read_only_card_runs_from_the_runtime_folder(self): + device = self.device() + self.gamedir.chmod(0o555) + self.addCleanup(self.gamedir.chmod, 0o755) + proc = device.run(["bash", self.script]) + self.assertEqual(proc.returncode, 0, proc.stderr.decode()) + self.assertEqual(len(device.tui_calls()), 1) + spare = device.root / "runtime" / "retrobios" + log = (spare / "log.txt").read_text() + self.assertIn(f"{self.gamedir} cannot be written", log) + self.assertTrue((spare / "run" / "status").exists()) + + def test_nothing_writable_is_said_on_the_console(self): + device = self.device() + self.gamedir.chmod(0o555) + self.addCleanup(self.gamedir.chmod, 0o755) + runtime = device.root / "runtime" + runtime.mkdir(mode=0o555) + self.addCleanup(runtime.chmod, 0o755) + proc = device.run(["bash", self.script]) + self.assertEqual(proc.returncode, 1) + self.assertEqual(device.calls(), []) + self.assertIn("the SD card may be read-only or full", " ".join(device.screen.split())) + + def test_the_previous_log_is_kept(self): + device = self.device() + (self.gamedir / "log.txt").write_text("the failure\n") + device.run(["bash", self.script]) + self.assertEqual((self.gamedir / "log.prev.txt").read_text(), "the failure\n") + self.assertNotIn("the failure", (self.gamedir / "log.txt").read_text()) + class TheBatoceraPort(unittest.TestCase): def device(self, **env: str) -> tuple[Device, Path]: @@ -628,6 +1083,14 @@ class TheBatoceraPort(unittest.TestCase): ports = device.unpack(build_port.BATOCERA_ZIP, device.root / "userdata" / "roms" / "ports") return device, ports + def console_switch(self, device: Device) -> dict[str, str]: + (device.root / "active").write_text("tty2\n") + number = re.fullmatch(r"/dev/pts/(\d+)", device.console) + self.assertIsNotNone(number) + self.number = number.group(1) + return {"RB_VT_ACTIVE": str(device.root / "active"), "RB_VT_PREFIX": "/dev/pts/", + "RB_VT_SWITCH": self.number} + def test_xterm_in_the_x11_session(self): device, ports = self.device(DISPLAY=":0") device.tool("xterm", FAKE_TERMAPP) @@ -640,24 +1103,66 @@ class TheBatoceraPort(unittest.TestCase): # evmapy reads RetroBIOS.sh.keys: no gptokeyb is started. self.assertEqual(device.mapper_args(), []) + def test_the_x_cookie_goes_with_the_display(self): + """S31emulationstation starts X with XAUTHORITY=/var/lib/.Xauthority: + without it xterm is refused the display.""" + device, ports = self.device() + device.tool("xterm", FAKE_TERMAPP + "") + socket_dir = device.root / "x11" + socket_dir.mkdir() + import socket as _socket # noqa: PLC0415 + sock = _socket.socket(_socket.AF_UNIX) + sock.bind(str(socket_dir / "X0")) + self.addCleanup(sock.close) + cookie = device.root / "Xauthority" + cookie.write_text("") + device.tool("xterm", '#!/bin/sh\nprintf "%s %s\\n" "$DISPLAY" "$XAUTHORITY" ' + '> "$FAKE_STATE/x.env"\n' + FAKE_TERMAPP.split("\n", 1)[1]) + proc = device.run(["/bin/bash", str(ports / "RetroBIOS.sh")], + RB_X_SOCKET=str(socket_dir / "X0"), RB_XAUTH=str(cookie)) + self.assertEqual(proc.returncode, 0, proc.stderr.decode()) + self.assertEqual(device.lines("x.env"), [f":0 {cookie}"]) + def test_console_switch_when_no_terminal_app(self): device, ports = self.device(FAKE_PGREP="0") - (device.root / "active").write_text("tty2\n") - number = re.fullmatch(r"/dev/pts/(\d+)", device.console) - self.assertIsNotNone(number) - proc = device.run(["/bin/bash", str(ports / "RetroBIOS.sh")], - RB_VT_ACTIVE=str(device.root / "active"), - RB_VT_PREFIX="/dev/pts/", RB_VT_SWITCH=number.group(1)) + proc = device.run(["/bin/bash", str(ports / "RetroBIOS.sh")], **self.console_switch(device)) self.assertEqual(proc.returncode, 0, proc.stderr.decode()) [call] = device.tui_calls() self.assertEqual(call["stdin"], device.console) chvt = [line for line in device.tools() if line.startswith("chvt")] - self.assertEqual(chvt, [f"chvt {number.group(1)}", "chvt 2"]) + self.assertEqual(chvt, [f"chvt {self.number}", "chvt 2"]) device.assert_console_restored() + def test_a_refused_console_switch_shows_nothing_on_an_unseen_console(self): + """The compositor keeps the display: chvt waits and the console in + front stays the same. The launcher switches back and stops, instead + of an interface no key reaches.""" + device, ports = self.device(FAKE_PGREP="0") + proc = device.run(["/bin/bash", str(ports / "RetroBIOS.sh")], FAKE_CHVT_REFUSED="1", + **self.console_switch(device)) + self.assertEqual(proc.returncode, 125) + self.assertLess(device.elapsed, 20) + self.assertEqual(device.tui_calls(), []) + chvt = [line for line in device.tools() if line.startswith("chvt")] + self.assertEqual(chvt, [f"chvt {self.number}", "chvt 2"]) + self.assertEqual((device.root / "active").read_text(), "tty2\n") + log = (ports / "retrobios" / "log.txt").read_text() + self.assertIn(f"console {self.number} did not come to the front", log) + self.assertNotIn("\x1bc", device.screen) + + def test_xterm_that_cannot_open_hands_over_to_the_console(self): + device, ports = self.device(DISPLAY=":0", FAKE_PGREP="0") + device.tool("xterm", FAKE_TERMAPP) + proc = device.run(["/bin/bash", str(ports / "RetroBIOS.sh")], FAKE_TERM_FAILS="1", + **self.console_switch(device)) + self.assertEqual(proc.returncode, 0, proc.stderr.decode()) + [call] = device.tui_calls() + self.assertEqual(call["stdin"], device.console) + self.assertTrue(device.tools()[0].startswith("xterm -fullscreen -e bash")) + class TheMuosApp(unittest.TestCase): - def device(self, muxterm: bool) -> tuple[Device, Path]: + def device(self, muxterm: bool, gptokeyb2: bool = True) -> tuple[Device, Path]: device = Device(self) apps = device.unpack(build_port.MUOS_APP, device.root / "mmc" / "MUOS" / "application") muos = device.root / "opt-muos" @@ -673,7 +1178,9 @@ class TheMuosApp(unittest.TestCase): shutil.copy(device.tool("muxterm", FAKE_TERMAPP), muos / "frontend" / "muxterm") pm = device.root / "mmc" / "MUOS" / "PortMaster" pm.mkdir(parents=True) - shutil.copy(device.bin / "gptokeyb", pm / "gptokeyb2") + if gptokeyb2: + (pm / "gptokeyb2").write_text(FAKE_GPTOKEYB2) + (pm / "gptokeyb2").chmod(0o755) device.env["RB_MUOS"] = str(muos) return device, apps / "RetroBIOS" @@ -686,17 +1193,52 @@ class TheMuosApp(unittest.TestCase): self.assertEqual(call["args"], ["--tui"]) self.assertEqual(device.tools(), ["SETUP_APP retrobios", f"muxterm --gl -- bash {app}/tui.txt here"]) - self.assertEqual(device.mapper_args(), - [str(app / "install.py"), "-c", str(app / "muxterm.gptk")]) + # muxterm leaves the pad to gptokeyb2, which sends each button once + # with the interface's mapping: Y is Tab (Options), not muxterm's Space. + self.assertEqual(device.lines("termapp.sdl"), ["0x0001/0x0001"]) + args = device.mapper_args() + self.assertEqual(args[0], "-X") + self.assertRegex(args[1], r"^retrobios-stop-\d+x$") + self.assertEqual(args[2:], ["-c", str(app / "retrobios.gptk")]) + self.assertEqual(read_gptk(app / "retrobios.gptk")["y"], ["tab"]) device.assert_mapper_stopped() + def test_muxterm_alone_without_gptokeyb2(self): + device, app = self.device(muxterm=True, gptokeyb2=False) + proc = device.run([str(app / "mux_launch.sh"), str(app)]) + self.assertEqual(proc.returncode, 0, proc.stderr.decode()) + self.assertEqual(len(device.tui_calls()), 1) + # muxterm then reads the pad itself. + self.assertEqual(device.lines("termapp.sdl"), [""]) + self.assertEqual(device.mapper_args(), []) + + def test_start_select_ends_the_run_in_muxterm(self): + """gptokeyb2 kills with SIGKILL: -X reaches the sentinel, and the + interface is asked to stop as on the other routes.""" + device, app = self.device(muxterm=True) + proc = device.run([str(app / "mux_launch.sh"), str(app)], FAKE_HOLD="1", + FAKE_PRESS="1", RB_MESSAGE_WAIT="300") + self.assertEqual(proc.returncode, 143, proc.stderr.decode()) + self.assertLess(device.elapsed, 10) + self.assertEqual(device.lines("ui.log"), ["signal 15"]) + device.assert_mapper_gone() + + def test_start_select_ends_the_run_on_the_console(self): + device, app = self.device(muxterm=False) + proc = device.run([str(app / "mux_launch.sh"), str(app)], FAKE_HOLD="1", + FAKE_PRESS="1", RB_CONSOLE=device.console, RB_MESSAGE_WAIT="300") + self.assertEqual(proc.returncode, 143, proc.stderr.decode()) + self.assertLess(device.elapsed, 10) + self.assertEqual(device.lines("ui.log"), ["signal 15"]) + device.assert_console_restored() + def test_on_the_console_without_muxterm(self): device, app = self.device(muxterm=False) proc = device.run([str(app / "mux_launch.sh"), str(app)], RB_CONSOLE=device.console) self.assertEqual(proc.returncode, 0, proc.stderr.decode()) [call] = device.tui_calls() self.assertEqual(call["stdin"], device.console) - self.assertEqual(device.mapper_args()[2], str(app / "retrobios.gptk")) + self.assertEqual(device.mapper_args()[3], str(app / "retrobios.gptk")) device.assert_console_restored() def test_offline_reads_a_pack_at_the_card_root(self): @@ -707,6 +1249,15 @@ class TheMuosApp(unittest.TestCase): [call] = device.tui_calls() self.assertEqual(call["args"][:3], ["--tui", "--from-pack", str(pack)]) + def test_offline_reads_a_pack_on_the_second_card(self): + device, app = self.device(muxterm=True) + (device.root / "sdcard").mkdir() + pack = device.root / "sdcard" / "RetroArch_Lakka_v1.22.2_BIOS_Pack.zip" + pack.write_bytes(b"") + device.run([str(app / "mux_launch.sh"), str(app)], FAKE_CURL="7") + [call] = device.tui_calls() + self.assertEqual(call["args"][:3], ["--tui", "--from-pack", str(pack)]) + def two_cards(self, device: Device) -> tuple[Path, Path, Path]: """SD1 and SD2 both hold MUOS/bios, SD2's is the one muOS binds.""" sd1 = device.root / "mmc" / "MUOS" / "bios" @@ -767,29 +1318,59 @@ class TheToolsEntry(unittest.TestCase): device.env["RB_ROOT"] = str(device.root / "fs") return device, tools - def test_arkos_console_one(self): - device, tools = self.device() + def arkos(self, device: Device) -> Path: fs = device.root / "fs" (fs / "opt" / "system").mkdir(parents=True) (fs / "opt" / "inttools").mkdir(parents=True) shutil.copy(device.bin / "gptokeyb", fs / "opt" / "inttools" / "gptokeyb") + return fs + + def test_arkos_console_one(self): + device, tools = self.device() + self.arkos(device) proc = device.run(["bash", str(tools / "RetroBIOS.sh")], RB_CONSOLE=device.console) self.assertEqual(proc.returncode, 0, proc.stderr.decode()) [call] = device.tui_calls() self.assertEqual((call["stdin"], call["term"]), (device.console, "linux")) - installer = str(tools / "retrobios" / "install.py") - self.assertEqual(device.mapper_args(), - ["-1", installer, "-c", str(tools / "retrobios" / "retrobios.gptk")]) + args = device.mapper_args() + self.assertEqual(args[0], "-1") + self.assertRegex(args[1], r"^retrobios-stop-\d+x$") + self.assertEqual(args[2:], ["-c", str(tools / "retrobios" / "retrobios.gptk")]) device.assert_mapper_stopped() device.assert_console_restored() + def test_arkos_sudo_keeps_the_controller_list(self): + """sudo's env_reset would drop SDL_GAMECONTROLLERCONFIG_FILE, and a + pad SDL does not know sends nothing.""" + device, tools = self.device() + fs = self.arkos(device) + db = fs / "opt" / "inttools" / "gamecontrollerdb.txt" + db.write_text("") + device.tool("sudo", FAKE_SUDO) + proc = device.run(["bash", str(tools / "RetroBIOS.sh")], RB_CONSOLE=device.console) + self.assertEqual(proc.returncode, 0, proc.stderr.decode()) + self.assertEqual(device.lines("mapper.db"), [str(db)]) + started = [t for t in device.tools() if "gptokeyb" in t] + self.assertTrue(started[0].startswith( + "sudo --preserve-env=SDL_GAMECONTROLLERCONFIG_FILE "), started) + + def test_start_select_on_the_console(self): + device, tools = self.device() + self.arkos(device) + proc = device.run(["bash", str(tools / "RetroBIOS.sh")], RB_CONSOLE=device.console, + FAKE_HOLD="1", FAKE_PRESS="1", RB_MESSAGE_WAIT="300") + self.assertEqual(proc.returncode, 143, proc.stderr.decode()) + self.assertLess(device.elapsed, 10) + self.assertEqual(device.lines("ui.log"), ["signal 15"]) + self.assertNotIn("Press A", device.screen) + device.assert_mapper_gone() + device.assert_console_restored() + def test_arkos_other_card_without_a_pack_needs_one(self): """--from needs the list of files, which an offline run takes from a pack: the message says so.""" device, tools = self.device(FAKE_CURL="7") - fs = device.root / "fs" - (fs / "opt" / "system").mkdir(parents=True) - (fs / "opt" / "inttools").mkdir(parents=True) + fs = self.arkos(device) roms, roms2 = device.root / "roms" / "bios", device.root / "roms2" / "bios" roms.mkdir(parents=True) roms2.mkdir(parents=True) @@ -814,13 +1395,16 @@ class TheToolsEntry(unittest.TestCase): self.assertEqual(proc.returncode, 0, proc.stderr.decode()) [call] = device.tui_calls() self.assertEqual(call["stdin"], device.console) - self.assertEqual(device.mapper_args()[:2], ["-1", str(tools / "retrobios" / "install.py")]) + self.assertEqual(device.mapper_args()[0], "-1") - def test_rocknix_runs_in_the_terminal_tools_opens(self): - device, tools = self.device() + def rocknix(self, device: Device) -> None: etc = device.root / "fs" / "etc" etc.mkdir(parents=True) (etc / "os-release").write_text('NAME="ROCKNIX"\n') + + def test_rocknix_runs_in_the_terminal_tools_opens(self): + device, tools = self.device() + self.rocknix(device) proc = device.run(["bash", str(tools / "RetroBIOS.sh")], stdio_tty=True, TERM="xterm") self.assertEqual(proc.returncode, 0, proc.stderr.decode()) [call] = device.tui_calls() @@ -830,6 +1414,58 @@ class TheToolsEntry(unittest.TestCase): self.assertNotIn("\x1bc", device.screen) self.assertEqual([t for t in device.tools() if t.startswith("setfont")], []) + def test_rocknix_loads_the_controller_list_its_own_tools_load(self): + """control-gen_init.sh writes control.ini, which names the pad's + controller list and the GPTOKEYB to start (ROCKNIX fex-emu's Start + FEXConfig.sh). Its ESUDO is not this launcher's.""" + device, tools = self.device() + self.rocknix(device) + folder = device.root / "fs" / "storage" / ".config" / "gptokeyb" + shutil.copy(device.bin / "gptokeyb", device.root / "gptokeyb-rocknix") + device.tool("control-gen_init.sh", f'''#!/bin/sh +mkdir -p "{folder}" +cat > "{folder}/control.ini" <<'INI' +controlfolder="{device.root}" +ESUDO="sudo" +ESUDOKILL="-sudokill" +export SDL_GAMECONTROLLERCONFIG_FILE="$controlfolder/gamecontrollerdb.txt" +get_controls() {{ +DEVICE="03000000" +param_device="fake pad" +}} +GPTOKEYB="$controlfolder/gptokeyb-rocknix $ESUDOKILL" +INI +''') + proc = device.run(["bash", str(tools / "RetroBIOS.sh")], stdio_tty=True, TERM="xterm") + self.assertEqual(proc.returncode, 0, proc.stderr.decode()) + args = device.mapper_args() + self.assertEqual(args[0], "-sudokill") + self.assertRegex(args[1], r"^retrobios-stop-\d+x$") + self.assertEqual(device.lines("mapper.db"), [f"{device.root}/gamecontrollerdb.txt"]) + # Stopped without the sudo control.ini names. + device.assert_mapper_stopped() + + def test_rocknix_start_select_in_foot(self): + device, tools = self.device() + self.rocknix(device) + proc = device.run(["bash", str(tools / "RetroBIOS.sh")], stdio_tty=True, TERM="xterm", + FAKE_HOLD="1", FAKE_PRESS="1", FAKE_TERM_ERROR="unreachable", + RB_MESSAGE_WAIT="300") + self.assertEqual(proc.returncode, 143, proc.stderr.decode()) + self.assertLess(device.elapsed, 10) + self.assertNotIn("Press A", device.screen) + device.assert_mapper_gone() + + +class TheDocs(unittest.TestCase): + def test_muos_paths_are_the_card_muos_mounts(self): + """muOS mounts the first card at /mnt/mmc (MustardOS internal + mux_launch.sh, GET_VAR storage/rom/mount).""" + text = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8") + section = text[text.index("## On a handheld"):text.index("## Without a network")] + self.assertNotIn("/mmc/MUOS", section.replace("/mnt/mmc", "")) + self.assertIn("`MUOS/PortMaster/autoinstall/`", section) + if __name__ == "__main__": unittest.main() From 3db394900600d3b5fedc2c11e17564b10b6d50b4 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:21:48 +0200 Subject: [PATCH 2/9] docs: say where the handheld files come from The archives ship with the next release, and build_port.py writes them until then. muOS points to the muxapp and to /mnt/mmc, and the quit combo reads Select first. --- wiki/installer.md | 57 ++++++++++++++++++++++++------------------- wiki/testing-guide.md | 17 +++++++------ 2 files changed, 42 insertions(+), 32 deletions(-) diff --git a/wiki/installer.md b/wiki/installer.md index 40098811..f283fec7 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -945,22 +945,25 @@ python install.py --platform retroarch --dest /storage/emulated/0/RetroArch/syst RetroBIOS also runs on the handheld itself, in the [full-screen interface](#the-full-screen-interface), with the controller. -Take the file for your system from the -[latest release](https://github.com/Abdess/retrobios/releases/latest), copy -it to the SD card, then start RetroBIOS from the menu. +Copy the file for your system to the SD card, then start RetroBIOS from the +menu. + +The files below ship with the next release. Until then, build them from a +clone of the repository with `python scripts/build_port.py`, which writes all +four into `dist/`, or use the computer route at the end of this section. | System | File | Copy it to | Start it from | |--------|------|------------|---------------| -| Any system with [PortMaster](https://portmaster.games/): ArkOS, dArkOS, ROCKNIX, AmberELEC, JELOS, muOS, Knulli | `RetroBIOS.zip` | PortMaster's `autoinstall` folder: `/roms/ports/PortMaster/autoinstall/` on ROCKNIX and AmberELEC, `/roms/tools/PortMaster/autoinstall/` on ArkOS, `/mmc/MUOS/PortMaster/autoinstall/` on muOS, `/userdata/system/.local/share/PortMaster/autoinstall/` on Knulli | Ports, after opening PortMaster once | -| Batocera, Knulli | `RetroBIOS-batocera.zip`, extracted | `roms/ports` on the SHARE partition | Ports, after updating the game list | | muOS | `RetroBIOS.muxapp` | `ARCHIVE` at the root of the card, then install it from the Archive Manager | Applications | +| Any other system with [PortMaster](https://portmaster.games/): ArkOS, dArkOS, ROCKNIX, AmberELEC, JELOS, Knulli | `RetroBIOS.zip` | PortMaster's `autoinstall` folder: `/roms/ports/PortMaster/autoinstall/` on ROCKNIX and AmberELEC, `/roms/tools/PortMaster/autoinstall/` on ArkOS, `/userdata/system/.local/share/PortMaster/autoinstall/` on Knulli | Ports, after opening PortMaster once | +| Batocera, Knulli | `RetroBIOS-batocera.zip`, extracted | `roms/ports` on the SHARE partition | Ports, after updating the game list | | ArkOS, dArkOS | `RetroBIOS-tools.zip`, extracted | `tools` on the EASYROMS partition | Options, then Tools | | ROCKNIX, JELOS | `RetroBIOS-tools.zip`, extracted | `/storage/.config/modules`, over the network share or SSH | Tools | | AmberELEC | `RetroBIOS-tools.zip`, extracted | `/storage/.config/distribution/modules` | Tools | -A release cut before these files existed carries none of them. From a clone -of the repository, `python scripts/build_port.py` writes all four into -`dist/`. +`RetroBIOS.zip` also installs on muOS, from `MUOS/PortMaster/autoinstall/` +on the first card (`/mnt/mmc` on the device). It then shows the interface in +muxterm, as the muOS app does. | Button | Action | |--------|--------| @@ -970,17 +973,18 @@ of the repository, `python scripts/build_port.py` writes all four into | X | Tick or untick a system | | Y | Options | | L1, R1 | Page up, page down | -| Start + Select | Quit at once (PortMaster port, Tools) | +| Hold Select, then Start | Quit at once (PortMaster port, muOS, Tools) | -On muOS the interface runs in muxterm, which already reads Start, B, Y (tick) -and the D-pad. A, X, L1 and R1 come from PortMaster's gptokeyb2 when -PortMaster is installed, and Menu quits. +Start pressed first also chooses the line in focus: hold Select first. A +running install is then stopped the way B stops it, and the last screen says +what it wrote. On muOS without PortMaster, muxterm reads the pad itself: Y +ticks a system, Select shows its keyboard, and Menu quits. **Without Wi-Fi.** On a computer, download the BIOS pack whose name contains your system (every part, for a pack in parts) and copy it next to RetroBIOS: into `ports/retrobios` for the PortMaster port and on Batocera, to the root -of the card on muOS, into the `retrobios` folder of the Tools entry. With no -connection, RetroBIOS installs from it, as +of either card on muOS, into the `retrobios` folder of the Tools entry. With +no connection, RetroBIOS installs from it, as [`--from-pack`](#without-a-network-offline-install) does. With no pack there, it names the pack and the folder. @@ -995,28 +999,24 @@ still comes from the pack, so the pack is needed too. first use and the Tools entry then reuses. That runtime uses the system's OpenSSL 1.1, so on an image with OpenSSL 3 only it may not download (not tried on a device). The BIOS folder must be one the installer detects, see -[Platform detection](#platform-detection). +[Platform detection](#platform-detection). On a read-only or full card, +RetroBIOS keeps its log in `retrobios` under `XDG_RUNTIME_DIR` or `/tmp`, and the interface names the +folder it cannot write. **Where no terminal shows.** The PortMaster port draws on the console, or in foot or xterm under a desktop session. Where neither can, it opens PortMaster's own screens instead: the same check, update and choice of systems, one list at a time. On Batocera and Knulli the visible terminal is not verified on a device yet: RetroBIOS tries xterm or foot, then console 4, -and `retrobios/log.txt` names the route it took. +and stops when console 4 does not come to the front. `retrobios/log.txt` +names each route it tried, and `log.prev.txt` keeps the run before. **From a computer.** Onion, MinUI, NextUI, Allium, GarlicOS and the Switch -have no Python 3 to run the installer. Put the card in a computer and run the -[one-line command](#one-line) there with +have no Python 3 to run the installer. Put the card in a computer and run +the [one-line command](#one-line) there with `--platform --dest `. This works for every system above too. -**Which installer runs.** Each file carries `install.py`, the one the -one-line command of the same commit downloads: `scripts/build_port.py` -refuses any other (it compares it with the SHA-256 `install.sh` pins). The -file lists come from `main`. The launchers in `ports/` start it with `--tui`, -and read it through [Machine-readable events](#machine-readable-events) for -the rest. - ## Without a network (offline install) { #without-a-network-offline-install } @@ -1142,6 +1142,13 @@ The build scripts need 3.9 and are not bound by this. Every change to `install.py` recomputes the SHA-256 pinned in `install.sh` and `install.ps1` in the same commit. The tests compare the pins with the file. +The [handheld](#on-a-handheld-the-retrobios-port) archives each carry +`install.py`, the one the one-line command of the same commit downloads: +`scripts/build_port.py` refuses any other (it compares it with the SHA-256 +`install.sh` pins). The file lists come from `main`. The launchers in +`ports/` start it with `--tui`, and read it through +[Machine-readable events](#machine-readable-events) for the rest. + Narrowing by system, core, region or target replays the pack builder's rules from fields the manifest carries, and a parity test compares an installer selection with the pack built the same way. Given a manifest without those diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md index 6e833970..6cdee9bd 100644 --- a/wiki/testing-guide.md +++ b/wiki/testing-guide.md @@ -274,13 +274,16 @@ pins). Each launcher then runs from its extracted archive on a simulated device: a pseudo-terminal stands for the console, and fakes stand for `install.py`, -gptokeyb, setfont, chvt, curl, foot, xterm, muxterm, PortMaster's -`control.txt` and muOS's `retro.sh`. The checks: gptokeyb starts in kill mode -on the installer's path and is stopped, the console's settings and font are -put back, an offline run picks the pack of its system from the card or says -where to put one, a refused console falls back to PortMaster's screens, and -each firmware's terminal is the one used. What reaches the screen and the -pad on a real device is not exercised. +gptokeyb and gptokeyb2 (each killing the way its source does), setfont, +chvt, curl, sudo, foot, xterm, muxterm, PortMaster's `control.txt` and +muOS's `retro.sh`. The checks: gptokeyb's kill reaches only the Start + +Select sentinel and the interface then stops with its result written, the +console's settings and font are put back, an offline run says so at once, +picks the pack of its system from the card or says where to put one, and +each firmware's terminal is the one used. The fakes also fail: a mapper that +dies, a chvt the compositor refuses, a terminal app that cannot open, a +read-only card. None of these may leave a console waiting for a button. +What reaches the screen and the pad on a real device is not exercised. ```bash python -m unittest tests.test_handheld_launchers -v From e534f11713ed04c61f3522c9206c4dcc04cac256 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:31:33 +0200 Subject: [PATCH 3/9] fix: second audit of the full-screen interface - A handheld card mounted on a computer is found by the files its firmware writes there: muOS, Onion, spruceOS, CrossMix, Allium and Koriki from their device rules read below the card root, KNULLI and ROCKNIX from the share their first boot seeds. It is a setup of its own ("muOS SD card (E:\)") with the folder that system reads, and --dest writes there too. --platform muos with a folder that is no muOS card is refused with the folder to name. - Space (X on PortMaster, Y on muxterm) opens the confirm screen from Install and never installs, closes or quits by itself. - Short of space, Choose another folder comes first, and the result says the card is full in words, with no log lines and no command naming the interface's own copy of the list. - The plan keeps the BIOS folder second, cut in its middle, a --from folder by its name, and the config that named the folder. - The result says how many files came from the old card, how many were downloaded and how many are still missing, as the line mode. - The "other" row is named and explained, files shared by several systems are counted apart, and a file that cannot be supplied is named with its system, only where it matters. - A work folder left by SIGKILL is removed by the next run, a failed run's log after a week. A full card keeps no log. - Smaller: the sudo advice names the folder, removing a --from folder says so, the preview counts its files only, a proxy is named, a list that is no JSON and the clock error read as plain sentences. --- install.ps1 | 2 +- install.py | 532 +++++++++++++++++--- install.sh | 2 +- tests/fixtures/tui/confirm-120x40.txt | 2 +- tests/fixtures/tui/confirm-40x12.txt | 4 +- tests/fixtures/tui/confirm-40x15.txt | 4 +- tests/fixtures/tui/confirm-80x24.txt | 2 +- tests/fixtures/tui/options-120x40.txt | 2 +- tests/fixtures/tui/options-40x12.txt | 2 +- tests/fixtures/tui/options-40x15.txt | 2 +- tests/fixtures/tui/options-80x24.txt | 2 +- tests/fixtures/tui/plan-120x40.txt | 4 +- tests/fixtures/tui/plan-40x12.txt | 4 +- tests/fixtures/tui/plan-40x15.txt | 4 +- tests/fixtures/tui/plan-80x24.txt | 4 +- tests/fixtures/tui/plan-in-place-120x40.txt | 2 +- tests/fixtures/tui/plan-in-place-40x12.txt | 4 +- tests/fixtures/tui/plan-in-place-40x15.txt | 4 +- tests/fixtures/tui/plan-in-place-80x24.txt | 2 +- tests/fixtures/tui/result-120x40.txt | 2 +- tests/fixtures/tui/result-40x12.txt | 2 +- tests/fixtures/tui/result-40x15.txt | 2 +- tests/fixtures/tui/result-80x24.txt | 4 +- tests/test_install_from.py | 3 +- tests/test_install_tui.py | 481 +++++++++++++++++- wiki/installer.md | 32 +- 26 files changed, 999 insertions(+), 111 deletions(-) diff --git a/install.ps1 b/install.ps1 index 0a3b78fc..379d2f1f 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "bcbde4c289b32c02ada78001b75274a87b125c1efd3d8fb1064d7f5482ef3997" + $defaultInstallSha256 = "7ac3e6030b12bacd262964fea5373f27fb09e62296255bc3c22255a1aaad5fe8" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 7f816c98..14c9e699 100644 --- a/install.py +++ b/install.py @@ -7594,6 +7594,11 @@ def main() -> None: if args.cores_dir: args.installed_cores = True _INVOCATION.record(args) + if os.environ.get(TUI_LIST_ENV): + # The full screen's own copy of the list, deleted when it closes: a + # command naming it fails once the screen is gone. + _INVOCATION.options = [option for option in _INVOCATION.options + if option[0] != "--manifest"] if not 1 <= args.jobs <= 32: parser.error("--jobs must be between 1 and 32") if args.region: @@ -7818,6 +7823,106 @@ def card_platform(root: Path) -> str | None: return None +# A system that runs from its SD card names the card by where the device +# mounts it: on a computer the card's root stands in for that mount point, +# and its device rule then reads the same below the root. +_CARD_MOUNTS = ("/mnt/SDCARD/", "/mnt/mmc/") + +# Systems whose card a computer sees as their data partition, by the file +# their own build writes there, paths below that partition. KNULLI's is +# /userdata: S12populateshare:15,105 copies datainit there, and every board's +# datainit/system/batocera.conf sets system.hostname=KNULLI (knulli-cfw +# distribution ab3a6809, board/batocera/*/fsoverlay/usr/share/batocera/). +# ROCKNIX's is /storage: userconfig-setup:12 copies /usr/config into +# /storage/.config, and its system.cfg sets root.password=rocknix (ROCKNIX +# distribution 8b70f693, projects/ROCKNIX/packages/rocknix/config/system/ +# configs/system.cfg:152). +_SHARE_RULES: tuple[_OsRule, ...] = ( + _OsRule(_STANDIN_MANIFESTS["KNULLI"], "KNULLI", "file_matches", + ("system/batocera.conf", r"^system\.hostname=KNULLI\s*$"), "bios"), + _OsRule("rocknix", "ROCKNIX", "file_matches", + (".config/system/configs/system.cfg", r"^root\.password=rocknix\s*$"), + "roms/bios"), +) + + +def _card_rules() -> list[_OsRule]: + """The device rules that name a card, their paths below its root.""" + rules = list(_SHARE_RULES) + for rule in _EMBEDDED_RULES: + if rule.method != "file_exists" or not rule.manifest or not rule.bios: + continue + mount = next((m for m in _CARD_MOUNTS if rule.bios.startswith(m) + and all(value.startswith(m) for value in rule.values)), "") + if mount: + rules.append(rule._replace( + values=tuple(value[len(mount):] for value in rule.values), + bios=rule.bios[len(mount):], configs=())) + return rules + + +def _card_rule_matches(rule: _OsRule, root: Path) -> bool: + if rule.method == "file_matches": + path, pattern = rule.values + try: + text = (root / path).read_text(encoding="utf-8", errors="replace") + except OSError: + return False + return re.search(pattern, text, re.MULTILINE) is not None + return all((root / value).exists() for value in rule.values) + + +def card_system(root: Path) -> tuple[str, Path, str] | None: + """(file list, BIOS folder, system name) of the card mounted at root. + + None when nothing on it names a system, or when two systems that would + write to different folders both match: the card is then the user's to + name, never a guess. + """ + plat = card_platform(root) + if plat: + return plat, root, display_name(plat) + matched = [rule for rule in _card_rules() if _card_rule_matches(rule, root)] + bases = {rule.base for rule in matched if rule.base} + matched = [rule for rule in matched if rule.name not in bases] + targets = {(rule.manifest, rule.bios) for rule in matched} + if len(targets) != 1: + return None + rule = matched[0] + return rule.manifest, root / rule.bios, rule.name + + +def card_dest(named: str, plat: str, dest: Path) -> tuple[str, Path]: + """The file list and folder for a --dest that names a handheld's card. + + The card's root is where the user points, its BIOS folder is below it: + `--platform muos --dest /media/me/SD1` wrote to the root, which muOS + never reads. A system named on the command line whose card this is not + is refused with the folder it reads. + """ + found = card_system(dest) + if found and (not plat or found[0] == plat) and found[1] != dest: + print(f" {found[2]} card: {display_name(found[0])} files, in {found[1]}") + return found[0], found[1] + # Only a system named by its own name: --platform retroarch or knulli + # says which list, not which card. + key = _name_key(named) + rule = next((rule for rule in _card_rules() if key and key == _name_key(rule.name) + and key != _name_key(display_name(rule.manifest))), None) + if rule is None or found: + return plat, dest + # Its folder on the card, or one its device reads (/run/muos/storage/bios). + own = {device.bios for device in _EMBEDDED_RULES if device.name == rule.name} + if dest.as_posix() in own or dest.as_posix().casefold().endswith(rule.bios.casefold()): + return plat, dest + marker = rule.values[0].split("/")[0] + print(f"Error: {dest} is not a {rule.name} card: it holds no {marker}. " + f"{rule.name} reads its BIOS files from {rule.bios} on its card: name the card's " + f"root (the folder holding {marker}) or that folder: --dest /{rule.bios}", + file=sys.stderr) + sys.exit(1) + + def _check_card_root(plat: str, dest: Path) -> None: """Refuse a folder inside the card where its root is meant. @@ -7915,8 +8020,12 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: # Platform detection or override. A destination taken from the default # table, the platform not being found here, is only a guess. assumed_dests: set[Path] = set() + named = args.platform or "" if args.platform: args.platform = normalize_platform(args.platform) + if args.dest: + plat, args.dest = card_dest(named, args.platform or "", args.dest) + args.platform = plat or args.platform if args.platform and args.dest: if args.platform in CARD_PLATFORMS: _check_card_root(args.platform, args.dest) @@ -8638,6 +8747,8 @@ TUI_MAX_WIDTH = 100 # past this wait an Esc with nothing after it is the key. TUI_ESC_WAIT = 0.04 TUI_FRAME = 0.1 +# Set for a child run handed the interface's own copy of the file list. +TUI_LIST_ENV = "RETROBIOS_TUI_LIST" _PASTE_START = "\x1b[200~" _PASTE_END = "\x1b[201~" # Turned on by the interface, so a pasted path arrives as one piece and its @@ -8971,8 +9082,9 @@ class TuiState: self.found: list = [] # What the browser is choosing: the BIOS folder, or one to copy from. self.browse_for: str = "dest" - # Files an install copies from this machine, whose bytes are no download. - self.local_dests: set = set() + # Files an install copies from this machine, whose bytes are no + # download: dest to (folder, reason), reason set for a misplaced one. + self.local_dests: dict = {} self.local_done: int = 0 # The child run in progress: check, confirm, preview or install. self.run: str = "" @@ -8987,6 +9099,9 @@ class TuiState: self.dry: dict | None = None self.dry_files: list = [] self.dry_summary: dict | None = None + # The confirm screen waits for A even when the plan is unchanged: + # it was opened by Space, which never installs by itself. + self.must_confirm: bool = False # Choices, each one an option of the command line. self.ticked: set = set() self.cli_systems: list = [] @@ -9161,6 +9276,8 @@ def _tui_system_label(row: dict, titles: dict | None = None) -> str: title = (titles or {}).get(row["system"]) if isinstance(title, str) and title.strip(): return title.strip() + if row["system"] == OTHER_SYSTEMS: + return OTHER_TITLE name = row.get("name") or row["system"] if name == row["system"]: return name @@ -9169,6 +9286,12 @@ def _tui_system_label(row: dict, titles: dict | None = None) -> str: return f"{row['system']} ({name})" +# What the "other" row of the systems holds, in words (OTHER_SYSTEMS). +OTHER_TITLE = "Other systems and game engines" +OTHER_HELP = ("Other systems and game engines: what cores load for systems this setup " + "does not list, such as game engines and extra computers.") + + def _matches(text: str, needle: str) -> bool: return not needle or _folded(needle) in _folded(text) @@ -9279,8 +9402,9 @@ def _plan_rows(state: TuiState) -> list[TuiRow]: size = check.get("bytes_needed", 0) label = f"Install {format_size(size)}" if size >= CONFIRM_FROM_BYTES else "Install" rows = [TuiRow("install", label)] - if state.facts.get("writable") is False: - # Every file would fail alike: the way out is another folder. + if state.facts.get("writable") is False or _short_of_space(state, check): + # Every file would fail alike, or the run would stop before the + # first one: the way out is another folder. rows = [TuiRow("folder", "Choose another folder"), TuiRow("install", "Install anyway"), *rows[1:]] if records_selection((state.manifest or {}).get("files", [])) and len(state.systems) > 1: @@ -9291,23 +9415,51 @@ def _plan_rows(state: TuiState) -> list[TuiRow]: return rows + [TuiRow("options", "Options"), TuiRow("quit", "Quit")] +def _short_of_space(state: TuiState, check: dict) -> bool: + free = state.facts.get("free") + return free is not None and check.get("bytes_needed", 0) > free + + def _system_rows(state: TuiState) -> list[TuiRow]: needle = state.filters.get("systems", "") titles = _system_titles(state) rows = [TuiRow("all", "All"), TuiRow("none", "None"), _filter_row(state)] + shared = _shared_needed(state) for row in _sorted_systems(state): label = _tui_system_label(row, titles) if not _matches(f"{label} {row['system']}", needle): continue box = "[x]" if row["system"] in state.ticked else "[ ]" need = _needed(row) - right = (f"{_plural(need, 'file')}, {format_size(row['bytes_needed'])}" - if need else "in place") + count, size = shared.get(row["system"], (0, 0)) + own, own_size = need - count, row["bytes_needed"] - size + if not need: + right = "in place" + elif not count: + right = f"{_plural(need, 'file')}, {format_size(row['bytes_needed'])}" + elif not own: + right = f"{_plural(need, 'file')}, {format_size(size)}, all shared" + else: + right = f"{_plural(own, 'file')}, {format_size(own_size)} + {count} shared" rows.append(TuiRow("system", f"{box} {label}", right, row["system"], "" if need else "dim")) return rows +def _shared_needed(state: TuiState) -> dict[str, tuple[int, int]]: + """Per system, the files to download that other systems carry too, and + their size: `3DO 1704 files, 349.0 MB` read as the 3DO BIOS, when 1689 + are the MAME files six systems share. The line mode counts them apart + the same way (_system_sized).""" + shared: dict[str, tuple[int, int]] = {} + for event in state.files: + if len(event["systems"]) > 1: + for system in event["systems"]: + count, size = shared.get(system, (0, 0)) + shared[system] = (count + 1, size + event["size"]) + return shared + + def _cores_dir_given(state: TuiState) -> bool: return any(flag == "--cores-dir" for flag, _value in state.passthrough) @@ -9325,7 +9477,9 @@ def _option_rows(state: TuiState) -> list[TuiRow]: + (", ".join(map(region_title, state.regions)) or "any"), "--region")) if manifest.get("categorized"): - rows.append(TuiRow("game_data", f"Leave out game data: {_yes(state.no_game_data)}", + # "Leave out game data: no" was a double negative. + rows.append(TuiRow("game_data", "Game data: " + + ("left out, firmware only" if state.no_game_data else "kept"), "--no-game-data")) found = len(state.facts.get("cores", [])) if _cores_dir_given(state): @@ -9343,7 +9497,7 @@ def _option_rows(state: TuiState) -> list[TuiRow]: TuiRow("jobs", f"Parallel downloads: {state.jobs}", "--jobs"), TuiRow("folder", f"BIOS folder: {_short_path(state.dest, state.home)}", "--dest", style="path"), - TuiRow("from", f"Copy from: {copied}", "--from"), + TuiRow("from", f"Copy from: {copied}", "--from", style="path"), TuiRow("preview", "Preview the file list", "--dry-run"), TuiRow("command", "Show the command line"), ] @@ -9420,7 +9574,7 @@ def _start_run(state: TuiState, run: str) -> list[tuple]: state.dry, state.dry_files, state.dry_summary = None, [], None else: state.download = state.progress = state.reading = None - state.samples, state.local_dests, state.local_done = [], set(), 0 + state.samples, state.local_dests, state.local_done = [], {}, 0 state.finished, state.summary, state.code, state.stopping = [], None, None, False return [("run", run)] @@ -9442,6 +9596,10 @@ def _start_check(state: TuiState) -> list[tuple]: def _choose(state: TuiState, plat: str, dest: Path) -> list[tuple]: + card = card_system(dest) + if card and card[0] == plat and card[1] != dest: + # A muOS or KNULLI card's root: that system reads a folder below it. + dest = card[1] state.platform, state.dest = plat, dest if plat in CARD_PLATFORMS and card_platform(dest) is None: # The engine refuses a folder inside the card, the list writing @@ -9466,6 +9624,11 @@ def _open_folder_screen(state: TuiState, default: Path | None) -> list[tuple]: return _push(state, "folder") +def _mount_name(mount: Path) -> str: + """How a person names a mounted card: E:\\ on Windows, its label elsewhere.""" + return mount.name or str(mount) + + def _mounted_cards() -> list[Path]: """Mount points of removable media, where a card or a share appears. @@ -9507,8 +9670,8 @@ def _browse_rows(folder: Path | None, zips: bool = False) -> tuple[list[TuiRow], rows = [TuiRow("use", "Use this folder"), TuiRow("up", "..", value=folder.parent)] for card in _mounted_cards(): if card != folder: - plat = card_platform(card) - what = f"{display_name(plat)} card" if plat else "card or drive" + system = card_system(card) + what = f"{system[2]} card" if system else "card or drive" rows.append(TuiRow("dir", f"{card} ({what})", value=card)) try: entries = list(os.scandir(folder)) @@ -9698,9 +9861,10 @@ def _option(state: TuiState, action: str, step: int) -> list[tuple]: elif action == "folder": return _open_folder_screen(state, state.dest) elif action == "from" and step < 0: + # Said, and the screen stays: Back to the plan counts again. if state.from_folders: - state.from_folders.pop() - return _start_check(state) + removed = state.from_folders.pop() + state.status = f"No longer copying from {_last_name(str(removed))}." elif action == "from": return _open_from_browser(state) elif action == "preview": @@ -9740,6 +9904,7 @@ def _begin_install(state: TuiState) -> list[tuple]: if state.systems and not state.ticked: state.status = "No system is ticked: tick one, or choose All." return [] + state.must_confirm = False if tui_flags(state) != state.checked_flags: _push(state, "confirm") return _start_run(state, "confirm") @@ -9965,12 +10130,34 @@ def tui_update(state: TuiState, key: TuiKey) -> list[tuple]: return _option(state, row.action, 1 if name == "right" else -1) \ if row.action in ("game_data", "installed", "jobs") \ or (row.action == "from" and name == "left") else [] - elif name == "enter" or char == " ": + elif char == " ": + return _space(state, row) + elif name == "enter": return _activate(state, row) state.focus[screen] = focus return [] +# Rows Space leaves alone: it is X (tick) on PortMaster and Y on muxterm, a +# key pressed for something else, and these end the run or write. +_SPACE_INERT = ("install", "close", "quit") + + +def _space(state: TuiState, row: TuiRow) -> list[tuple]: + """Space opens what a row leads to, and never installs by itself: on + the plan's Install it opens the confirm screen, where A installs.""" + if row.action == "install" and state.screen == "plan": + if state.systems and not state.ticked: + state.status = "No system is ticked: tick one, or choose All." + return [] + state.must_confirm = True + _push(state, "confirm") + return _start_run(state, "confirm") + if row.action in _SPACE_INERT: + return [] + return _activate(state, row) + + def _take_event(state: TuiState, event: dict) -> None: kind = event.get("event") if kind == "checking" and state.run in ("check", "install"): @@ -9978,7 +10165,7 @@ def _take_event(state: TuiState, event: dict) -> None: elif kind == "found" and state.run == "check": state.found.append(event) elif kind == "found" and state.run == "install": - state.local_dests.add(event["dest"]) + state.local_dests[event["dest"]] = (event["folder"], event["reason"]) elif state.run == "check": if kind == "check": state.check = event @@ -10049,7 +10236,7 @@ def tui_receive(state: TuiState, message: tuple) -> list[tuple]: elif state.run == "confirm" and state.screen == "confirm": if state.dry is None: return _fail(state, tail, code) - if _same_plan(state.check, state.dry): + if _same_plan(state.check, state.dry) and not state.must_confirm: state.stack.pop() return _install_now(state) elif state.run == "install": @@ -10110,6 +10297,10 @@ def _text_lines(items: list, width: int) -> list[TuiLine]: lines: list[TuiLine] = [] for item in items: style = getattr(item, "style", "") + if style == "path": + # One line, cut in its middle: the end of a path tells it apart. + lines.append(_styled(middle(str(item), width))) + continue for piece in _wrap(str(item), width): lines.append(_styled(fit(piece, width), style)) return lines @@ -10161,7 +10352,9 @@ def _compose( return body + footer_lines -def _pager(state: TuiState, width: int, height: int, items: list) -> list[TuiLine]: +def _pager( + state: TuiState, width: int, height: int, items: list, lead: int = 0, +) -> list[TuiLine]: """The items in view, from the first shown on, each wrapped alone: the 8,000 lines of a full preview were wrapped again at every key.""" first = max(0, min(state.focus.get(state.screen, 0), _pager_last(state, items))) @@ -10177,7 +10370,9 @@ def _pager(state: TuiState, width: int, height: int, items: list) -> list[TuiLin index += 1 whole = index if len(lines) <= room else index - 1 lines = lines[:room] - return lines + [_styled(f"{first + 1}-{max(first + 1, whole)} of {len(items)}", "dim")] + start = max(1, first + 1 - lead) + return lines + [_styled(f"{start}-{max(start, whole - lead)} of {len(items) - lead}", + "dim")] def _tui_misplaced(state: TuiState) -> list[dict]: @@ -10194,6 +10389,11 @@ def _tui_found_elsewhere(state: TuiState) -> dict[str, list[dict]]: return found +def _last_name(folder: str) -> str: + """A folder or ZIP by its last name, a drive by itself (E:\\).""" + return Path(folder).name or folder + + def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: need = _needed(check) data = check.get("data_missing", 0) @@ -10206,7 +10406,9 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: fetch_bytes = max(0, check["bytes_needed"] - sum(sizes[dest_] for dest_ in copied)) elsewhere = _tui_found_elsewhere(state) if check is state.check else {} if elsewhere: - where = ", ".join(f"{len(events)} found in {folder}" + # The folder by its last name: the whole path pushed the BIOS + # folder off a 40x15 screen. The Options row spells it out. + where = ", ".join(f"{len(events)} found in {_last_name(folder)}" for folder, events in elsewhere.items()) headline = (f"{where}, {fetch} to download ({format_size(fetch_bytes)})" if fetch else f"{where}, nothing to download") @@ -10221,35 +10423,46 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: headline = (f"{_plural(data, 'data file')} to fetch" if data else "Everything is in place") wrong = check["wrong_hash"] + # The folder comes second: on a 40x12 screen it is what is left once + # the rows are drawn, and nothing is written before it is seen. lines: list = [ _styled(headline, "title"), - f"{check['present']} in place, {check['missing']} missing, " - f"{wrong} wrong {'copy' if wrong == 1 else 'copies'}", - f"BIOS folder: {_short_path(dest, state.home)}", + _styled(f"BIOS folder: {_short_path(dest, state.home)}", "path"), ] + if state.facts.get("writable") is False: + lines.append("You cannot write to this folder: choose another one.") + lines.append(f"{check['present']} in place, {check['missing']} missing, " + f"{wrong} wrong {'copy' if wrong == 1 else 'copies'}") + found_in = _RETROARCH_CONFIGS.get(dest) if dest is not None else None + if found_in: + lines.append(_styled(f"Named by {_short_path(Path(found_in[0]), state.home)}", "path")) free = state.facts.get("free") if free is not None: lines.append(f"Free space: {format_size(free)}") if check["bytes_needed"] > free: - lines.append("Not enough free space for this download: free some, or " - "choose another folder in Options.") + lines.append(f"Not enough free space: {format_size(check['bytes_needed'])} " + "needed. Free some, or choose another folder.") if wrong: lines.append("They are replaced, and the old copies are kept in a backup folder.") misplaced = _tui_misplaced(state) if check is state.check else [] if misplaced: - lines.insert(1, f"{_plural(len(misplaced), 'file')} " + lines.insert(3, f"{_plural(len(misplaced), 'file')} " f"{'is' if len(misplaced) == 1 else 'are'} in the wrong place, " f"{'it' if len(misplaced) == 1 else 'they'} will be copied where the " "emulators read " f"{'it' if len(misplaced) == 1 else 'them'}.") if state.facts.get("writable") is False: - lines.insert(1, "You cannot write to this folder: choose another one, or run " - + ("the installer as an administrator." if state.os_type == "windows" - else "the installer with sudo.")) + if state.os_type == "windows": + lines.append("Or run the installer as an administrator.") + else: + # sudo changes the home folder, and with it what is detected: + # the folder is named so root writes here. + hint = Invocation() + hint.os_type = state.os_type + lines.append("Or run it as root, this folder named: " + + hint.command(state.platform, dest, elevated=True)) if check.get("not_installable"): - lines.append(f"{_plural(check['not_installable'], 'file')} of the list " - f"{'is' if check['not_installable'] == 1 else 'are'} not in " - "the collection.") + lines += _unavailable_lines(state) return lines @@ -10367,14 +10580,17 @@ def _choice_line(state: TuiState) -> list[str]: def _view_plan(state: TuiState, width: int, height: int) -> tuple: check = state.check or {} lines = _plan_lines(check, state.dest, state) - lines[1:1] = _choice_line(state) + lines[2:2] = _choice_line(state) if state.status: lines.append(state.status) rows = tui_rows(state) if width < 54: rows = [row._replace(label="Copy from your old card or a folder") if row.action == "fromcard" else row for row in rows] - return (display_name(state.platform, state.manifest), + # A muOS card gets RetroArch's files: the title says both. + system = detected_os(state.platform, state.dest) if state.dest is not None else "" + name = display_name(state.platform, state.manifest) + return (f"{system} ({name} files)" if system and system != name else name, _compose(state, width, height, lines, rows), ("A/Enter Install", "B/Esc Back", "Y/o Options", "? Help")) @@ -10398,7 +10614,7 @@ def _view_systems(state: TuiState, width: int, height: int) -> tuple: if width < 60: # The name first: the size alone stays beside it, the count is in # the total below. - rows = [row._replace(right=row.right.split(", ")[-1]) for row in rows] + rows = [row._replace(right=re.sub(r"^\d+ files?, ", "", row.right)) for row in rows] elif width >= 78: # The id --system takes, beside a name that does not show it. # Only where it fits whole: a cut id ("maker26-co") said nothing. @@ -10411,6 +10627,13 @@ def _view_systems(state: TuiState, width: int, height: int) -> tuple: f"{_plural(len(chosen), 'file')}, " f"{format_size(sum(e['size'] for e in chosen))}" + (" before your options" if _counts_predate_options(state) else "")] + focused = rows[min(state.focus.get("systems", 0), len(rows) - 1)] + if focused.value == OTHER_SYSTEMS: + footer.insert(0, OTHER_HELP) + elif "shared" in focused.right: + count = _shared_needed(state)[focused.value][0] + footer.insert(0, f"{count} of these files are shared with other systems, " + "counted once in the total.") needle = state.filters.get("systems", "") after = [f"No system matches '{needle}'."] if len(rows) == 3 and needle else [] if state.status: @@ -10433,12 +10656,19 @@ def _view_options(state: TuiState, width: int, height: int) -> tuple: rows = [row._replace(right="") for row in rows] # A folder is cut in its middle, its last levels being what tells it apart. room = width - 4 - (0 if width < 60 else 2 + max(cell_width(r.right) for r in rows)) - rows = [row._replace(label="BIOS folder: " + middle(row.label[13:], room - 13), style="") - if row.style == "path" else row for row in rows] + rows = [_middle_label(row, room) if row.style == "path" else row for row in rows] + if state.status: + after.append(state.status) return ("Options", _compose(state, width, height, before, rows, after), ("A/Enter Change", "B/Esc Back", "Left/Right Set", "? Help")) +def _middle_label(row: TuiRow, room: int) -> TuiRow: + """A row naming a folder, the folder cut in its middle to fit.""" + name, _sep, value = row.label.partition(": ") + return row._replace(label=f"{name}: " + middle(value, room - len(name) - 2), style="") + + def _view_regions(state: TuiState, width: int, height: int) -> tuple: before = ["Tick the regions to keep, best first. Where a system has one BIOS " "per region, the best one found is installed."] @@ -10480,7 +10710,9 @@ def _pager_items(state: TuiState) -> list: def _view_preview(state: TuiState, width: int, height: int) -> tuple: - return ("Preview", _pager(state, width, height, _pager_items(state)), + # The count and the blank line above the files are not among them. + lead = 2 if state.dry is not None and not state.running else 0 + return ("Preview", _pager(state, width, height, _pager_items(state), lead), ("A/Enter Back", "Up/Down Scroll")) @@ -10548,6 +10780,83 @@ def _view_installing(state: TuiState, width: int, height: int) -> tuple: ("B/Esc Stop", "? Help")) +_SPACE_NEEDED = re.compile(r"Needed: (\S+ \S+)\. Free: (\S+ \S+)\.") + + +def _space_stop(state: TuiState) -> re.Match | None: + """Needed and free, when the run stopped for lack of space.""" + return next(filter(None, (_SPACE_NEEDED.search(line) for line in state.tail)), None) + + +def _space_words(state: TuiState) -> str: + """The run's stop for lack of space, in words, "" for another stop.""" + match = _space_stop(state) + if match is None: + return "" + dest = (state.dest or Path(".")).absolute() + card = any((mount == dest or mount in dest.parents) and card_system(mount) + for mount in _mounted_cards()) + return f"The {'card' if card else 'drive'} is full: {match[1]} needed, {match[2]} free." + + +def _tui_sources(state: TuiState, installed: int, failed: int) -> str: + """Where the installed files came from, as the line mode says it: + "6 files copied from /media/me/OLD, 42 downloaded, 2 still missing." """ + ok = {dest for dest, good, _why in state.finished if good} + folders: dict[str, int] = {} + fixed = 0 + for dest, (folder, reason) in state.local_dests.items(): + if dest not in ok: + continue + if reason: + fixed += 1 + else: + # By its name, as the plan said "6 found in old card". + name = _last_name(folder) + folders[name] = folders.get(name, 0) + 1 + copied = fixed + sum(folders.values()) + return sources_line(list(folders.items()), fixed, max(0, installed - copied), failed) + + +def _unavailable(state: TuiState) -> tuple[list[dict], int]: + """The list's files RetroBIOS cannot supply for the systems chosen: the + ones an emulator needs to start, and how many others.""" + listed = {row["system"] for row in state.systems} + chosen = state.ticked if state.systems else None + needed, others = [], 0 + for entry in (state.manifest or {}).get("omitted_files") or []: + system = entry.get("system") if entry.get("system") in listed else OTHER_SYSTEMS + if chosen is not None and system not in chosen: + continue + if state.no_game_data and entry.get("category") == "game_data": + continue + if entry.get("required"): + needed.append(entry) + else: + others += 1 + return needed, others + + +def _unavailable_lines(state: TuiState) -> list[str]: + """The files that cannot be supplied and stop an emulator, by name and + system: "1 file ... an emulator needs it" named neither.""" + needed, _others = _unavailable(state) + if not needed: + return [] + titles = _system_titles(state) + rows = {row["system"]: row for row in state.systems} + + def system(name: str) -> str: + return _tui_system_label(rows.get(name) or {"system": name}, titles) + + names = [f"{entry['dest']} ({system(entry['system'])})" if entry.get("system") + else entry["dest"] for entry in needed[:3]] + more = f" and {len(needed) - 3} more" if len(needed) > 3 else "" + return [f"Not available here, and needed to start " + f"{'its emulator' if len(needed) == 1 else 'their emulators'}: " + f"{', '.join(names)}{more}. Your own copy goes in the BIOS folder."] + + def tui_outcome(state: TuiState) -> tuple[str, list[str]]: """The result in words, first what happened, then where and what next.""" summary = state.summary or {} @@ -10557,6 +10866,7 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: failed = summary.get("failed") or [] up_to_date = summary.get("up_to_date", 0) total = (state.download or {}).get("files", 0) + space = "" if summary else _space_words(state) if summary.get("interrupted") or state.code == 130: title = "Stopped" head = (f"Stopped: {installed} of {total} installed, run again to finish." @@ -10567,6 +10877,9 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: elif failed: title = "Nothing installed" head = f"Nothing was installed: {_plural(len(failed), 'file')} failed." + elif not summary and space: + title = "Not enough space" + head = space elif not summary: title = "Stopped" head = f"The installer stopped with code {state.code}." @@ -10578,7 +10891,13 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: else: title = "Done" head = "Everything is in place." - lines = [head, f"BIOS folder: {state.dest}"] + lines = [head] + sources = _tui_sources(state, installed, len(failed)) + if sources: + lines.append(sources) + lines.append(f"BIOS folder: {state.dest}") + if space: + lines.append("Free some space, or run again and choose another folder.") causes: dict[str, list[str]] = {} for failure in failed: causes.setdefault(failure["reason"], []).append(failure["dest"]) @@ -10586,8 +10905,8 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: for reason, dests in causes.items(): if len(dests) > 3: # A read-only folder fails every file alike: said once. - lines.append(f" {len(dests)} files failed ({reason or 'no reason given'}), " - f"the first {dests[0]}") + lines.append(f" {len(dests)} files failed, all for one reason: " + f"{reason or 'none given'}.") continue for dest in dests: if shown < 20: @@ -10595,7 +10914,7 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: shown += 1 if shown > 20: lines.append(f" ... and {shown - 20} more") - if not summary: + if not summary and not space: lines += [f" {line}" for line in state.tail[-8:]] run = Invocation() run.os_type = state.os_type @@ -10603,15 +10922,8 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: lines.append(f"The files it replaced are kept in {kept['folder']}") lines.append(f"To put them back: {run.launcher()} --restore " f"{run.word(str(kept['folder']))}") - skipped = summary.get("skipped") or [] - if skipped: - count = sum(group["files"] for group in skipped) - needed = sum(group["required"] for group in skipped) - lines.append(f"{_plural(count, 'file')} of the list " - f"{'is' if count == 1 else 'are'} not in the collection" - + ("." if not needed - else ", and an emulator needs it to start." if count == 1 - else f", {needed} of them needed to start an emulator.")) + if summary and not summary.get("interrupted") and installed + up_to_date: + lines += _unavailable_lines(state) for group in summary.get("left_out") or []: lines.append(f"Left out as {group['label']}: {_plural(group['files'], 'file')}.") lines += ["Same choice without questions:", f" {tui_command(state)}"] @@ -10625,7 +10937,9 @@ def tui_keeps_log(state: TuiState) -> bool: 57 folders of logs from stopped and finished runs were left in /tmp.""" if state.screen == "crashed": return True - return bool(state.log) and state.code not in (0, 130, None) + # A full card is said in words on the screen: its log adds nothing. + return (bool(state.log) and state.code not in (0, 130, None) + and _space_stop(state) is None) def _view_result(state: TuiState, width: int, height: int) -> tuple: @@ -10750,15 +11064,21 @@ def _install_kind(path: Path) -> str: return word if (path.parent / "retroarch.cfg").is_file(): return "portable" - return "/".join(path.parts[-2:]) + return "" def tell_apart(setups: list[tuple]) -> list[tuple]: """Two setups that read alike get the word that separates them: the - Flatpak and the native RetroArch were both "RetroArch" at 40 columns.""" + Flatpak and the native RetroArch were both "RetroArch" at 40 columns. + The one no package names is "installed", and two of those keep the + end of their folder, the only thing that tells them apart.""" labels = [label for _plat, _path, label in setups] - return [(plat, path, f"{label} ({_install_kind(path)})" if labels.count(label) > 1 - else label) for plat, path, label in setups] + kinds = [_install_kind(path) or "installed" for _plat, path, _label in setups] + named = list(zip(labels, kinds)) + return [(plat, path, label if labels.count(label) == 1 + else f"{label} ({kind})" if named.count((label, kind)) == 1 + else f"{label} ({'/'.join(path.parts[-2:])})") + for (plat, path, label), kind in zip(setups, kinds)] def _tui_passthrough(args: argparse.Namespace) -> list[tuple[str, str | None]]: @@ -10805,10 +11125,16 @@ def tui_start( with contextlib.redirect_stdout(captured), contextlib.redirect_stderr(captured): found = detect_platforms(os_type) frontends = detect_frontends(os_type) - cards = [(card_platform(mount), mount) for mount in _mounted_cards()] + cards = [(card_system(mount), mount) for mount in _mounted_cards()] state.setups = [(name, path, _setup_label(name, path)) for name, path in found] - state.setups += [(card, mount, f"{display_name(card)} SD card") - for card, mount in cards if card and (card, mount) not in found] + # On the device itself its own card is the system found, by its + # own path (muOS reads /run/muos/storage/bios, a bind of MUOS/bios). + here = {detected_os(name, path) for name, path in found} + for card, mount in cards: + if card and (card[0], card[1]) not in found and card[2] not in here: + _DETECTED_OS.setdefault((card[0], card[1]), card[2]) + state.setups.append((card[0], card[1], + f"{card[2]} SD card ({_mount_name(mount)})")) state.setups = tell_apart(state.setups) if "esde" in frontends: state.notes.append("ES-DE found: its BIOS files go to each emulator, " @@ -10859,11 +11185,16 @@ def _log_tail(path: Path, count: int = 12) -> list[str]: class _EngineRun: """This installer run as a child with --events, read line by line.""" - def __init__(self, argv: list[str], log: Path, inbox: queue.Queue, run_id: int) -> None: + def __init__( + self, argv: list[str], log: Path, inbox: queue.Queue, run_id: int, + env: dict | None = None, + ) -> None: self.log = log # Its own session: a signal typed at the terminal reaches the # interface alone, which forwards one Ctrl-C and waits for the summary. options: dict = {} if os.name == "nt" else {"start_new_session": True} + if env is not None: + options["env"] = env with open(log, "w", encoding="utf-8", errors="replace") as handle: self.proc = subprocess.Popen( argv, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=handle, @@ -10938,6 +11269,10 @@ def _tui_fetch(plat: str, inbox: queue.Queue, log: io.StringIO) -> None: return except ValueError as exc: title, lines = "The file list is not valid", [f"What arrived is not a file list: {exc}."] + if isinstance(exc.__cause__, (json.JSONDecodeError, UnicodeDecodeError)): + # The parser's "Expecting value: line 2 column 1" told no one anything. + lines = ["What arrived is not a file list: a Wi-Fi sign-in page or a cut " + "download looks like this.", "Check the connection, then choose Try again."] except urllib.error.HTTPError as exc: title = "No file list" lines = [f"There is no file list for {display_name(plat)} at this address." @@ -10946,10 +11281,18 @@ def _tui_fetch(plat: str, inbox: queue.Queue, log: io.StringIO) -> None: code, words = network_words(exc) title = {"clock": "The clock is wrong", "no_ca": "No trusted certificates"}.get(code, "No connection") - lines = [words[0].upper() + words[1:] + "."] + # The screen's first row is Try again: "then run again" was a second way. + lines = [words[0].upper() + words[1:].replace(", then run again", + ", then choose Try again") + "."] if code == "network": lines = [f"The list of BIOS files could not be downloaded: {words}.", "Check the Wi-Fi or the cable, then choose Try again."] + proxy = next((name for name in ("HTTPS_PROXY", "https_proxy", "ALL_PROXY", + "all_proxy") if os.environ.get(name)), "") + if proxy: + # Named, never shown: the value can hold a password. + lines.append(f"{proxy} is set, so the connection goes through a proxy: " + "check that it answers.") if os.environ.get("RETROBIOS_BASE_URL"): lines.append(f"RETROBIOS_BASE_URL chose the address: {url}") inbox.put(("manifest_failed", plat, title, lines, 1)) @@ -11078,6 +11421,66 @@ class _TuiTerminal: time.sleep(0.01) +TUI_WORK_PREFIX = "retrobios-tui-" +# The interface's process id, in its work folder: a folder whose process is +# gone was left by a kill no handler sees (SIGKILL, a power cut). +TUI_OWNER = "owner" +# Written when a failed run's log is kept: it stays a week, then goes. +TUI_KEPT = "kept" +TUI_KEEP_SECONDS = 7 * 24 * 3600 + + +def _owner_alive(folder: Path) -> bool | None: + """Whether the run that made folder is still running, None when unknown.""" + try: + pid = int((folder / TUI_OWNER).read_text(encoding="utf-8").strip()) + except (OSError, ValueError): + return None + if os.name == "nt": + # os.kill(pid, 0) ends a process on Windows instead of asking. + return None + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + except OSError: + return None + return True + + +def _mark_kept(work: Path) -> None: + try: + (work / TUI_KEPT).write_text("", encoding="utf-8") + except OSError as exc: + # The log is still there, only its week is not counted. + print(f"The log folder {work} could not be marked: {exc}", file=sys.stderr) + + +def _prune_work_folders(root: Path, now: float) -> list[Path]: + """Remove what earlier runs of the interface left in root: the folder of + a run killed outright, and a failed run's log after a week. A folder + whose run is still going, or unknown and recent, stays.""" + removed = [] + try: + folders = [path for path in root.glob(TUI_WORK_PREFIX + "*") if path.is_dir()] + except OSError: + return removed + for folder in folders: + try: + age = now - folder.stat().st_mtime + kept = (folder / TUI_KEPT).exists() + except OSError: + continue + alive = _owner_alive(folder) + if alive or ((alive is None or kept) and age < TUI_KEEP_SECONDS): + continue + shutil.rmtree(folder, ignore_errors=True) + removed.append(folder) + return removed + + def tui_farewell(state: TuiState) -> list[str]: """What stays in the terminal once the full screen is gone.""" return [screen_text(line) for line in _farewell_lines(state)] @@ -11109,7 +11512,9 @@ def run_tui(args: argparse.Namespace) -> int: _enter_wsl() _open_local_sources(args) state, effects = tui_start(args, os_type) - work = Path(tempfile.mkdtemp(prefix="retrobios-tui-")) + work = Path(tempfile.mkdtemp(prefix=TUI_WORK_PREFIX)) + (work / TUI_OWNER).write_text(str(os.getpid()), encoding="utf-8") + _prune_work_folders(work.parent, time.time()) script = str(Path(__file__).resolve()) inbox: queue.Queue = queue.Queue() current: list[_EngineRun] = [] @@ -11144,10 +11549,11 @@ def run_tui(args: argparse.Namespace) -> int: log = work / f"{effect[1]}.log" if effect[1] == "install": state.log = str(log) + own_list = state.manifest_path in written.values() try: current.append(_EngineRun( tui_argv(state, effect[1], sys.executable, script), log, inbox, - state.run_id)) + state.run_id, {**os.environ, TUI_LIST_ENV: "1"} if own_list else None)) except OSError as exc: inbox.put(("ended", state.run_id, 1, [f"The installer could not be started: {exc}"])) @@ -11254,6 +11660,8 @@ def run_tui(args: argparse.Namespace) -> int: path.unlink() if not tui_keeps_log(state): shutil.rmtree(work, ignore_errors=True) + else: + _mark_kept(work) stream = sys.stderr if state.screen in ("error", "crashed") else sys.stdout lines = tui_farewell(state) if problems: diff --git a/install.sh b/install.sh index 98cf7f82..d99d7ebe 100755 --- a/install.sh +++ b/install.sh @@ -18,7 +18,7 @@ esac TEMP_INSTALLER="" TEMP_DIRECTORY="" DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" -DEFAULT_INSTALL_SHA256="bcbde4c289b32c02ada78001b75274a87b125c1efd3d8fb1064d7f5482ef3997" +DEFAULT_INSTALL_SHA256="7ac3e6030b12bacd262964fea5373f27fb09e62296255bc3c22255a1aaad5fe8" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/tests/fixtures/tui/confirm-120x40.txt b/tests/fixtures/tui/confirm-120x40.txt index 66f8307e..89ee7e0e 100644 --- a/tests/fixtures/tui/confirm-120x40.txt +++ b/tests/fixtures/tui/confirm-120x40.txt @@ -1,8 +1,8 @@ RetroBIOS: Confirm 1 file to download, 16.0 KB - 0 in place, 1 missing, 0 wrong copies BIOS folder: ~/.config/retroarch/system + 0 in place, 1 missing, 0 wrong copies Free space: 50.0 GB > Install diff --git a/tests/fixtures/tui/confirm-40x12.txt b/tests/fixtures/tui/confirm-40x12.txt index 69373976..e6b28e26 100644 --- a/tests/fixtures/tui/confirm-40x12.txt +++ b/tests/fixtures/tui/confirm-40x12.txt @@ -1,12 +1,12 @@ RetroBIOS: Confirm 1 file to download, 16.0 KB + BIOS folder: ~/.co.../retroarch/system 0 in place, 1 missing, 0 wrong copies - BIOS folder: - ~/.config/retroarch/system Free space: 50.0 GB > Install Back + A/Enter Install B/Esc Back ? Help diff --git a/tests/fixtures/tui/confirm-40x15.txt b/tests/fixtures/tui/confirm-40x15.txt index 7bf203fd..d44fb000 100644 --- a/tests/fixtures/tui/confirm-40x15.txt +++ b/tests/fixtures/tui/confirm-40x15.txt @@ -1,9 +1,8 @@ RetroBIOS: Confirm 1 file to download, 16.0 KB + BIOS folder: ~/.co.../retroarch/system 0 in place, 1 missing, 0 wrong copies - BIOS folder: - ~/.config/retroarch/system Free space: 50.0 GB > Install @@ -12,4 +11,5 @@ + A/Enter Install B/Esc Back ? Help diff --git a/tests/fixtures/tui/confirm-80x24.txt b/tests/fixtures/tui/confirm-80x24.txt index 9e63bd66..6e210148 100644 --- a/tests/fixtures/tui/confirm-80x24.txt +++ b/tests/fixtures/tui/confirm-80x24.txt @@ -1,8 +1,8 @@ RetroBIOS: Confirm 1 file to download, 16.0 KB - 0 in place, 1 missing, 0 wrong copies BIOS folder: ~/.config/retroarch/system + 0 in place, 1 missing, 0 wrong copies Free space: 50.0 GB > Install diff --git a/tests/fixtures/tui/options-120x40.txt b/tests/fixtures/tui/options-120x40.txt index b3b47fd2..21aae272 100644 --- a/tests/fixtures/tui/options-120x40.txt +++ b/tests/fixtures/tui/options-120x40.txt @@ -3,7 +3,7 @@ Each row is an option of the command line, named on the right. > Preferred regions: North America, Japan --region - Leave out game data: no --no-game-data + Game data: kept --no-game-data Installed cores only: no (1 found) --installed-cores Cores: all --core Parallel downloads: 8 --jobs diff --git a/tests/fixtures/tui/options-40x12.txt b/tests/fixtures/tui/options-40x12.txt index 81190984..0aad95e4 100644 --- a/tests/fixtures/tui/options-40x12.txt +++ b/tests/fixtures/tui/options-40x12.txt @@ -1,7 +1,7 @@ RetroBIOS: Options > Preferred regions: North America, Ja - Leave out game data: no + Game data: kept Installed cores only: no (1 found) Cores: all Parallel downloads: 8 diff --git a/tests/fixtures/tui/options-40x15.txt b/tests/fixtures/tui/options-40x15.txt index 1508976a..0d94cbea 100644 --- a/tests/fixtures/tui/options-40x15.txt +++ b/tests/fixtures/tui/options-40x15.txt @@ -1,7 +1,7 @@ RetroBIOS: Options > Preferred regions: North America, Ja - Leave out game data: no + Game data: kept Installed cores only: no (1 found) Cores: all Parallel downloads: 8 diff --git a/tests/fixtures/tui/options-80x24.txt b/tests/fixtures/tui/options-80x24.txt index 041866a9..b4e6e1da 100644 --- a/tests/fixtures/tui/options-80x24.txt +++ b/tests/fixtures/tui/options-80x24.txt @@ -3,7 +3,7 @@ Each row is an option of the command line, named on the right. > Preferred regions: North America, Japan --region - Leave out game data: no --no-game-data + Game data: kept --no-game-data Installed cores only: no (1 found) --installed-cores Cores: all --core Parallel downloads: 8 --jobs diff --git a/tests/fixtures/tui/plan-120x40.txt b/tests/fixtures/tui/plan-120x40.txt index b4fabf6b..0c0ad949 100644 --- a/tests/fixtures/tui/plan-120x40.txt +++ b/tests/fixtures/tui/plan-120x40.txt @@ -1,11 +1,10 @@ RetroBIOS: RetroArch 4 files to download, 1.0 MB - 1 in place, 3 missing, 1 wrong copy BIOS folder: ~/.config/retroarch/system + 1 in place, 3 missing, 1 wrong copy Free space: 50.0 GB They are replaced, and the old copies are kept in a backup folder. - 2 files of the list are not in the collection. > Install Choose systems @@ -35,6 +34,7 @@ + A/Enter Install B/Esc Back Y/o Options ? Help diff --git a/tests/fixtures/tui/plan-40x12.txt b/tests/fixtures/tui/plan-40x12.txt index b0fbb0d5..80132bf2 100644 --- a/tests/fixtures/tui/plan-40x12.txt +++ b/tests/fixtures/tui/plan-40x12.txt @@ -1,9 +1,9 @@ RetroBIOS: RetroArch 4 files to download, 1.0 MB + BIOS folder: ~/.co.../retroarch/system 1 in place, 3 missing, 1 wrong copy - BIOS folder: - ~/.config/retroarch/system + Free space: 50.0 GB > Install Choose systems Copy from your old card or a folder diff --git a/tests/fixtures/tui/plan-40x15.txt b/tests/fixtures/tui/plan-40x15.txt index 94738418..709f289f 100644 --- a/tests/fixtures/tui/plan-40x15.txt +++ b/tests/fixtures/tui/plan-40x15.txt @@ -1,12 +1,12 @@ RetroBIOS: RetroArch 4 files to download, 1.0 MB + BIOS folder: ~/.co.../retroarch/system 1 in place, 3 missing, 1 wrong copy - BIOS folder: - ~/.config/retroarch/system Free space: 50.0 GB They are replaced, and the old copies are kept in a backup folder. + > Install Choose systems Copy from your old card or a folder diff --git a/tests/fixtures/tui/plan-80x24.txt b/tests/fixtures/tui/plan-80x24.txt index 0e320fc2..ee2684f4 100644 --- a/tests/fixtures/tui/plan-80x24.txt +++ b/tests/fixtures/tui/plan-80x24.txt @@ -1,11 +1,10 @@ RetroBIOS: RetroArch 4 files to download, 1.0 MB - 1 in place, 3 missing, 1 wrong copy BIOS folder: ~/.config/retroarch/system + 1 in place, 3 missing, 1 wrong copy Free space: 50.0 GB They are replaced, and the old copies are kept in a backup folder. - 2 files of the list are not in the collection. > Install Choose systems @@ -21,4 +20,5 @@ + A/Enter Install B/Esc Back Y/o Options ? Help diff --git a/tests/fixtures/tui/plan-in-place-120x40.txt b/tests/fixtures/tui/plan-in-place-120x40.txt index 97d74f80..8f604834 100644 --- a/tests/fixtures/tui/plan-in-place-120x40.txt +++ b/tests/fixtures/tui/plan-in-place-120x40.txt @@ -1,8 +1,8 @@ RetroBIOS: RetroArch Everything is in place - 5 in place, 0 missing, 0 wrong copies BIOS folder: ~/.config/retroarch/system + 5 in place, 0 missing, 0 wrong copies Free space: 50.0 GB > Close diff --git a/tests/fixtures/tui/plan-in-place-40x12.txt b/tests/fixtures/tui/plan-in-place-40x12.txt index 8755e793..0bd14655 100644 --- a/tests/fixtures/tui/plan-in-place-40x12.txt +++ b/tests/fixtures/tui/plan-in-place-40x12.txt @@ -1,12 +1,12 @@ RetroBIOS: RetroArch Everything is in place + BIOS folder: ~/.co.../retroarch/system 5 in place, 0 missing, 0 wrong copies - BIOS folder: - ~/.config/retroarch/system Free space: 50.0 GB > Close Options + A Close B Back Y Options ? Help diff --git a/tests/fixtures/tui/plan-in-place-40x15.txt b/tests/fixtures/tui/plan-in-place-40x15.txt index 0aa6b200..f4907958 100644 --- a/tests/fixtures/tui/plan-in-place-40x15.txt +++ b/tests/fixtures/tui/plan-in-place-40x15.txt @@ -1,9 +1,8 @@ RetroBIOS: RetroArch Everything is in place + BIOS folder: ~/.co.../retroarch/system 5 in place, 0 missing, 0 wrong copies - BIOS folder: - ~/.config/retroarch/system Free space: 50.0 GB > Close @@ -12,4 +11,5 @@ + A Close B Back Y Options ? Help diff --git a/tests/fixtures/tui/plan-in-place-80x24.txt b/tests/fixtures/tui/plan-in-place-80x24.txt index a73a9173..8cf344f6 100644 --- a/tests/fixtures/tui/plan-in-place-80x24.txt +++ b/tests/fixtures/tui/plan-in-place-80x24.txt @@ -1,8 +1,8 @@ RetroBIOS: RetroArch Everything is in place - 5 in place, 0 missing, 0 wrong copies BIOS folder: ~/.config/retroarch/system + 5 in place, 0 missing, 0 wrong copies Free space: 50.0 GB > Close diff --git a/tests/fixtures/tui/result-120x40.txt b/tests/fixtures/tui/result-120x40.txt index 4f0bc6ec..16b4dffc 100644 --- a/tests/fixtures/tui/result-120x40.txt +++ b/tests/fixtures/tui/result-120x40.txt @@ -6,7 +6,6 @@ The files it replaced are kept in /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 To put them back: python3 install.py --restore /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 - 2 files of the list are not in the collection, 1 of them needed to start an emulator. Same choice without questions: python3 install.py --platform retroarch --dest /home/user/.config/retroarch/system Details: /tmp/retrobios-x/install.log @@ -35,6 +34,7 @@ + A/Enter Exit Up/Down Scroll diff --git a/tests/fixtures/tui/result-40x12.txt b/tests/fixtures/tui/result-40x12.txt index 85613a75..e1ef9b13 100644 --- a/tests/fixtures/tui/result-40x12.txt +++ b/tests/fixtures/tui/result-40x12.txt @@ -8,5 +8,5 @@ /home/user/.config/retroarch/ retrobios-backup/2026-10-23_1200 To put them back: python3 install.py - 1-4 of 9 + 1-4 of 8 A/Enter Exit Up/Down Scroll diff --git a/tests/fixtures/tui/result-40x15.txt b/tests/fixtures/tui/result-40x15.txt index 86cb62b4..c551897a 100644 --- a/tests/fixtures/tui/result-40x15.txt +++ b/tests/fixtures/tui/result-40x15.txt @@ -11,5 +11,5 @@ --restore /home/user/.config/retroarch/ retrobios-backup/2026-10-23_1200 - 1-5 of 9 + 1-5 of 8 A/Enter Exit Up/Down Scroll diff --git a/tests/fixtures/tui/result-80x24.txt b/tests/fixtures/tui/result-80x24.txt index 5e8e6cb9..ee5cd165 100644 --- a/tests/fixtures/tui/result-80x24.txt +++ b/tests/fixtures/tui/result-80x24.txt @@ -7,8 +7,6 @@ /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 To put them back: python3 install.py --restore /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 - 2 files of the list are not in the collection, 1 of them needed to start an - emulator. Same choice without questions: python3 install.py --platform retroarch --dest /home/user/.config/retroarch/system @@ -21,4 +19,6 @@ + + A/Enter Exit Up/Down Scroll diff --git a/tests/test_install_from.py b/tests/test_install_from.py index 056c3c8f..7cb60f7f 100644 --- a/tests/test_install_from.py +++ b/tests/test_install_from.py @@ -349,7 +349,8 @@ class TheInterfaceReadsAFoundEvent(unittest.TestCase): state, _before = self.receive("check") self.assertEqual(state.found, [self.FOUND]) state, _before = self.receive("install") - self.assertEqual(state.local_dests, {"scph5501.bin"}) + self.assertEqual(list(state.local_dests), ["scph5501.bin"]) + self.assertEqual(state.local_dests["scph5501.bin"][0], self.FOUND["folder"]) def test_a_dry_run_ignores_it(self): for run in ("confirm", "preview"): diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 2bebdce9..7feade1c 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -28,7 +28,7 @@ import threading import time import unittest import warnings -from pathlib import Path +from pathlib import Path, PureWindowsPath from unittest import mock REPO_ROOT = Path(__file__).resolve().parent.parent @@ -656,9 +656,10 @@ class Flow(unittest.TestCase): mock.patch.object(install, "detect_frontends", return_value=[]): state, effects = install.tui_start(_args(), "linux") self.assertEqual((state.screen, effects), ("setup", [])) - self.assertEqual(state.setups, [("minui", card, "MinUI SD card")]) + label = f"MinUI SD card ({card.name})" + self.assertEqual(state.setups, [("minui", card, label)]) self.assertEqual([row.label for row in install.tui_rows(state)], - ["MinUI SD card", "Another setup or folder"]) + [label, "Another setup or folder"]) def test_an_error_offers_another_try(self): state = fresh_state() @@ -717,9 +718,11 @@ class PlanWarnings(unittest.TestCase): def test_a_folder_that_cannot_take_the_files_says_so_first(self): state = checked_state({"free": 1024, "writable": False, "cores": []}) text = " ".join(install.tui_view(state, 120, 40)) - self.assertIn("Not enough free space for this download", text) - self.assertIn("You cannot write to this folder: choose another one, or run the " - "installer with sudo.", text) + self.assertIn("Not enough free space: 1.0 MB needed.", text) + self.assertIn("You cannot write to this folder: choose another one.", text) + # sudo changes the home folder: the advice names this one. + self.assertIn("Or run it as root, this folder named: sudo python3 install.py " + f"--platform retroarch --dest {SYSTEM_DIR}", " ".join(text.split())) rows = install.tui_rows(state) self.assertEqual([row.action for row in rows[:2]], ["folder", "install"]) self.assertEqual(state.focus.get("plan", 0), 0) @@ -1248,8 +1251,9 @@ class CleanupSurvivesABrokenTerminal(unittest.TestCase): code = install.run_tui(_args()) self.assertIs(sys.stdout, tty) self.assertEqual(code, 1) - # The crash keeps its log, and nothing else of the run. - self.assertEqual(sorted(path.name for path in work.iterdir()), ["error.log"]) + # The crash keeps its log, marked kept, and nothing else of the run. + self.assertEqual(sorted(path.name for path in work.iterdir()), + ["error.log", "kept", "owner"]) self.assertEqual(termios.tcgetattr(slave), before) for number, handler in handlers.items(): self.assertEqual(signal.getsignal(number), handler) @@ -1384,7 +1388,7 @@ class AuditRoundTwo(unittest.TestCase): _title, lines = install.tui_outcome(state) self.assertEqual([line for line in lines if "failed" in line], ["Nothing was installed: 50 files failed.", - " 50 files failed (folder not writable), the first f0.bin"]) + " 50 files failed, all for one reason: folder not writable."]) def test_p2_6_a_folder_inside_a_card_offers_its_root(self): card = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) @@ -1444,7 +1448,12 @@ class AuditRoundTwo(unittest.TestCase): ("retroarch", SYSTEM_DIR, "RetroArch"), ("emudeck", HOME / "Emulation/bios", "EmuDeck")]) self.assertEqual([label for _p, _f, label in setups], - ["RetroArch (Flatpak)", "RetroArch (retroarch/system)", "EmuDeck"]) + ["RetroArch (Flatpak)", "RetroArch (installed)", "EmuDeck"]) + # Two that no package names keep the end of their folder. + twice = install.tell_apart([("retroarch", SYSTEM_DIR, "RetroArch"), + ("retroarch", HOME / "ra2/system", "RetroArch")]) + self.assertEqual([label for _p, _f, label in twice], + ["RetroArch (retroarch/system)", "RetroArch (ra2/system)"]) state = fresh_state() state.setups = setups self.assertIn("RetroArch (Flatpak)", "\n".join(install.tui_view(state, 40, 15))) @@ -1634,7 +1643,8 @@ class CopyFromAnOldCard(unittest.TestCase): self.recheck(state, [found("scph5500.bin", str(old)), found("gba_bios.bin", str(old))]) self.assertEqual(state.screen, "plan") text = "".join("".join(install.tui_view(state, 120, 40)).split()) - self.assertIn("".join(f"2 found in {old}, 2 to download (512.0 KB)".split()), text) + # The folder by its name: the whole path pushed the BIOS folder off. + self.assertIn("".join(f"2 found in {old.name}, 2 to download (512.0 KB)".split()), text) # A second folder from the options: --from twice. press(state, "o") state.focus["options"] = next(i for i, r in enumerate(install.tui_rows(state)) @@ -1655,11 +1665,15 @@ class CopyFromAnOldCard(unittest.TestCase): def test_left_takes_the_last_folder_back(self): state = checked_state() state.from_folders = [Path("/media/old")] + state.checked_flags = install.tui_flags(state) state.stack.append("options") state.focus["options"] = next(i for i, r in enumerate(install.tui_rows(state)) if r.action == "from") - self.assertEqual(press(state, "left"), [("run", "check")]) - self.assertEqual(state.from_folders, []) + # Said, and the screen stays: Back to the plan counts again. + self.assertEqual(press(state, "left"), []) + self.assertEqual((state.screen, state.from_folders), ("options", [])) + self.assertIn("No longer copying from old.", "\n".join(install.tui_view(state, 80, 24))) + self.assertEqual(press(state, "esc"), [("run", "check")]) def test_the_command_line_folders_are_kept(self): with mock.patch.object(install, "detect_platforms", return_value=[]), \ @@ -1710,6 +1724,438 @@ class CopyFromAnOldCard(unittest.TestCase): self.assertIn("1.0 MB/s, 1:38 left", text) +class AuditRoundThree(unittest.TestCase): + """tui_audit_2.md: its P1 and P2 findings, each held by a test.""" + + CARDS = { + "muOS": ({"MUOS/": ""}, "retroarch", "MUOS/bios"), + "Onion": ({".tmp_update/onionVersion/version.txt": "4.3.1\n"}, "retroarch", "BIOS"), + "spruceOS": ({"spruce/": ""}, "retroarch", "BIOS"), + "KNULLI": ({"system/batocera.conf": "a=1\nsystem.hostname=KNULLI\n"}, "knulli", + "bios"), + "ROCKNIX": ({".config/system/configs/system.cfg": "root.password=rocknix\n"}, + "rocknix", "roms/bios"), + } + + def card(self, files: dict) -> Path: + root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, root, True) + for name, text in files.items(): + path = root / name + if name.endswith("/"): + path.mkdir(parents=True, exist_ok=True) + else: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return root + + # P1-1: a handheld's card mounted on a computer. + + def test_p1_1_each_card_by_its_own_files(self): + for name, (files, plat, folder) in self.CARDS.items(): + with self.subTest(card=name): + root = self.card(files) + self.assertEqual(install.card_system(root), (plat, root / folder, name)) + + def test_p1_1_a_card_nothing_names_is_left_alone(self): + self.assertIsNone(install.card_system(self.card({"roms/": "", "BIOS/": ""}))) + # Batocera's share and a KNULLI board seeded with its name read alike. + self.assertIsNone(install.card_system(self.card({ + "system/batocera.conf": "system.hostname=BATOCERA\n"}))) + + def test_p1_1_the_card_rules_are_the_device_rules(self): + # The folder and the marker come from the rule the device reads, so + # they are never written twice. + rules = {rule.name: rule for rule in install._card_rules()} + self.assertLessEqual({"muOS", "Onion", "spruceOS", "CrossMix-OS", "Allium", "Koriki", + "KNULLI", "ROCKNIX"}, set(rules)) + device = {rule.name: rule for rule in install._EMBEDDED_RULES + if rule.values and rule.values[0].startswith(install._CARD_MOUNTS)} + for name, rule in device.items(): + if rule.bios and rule.manifest: + with self.subTest(system=name): + self.assertTrue(rule.bios.endswith("/" + rules[name].bios)) + self.assertEqual(install.DEFAULT_DESTS["knulli"], Path("/userdata") / "bios") + self.assertEqual(install.DEFAULT_DESTS["rocknix"], Path("/storage") / "roms/bios") + + def test_p1_1_the_first_screen_offers_the_card_folder(self): + root = self.card(self.CARDS["muOS"][0]) + with mock.patch.object(install, "_mounted_cards", return_value=[root]), \ + mock.patch.object(install, "detect_platforms", return_value=[]), \ + mock.patch.object(install, "detect_frontends", return_value=[]): + state, effects = install.tui_start(_args(), "linux") + label = f"muOS SD card ({root.name})" + self.assertEqual(state.setups, [("retroarch", root / "MUOS" / "bios", label)]) + text = " ".join(" ".join(install.tui_view(state, 120, 40)).split()) + self.assertIn(f"> {label}", text) + self.assertIn(f"BIOS folder: {root / 'MUOS' / 'bios'}", text) + (root / "MUOS" / "bios").mkdir() + self.assertEqual(press(state, "enter"), [("fetch", "retroarch")]) + self.assertEqual(state.dest, root / "MUOS" / "bios") + + def test_p1_1_a_drive_letter_names_the_card(self): + self.assertEqual(install._mount_name(PureWindowsPath("E:\\")), "E:\\") + self.assertEqual(install._mount_name(Path("/media/me/MUOS")), "MUOS") + + def test_p1_1_on_the_device_its_card_is_not_offered_twice(self): + root = self.card(self.CARDS["muOS"][0]) + bound = Path("/run/muos/storage/bios") + with mock.patch.object(install, "_mounted_cards", return_value=[root]), \ + mock.patch.object(install, "detect_platforms", return_value=[("retroarch", + bound)]), \ + mock.patch.object(install, "detect_frontends", return_value=[]), \ + mock.patch.dict(install._DETECTED_OS, {("retroarch", bound): "muOS"}): + state, _effects = install.tui_start(_args(), "linux") + self.assertEqual([setup[1] for setup in state.setups], [bound]) + + def test_p1_1_browsing_to_the_card_root_uses_its_folder(self): + root = self.card(self.CARDS["KNULLI"][0]) + with mock.patch.object(install, "_mounted_cards", return_value=[root]): + rows, _error = install._browse_rows(REPO_ROOT / "tests") + self.assertEqual(rows[2].label, f"{root} (KNULLI card)") + state = fresh_state() + install._choose(state, "knulli", root) + self.assertEqual(state.dest, root / "bios") + # Another platform's list keeps the folder it was given. + install._choose(state, "batocera", root) + self.assertEqual(state.dest, root) + + def test_p1_1_the_command_line_writes_where_muos_reads(self): + root = self.card(self.CARDS["muOS"][0]) + with FakeRepository() as repo: + proc = install_run("--platform", "muos", "--dest", str(root), "--yes", + env=repo.env()) + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + self.assertIn(f"muOS card: RetroArch files, in {root / 'MUOS' / 'bios'}", proc.stdout) + self.assertEqual(sorted(path.name for path in root.iterdir()), ["MUOS"]) + self.assertTrue((root / "MUOS" / "bios" / "scph5501.bin").is_file()) + + def test_p1_1_a_folder_that_is_no_muos_card_is_refused(self): + root = self.card({"roms/": ""}) + proc = install_run("--platform", "muos", "--dest", str(root), "--yes") + self.assertEqual(proc.returncode, 1) + self.assertIn(f"{root} is not a muOS card: it holds no MUOS.", proc.stderr) + self.assertIn("--dest /MUOS/bios", proc.stderr) + self.assertEqual(list(root.iterdir()), [root / "roms"]) + # Its BIOS folder named outright is taken as given. + self.assertEqual(install.card_dest("muos", "retroarch", root / "MUOS" / "bios"), + ("retroarch", root / "MUOS" / "bios")) + # On the device, the folder muOS binds it to. + bound = Path("/run/muos/storage/bios") + self.assertEqual(install.card_dest("muos", "retroarch", bound), ("retroarch", bound)) + # A list named by its own name says which list, not which card. + self.assertEqual(install.card_dest("retroarch", "retroarch", root), + ("retroarch", root)) + + # P1-2: Space is X on PortMaster and Y on muxterm. + + def test_p1_2_space_on_install_opens_the_confirm_screen(self): + state = checked_state() + self.assertEqual(install.tui_rows(state)[0].action, "install") + self.assertEqual(press(state, " "), [("run", "confirm")]) + self.assertEqual(state.screen, "confirm") + # The same plan comes back: the screen still waits for A. + install.tui_receive(state, ("event", state.run_id, dict(CHECK))) + self.assertEqual(install.tui_receive(state, ("ended", state.run_id, 0, [])), []) + self.assertEqual(state.screen, "confirm") + self.assertEqual(install.tui_rows(state)[0].action, "install") + self.assertEqual(press(state, " "), []) + self.assertEqual(state.screen, "confirm") + self.assertEqual(press(state, "enter"), [("run", "install")]) + + def test_p1_2_space_never_closes_or_quits(self): + for action in ("quit", "close"): + with self.subTest(row=action): + state = checked_state() + if action == "close": + state.check = {**CHECK, "missing": 0, "wrong_hash": 0, "bytes_needed": 0} + rows = install.tui_rows(state) + state.focus["plan"] = next(i for i, r in enumerate(rows) if r.action == action) + self.assertEqual(press(state, " "), []) + self.assertIsNone(state.exit_code) + state = checked_state() + state.focus["plan"] = next(i for i, r in enumerate(install.tui_rows(state)) + if r.action == "options") + press(state, " ") + self.assertEqual(state.screen, "options") + + def test_p1_2_enter_still_installs_in_three_presses(self): + state = checked_state() + self.assertEqual(press(state, "enter"), [("run", "install")]) + self.assertEqual(state.screen, "installing") + + # P2-1: a card too small for the download. + + def test_p2_1_another_folder_comes_first_when_space_is_short(self): + state = checked_state({"free": 1024, "writable": True, "cores": []}) + self.assertEqual([row.label for row in install.tui_rows(state)[:2]], + ["Choose another folder", "Install anyway"]) + self.assertEqual(press(state, "enter"), []) + self.assertEqual(state.screen, "folder") + + FULL_TAIL = [ + " Checking existing files...", + " 0/50 present (0 verified, 0 wrong hash)", + " Downloading 50 files (845.7 KB)...", + "Not enough space on the drive holding /media/me/MUOS/MUOS/bios.", + " Needed: 1.2 GB. Free: 300.0 MB.", + " Free up 920.0 MB, or install to another drive:", + " python3 install.py --platform retroarch --dest ''", + ] + + def test_p2_1_a_full_card_is_said_in_words(self): + root = self.card(self.CARDS["muOS"][0]) + state = checked_state() + state.dest, state.log = root / "MUOS" / "bios", "/tmp/retrobios-tui-x/install.log" + state.stack.append("result") + state.code, state.tail = 2, list(self.FULL_TAIL) + with mock.patch.object(install, "_mounted_cards", return_value=[root]): + title, lines = install.tui_outcome(state) + self.assertEqual(title, "Not enough space") + self.assertEqual(lines[0], "The card is full: 1.2 GB needed, 300.0 MB free.") + self.assertIn("Free some space, or run again and choose another folder.", lines) + text = "\n".join(lines) + for raw in ("Checking existing files", "Downloading", "stopped with code", "Details:"): + self.assertNotIn(raw, text) + self.assertFalse(install.tui_keeps_log(state)) + + def test_p2_1_advice_never_names_the_interface_list(self): + if hasattr(os, "geteuid") and os.geteuid() == 0: + self.skipTest("root writes to a read-only folder") + folder = self.card({}) + folder.chmod(0o555) + self.addCleanup(folder.chmod, 0o755) + with FakeRepository() as repo: + listed = repo.root / "install" / "retroarch.json" + proc = install_run("--platform", "retroarch", "--dest", str(folder), + f"--manifest={listed}", "--yes", + env={**repo.env(), install.TUI_LIST_ENV: "1"}) + self.assertIn("sudo", proc.stdout + proc.stderr) + self.assertNotIn("--manifest", proc.stdout + proc.stderr) + + # P2-2: the BIOS folder line on a handheld screen. + + def test_p2_2_the_plan_keeps_the_folder_at_handheld_sizes(self): + zipped = "/home/me/a/long/path/to/the/Downloads/PSX BIOS.zip" + for cols, rows in ((40, 12), (40, 15)): + with self.subTest(size=f"{cols}x{rows}"): + state = checked_state() + state.dest = Path("/home/someone/with/a/long/path/.config/retroarch/system") + state.found = [found("scph5500.bin", zipped)] + state.cols, state.rows = cols, rows + lines = [line.strip() for line in install.tui_view(state, cols, rows)] + folder = next(line for line in lines if line.startswith("BIOS folder: ")) + self.assertTrue(folder.endswith("retroarch/system"), folder) + self.assertIn("...", folder) + self.assertIn("1 found in PSX BIOS.zip,", " ".join(lines)) + self.assertNotIn("/home/me/a/long", " ".join(lines)) + + # P2-3: where the files came from. + + def test_p2_3_the_result_says_what_came_from_the_card(self): + state = checked_state() + state.stack.append("result") + state.code, state.download = 0, {"files": 5, "bytes": 10} + state.local_dests = {"scph5500.bin": ("/media/me/OLD", ""), + "scph5501.bin": ("/media/me/OLD", ""), + "gba_bios.bin": (str(SYSTEM_DIR), "one folder too deep")} + state.finished = [("scph5500.bin", True, ""), ("scph5501.bin", True, ""), + ("gba_bios.bin", True, ""), ("pcsx/extra.bin", True, ""), + ("fc/disksys.rom", False, "HTTP 404")] + state.summary = {"installed": 4, "up_to_date": 0, "failed": [ + {"dest": "fc/disksys.rom", "reason": "HTTP 404"}]} + _title, lines = install.tui_outcome(state) + self.assertEqual(lines[1], "2 files copied from OLD, 1 fixed from copies " + "already in the BIOS folder, 1 downloaded, 1 still missing.") + # The line mode's own sentence, from the same counts. + self.assertEqual(lines[1], install.sources_line([("OLD", 2)], 1, 1, 1)) + + def test_p2_3_a_run_that_copied_nothing_adds_no_line(self): + state = install.TuiState() + state.summary, state.finished = {"installed": 2, "failed": []}, [("a", True, "")] + self.assertEqual(install._tui_sources(state, 2, 0), "") + + # P2-4 and P2-5: the systems list. + + def systems_state(self) -> install.TuiState: + state = checked_state() + state.systems = [*state.systems, + {"system": "other", "name": "other", "files": 2, "present": 0, + "missing": 2, "wrong_hash": 0, "bytes_needed": 300}, + {"system": "3do", "name": "3do", "files": 3, "present": 0, + "missing": 3, "wrong_hash": 0, "bytes_needed": 600}] + state.files = [*state.files, + {"event": "file", "dest": "panafz1.bin", "systems": ["3do"], + "size": 100, "state": "missing"}, + {"event": "file", "dest": "mame/hash/a.xml", "systems": ["3do", "mac"], + "size": 200, "state": "missing"}, + {"event": "file", "dest": "mame/hash/b.xml", "systems": ["3do", "mac"], + "size": 300, "state": "missing"}] + state.ticked |= {"other", "3do"} + state.stack.append("systems") + return state + + def test_p2_4_the_other_row_is_named_and_explained(self): + state = self.systems_state() + rows = install.tui_rows(state) + other = next(i for i, row in enumerate(rows) if row.value == "other") + self.assertEqual(rows[other].label, "[x] Other systems and game engines") + state.focus["systems"] = other + text = " ".join(" ".join(install.tui_view(state, 80, 24)).split()) + self.assertIn("Other systems and game engines: what cores load for systems this " + "setup does not list", text) + + def test_p2_5_shared_files_are_counted_apart(self): + state = self.systems_state() + rows = install.tui_rows(state) + three = next(i for i, row in enumerate(rows) if row.value == "3do") + self.assertEqual(rows[three].right, "1 file, 100 B + 2 shared") + state.focus["systems"] = three + text = " ".join(" ".join(install.tui_view(state, 80, 24)).split()) + self.assertIn("2 of these files are shared with other systems, counted once", text) + # The line mode counts the same files apart. + self.assertIn("and 2 files, 500 B shared with other systems", install._system_sized([ + {"dest": "panafz1.bin", "size": 100, "systems": ["3do"]}, + {"dest": "a.xml", "size": 200, "systems": ["3do", "mac"]}, + {"dest": "b.xml", "size": 300, "systems": ["3do", "mac"]}])) + narrow = install.tui_view(state, 40, 15) + self.assertTrue(any("100 B + 2 shared" in line for line in narrow), narrow) + + # P2-6: a file that cannot be supplied, by name, where it matters. + + def unavailable_state(self) -> install.TuiState: + state = checked_state() + state.manifest = {**MANIFEST, "system_titles": {"nintendo-gba": "Game Boy Advance"}, + "omitted_files": [ + {"dest": "gba_extra.bin", "system": "nintendo-gba", + "required": True, "reason": "not_found"}, + {"dest": "optional.bin", "system": "nintendo-gba", + "required": False, "reason": "not_found"}]} + state.stack.append("result") + state.code, state.download = 0, {"files": 1, "bytes": 1} + state.summary = {"installed": 1, "up_to_date": 0, "failed": []} + return state + + def test_p2_6_the_missing_file_is_named_with_its_system(self): + state = self.unavailable_state() + _title, lines = install.tui_outcome(state) + self.assertIn("Not available here, and needed to start its emulator: gba_extra.bin " + "(Game Boy Advance). Your own copy goes in the BIOS folder.", lines) + self.assertNotIn("collection", " ".join(lines)) + + def test_p2_6_it_is_left_out_when_it_does_not_concern_the_run(self): + state = self.unavailable_state() + state.ticked = {"sony-playstation"} + self.assertEqual(install._unavailable_lines(state), []) + state = self.unavailable_state() + state.summary = {"installed": 1, "failed": [], "interrupted": True} + self.assertFalse(any("Not available" in line for line in install.tui_outcome(state)[1])) + + # P3: small fixes the audit listed. + + def fetch_failure(self, error: BaseException, env: dict | None = None) -> tuple: + inbox = __import__("queue").Queue() + with mock.patch.object(install, "_fetch_json", side_effect=error), \ + mock.patch.dict(os.environ, env or {}): + for name in ("HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"): + if name not in (env or {}): + os.environ.pop(name, None) + install._tui_fetch("retroarch", inbox, io.StringIO()) + _kind, _plat, title, lines, _code = inbox.get_nowait() + return title, lines + + def test_p3_the_clock_names_the_row_to_choose(self): + import ssl + + error = urllib_error(ssl.SSLCertVerificationError("certificate is not yet valid")) + words = install.network_words(error, today=install.datetime.date(1980, 1, 1)) + with mock.patch.object(install, "network_words", return_value=words): + _title, lines = self.fetch_failure(error) + self.assertTrue(lines[0].endswith("then choose Try again."), lines[0]) + + def test_p3_a_proxy_in_the_way_is_named_not_shown(self): + error = urllib_error(ConnectionRefusedError(111, "Connection refused")) + _title, lines = self.fetch_failure( + error, {"HTTPS_PROXY": "http://me:secret@10.0.0.1:3128"}) + self.assertIn("HTTPS_PROXY is set, so the connection goes through a proxy: check " + "that it answers.", lines) + self.assertNotIn("secret", " ".join(lines)) + _title, lines = self.fetch_failure(error) + self.assertFalse(any("PROXY" in line for line in lines)) + + def test_p3_a_list_that_is_no_json_is_said_in_words(self): + try: + install.json.loads("") + except install.json.JSONDecodeError as cause: + error = ValueError(f"invalid file list: {cause}") + error.__cause__ = cause + title, lines = self.fetch_failure(error) + self.assertEqual(title, "The file list is not valid") + self.assertNotIn("Expecting value", " ".join(lines)) + self.assertIn("a Wi-Fi sign-in page or a cut download looks like this.", lines[0]) + + def test_p3_the_preview_counts_its_files_only(self): + state = checked_state() + state.stack += ["options", "preview"] + state.dry = dict(CHECK) + state.dry_files = [{**FILES[0], "dest": f"f{n}.bin"} for n in range(40)] + state.rows = 24 + lines = [line.strip() for line in install.tui_view(state, 80, 24)] + self.assertIn("1-18 of 40", lines) + + def test_p3_game_data_reads_without_a_double_negative(self): + state = checked_state() + label = next(row.label for row in install._option_rows(state) + if row.action == "game_data") + self.assertEqual(label, "Game data: kept") + state.no_game_data = True + label = next(row.label for row in install._option_rows(state) + if row.action == "game_data") + self.assertEqual(label, "Game data: left out, firmware only") + + def test_p3_the_plan_says_which_config_named_the_folder(self): + config = HOME / ".config/retroarch/retroarch.cfg" + with mock.patch.dict(install._RETROARCH_CONFIGS, {SYSTEM_DIR: (config, config.parent)}): + text = "\n".join(install.tui_view(checked_state(), 80, 24)) + self.assertIn("Named by ~/.config/retroarch/retroarch.cfg", text) + + # Work folders: only a kill no handler sees leaves one, and the next + # run takes it away. + + def test_work_folders_left_behind_are_pruned(self): + import subprocess + + root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, root, True) + gone = subprocess.Popen([sys.executable, "-c", "pass"]) + gone.wait() + now = time.time() + week = install.TUI_KEEP_SECONDS + + def folder(name: str, pid: int | None, kept: bool, age: float) -> Path: + path = root / f"{install.TUI_WORK_PREFIX}{name}" + path.mkdir() + if pid is not None: + (path / install.TUI_OWNER).write_text(str(pid)) + if kept: + (path / install.TUI_KEPT).write_text("") + os.utime(path, (now - age, now - age)) + return path + + killed = folder("killed", gone.pid, False, 60) + running = folder("running", os.getpid(), False, 2 * week) + failed = folder("failed", gone.pid, True, 60) + failed_old = folder("failed-old", gone.pid, True, 2 * week) + unknown = folder("unknown", None, False, 60) + unknown_old = folder("unknown-old", None, False, 2 * week) + other = root / "something-else" + other.mkdir() + removed = install._prune_work_folders(root, now) + self.assertEqual(sorted(removed), sorted([killed, failed_old, unknown_old])) + self.assertEqual(sorted(path.name for path in root.iterdir()), + sorted(path.name for path in (running, failed, unknown, other))) + + class Crash(unittest.TestCase): """An unexpected error ends on one sentence and a log, never a traceback.""" @@ -2030,11 +2476,14 @@ class EndToEnd(unittest.TestCase): term.send("\r") screen = term.wait("found in") # The folder wraps at a slash: compared without the spaces. - self.assertIn("".join(f"3 found in {old}, 2 to download (8 B)".split()), - "".join(screen.split())) + self.assertIn("3 found in oldcard, 2 to download (8 B)", screen) # Checked again, the plan opens on Install. term.send("\r") - term.wait("files installed.") + screen = term.wait("files installed.") + # Where each came from, as the line mode says it. + self.assertIn("5 files installed.", screen) + self.assertIn("3 files copied from", screen) + self.assertIn("2 downloaded.", screen) term.send("\r") self.assertEqual(term.close(), 0) self.assertEqual(len(repo.installed()), 5) diff --git a/wiki/installer.md b/wiki/installer.md index 40098811..9c8e07c1 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -67,7 +67,10 @@ curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh \ ``` `--dest` is the BIOS folder itself: on a card, `retroarch/cores/system` for -RetroArch on Switch, `RetroArch/system` on Android. +RetroArch on Switch, `RetroArch/system` on Android. The root of a handheld's +card works too when the installer knows that card (muOS, Onion, spruceOS, +KNULLI, ROCKNIX and the MinUI family, see +[Platform detection](#platform-detection)): the files go where its system reads. PowerShell needs another form. `iex` takes the script text as its own argument, so anything appended to `irm ... | iex` is read as an argument to `iex` and @@ -454,6 +457,9 @@ python install.py --from ~/Downloads --from /media/me/backup python install.py --check --from /media/me/STOCK ``` +The first line names the root of the muOS card, the one holding `MUOS`: the +files go to `MUOS/bios` on it, where muOS reads them, and the run says so. + Every file is recognised by its contents, never by its name: `PSX BIOS (USA).bin` in a subfolder lands as `scph5501.bin` when it holds those bytes, and a file of the right name with other bytes (an empty file left by a @@ -742,6 +748,26 @@ family writes the release name as the first line of `.system/version.txt` at the card root (`MinUI-`, `NextUI-`, `MyMinUI-`), and that line names the file list. +A card that holds a system's own files is read the same way, without +`--platform` or with the one that names its list, by the rules the table above +gives for the device, below the card's root instead of its mount point: + +| On the card | System | Files go to | +|-------------|--------|-------------| +| `MUOS/` | muOS | `MUOS/bios`, RetroArch's list | +| `.tmp_update/onionVersion/version.txt` | Onion | `BIOS`, RetroArch's list | +| `spruce/` | spruceOS | `BIOS`, RetroArch's list | +| `System/usr/trimui/crossmix-version.txt` | CrossMix-OS | `BIOS`, RetroArch's list | +| `.allium/` | Allium | `BIOS`, RetroArch's list | +| `Koriki/version.txt` | Koriki | `BIOS`, RetroArch's list | +| `system/batocera.conf` with `system.hostname=KNULLI`, as KNULLI's first boot writes its share | KNULLI | `bios`, KNULLI's list | +| `.config/system/configs/system.cfg` with `root.password=rocknix`, as ROCKNIX's first boot writes its storage | ROCKNIX | `roms/bios`, ROCKNIX's list | + +The full screen lists such a card on its first screen ("muOS SD card (E:\)"). +Named by its system (`--platform muos`, `--platform onion`), a `--dest` that +is neither such a card nor its BIOS folder is refused, with the folder that +system reads. + Desktop installs are read from their own configuration, and several can be found on one machine. @@ -1157,6 +1183,10 @@ fields, the installer refuses to narrow rather than install everything. | `130` | stopped with Ctrl+C, or with `q`, Ctrl+C or Ctrl+D at a question. Run the same command again to carry on | | `141` | the output was piped to a reader that closed early (`--list-platforms \| head`) | +The full screen ended by SIGTERM (Start+Select on a handheld) before any +install exits `143`. During an install it stops the install first, and exits +`130`. + `install.ps1` passes the installer's code on unchanged: run as a file it exits with it, run through `irm \| iex` it sets `$LASTEXITCODE` (an `exit` there would close the PowerShell window). It declares no parameter of its own, so From bbbf551140d9679b6ab24f019030b14b763887ee Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:31:33 +0200 Subject: [PATCH 4/9] docs: first install for the full screen The one-liner opens the full screen, and the page walked through the line questions. It now shows the first screen, the keys and the controller, Install, the copy from an old card and the result titles. Troubleshooting no longer asks to move a nested bios folder by hand. --- tests/test_doc_pages.py | 29 +++++++++-------- tests/test_site_r2_wiki.py | 14 ++++++--- tests/test_site_r3_wiki.py | 20 +++++++++--- wiki/first-install.md | 64 ++++++++++++++++++-------------------- wiki/troubleshooting.md | 3 +- 5 files changed, 73 insertions(+), 57 deletions(-) diff --git a/tests/test_doc_pages.py b/tests/test_doc_pages.py index 025cde83..73d75bfd 100644 --- a/tests/test_doc_pages.py +++ b/tests/test_doc_pages.py @@ -699,13 +699,14 @@ class FirstInstallWalkthrough(unittest.TestCase): step3 = _section(self.run, "The file index and the download") step4 = _section(self.run, "The end of the run") step5 = _section(self.run, "A later check") - # First install names the three endings a run prints, and no count: - # a figure from one setup read as the size of every install. + # First install names the three endings the full screen shows as + # its title, and no count: a figure from one setup read as the size + # of every install. source = (ROOT / "install.py").read_text(encoding="utf-8") - for ending in ("Done.", "Finished with errors.", "Nothing was installed."): + for ending in ("Done", "Finished with errors", "Nothing installed"): with self.subTest(ending=ending): - self.assertIn(f"`{ending}`", _section(self.text, "5. Read the last line")) - self.assertIn(f'"\\n{ending} ', source) + self.assertIn(f"**{ending}**", _section(self.text, "5. Read the result")) + self.assertIn(f'title = "{ending}"', source) self.assertNotRegex(self.text, r"Done\. \d+ files") count, size = re.search(r"\n (\d+) files \(([\d.]+ [KMG]B)\)", step3).groups() self.assertIn(f"0/{count} present", step3) @@ -726,15 +727,17 @@ class FirstInstallWalkthrough(unittest.TestCase): self.assertIn(f"all {required} belong", step4) self.assertIn(f"other {total - required} are optional", step4) - def test_no_platform_detected_reads_like_the_installer(self): - source = (ROOT / "install.py").read_text(encoding="utf-8") - branch = source.split('print(" No supported platform detected.")', 1)[1][:1600] - self.assertIn("if not interactive or listing:", branch) - self.assertIn("_prompt_manual_platform(os_type)", branch) + def test_nothing_found_reads_like_the_installer(self): + # The one-liner hands the terminal to the installer, which opens the + # full screen: its first screen says the heading and offers the row. + import install + self.assertIn('"$INSTALLER" "$@" ", section) self.assertNotIn("Batocera", section) def test_the_diagram_follows_the_steps(self): text = _wiki("first-install.md") - self.assertLess(text.index("## 5. Read the last line"), text.index("")) + self.assertLess(text.index("## 5. Read the result"), text.index("")) class OneNamePerPage(unittest.TestCase): diff --git a/tests/test_site_r3_wiki.py b/tests/test_site_r3_wiki.py index 78970435..3fadac8b 100644 --- a/tests/test_site_r3_wiki.py +++ b/tests/test_site_r3_wiki.py @@ -251,11 +251,21 @@ class SmallCopyFixes(unittest.TestCase): self.assertIn("`retroarch/cores/system/` on the SD card", guides) self.assertIn("--dest E:\\retroarch\\cores\\system", section) - def test_the_end_line_is_the_printed_one(self): - section = _section(_wiki("first-install.md"), "5. Read the last line") - self.assertIn("`N files installed, M already up to date.`", section) - source = (REPO_ROOT / "install.py").read_text(encoding="utf-8") - self.assertIn("installed, \"\n f\"{_plural(up_to_date, 'file')} already up to date.", source) + def test_the_end_is_the_shown_one(self): + # The one-liner opens the full screen: the page names the titles + # its result screen shows, as tui_outcome writes them. + import install + + section = _section(_wiki("first-install.md"), "5. Read the result") + state = install.TuiState() + state.stack, state.code = ["result"], 0 + for summary in ({"installed": 3, "up_to_date": 0, "failed": []}, + {"installed": 2, "failed": [{"dest": "a.bin", "reason": "HTTP 404"}]}, + {"installed": 0, "failed": [{"dest": "a.bin", "reason": "HTTP 404"}]}): + state.summary = summary + title, _lines = install.tui_outcome(state) + with self.subTest(title=title): + self.assertIn(f"**{title}**", section) class CommunityAnswers(unittest.TestCase): diff --git a/wiki/first-install.md b/wiki/first-install.md index 6b461a83..33a97475 100644 --- a/wiki/first-install.md +++ b/wiki/first-install.md @@ -67,44 +67,44 @@ curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | On Batocera, Recalbox and similar setups, see [Get your files](../which-pack.md). -## 2. Check the folder it found +## 2. Check the setup it found -It shows your setup and its BIOS folder: +It opens a menu: ``` - Found at + RetroBIOS: Where do you play? + Found on this machine: + > + Another setup or folder + BIOS folder: ``` -- **Right folder?** Go on. -- **Wrong folder?** Press Ctrl+C. Run it again with your folder: +- **Right folder?** Press Enter. +- **Wrong folder?** Choose **Another setup or folder**, then browse to yours. +- **A handheld's SD card in a reader?** It is listed, as **muOS SD card**. - -```sh -curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh -s -- --dest ~/path/to/bios -``` +Arrows move, Enter chooses, Esc goes back. A controller's A and B do the same. -## 3. Choose what to install +## 3. Install -``` - Enter) install everything - i) only what the installed cores read - c) choose systems, cores and regions - q) quit -``` +The next screen counts the missing files and the free space. +Press Enter on **Install**. **Choose systems** keeps the consoles you +play. -**Enter** installs everything. **c** lets you pick consoles. +**Old card?** In a card reader, choose **Copy the BIOS files from your old +card or a folder**, then the card. It stays unchanged. ## 4. Wait -Ctrl+C stops it. Run the command again to carry on. +B or Ctrl+C stops it. Run it again to carry on. -## 5. Read the last line +## 5. Read the result -- **`Done.`** `N files installed, M already up to date.` All files are in place. -- **`Finished with errors.`** Some files failed. Run the same command again. - It retries only those. -- **`Nothing was installed.`** The lines above it say why. See - [If it stops](#if-it-stops). +- **Done**: all files are in place. +- **Finished with errors**: run the command again. It retries only those. +- **Nothing installed**: the screen says why. See [If it stops](#if-it-stops). + +For questions line by line (screen readers), add `--plain`. ## What the command does @@ -127,18 +127,14 @@ All good: ## Undo -Every file it replaces goes to a `retrobios-backup` folder next to your BIOS -folder. The last lines say how to put them back. +Files it replaces go to a `retrobios-backup` folder beside your BIOS folder. +The result says how to restore them. ## If it stops -### No supported platform detected. +### Nothing was found here. -At a terminal, a menu follows. Pick your setup by its number, then type its -BIOS folder, or press Enter for the usual one. - -With `--no-input`, or without a terminal, it stops and prints the command to -use. +Choose **Another setup or folder**, then your setup and its folder. ### Error: Python 3.8 or newer is required. @@ -157,9 +153,9 @@ The download was cut short. Nothing was changed. Run the command again. You cannot write there. Do what the message says. -### Not enough space on the drive holding ... +### The card is full -Nothing was written. Free some space, or pick another drive with `--dest`. +Nothing was written. Free some space, or choose another folder. ### The game still does not start diff --git a/wiki/troubleshooting.md b/wiki/troubleshooting.md index af29ce2a..deec29fe 100644 --- a/wiki/troubleshooting.md +++ b/wiki/troubleshooting.md @@ -38,7 +38,8 @@ Run it without `--check`. It adds missing files and replaces wrong ones. - **Right folder?** [Get your files](../which-pack.md) names it. - **Exact name?** `scph5501.bin` and `SCPH-5501.BIN` differ. On Linux, capitals count too. -- **A `bios` folder inside your BIOS folder?** Move its files up one level. +- **A `bios` folder inside your BIOS folder?** Run the installer: it copies + those files up one level. - **Unzipped by hand on Linux?** The command adds names that differ by capitals. - **A ZIP in parts, like `Pack.part1of2.zip`?** Unzip every part into one From d614acbef37b7d7f42b916ae729cb685fda79ad4 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:39:45 +0200 Subject: [PATCH 5/9] fix: offer only a pack that serves the system Release packs now exist for six platforms only, and install.py refuses a pack named for another. build_port.py writes packs.txt from the registry's release_pack groups. Offline, the launcher takes only a pack spelling the detected system, names that pack, or says no release pack serves it and gives Wi-Fi or the computer route. --- ports/shared/tui.txt | 39 ++++++++++++++--- scripts/build_port.py | 56 ++++++++++++++++++++++--- tests/test_handheld_launchers.py | 72 +++++++++++++++++++++++++++++++- wiki/installer.md | 14 ++++--- 4 files changed, 161 insertions(+), 20 deletions(-) diff --git a/ports/shared/tui.txt b/ports/shared/tui.txt index bacc19b7..ecb73063 100644 --- a/ports/shared/tui.txt +++ b/ports/shared/tui.txt @@ -304,9 +304,10 @@ rb_detect() { 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). +# The first pack in RB_PACK_PATH whose name spells this system: install.py +# reads the platform from the same spelling (Name_With_Underscores between +# two underscores) and refuses a pack named for another. Any pack only when +# the system is not known, since install.py then reads it from the name. rb_find_pack() { local dir file base stem first="" want="" if [ -n "${RB_PLATFORM_NAME:-}" ]; then @@ -323,13 +324,27 @@ rb_find_pack() { printf '%s\n' "$file" return 0 fi - [ -z "$first" ] && first="$file" + [ -z "$want" ] && [ -z "$first" ] && first="$file" done done [ -n "$first" ] && printf '%s\n' "$first" return 0 } +# How the release pack that serves this system is named: the platforms of its +# group, as packs.txt lists them. build_port.py writes packs.txt from the +# registry's release_pack platforms, so a system with no line has no pack. +rb_pack_label() { + [ -n "${RB_PLATFORM:-}" ] && [ -f "$RB_DIR/packs.txt" ] || return 1 + awk -F'\t' -v p="$RB_PLATFORM" '$1 == p { print $2; found = 1; exit } END { exit !found }' \ + "$RB_DIR/packs.txt" +} + +# Whether the system is known to have no release pack. +rb_no_pack_serves() { + [ -n "${RB_PLATFORM:-}" ] && [ -f "$RB_DIR/packs.txt" ] && ! rb_pack_label > /dev/null +} + 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)" ] @@ -355,8 +370,14 @@ rb_other_card() { } rb_pack_text() { - local dir="${RB_PACK_PATH%%:*}" pack - if [ -n "${RB_PLATFORM_NAME:-}" ]; then + local dir="${RB_PACK_PATH%%:*}" pack label + if rb_no_pack_serves; then + printf '%s\n' "No network connection, and the releases carry no BIOS pack for ${RB_PLATFORM_NAME:-$RB_PLATFORM}: connect to Wi-Fi and start RetroBIOS again. $RB_PC_ROUTE" + return 0 + fi + if label=$(rb_pack_label); then + pack="the $label BIOS pack (a file whose name starts with ${label}_ and ends in _BIOS_Pack.zip)" + elif [ -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)" @@ -752,6 +773,12 @@ rb_here() { printf 'No connection. Looking for the BIOS folder and a pack on the card...\n' # Detection also names the BIOS folder the other card must not be. rb_detect + if rb_no_pack_serves; then + # Copying from the other card needs a file list too: nothing to ask. + rb_message "$(rb_pack_text)" + printf '1\n' > "$RB_STATE/status" + return 1 + fi 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") diff --git a/scripts/build_port.py b/scripts/build_port.py index db703544..b8ca5d67 100644 --- a/scripts/build_port.py +++ b/scripts/build_port.py @@ -46,6 +46,8 @@ MUOS_APP = "RetroBIOS.muxapp" TOOLS_ZIP = "RetroBIOS-tools.zip" # Every archive a release attaches, in the order the release notes list them. ARCHIVES = (PORT_ZIP, BATOCERA_ZIP, MUOS_APP, TOOLS_ZIP) +# Which release pack serves each platform, read by tui.txt offline. +PACKS_TABLE = "packs.txt" _PIN = re.compile(r'^DEFAULT_INSTALL_SHA256="([0-9a-f]{64})"$', re.MULTILINE) @@ -57,7 +59,7 @@ def pinned_installer_sha256(root: Path = REPO_ROOT) -> str: return found.group(1) -def _engine(folder: str, root: Path, mapping: bool = True) -> list[tuple[str, Path]]: +def _engine(folder: str, root: Path, mapping: bool = True) -> list[tuple[str, Path | None]]: """What every launcher runs: the installer, the interface launcher, the event reader, the licenses, and the gptokeyb mapping where one is read.""" shared = root / "ports" / "shared" @@ -67,14 +69,17 @@ def _engine(folder: str, root: Path, mapping: bool = True) -> list[tuple[str, Pa (f"{folder}/rbevents.py", shared / "rbevents.py"), (f"{folder}/licenses/LICENSE-retrobios.txt", root / "LICENSE"), (f"{folder}/licenses/NOTICE-retrobios.txt", root / "NOTICE"), + # Written by build_archive from the registry. + (f"{folder}/{PACKS_TABLE}", None), ] if mapping: members.append((f"{folder}/retrobios.gptk", shared / "retrobios.gptk")) return members -def archive_members(name: str, root: Path = REPO_ROOT) -> list[tuple[str, Path]]: - """(name in the archive, source file) for every member of *name*, sorted.""" +def archive_members(name: str, root: Path = REPO_ROOT) -> list[tuple[str, Path | None]]: + """(name in the archive, source file) for every member of *name*, sorted. + The source is None for the packs table build_archive writes.""" ports = root / "ports" if name == PORT_ZIP: source = ports / "RetroBIOS" @@ -121,6 +126,40 @@ def archive_members(name: str, root: Path = REPO_ROOT) -> list[tuple[str, Path]] return sorted(members) +def release_pack_labels(root: Path = REPO_ROOT) -> dict[str, str]: + """Platform -> the platform part of the release pack that serves it. + + Only the platforms the registry marks release_pack have a pack, grouped + as the release builds them (pipeline.py --include-archived). install.py + refuses a pack whose name does not spell the platform, so a platform + outside these groups has no pack to copy, whatever its file list. + """ + from common import ( # noqa: PLC0415 + group_identical_platforms, + list_release_pack_platforms, + load_platform_config, + ) + from generate_pack import _name_part, _platform_pack_stem # noqa: PLC0415 + + platforms_dir = str(root / "platforms") + pool = list_release_pack_platforms(platforms_dir, include_archived=True) + labels: dict[str, str] = {} + for group, representative in group_identical_platforms(pool, platforms_dir): + stem = _platform_pack_stem(group, representative, platforms_dir) + config = load_platform_config(representative, platforms_dir) + version = config.get("version", config.get("dat_version", "")) + tag = f"_{_name_part(str(version))}" if version else "" + label = stem[: -len(tag)] if tag and stem.endswith(tag) else stem + for platform in group: + labels[platform] = label + return dict(sorted(labels.items())) + + +def _packs_table(root: Path) -> bytes: + return "".join(f"{platform}\t{label}\n" + for platform, label in release_pack_labels(root).items()).encode() + + def build_archive(name: str, output: Path, root: Path = REPO_ROOT) -> Path: """Write the launcher archive *name* to *output* and return it.""" pinned = pinned_installer_sha256(root) @@ -137,9 +176,14 @@ def build_archive(name: str, output: Path, root: Path = REPO_ROOT) -> Path: members = archive_members(name, root) output.parent.mkdir(parents=True, exist_ok=True) scratch = output.with_name(f".{output.name}.tmp") - with zipfile.ZipFile(scratch, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as zf: - for member, path in members: - _add_pack_member(zf, str(path), member) + table = output.with_name(f".{output.name}.{PACKS_TABLE}") + table.write_bytes(_packs_table(root)) + try: + with zipfile.ZipFile(scratch, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as zf: + for member, path in members: + _add_pack_member(zf, str(path or table), member) + finally: + table.unlink() scratch.replace(output) return output diff --git a/tests/test_handheld_launchers.py b/tests/test_handheld_launchers.py index a387dc04..9475bad8 100644 --- a/tests/test_handheld_launchers.py +++ b/tests/test_handheld_launchers.py @@ -28,6 +28,8 @@ import unittest import zipfile from pathlib import Path +import yaml + REPO_ROOT = Path(__file__).resolve().parent.parent PORTS = REPO_ROOT / "ports" sys.path.insert(0, str(REPO_ROOT / "scripts")) @@ -235,6 +237,21 @@ class TheArchives(unittest.TestCase): second = build_port.build_archive(name, self.out / f"b-{name}").read_bytes() self.assertEqual(first, second) + def test_each_names_the_release_packs(self): + """packs.txt names the platforms a release publishes a pack for, as + the registry marks them, and the pack's name for each.""" + registry = yaml.safe_load((REPO_ROOT / "platforms" / "_registry.yml").read_text()) + released = {name for name, entry in registry["platforms"].items() + if entry.get("release_pack") is True} + labels = build_port.release_pack_labels() + self.assertEqual(set(labels), released) + self.assertEqual(labels["retroarch"], "RetroArch") + expected = "".join(f"{p}\t{label}\n" for p, label in sorted(labels.items())) + for name, (_script, folder) in self.LAYOUT.items(): + with self.subTest(archive=name): + with zipfile.ZipFile(build_port.build_archive(name, self.out / name)) as zf: + self.assertEqual(zf.read(f"{folder}/packs.txt").decode(), expected) + def test_an_unpinned_installer_is_refused(self): root = _tempdir(self) for name in ("install.py", "install.sh", "LICENSE", "NOTICE"): @@ -296,10 +313,12 @@ if "--check" in sys.argv: if os.environ.get("FAKE_DETECT_HANG"): time.sleep(60) print(json.dumps({"event": "detect", "os": "linux", "os_name": "Fake OS", - "platforms": [{"platform": "retroarch", "name": "RetroArch", + "platforms": [{"platform": os.environ.get("FAKE_PLATFORM", "retroarch"), + "name": os.environ.get("FAKE_PLATFORM_NAME", "RetroArch"), "bios_dir": "/roms/bios"}]})) print(json.dumps({"event": "error", "stage": "manifest", "reason": "network", - "platform": "retroarch", "message": "unreachable"})) + "platform": os.environ.get("FAKE_PLATFORM", "retroarch"), + "message": "unreachable"})) print(json.dumps({"event": "end", "code": 1})) sys.exit(1) if os.environ.get("FAKE_REFUSE"): @@ -1249,6 +1268,24 @@ class TheMuosApp(unittest.TestCase): [call] = device.tui_calls() self.assertEqual(call["args"][:3], ["--tui", "--from-pack", str(pack)]) + def test_offline_names_the_pack_that_serves_muos(self): + """muOS runs RetroArch, and install.py reads it as retroarch: the + RetroArch pack serves it.""" + device, app = self.device(muxterm=True) + proc = device.run([str(app / "mux_launch.sh"), str(app)], FAKE_CURL="7") + self.assertEqual(proc.returncode, 1) + # muxterm draws what its child writes: here the launcher's stdout. + screen = " ".join(proc.stdout.decode().split()) + self.assertIn("download the RetroArch BIOS pack (a file whose name starts with " + "RetroArch_ and ends in _BIOS_Pack.zip)", screen) + + def test_offline_takes_no_pack_named_for_another_system(self): + device, app = self.device(muxterm=True) + (device.root / "mmc" / "Batocera_43.1_BIOS_Pack.zip").write_bytes(b"") + proc = device.run([str(app / "mux_launch.sh"), str(app)], FAKE_CURL="7") + self.assertEqual(proc.returncode, 1) + self.assertEqual(device.tui_calls(), []) + def test_offline_reads_a_pack_on_the_second_card(self): device, app = self.device(muxterm=True) (device.root / "sdcard").mkdir() @@ -1445,6 +1482,22 @@ INI # Stopped without the sudo control.ini names. device.assert_mapper_stopped() + def test_rocknix_offline_names_no_pack_the_releases_lack(self): + """No release pack spells ROCKNIX, and install.py refuses one named + for another system: the RetroArch pack on the card is not offered, + and the message gives the computer route.""" + device, tools = self.device(FAKE_CURL="7") + self.rocknix(device) + (tools / "retrobios" / "RetroArch_v1.22.2_BIOS_Pack.zip").write_bytes(b"") + proc = device.run(["bash", str(tools / "RetroBIOS.sh")], stdio_tty=True, TERM="xterm", + FAKE_PLATFORM="rocknix", FAKE_PLATFORM_NAME="ROCKNIX") + self.assertEqual(proc.returncode, 1) + self.assertEqual(device.tui_calls(), []) + screen = " ".join(device.screen.split()) + self.assertIn("the releases carry no BIOS pack for ROCKNIX", screen) + self.assertIn("--dest", screen) + self.assertNotIn("download", screen) + def test_rocknix_start_select_in_foot(self): device, tools = self.device() self.rocknix(device) @@ -1458,6 +1511,21 @@ INI class TheDocs(unittest.TestCase): + def test_the_offline_packs_named_are_the_ones_that_serve(self): + """The wiki names the pack each firmware takes offline: the release + pack of the platform install.py detects it as, or none.""" + labels = build_port.release_pack_labels() + served = {} + for rule in install._EMBEDDED_RULES: + served.setdefault(rule.name, labels.get(rule.manifest)) + for name in ("muOS", "ArkOS", "dArkOS", "JELOS"): + self.assertEqual(served[name], "RetroArch", name) + self.assertEqual(served["Batocera"], "Batocera") + for name in ("ROCKNIX", "KNULLI", "AmberELEC"): + self.assertIsNone(served[name], name) + text = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8") + self.assertIn("The releases carry no pack for ROCKNIX,\nKnulli or AmberELEC", text) + def test_muos_paths_are_the_card_muos_mounts(self): """muOS mounts the first card at /mnt/mmc (MustardOS internal mux_launch.sh, GET_VAR storage/rom/mount).""" diff --git a/wiki/installer.md b/wiki/installer.md index 7aeb67df..7f7298ae 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -987,13 +987,15 @@ running install is then stopped the way B stops it, and the last screen says what it wrote. On muOS without PortMaster, muxterm reads the pad itself: Y ticks a system, Select shows its keyboard, and Menu quits. -**Without Wi-Fi.** On a computer, download the BIOS pack whose name contains -your system (every part, for a pack in parts) and copy it next to RetroBIOS: -into `ports/retrobios` for the PortMaster port and on Batocera, to the root -of either card on muOS, into the `retrobios` folder of the Tools entry. With -no connection, RetroBIOS installs from it, as +**Without Wi-Fi.** On a computer, download the BIOS pack that serves your +system (every part, for a pack in parts): RetroArch's on muOS, ArkOS, +dArkOS and JELOS, Batocera's on Batocera. Copy it next to RetroBIOS: into +`ports/retrobios` for the PortMaster port and on Batocera, to the root of +either card on muOS, into the `retrobios` folder of the Tools entry. With no +connection, RetroBIOS installs from it, as [`--from-pack`](#without-a-network-offline-install) does. With no pack there, -it names the pack and the folder. +it names the pack and the folder. The releases carry no pack for ROCKNIX, +Knulli or AmberELEC: there RetroBIOS needs Wi-Fi, or the computer route. On a two-card device, RetroBIOS without Wi-Fi first asks to **copy the BIOS files from your other card**: muOS's `MUOS/bios` on the card the system does From 4be68b1eeebfe0679a669d4e604f48985887c4e2 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:43:11 +0200 Subject: [PATCH 6/9] fix: keep the example profile out of core lists 00_example is the libretro-core-info .info template, not a core the buildbot publishes. As type libretro, all_libretro resolved it for RetroArch, Lakka, EmuDeck, RetroDECK and ROCKNIX. type test keeps it out, as the site already did for that type. --- emulators/00_example.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/emulators/00_example.yml b/emulators/00_example.yml index d87e75be..b7e3a710 100644 --- a/emulators/00_example.yml +++ b/emulators/00_example.yml @@ -1,5 +1,5 @@ emulator: "00_example" -type: libretro +type: test core_classification: pure_libretro source: "https://github.com/libretro/libretro-core-info" profiled_date: "2026-03-22" From cffd9eeb029a17536b9972374d2251ce3d8be093 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:43:18 +0200 Subject: [PATCH 7/9] feat: read the engine's transfer and scan events The speed is bytes_network over time, the interface's own guess at local bytes is gone. The plan says when a --from folder was read only in part or holds another drive, and how many files on the old card could not be read, with a list of each reason. The --from folders are only checked before the screen opens, never indexed there. --- install.ps1 | 2 +- install.py | 85 ++++++++++++++++++++++++++++++--------- install.sh | 2 +- tests/test_install_tui.py | 71 +++++++++++++++++++++++++++++++- 4 files changed, 138 insertions(+), 22 deletions(-) diff --git a/install.ps1 b/install.ps1 index 1dcc2992..9f56ce58 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "e0a65cc08918a2f53032f63d08974995542a0beabb7528ac3de35d0fdb698e36" + $defaultInstallSha256 = "1a26e1066a699413ab3d19f50ee552185ee2e9cbf74ee44548d3c9ea66f1b512" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 09f75b88..f2daef16 100644 --- a/install.py +++ b/install.py @@ -9468,7 +9468,10 @@ class TuiState: # Files an install copies from this machine, whose bytes are no # download: dest to (folder, reason), reason set for a misplaced one. self.local_dests: dict = {} - self.local_done: int = 0 + # Per --from folder, its scanned event, and the files on those + # folders that could not be read (unreadable events). + self.scanned: list = [] + self.unreadable: list = [] # The child run in progress: check, confirm, preview or install. self.run: str = "" self.running: bool = False @@ -9498,9 +9501,11 @@ class TuiState: # The install. self.download: dict | None = None self.progress: dict | None = None - # (time, bytes done) of the last progress events: a partial copy - # resumed counts in the first one and never in a speed. + # (time, bytes from the network) of the last transfer events: what + # came from this machine (a copy, the pack, a partial file resumed) + # is never in a speed. self.samples: list = [] + self.network: int = 0 self.finished: list = [] self.summary: dict | None = None self.code: int | None = None @@ -9644,7 +9649,7 @@ class TuiRow(NamedTuple): # Screens that scroll text instead of listing rows. -_PAGERS = ("preview", "command", "result", "misplaced") +_PAGERS = ("preview", "command", "result", "misplaced", "unreadable") _CHECKLISTS = ("systems", "cores") @@ -9795,6 +9800,8 @@ def _plan_rows(state: TuiState) -> list[TuiRow]: rows.append(TuiRow("fromcard", "Copy the BIOS files from your old card or a folder")) if _tui_misplaced(state): rows.append(TuiRow("misplaced", "Show the files in the wrong place")) + if state.unreadable: + rows.append(TuiRow("unreadable", "Show the files that could not be read")) return rows + [TuiRow("options", "Options"), TuiRow("quit", "Quit")] @@ -9951,13 +9958,13 @@ def _start_run(state: TuiState, run: str) -> list[tuple]: # The systems of an earlier check go first: ticks read against them # dropped the --system the command line gave. state.check, state.files, state.systems, state.reading = None, [], [], None - state.found = [] + state.found, state.scanned, state.unreadable = [], [], [] state.checked_flags = tui_flags(state) elif run in ("confirm", "preview"): state.dry, state.dry_files, state.dry_summary = None, [], None else: state.download = state.progress = state.reading = None - state.samples, state.local_dests, state.local_done = [], {}, 0 + state.samples, state.local_dests, state.network = [], {}, 0 state.finished, state.summary, state.code, state.stopping = [], None, None, False return [("run", run)] @@ -10174,8 +10181,8 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: return _open_folder_screen(state, state.dest) if action == "fromcard": return _open_from_browser(state) - if action == "misplaced": - return _push(state, "misplaced") + if action in ("misplaced", "unreadable"): + return _push(state, action) # On the first system, so A continues and the D-pad ticks. return _push(state, action, 3 if action == "systems" else 0) if screen == "systems": @@ -10547,6 +10554,10 @@ def _take_event(state: TuiState, event: dict) -> None: state.reading = (event["files_done"], event["files_total"]) elif kind == "found" and state.run == "check": state.found.append(event) + elif kind == "scanned" and state.run == "check": + state.scanned.append(event) + elif kind == "unreadable" and state.run == "check": + state.unreadable.append(event) elif kind == "found" and state.run == "install": state.local_dests[event["dest"]] = (event["folder"], event["reason"]) elif state.run == "check": @@ -10566,15 +10577,12 @@ def _take_event(state: TuiState, event: dict) -> None: elif state.run == "install": if kind == "download": state.download = event + elif kind == "transfer": + state.network = event["bytes_network"] + state.samples = [sample for sample in state.samples + if sample[0] >= state.now - 10] + [(state.now, state.network)] elif kind in ("progress", "downloaded"): state.progress = event - if kind == "downloaded" and event["dest"] in state.local_dests: - # A copy from an old card or a folder: its bytes come from - # this machine, and a speed counting them was not the network's. - state.local_done += event["size"] - state.samples = [sample for sample in state.samples - if sample[0] >= state.now - 10] + [ - (state.now, event["bytes_done"] - state.local_done)] if kind == "downloaded": state.finished.append((event["dest"], event["ok"], event["reason"])) elif kind == "summary": @@ -10667,6 +10675,7 @@ _ROW_VERBS = { "preview": "Open", "folder": "Choose", "game_data": "Change", "installed": "Change", "jobs": "Change", "retry": "Try again", "back": "Back", "stay": "Stay", "fromcard": "Choose", "from": "Add a folder", "misplaced": "Show", "zip": "Copy from it", + "unreadable": "Show", } @@ -10772,6 +10781,20 @@ def _tui_found_elsewhere(state: TuiState) -> dict[str, list[dict]]: return found +def _scan_lines(state: TuiState) -> list[str]: + """What the reading of the --from folders left out, one line each.""" + lines = [] + for scan in state.scanned: + if scan["truncated"]: + lines.append(f"Read the first {MAX_SCANNED_FILES:,} files of {scan['folder']}.") + lines += [f"{drive} is another drive: add it to copy from it too." + for drive in scan["other_drives"]] + if state.unreadable: + count = len(state.unreadable) + lines.append(f"{_plural(count, 'file')} on your old card could not be read.") + return lines + + def _last_name(folder: str) -> str: """A folder or ZIP by its last name, a drive by itself (E:\\).""" return Path(folder).name or folder @@ -10844,6 +10867,8 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: hint.os_type = state.os_type lines.append("Or run it as root, this folder named: " + hint.command(state.platform, dest, elevated=True)) + if check is state.check: + lines += _scan_lines(state) if check.get("not_installable"): lines += _unavailable_lines(state) return lines @@ -11073,6 +11098,9 @@ def _pager_items(state: TuiState) -> list: """The text of a screen that scrolls, the same for its view and its keys.""" if state.screen == "misplaced": return [f"{event['dest']}: {event['reason']}" for event in _tui_misplaced(state)] + if state.screen == "unreadable": + return [f"{event['path']}" + (f", {event['member']}" if event["member"] else "") + + f": {event['reason']}" for event in state.unreadable] if state.screen == "result": return tui_outcome(state)[1] if state.screen == "command": @@ -11099,6 +11127,12 @@ def _view_preview(state: TuiState, width: int, height: int) -> tuple: ("A/Enter Back", "Up/Down Scroll")) +def _view_unreadable(state: TuiState, width: int, height: int) -> tuple: + return ("Files that could not be read", + _pager(state, width, height, _pager_items(state)), + ("A/Enter Back", "Up/Down Scroll")) + + def _view_misplaced(state: TuiState, width: int, height: int) -> tuple: return ("Files in the wrong place", _pager(state, width, height, _pager_items(state)), ("A/Enter Back", "Up/Down Scroll")) @@ -11136,10 +11170,9 @@ def _view_installing(state: TuiState, width: int, height: int) -> tuple: lines.append(_bar(min(done / total, 1.0) if total else 1.0, width)) lines.append(f"{format_size(done)} of {format_size(total)}") window = [sample for sample in state.samples if sample[0] >= state.now - 5] - fetched = done - state.local_done - start, base = window[0] if window else (state.now, fetched) + start, base = window[0] if window else (state.now, state.network) seconds = state.now - start - speed = (fetched - base) / seconds if seconds >= 1 else 0 + speed = (state.network - base) / seconds if seconds >= 1 else 0 if speed > 0: lines.append(f"{format_size(int(speed))}/s, " f"{format_duration((total - done) / speed)} left") @@ -11351,6 +11384,7 @@ _TUI_VIEWS: dict[str, Callable[[TuiState, int, int], tuple]] = { "preview": _view_preview, "command": _view_command, "confirm": _view_confirm, "installing": _view_installing, "result": _view_result, "error": _view_error, "quit": _view_quit, "cardroot": _view_cardroot, "misplaced": _view_misplaced, + "unreadable": _view_unreadable, } @@ -11893,7 +11927,20 @@ def run_tui(args: argparse.Namespace) -> int: os_type = _INVOCATION.os_type or detect_os() if os_type == "wsl": _enter_wsl() - _open_local_sources(args) + # The --from folders are read by each check, not here: indexing a large + # card held the first frame back. Only whether each can be read is asked. + folders = getattr(args, "from") or [] + setattr(args, "from", None) + try: + _open_local_sources(args) + finally: + setattr(args, "from", folders) + for folder in folders: + problem = from_folder_problem(str(folder)) + if problem is not None: + print(f"Error: {folder} {problem[1]}: --from names the folder to copy BIOS " + "files from.", file=sys.stderr) + return 1 state, effects = tui_start(args, os_type) work = Path(tempfile.mkdtemp(prefix=TUI_WORK_PREFIX)) (work / TUI_OWNER).write_text(str(os.getpid()), encoding="utf-8") diff --git a/install.sh b/install.sh index d2ad8edf..5a10ae72 100755 --- a/install.sh +++ b/install.sh @@ -18,7 +18,7 @@ esac TEMP_INSTALLER="" TEMP_DIRECTORY="" DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" -DEFAULT_INSTALL_SHA256="e0a65cc08918a2f53032f63d08974995542a0beabb7528ac3de35d0fdb698e36" +DEFAULT_INSTALL_SHA256="1a26e1066a699413ab3d19f50ee552185ee2e9cbf74ee44548d3c9ea66f1b512" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 7feade1c..6a907996 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -883,6 +883,7 @@ def golden_states() -> dict: installing.run, installing.running = "install", True installing.download = {"event": "download", "files": 4, "bytes": 1064964} installing.samples, installing.now = [(100.0, 12672), (104.0, 540672)], 104.0 + installing.network = 540672 installing.progress = {"files_done": 2, "files_total": 4, "bytes_done": 540672, "bytes_total": 1064964} installing.finished = [("gba_bios.bin", True, ""), ("scph5500.bin", False, "HTTP 404")] @@ -1103,6 +1104,10 @@ class SmallFixes(unittest.TestCase): install.tui_receive(state, ("event", state.run_id, { "event": "progress", "files_done": 0, "files_total": 2, "bytes_done": done, "bytes_total": 1000 * mega})) + # The partial copy resumed is counted local by the engine. + install.tui_receive(state, ("event", state.run_id, { + "event": "transfer", "bytes_local": 500 * mega, + "bytes_network": done - 500 * mega})) text = "\n".join(install.tui_view(state, 80, 24)) self.assertIn("1.0 MB/s, 8:18 left", text) @@ -1713,10 +1718,17 @@ class CopyFromAnOldCard(unittest.TestCase): (10.0, {"event": "downloaded", "dest": "big.bin", "ok": True, "reason": "", "size": 200 * mega, "files_done": 1, "files_total": 2, "bytes_done": 200 * mega, "bytes_total": 300 * mega}), + # The engine says which bytes came from this machine. + (10.0, {"event": "transfer", "bytes_local": 200 * mega, + "bytes_network": 0}), (11.0, {"event": "progress", "files_done": 1, "files_total": 2, "bytes_done": 201 * mega, "bytes_total": 300 * mega}), + (11.0, {"event": "transfer", "bytes_local": 200 * mega, + "bytes_network": mega}), (12.0, {"event": "progress", "files_done": 1, "files_total": 2, - "bytes_done": 202 * mega, "bytes_total": 300 * mega})] + "bytes_done": 202 * mega, "bytes_total": 300 * mega}), + (12.0, {"event": "transfer", "bytes_local": 200 * mega, + "bytes_network": 2 * mega})] for when, event in events: state.now = when install.tui_receive(state, ("event", state.run_id, event)) @@ -2119,6 +2131,63 @@ class AuditRoundThree(unittest.TestCase): text = "\n".join(install.tui_view(checked_state(), 80, 24)) self.assertIn("Named by ~/.config/retroarch/retroarch.cfg", text) + # The engine's own events (main, after the second audit). + + def test_the_speed_is_the_network_bytes(self): + state = checked_state() + state.stack.append("installing") + state.run, state.running = "install", True + install.tui_receive(state, ("event", state.run_id, + {"event": "download", "files": 4, "bytes": 4 << 20})) + for now, done, network in ((100.0, 1 << 20, 0), (102.0, 3 << 20, 1 << 20)): + state.now = now + install.tui_receive(state, ("event", state.run_id, { + "event": "progress", "files_done": 1, "files_total": 4, "bytes_done": done, + "bytes_total": 4 << 20})) + install.tui_receive(state, ("event", state.run_id, { + "event": "transfer", "bytes_local": done - network, "bytes_network": network})) + text = "\n".join(install.tui_view(state, 80, 24)) + # 1 MB from the network in 2 s, whatever came from the old card. + self.assertIn("512.0 KB/s, 0:02 left", text) + + def test_the_plan_says_what_the_scan_left_out(self): + state = checked_state() + state.from_folders = [Path("E:\\")] + install._start_check(state) + events = [{"event": "scanned", "folder": "E:\\", "files": 200000, "truncated": True, + "other_drives": ["E:\\D"]}, + {"event": "unreadable", "platform": "retroarch", "folder": "E:\\", + "path": "E:\\bios\\scph5501.bin", "member": "", "reason": "Permission denied"}, + {"event": "unreadable", "platform": "retroarch", "folder": "E:\\", + "path": "E:\\backup.zip", "member": "gba_bios.bin", + "reason": "it is encrypted, a password is needed"}] + for event in [*FILES, *SYSTEMS, *events, CHECK]: + install.tui_receive(state, ("event", state.run_id, event)) + with mock.patch.object(install, "_tui_folder_facts", return_value={ + "free": 50 * 1024 ** 3, "writable": True, "cores": []}): + install.tui_receive(state, ("ended", state.run_id, 1, [])) + text = " ".join(" ".join(install.tui_view(state, 120, 40)).split()) + self.assertIn("Read the first 200,000 files of E:\\.", text) + self.assertIn("E:\\D is another drive: add it to copy from it too.", text) + self.assertIn("2 files on your old card could not be read.", text) + rows = install.tui_rows(state) + state.focus["plan"] = next(i for i, row in enumerate(rows) if row.action == "unreadable") + press(state, "enter") + self.assertEqual(state.screen, "unreadable") + self.assertEqual(install._pager_items(state), [ + "E:\\bios\\scph5501.bin: Permission denied", + "E:\\backup.zip, gba_bios.bin: it is encrypted, a password is needed"]) + + def test_the_interface_does_not_index_the_folders_itself(self): + missing = REPO_ROOT / "tmp" / "no-such-old-card" + err = io.StringIO() + with mock.patch.object(install, "FolderIndex", side_effect=AssertionError("indexed")), \ + mock.patch.object(install, "detect_os", return_value="linux"), \ + mock.patch.object(install, "tui_start", side_effect=AssertionError("opened")), \ + mock.patch.object(sys, "stderr", err): + self.assertEqual(install.run_tui(_args(**{"from": [missing]})), 1) + self.assertIn(f"Error: {missing} does not exist: --from names the folder", err.getvalue()) + # Work folders: only a kill no handler sees leaves one, and the next # run takes it away. From 50b42823caba064be89274610a94aa1d34e8ae5a Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:44:25 +0200 Subject: [PATCH 8/9] feat: add a per-os data_root to profiles Apps such as AetherSX2 named their user data folder only in prose, so the site could neither print it nor show only the OS tabs that apply. data_root keys android, ios, windows, macos and linux, each with the path the user finds and the source_ref that builds it. --- schemas/emulator.schema.json | 16 ++++++++++++++++ tests/test_schema_semantic_gate.py | 26 ++++++++++++++++++++++++++ wiki/profiling.md | 1 + 3 files changed, 43 insertions(+) diff --git a/schemas/emulator.schema.json b/schemas/emulator.schema.json index 23c3356c..3b6062aa 100644 --- a/schemas/emulator.schema.json +++ b/schemas/emulator.schema.json @@ -912,6 +912,22 @@ "type": "string", "description": "Why source_commit is held on purpose rather than followed. The profile is then judged against its own revision, like one pinned to a superseded tag: comparing it to HEAD says nothing and recaling would repoint its refs at code the build it documents never had." }, + "data_root": { + "type": "object", + "minProperties": 1, + "propertyNames": {"enum": ["android", "ios", "windows", "macos", "linux"]}, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": ["path", "source_ref"], + "properties": { + "path": {"type": "string", "minLength": 1, "description": "The folder as the user finds it on that OS, with the environment variable or home prefix the code expands (%APPDATA%, ~, Android/data/)."}, + "source_ref": {"type": "string", "minLength": 1}, + "note": {"type": "string"} + } + }, + "description": "Per operating system, the folder the app reads its user data from, under which standalone_path destinations sit. Only the systems the app runs on are keyed." + }, "upstream_frozen": { "type": "string", "description": "Why upstream_commit is held on purpose rather than followed: the port vendors that upstream tree, synced to that release, or merged that commit. The upstream view is judged against its own revision and a bump leaves it, the way pin_frozen holds source_commit." diff --git a/tests/test_schema_semantic_gate.py b/tests/test_schema_semantic_gate.py index 6c06b3d0..746d2822 100644 --- a/tests/test_schema_semantic_gate.py +++ b/tests/test_schema_semantic_gate.py @@ -96,3 +96,29 @@ class SemanticManifestValidation(unittest.TestCase): if __name__ == "__main__": unittest.main() + + +try: + import jsonschema +except ImportError: + jsonschema = None + + +@unittest.skipIf(jsonschema is None, "jsonschema unavailable") +class DataRootIsKeyedBySystem(unittest.TestCase): + """AetherSX2 gave Android/data/xyz.aethersx2.android only in its notes: + the site could not print the folder, nor which OS tabs apply.""" + + def _errors(self, value): + schema = json.loads((REPO_ROOT / "schemas/emulator.schema.json").read_text(encoding="utf-8")) + validator = jsonschema.Draft202012Validator(schema["properties"]["data_root"]) + return list(validator.iter_errors(value)) + + def test_a_system_with_its_path_and_reference(self): + self.assertEqual(self._errors({"android": { + "path": "Android/data/xyz.aethersx2.android/files", "source_ref": "a.kt:1"}}), []) + + def test_an_unknown_system_or_a_path_without_reference(self): + self.assertTrue(self._errors({"amiga": {"path": "x", "source_ref": "y"}})) + self.assertTrue(self._errors({"windows": {"path": "%APPDATA%/X"}})) + self.assertTrue(self._errors({})) diff --git a/wiki/profiling.md b/wiki/profiling.md index 420c6336..0e106927 100644 --- a/wiki/profiling.md +++ b/wiki/profiling.md @@ -591,6 +591,7 @@ does not look. | `known_hash_adler32` | expected Adler-32 hash (Dolphin's DSP ROMs). Pair with `adler32_byteswap` when the code hashes 16-bit byte-swapped data | | `adler32_pairs_with`, `adler32_pairs` | the file hashed together with this one and the combinations the code accepts, this file's value first, in table order: Dolphin warns on a DSP ROM pair outside its `known_roms` even when each file passes alone | | `pin_frozen` | why `source_commit` is held on purpose rather than followed. The profile is then judged against its own revision, like one pinned to a superseded tag | +| `data_root` | per operating system (`android`, `ios`, `windows`, `macos`, `linux`), the folder the app reads its user data from, under which `standalone_path` destinations sit: `path` as the user finds it (`Android/data//files`, `%APPDATA%/...`), with the `source_ref` that builds it. Only the systems the app runs on are keyed | | `upstream_frozen` | why `upstream_commit` is held: the port vendors that upstream tree, synced to that release, or merged that commit. The upstream view is read at its own pin and a bump leaves it. Without it, a port that is a GitHub fork of its upstream follows the merge base of the two | | `prose_written_at` | the pin a reader confirmed the prose citations against. When the profile's history shows a text living under several pins, profile_sync takes this one as the revision the text describes, so a bump is no longer refused for a citation already reread. It never overrides a history with a single candidate | | `version_source` | for a project published outside any forge, `{url, pattern}`: the page that announces releases and a regular expression whose first group is the latest version. `profile_sync --check-version` compares it with `core_version`, and `check_freshness --profiles` calls the profile stale when they differ. `no_genuine_copy` names an announced version found only rebuilt or re-signed: the profile stays on the newest unmodified build and is not stale until the page announces a later one | From c8c2dcd1a3b5066219b0455474917550c1edcb6e Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:58:37 +0200 Subject: [PATCH 9/9] feat: tell the interface which pad route runs Every launcher exports RETROBIOS_PAD: gptokeyb, muxterm, evmapy or miyoo, so the interface can name the buttons that reach it. The wiki lists the values, and the tests check each launcher's. --- ports/batocera/RetroBIOS.sh | 1 + ports/muos/mux_launch.sh | 1 + ports/shared/tui.txt | 12 ++++++++++++ tests/test_handheld_launchers.py | 30 ++++++++++++++++++++++++++++-- wiki/installer.md | 12 ++++++++++++ 5 files changed, 54 insertions(+), 2 deletions(-) diff --git a/ports/batocera/RetroBIOS.sh b/ports/batocera/RetroBIOS.sh index b296b640..5776c288 100755 --- a/ports/batocera/RetroBIOS.sh +++ b/ports/batocera/RetroBIOS.sh @@ -16,6 +16,7 @@ RB_ROUTES="app vt" RB_VT_SWITCH="${RB_VT_SWITCH:-4}" RB_PACK_PATH="$RB_DIR:$HERE:/userdata" RB_LOG="$RB_DIR/log.txt" +RB_PAD="evmapy" # The X11 builds start EmulationStation on display :0, which a script run # from it does not always inherit, with the cookie S31emulationstation diff --git a/ports/muos/mux_launch.sh b/ports/muos/mux_launch.sh index f0706c14..1cdcb98a 100755 --- a/ports/muos/mux_launch.sh +++ b/ports/muos/mux_launch.sh @@ -55,6 +55,7 @@ if [ -x "$MUXTERM" ]; then else # No gptokeyb2: muxterm's own buttons are all there is. RB_TERMAPP=("$MUXTERM" --gl --) + RB_PAD="muxterm" fi else RB_ROUTES="vt" diff --git a/ports/shared/tui.txt b/ports/shared/tui.txt index ecb73063..bccb30c6 100644 --- a/ports/shared/tui.txt +++ b/ports/shared/tui.txt @@ -30,6 +30,10 @@ # 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 +# RB_PAD the input route, exported to the interface as +# RETROBIOS_PAD: gptokeyb (retrobios.gptk, the default when +# RB_MAPPER is set), muxterm (muxterm's own keys), evmapy +# (RetroBIOS.sh.keys), miyoo (buttons already keys) # ESUDO sudo where the launcher needs it, as PortMaster sets it RB_LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")" @@ -55,6 +59,7 @@ RB_STOP_GRACE="${RB_STOP_GRACE:-10}" RB_DEAD_WAIT="${RB_DEAD_WAIT:-10}" RB_DETECT_LIMIT="${RB_DETECT_LIMIT:-20}" RB_MAPPER_NEEDED="${RB_MAPPER_NEEDED:-1}" +RB_PAD="${RB_PAD:-}" 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" @@ -893,6 +898,13 @@ rb_launch() { export RB_DIR RB_INSTALL RB_EVENTS RB_STATE RB_LOG RB_PACK_PATH RB_FALLBACK export RB_OTHER_CARDS RB_BIOS_DEST RB_STOP_GRACE RB_DEAD_WAIT RB_DETECT_LIMIT export RB_PYTHON RB_PYTHONHOME RB_PYTHON_PROBLEM RB_MESSAGE_WAIT RB_ROUTE_TABLE + # The interface names the buttons of this route in its hints. + if [ -z "$RB_PAD" ] && [ -n "${RB_MAPPER:-}" ]; then + RB_PAD="gptokeyb" + fi + if [ -n "$RB_PAD" ]; then + export RETROBIOS_PAD="$RB_PAD" + fi trap 'rb_cleanup; exit 143' TERM HUP for route in $RB_ROUTES; do rb_route_ready "$route" || continue diff --git a/tests/test_handheld_launchers.py b/tests/test_handheld_launchers.py index 9475bad8..e20c8700 100644 --- a/tests/test_handheld_launchers.py +++ b/tests/test_handheld_launchers.py @@ -308,7 +308,8 @@ with open(calls, "a") as fh: fh.write(json.dumps({"argv0": sys.argv[0], "args": sys.argv[1:], "stdin": tty(0), "stdout": tty(1), "term": os.environ.get("TERM", ""), "pythonhome": os.environ.get("PYTHONHOME", ""), - "base_url": os.environ.get("RETROBIOS_BASE_URL", "")}) + "\n") + "base_url": os.environ.get("RETROBIOS_BASE_URL", ""), + "pad": os.environ.get("RETROBIOS_PAD")}) + "\n") if "--check" in sys.argv: if os.environ.get("FAKE_DETECT_HANG"): time.sleep(60) @@ -725,6 +726,7 @@ class ThePortMasterPort(unittest.TestCase): self.assertEqual(call["term"], "linux") installer = str(self.gamedir / "install.py") self.assertEqual(call["argv0"], installer) + self.assertEqual(call["pad"], "gptokeyb") # Kill mode on the Start + Select sentinel, never on the installer's # path, which the engine's command line carries too. args = device.mapper_args() @@ -955,6 +957,7 @@ class ThePortMasterPort(unittest.TestCase): self.assertEqual(device.tools(), [f"muxterm --gl -- bash {self.gamedir}/tui.txt here"]) self.assertEqual(device.lines("termapp.sdl"), ["0x0001/0x0001"]) self.assertEqual(device.mapper_args()[2:], ["-c", str(self.gamedir / "retrobios.gptk")]) + self.assertEqual(call["pad"], "gptokeyb") self.assertEqual(self.dialog(device), ["FINISH"]) def test_on_muos_without_muxterm_the_log_says_why_the_screens_open(self): @@ -1121,6 +1124,7 @@ class TheBatoceraPort(unittest.TestCase): [f"xterm -fullscreen -e bash {ports}/retrobios/tui.txt here"]) # evmapy reads RetroBIOS.sh.keys: no gptokeyb is started. self.assertEqual(device.mapper_args(), []) + self.assertEqual(call["pad"], "evmapy") def test_the_x_cookie_goes_with_the_display(self): """S31emulationstation starts X with XAUTHORITY=/var/lib/.Xauthority: @@ -1220,13 +1224,15 @@ class TheMuosApp(unittest.TestCase): self.assertRegex(args[1], r"^retrobios-stop-\d+x$") self.assertEqual(args[2:], ["-c", str(app / "retrobios.gptk")]) self.assertEqual(read_gptk(app / "retrobios.gptk")["y"], ["tab"]) + self.assertEqual(call["pad"], "gptokeyb") device.assert_mapper_stopped() def test_muxterm_alone_without_gptokeyb2(self): device, app = self.device(muxterm=True, gptokeyb2=False) proc = device.run([str(app / "mux_launch.sh"), str(app)]) self.assertEqual(proc.returncode, 0, proc.stderr.decode()) - self.assertEqual(len(device.tui_calls()), 1) + [call] = device.tui_calls() + self.assertEqual(call["pad"], "muxterm") # muxterm then reads the pad itself. self.assertEqual(device.lines("termapp.sdl"), [""]) self.assertEqual(device.mapper_args(), []) @@ -1369,6 +1375,7 @@ class TheToolsEntry(unittest.TestCase): self.assertEqual(proc.returncode, 0, proc.stderr.decode()) [call] = device.tui_calls() self.assertEqual((call["stdin"], call["term"]), (device.console, "linux")) + self.assertEqual(call["pad"], "gptokeyb") args = device.mapper_args() self.assertEqual(args[0], "-1") self.assertRegex(args[1], r"^retrobios-stop-\d+x$") @@ -1376,6 +1383,15 @@ class TheToolsEntry(unittest.TestCase): device.assert_mapper_stopped() device.assert_console_restored() + def test_the_previous_log_is_kept(self): + device, tools = self.device() + self.arkos(device) + log = tools / "retrobios" / "log.txt" + log.write_text("the failure\n") + device.run(["bash", str(tools / "RetroBIOS.sh")], RB_CONSOLE=device.console) + self.assertEqual((tools / "retrobios" / "log.prev.txt").read_text(), "the failure\n") + self.assertNotIn("the failure", log.read_text()) + def test_arkos_sudo_keeps_the_controller_list(self): """sudo's env_reset would drop SDL_GAMECONTROLLERCONFIG_FILE, and a pad SDL does not know sends nothing.""" @@ -1447,6 +1463,7 @@ class TheToolsEntry(unittest.TestCase): [call] = device.tui_calls() self.assertEqual((call["stdin"], call["stdout"]), (device.console, device.console)) self.assertEqual(call["term"], "xterm") + self.assertEqual(call["pad"], "gptokeyb") # The terminal app owns its window: no console reset, no font. self.assertNotIn("\x1bc", device.screen) self.assertEqual([t for t in device.tools() if t.startswith("setfont")], []) @@ -1526,6 +1543,15 @@ class TheDocs(unittest.TestCase): text = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8") self.assertIn("The releases carry no pack for ROCKNIX,\nKnulli or AmberELEC", text) + def test_every_pad_value_is_documented(self): + """RETROBIOS_PAD is a contract the interface reads: each value tui.txt + names has its row in the handheld section.""" + text = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8") + tui = (PORTS / "shared" / "tui.txt").read_text() + for value in ("gptokeyb", "muxterm", "evmapy", "miyoo"): + self.assertIn(f"| `{value}` |", text) + self.assertIn(value, tui) + def test_muos_paths_are_the_card_muos_mounts(self): """muOS mounts the first card at /mnt/mmc (MustardOS internal mux_launch.sh, GET_VAR storage/rom/mount).""" diff --git a/wiki/installer.md b/wiki/installer.md index e9ae8128..417b9316 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -1013,6 +1013,18 @@ running install is then stopped the way B stops it, and the last screen says what it wrote. On muOS without PortMaster, muxterm reads the pad itself: Y ticks a system, Select shows its keyboard, and Menu quits. +Each launcher tells the interface which buttons reach it, in +`RETROBIOS_PAD`, so the hints can name them: + +| Value | Input | Set by | +|-------|-------|--------| +| `gptokeyb` | gptokeyb or gptokeyb2 with `retrobios.gptk`, the table above | the PortMaster port, the Tools entry, the muOS app with PortMaster | +| `muxterm` | muxterm's own keys: D-pad, Start as Enter, B as Backspace, Y as Space | the muOS app without PortMaster | +| `evmapy` | `RetroBIOS.sh.keys`, the same buttons as the table | the Batocera and Knulli port | +| `miyoo` | buttons that are already keys, A as Space and B as Left Ctrl | no launcher yet | + +A launcher with no mapper at all (a keyboard) leaves it unset. + **Without Wi-Fi.** On a computer, download the BIOS pack that serves your system (every part, for a pack in parts): RetroArch's on muOS, ArkOS, dArkOS and JELOS, Batocera's on Batocera. Copy it next to RetroBIOS: into