From 801a0150e6895111347e6971dfc017e7d9980547 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:26:47 +0200 Subject: [PATCH 1/4] feat: run the handheld launchers in the tui The PortMaster port now starts install.py --tui on the console (or in foot or xterm under a compositor), with gptokeyb in kill mode on the installer's path, retrobios.gptk, TERM=linux and a Terminus font, and puts the console, font and mapper back on exit. pugwash screens stay as the fallback where no terminal can show, and start in no-check mode so PortMaster's A/B fix applies. Without a network, every launcher installs from a pack found on the card or names the pack and the folder. Adds launchers for Batocera and Knulli (Ports, evmapy .keys), muOS (.muxapp in muxterm with gptokeyb2) and the Tools menu of ArkOS, dArkOS, ROCKNIX, JELOS and AmberELEC, built by build_port.py with the pinned installer and tested on a simulated device. --- ports/RetroBIOS/README.md | 27 +- ports/RetroBIOS/RetroBIOS.sh | 38 +- ports/RetroBIOS/port.json | 2 +- ports/RetroBIOS/retrobios/screens.txt | 143 ++-- ports/batocera/README.md | 23 + ports/batocera/RetroBIOS.sh | 35 + ports/batocera/RetroBIOS.sh.keys | 20 + ports/muos/README.md | 16 + ports/muos/mux_launch.sh | 53 ++ ports/muos/muxterm.gptk | 10 + .../retrobios => shared}/rbevents.py | 14 +- ports/shared/retrobios.gptk | 31 + ports/shared/tui.txt | 502 ++++++++++++ ports/tools/README.md | 20 + ports/tools/RetroBIOS.sh | 77 ++ scripts/build_port.py | 136 +++- scripts/check_release.py | 12 +- tests/test_handheld_launchers.py | 732 ++++++++++++++++++ tests/test_port.py | 80 +- wiki/release-process.md | 13 +- wiki/testing-guide.md | 44 +- 21 files changed, 1822 insertions(+), 206 deletions(-) create mode 100644 ports/batocera/README.md create mode 100755 ports/batocera/RetroBIOS.sh create mode 100644 ports/batocera/RetroBIOS.sh.keys create mode 100644 ports/muos/README.md create mode 100755 ports/muos/mux_launch.sh create mode 100644 ports/muos/muxterm.gptk rename ports/{RetroBIOS/retrobios => shared}/rbevents.py (93%) create mode 100644 ports/shared/retrobios.gptk create mode 100644 ports/shared/tui.txt create mode 100644 ports/tools/README.md create mode 100755 ports/tools/RetroBIOS.sh create mode 100644 tests/test_handheld_launchers.py diff --git a/ports/RetroBIOS/README.md b/ports/RetroBIOS/README.md index eb023ef9..38b35a13 100644 --- a/ports/RetroBIOS/README.md +++ b/ports/RetroBIOS/README.md @@ -2,17 +2,28 @@ RetroBIOS runs the RetroBIOS installer (`install.py`, from [github.com/Abdess/retrobios](https://github.com/Abdess/retrobios)) on the -device. The installer finds the BIOS folder of the system, checks every file -its emulators read against its hash, and downloads the missing ones. The -screens are PortMaster's own. +device, in its full-screen interface. The installer finds the BIOS folder of +the system, checks every file its emulators read against its hash, and +installs the missing ones. -Needs a network connection. Python 3.8 or newer: the system's, or -PortMaster's `python_3.11` runtime on aarch64, downloaded on first use. +With a network connection the files are downloaded. Without one, copy the +BIOS pack of your system (a `_BIOS_Pack.zip` file from the releases page) +into `ports/retrobios` on the card, and RetroBIOS installs from it. + +Python 3.8 or newer: the system's, or PortMaster's `python_3.11` runtime on +aarch64, downloaded on first use. + +Under a desktop session with neither foot nor xterm, RetroBIOS opens +PortMaster's own screens instead of the full-screen interface. ## Controls | Button | Action | |--|--| -| D-Pad | Move in a list | -| A | Choose | -| B | Back, on PortMaster releases that read it (every list also has a Back or Quit entry) | +| D-Pad | Move | +| A or Start | Choose | +| B or Select | Back | +| X | Tick or untick a system | +| Y | Options | +| L1 / R1 | Page up / down | +| Start + Select | Quit | diff --git a/ports/RetroBIOS/RetroBIOS.sh b/ports/RetroBIOS/RetroBIOS.sh index 59928003..d3bf234a 100755 --- a/ports/RetroBIOS/RetroBIOS.sh +++ b/ports/RetroBIOS/RetroBIOS.sh @@ -1,6 +1,7 @@ #!/bin/bash # RetroBIOS: checks the BIOS folder of this system and downloads the files -# it lacks, with PortMaster's own screens. +# it lacks, in the installer's full-screen interface on the console, the way +# PortMaster's Moonlight and Rlvm ports draw dialog there. XDG_DATA_HOME=${XDG_DATA_HOME:-$HOME/.local/share} @@ -14,29 +15,58 @@ else controlfolder="/roms/ports/PortMaster" fi +# shellcheck source=/dev/null source "$controlfolder/control.txt" +# shellcheck source=/dev/null [ -f "${controlfolder}/mod_${CFW_NAME}.txt" ] && source "${controlfolder}/mod_${CFW_NAME}.txt" get_controls +# Set by control.txt. +: "${directory:?}" "${GPTOKEYB:=}" "${sdl_controllerconfig:=}" + GAMEDIR="/$directory/ports/retrobios" cd "$GAMEDIR" || exit 1 -> "$GAMEDIR/log.txt" && exec > >(tee "$GAMEDIR/log.txt") 2>&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 # 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 -# it: it is sourced here. +# it: it is sourced here. It downloads the Python runtime when one is +# needed, and draws the fallback screens. if [ ! -f "$controlfolder/PortMasterDialog.txt" ]; then echo "RetroBIOS needs PortMaster 2024.02.26 or newer: update PortMaster, then start RetroBIOS again." exit 1 fi +# shellcheck source=/dev/null source "$controlfolder/PortMasterDialog.txt" + +RB_DIR="$GAMEDIR" +RB_ROUTES="app vt" +RB_CONSOLE="${CUR_TTY:-/dev/tty0}" +RB_MAPPER="$GPTOKEYB" +RB_FALLBACK=1 +RB_PACK_PATH="$GAMEDIR:/$directory/ports:/$directory" +# shellcheck source=../shared/tui.txt +source "$GAMEDIR/tui.txt" +# shellcheck source=retrobios/screens.txt source "$GAMEDIR/screens.txt" export SDL_GAMECONTROLLERCONFIG="$sdl_controllerconfig" -rb_main +rb_select_python +# A launcher's own arguments are not install.py's (muOS passes the app folder). +# shellcheck disable=SC2119 +rb_launch status=$? +if [ "$status" = "125" ]; then + # No console or terminal app shows the interface here: PortMaster's own + # screens do. + rb_main + status=$? +fi +rb_release_python if declare -F pm_finish > /dev/null; then pm_finish diff --git a/ports/RetroBIOS/port.json b/ports/RetroBIOS/port.json index 083ef871..5ee91583 100644 --- a/ports/RetroBIOS/port.json +++ b/ports/RetroBIOS/port.json @@ -13,7 +13,7 @@ ], "desc": "Checks the BIOS folder of this system against the files its emulators read, and downloads the missing ones from the RetroBIOS collection, each verified by its hash.", "desc_md": null, - "inst": "Needs a network connection: the list of files and the files come from the internet. Uses the system's Python 3.8 or newer, or PortMaster's python_3.11 runtime on aarch64.", + "inst": "Downloads the files with a network connection. Without one, copy the BIOS pack of your system (a _BIOS_Pack.zip file from github.com/Abdess/retrobios/releases) into ports/retrobios. Uses the system's Python 3.8 or newer, or PortMaster's python_3.11 runtime on aarch64.", "inst_md": null, "genres": [ "other" diff --git a/ports/RetroBIOS/retrobios/screens.txt b/ports/RetroBIOS/retrobios/screens.txt index c3348761..b486cb87 100644 --- a/ports/RetroBIOS/retrobios/screens.txt +++ b/ports/RetroBIOS/retrobios/screens.txt @@ -1,7 +1,12 @@ #!/bin/bash +# shellcheck shell=bash # RetroBIOS screens, drawn by PortMaster's own GUI (pugwash fifo_control) # through the functions of PortMasterDialog.txt. Sourced by RetroBIOS.sh -# after PortMasterDialog.txt. +# after PortMasterDialog.txt and tui.txt. +# +# They are the port's fallback: RetroBIOS.sh opens them only when no terminal +# can show install.py --tui, that is under a compositor that has neither foot +# nor xterm, or when the interface refuses the console it was given. # # Only what PortMaster 2024.02.26 and the 8.5.23 copy JELOS and KOLOS ship # is used: PortMasterDialogInit, PortMasterDialog, PortMasterDialogResult, @@ -13,97 +18,42 @@ # and their wait for an answer gives up after 30 seconds: rb_answer keeps # waiting while pugwash is alive. # -# Inputs: GAMEDIR, controlfolder, ESUDO, and RB_INSTALL (install.py). +# pugwash starts in "no-check" mode, never "no-harbour": only with +# HarbourMaster loaded does it apply the A/B fix PortMaster keeps for Miyoo, +# TrimUI, Knulli and the Xbox-layout ArkOS and AmberELEC pads. +# +# Inputs: GAMEDIR, controlfolder, ESUDO, and what tui.txt defines +# (RB_INSTALL, RB_STATE, RB_LOG, rb_py, rb_online, rb_find_pack). -RB_STATE="${RB_STATE:-$GAMEDIR/run}" -RB_LOG="${RB_LOG:-$GAMEDIR/log.txt}" -RB_INSTALL="${RB_INSTALL:-$GAMEDIR/install.py}" RB_EVENTS="${RB_EVENTS:-$GAMEDIR/rbevents.py}" -RB_RUNTIME="python_3.11" -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_PYTHONHOME="" -RB_MOUNTED="" -RB_PYTHON_PROBLEM="" +RB_SOURCE_ARGS=() +# Read by tui.txt's rb_python_text. +# shellcheck disable=SC2034 +RB_PYTHON_PROBLEM="${RB_PYTHON_PROBLEM:-}" -rb_arch() { - uname -m -} +# --- Python runtime ------------------------------------------------------- -rb_py() { - if [ -n "$RB_PYTHONHOME" ]; then - PYTHONHOME="$RB_PYTHONHOME" "$RB_PYTHON" "$@" - else - "$RB_PYTHON" "$@" - fi -} - -# --- Python --------------------------------------------------------------- - -rb_python_ok() { - "$@" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 8) else 1)' >/dev/null 2>&1 -} - -# The OS python3 when it is 3.8 or newer, else PortMaster's python_3.11 -# runtime on aarch64, mounted as PortMaster's own Python ports do. Sets -# RB_PYTHON_PROBLEM when neither can run install.py. -rb_select_python() { - if command -v python3 >/dev/null 2>&1 && rb_python_ok python3; then - RB_PYTHON="python3" - return 0 - fi - if [ "$(rb_arch)" != "aarch64" ]; then - RB_PYTHON_PROBLEM="old" +# tui.txt's rb_runtime calls this when the runtime is not on the card yet: +# PortMaster downloads it the way its own ports get theirs. +rb_fetch_runtime() { + local squashfs="$1" answer + if [ -z "$(PortMasterIPCheck)" ]; then + RB_PYTHON_PROBLEM="runtime_offline" return 1 fi - rb_runtime -} - -rb_runtime() { - local python_dir="$HOME/$RB_RUNTIME" - local squashfs="$controlfolder/libs/$RB_RUNTIME.squashfs" - if [ ! -f "$squashfs" ]; then - if [ -z "$(PortMasterIPCheck)" ]; then - RB_PYTHON_PROBLEM="runtime_offline" - return 1 - fi - PortMasterDialogInit "no-check" - PortMasterDialog "messages_begin" - PortMasterDialog "message" "This system's Python is older than 3.8. Downloading PortMaster's $RB_RUNTIME runtime." - local answer - answer=$(rb_answer "check_runtime" "$RB_RUNTIME.squashfs") - PortMasterDialog "messages_end" - PortMasterDialogExit - if [ "$answer" != "OKAY" ] || [ ! -f "$squashfs" ]; then - RB_PYTHON_PROBLEM="runtime_failed" - return 1 - fi - fi - mkdir -p "$python_dir" - if [ "${PM_CAN_MOUNT:-Y}" != "N" ]; then - $ESUDO umount "$python_dir" >/dev/null 2>&1 - fi - if ! $ESUDO mount "$squashfs" "$python_dir"; then - RB_PYTHON_PROBLEM="runtime_failed" - return 1 - fi - RB_MOUNTED="$python_dir" - RB_PYTHON="$python_dir/bin/python3" - RB_PYTHONHOME="$python_dir" - if ! PYTHONHOME="$python_dir" rb_python_ok "$RB_PYTHON"; then + PortMasterDialogInit "no-check" + PortMasterDialog "messages_begin" + PortMasterDialog "message" "This system's Python is older than 3.8. Downloading PortMaster's $RB_RUNTIME runtime." + answer=$(rb_answer "check_runtime" "$RB_RUNTIME.squashfs") + PortMasterDialog "messages_end" + PortMasterDialogExit + if [ "$answer" != "OKAY" ] || [ ! -f "$squashfs" ]; then RB_PYTHON_PROBLEM="runtime_failed" return 1 fi return 0 } -rb_release_python() { - if [ -n "$RB_MOUNTED" ] && [ "${PM_CAN_MOUNT:-Y}" != "N" ]; then - $ESUDO umount "$RB_MOUNTED" >/dev/null 2>&1 - fi - RB_MOUNTED="" -} - # --- Dialog primitives ---------------------------------------------------- # A command that waits for the user, and its answer. PortMasterDialogResult @@ -174,12 +124,19 @@ rb_notice() { # --- Reading install.py --------------------------------------------------- -# Run a --check and read its records into RB_* variables. +# Run a --check and read its records into RB_* variables. Without a network +# the files come from a pack on the card, when there is one. rb_check() { mkdir -p "$RB_STATE" PortMasterDialog "messages_begin" PortMasterDialog "message" "Reading the BIOS folder and the file list." - rb_py "$RB_INSTALL" --check --events "${RB_PLATFORM_ARGS[@]}" > "$RB_STATE/check.jsonl" 2>> "$RB_LOG" + RB_SOURCE_ARGS=() + if ! rb_online; then + local pack + pack=$(rb_find_pack) + [ -n "$pack" ] && RB_SOURCE_ARGS=(--from-pack "$pack") + fi + rb_py "$RB_INSTALL" --check --events "${RB_SOURCE_ARGS[@]}" "${RB_PLATFORM_ARGS[@]}" > "$RB_STATE/check.jsonl" 2>> "$RB_LOG" PortMasterDialog "messages_end" rb_py "$RB_EVENTS" status "$RB_STATE/check.jsonl" > "$RB_STATE/status.tsv" 2>> "$RB_LOG" rb_read_status "$RB_STATE/status.tsv" @@ -235,15 +192,7 @@ rb_pending() { # --- Screens -------------------------------------------------------------- rb_screen_no_python() { - local found - found=$(python3 --version 2>&1 | awk '{print $2}') - local text - case "$RB_PYTHON_PROBLEM" in - old) text="Python 3.8 or newer is needed. This system has ${found:-none}, and PortMaster's Python runtime exists for aarch64 only, and this device is $(rb_arch)." ;; - runtime_offline) text="Python 3.8 or newer is needed. This system has ${found:-none}, and PortMaster's $RB_RUNTIME runtime needs a connection to download." ;; - *) text="Python 3.8 or newer is needed. This system has ${found:-none}, and PortMaster's $RB_RUNTIME runtime could not be installed or run." ;; - esac - rb_notice "Python 3.8 or newer is needed" "$text $RB_PC_ROUTE" "quit" "Quit" > /dev/null + rb_notice "Python 3.8 or newer is needed" "$(rb_python_text)" "quit" "Quit" > /dev/null } # What the check found when it found nothing to show: no platform, no @@ -256,7 +205,7 @@ rb_screen_problem() { elif [ "$RB_DETECTED" = "0" ]; then rb_notice "No BIOS folder found" "install.py knows no BIOS folder on $RB_OS_NAME. $RB_PC_ROUTE" "check" "Check again" "quit" "Quit" elif [ "$RB_ERROR" = "network" ]; then - rb_notice "No connection" "$RB_OS_NAME, BIOS folder $RB_BIOS_DIR. The list of files to check comes from the internet, and so do the files: without a connection nothing can be checked or downloaded. Connect to Wi-Fi, then check again." "check" "Check again" "quit" "Quit" + rb_notice "No connection" "$RB_OS_NAME, BIOS folder $RB_BIOS_DIR. $(rb_pack_text)" "check" "Check again" "quit" "Quit" elif [ "$RB_ERROR" = "invalid" ]; then rb_notice "The file list is newer than this port" "$RB_ERROR_TEXT. Update RetroBIOS from PortMaster, or use the installer on a computer. $RB_PC_ROUTE" "check" "Check again" "quit" "Quit" else @@ -354,7 +303,7 @@ rb_update() { PortMasterDialog "message" "Downloading to $RB_BIOS_DIR." PortMasterDialog "progress" "Starting" 0 1 local kind a b c d e f - rb_py "$RB_INSTALL" --events "${RB_PLATFORM_ARGS[@]}" "${args[@]}" 2>> "$RB_LOG" \ + rb_py "$RB_INSTALL" --events "${RB_SOURCE_ARGS[@]}" "${RB_PLATFORM_ARGS[@]}" "${args[@]}" 2>> "$RB_LOG" \ | rb_py "$RB_EVENTS" follow "$RB_STATE/summary.tsv" 2>> "$RB_LOG" \ | while IFS=$'\t' read -r kind a b c d e f; do case "$kind" in @@ -425,15 +374,14 @@ rb_screen_summary() { # --- Flow ----------------------------------------------------------------- +# The screens, once rb_select_python has run. rb_main() { - if ! rb_select_python; then - PortMasterDialogInit "no-harbour" + PortMasterDialogInit "no-check" + if [ -z "$RB_PYTHON" ]; then rb_screen_no_python PortMasterDialogExit - rb_release_python return 1 fi - PortMasterDialogInit "no-harbour" RB_PLATFORM_ARGS=() local action systems chosen rb_check @@ -466,6 +414,5 @@ rb_main() { esac done PortMasterDialogExit - rb_release_python return 0 } diff --git a/ports/batocera/README.md b/ports/batocera/README.md new file mode 100644 index 00000000..37264e1c --- /dev/null +++ b/ports/batocera/README.md @@ -0,0 +1,23 @@ +# RetroBIOS for Batocera and Knulli + +Copy `RetroBIOS.sh`, `RetroBIOS.sh.keys` and the `retrobios` folder into +`roms/ports` on the SHARE partition (`/userdata/roms/ports`), update the +game list, then start **RetroBIOS** from **Ports**. + +It checks the BIOS folder (`/userdata/bios`) against the files its emulators +read and installs the missing ones: downloaded with a network connection, +or from a pack copied into `roms/ports/retrobios` without one. + +The controller drives it through `RetroBIOS.sh.keys`: 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. B on the first screen closes it. + +## Not tried on a device yet + +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 +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 new file mode 100755 index 00000000..aa4d27ba --- /dev/null +++ b/ports/batocera/RetroBIOS.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# RetroBIOS for Batocera and Knulli, started from Ports: checks the BIOS +# folder and downloads the files it lacks, in the installer's full-screen +# interface. Batocera gives the controller the keys of RetroBIOS.sh.keys +# (evmapy) while this runs. +# +# The interface needs a terminal the player can see, and EmulationStation +# covers the console. In order: xterm or foot in the graphical session, else +# console 4 switched to and back from. Console 4 because Batocera's exit +# hotkey sends Alt+F4 (batocera_launch emulators/sh.py), which on a console +# switches to console 4: there it leaves the interface where it is. + +HERE="$(cd "$(dirname "$0")" && pwd)" +RB_DIR="$HERE/retrobios" +RB_ROUTES="app vt" +RB_VT_SWITCH="${RB_VT_SWITCH:-4}" +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 + export DISPLAY=:0 +fi + +# shellcheck source=../shared/tui.txt +source "$RB_DIR/tui.txt" +: > "$RB_LOG" +rb_select_python +# A launcher's own arguments are not install.py's (muOS passes the app folder). +# shellcheck disable=SC2119 +rb_launch +status=$? +rb_release_python +exit $status diff --git a/ports/batocera/RetroBIOS.sh.keys b/ports/batocera/RetroBIOS.sh.keys new file mode 100644 index 00000000..5bd8d46a --- /dev/null +++ b/ports/batocera/RetroBIOS.sh.keys @@ -0,0 +1,20 @@ +{ + "actions_player1": [ + {"trigger": "a", "type": "key", "target": ["KEY_ENTER"]}, + {"trigger": "b", "type": "key", "target": ["KEY_ESC"]}, + {"trigger": "x", "type": "key", "target": ["KEY_SPACE"]}, + {"trigger": "y", "type": "key", "target": ["KEY_TAB"]}, + {"trigger": "start", "type": "key", "target": ["KEY_ENTER"]}, + {"trigger": "select", "type": "key", "target": ["KEY_ESC"]}, + {"trigger": "pageup", "type": "key", "target": ["KEY_PAGEUP"]}, + {"trigger": "pagedown", "type": "key", "target": ["KEY_PAGEDOWN"]}, + {"trigger": "up", "type": "key", "target": ["KEY_UP"]}, + {"trigger": "down", "type": "key", "target": ["KEY_DOWN"]}, + {"trigger": "left", "type": "key", "target": ["KEY_LEFT"]}, + {"trigger": "right", "type": "key", "target": ["KEY_RIGHT"]}, + {"trigger": "joystick1up", "type": "key", "target": ["KEY_UP"]}, + {"trigger": "joystick1down", "type": "key", "target": ["KEY_DOWN"]}, + {"trigger": "joystick1left", "type": "key", "target": ["KEY_LEFT"]}, + {"trigger": "joystick1right", "type": "key", "target": ["KEY_RIGHT"]} + ] +} diff --git a/ports/muos/README.md b/ports/muos/README.md new file mode 100644 index 00000000..47e4f1d7 --- /dev/null +++ b/ports/muos/README.md @@ -0,0 +1,16 @@ +# RetroBIOS for muOS + +Copy `RetroBIOS.muxapp` to the card, open it from muOS's archive manager to +install it, then start **RetroBIOS** from **Applications**. + +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. + +Not tried on a device yet: the icon, and whether muxterm and gptokeyb2 share +the pad as described. diff --git a/ports/muos/mux_launch.sh b/ports/muos/mux_launch.sh new file mode 100755 index 00000000..5e567756 --- /dev/null +++ b/ports/muos/mux_launch.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# HELP: Checks the BIOS folder and installs the missing BIOS files +# ICON: retrobios +# GRID: RetroBIOS + +# 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. + +MUOS="${RB_MUOS:-/opt/muos}" +# shellcheck source=/dev/null +. "$MUOS/script/var/retro.sh" +SETUP_APP "retrobios" "" + +APP_DIR="$(cd "$(dirname "$0")" && pwd)" +ROM_MOUNT="$(GET_VAR "device" "storage/rom/mount")" +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" +# PortMaster keeps its runtimes here: the Python runtime is used when +# PortMaster has downloaded it. +controlfolder="$PM_DIR" + +# shellcheck source=../shared/tui.txt +source "$APP_DIR/tui.txt" +: > "$RB_LOG" + +if [ -x "$PM_DIR/gptokeyb2" ]; then + # As muOS's own launchers start it (script/launch/ext-xroar.sh). + ln -sf "$PM_DIR/libinterpose.aarch64.so" "/usr/lib/libinterpose.aarch64.so" >/dev/null 2>&1 + RB_MAPPER="$PM_DIR/gptokeyb2" +fi +if [ -x "$MUXTERM" ]; then + RB_ROUTES="app" + RB_TERMAPP=("$MUXTERM" --gl --) + RB_GPTK="$APP_DIR/muxterm.gptk" +else + RB_ROUTES="vt" + RB_CONSOLE="${RB_CONSOLE:-/dev/tty0}" + RB_GPTK="$APP_DIR/retrobios.gptk" +fi + +rb_select_python +# A launcher's own arguments are not install.py's (muOS passes the app folder). +# shellcheck disable=SC2119 +rb_launch +status=$? +rb_release_python +exit $status diff --git a/ports/muos/muxterm.gptk b/ports/muos/muxterm.gptk new file mode 100644 index 00000000..c0699f04 --- /dev/null +++ b/ports/muos/muxterm.gptk @@ -0,0 +1,10 @@ +# 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/RetroBIOS/retrobios/rbevents.py b/ports/shared/rbevents.py similarity index 93% rename from ports/RetroBIOS/retrobios/rbevents.py rename to ports/shared/rbevents.py index 04e32a84..29e191ce 100644 --- a/ports/RetroBIOS/retrobios/rbevents.py +++ b/ports/shared/rbevents.py @@ -1,11 +1,13 @@ #!/usr/bin/env python3 -"""Turn install.py --events into tab-separated records for the launcher. +"""Turn install.py --events into tab-separated records for the launchers. -The launcher is bash and draws through PortMaster's dialog FIFO, so it reads -one record per line with `IFS=$'\\t' read`. Text fields are cleaned for that -FIFO: PortMasterDialog sends its arguments through an unquoted -`echo $(printf ...)`, where a tab or newline splits a field and `*`, `?` or -`[` expand as a file pattern. +The launchers are bash, so they read one record per line with +`IFS=$'\\t' read`: tui.txt reads the system and its BIOS folder to name the +pack an offline run needs, and the PortMaster screens draw through +PortMaster's dialog FIFO. Text fields are cleaned for that FIFO: +PortMasterDialog sends its arguments through an unquoted `echo $(printf ...)`, +where a tab or newline splits a field and `*`, `?` or `[` expand as a file +pattern. rbevents.py status EVENTS records of a --check run rbevents.py follow SUMMARY an install run on stdin: progress diff --git a/ports/shared/retrobios.gptk b/ports/shared/retrobios.gptk new file mode 100644 index 00000000..a5d76dc3 --- /dev/null +++ b/ports/shared/retrobios.gptk @@ -0,0 +1,31 @@ +# RetroBIOS full-screen interface: the menu keys dArkOS, AmberELEC and the +# PortMaster dialog ports give gptokeyb. A chooses, B goes back, X ticks a +# system, Y opens the options, L1 and R1 page through a list. Start + Select +# closes the interface (gptokeyb kill mode). +back = esc +start = enter +guide = \" +a = enter +b = esc +x = space +y = tab +l1 = pageup +l2 = \" +l3 = \" +r1 = pagedown +r2 = \" +r3 = \" +up = up +up = repeat +down = down +down = repeat +left = left +right = right +left_analog_up = up +left_analog_down = down +left_analog_left = left +left_analog_right = right +right_analog_up = \" +right_analog_down = \" +right_analog_left = \" +right_analog_right = \" diff --git a/ports/shared/tui.txt b/ports/shared/tui.txt new file mode 100644 index 00000000..36dc72c6 --- /dev/null +++ b/ports/shared/tui.txt @@ -0,0 +1,502 @@ +#!/bin/bash +# shellcheck shell=bash +# RetroBIOS on a handheld: runs install.py --tui on a terminal the player can +# see, with the controller mapped to the keys the interface reads. +# +# Sourced by every launcher (the PortMaster port, the Batocera and Knulli +# port, the muOS app, the Tools entry), each of which sets the inputs below +# and calls rb_select_python, then rb_launch. Run as `bash tui.txt here`, it +# is the part that runs inside a terminal app the launcher opened. +# +# Inputs, all optional: +# RB_ROUTES where to look for a terminal, in order: here (this +# script's own stdin and stdout), app (foot or xterm in the +# graphical session), vt (a Linux console). Default "app vt". +# RB_CONSOLE the console of the vt route (default /dev/tty0) +# RB_VT_SWITCH a console number to switch to when a graphical session +# covers the current one (Batocera, Knulli) +# RB_MAPPER the command that starts gptokeyb, kill flag included +# ("$GPTOKEYB" in a PortMaster port), empty for none +# RB_GPTK its mapping (default retrobios.gptk next to this file) +# RB_PACK_PATH folders searched for a pack when there is no network, +# separated by ":", the first one named to the player +# RB_FALLBACK 1 when the launcher has screens of its own to fall back to +# ESUDO sudo where the launcher needs it, as PortMaster sets it + +RB_LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")" +RB_DIR="${RB_DIR:-$(dirname "$RB_LIB")}" +RB_INSTALL="${RB_INSTALL:-$RB_DIR/install.py}" +RB_EVENTS="${RB_EVENTS:-$RB_DIR/rbevents.py}" +RB_GPTK="${RB_GPTK:-$RB_DIR/retrobios.gptk}" +RB_STATE="${RB_STATE:-$RB_DIR/run}" +RB_LOG="${RB_LOG:-$RB_DIR/log.txt}" +RB_ROUTES="${RB_ROUTES:-app vt}" +RB_CONSOLE="${RB_CONSOLE:-/dev/tty0}" +RB_PACK_PATH="${RB_PACK_PATH:-$RB_DIR}" +RB_FONTS="${RB_FONTS:-/usr/share/consolefonts}" +RB_FB="${RB_FB:-/sys/class/graphics/fb0/virtual_size}" +RB_ROUTE_TABLE="${RB_ROUTE_TABLE:-/proc/net/route}" +RB_VT_ACTIVE="${RB_VT_ACTIVE:-/sys/class/tty/tty0/active}" +RB_PROBE_URL="${RETROBIOS_BASE_URL:-https://raw.githubusercontent.com/Abdess/retrobios/}" +RB_RUNTIME="python_3.11" +RB_RELEASES="github.com/Abdess/retrobios/releases/latest" +RB_PC_ROUTE="On a computer, with this SD card mounted, the same installer fills the card: install.py --platform NAME --dest BIOS-FOLDER-ON-THE-CARD. Instructions: abdess.github.io/retrobios/wiki/installer" +RB_PYTHON="${RB_PYTHON:-}" +RB_PYTHONHOME="${RB_PYTHONHOME:-}" +RB_PYTHON_PROBLEM="${RB_PYTHON_PROBLEM:-}" +RB_MOUNTED="" +RB_MAPPER_PID="" +RB_TTY="" +RB_STTY="" +RB_FONT_BACK="" +RB_VT_BACK="" +RB_ROUTE="" +RB_TERMAPP=() + +rb_arch() { + uname -m +} + +rb_py() { + if [ -n "$RB_PYTHONHOME" ]; then + PYTHONHOME="$RB_PYTHONHOME" "$RB_PYTHON" "$@" + else + "$RB_PYTHON" "$@" + fi +} + +# --- Python --------------------------------------------------------------- + +rb_python_ok() { + "$@" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 8) else 1)' >/dev/null 2>&1 +} + +# The OS python3 when it is 3.8 or newer, else PortMaster's python_3.11 +# runtime on aarch64, mounted as PortMaster's own Python ports do. A launcher +# that can download the runtime defines rb_fetch_runtime. Sets +# RB_PYTHON_PROBLEM when neither can run install.py. +rb_select_python() { + if command -v python3 >/dev/null 2>&1 && rb_python_ok python3; then + RB_PYTHON="python3" + return 0 + fi + if [ "$(rb_arch)" != "aarch64" ]; then + RB_PYTHON_PROBLEM="old" + return 1 + fi + rb_runtime +} + +rb_runtime() { + local python_dir="$HOME/$RB_RUNTIME" + local squashfs="${controlfolder:-/nonexistent}/libs/$RB_RUNTIME.squashfs" + if [ ! -f "$squashfs" ]; then + if ! declare -F rb_fetch_runtime >/dev/null; then + RB_PYTHON_PROBLEM="runtime_missing" + return 1 + fi + rb_fetch_runtime "$squashfs" || return 1 + fi + mkdir -p "$python_dir" + if [ "${PM_CAN_MOUNT:-Y}" != "N" ]; then + $ESUDO umount "$python_dir" >/dev/null 2>&1 + fi + if ! $ESUDO mount "$squashfs" "$python_dir"; then + RB_PYTHON_PROBLEM="runtime_failed" + return 1 + fi + RB_MOUNTED="$python_dir" + RB_PYTHON="$python_dir/bin/python3" + RB_PYTHONHOME="$python_dir" + if ! PYTHONHOME="$python_dir" rb_python_ok "$RB_PYTHON"; then + RB_PYTHON="" + RB_PYTHON_PROBLEM="runtime_failed" + return 1 + fi + return 0 +} + +rb_release_python() { + if [ -n "$RB_MOUNTED" ] && [ "${PM_CAN_MOUNT:-Y}" != "N" ]; then + $ESUDO umount "$RB_MOUNTED" >/dev/null 2>&1 + fi + RB_MOUNTED="" +} + +rb_python_text() { + local found + found=$(python3 --version 2>&1 | awk '{print $2}') + case "$RB_PYTHON_PROBLEM" in + old) printf '%s\n' "Python 3.8 or newer is needed. This system has ${found:-none}, and PortMaster's Python runtime exists for aarch64 only, and this device is $(rb_arch). $RB_PC_ROUTE" ;; + runtime_offline) printf '%s\n' "Python 3.8 or newer is needed. This system has ${found:-none}, and PortMaster's $RB_RUNTIME runtime needs a connection to download. $RB_PC_ROUTE" ;; + runtime_missing) printf '%s\n' "Python 3.8 or newer is needed. This system has ${found:-none}. The RetroBIOS port from PortMaster downloads PortMaster's $RB_RUNTIME runtime, which this entry then uses too. $RB_PC_ROUTE" ;; + *) printf '%s\n' "Python 3.8 or newer is needed. This system has ${found:-none}, and PortMaster's $RB_RUNTIME runtime could not be installed or run. $RB_PC_ROUTE" ;; + esac +} + +# --- Network and packs ---------------------------------------------------- + +# Whether the file list can be reached. Only a failure to resolve or to +# connect counts as offline: a certificate refused by an old curl still means +# a network, and install.py names that problem itself. +rb_online() { + local code + if command -v curl >/dev/null 2>&1; then + curl -sSI --max-time 8 "$RB_PROBE_URL" >/dev/null 2>&1 + code=$? + case "$code" in + 5|6|7|28) return 1 ;; + *) return 0 ;; + esac + fi + if command -v wget >/dev/null 2>&1; then + wget -q --spider -T 8 "$RB_PROBE_URL" >/dev/null 2>&1 + [ $? -ne 4 ] + return + fi + # Neither tool: a default route is the best guess there is. + awk 'NR > 1 && $2 == "00000000" { found = 1 } END { exit !found }' "$RB_ROUTE_TABLE" 2>/dev/null +} + +# The system and BIOS folder install.py finds here, read without a network: +# detection is local, and the run stops where the file list would download. +rb_detect() { + RB_PLATFORM="" + RB_PLATFORM_NAME="" + RB_BIOS_DIR="" + mkdir -p "$RB_STATE" + rb_py "$RB_INSTALL" --check --events > "$RB_STATE/detect.jsonl" 2>> "$RB_LOG" + local kind a b c + while IFS=$'\t' read -r kind a b c; do + if [ "$kind" = "platform" ]; then + RB_PLATFORM="$a" + RB_PLATFORM_NAME="$b" + RB_BIOS_DIR="$c" + [ "$RB_BIOS_DIR" = "-" ] && RB_BIOS_DIR="" + break + fi + done < <(rb_py "$RB_EVENTS" status "$RB_STATE/detect.jsonl" 2>> "$RB_LOG") +} + +# The first pack in RB_PACK_PATH, one whose name spells this system first: +# install.py reads the platform from the same spelling (Name_With_Underscores +# between two underscores). +rb_find_pack() { + local dir file base stem first="" want="" + if [ -n "${RB_PLATFORM_NAME:-}" ]; then + want="_${RB_PLATFORM_NAME// /_}_" + fi + local IFS=":" + for dir in $RB_PACK_PATH; do + [ -d "$dir" ] || continue + for file in "$dir"/*_BIOS_Pack.zip "$dir"/*_BIOS_Pack.part1of*.zip; do + [ -f "$file" ] || continue + base="${file##*/}" + stem="_${base%%_BIOS_Pack*}_" + if [ -n "$want" ] && [[ "$stem" == *"$want"* ]]; then + printf '%s\n' "$file" + return 0 + fi + [ -z "$first" ] && first="$file" + done + done + [ -n "$first" ] && printf '%s\n' "$first" + return 0 +} + +rb_pack_text() { + local dir="${RB_PACK_PATH%%:*}" pack + if [ -n "${RB_PLATFORM_NAME:-}" ]; then + pack="the BIOS pack whose name contains ${RB_PLATFORM_NAME// /_}" + else + pack="the BIOS pack for this system (a file whose name ends in _BIOS_Pack.zip)" + fi + printf '%s\n' "No network connection. To install without one, download $pack from $RB_RELEASES on a computer, copy it into $dir on the SD card, then start RetroBIOS again. Or connect to Wi-Fi and start again." +} + +# --- Terminal ------------------------------------------------------------- + +rb_graphical() { + [ -n "${WAYLAND_DISPLAY:-}" ] || [ -n "${DISPLAY:-}" ] \ + || pgrep -x 'sway|weston|labwc|cage|gamescope|Xorg|X' >/dev/null 2>&1 +} + +# A terminal app the graphical session can show, full screen. A launcher +# that knows its own (muOS's muxterm) sets RB_TERMAPP after sourcing this. +rb_terminal_app() { + if [ "${#RB_TERMAPP[@]}" -gt 0 ]; then + return 0 + fi + if [ -z "${WAYLAND_DISPLAY:-}" ] && [ -z "${DISPLAY:-}" ]; then + return 1 + fi + if command -v foot >/dev/null 2>&1; then + RB_TERMAPP=(foot -F --) + elif command -v xterm >/dev/null 2>&1; then + RB_TERMAPP=(xterm -fullscreen -e) + else + return 1 + fi +} + +# Sets RB_ROUTE to the first of RB_ROUTES that can show text: here, app, +# vt, or none. +rb_route() { + local route + RB_ROUTE="none" + for route in $RB_ROUTES; do + case "$route" in + here) + if [ -t 0 ] && [ -t 1 ]; then + RB_ROUTE="here" + return 0 + fi + ;; + app) + if rb_terminal_app; then + RB_ROUTE="app" + return 0 + fi + ;; + vt) + # A compositor or X server holds the console it runs on: text + # written there is never seen, so the console needs a switch. + if rb_graphical; then + if [ -n "${RB_VT_SWITCH:-}" ] && command -v chvt >/dev/null 2>&1 \ + && [ -n "$(rb_active_vt)" ]; then + RB_ROUTE="vt" + return 0 + fi + elif [ -c "$RB_CONSOLE" ]; then + RB_ROUTE="vt" + return 0 + fi + ;; + esac + done + return 1 +} + +# The largest Terminus font that keeps 56 columns and 20 rows, the way +# dArkOS's own tools pick one per screen (Lat7-Terminus16 on 480x320, +# TerminusBold22x11 on 640x480, 32x16 on 1024x768). Only changed when the +# font in place can be put back: saved with setfont -O, or the +# Lat7-Terminus20x10 ArkOS tools restore. +rb_font_set() { + command -v setfont >/dev/null 2>&1 || return 0 + local size width height spec fh fw name file="" fallback + size=$(cat "$RB_FB" 2>/dev/null) || return 0 + width="${size%%,*}" + height="${size##*,}" + case "$width$height" in + ''|*[!0-9]*) return 0 ;; + esac + for spec in "32 16 Lat7-TerminusBold32x16" "28 14 Lat7-TerminusBold28x14" \ + "22 11 Lat7-TerminusBold22x11" "20 10 Lat7-TerminusBold20x10" \ + "16 8 Lat7-Terminus16"; do + read -r fh fw name <<< "$spec" + if [ $((width / fw)) -ge 56 ] && [ $((height / fh)) -ge 20 ] \ + && [ -f "$RB_FONTS/$name.psf.gz" ]; then + file="$RB_FONTS/$name.psf.gz" + break + fi + done + if [ -z "$file" ] && [ $((width / 8)) -ge 40 ] && [ $((height / 16)) -ge 12 ] \ + && [ -f "$RB_FONTS/Lat7-Terminus16.psf.gz" ]; then + file="$RB_FONTS/Lat7-Terminus16.psf.gz" + fi + [ -n "$file" ] || return 0 + if $ESUDO setfont -C "$RB_TTY" -O "$RB_STATE/font.psf" "$file" >> "$RB_LOG" 2>&1 \ + && [ -s "$RB_STATE/font.psf" ]; then + RB_FONT_BACK="$RB_STATE/font.psf" + return 0 + fi + fallback="$RB_FONTS/Lat7-Terminus20x10.psf.gz" + if [ -f "$fallback" ] && $ESUDO setfont -C "$RB_TTY" "$file" >> "$RB_LOG" 2>&1; then + RB_FONT_BACK="$fallback" + fi +} + +rb_font_restore() { + if [ -n "$RB_FONT_BACK" ]; then + $ESUDO setfont -C "$RB_TTY" "$RB_FONT_BACK" >> "$RB_LOG" 2>&1 + fi + RB_FONT_BACK="" +} + +# The number of the console in front, which a switch has to come back to. +rb_active_vt() { + local active + active=$(cat "$RB_VT_ACTIVE" 2>/dev/null) + active="${active#tty}" + case "$active" in + ''|*[!0-9]*) active=$(fgconsole 2>/dev/null) ;; + esac + case "$active" in + ''|*[!0-9]*) return 1 ;; + esac + printf '%s\n' "$active" +} + +rb_vt_enter() { + RB_TTY="$RB_CONSOLE" + if rb_graphical && [ -n "${RB_VT_SWITCH:-}" ]; then + RB_VT_BACK=$(rb_active_vt) + RB_TTY="${RB_VT_PREFIX:-/dev/tty}$RB_VT_SWITCH" + $ESUDO chvt "$RB_VT_SWITCH" >> "$RB_LOG" 2>&1 + fi + if [ ! -r "$RB_TTY" ] || [ ! -w "$RB_TTY" ]; then + $ESUDO chmod 666 "$RB_TTY" >> "$RB_LOG" 2>&1 + fi + RB_STTY=$(stty -g < "$RB_TTY" 2>/dev/null) + printf '\033c' > "$RB_TTY" + rb_font_set +} + +rb_vt_leave() { + [ -n "$RB_TTY" ] || return 0 + rb_font_restore + printf '\033c' > "$RB_TTY" 2>/dev/null + if [ -n "$RB_STTY" ]; then + stty "$RB_STTY" < "$RB_TTY" 2>/dev/null + fi + if [ -n "$RB_VT_BACK" ]; then + $ESUDO chvt "$RB_VT_BACK" >> "$RB_LOG" 2>&1 + fi + RB_TTY="" + RB_STTY="" + RB_VT_BACK="" +} + +# gptokeyb on a name only this installer's processes carry: kill mode runs +# pkill -f on it, and the path of install.py is in the command line of the +# interface and of every run it starts, never in python3's alone. +rb_mapper_start() { + [ -n "${RB_MAPPER:-}" ] || return 0 + # shellcheck disable=SC2086 # RB_MAPPER is a command and its flags, as $GPTOKEYB is + $RB_MAPPER "$RB_INSTALL" -c "$RB_GPTK" >> "$RB_LOG" 2>&1 & + RB_MAPPER_PID=$! +} + +rb_mapper_stop() { + [ -n "$RB_MAPPER_PID" ] || return 0 + $ESUDO kill "$RB_MAPPER_PID" >/dev/null 2>&1 + wait "$RB_MAPPER_PID" 2>/dev/null + RB_MAPPER_PID="" +} + +# A screen of text on the current terminal, closed by any button. +rb_message() { + local cols + cols=$(stty size 2>/dev/null | awk '{print $2}') + case "$cols" in + ''|*[!0-9]*) cols=40 ;; + esac + [ "$cols" -ge 20 ] || cols=40 + printf '\033[2J\033[H' + printf 'RetroBIOS\n\n' + printf '%s\n' "$1" | fold -s -w $((cols - 1)) + printf '\n%s\n' "Press A or Enter to close." + read -r -s -n 1 -t "${RB_MESSAGE_WAIT:-300}" _ + printf '\033[2J\033[H' +} + +# --- The run -------------------------------------------------------------- + +# The interface on the current terminal: install.py --tui, from a pack on the +# card when there is no network. The status also goes to RB_STATE/status, +# since a terminal app does not always pass its child's on. +rb_here() { + local status pack args=() + mkdir -p "$RB_STATE" + rm -f "$RB_STATE/status" + case "${TERM:-}" in + ''|dumb|unknown) export TERM=xterm ;; + esac + if [ -z "$RB_PYTHON" ]; then + rb_message "$(rb_python_text)" + printf '1\n' > "$RB_STATE/status" + return 1 + fi + if ! rb_online; then + rb_detect + pack=$(rb_find_pack) + if [ -z "$pack" ]; then + rb_message "$(rb_pack_text)" + printf '1\n' > "$RB_STATE/status" + return 1 + fi + args=(--from-pack "$pack") + if [ -n "$RB_PLATFORM" ]; then + args+=(--platform "$RB_PLATFORM") + fi + fi + rb_py "$RB_INSTALL" --tui "${args[@]}" "$@" 2> "$RB_STATE/tui.err" + status=$? + cat "$RB_STATE/tui.err" >> "$RB_LOG" 2>/dev/null + if [ "$status" = "2" ] && grep -q -- "--tui cannot open" "$RB_STATE/tui.err"; then + status=125 + if [ "${RB_FALLBACK:-0}" != "1" ]; then + rb_message "$(tail -n 1 "$RB_STATE/tui.err") The messages are in $RB_LOG." + fi + elif [ "$status" != "0" ] && [ -s "$RB_STATE/tui.err" ]; then + rb_message "$(tail -n 1 "$RB_STATE/tui.err") The messages are in $RB_LOG." + fi + printf '%s\n' "$status" > "$RB_STATE/status" + return "$status" +} + +rb_cleanup() { + rb_vt_leave + rb_mapper_stop +} + +# The whole session: the terminal, the mapper, the interface, then the +# console, its font and the mapper put back. Returns the interface's status, +# 125 when no terminal could show it. +rb_launch() { + local status + mkdir -p "$RB_STATE" + if ! rb_route; then + printf '%s\n' "RetroBIOS: no terminal to show the interface on (routes: $RB_ROUTES)" >> "$RB_LOG" + return 125 + fi + export RB_DIR RB_INSTALL RB_EVENTS RB_STATE RB_LOG RB_PACK_PATH RB_FALLBACK + export RB_PYTHON RB_PYTHONHOME RB_PYTHON_PROBLEM RB_MESSAGE_WAIT RB_ROUTE_TABLE + trap 'rb_cleanup; exit 143' TERM HUP + rb_mapper_start + case "$RB_ROUTE" in + here) + rb_here "$@" + status=$? + ;; + app) + "${RB_TERMAPP[@]}" bash "$RB_LIB" here "$@" 2>> "$RB_LOG" + status=$(cat "$RB_STATE/status" 2>/dev/null) + ;; + vt) + rb_vt_enter + export TERM=linux + # shellcheck disable=SC2094 # a console is read and written at once by design + rb_here "$@" < "$RB_TTY" > "$RB_TTY" 2>> "$RB_LOG" + status=$? + ;; + esac + rb_cleanup + trap - TERM HUP + return "${status:-1}" +} + +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + case "${1:-}" in + here) + shift + rb_here "$@" + exit $? + ;; + *) + printf '%s\n' "usage: bash tui.txt here [install.py options]" >&2 + exit 2 + ;; + esac +fi diff --git a/ports/tools/README.md b/ports/tools/README.md new file mode 100644 index 00000000..394a14cb --- /dev/null +++ b/ports/tools/README.md @@ -0,0 +1,20 @@ +# RetroBIOS in the Tools menu + +Copy `RetroBIOS.sh` and the `retrobios` folder next to the other tools: + +| System | Folder | Menu | +|--------|--------|------| +| ArkOS, dArkOS | `tools` on the EASYROMS partition (`/roms/tools`) | Options, Tools | +| ROCKNIX, JELOS | `/storage/.config/modules` | Tools | +| AmberELEC | `/storage/.config/distribution/modules` | Tools | + +It checks the BIOS folder against the files its emulators read and installs +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. + +ArkOS has Python 3.7, older than the installer needs: RetroBIOS uses the +Python runtime the RetroBIOS PortMaster port downloads, so start that port +once first. diff --git a/ports/tools/RetroBIOS.sh b/ports/tools/RetroBIOS.sh new file mode 100755 index 00000000..302f2e49 --- /dev/null +++ b/ports/tools/RetroBIOS.sh @@ -0,0 +1,77 @@ +#!/bin/bash +# RetroBIOS from the Tools menu of ArkOS, dArkOS, ROCKNIX, JELOS and +# AmberELEC: checks the BIOS folder and downloads the files it lacks, in the +# installer's full-screen interface, each firmware the way its own tools do. +# +# ArkOS, dArkOS console 1 with a Terminus font, /opt/inttools/gptokeyb +# (dArkOS_Tools/Wifi.sh) +# AmberELEC /dev/console, TERM=linux, the system gptokeyb +# (modules/SetTime.sh) +# ROCKNIX, JELOS the foot or weston terminal the Tools menu opens, then a +# graphical terminal or the console, with gptokeyb + +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="${RB_ROOT:-}" +RB_DIR="$HERE/retrobios" +RB_LOG="$RB_DIR/log.txt" +ESUDO="" +OS_NAME="" +if [ -f "$ROOT/etc/os-release" ]; then + OS_NAME=$(awk -F= '$1 == "NAME" { gsub(/"/, "", $2); print $2 }' "$ROOT/etc/os-release") +fi + +rb_gptokeyb() { + local candidate + for candidate in "$@"; do + if [ -x "$candidate" ]; then + printf '%s\n' "$candidate" + return 0 + fi + done + command -v gptokeyb 2>/dev/null +} + +if [ -d "$ROOT/opt/system" ] && [ -d "$ROOT/opt/inttools" ]; then + # ArkOS and dArkOS run EmulationStation as a user with sudo. + if sudo -n true >/dev/null 2>&1; then + ESUDO="sudo" + fi + RB_ROUTES="vt" + RB_CONSOLE="${RB_CONSOLE:-/dev/tty1}" + RB_PACK_PATH="$RB_DIR:$HERE:/roms:/roms2" + controlfolder="$ROOT/opt/system/Tools/PortMaster" + mapper=$(rb_gptokeyb "$ROOT/opt/inttools/gptokeyb" "$controlfolder/gptokeyb") + [ -f "$ROOT/opt/inttools/gamecontrollerdb.txt" ] \ + && export SDL_GAMECONTROLLERCONFIG_FILE="$ROOT/opt/inttools/gamecontrollerdb.txt" +elif [ "$OS_NAME" = "AmberELEC" ]; then + RB_ROUTES="vt" + RB_CONSOLE="${RB_CONSOLE:-/dev/console}" + RB_PACK_PATH="$RB_DIR:$HERE:/storage/roms" + controlfolder="/storage/roms/ports/PortMaster" + mapper=$(rb_gptokeyb "$ROOT/usr/bin/gptokeyb" "$controlfolder/gptokeyb") + [ -f /storage/.config/SDL-GameControllerDB/gamecontrollerdb.txt ] \ + && export SDL_GAMECONTROLLERCONFIG_FILE="/storage/.config/SDL-GameControllerDB/gamecontrollerdb.txt" +else + # ROCKNIX, JELOS and the other systems built like them: the Tools menu + # runs this inside foot on sway builds, so its own terminal comes first. + RB_ROUTES="here app vt" + RB_CONSOLE="${RB_CONSOLE:-/dev/tty1}" + RB_PACK_PATH="$RB_DIR:$HERE:/storage/roms" + controlfolder="/storage/roms/ports/PortMaster" + mapper=$(rb_gptokeyb "$ROOT/usr/bin/gptokeyb" "$controlfolder/gptokeyb") +fi +# Kill mode on the installer's own path: Start + Select closes the +# interface, never another program. +RB_MAPPER="" +[ -n "$mapper" ] && RB_MAPPER="$ESUDO $mapper -1" + +# shellcheck source=../shared/tui.txt +source "$RB_DIR/tui.txt" +: > "$RB_LOG" +rb_select_python +# A launcher's own arguments are not install.py's (muOS passes the app folder). +# shellcheck disable=SC2119 +rb_launch +status=$? +rb_release_python +exit $status diff --git a/scripts/build_port.py b/scripts/build_port.py index 33082be6..ca2894d9 100644 --- a/scripts/build_port.py +++ b/scripts/build_port.py @@ -1,14 +1,25 @@ #!/usr/bin/env python3 -"""Build the PortMaster port in ports/RetroBIOS into dist/RetroBIOS.zip. +"""Build the handheld launchers in ports/ into their archives in dist/. -The archive has the layout PortMaster's own release build gives a port + RetroBIOS.zip the PortMaster port (ports/RetroBIOS) + RetroBIOS-batocera.zip Batocera and Knulli, from Ports (ports/batocera) + RetroBIOS.muxapp muOS, from Applications (ports/muos) + RetroBIOS-tools.zip the Tools menu of ArkOS, dArkOS, ROCKNIX, JELOS + and AmberELEC (ports/tools) + +Each carries ports/shared (tui.txt, which runs the full-screen interface, +rbevents.py and the controller mapping) beside its own launcher. The +PortMaster archive has the layout PortMaster's own release build gives a port (PortMaster-New tools/build_release.py): the launch script at the top, and one folder holding port.json, gameinfo.xml, the README as .md, the -licenses and the port's files. install.py is copied from the repository -root, and only when it is the installer the bootstraps pin, so the port -runs the same bytes as the one-line command of the same commit. Members are -written as the packs write theirs: fixed timestamps, sorted names, the -executable bit read from the source, so the same tree gives the same zip. +licenses and the port's files. A muOS application archive is extracted into +the applications folder, so its top folder is the application. + +install.py is copied from the repository root, and only when it is the +installer the bootstraps pin, so every launcher runs the same bytes as the +one-line command of the same commit. Members are written as the packs write +theirs: fixed timestamps, sorted names, the executable bit read from the +source, so the same tree gives the same archives. Usage: python scripts/build_port.py @@ -27,10 +38,14 @@ from pathlib import Path from artifacts import ArtifactLockBusy, artifact_lock REPO_ROOT = Path(__file__).resolve().parent.parent -PORT_SOURCE = REPO_ROOT / "ports" / "RetroBIOS" PORT_DIR = "retrobios" PORT_SCRIPT = "RetroBIOS.sh" PORT_ZIP = "RetroBIOS.zip" +BATOCERA_ZIP = "RetroBIOS-batocera.zip" +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) _PIN = re.compile(r'^DEFAULT_INSTALL_SHA256="([0-9a-f]{64})"$', re.MULTILINE) @@ -42,66 +57,115 @@ def pinned_installer_sha256(root: Path = REPO_ROOT) -> str: return found.group(1) -def port_members(root: Path = REPO_ROOT) -> list[tuple[str, Path]]: - """(name in the archive, source file) for every member, sorted by name.""" - source = root / "ports" / "RetroBIOS" +def _engine(folder: str, root: Path, mapping: bool = True) -> list[tuple[str, Path]]: + """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" members = [ - (PORT_SCRIPT, source / PORT_SCRIPT), - (f"{PORT_DIR}/port.json", source / "port.json"), - (f"{PORT_DIR}/gameinfo.xml", source / "gameinfo.xml"), - (f"{PORT_DIR}/{PORT_DIR}.md", source / "README.md"), - (f"{PORT_DIR}/install.py", root / "install.py"), - (f"{PORT_DIR}/licenses/LICENSE-retrobios.txt", root / "LICENSE"), - (f"{PORT_DIR}/licenses/NOTICE-retrobios.txt", root / "NOTICE"), + (f"{folder}/install.py", root / "install.py"), + (f"{folder}/tui.txt", shared / "tui.txt"), + (f"{folder}/rbevents.py", shared / "rbevents.py"), + (f"{folder}/licenses/LICENSE-retrobios.txt", root / "LICENSE"), + (f"{folder}/licenses/NOTICE-retrobios.txt", root / "NOTICE"), ] - members += [ - (f"{PORT_DIR}/{path.relative_to(source / PORT_DIR).as_posix()}", path) - for path in sorted((source / PORT_DIR).rglob("*")) - if path.is_file() and "__pycache__" not in path.parts - ] - names = [name for name, _path in members] + 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.""" + ports = root / "ports" + if name == PORT_ZIP: + source = ports / "RetroBIOS" + members = [ + (PORT_SCRIPT, source / PORT_SCRIPT), + (f"{PORT_DIR}/port.json", source / "port.json"), + (f"{PORT_DIR}/gameinfo.xml", source / "gameinfo.xml"), + (f"{PORT_DIR}/{PORT_DIR}.md", source / "README.md"), + *_engine(PORT_DIR, root), + ] + members += [ + (f"{PORT_DIR}/{path.relative_to(source / PORT_DIR).as_posix()}", path) + for path in sorted((source / PORT_DIR).rglob("*")) + if path.is_file() and "__pycache__" not in path.parts + ] + elif name == BATOCERA_ZIP: + source = ports / "batocera" + members = [ + (PORT_SCRIPT, source / PORT_SCRIPT), + (f"{PORT_SCRIPT}.keys", source / f"{PORT_SCRIPT}.keys"), + (f"{PORT_DIR}/README.md", source / "README.md"), + # evmapy reads RetroBIOS.sh.keys: no gptokeyb there. + *_engine(PORT_DIR, root, mapping=False), + ] + elif name == MUOS_APP: + 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), + ] + elif name == TOOLS_ZIP: + source = ports / "tools" + members = [ + (PORT_SCRIPT, source / PORT_SCRIPT), + (f"{PORT_DIR}/README.md", source / "README.md"), + *_engine(PORT_DIR, root), + ] + else: + raise ValueError(f"no launcher archive is named {name}") + names = [member for member, _path in members] if len(set(names)) != len(names): - raise ValueError("two port members share a name") + raise ValueError(f"two members of {name} share a name") return sorted(members) -def build_port(output: Path, root: Path = REPO_ROOT) -> Path: - """Write the port archive to *output* and return it.""" +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) actual = hashlib.sha256((root / "install.py").read_bytes()).hexdigest() if actual != pinned: raise ValueError( f"install.py is {actual[:12]}, install.sh pins {pinned[:12]}: " - "re-pin the bootstraps before building the port" + "re-pin the bootstraps before building the launchers" ) # The packs' member writer: imported here, as it loads the whole - # pack builder, which a caller reading PORT_ZIP does not need. + # pack builder, which a caller reading ARCHIVES does not need. from generate_pack import _add_pack_member # noqa: PLC0415 + 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 name, path in port_members(root): - _add_pack_member(zf, str(path), name) + for member, path in members: + _add_pack_member(zf, str(path), member) scratch.replace(output) return output +def build_port(output: Path, root: Path = REPO_ROOT) -> Path: + """Write the PortMaster port archive to *output* and return it.""" + return build_archive(PORT_ZIP, output, root) + + def main() -> int: parser = argparse.ArgumentParser( - allow_abbrev=False, description="Build the RetroBIOS PortMaster port" + allow_abbrev=False, description="Build the RetroBIOS handheld launchers" ) parser.add_argument("--output-dir", type=Path, default=REPO_ROOT / "dist", - help="where RetroBIOS.zip is written (default: dist/)") + help="where the archives are written (default: dist/)") args = parser.parse_args() try: with artifact_lock(str(args.output_dir)): - path = build_port(args.output_dir / PORT_ZIP) + paths = [build_archive(name, args.output_dir / name) for name in ARCHIVES] except (ArtifactLockBusy, OSError, ValueError) as exc: print(f"Error: {exc}", file=sys.stderr) return 1 - digest = hashlib.sha256(path.read_bytes()).hexdigest() - print(f"{path} ({path.stat().st_size} bytes, sha256 {digest})") + for path in paths: + digest = hashlib.sha256(path.read_bytes()).hexdigest() + print(f"{path} ({path.stat().st_size} bytes, sha256 {digest})") return 0 diff --git a/scripts/check_release.py b/scripts/check_release.py index 018dbc16..bd001f91 100644 --- a/scripts/check_release.py +++ b/scripts/check_release.py @@ -3,8 +3,8 @@ Run on the draft before publishing it, and on the published release at any time. Every pack asset release.json names must be on the release at the sha256 -SHA256SUMS.txt lists for it, as must the handheld port (RetroBIOS.zip) when -the list names it, the parts of a pack must add up to the download +SHA256SUMS.txt lists for it, as must each handheld launcher (RetroBIOS.zip +and the others build_port.py writes) the list names, the parts of a pack must add up to the download size the README prints, and nothing else may be attached. An upload that failed in the loop, or a part re-uploaded from an older build, otherwise publishes a release that `sha256sum --check` rejects for every user. @@ -27,7 +27,7 @@ from dataclasses import dataclass from pathlib import Path sys.path.insert(0, os.path.dirname(__file__)) -from build_port import PORT_ZIP +from build_port import ARCHIVES from check_release_assets import digests_of from largefiles import LARGE_FILES_REPO from release_record import RECORD, load_record @@ -93,9 +93,9 @@ def compare_release( findings.append(Finding("extra", name, "attached but neither recorded nor listed")) for name in sorted(set(recorded) - set(sums)): findings.append(Finding("unlisted", name, f"absent from {SUMS}")) - # The handheld port is a release file but no pack: release.json records - # packs, and the checksum list alone vouches for the port. - for name in sorted(set(sums) - set(recorded) - {PORT_ZIP}): + # The handheld launchers are release files but no packs: release.json + # records packs, and the checksum list alone vouches for the launchers. + for name in sorted(set(sums) - set(recorded) - set(ARCHIVES)): findings.append(Finding("unrecorded", name, f"listed in {SUMS}, absent from release.json")) wanted = {**sums, **(local_files or {})} diff --git a/tests/test_handheld_launchers.py b/tests/test_handheld_launchers.py new file mode 100644 index 00000000..4f42c92c --- /dev/null +++ b/tests/test_handheld_launchers.py @@ -0,0 +1,732 @@ +"""The handheld launchers: their scripts, controller mappings and archives, +and what each one does on a simulated device. + +A device is simulated with a pseudo-terminal standing for its console, a +fake install.py that records how it was started, and fakes for what the +firmware provides (gptokeyb, setfont, chvt, curl, foot, xterm, muxterm, +PortMaster's control.txt and muOS's retro.sh), all on PATH or in a folder +the launcher reads. What only a device shows (the text reaching the screen, +the pad reaching the console, a compositor covering it) is not exercised. +""" +from __future__ import annotations + +import hashlib +import importlib.util +import json +import os +import pty +import re +import shutil +import signal +import struct +import subprocess +import sys +import tempfile +import termios +import unittest +import zipfile +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +PORTS = REPO_ROOT / "ports" +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +import build_port # noqa: E402 + +_spec = importlib.util.spec_from_file_location("install", REPO_ROOT / "install.py") +install = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(install) + +SCRIPTS = [ + PORTS / "shared" / "tui.txt", + PORTS / "RetroBIOS" / "RetroBIOS.sh", + PORTS / "RetroBIOS" / "retrobios" / "screens.txt", + PORTS / "batocera" / "RetroBIOS.sh", + PORTS / "muos" / "mux_launch.sh", + PORTS / "tools" / "RetroBIOS.sh", +] + +# What each button does in the interface (wiki/installer.md, the full-screen +# interface): the convergent menu mapping of handheld_runtime.md. +ROLES = { + "a": "enter", "start": "enter", "b": "esc", "back": "esc", "x": "space", + "y": "tab", "l1": "pageup", "r1": "pagedown", + "up": "up", "down": "down", "left": "left", "right": "right", +} +# What a Linux console or a terminal sends for each key gptokeyb and evmapy +# emit, and the key the interface reads from it. +SEQUENCES = { + "enter": "\r", "esc": "\x1b", "space": " ", "tab": "\t", + "pageup": "\x1b[5~", "pagedown": "\x1b[6~", + "up": "\x1b[A", "down": "\x1b[B", "left": "\x1b[D", "right": "\x1b[C", +} +INTERFACE_KEYS = { + "enter": "enter", "esc": "esc", "space": "char", "tab": "tab", + "pageup": "pgup", "pagedown": "pgdn", + "up": "up", "down": "down", "left": "left", "right": "right", +} +# gptokeyb's char_to_keycode (PortsMaster/gptokeyb src/util.cpp) names. +GPTOKEYB_NAMES = set(SEQUENCES) | {"backspace", '\\"'} + + +def _tempdir(test: unittest.TestCase) -> Path: + scratch = REPO_ROOT / "tmp" + scratch.mkdir(exist_ok=True) + path = Path(tempfile.mkdtemp(dir=str(scratch))) + test.addCleanup(shutil.rmtree, path, True) + return path + + +def read_gptk(path: Path) -> dict[str, list[str]]: + """key -> values, as gptokeyb's parseConfigFile reads `key = value`.""" + mapping: dict[str, list[str]] = {} + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or line.startswith("["): + continue + key, sep, value = line.partition(" = ") + assert sep, line + mapping.setdefault(key, []).append(value.split("#")[0].strip()) + return mapping + + +class EveryScriptParses(unittest.TestCase): + def test_bash_n(self): + for script in SCRIPTS: + with self.subTest(script=str(script.relative_to(REPO_ROOT))): + proc = subprocess.run(["bash", "-n", str(script)], capture_output=True, + text=True, check=False) + self.assertEqual(proc.returncode, 0, proc.stderr) + + @unittest.skipUnless(shutil.which("shellcheck"), "shellcheck is not installed") + def test_shellcheck(self): + proc = subprocess.run( + ["shellcheck", "--shell=bash", "--severity=style", "--external-sources", + "--source-path=SCRIPTDIR", *map(str, SCRIPTS)], + capture_output=True, text=True, check=False) + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + + def test_launchers_are_executable(self): + for script in (PORTS / "RetroBIOS" / "RetroBIOS.sh", PORTS / "batocera" / "RetroBIOS.sh", + PORTS / "muos" / "mux_launch.sh", PORTS / "tools" / "RetroBIOS.sh"): + with self.subTest(script=str(script.relative_to(REPO_ROOT))): + self.assertTrue(script.stat().st_mode & 0o111) + self.assertTrue(script.read_text().startswith("#!/bin/bash\n")) + + def test_muos_headers(self): + """muOS's frontend reads the HELP, ICON and GRID lines of an app + (MustardOS internal share/application/*/mux_launch.sh).""" + head = (PORTS / "muos" / "mux_launch.sh").read_text().splitlines()[1:4] + self.assertEqual([line.split(":")[0] for line in head], ["# HELP", "# ICON", "# GRID"]) + self.assertTrue(all(line.split(":", 1)[1].strip() for line in head)) + + +class ControllerMappings(unittest.TestCase): + def _decodes(self, key: str) -> str: + decoder = install.KeyDecoder() + keys = decoder.feed(SEQUENCES[key]) + decoder.flush() + self.assertEqual(len(keys), 1, key) + return keys[0].name + + def test_gptk_maps_every_button_the_interface_reads(self): + mapping = read_gptk(PORTS / "shared" / "retrobios.gptk") + for button, key in ROLES.items(): + with self.subTest(button=button): + self.assertEqual(mapping[button][0], key) + self.assertEqual(self._decodes(key), INTERFACE_KEYS[key]) + for values in mapping.values(): + self.assertIn(values[0], GPTOKEYB_NAMES | {"repeat"}) + self.assertTrue(set(values[1:]) <= {"repeat"}) + self.assertEqual(mapping["up"], ["up", "repeat"]) + self.assertEqual(mapping["down"], ["down", "repeat"]) + self.assertEqual(mapping["guide"], ['\\"']) + + def test_evmapy_keys_map_the_same_buttons(self): + """Batocera's evmapy names L1 and R1 pageup and pagedown, and Select + select (batocera-pygame evmapy.keys).""" + data = json.loads((PORTS / "batocera" / "RetroBIOS.sh.keys").read_text()) + actions = {a["trigger"]: a for a in data["actions_player1"]} + triggers = {"a": "a", "start": "start", "b": "b", "back": "select", "x": "x", + "y": "y", "l1": "pageup", "r1": "pagedown", "up": "up", + "down": "down", "left": "left", "right": "right"} + for button, trigger in triggers.items(): + with self.subTest(button=button): + action = actions[trigger] + self.assertEqual(action["type"], "key") + self.assertEqual(action["target"], [f"KEY_{ROLES[button].upper()}"]) + for stick in ("up", "down", "left", "right"): + 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"]}) + + +class TheArchives(unittest.TestCase): + LAYOUT = { + build_port.PORT_ZIP: ("RetroBIOS.sh", "retrobios"), + build_port.BATOCERA_ZIP: ("RetroBIOS.sh", "retrobios"), + build_port.MUOS_APP: (None, "RetroBIOS"), + build_port.TOOLS_ZIP: ("RetroBIOS.sh", "retrobios"), + } + + def setUp(self): + self.out = _tempdir(self) + + def test_every_release_archive_is_built(self): + self.assertEqual(set(build_port.ARCHIVES), set(self.LAYOUT)) + + def test_layout(self): + 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: + names = zf.namelist() + modes = {n: zf.getinfo(n).external_attr >> 16 for n in names} + self.assertEqual(names, sorted(names)) + top = [n for n in names if "/" not in n] + self.assertEqual({n.split("/")[0] for n in names if "/" in n}, {folder}) + for member in ("install.py", "tui.txt", "rbevents.py", + "licenses/LICENSE-retrobios.txt"): + self.assertIn(f"{folder}/{member}", names) + launcher = script or f"{folder}/mux_launch.sh" + self.assertTrue(modes[launcher] & 0o111, launcher) + if name == build_port.BATOCERA_ZIP: + self.assertEqual(top, ["RetroBIOS.sh", "RetroBIOS.sh.keys"]) + self.assertNotIn("retrobios/retrobios.gptk", names) + elif script: + self.assertEqual(top, [script]) + 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): + pinned = build_port.pinned_installer_sha256() + 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: + bundled = hashlib.sha256(zf.read(f"{folder}/install.py")).hexdigest() + self.assertEqual(bundled, pinned) + + def test_reproducible(self): + for name in build_port.ARCHIVES: + with self.subTest(archive=name): + first = build_port.build_archive(name, self.out / f"a-{name}").read_bytes() + second = build_port.build_archive(name, self.out / f"b-{name}").read_bytes() + self.assertEqual(first, second) + + def test_an_unpinned_installer_is_refused(self): + root = _tempdir(self) + for name in ("install.py", "install.sh", "LICENSE", "NOTICE"): + shutil.copy(REPO_ROOT / name, root / name) + shutil.copytree(PORTS, root / "ports") + with (root / "install.py").open("a") as fh: + fh.write("\n") + for name in build_port.ARCHIVES: + with self.subTest(archive=name), self.assertRaises(ValueError): + build_port.build_archive(name, self.out / name, root) + + def test_the_release_process_attaches_each(self): + text = (REPO_ROOT / "wiki" / "release-process.md").read_text(encoding="utf-8") + for name in build_port.ARCHIVES: + self.assertIn(name, text) + + +FAKE_INSTALL = r''' +import json, os, sys +calls = os.environ["FAKE_CALLS"] + + +def tty(fd): + try: + return os.ttyname(fd) + except OSError: + return "" + + +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") +if "--check" in sys.argv: + print(json.dumps({"event": "detect", "os": "linux", "os_name": "Fake OS", + "platforms": [{"platform": "retroarch", "name": "RetroArch", + "bios_dir": "/roms/bios"}]})) + print(json.dumps({"event": "error", "stage": "manifest", "reason": "network", + "platform": "retroarch", "message": "unreachable"})) + print(json.dumps({"event": "end", "code": 1})) + sys.exit(1) +if os.environ.get("FAKE_REFUSE"): + print("install.py: error: --tui cannot open the full-screen interface here: " + "the terminal is 30x10, smaller than 40x12", file=sys.stderr) + sys.exit(2) +if os.environ.get("FAKE_RAW") and os.isatty(0): + import tty as _tty + _tty.setraw(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 +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 +''' + +FAKE_RECORDER = '''#!/bin/sh +printf '%s\\n' "$(basename "$0") $*" >> "$FAKE_STATE/tools.log" +''' + +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 +printf '%s\\n' "$(basename "$0") $*" >> "$FAKE_STATE/tools.log" +while [ $# -gt 0 ]; do + case "$1" in + --|-e) shift; break ;; + *) shift ;; + esac +done +exec "$@" +''' + + +class Device: + """A simulated handheld: fake tools on PATH and a pty as its console.""" + + def __init__(self, test: unittest.TestCase, **env: str): + self.test = test + self.root = _tempdir(test) + self.state = self.root / "state" + self.state.mkdir() + self.bin = self.root / "bin" + self.bin.mkdir() + master, slave = pty.openpty() + test.addCleanup(os.close, master) + test.addCleanup(os.close, slave) + termios.tcsetattr(slave, termios.TCSANOW, termios.tcgetattr(slave)) + import fcntl # noqa: PLC0415 + # Wide enough that no path in a message is folded. + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 50, 250, 0, 0)) + self.master, self.slave = master, slave + self.console = os.ttyname(slave) + self.before = termios.tcgetattr(slave) + (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) + # 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') + fonts = self.root / "fonts" + fonts.mkdir() + for name in ("Lat7-TerminusBold32x16", "Lat7-TerminusBold22x11", + "Lat7-TerminusBold20x10", "Lat7-Terminus16"): + (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")} + self.env = { + **environ, + "PATH": f"{self.bin}{os.pathsep}{os.environ['PATH']}", + "HOME": str(self.root), + "FAKE_STATE": str(self.state), + "FAKE_CALLS": str(self.state / "calls.jsonl"), + "RB_FONTS": str(fonts), + "RB_FB": str(self.root / "fb"), + "RB_MESSAGE_WAIT": "1", + "TERM": "dumb", + **env, + } + + def tool(self, name: str, text: str) -> Path: + path = self.bin / name + # python3 starts as a link to the real interpreter: never write through it. + if path.is_symlink() or path.exists(): + path.unlink() + path.write_text(text) + path.chmod(0o755) + return path + + def unpack(self, archive: str, into: Path) -> Path: + """The launcher archive extracted where the firmware keeps it, with + the fake installer in place of install.py.""" + into.mkdir(parents=True, exist_ok=True) + built = build_port.build_archive(archive, self.root / "dist" / archive) + with zipfile.ZipFile(built) as zf: + zf.extractall(into) + for info in zf.infolist(): + mode = info.external_attr >> 16 + if mode: + (into / info.filename).chmod(mode & 0o777) + for found in into.rglob("install.py"): + found.write_text(FAKE_INSTALL) + return into + + def run(self, argv: list[str], stdio_tty: bool = False, **env: str): + """Run a launcher; its console output is read from the pty.""" + stdin = self.slave if stdio_tty else subprocess.DEVNULL + stdout = self.slave if stdio_tty else subprocess.PIPE + proc = subprocess.run(argv, env={**self.env, **env}, stdin=stdin, stdout=stdout, + stderr=subprocess.PIPE, timeout=120, check=False) + self.screen = self._drain() + return proc + + def _drain(self) -> str: + chunks = [] + os.set_blocking(self.master, False) + while True: + try: + chunk = os.read(self.master, 65536) + except (BlockingIOError, OSError): + break + if not chunk: + break + chunks.append(chunk) + return b"".join(chunks).decode("utf-8", "replace") + + def calls(self) -> list[dict]: + path = self.state / "calls.jsonl" + if not path.exists(): + return [] + return [json.loads(line) for line in path.read_text().splitlines()] + + 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" + return path.read_text().splitlines() if path.exists() else [] + + def mapper_args(self) -> list[str]: + path = self.state / "mapper.args" + return path.read_text().splitlines() if path.exists() else [] + + def assert_mapper_stopped(self) -> None: + """The launcher stopped gptokeyb itself, before it exited.""" + pid = int((self.state / "mapper.pid").read_text()) + stopped = (self.state / "mapper.stopped").exists() + if not stopped: + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + pass + self.test.assertTrue(stopped, "gptokeyb was left running") + + def assert_console_restored(self) -> None: + self.test.assertEqual(termios.tcgetattr(self.slave), self.before) + + +CONTROL_TXT = ''' +directory="{roms}" +ESUDO="" +ESUDOKILL="-1" +CUR_TTY="{console}" +GPTOKEYB="$controlfolder/gptokeyb $ESUDOKILL" +get_controls() {{ sdl_controllerconfig="fake"; }} +''' + +DIALOG_TXT = ''' +PM_PID="" +PM_DONE="$FAKE_STATE/pm_done" +trace() { printf '%s\\n' "$*" >> "$FAKE_STATE/dialog.log"; } +PortMasterIPCheck() { echo 192.168.0.1; } +PortMasterDialogInit() { trace "INIT $*"; } +PortMasterDialogExit() { trace EXIT; } +PortMasterDialog() { trace "CMD $*"; } +PortMasterDialogResult() { trace "ASK $*"; echo quit; } +''' + + +class ThePortMasterPort(unittest.TestCase): + """RetroBIOS.sh from the PortMaster archive, on a fake PortMaster.""" + + def device(self, **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) + (control / "control.txt").write_text( + CONTROL_TXT.format(roms=str(roms).lstrip("/"), console=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") + 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 [] + + def test_online_on_the_console(self): + device = self.device() + proc = device.run(["bash", self.script], FAKE_RAW="1", FAKE_CODE="0") + self.assertEqual(proc.returncode, 0, proc.stderr.decode()) + [call] = device.tui_calls() + 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) + device.assert_mapper_stopped() + device.assert_console_restored() + self.assertIn("\x1bc", device.screen) + 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), []) + + def test_the_interface_status_is_the_port_status(self): + device = self.device() + proc = device.run(["bash", self.script], FAKE_CODE="3", FAKE_ERROR="the folder is read-only") + self.assertEqual(proc.returncode, 3) + self.assertIn("Error: the folder is read-only", device.screen) + self.assertIn("log.txt", device.screen) + + def test_offline_installs_from_a_pack_on_the_card(self): + device = self.device(FAKE_CURL="7") + roms = device.root / "roms" + (roms / "Batocera_43.1_BIOS_Pack.zip").write_bytes(b"") + pack = self.gamedir / "RetroArch_Lakka_v1.22.2_BIOS_Pack.zip" + pack.write_bytes(b"") + proc = device.run(["bash", self.script]) + self.assertEqual(proc.returncode, 0, proc.stderr.decode()) + [call] = device.tui_calls() + self.assertEqual(call["args"], ["--tui", "--from-pack", str(pack), + "--platform", "retroarch"]) + + def test_offline_prefers_the_pack_of_this_system(self): + device = self.device(FAKE_CURL="6") + (self.gamedir / "Batocera_43.1_BIOS_Pack.zip").write_bytes(b"") + pack = device.root / "roms" / "RetroArch_Lakka_v1.22.2_BIOS_Pack.part1of2.zip" + pack.write_bytes(b"") + device.run(["bash", self.script]) + [call] = device.tui_calls() + self.assertEqual(call["args"][1:3], ["--from-pack", str(pack)]) + + def test_offline_without_a_pack_says_where_to_put_one(self): + device = self.device(FAKE_CURL="7") + proc = device.run(["bash", self.script]) + self.assertEqual(proc.returncode, 1) + self.assertEqual(device.tui_calls(), []) + screen = " ".join(device.screen.split()) + self.assertIn("No network connection", screen) + self.assertIn("RetroArch", screen) + self.assertIn(str(self.gamedir), screen) + self.assertIn("github.com/Abdess/retrobios/releases/latest", screen) + device.assert_mapper_stopped() + device.assert_console_restored() + + 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") + device.run(["bash", self.script]) + [call] = device.tui_calls() + self.assertEqual(call["args"], ["--tui"]) + + def test_an_old_python_is_told_on_the_console(self): + device = self.device() + device.tool("python3", '#!/bin/sh\n[ "$1" = "--version" ] && echo "Python 3.7.3"\nexit 1\n') + device.tool("uname", "#!/bin/sh\necho armv7l\n") + proc = device.run(["bash", self.script]) + self.assertEqual(proc.returncode, 1) + screen = " ".join(device.screen.split()) + self.assertIn("Python 3.8 or newer is needed", screen) + self.assertIn("3.7.3", screen) + self.assertIn("--dest", screen) + self.assertEqual(device.calls(), []) + + def test_a_refused_console_falls_back_to_portmaster_screens(self): + device = self.device() + device.run(["bash", self.script], FAKE_REFUSE="1") + self.assertEqual(len(device.tui_calls()), 1) + dialog = self.dialog(device) + self.assertEqual(dialog[0], "INIT no-check") + self.assertIn("ASK selection_list --register=notice --want-description --want-cancel", + dialog) + device.assert_mapper_stopped() + device.assert_console_restored() + + def test_under_a_compositor_without_a_terminal_app_the_screens_open(self): + device = self.device(FAKE_PGREP="0") + device.run(["bash", self.script]) + self.assertEqual(device.tui_calls(), []) + self.assertEqual(self.dialog(device)[0], "INIT no-check") + self.assertEqual(device.mapper_args(), []) + + def test_under_a_compositor_foot_shows_the_interface(self): + device = self.device(WAYLAND_DISPLAY="wayland-1") + device.tool("foot", FAKE_TERMAPP) + proc = device.run(["bash", self.script], FAKE_CODE="4") + self.assertEqual(proc.returncode, 4) + [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")) + device.assert_mapper_stopped() + + +class TheBatoceraPort(unittest.TestCase): + def device(self, **env: str) -> tuple[Device, Path]: + device = Device(self, **env) + ports = device.unpack(build_port.BATOCERA_ZIP, device.root / "userdata" / "roms" / "ports") + return device, ports + + def test_xterm_in_the_x11_session(self): + device, ports = self.device(DISPLAY=":0") + device.tool("xterm", FAKE_TERMAPP) + proc = device.run(["/bin/bash", str(ports / "RetroBIOS.sh")]) + self.assertEqual(proc.returncode, 0, proc.stderr.decode()) + [call] = device.tui_calls() + self.assertEqual(call["args"], ["--tui"]) + self.assertEqual(device.tools(), + [f"xterm -fullscreen -e bash {ports}/retrobios/tui.txt here"]) + # evmapy reads RetroBIOS.sh.keys: no gptokeyb is started. + self.assertEqual(device.mapper_args(), []) + + 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)) + 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"]) + device.assert_console_restored() + + +class TheMuosApp(unittest.TestCase): + def device(self, muxterm: bool) -> tuple[Device, Path]: + device = Device(self) + apps = device.unpack(build_port.MUOS_APP, device.root / "mmc" / "MUOS" / "application") + muos = device.root / "opt-muos" + (muos / "script" / "var").mkdir(parents=True) + (muos / "script" / "var" / "retro.sh").write_text( + 'SETUP_APP() { printf "SETUP_APP %s\\n" "$1" >> "$FAKE_STATE/tools.log"; }\n' + f'GET_VAR() {{ echo "{device.root / "mmc"}"; }}\n') + if muxterm: + (muos / "frontend").mkdir() + 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") + device.env["RB_MUOS"] = str(muos) + return device, apps / "RetroBIOS" + + def test_in_muxterm(self): + device, app = self.device(muxterm=True) + # muOS starts an app as /mux_launch.sh . + proc = device.run([str(app / "mux_launch.sh"), str(app)]) + self.assertEqual(proc.returncode, 0, proc.stderr.decode()) + [call] = device.tui_calls() + 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")]) + device.assert_mapper_stopped() + + 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")) + device.assert_console_restored() + + def test_offline_reads_a_pack_at_the_card_root(self): + device, app = self.device(muxterm=True) + pack = device.root / "mmc" / "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)]) + + +class TheToolsEntry(unittest.TestCase): + def device(self, **env: str) -> tuple[Device, Path]: + device = Device(self, **env) + tools = device.unpack(build_port.TOOLS_ZIP, device.root / "tools") + device.env["RB_ROOT"] = str(device.root / "fs") + return device, tools + + def test_arkos_console_one(self): + device, tools = self.device() + 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") + 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")]) + device.assert_mapper_stopped() + device.assert_console_restored() + + def test_amberelec_console(self): + device, tools = self.device() + etc = device.root / "fs" / "etc" + etc.mkdir(parents=True) + (etc / "os-release").write_text('NAME="AmberELEC"\n') + 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"], device.console) + self.assertEqual(device.mapper_args()[:2], ["-1", str(tools / "retrobios" / "install.py")]) + + def test_rocknix_runs_in_the_terminal_tools_opens(self): + device, tools = self.device() + etc = device.root / "fs" / "etc" + etc.mkdir(parents=True) + (etc / "os-release").write_text('NAME="ROCKNIX"\n') + 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() + self.assertEqual((call["stdin"], call["stdout"]), (device.console, device.console)) + self.assertEqual(call["term"], "xterm") + # 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")], []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_port.py b/tests/test_port.py index ce73d392..82f79744 100644 --- a/tests/test_port.py +++ b/tests/test_port.py @@ -1,9 +1,10 @@ -"""The PortMaster port: its archive, its event reader and its screens. +"""The PortMaster port: its archive, its event reader and its fallback screens. The screens are driven here with PortMasterDialog's functions replaced by stubs that record each command and answer from a script, and with a fake install.py that writes canned --events streams. What a device adds (pugwash -drawing, the pad, mount, the network) is not exercised. +drawing, the pad, mount, the network) is not exercised. The full-screen +route of the port, and the other launchers, are in test_handheld_launchers. """ from __future__ import annotations @@ -12,6 +13,7 @@ import importlib.util import io import json import os +import re import shutil import subprocess import sys @@ -22,6 +24,7 @@ from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent PORT = REPO_ROOT / "ports" / "RetroBIOS" +SHARED = REPO_ROOT / "ports" / "shared" sys.path.insert(0, str(REPO_ROOT / "scripts")) import build_port # noqa: E402 @@ -29,8 +32,7 @@ import build_port # noqa: E402 _spec = importlib.util.spec_from_file_location("install", REPO_ROOT / "install.py") install = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(install) -_spec = importlib.util.spec_from_file_location( - "rbevents", PORT / "retrobios" / "rbevents.py") +_spec = importlib.util.spec_from_file_location("rbevents", SHARED / "rbevents.py") rbevents = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(rbevents) @@ -46,13 +48,6 @@ def _tempdir(test: unittest.TestCase) -> Path: class EveryScriptParses(unittest.TestCase): - def test_bash_n(self): - for script in SCRIPTS: - with self.subTest(script=script.name): - proc = subprocess.run(["bash", "-n", str(script)], capture_output=True, - text=True, check=False) - self.assertEqual(proc.returncode, 0, proc.stderr) - def test_only_dialog_functions_the_oldest_releases_have(self): """PortMaster 2024.02.26 and 8.5.23 define these in PortMasterDialog.txt, and have no funcs.txt: pm_message and the rest would be undefined.""" @@ -62,7 +57,6 @@ class EveryScriptParses(unittest.TestCase): "messages_begin", "message", "messages_end", "progress", "progress_clear", "check_runtime"} text = "\n".join(path.read_text(encoding="utf-8") for path in SCRIPTS) - import re # noqa: PLC0415 used = set(re.findall(r"\b(PortMasterDialog\w*|PortMasterIPCheck)\b", text)) self.assertLessEqual(used - {"PortMasterDialog.txt"}, known) self.assertNotRegex(text, r"\bpm_message\b|\bpm_platform_helper\b") @@ -70,6 +64,15 @@ class EveryScriptParses(unittest.TestCase): self.assertLessEqual(sent, commands) self.assertNotIn("register_clear_info", text) + def test_pugwash_applies_the_button_layout(self): + """Under no-harbour pugwash skips apply_button_mapping, so A and B + land swapped on the devices PortMaster fixes (PortMaster-GUI + pugwash/main.py, app.py): every start is no-check.""" + text = (PORT / "retrobios" / "screens.txt").read_text(encoding="utf-8") + inits = re.findall(r'PortMasterDialogInit "([\w-]+)"', text) + self.assertTrue(inits) + self.assertEqual(set(inits), {"no-check"}) + class ThePortArchive(unittest.TestCase): def setUp(self): @@ -90,7 +93,8 @@ class ThePortArchive(unittest.TestCase): self.assertEqual({n.split("/")[0] for n in names if "/" in n}, {"retrobios"}) self.assertEqual([n for n in names if n.endswith(".sh")], ["RetroBIOS.sh"]) for member in ("retrobios/install.py", "retrobios/rbevents.py", - "retrobios/screens.txt", "retrobios/gameinfo.xml", + "retrobios/screens.txt", "retrobios/tui.txt", + "retrobios/retrobios.gptk", "retrobios/gameinfo.xml", "retrobios/retrobios.md", "retrobios/licenses/LICENSE-retrobios.txt"): self.assertIn(member, names) self.assertEqual(port["version"], 4) @@ -106,21 +110,6 @@ class ThePortArchive(unittest.TestCase): install_sh = (REPO_ROOT / "install.sh").read_text(encoding="utf-8") self.assertIn(f'DEFAULT_INSTALL_SHA256="{bundled}"', install_sh) - def test_an_unpinned_installer_is_refused(self): - root = _tempdir(self) - for name in ("install.py", "install.sh", "LICENSE", "NOTICE"): - shutil.copy(REPO_ROOT / name, root / name) - shutil.copytree(PORT, root / "ports" / "RetroBIOS") - with (root / "install.py").open("a") as fh: - fh.write("\n") - with self.assertRaises(ValueError): - build_port.build_port(self.out / "x.zip", root) - - def test_reproducible(self): - first = self._build("a.zip").read_bytes() - second = self._build("b.zip").read_bytes() - self.assertEqual(first, second) - class EventRecords(unittest.TestCase): """rbevents turns --events into fields the FIFO carries intact.""" @@ -277,10 +266,12 @@ PortMasterDialogResult() { *) echo "$answer" ;; esac } +source "$RB_SHARED/tui.txt" source "$GAMEDIR/screens.txt" if [ -n "$FAKE_ARCH" ]; then rb_arch() { echo "$FAKE_ARCH"; } fi +rb_select_python rb_main trace STATUS "$?" ''' @@ -289,7 +280,7 @@ trace STATUS "$?" class TheScreens(unittest.TestCase): """Which screens the port shows, scenario by scenario.""" - def run_port(self, scenario: str, answers: list[str], **env: str) -> list[tuple]: + def run_port(self, scenario: str, answers: list[str], before=None, **env: str) -> list[tuple]: work = _tempdir(self) (work / "install.py").write_text(FAKE_INSTALL) (work / "driver.sh").write_text(DRIVER) @@ -299,12 +290,21 @@ class TheScreens(unittest.TestCase): # The oldest Python install.py supports, where it is installed: the # event reader runs on whatever the device has. (bin_dir / "python3").symlink_to(shutil.which("python3.8") or sys.executable) + # The network probe: 0 answers, 7 cannot connect. + (bin_dir / "curl").write_text('#!/bin/sh\nexit "${FAKE_CURL:-0}"\n') + (bin_dir / "curl").chmod(0o755) + (work / "packs").mkdir() + if before is not None: + before(work) self.work = work full_env = { **os.environ, "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", "HOME": str(work), "GAMEDIR": str(PORT / "retrobios"), + "RB_SHARED": str(SHARED), + "RB_EVENTS": str(SHARED / "rbevents.py"), + "RB_PACK_PATH": str(work / "packs"), "controlfolder": str(work / "PortMaster"), "ESUDO": "", "RB_STATE": str(work / "run"), @@ -367,7 +367,7 @@ class TheScreens(unittest.TestCase): self.assertEqual(self.lists(shown), [("menu", ["status", "check", "quit"])]) menu = shown[[s[0] for s in shown].index("list")][2] self.assertEqual(menu["status"][0], "Fake OS 1.0: all 3 BIOS files present") - self.assertEqual(shown[0], ("INIT", "no-harbour")) + self.assertEqual(shown[0], ("INIT", "no-check")) self.assertEqual(shown[-2:], [("EXIT",), ("STATUS", "0")]) self.assertEqual(self.calls(), ["--check --events"]) @@ -417,8 +417,26 @@ class TheScreens(unittest.TestCase): self.assertEqual(self.lists(shown), [("notice", ["info", "check", "quit"])]) notice = shown[[s[0] for s in shown].index("list")][2] self.assertEqual(notice["info"][0], "No connection") - self.assertIn("Connect to Wi-Fi", notice["info"][1]) + self.assertIn("connect to Wi-Fi", notice["info"][1]) self.assertIn("/roms/bios", notice["info"][1]) + # Where a pack goes, and which one. + self.assertIn(f"copy it into {self.work / 'packs'} on the SD card", notice["info"][1]) + self.assertIn("whose name contains RetroArch", notice["info"][1]) + + def test_offline_with_a_pack_on_the_card(self): + pack = None + + def place(work: Path) -> None: + nonlocal pack + pack = work / "packs" / "RetroArch_Lakka_v1.22.2_BIOS_Pack.zip" + pack.write_bytes(b"") + + shown = self.run_port("missing", ["update", "quit"], FAKE_CURL="7", before=place) + self.assertEqual(self.calls()[:2], [ + f"--check --events --from-pack {pack}", + f"--events --from-pack {pack} --platform retroarch", + ]) + self.assertEqual(shown[-1], ("STATUS", "0")) def test_detection_failure(self): shown = self.run_port("nodetect", ["check", "quit"]) diff --git a/wiki/release-process.md b/wiki/release-process.md index 126467c1..26945712 100644 --- a/wiki/release-process.md +++ b/wiki/release-process.md @@ -218,16 +218,19 @@ python scripts/pipeline.py --include-archived # which install.py --check reads to name a part missing from a folder. python scripts/split_pack.py dist/ -# 2b. The handheld port, RetroBIOS.zip, built from ports/RetroBIOS with the -# installer the bootstraps pin. It is a release file, listed in the -# checksums below, and check_release.py holds it to them. +# 2b. The handheld launchers, each carrying the installer the bootstraps pin: +# RetroBIOS.zip (the PortMaster port), RetroBIOS-batocera.zip (Batocera +# and Knulli Ports), RetroBIOS.muxapp (muOS Applications) and +# RetroBIOS-tools.zip (the Tools menu of ArkOS, dArkOS, ROCKNIX, JELOS, +# AmberELEC). They are release files, listed in the checksums below, and +# check_release.py compares each asset with its line there. python scripts/build_port.py # 3. Checksums of the files as published, then sign the list. The checksums # answer corruption, and the signature answers a rewritten release, which is # the one thing a checksum published beside its own artifacts cannot # answer. -(cd dist && sha256sum *.zip > SHA256SUMS.txt) +(cd dist && sha256sum *.zip RetroBIOS.muxapp > SHA256SUMS.txt) ssh-keygen -Y sign -f ~/.ssh/retrobios_signing -n file dist/SHA256SUMS.txt # 4. Record what each pack holds, read from the archives just checked: @@ -249,7 +252,7 @@ python scripts/release_notes.py --whats-new tmp/whats-new.md --output tmp/notes. # A public release with half its assets is a broken download for everyone # during the whole upload. gh release create "v${DATE}" --draft --title "BIOS Pack v${DATE}" --notes-file tmp/notes.md -for f in dist/SHA256SUMS.txt dist/SHA256SUMS.txt.sig dist/*.zip; do +for f in dist/SHA256SUMS.txt dist/SHA256SUMS.txt.sig dist/*.zip dist/RetroBIOS.muxapp; do [ -f "$f" ] && gh release upload "v${DATE}" "$f#$(basename "$f")" --clobber done # A count does not see a failed upload beside a stray file, nor a part diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md index 82df9eca..4f1c5dee 100644 --- a/wiki/testing-guide.md +++ b/wiki/testing-guide.md @@ -55,7 +55,7 @@ Every module in `tests/`, by the area it guards. | Slots and regions | synthetic + profiles | `test_region`, `test_region_agnostic_scan`, `test_region_groups`, `test_region_held`, `test_region_platform_index`, `test_region_required_only`, `test_slot`, `test_slots`, `test_slots_shipped_file` | | Hardware targets and systems | synthetic + target files | `test_extract_targets`, `test_lakka_targets`, `test_libretro_slugs`, `test_system_ids`, `test_system_selection`, `test_target_names`, `test_target_scrapers`, `test_target_systems`, `test_target_verify` | | Installer and downloads | loopback server | `test_check_release`, `test_download`, `test_install`, `test_install_cores`, `test_install_experience`, `test_install_offline`, `test_install_selection`, `test_install_tui` | -| Handheld port | stubbed PortMaster dialogs, fake installer | `test_port` | +| Handheld launchers | a pty as the console, fake installer and firmware tools, stubbed PortMaster dialogs | `test_handheld_launchers`, `test_port` | | Truth and native export | synthetic + cached originals | `test_export_counts`, `test_export_native_layer`, `test_export_report`, `test_exporters`, `test_truth_data_dir_readers`, `test_truth_diff`, `test_truth_layout` | | Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `test_site_platform_hashes`, `test_site_r2_components`, `test_site_r2_css`, `test_site_r2_emulators`, `test_site_r2_entry`, `test_site_r2_gate`, `test_site_r2_nav`, `test_site_r2_p0`, `test_site_r2_reference`, `test_site_r2_systems`, `test_site_r2_wiki`, `test_site_r3_collection`, `test_site_r3_components`, `test_site_r3_css`, `test_site_r3_emulators`, `test_site_r3_entry`, `test_site_r3_nav`, `test_site_r3_p0`, `test_site_r4_p0`, `test_site_r3_reference`, `test_site_r3_systems`, `test_site_r3_wiki`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_source_ref_targets`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` | @@ -262,18 +262,40 @@ python scripts/generate_pack.py --all --verify-packs --output-dir dist/ Integrated as pipeline step 6/8 (runs after consistency check, before README generation). Requires packs in `dist/`, skip with `--skip-packs`. +### test_handheld_launchers.py + +The launchers in `ports/`: the PortMaster port, the Batocera and Knulli +port, the muOS app and the Tools entry, all built on `ports/shared/tui.txt`. +`bash -n` and `shellcheck` on every script (skipped where shellcheck is not +installed), the gptokeyb and evmapy mappings decoded by the interface's own +key decoder, and the four archives `scripts/build_port.py` writes (layout, +reproducibility, the bundled `install.py` equal to the one `install.sh` +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. + +```bash +python -m unittest tests.test_handheld_launchers -v +``` + ### test_port.py -The PortMaster port in `ports/RetroBIOS/`. `bash -n` on its scripts, the -archive `scripts/build_port.py` writes (layout, reproducibility, the bundled -`install.py` equal to the one `install.sh` pins), and the event reader. The -screens run in bash with PortMasterDialog's functions replaced by stubs that -record each command and answer from a script, against a fake `install.py` -whose event streams follow `EVENT_KEYS`: all files present, some missing -(update everything, choose systems), no network, no BIOS folder detected, -Python too old, and an answer that arrives after the 30-second wait of the -older PortMaster releases. Drawing, the pad, `mount` and the network belong -to a device and are not exercised. +The PortMaster port's fallback screens, drawn by pugwash, and its event +reader. The screens run in bash with PortMasterDialog's functions replaced by +stubs that record each command and answer from a script, against a fake +`install.py` whose event streams follow `EVENT_KEYS`: all files present, some +missing (update everything, choose systems), no network with and without a +pack on the card, no BIOS folder detected, Python too old, and an answer that +arrives after the 30-second wait of the older PortMaster releases. Drawing, +the pad, `mount` and the network belong to a device and are not exercised. ```bash python -m unittest tests.test_port -v From 4b8b1f7727ba0f3ca5918201f609e2511001fbea Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:26:51 +0200 Subject: [PATCH 2/4] docs: rewrite the handheld installer section --- wiki/installer.md | 110 +++++++++++++++++++++++++--------------------- 1 file changed, 59 insertions(+), 51 deletions(-) diff --git a/wiki/installer.md b/wiki/installer.md index 717fb99e..19f4980c 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -868,67 +868,75 @@ elsewhere. A custom BIOS directory is still named directly: python install.py --platform retroarch --dest /storage/emulated/0/RetroArch/system ``` -## On a handheld: the RetroBIOS port +## On a handheld { #on-a-handheld-the-retrobios-port } -On a Linux handheld that runs [PortMaster](https://portmaster.games/) (ArkOS, -ROCKNIX, AmberELEC, muOS, KNULLI and the others it supports), the installer -also runs from the device itself, as a port with gamepad screens. The screens -are PortMaster's own, so the port carries no graphics code. +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. -**Getting it.** Two ways: +| 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 | +| 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 | -- From PortMaster's catalog, once the port is accepted there: open PortMaster, - find RetroBIOS under the Other genre, install. -- `RetroBIOS.zip`, copied into PortMaster's autoinstall folder. The release - process attaches it to each [release](https://github.com/Abdess/retrobios/releases/latest) - cut after the port was added. Releases cut before carry none: when the - latest one lists no `RetroBIOS.zip`, build it from a clone (below). Starting PortMaster then installs it, with or without a - network connection. PortMaster's FAQ names the folder: `/roms/ports/PortMaster/autoinstall/` - on AmberELEC and ROCKNIX, `/roms/tools/PortMaster/autoinstall/` on ArkOS, - `/mmc/MUOS/PortMaster/autoinstall/` on muOS, - `/userdata/system/.local/share/PortMaster/autoinstall/` on KNULLI. +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/`. -From a clone of the repository, `python scripts/build_port.py` builds the -same archive into `dist/RetroBIOS.zip`. +| Button | Action | +|--------|--------| +| D-pad | Move | +| A, Start | Choose, continue | +| B, Select | Back, and quit on the first screen | +| X | Tick or untick a system | +| Y | Options | +| L1, R1 | Page up, page down | +| Start + Select | Quit at once (PortMaster port, Tools) | -**What it shows.** RetroBIOS appears among the ports. Started, it runs -`install.py --check --events` and opens on the result: the system's name, -how many BIOS files are present, missing, or present with a hash that does -not match. The menu -offers to update everything missing, to choose systems (each listed with what -it lacks, selected or cleared with A), to check again, or to quit. An update -shows a progress bar, one step per file, then what was installed, what failed -and why, and what the collection cannot supply. Every list has its own Back or -Quit entry, since older PortMaster releases do not read B there. +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. -**What it needs.** +**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 +[`--from-pack`](#without-a-network-offline-install) does. With no pack there, +it names the pack and the folder. -- A network connection: the list of files to check comes from the internet, - as do the files. Without one the port says so and offers to check again. -- Python 3.8 or newer. The port uses the system's `python3` when it is recent - enough, else PortMaster's `python_3.11` runtime on aarch64 devices, which it - downloads through PortMaster on first use. An armhf device with an older - Python is told so, with the computer route below. -- A BIOS folder the installer detects (see [Platform detection](#platform-detection)). - On a system it does not know yet, the port says it found no BIOS folder. +**What it needs.** Python 3.8 or newer: the system's, else PortMaster's +`python_3.11` runtime on aarch64, which the PortMaster port downloads on +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). -**The computer route.** With the SD card mounted on a computer, the one-line -command fills it, naming the platform and the card's BIOS folder: -`--platform --dest `. The port's screens -for an unknown system or an old Python name this route. +**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. -**Which installer runs.** The archive carries `install.py`, the file the -one-line command of the same commit downloads: the build refuses any other -(`scripts/build_port.py` compares it with the SHA-256 `install.sh` pins). A -port that downloaded the installer at each start against a pinned SHA-256 -would stop working at the next change to `install.py`, since the pin is fixed -when the port is built. The file lists come from `main`, as for the one-line -command. A newer port version carries a newer installer. When a list is in a -format the carried installer does not read, the port says to update it. +**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 +`--platform --dest `. This works for +every system above too. -The launcher (`ports/RetroBIOS/RetroBIOS.sh`) and its screens -(`retrobios/screens.txt`) read the installer through -[Machine-readable events](#machine-readable-events) only. +**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 } From c496014f6414a2d08ebc86fa6f9640a475e94fdb Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:56:27 +0200 Subject: [PATCH 3/4] fix: pass --from to the runs the tui starts The interface accepted --from and its check and install runs never got it, so they downloaded what the folder holds. Pins in install.sh and install.ps1 follow install.py. --- install.ps1 | 2 +- install.py | 4 ++++ install.sh | 2 +- tests/test_install_tui.py | 14 ++++++++++++++ 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/install.ps1 b/install.ps1 index 3524c08d..38055c5f 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "479b73569414c3e22a10b3a64fe8e57460b858ffa6a3fc0088357affea2e983e" + $defaultInstallSha256 = "163a216c1b09e8d788d4de6148194705813d4c6b8a956c84a70a267205f2c25d" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 82b8fd3c..87dce8fa 100644 --- a/install.py +++ b/install.py @@ -10665,6 +10665,10 @@ def _tui_passthrough(args: argparse.Namespace) -> list[tuple[str, str | None]]: flags.append((flag, str(Path(value).absolute()))) flags += [("--emulator", name) for name in args.emulator or []] flags += [("--from-pack", str(Path(pack).absolute())) for pack in args.from_pack or []] + # The handheld launchers start --tui --from the other card: without it here + # the runs downloaded what that card holds. + flags += [("--from", str(Path(folder).absolute())) + for folder in getattr(args, "from", None) or []] for flag in ("--every-board", "--standalone-copies", "--verbose"): if getattr(args, flag[2:].replace("-", "_")): flags.append((flag, None)) diff --git a/install.sh b/install.sh index 316460d2..c46a1c1d 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="479b73569414c3e22a10b3a64fe8e57460b858ffa6a3fc0088357affea2e983e" +DEFAULT_INSTALL_SHA256="163a216c1b09e8d788d4de6148194705813d4c6b8a956c84a70a267205f2c25d" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index d94142d5..9d207ee2 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -399,6 +399,20 @@ class Flow(unittest.TestCase): press(state, "enter") self.assertEqual(state.exit_code, 0) + def test_every_run_reads_the_folders_given_with_from(self): + """--tui --from OLD: the check and the install both copy from OLD.""" + args = _args(**{"from": [Path("old-card"), Path("backup.zip")], + "from_pack": [Path("pack.zip")]}) + flags = install._tui_passthrough(args) + self.assertIn(("--from", str(Path("old-card").absolute())), flags) + self.assertIn(("--from", str(Path("backup.zip").absolute())), flags) + self.assertIn(("--from-pack", str(Path("pack.zip").absolute())), flags) + state = checked_state() + state.passthrough = flags + for run in ("check", "install"): + self.assertIn(f"--from={Path('old-card').absolute()}", + install.tui_argv(state, run, "py", "i.py")) + def test_the_check_runs_with_the_command_line_choices(self): state = fresh_state() state.passthrough = [("--target", "switch")] From 9646a9f42921b3066832864632fa09f91bfb4a21 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:56:28 +0200 Subject: [PATCH 4/4] feat: offer the other card in offline launchers Without a network, muOS and ArkOS launchers ask to copy the BIOS files from the card the system does not read (muOS bind.sh, dArkOS roms2), then install with --from and the pack. CI checks that shellcheck is present, and the test fails under CI without it. --- .github/workflows/validate.yml | 5 ++ ports/RetroBIOS/RetroBIOS.sh | 7 ++ ports/muos/mux_launch.sh | 6 ++ ports/shared/tui.txt | 69 ++++++++++++++++++- ports/tools/RetroBIOS.sh | 6 ++ tests/test_handheld_launchers.py | 111 +++++++++++++++++++++++++++++-- wiki/installer.md | 6 ++ 7 files changed, 204 insertions(+), 6 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index bc4d29ec..7c1cb6af 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -227,6 +227,11 @@ jobs: - name: Hold install.py to Python 3.8 run: vermin --no-tips -t=3.8- --violations install.py + # The handheld launchers' test runs shellcheck and fails under CI + # without it. The Ubuntu image ships it: this names it if one stops. + - name: Check that shellcheck is installed + run: shellcheck --version + # The site is built as deploy-site.yml builds it, from the lockfile. - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: diff --git a/ports/RetroBIOS/RetroBIOS.sh b/ports/RetroBIOS/RetroBIOS.sh index d3bf234a..d7ea21ff 100755 --- a/ports/RetroBIOS/RetroBIOS.sh +++ b/ports/RetroBIOS/RetroBIOS.sh @@ -48,6 +48,13 @@ 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. +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" +fi # shellcheck source=../shared/tui.txt source "$GAMEDIR/tui.txt" # shellcheck source=retrobios/screens.txt diff --git a/ports/muos/mux_launch.sh b/ports/muos/mux_launch.sh index 5e567756..650fe862 100755 --- a/ports/muos/mux_launch.sh +++ b/ports/muos/mux_launch.sh @@ -21,6 +21,12 @@ 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_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 # PortMaster has downloaded it. controlfolder="$PM_DIR" diff --git a/ports/shared/tui.txt b/ports/shared/tui.txt index 36dc72c6..6de50f46 100644 --- a/ports/shared/tui.txt +++ b/ports/shared/tui.txt @@ -20,6 +20,10 @@ # RB_GPTK its mapping (default retrobios.gptk next to this file) # RB_PACK_PATH folders searched for a pack when there is no network, # separated by ":", the first one named to the player +# RB_OTHER_CARDS BIOS folders the firmware keeps on each of its cards, +# separated by ":", as its own code names them +# RB_BIOS_DEST the one of them the firmware reads, never offered as the +# other card # RB_FALLBACK 1 when the launcher has screens of its own to fall back to # ESUDO sudo where the launcher needs it, as PortMaster sets it @@ -33,6 +37,8 @@ RB_LOG="${RB_LOG:-$RB_DIR/log.txt}" RB_ROUTES="${RB_ROUTES:-app vt}" RB_CONSOLE="${RB_CONSOLE:-/dev/tty0}" RB_PACK_PATH="${RB_PACK_PATH:-$RB_DIR}" +RB_OTHER_CARDS="${RB_OTHER_CARDS:-}" +RB_BIOS_DEST="${RB_BIOS_DEST:-}" RB_FONTS="${RB_FONTS:-/usr/share/consolefonts}" RB_FB="${RB_FB:-/sys/class/graphics/fb0/virtual_size}" RB_ROUTE_TABLE="${RB_ROUTE_TABLE:-/proc/net/route}" @@ -204,6 +210,30 @@ rb_find_pack() { return 0 } +rb_same_dir() { + [ -n "$1" ] && [ -n "$2" ] || return 1 + [ "$(stat -L -c '%d:%i' "$1" 2>/dev/null)" = "$(stat -L -c '%d:%i' "$2" 2>/dev/null)" ] +} + +# The BIOS folder on the firmware's other card, when it holds a file: a +# two-card device reads one card (muOS binds MUOS/bios from SD2 when it has +# one, else from SD1), and the files bought for it often sit on the other. +rb_other_card() { + local dir + local IFS=":" + for dir in ${RB_OTHER_CARDS:-}; do + [ -d "$dir" ] || continue + if rb_same_dir "$dir" "${RB_BIOS_DEST:-}" || rb_same_dir "$dir" "${RB_BIOS_DIR:-}"; then + continue + fi + if [ -n "$(find "$dir" -type f -print 2>/dev/null | head -n 1)" ]; then + printf '%s\n' "$dir" + return 0 + fi + done + return 0 +} + rb_pack_text() { local dir="${RB_PACK_PATH%%:*}" pack if [ -n "${RB_PLATFORM_NAME:-}" ]; then @@ -401,6 +431,29 @@ rb_message() { printf '\033[2J\033[H' } +# A question on the current terminal: true for A or Enter, false for B, Esc, +# any other button or no answer. +rb_ask() { + local cols key + cols=$(stty size 2>/dev/null | awk '{print $2}') + case "$cols" in + ''|*[!0-9]*) cols=40 ;; + esac + [ "$cols" -ge 20 ] || cols=40 + printf '\033[2J\033[H' + printf 'RetroBIOS\n\n' + printf '%s\n' "$1" | fold -s -w $((cols - 1)) + printf '\n%s\n' "A or Enter: yes. B or Esc: no." + if ! IFS= read -r -s -n 1 -t "${RB_MESSAGE_WAIT:-300}" key; then + printf '\033[2J\033[H' + return 1 + fi + # The rest of an escape sequence (an arrow) is not a second answer. + read -r -s -t 0.05 -n 8 _ 2>/dev/null + printf '\033[2J\033[H' + [ -z "$key" ] || [ "$key" = " " ] +} + # --- The run -------------------------------------------------------------- # The interface on the current terminal: install.py --tui, from a pack on the @@ -418,15 +471,26 @@ rb_here() { printf '1\n' > "$RB_STATE/status" return 1 fi + local other if ! rb_online; then + # Detection also names the BIOS folder the other card must not be. rb_detect + other=$(rb_other_card) + if [ -n "$other" ] && rb_ask "Copy the BIOS files from your other card? $other holds files. RetroBIOS copies the ones this system needs, each checked by its hash, and leaves that card as it is."; then + args=(--from "$other") + fi pack=$(rb_find_pack) if [ -z "$pack" ]; then - rb_message "$(rb_pack_text)" + if [ "${#args[@]}" -gt 0 ]; then + # --from needs the list of files, which comes from the network or a pack. + rb_message "$(rb_pack_text) The pack carries the list of files: with it, the files on your other card are copied too." + else + rb_message "$(rb_pack_text)" + fi printf '1\n' > "$RB_STATE/status" return 1 fi - args=(--from-pack "$pack") + args+=(--from-pack "$pack") if [ -n "$RB_PLATFORM" ]; then args+=(--platform "$RB_PLATFORM") fi @@ -462,6 +526,7 @@ rb_launch() { 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 diff --git a/ports/tools/RetroBIOS.sh b/ports/tools/RetroBIOS.sh index 302f2e49..01682e04 100755 --- a/ports/tools/RetroBIOS.sh +++ b/ports/tools/RetroBIOS.sh @@ -39,6 +39,12 @@ if [ -d "$ROOT/opt/system" ] && [ -d "$ROOT/opt/inttools" ]; then RB_ROUTES="vt" RB_CONSOLE="${RB_CONSOLE:-/dev/tty1}" RB_PACK_PATH="$RB_DIR:$HERE:/roms:/roms2" + # The second card is /roms2, and "Switch to SD2 for Roms" moves RetroArch's + # system folder there (dArkOS scripts/Switch to SD2 for Roms.sh:91-92): the + # folder RetroArch reads is the destination, the other card's is offered. + RB_OTHER_CARDS="${RB_OTHER_CARDS:-/roms/bios:/roms2/bios}" + RB_BIOS_DEST=$(awk -F'"' '/^system_directory *=/ { print $2; exit }' \ + "$ROOT/home/ark/.config/retroarch/retroarch.cfg" 2>/dev/null) controlfolder="$ROOT/opt/system/Tools/PortMaster" mapper=$(rb_gptokeyb "$ROOT/opt/inttools/gptokeyb" "$controlfolder/gptokeyb") [ -f "$ROOT/opt/inttools/gamecontrollerdb.txt" ] \ diff --git a/tests/test_handheld_launchers.py b/tests/test_handheld_launchers.py index 4f42c92c..d1077fe4 100644 --- a/tests/test_handheld_launchers.py +++ b/tests/test_handheld_launchers.py @@ -98,8 +98,13 @@ class EveryScriptParses(unittest.TestCase): text=True, check=False) self.assertEqual(proc.returncode, 0, proc.stderr) - @unittest.skipUnless(shutil.which("shellcheck"), "shellcheck is not installed") def test_shellcheck(self): + """CI provides shellcheck (validate.yml checks it), so there it may + not skip.""" + if shutil.which("shellcheck") is None: + if os.environ.get("CI"): + self.fail("CI must provide shellcheck for the handheld launchers") + self.skipTest("shellcheck is not installed") proc = subprocess.run( ["shellcheck", "--shell=bash", "--severity=style", "--external-sources", "--source-path=SCRIPTDIR", *map(str, SCRIPTS)], @@ -385,8 +390,11 @@ class Device: found.write_text(FAKE_INSTALL) return into - def run(self, argv: list[str], stdio_tty: bool = False, **env: str): - """Run a launcher; its console output is read from the pty.""" + def run(self, argv: list[str], stdio_tty: bool = False, keys: str = "", **env: str): + """Run a launcher with *keys* waiting on the console as buttons + pressed. Its console output is read from the pty.""" + if keys: + os.write(self.master, keys.encode()) stdin = self.slave if stdio_tty else subprocess.DEVNULL stdout = self.slave if stdio_tty else subprocess.PIPE proc = subprocess.run(argv, env={**self.env, **env}, stdin=stdin, stdout=stdout, @@ -584,6 +592,24 @@ class ThePortMasterPort(unittest.TestCase): self.assertEqual(self.dialog(device)[0], "INIT no-check") self.assertEqual(device.mapper_args(), []) + def test_arkos_offers_the_card_portmaster_does_not_run_from(self): + 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") + 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}") + [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_under_a_compositor_foot_shows_the_interface(self): device = self.device(WAYLAND_DISPLAY="wayland-1") device.tool("foot", FAKE_TERMAPP) @@ -638,7 +664,10 @@ class TheMuosApp(unittest.TestCase): (muos / "script" / "var").mkdir(parents=True) (muos / "script" / "var" / "retro.sh").write_text( 'SETUP_APP() { printf "SETUP_APP %s\\n" "$1" >> "$FAKE_STATE/tools.log"; }\n' - f'GET_VAR() {{ echo "{device.root / "mmc"}"; }}\n') + 'GET_VAR() { case "$2" in\n' + f' storage/rom/mount) echo "{device.root / "mmc"}" ;;\n' + f' storage/sdcard/mount) echo "{device.root / "sdcard"}" ;;\n' + ' *) echo "" ;;\nesac; }\n') if muxterm: (muos / "frontend").mkdir() shutil.copy(device.tool("muxterm", FAKE_TERMAPP), muos / "frontend" / "muxterm") @@ -678,6 +707,58 @@ class TheMuosApp(unittest.TestCase): [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" + sd2 = device.root / "sdcard" / "MUOS" / "bios" + sd1.mkdir(parents=True) + sd2.mkdir(parents=True) + (sd1 / "scph5501.bin").write_bytes(b"x") + (sd2 / "gba_bios.bin").write_bytes(b"y") + pack = device.root / "mmc" / "RetroArch_Lakka_v1.22.2_BIOS_Pack.zip" + pack.write_bytes(b"") + return sd1, sd2, pack + + def test_offline_offers_the_other_card(self): + device, app = self.device(muxterm=False) + sd1, sd2, pack = self.two_cards(device) + proc = device.run([str(app / "mux_launch.sh"), str(app)], keys="\r", FAKE_CURL="7", + RB_CONSOLE=device.console, RB_BIOS_DEST=str(sd2)) + self.assertEqual(proc.returncode, 0, proc.stderr.decode()) + self.assertIn("Copy the BIOS files from your other card?", device.screen) + self.assertIn(str(sd1), device.screen) + [call] = device.tui_calls() + self.assertEqual(call["args"], ["--tui", "--from", str(sd1), "--from-pack", str(pack), + "--platform", "retroarch"]) + + def test_b_declines_the_other_card(self): + device, app = self.device(muxterm=False) + _sd1, sd2, pack = self.two_cards(device) + device.run([str(app / "mux_launch.sh"), str(app)], keys="\x1b", FAKE_CURL="7", + RB_CONSOLE=device.console, RB_BIOS_DEST=str(sd2)) + [call] = device.tui_calls() + self.assertEqual(call["args"], ["--tui", "--from-pack", str(pack), + "--platform", "retroarch"]) + + def test_the_card_the_system_reads_is_never_offered(self): + device, app = self.device(muxterm=False) + sd1, _sd2, _pack = self.two_cards(device) + shutil.rmtree(device.root / "sdcard") + device.run([str(app / "mux_launch.sh"), str(app)], FAKE_CURL="7", + RB_CONSOLE=device.console, RB_BIOS_DEST=str(sd1)) + self.assertNotIn("other card", device.screen) + [call] = device.tui_calls() + self.assertNotIn("--from", call["args"]) + + def test_online_asks_nothing(self): + device, app = self.device(muxterm=False) + _sd1, sd2, _pack = self.two_cards(device) + device.run([str(app / "mux_launch.sh"), str(app)], + RB_CONSOLE=device.console, RB_BIOS_DEST=str(sd2)) + self.assertNotIn("other card", device.screen) + [call] = device.tui_calls() + self.assertEqual(call["args"], ["--tui"]) + class TheToolsEntry(unittest.TestCase): def device(self, **env: str) -> tuple[Device, Path]: @@ -702,6 +783,28 @@ class TheToolsEntry(unittest.TestCase): device.assert_mapper_stopped() 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) + roms, roms2 = device.root / "roms" / "bios", device.root / "roms2" / "bios" + roms.mkdir(parents=True) + roms2.mkdir(parents=True) + (roms / "bios.bin").write_bytes(b"x") + cfg = fs / "home" / "ark" / ".config" / "retroarch" + cfg.mkdir(parents=True) + (cfg / "retroarch.cfg").write_text(f'system_directory = "{roms2}"\n') + proc = device.run(["bash", str(tools / "RetroBIOS.sh")], keys="\r", + RB_CONSOLE=device.console, RB_OTHER_CARDS=f"{roms}:{roms2}") + self.assertEqual(proc.returncode, 1) + screen = " ".join(device.screen.split()) + self.assertIn(f"Copy the BIOS files from your other card? {roms} holds files", screen) + self.assertIn("with it, the files on your other card are copied too", screen) + self.assertEqual(device.tui_calls(), []) + def test_amberelec_console(self): device, tools = self.device() etc = device.root / "fs" / "etc" diff --git a/wiki/installer.md b/wiki/installer.md index bda65bd7..a1d269aa 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -979,6 +979,12 @@ 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. +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 +not read, or the other of `/roms/bios` and `/roms2/bios` on ArkOS and dArkOS. +A says yes, as [`--from`](#copy-from-an-old-card) does. The list of files +still comes from the pack, so the pack is needed too. + **What it needs.** Python 3.8 or newer: the system's, else PortMaster's `python_3.11` runtime on aarch64, which the PortMaster port downloads on first use and the Tools entry then reuses. That runtime uses the system's