mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-26 13:47:20 -05:00
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.
This commit is contained in:
1 parent
8f1b1f2a84
commit
801a0150e6
21 files changed
+1822
-206
No files matched your search
@@ -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 |
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
Executable
+35
@@ -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
|
||||
@@ -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"]}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
Executable
+53
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 = \"
|
||||
@@ -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
|
||||
@@ -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.
|
||||
Executable
+77
@@ -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
|
||||
+100
-36
@@ -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 <name>.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
|
||||
|
||||
|
||||
|
||||
@@ -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 {})}
|
||||
|
||||
@@ -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 <app>/mux_launch.sh <app>.
|
||||
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()
|
||||
+49
-31
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
|
||||
+33
-11
@@ -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
|
||||
|
||||
Reference in new issue
Block a user