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()