diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index bc4d29ec..7c1cb6af 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -227,6 +227,11 @@ jobs: - name: Hold install.py to Python 3.8 run: vermin --no-tips -t=3.8- --violations install.py + # The handheld launchers' test runs shellcheck and fails under CI + # without it. The Ubuntu image ships it: this names it if one stops. + - name: Check that shellcheck is installed + run: shellcheck --version + # The site is built as deploy-site.yml builds it, from the lockfile. - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: diff --git a/ports/RetroBIOS/RetroBIOS.sh b/ports/RetroBIOS/RetroBIOS.sh index d3bf234a..d7ea21ff 100755 --- a/ports/RetroBIOS/RetroBIOS.sh +++ b/ports/RetroBIOS/RetroBIOS.sh @@ -48,6 +48,13 @@ RB_CONSOLE="${CUR_TTY:-/dev/tty0}" RB_MAPPER="$GPTOKEYB" RB_FALLBACK=1 RB_PACK_PATH="$GAMEDIR:/$directory/ports:/$directory" +# ArkOS and dArkOS, as control.txt tells them apart (its plymouth theme): the +# games card is /$directory, roms2 when fstab mounts a second card, and its +# bios folder is the one RetroArch reads. The other card's is offered. +if [ -e "${RB_ARK_MARKER:-/usr/share/plymouth/themes/text.plymouth}" ]; then + RB_OTHER_CARDS="${RB_OTHER_CARDS:-/roms/bios:/roms2/bios}" + RB_BIOS_DEST="/$directory/bios" +fi # shellcheck source=../shared/tui.txt source "$GAMEDIR/tui.txt" # shellcheck source=retrobios/screens.txt diff --git a/ports/muos/mux_launch.sh b/ports/muos/mux_launch.sh index 5e567756..650fe862 100755 --- a/ports/muos/mux_launch.sh +++ b/ports/muos/mux_launch.sh @@ -21,6 +21,12 @@ MUXTERM="$MUOS/frontend/muxterm" RB_DIR="$APP_DIR" RB_PACK_PATH="$APP_DIR:$ROM_MOUNT" RB_LOG="$APP_DIR/log.txt" +# muOS binds MUOS/bios from SD2 when that card has one, else from SD1, onto +# /run/muos/storage/bios (script/device/bind.sh:10-11,30-52): the other card's +# MUOS/bios is the one offered to copy from. +SD2_MOUNT="$(GET_VAR "device" "storage/sdcard/mount")" +RB_OTHER_CARDS="$ROM_MOUNT/MUOS/bios:$SD2_MOUNT/MUOS/bios" +RB_BIOS_DEST="${RB_BIOS_DEST:-/run/muos/storage/bios}" # PortMaster keeps its runtimes here: the Python runtime is used when # PortMaster has downloaded it. controlfolder="$PM_DIR" diff --git a/ports/shared/tui.txt b/ports/shared/tui.txt index 36dc72c6..6de50f46 100644 --- a/ports/shared/tui.txt +++ b/ports/shared/tui.txt @@ -20,6 +20,10 @@ # RB_GPTK its mapping (default retrobios.gptk next to this file) # RB_PACK_PATH folders searched for a pack when there is no network, # separated by ":", the first one named to the player +# RB_OTHER_CARDS BIOS folders the firmware keeps on each of its cards, +# separated by ":", as its own code names them +# RB_BIOS_DEST the one of them the firmware reads, never offered as the +# other card # RB_FALLBACK 1 when the launcher has screens of its own to fall back to # ESUDO sudo where the launcher needs it, as PortMaster sets it @@ -33,6 +37,8 @@ RB_LOG="${RB_LOG:-$RB_DIR/log.txt}" RB_ROUTES="${RB_ROUTES:-app vt}" RB_CONSOLE="${RB_CONSOLE:-/dev/tty0}" RB_PACK_PATH="${RB_PACK_PATH:-$RB_DIR}" +RB_OTHER_CARDS="${RB_OTHER_CARDS:-}" +RB_BIOS_DEST="${RB_BIOS_DEST:-}" RB_FONTS="${RB_FONTS:-/usr/share/consolefonts}" RB_FB="${RB_FB:-/sys/class/graphics/fb0/virtual_size}" RB_ROUTE_TABLE="${RB_ROUTE_TABLE:-/proc/net/route}" @@ -204,6 +210,30 @@ rb_find_pack() { return 0 } +rb_same_dir() { + [ -n "$1" ] && [ -n "$2" ] || return 1 + [ "$(stat -L -c '%d:%i' "$1" 2>/dev/null)" = "$(stat -L -c '%d:%i' "$2" 2>/dev/null)" ] +} + +# The BIOS folder on the firmware's other card, when it holds a file: a +# two-card device reads one card (muOS binds MUOS/bios from SD2 when it has +# one, else from SD1), and the files bought for it often sit on the other. +rb_other_card() { + local dir + local IFS=":" + for dir in ${RB_OTHER_CARDS:-}; do + [ -d "$dir" ] || continue + if rb_same_dir "$dir" "${RB_BIOS_DEST:-}" || rb_same_dir "$dir" "${RB_BIOS_DIR:-}"; then + continue + fi + if [ -n "$(find "$dir" -type f -print 2>/dev/null | head -n 1)" ]; then + printf '%s\n' "$dir" + return 0 + fi + done + return 0 +} + rb_pack_text() { local dir="${RB_PACK_PATH%%:*}" pack if [ -n "${RB_PLATFORM_NAME:-}" ]; then @@ -401,6 +431,29 @@ rb_message() { printf '\033[2J\033[H' } +# A question on the current terminal: true for A or Enter, false for B, Esc, +# any other button or no answer. +rb_ask() { + local cols key + cols=$(stty size 2>/dev/null | awk '{print $2}') + case "$cols" in + ''|*[!0-9]*) cols=40 ;; + esac + [ "$cols" -ge 20 ] || cols=40 + printf '\033[2J\033[H' + printf 'RetroBIOS\n\n' + printf '%s\n' "$1" | fold -s -w $((cols - 1)) + printf '\n%s\n' "A or Enter: yes. B or Esc: no." + if ! IFS= read -r -s -n 1 -t "${RB_MESSAGE_WAIT:-300}" key; then + printf '\033[2J\033[H' + return 1 + fi + # The rest of an escape sequence (an arrow) is not a second answer. + read -r -s -t 0.05 -n 8 _ 2>/dev/null + printf '\033[2J\033[H' + [ -z "$key" ] || [ "$key" = " " ] +} + # --- The run -------------------------------------------------------------- # The interface on the current terminal: install.py --tui, from a pack on the @@ -418,15 +471,26 @@ rb_here() { printf '1\n' > "$RB_STATE/status" return 1 fi + local other if ! rb_online; then + # Detection also names the BIOS folder the other card must not be. rb_detect + other=$(rb_other_card) + if [ -n "$other" ] && rb_ask "Copy the BIOS files from your other card? $other holds files. RetroBIOS copies the ones this system needs, each checked by its hash, and leaves that card as it is."; then + args=(--from "$other") + fi pack=$(rb_find_pack) if [ -z "$pack" ]; then - rb_message "$(rb_pack_text)" + if [ "${#args[@]}" -gt 0 ]; then + # --from needs the list of files, which comes from the network or a pack. + rb_message "$(rb_pack_text) The pack carries the list of files: with it, the files on your other card are copied too." + else + rb_message "$(rb_pack_text)" + fi printf '1\n' > "$RB_STATE/status" return 1 fi - args=(--from-pack "$pack") + args+=(--from-pack "$pack") if [ -n "$RB_PLATFORM" ]; then args+=(--platform "$RB_PLATFORM") fi @@ -462,6 +526,7 @@ rb_launch() { return 125 fi export RB_DIR RB_INSTALL RB_EVENTS RB_STATE RB_LOG RB_PACK_PATH RB_FALLBACK + export RB_OTHER_CARDS RB_BIOS_DEST export RB_PYTHON RB_PYTHONHOME RB_PYTHON_PROBLEM RB_MESSAGE_WAIT RB_ROUTE_TABLE trap 'rb_cleanup; exit 143' TERM HUP rb_mapper_start diff --git a/ports/tools/RetroBIOS.sh b/ports/tools/RetroBIOS.sh index 302f2e49..01682e04 100755 --- a/ports/tools/RetroBIOS.sh +++ b/ports/tools/RetroBIOS.sh @@ -39,6 +39,12 @@ if [ -d "$ROOT/opt/system" ] && [ -d "$ROOT/opt/inttools" ]; then RB_ROUTES="vt" RB_CONSOLE="${RB_CONSOLE:-/dev/tty1}" RB_PACK_PATH="$RB_DIR:$HERE:/roms:/roms2" + # The second card is /roms2, and "Switch to SD2 for Roms" moves RetroArch's + # system folder there (dArkOS scripts/Switch to SD2 for Roms.sh:91-92): the + # folder RetroArch reads is the destination, the other card's is offered. + RB_OTHER_CARDS="${RB_OTHER_CARDS:-/roms/bios:/roms2/bios}" + RB_BIOS_DEST=$(awk -F'"' '/^system_directory *=/ { print $2; exit }' \ + "$ROOT/home/ark/.config/retroarch/retroarch.cfg" 2>/dev/null) controlfolder="$ROOT/opt/system/Tools/PortMaster" mapper=$(rb_gptokeyb "$ROOT/opt/inttools/gptokeyb" "$controlfolder/gptokeyb") [ -f "$ROOT/opt/inttools/gamecontrollerdb.txt" ] \ diff --git a/tests/test_handheld_launchers.py b/tests/test_handheld_launchers.py index 4f42c92c..d1077fe4 100644 --- a/tests/test_handheld_launchers.py +++ b/tests/test_handheld_launchers.py @@ -98,8 +98,13 @@ class EveryScriptParses(unittest.TestCase): text=True, check=False) self.assertEqual(proc.returncode, 0, proc.stderr) - @unittest.skipUnless(shutil.which("shellcheck"), "shellcheck is not installed") def test_shellcheck(self): + """CI provides shellcheck (validate.yml checks it), so there it may + not skip.""" + if shutil.which("shellcheck") is None: + if os.environ.get("CI"): + self.fail("CI must provide shellcheck for the handheld launchers") + self.skipTest("shellcheck is not installed") proc = subprocess.run( ["shellcheck", "--shell=bash", "--severity=style", "--external-sources", "--source-path=SCRIPTDIR", *map(str, SCRIPTS)], @@ -385,8 +390,11 @@ class Device: found.write_text(FAKE_INSTALL) return into - def run(self, argv: list[str], stdio_tty: bool = False, **env: str): - """Run a launcher; its console output is read from the pty.""" + def run(self, argv: list[str], stdio_tty: bool = False, keys: str = "", **env: str): + """Run a launcher with *keys* waiting on the console as buttons + pressed. Its console output is read from the pty.""" + if keys: + os.write(self.master, keys.encode()) stdin = self.slave if stdio_tty else subprocess.DEVNULL stdout = self.slave if stdio_tty else subprocess.PIPE proc = subprocess.run(argv, env={**self.env, **env}, stdin=stdin, stdout=stdout, @@ -584,6 +592,24 @@ class ThePortMasterPort(unittest.TestCase): self.assertEqual(self.dialog(device)[0], "INIT no-check") self.assertEqual(device.mapper_args(), []) + def test_arkos_offers_the_card_portmaster_does_not_run_from(self): + device = self.device(FAKE_CURL="7") + marker = device.root / "text.plymouth" + marker.write_text("") + roms = device.root / "roms" / "bios" + other = device.root / "roms2" / "bios" + roms.mkdir(parents=True) + other.mkdir(parents=True) + (roms / "a.bin").write_bytes(b"x") + (other / "b.bin").write_bytes(b"y") + pack = self.gamedir / "RetroArch_Lakka_v1.22.2_BIOS_Pack.zip" + pack.write_bytes(b"") + device.run(["bash", self.script], keys="\r", RB_ARK_MARKER=str(marker), + RB_OTHER_CARDS=f"{roms}:{other}") + [call] = device.tui_calls() + # The port's games card is roms here: its bios is the destination. + self.assertEqual(call["args"][:3], ["--tui", "--from", str(other)]) + def test_under_a_compositor_foot_shows_the_interface(self): device = self.device(WAYLAND_DISPLAY="wayland-1") device.tool("foot", FAKE_TERMAPP) @@ -638,7 +664,10 @@ class TheMuosApp(unittest.TestCase): (muos / "script" / "var").mkdir(parents=True) (muos / "script" / "var" / "retro.sh").write_text( 'SETUP_APP() { printf "SETUP_APP %s\\n" "$1" >> "$FAKE_STATE/tools.log"; }\n' - f'GET_VAR() {{ echo "{device.root / "mmc"}"; }}\n') + 'GET_VAR() { case "$2" in\n' + f' storage/rom/mount) echo "{device.root / "mmc"}" ;;\n' + f' storage/sdcard/mount) echo "{device.root / "sdcard"}" ;;\n' + ' *) echo "" ;;\nesac; }\n') if muxterm: (muos / "frontend").mkdir() shutil.copy(device.tool("muxterm", FAKE_TERMAPP), muos / "frontend" / "muxterm") @@ -678,6 +707,58 @@ class TheMuosApp(unittest.TestCase): [call] = device.tui_calls() self.assertEqual(call["args"][:3], ["--tui", "--from-pack", str(pack)]) + def two_cards(self, device: Device) -> tuple[Path, Path, Path]: + """SD1 and SD2 both hold MUOS/bios, SD2's is the one muOS binds.""" + sd1 = device.root / "mmc" / "MUOS" / "bios" + sd2 = device.root / "sdcard" / "MUOS" / "bios" + sd1.mkdir(parents=True) + sd2.mkdir(parents=True) + (sd1 / "scph5501.bin").write_bytes(b"x") + (sd2 / "gba_bios.bin").write_bytes(b"y") + pack = device.root / "mmc" / "RetroArch_Lakka_v1.22.2_BIOS_Pack.zip" + pack.write_bytes(b"") + return sd1, sd2, pack + + def test_offline_offers_the_other_card(self): + device, app = self.device(muxterm=False) + sd1, sd2, pack = self.two_cards(device) + proc = device.run([str(app / "mux_launch.sh"), str(app)], keys="\r", FAKE_CURL="7", + RB_CONSOLE=device.console, RB_BIOS_DEST=str(sd2)) + self.assertEqual(proc.returncode, 0, proc.stderr.decode()) + self.assertIn("Copy the BIOS files from your other card?", device.screen) + self.assertIn(str(sd1), device.screen) + [call] = device.tui_calls() + self.assertEqual(call["args"], ["--tui", "--from", str(sd1), "--from-pack", str(pack), + "--platform", "retroarch"]) + + def test_b_declines_the_other_card(self): + device, app = self.device(muxterm=False) + _sd1, sd2, pack = self.two_cards(device) + device.run([str(app / "mux_launch.sh"), str(app)], keys="\x1b", FAKE_CURL="7", + RB_CONSOLE=device.console, RB_BIOS_DEST=str(sd2)) + [call] = device.tui_calls() + self.assertEqual(call["args"], ["--tui", "--from-pack", str(pack), + "--platform", "retroarch"]) + + def test_the_card_the_system_reads_is_never_offered(self): + device, app = self.device(muxterm=False) + sd1, _sd2, _pack = self.two_cards(device) + shutil.rmtree(device.root / "sdcard") + device.run([str(app / "mux_launch.sh"), str(app)], FAKE_CURL="7", + RB_CONSOLE=device.console, RB_BIOS_DEST=str(sd1)) + self.assertNotIn("other card", device.screen) + [call] = device.tui_calls() + self.assertNotIn("--from", call["args"]) + + def test_online_asks_nothing(self): + device, app = self.device(muxterm=False) + _sd1, sd2, _pack = self.two_cards(device) + device.run([str(app / "mux_launch.sh"), str(app)], + RB_CONSOLE=device.console, RB_BIOS_DEST=str(sd2)) + self.assertNotIn("other card", device.screen) + [call] = device.tui_calls() + self.assertEqual(call["args"], ["--tui"]) + class TheToolsEntry(unittest.TestCase): def device(self, **env: str) -> tuple[Device, Path]: @@ -702,6 +783,28 @@ class TheToolsEntry(unittest.TestCase): device.assert_mapper_stopped() device.assert_console_restored() + def test_arkos_other_card_without_a_pack_needs_one(self): + """--from needs the list of files, which an offline run takes from a + pack: the message says so.""" + device, tools = self.device(FAKE_CURL="7") + fs = device.root / "fs" + (fs / "opt" / "system").mkdir(parents=True) + (fs / "opt" / "inttools").mkdir(parents=True) + roms, roms2 = device.root / "roms" / "bios", device.root / "roms2" / "bios" + roms.mkdir(parents=True) + roms2.mkdir(parents=True) + (roms / "bios.bin").write_bytes(b"x") + cfg = fs / "home" / "ark" / ".config" / "retroarch" + cfg.mkdir(parents=True) + (cfg / "retroarch.cfg").write_text(f'system_directory = "{roms2}"\n') + proc = device.run(["bash", str(tools / "RetroBIOS.sh")], keys="\r", + RB_CONSOLE=device.console, RB_OTHER_CARDS=f"{roms}:{roms2}") + self.assertEqual(proc.returncode, 1) + screen = " ".join(device.screen.split()) + self.assertIn(f"Copy the BIOS files from your other card? {roms} holds files", screen) + self.assertIn("with it, the files on your other card are copied too", screen) + self.assertEqual(device.tui_calls(), []) + def test_amberelec_console(self): device, tools = self.device() etc = device.root / "fs" / "etc" diff --git a/wiki/installer.md b/wiki/installer.md index bda65bd7..a1d269aa 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -979,6 +979,12 @@ connection, RetroBIOS installs from it, as [`--from-pack`](#without-a-network-offline-install) does. With no pack there, it names the pack and the folder. +On a two-card device, RetroBIOS without Wi-Fi first asks to **copy the BIOS +files from your other card**: muOS's `MUOS/bios` on the card the system does +not read, or the other of `/roms/bios` and `/roms2/bios` on ArkOS and dArkOS. +A says yes, as [`--from`](#copy-from-an-old-card) does. The list of files +still comes from the pack, so the pack is needed too. + **What it needs.** Python 3.8 or newer: the system's, else PortMaster's `python_3.11` runtime on aarch64, which the PortMaster port downloads on first use and the Tools entry then reuses. That runtime uses the system's