From a334ace439a6bc75f899c42cd735939701f888b8 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:40:28 +0100 Subject: [PATCH 01/13] fix: name cards by files no menu rewrites A KNULLI card renamed in its menu was read as a Batocera SHARE drive. KNULLI is now named by about.info first, Batocera by its own hostname, and a drive two systems fit is said instead of guessed. Runs without questions see mounted cards as the screen does and stop on two setups. The card marker table of the wiki is generated. --- install.ps1 | 2 +- install.py | 254 ++++++++++++++++++++++++------ install.sh | 2 +- install/launchers/index.json | 16 +- scripts/generate_installer_doc.py | 38 ++++- tests/test_install_experience.py | 12 +- tests/test_install_tui.py | 247 ++++++++++++++++++++++++++++- wiki/installer.md | 32 +++- 8 files changed, 537 insertions(+), 66 deletions(-) diff --git a/install.ps1 b/install.ps1 index aceaf62d1..49ce42b0c 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "71fa422aa38f9710c9aab81ba038cad71df66f574a527dc62e0307c69c30d1b0" + $defaultInstallSha256 = "f4f2d041d923e32d46c828222f43f4359c5241fcce7ccc29bfc9b3e8565bba0b" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 6c9a3376a..6b6bc0fc3 100644 --- a/install.py +++ b/install.py @@ -10262,14 +10262,28 @@ def _card_mount(path: str) -> str: # Systems whose card a computer sees as their data partition, by the file # their own build writes there, paths below that partition. KNULLI's is -# /userdata: S12populateshare:15,105 copies datainit there, and every board's -# datainit/system/batocera.conf sets system.hostname=KNULLI (knulli-cfw -# distribution ab3a6809, board/batocera/*/fsoverlay/usr/share/batocera/). -# ROCKNIX's is /storage: userconfig-setup:12 copies /usr/config into -# /storage/.config, and its system.cfg sets root.password=rocknix (ROCKNIX -# distribution 8b70f693, projects/ROCKNIX/packages/rocknix/config/system/ -# configs/system.cfg:152). +# /userdata, which S12populateshare fills from datainit (knulli-cfw +# distribution ab3a6809, board/batocera/fsoverlay/etc/init.d/ +# S12populateshare:93). The first rule is system/configs/emulationstation/ +# about.info, which every board's datainit holds with the one line KNULLI +# (board/batocera/*/fsoverlay/usr/share/batocera/datainit/system/configs/ +# emulationstation/about.info), and which S12populateshare copies back at +# each start when it is missing or older. EmulationStation reads it as the +# system's name (knulli-cfw/batocera-emulationstation f7c5ae10, +# es-app/src/ApiSystem.cpp:190-208) and no menu writes it. The second is +# system.hostname=KNULLI in system/batocera.conf, which datainit sets +# (datainit/system/batocera.conf:485) and the HOSTNAME row of the menu +# rewrites (es-app/src/guis/GuiMenu.cpp:4092): a card renamed there was +# read as a Batocera SHARE drive. ROCKNIX's is /storage: userconfig-setup:12 +# copies /usr/config into /storage/.config, and its system.cfg sets +# root.password=rocknix (ROCKNIX distribution 8b70f693, projects/ROCKNIX/ +# packages/rocknix/config/system/configs/system.cfg:153). Nothing else there +# names ROCKNIX: a password changed with setrootpass +# (packages/rocknix/sources/scripts/setrootpass:25) leaves the card named +# by nothing, never by another system. _SHARE_RULES: tuple[_OsRule, ...] = ( + _OsRule(_STANDIN_MANIFESTS["KNULLI"], "KNULLI", "file_matches", + ("system/configs/emulationstation/about.info", r"\AKNULLI\s*$"), "bios"), _OsRule(_STANDIN_MANIFESTS["KNULLI"], "KNULLI", "file_matches", ("system/batocera.conf", r"^system\.hostname=KNULLI\s*$"), "bios"), _OsRule("rocknix", "ROCKNIX", "file_matches", @@ -10342,22 +10356,92 @@ def card_system(root: Path) -> tuple[str, Path, str] | None: # The SHARE drive of a system a computer reaches over the network, by the -# file the system keeps on it: Batocera's data part (/userdata) holds -# system/batocera.conf, Recalbox's (/recalbox/share) system/recalbox.conf, -# and both read bios/ beside it. KNULLI, built on Batocera, writes the same -# batocera.conf with its own hostname: its rule (_SHARE_RULES) comes first. -# Typed at the folder question, the drive was taken as the BIOS folder and -# filled beside roms/ and system/, where neither reads. +# file the system keeps on it, and both read bios/ beside it. Batocera's data +# part (/userdata) holds system/batocera.conf, which S12populateshare copies +# there (batocera.linux d1966272, board/batocera/fsoverlay/etc/init.d/ +# S12populateshare:104) and which sets system.hostname=BATOCERA +# (package/batocera/core/batocera-system/batocera.conf:103). Recalbox's +# (/recalbox/share) holds system/recalbox.conf (recalbox 64b0fd40, +# board/recalbox/fsoverlay/etc/init.d/S12populateshare:93), a name no menu +# changes. KNULLI, built on Batocera, writes the same batocera.conf: its +# rules (_SHARE_RULES) are read first, and a batocera.conf whose hostname +# is neither system's own, on a drive no about.info names, is one of the +# two renamed in its menu. That drive is said to be either (share_doubt), +# never taken for one: a renamed KNULLI card was filled with Batocera's +# list. Typed at the folder question, a drive was taken as the BIOS folder +# and filled beside roms/ and system/, where neither reads. _SHARE_DRIVES: tuple[tuple[str, str], ...] = ( ("batocera", "system/batocera.conf"), ("recalbox", "system/recalbox.conf"), ) +_HOSTNAME_LINE = re.compile(r"^system\.hostname=(.*?)\s*$", re.MULTILINE) + + +def _share_markers(root: Path) -> list[str]: + """The platforms whose SHARE drive file is on root's own drive.""" + return [plat for plat, marker in _SHARE_DRIVES + if _is_file(root / marker) and _on_the_drive(root, marker)] + + +def _share_hostname(root: Path) -> str | None: + """The hostname system/batocera.conf on root sets, "" when it sets none, + None when the file cannot be read.""" + try: + text = (root / "system" / "batocera.conf").read_text(encoding="utf-8", + errors="replace") + except OSError: + return None + found = _HOSTNAME_LINE.findall(text) + return found[-1] if found else "" def share_drive(root: Path) -> str | None: - """The platform whose SHARE drive root is, by its own files, or None.""" - return next((plat for plat, marker in _SHARE_DRIVES - if _is_file(root / marker) and _on_the_drive(root, marker)), None) + """The platform whose SHARE drive root is, by its own files, or None: + for none, and for files that name two systems (share_doubt).""" + markers = _share_markers(root) + if len(markers) != 1: + return None + if markers == ["batocera"] and _share_hostname(root) not in ("", "BATOCERA"): + return None + return markers[0] + + +def share_doubt(root: Path) -> tuple[tuple[str, ...], str]: + """(platforms, why) for a drive whose files fit more than one system, + ((), "") for any other: a batocera.conf renamed in its menu, with no + file KNULLI alone writes, or the files of Batocera and Recalbox both.""" + if card_platform(root) or _card_matches(root): + return (), "" + markers = _share_markers(root) + if len(markers) > 1: + return tuple(markers), "it holds system/batocera.conf and system/recalbox.conf" + if markers == ["batocera"]: + hostname = _share_hostname(root) + if hostname not in ("", "BATOCERA"): + return ((_STANDIN_MANIFESTS["KNULLI"], "batocera"), + f"its system/batocera.conf names the host {screen_text(hostname or '?')}, " + "which the menus of KNULLI and Batocera both set") + return (), "" + + +def _doubt_kinds(plats: tuple[str, ...]) -> str: + """The systems a drive could be, in words: a KNULLI card or a Batocera + SHARE drive.""" + return " or ".join(f"{_a(display_name(plat))} " + f"{'SHARE drive' if plat in _SHARE_FOLDERS else 'card'}" + for plat in plats) + + +def doubt_words(root: Path) -> str: + """What is said of a mounted drive share_doubt cannot name, and how to + name it: the same words on the first screen and in line mode.""" + plats, why = share_doubt(root) + if not plats: + return "" + shown = shown_path(root) + kinds = _doubt_kinds(plats) + names = " or ".join(f"--platform {plat} --dest {shown}" for plat in plats) + return f"{shown} could be {kinds}: {why}. Name it: {names}." def drive_kind(system: tuple[str, Path, str]) -> str: @@ -10447,8 +10531,11 @@ def card_setups() -> list[CardSetup]: rules = _card_rules() setups = [CardSetup(display_name(plat), plat, "") for plat in AVAILABLE_PLATFORMS if plat in CARD_PLATFORMS] - setups += [CardSetup(rule.name, rule.manifest, rule.bios, (rule,), True) - for rule in _SHARE_RULES] + # KNULLI is named by two files: one setup holding both rules. + shares = {rule.name: tuple(each for each in _SHARE_RULES if each.name == rule.name) + for rule in _SHARE_RULES} + setups += [CardSetup(name, rules[0].manifest, rules[0].bios, rules, True) + for name, rules in shares.items()] # AmberELEC and EmuELEC mount their card's games part as /storage/roms, # the folder their list is written below: AmberELEC 5a3882cb fs-resize # formats it exFAT as GAMES (packages/sysutils/busybox/scripts/ @@ -10529,9 +10616,12 @@ def _card_is(setup: CardSetup, card: Path, above: bool = False) -> tuple[str, st names = sorted({rule.name for rule in _card_matches(card, above)}) if setup.name in names: return "own", setup.name - if not names and share_drive(card): - # A Batocera share is no KNULLI card not started yet. - return "other", display_name(share_drive(card)) + share = share_drive(card) + if not names and share and not (share == "batocera" and setup.name == "KNULLI"): + # A Recalbox share is no KNULLI card not started yet. A Batocera + # one may be: KNULLI writes the same batocera.conf, and a card + # older than its about.info has nothing else (_unknown_card_note). + return "other", display_name(share) return ("other", " and ".join(names)) if names else ("unknown", "") @@ -10576,11 +10666,17 @@ def _dest_refusal_words(choice: CardChoice, setup: CardSetup | None) -> str: KNULLI, and for a blank stick named for Onion, both refused the same.""" detail = f" {choice.detail}." if choice.detail else "" name = setup.name if setup else display_name(choice.plat) + if choice.other and choice.plat in _SHARE_FOLDERS and setup is None: + return (f"{choice.refused}.{detail} Connect the {name} SHARE drive, or name that " + f"drive's own system: --platform {_name_key(choice.other)}") if choice.other: return (f"{choice.refused}.{detail} Insert the {name} card, or name that " f"card's own system: --platform {_name_key(choice.other)}") if choice.root is not None: return f"{choice.refused}.{detail} Name the card's root: --dest {shown_path(choice.root)}" + if choice.plat in _SHARE_FOLDERS and setup is None: + return (f"{choice.refused}.{detail} Connect the {name} SHARE drive and name its " + "root with --dest") return f"{choice.refused}.{detail} Name the top folder of the {name} card with --dest" @@ -10589,6 +10685,13 @@ def _unknown_card_note(setup: CardSetup, card: Path, folder: Path) -> str: if setup.name in _GAMES_PART_CARDS: # Its list writes bios/ below the games part: the plan names that. folder = folder / "bios" + plats, why = share_doubt(card) + if setup.name == "KNULLI" and share_drive(card) == "batocera": + plats, why = ((setup.plat,), "it holds the system/batocera.conf Batocera and KNULLI " + "both write, and no about.info naming KNULLI") + if setup.plat in plats: + return (f"{shown_path(card)} could be another system's: {why}. Taken as " + f"{setup.name}'s, as named: the files go to {shown_path(folder)}") if setup.fresh: return (f"Nothing on {shown_path(card)} says it is {_a(setup.name)} card yet (not started " f"once?): the files go to {shown_path(folder)}") @@ -10682,10 +10785,24 @@ def _list_card_choice(plat: str, dest: Path) -> CardChoice: short=f"That is {_a(found[2])} {kind}.", other=found[2]) if found and plat and found[0] != plat: shown = shown_path(dest) - return CardChoice(plat, dest, f"Note: {shown} is {_a(found[2])} card, which reads " + return CardChoice(plat, dest, f"Note: {shown} is {_a(found[2])} {drive_kind(found)}, " + "which reads " f"the {display_name(found[0])} file list in {shown_path(found[1])}: " f"--platform {_name_key(found[2])} --dest {shown}. The " f"{display_name(plat)} list goes into it as named.") + doubt, why = ((), "") if found else share_doubt(dest) + if plat in doubt: + # Named by the user, a drive its files cannot name is taken for the + # system named, and the doubt is said. + folder = dest / "bios" + return CardChoice(plat, folder, f"Note: {shown_path(dest)} could be another system's: " + f"{why}. Taken as {display_name(plat)}'s, as named: the files go " + f"to {shown_path(folder)}") + if doubt and plat in _SHARE_FOLDERS: + name = display_name(plat) + return CardChoice(plat, dest, refused=f"{shown_path(dest)} could be " + f"{_doubt_kinds(doubt)}, not {_a(name)} SHARE drive", detail=why, + short=f"That could be {_doubt_kinds(doubt)}.") matched = [] if found else _card_matches(dest) if len({(match.manifest, match.bios) for match in matched}) > 1: names = " and ".join(sorted({match.name for match in matched})) @@ -10963,7 +11080,8 @@ def _platform_for_dest(os_type: str, dest: Path, interactive: bool) -> tuple[str print(f" File list: {display_name(plat)}. Same choice without questions: " f"--platform {_name_key(named) if named != plat else plat}") return plat, choice.folder - found = list(dict.fromkeys(name for name, _path in detect_platforms(os_type))) + # The setups the screen and line mode find, cards mounted here included. + found = list(dict.fromkeys(name for name, _path, _label in found_setups(os_type)[0])) default = found[0] if len(found) == 1 else "" if found else "retroarch" if not default: print(f"Error: {' and '.join(display_name(n) for n in found)} found here. " @@ -10975,6 +11093,24 @@ def _platform_for_dest(os_type: str, dest: Path, interactive: bool) -> tuple[str return default, dest +def _several_found(platforms: list[tuple[str, Path]], setups: list) -> None: + """Stop a run without questions that found more than one setup, each + named with the options that choose it.""" + labels = {(name, path): label for name, path, label in setups} + shown = [(labels.get((name, path)) or _setup_label(name, path), name, path) + for name, path in platforms] + conflict = os_conflict(platforms) + lead = (f"{' and '.join(conflict)} are both detected here, and a machine runs one" + if conflict else f"{len(shown)} setups were found here") + message = (f"{lead}, and a run without questions installs for one: " + + ", ".join(label for label, _n, _p in shown)) + EVENTS.emit("error", stage="detect", reason="conflict", platform="", message=message) + print(f"Error: {message}. Name the one to install for:", file=sys.stderr) + for label, name, path in shown: + print(f" {label}: --platform {name} --dest {shown_path(path)}", file=sys.stderr) + sys.exit(1) + + def _unknown_target(target: str, plat: str, targets: dict) -> None: print(f"Error: unknown target '{target}' for {plat}.", file=sys.stderr) if targets: @@ -11068,10 +11204,11 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: platforms = [(args.platform, args.dest)] elif args.platform: print("Looking for setups here...") - if interactive and not listing: - # The cards mounted here too, as the screen finds them: a pack's - # RetroArch list went to this computer's folder past the Onion - # card it was downloaded for. + if not listing: + # The cards mounted here too, as the screen finds them, with or + # without questions: a pack's RetroArch list went to this + # computer's folder past the Onion card it was downloaded for, + # and --yes did so after the screen had found the card. setups, mounted = found_setups(os_type) detected = [(name, path) for name, path, _label in setups] else: @@ -11161,12 +11298,13 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: print(" Found ES-DE (BIOS files go to each emulator, not to ES-DE itself)") if "launchbox" in frontends: print(" Found LaunchBox") - if interactive and not listing: - # A card mounted here is found as the screen finds it: line mode - # said "No supported platform detected" beside an Onion card. + if not listing: + # A card mounted here is found as the screen finds it, in every + # mode: line mode said "No supported platform detected" beside + # an Onion card, and --yes filled this computer's folder. setups, mounted = found_setups(os_type) platforms = [(name, path) for name, path, _label in setups] - offer = True + offer = interactive else: platforms = detect_platforms(os_type) if not platforms: @@ -11201,18 +11339,11 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: for name, path in platforms: print(f" Found {found_label(name, path)}") - conflict = os_conflict(platforms) - if conflict and not interactive and not listing: - # A machine runs one system: installing to each folder would write - # where nothing reads, and picking one would be a guess. - message = (f"{' and '.join(conflict)} are both detected here, and a " - "machine runs one") - EVENTS.emit("error", stage="detect", reason="conflict", platform="", - message=message) - print(f"Error: {message}. Name the folder with --dest (and --platform for " - "its file list).", - file=sys.stderr) - sys.exit(1) + if len(platforms) > 1 and not interactive and not listing: + # A run without questions installs for one setup, the one a person + # would have chosen: --yes beside two setups filled both, 14 GB in + # two folders, where its help says it stops on what to name. + _several_found(platforms, setups) if platforms: EVENTS.emit("detect", os=os_type, os_name=_os_name(), platforms=[ {"platform": name, "name": display_name(name), "bios_dir": str(path)} @@ -16291,15 +16422,46 @@ def found_setups(os_type: str) -> tuple[list[tuple[str, Path, str]], set]: # On the device itself its own card is the system found, by its own # path (muOS reads /run/muos/storage/bios, a bind of MUOS/bios). here = {detected_os(name, path) for name, path in found} + taken = [] for card, mount in cards: - if card and (card[0], card[1]) not in found and card[2] not in here: + if card is None: + # A drive whose files fit two systems is said, never guessed. + words = doubt_words(mount) + if words: + print(f" {words}") + continue + if (card[0], card[1]) not in found and card[2] not in here: _DETECTED_OS.setdefault((card[0], card[1]), card[2]) mounted.add((card[0], card[1])) - kind = "SHARE drive" if drive_kind(card) != "card" else "SD card" - setups.append((card[0], card[1], f"{card[2]} {kind} ({_mount_name(mount)})")) + taken.append((card, mount)) + systems = [card[2] for card, _mount in taken] + counts = {mount: _bios_count(card[1]) for card, mount in taken + if systems.count(card[2]) > 1} + # Two cards of one system read alike: each says what its BIOS folder + # holds, and the one with fewer files, the card being set up rather + # than the old one, comes first (tui_audit_11 P3-15). + taken.sort(key=lambda item: (systems.index(item[0][2]), counts.get(item[1], -1))) + for card, mount in taken: + kind = "SHARE drive" if drive_kind(card) != "card" else "SD card" + held = counts.get(mount) + what = ("" if held is None else ", no BIOS folder" if held < 0 + else f", {_plural(held, 'BIOS file')}") + setups.append((card[0], card[1], f"{card[2]} {kind} ({_mount_name(mount)}{what})")) return tell_apart(setups), mounted +def _bios_count(folder: Path, cap: int = 99999) -> int: + """How many files a card's BIOS folder holds, -1 when it has none.""" + if not _is_dir(folder): + return -1 + count = 0 + for _top, _dirs, files in os.walk(folder): + count += len(files) + if count >= cap: + break + return count + + def tui_start( args: argparse.Namespace, os_type: str, detect: bool = True, ) -> tuple[TuiState, list[tuple]]: diff --git a/install.sh b/install.sh index a99ddc501..1a8428252 100755 --- a/install.sh +++ b/install.sh @@ -18,7 +18,7 @@ esac TEMP_INSTALLER="" TEMP_DIRECTORY="" DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" -DEFAULT_INSTALL_SHA256="71fa422aa38f9710c9aab81ba038cad71df66f574a527dc62e0307c69c30d1b0" +DEFAULT_INSTALL_SHA256="f4f2d041d923e32d46c828222f43f4359c5241fcce7ccc29bfc9b3e8565bba0b" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 825839450..7710fa313 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 775811, - "sha256": "71fa422aa38f9710c9aab81ba038cad71df66f574a527dc62e0307c69c30d1b0", + "size": 784255, + "sha256": "f4f2d041d923e32d46c828222f43f4359c5241fcce7ccc29bfc9b3e8565bba0b", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 828360, - "sha256": "dc3204c1dab8db00fb10e47cb9d2cad8ad6320b9d5c7d8fff9168c238620d5ef" + "size": 836804, + "sha256": "2a46dfb5f18a6535fed35fc828078657fa09440abeafd3219ba099ca3ef4e580" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 775811, - "sha256": "71fa422aa38f9710c9aab81ba038cad71df66f574a527dc62e0307c69c30d1b0", + "size": 784255, + "sha256": "f4f2d041d923e32d46c828222f43f4359c5241fcce7ccc29bfc9b3e8565bba0b", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 775811, - "sha256": "71fa422aa38f9710c9aab81ba038cad71df66f574a527dc62e0307c69c30d1b0", + "size": 784255, + "sha256": "f4f2d041d923e32d46c828222f43f4359c5241fcce7ccc29bfc9b3e8565bba0b", "executable": false }, { diff --git a/scripts/generate_installer_doc.py b/scripts/generate_installer_doc.py index f96ec9c4d..7b09eeba2 100644 --- a/scripts/generate_installer_doc.py +++ b/scripts/generate_installer_doc.py @@ -22,7 +22,7 @@ import ast import importlib.util import re import sys -from pathlib import Path +from pathlib import Path, PurePosixPath ROOT = Path(__file__).resolve().parent.parent PAGE = ROOT / "wiki" / "installer.md" @@ -154,6 +154,40 @@ def route_keys() -> str: return _table(heading, rows) +def _marker(rule) -> str: + """A card rule's evidence in words: the file or folder, and the line it + holds for a rule that reads one.""" + if rule.method == "file_matches": + path, pattern = rule.values + line = re.sub(r"\\s\*\$$", "", re.sub(r"^(\^|\\A)", "", pattern)).replace("\\", "") + return f"`{path}` holding the line `{line}`" + return ", ".join(f"`{value}`" if PurePosixPath(value).suffix else f"`{value}/`" + for value in rule.values) + + +def _list_words(plat: str) -> str: + name = _install.display_name(plat) + return f"{name}'{'' if name.endswith('s') else 's'} list" + + +def card_marker_rows() -> list[list[str]]: + """The files that name a card or a SHARE drive, in the order they are + read: install.py's card rules, then its SHARE drive files.""" + rows = [[_marker(rule), rule.name, f"`{rule.bios}`, {_list_words(rule.manifest)}"] + for rule in _install._card_rules()] + hostname = "system.hostname=BATOCERA" + for plat, marker in _install._SHARE_DRIVES: + name = _install.display_name(plat) + evidence = (f"`{marker}` holding `{hostname}` or no hostname line, and no KNULLI file " + "above" if plat == "batocera" else f"`{marker}`") + rows.append([evidence, f"{name} SHARE drive", f"`bios`, {_list_words(plat)}"]) + rows.append([f"`{_install._SHARE_DRIVES[0][1]}` with any other hostname, and no KNULLI " + "file above, or the files of two SHARE drives", + "named by neither: the run says which systems it could be", + "the folder of the system named with `--platform`"]) + return rows + + def render(page: str) -> str: """The page with each marked table written from install.py.""" writers = { @@ -162,6 +196,8 @@ def render(page: str) -> str: "events": lambda body: _table(["Kind", "Keys", "When"], event_rows(body)), "pad-routes": lambda body: _table(["Value", "Input", "Set by"], pad_rows(body)), "route-keys": lambda _body: route_keys(), + "card-markers": lambda _body: _table(["On the card", "System", "Files go to"], + card_marker_rows()), } seen = [] diff --git a/tests/test_install_experience.py b/tests/test_install_experience.py index 34d5a9af8..36f496351 100644 --- a/tests/test_install_experience.py +++ b/tests/test_install_experience.py @@ -1174,11 +1174,17 @@ class ADetectedClosedFolderSaysWhereItCameFrom(unittest.TestCase): said = " ".join(proc.stderr.split()) self.assertIn(f"than the one {self.cfg} names (name another with --dest)", said) - def test_nothing_installed_comes_after_the_error(self): + def test_two_setups_stop_before_either_folder_is_read(self): + """code_audit_18 P2-3: a run without questions installs for one + setup, so two found stop on what to name before either folder is + read, the closed one included.""" proc = self.run_detected(True) self.assertEqual(proc.returncode, 1, proc.stdout + proc.stderr) - error = proc.stderr.index("Error: ") - self.assertGreater(proc.stderr.index("Nothing was installed."), error, proc.stderr) + said = " ".join(proc.stderr.split()) + self.assertIn("2 setups were found here, and a run without questions installs for " + "one", said) + self.assertIn("--platform emudeck --dest", said) + self.assertNotIn("Downloading", proc.stdout) def test_an_assumed_default_reads_plainly(self): self.assertIn("used by default, since {display_name(name)} was not found", diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 824a0da2f..f2c8fe72d 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -6629,6 +6629,81 @@ class LineModeAndScreenDecideAlike(unittest.TestCase): self.assertEqual(screen, expected) self.assertNotIn("using default path", text) + def unasked(self, mounts: list, found: list, **given) -> tuple: + """(platforms or exit code, text, events) of a run without + questions: --no-input, or --yes, which implies it.""" + def stop(_args, platforms): + raise _Decided(platforms) + + install.LOCAL.reset() + self.addCleanup(install.LOCAL.reset) + args = _args(declared_only=False, offline=False, no_input=True, **given) + events = [] + with mock.patch.object(install, "_mounted_cards", return_value=mounts), \ + mock.patch.object(install, "_mount_points", return_value=frozenset()), \ + mock.patch.object(install, "detect_platforms", return_value=found), \ + mock.patch.object(install, "detect_frontends", return_value=[]), \ + mock.patch.object(install, "app_refusal", side_effect=stop), \ + mock.patch.object(install, "detect_os", return_value="linux"), \ + mock.patch.object(install.EVENTS, "emit", + side_effect=lambda kind, **keys: events.append((kind, keys))), \ + mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}), \ + contextlib.redirect_stdout(io.StringIO()) as out, \ + contextlib.redirect_stderr(io.StringIO()) as err: + try: + install._install(args, io.StringIO()) + except _Decided as decided: + return [tuple(each) for each in decided.args[0]], out.getvalue(), events + except SystemExit as exit_: + return exit_.code, out.getvalue() + err.getvalue(), events + raise AssertionError(out.getvalue()) + + def test_a_run_without_questions_sees_the_cards_the_screen_sees(self): + """tui_audit_11 P1-1: --yes and --no-input looked at this machine + alone, beside the Onion card the screen and line mode open on: the + files went to the home folder, or the run said nothing is here.""" + onion = self.card("ONION", "Onion") + default = install._default_dest("linux", "retroarch") + for flag in ({"yes": True}, {}): + for name, (mounts, given) in { + "an Onion card, a list": ([onion], {"manifest": self.list}), + "an Onion card, nothing named": ([onion], {}), + }.items(): + with self.subTest(flag=flag, scenario=name): + chosen, text, _events = self.unasked(mounts, [], **flag, **given) + self.assertEqual(chosen, [("retroarch", onion / "BIOS")], text) + self.assertNotIn(str(default), text) + install._DETECTED_OS.clear() + with mock.patch.object(install, "_mounted_cards", return_value=mounts), \ + mock.patch.object(install, "_mount_points", + return_value=frozenset()), \ + mock.patch.object(install, "detect_platforms", return_value=[]), \ + mock.patch.object(install, "detect_frontends", return_value=[]): + screen = screen_decision(_args(declared_only=False, **given)) + self.assertEqual(chosen, [screen]) + + def test_a_run_without_questions_stops_on_two_setups(self): + """code_audit_18 P2-3, tui_audit_11 P1-1: --yes beside two setups + installed for both, where its help says it stops on what to name. + It now stops with an error event naming each and its --dest.""" + onion, muos = self.card("ONION", "Onion"), self.card("MUOS", "muOS") + pc = [("retroarch", self.here)] + for flag in ({"yes": True}, {}): + for name, (mounts, found, given) in { + "two cards": ([onion, muos], [], {}), + "a card and RetroArch here, a list": ([onion], pc, {"manifest": self.list}), + "two setups here": ([], [("retroarch", self.here), + ("emudeck", self.root / "Emulation" / "bios")], {}), + }.items(): + with self.subTest(flag=flag, scenario=name): + code, text, events = self.unasked(mounts, found, **flag, **given) + self.assertEqual(code, 1, text) + errors = [keys for kind, keys in events if kind == "error"] + self.assertEqual([(e["stage"], e["reason"]) for e in errors], + [("detect", "conflict")]) + self.assertIn("a run without questions installs for one", text) + self.assertRegex(text, r"--platform \w+ --dest ") + def test_line_mode_says_the_card_it_found_and_names_its_folder(self): onion = self.card("ONION", "Onion") with mock.patch.object(install, "_mounted_cards", return_value=[onion]), \ @@ -6759,7 +6834,8 @@ class LineModeAndScreenDecideAlike(unittest.TestCase): share = self.root / "media" / f"SHARE-{plat}" (share / "system").mkdir(parents=True) (share / "roms").mkdir() - (share / marker).write_text("system.hostname=SHARE\n") + # Batocera's own hostname: another would fit KNULLI renamed too. + (share / marker).write_text("system.hostname=BATOCERA\n") shares[plat] = share for plat, share in shares.items(): other = shares["recalbox" if plat == "batocera" else "batocera"] @@ -8350,5 +8426,174 @@ class EdgesTheMutantsFound(unittest.TestCase): ["Odroid"]) + +class AuditEighteenAndEleven(unittest.TestCase): + """code_audit_18 and tui_audit_11: what the cards, the screens and the + docs answer, line mode's parity being in LineModeAndScreenDecideAlike.""" + + def setUp(self): + (REPO_ROOT / "tmp").mkdir(exist_ok=True) + self.root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, self.root, True) + install.LOCAL.reset() + self.addCleanup(install.LOCAL.reset) + for patcher in (mock.patch.dict(install._DETECTED_OS, {}), + mock.patch.object(install, "_mount_points", return_value=frozenset())): + patcher.start() + self.addCleanup(patcher.stop) + + def drive(self, name: str, files: dict) -> Path: + root = self.root / "media" / name + root.mkdir(parents=True) + for path, text in files.items(): + (root / path).parent.mkdir(parents=True, exist_ok=True) + (root / path).write_text(text, encoding="utf-8") + return root + + ABOUT = "system/configs/emulationstation/about.info" + + def renamed(self) -> dict: + """The drives code_audit_18 P1-1 names: KNULLI and Batocera each + renamed in its own menu, with and without the file KNULLI alone + writes, and Recalbox's share.""" + conf = "system/batocera.conf" + return { + "KNULLI renamed": self.drive("K1", {self.ABOUT: "KNULLI\n", + conf: "system.hostname=RG40XXH\n"}), + "KNULLI as shipped": self.drive("K2", {self.ABOUT: "KNULLI\n", + conf: "system.hostname=KNULLI\n"}), + "renamed, no about.info": self.drive("K3", {conf: "system.hostname=RG40XXH\n"}), + "Batocera as shipped": self.drive("B1", {conf: "system.hostname=BATOCERA\n"}), + "Batocera, no hostname": self.drive("B2", {conf: "# system.hostname=X\n"}), + "Recalbox": self.drive("R1", {"system/recalbox.conf": "a=1\n"}), + "two shares": self.drive("T1", {conf: "system.hostname=BATOCERA\n", + "system/recalbox.conf": ""}), + } + + def test_a_renamed_card_is_named_by_the_file_no_menu_writes(self): + """code_audit_18 P1-1: a KNULLI card whose hostname was changed in + its menu was a Batocera SHARE drive, Enter filled it with Batocera's + list and --platform knulli was refused.""" + drives = self.renamed() + expected = { + "KNULLI renamed": ("knulli", "bios", "KNULLI"), + "KNULLI as shipped": ("knulli", "bios", "KNULLI"), + "renamed, no about.info": None, + "Batocera as shipped": ("batocera", "bios", "Batocera"), + "Batocera, no hostname": ("batocera", "bios", "Batocera"), + "Recalbox": ("recalbox", "bios", "Recalbox"), + "two shares": None, + } + for name, drive in drives.items(): + with self.subTest(drive=name): + found = install.card_system(drive) + want = expected[name] + self.assertEqual(found, want and (want[0], drive / want[1], want[2])) + doubt, _why = install.share_doubt(drive) + self.assertEqual(bool(doubt), want is None) + self.assertEqual(install.share_doubt(drives["renamed, no about.info"])[0], + ("knulli", "batocera")) + self.assertEqual(install.share_doubt(drives["two shares"])[0], + ("batocera", "recalbox")) + + def test_a_drive_two_systems_fit_is_taken_as_named_and_said(self): + """code_audit_18 P1-1: --platform knulli is never refused on a + drive whose only evidence is a batocera.conf, --platform batocera + takes its bios folder, and the doubt is said. Recalbox refuses it + with words for a SHARE drive (P3-4).""" + drives = self.renamed() + for name in ("renamed, no about.info", "Batocera as shipped"): + drive = drives[name] + with self.subTest(drive=name): + choice = install.card_choice("knulli", "knulli", drive) + self.assertEqual((choice.plat, choice.folder, choice.refused), + ("knulli", drive / "bios", "")) + self.assertIn("Taken as KNULLI's, as named", choice.note) + drive = drives["renamed, no about.info"] + choice = install.card_choice("batocera", "batocera", drive) + self.assertEqual((choice.plat, choice.folder), ("batocera", drive / "bios")) + self.assertIn("names the host RG40XXH", choice.note) + refused = install.card_choice("recalbox", "recalbox", drive) + self.assertIn("could be a KNULLI card or a Batocera SHARE drive", refused.refused) + words = install._dest_refusal_words(refused, None) + self.assertIn("Connect the Recalbox SHARE drive", words) + self.assertNotIn("Insert", words) + other = install.card_choice("recalbox", "recalbox", drives["Batocera as shipped"]) + words = install._dest_refusal_words(other, None) + self.assertIn("Connect the Recalbox SHARE drive, or name that drive's own system: " + "--platform batocera", words) + + def test_the_first_screen_and_line_mode_say_the_doubt(self): + """code_audit_18 P1-1: the screen's first row was "Batocera SHARE + drive (KNULLI2)". A renamed card with about.info is KNULLI's, one + without is listed by neither and said.""" + drives = self.renamed() + knulli, doubt = drives["KNULLI renamed"], drives["renamed, no about.info"] + with mock.patch.object(install, "_mounted_cards", return_value=[knulli, doubt]), \ + mock.patch.object(install, "detect_platforms", return_value=[]), \ + contextlib.redirect_stdout(io.StringIO()) as out: + setups, _mounted = install.found_setups("linux") + self.assertEqual([(plat, path) for plat, path, _label in setups], + [("knulli", knulli / "bios")]) + self.assertIn("KNULLI SD card", setups[0][2]) + text = out.getvalue() + self.assertIn(f"{doubt} could be a KNULLI card or a Batocera SHARE drive", text) + self.assertIn(f"--platform knulli --dest {doubt} or --platform batocera --dest {doubt}", + text) + with mock.patch.object(install, "_mounted_cards", return_value=[knulli, doubt]), \ + mock.patch.object(install, "detect_platforms", return_value=[]), \ + mock.patch.object(install, "detect_frontends", return_value=[]), \ + contextlib.redirect_stdout(io.StringIO()): + state, _effects = install.tui_start(_args(), "linux") + self.assertTrue(any("could be a KNULLI card" in note for note in state.notes)) + self.assertEqual([setup[0] for setup in state.setups], ["knulli"]) + + def test_a_share_drive_is_never_called_a_card(self): + """code_audit_18 P3-4, tui_audit_11 P3-14: "is a Batocera card" + and "is a Recalbox card" for their SHARE drives.""" + drives = self.renamed() + for name in ("Batocera as shipped", "Recalbox"): + with self.subTest(drive=name): + choice = install.card_choice("retroarch", "retroarch", drives[name]) + self.assertIn("SHARE drive, which reads", choice.note) + self.assertNotIn(" card, which reads", choice.note) + + def test_two_cards_of_one_system_say_what_they_hold(self): + """tui_audit_11 P3-15: an old Onion card with 6 BIOS files and a new + empty one looked the same, and Enter took the first by name.""" + old = self.root / "media" / "OLDONION" + make_card(old, "Onion") + for index in range(6): + (old / "BIOS" / f"f{index}.bin").write_bytes(b"x") + new = self.root / "media" / "ONION" + make_card(new, "Onion") + shutil.rmtree(new / "BIOS") + with mock.patch.object(install, "_mounted_cards", return_value=[old, new]), \ + mock.patch.object(install, "detect_platforms", return_value=[]), \ + contextlib.redirect_stdout(io.StringIO()): + setups, _mounted = install.found_setups("linux") + self.assertEqual([label for _plat, _path, label in setups], + ["Onion SD card (ONION, no BIOS folder)", + "Onion SD card (OLDONION, 6 BIOS files)"]) + + def test_the_card_marker_table_is_written_from_the_rules(self): + """code_audit_18 P3-6, tui_audit_11 P2-2: the table left out the + SHARE drives. It is generated: every card rule and SHARE drive file + is a row, about.info among them.""" + sys.path.insert(0, str(REPO_ROOT / "scripts")) + self.addCleanup(sys.path.remove, str(REPO_ROOT / "scripts")) + import generate_installer_doc # noqa: PLC0415 + + page = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8") + block = page[page.index(""): + page.index("")] + for rule in install._card_rules(): + self.assertIn(f"`{rule.values[0]}", block) + for _plat, marker in install._SHARE_DRIVES: + self.assertIn(f"`{marker}`", block) + self.assertIn(self.ABOUT, block) + self.assertIn("Recalbox SHARE drive", block) + self.assertEqual(generate_installer_doc.render(page), page) + if __name__ == "__main__": unittest.main() diff --git a/wiki/installer.md b/wiki/installer.md index 69929a48a..1ded72ce0 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -864,20 +864,42 @@ A card that holds a system's own files is read the same way, without `--platform` or with the one that names its list, by the rules the table above gives for the device, below the card's root instead of its mount point: + + | On the card | System | Files go to | |-------------|--------|-------------| +| `system/configs/emulationstation/about.info` holding the line `KNULLI` | KNULLI | `bios`, KNULLI's list | +| `system/batocera.conf` holding the line `system.hostname=KNULLI` | KNULLI | `bios`, KNULLI's list | +| `.config/system/configs/system.cfg` holding the line `root.password=rocknix` | ROCKNIX | `roms/bios`, ROCKNIX's list | | `MUOS/` | muOS | `MUOS/bios`, RetroArch's list | -| `.tmp_update/onionVersion/version.txt` | Onion | `BIOS`, RetroArch's list | | `spruce/` | spruceOS | `BIOS`, RetroArch's list | | `System/usr/trimui/crossmix-version.txt` | CrossMix-OS | `BIOS`, RetroArch's list | +| `.tmp_update/onionVersion/version.txt` | Onion | `BIOS`, RetroArch's list | | `.allium/` | Allium | `BIOS`, RetroArch's list | | `Koriki/version.txt` | Koriki | `BIOS`, RetroArch's list | -| `system/batocera.conf` with `system.hostname=KNULLI`, as KNULLI's first boot writes its share | KNULLI | `bios`, KNULLI's list | -| `.config/system/configs/system.cfg` with `root.password=rocknix`, as ROCKNIX's first boot writes its storage | ROCKNIX | `roms/bios`, ROCKNIX's list | +| `system/batocera.conf` holding `system.hostname=BATOCERA` or no hostname line, and no KNULLI file above | Batocera SHARE drive | `bios`, Batocera's list | +| `system/recalbox.conf` | Recalbox SHARE drive | `bios`, Recalbox's list | +| `system/batocera.conf` with any other hostname, and no KNULLI file above, or the files of two SHARE drives | named by neither: the run says which systems it could be | the folder of the system named with `--platform` | -The full screen lists such a card on its first screen ("muOS SD card (E:\)"). + + +Each file is one the system writes. KNULLI is read by `about.info` first, +which no menu writes: its HOSTNAME row rewrites `system.hostname`, and a KNULLI +card renamed there was read as a Batocera SHARE drive. ROCKNIX's line changes +with its root password, and a card whose password was changed is named by +nothing, never by another system. A drive whose files fit two systems is named +by neither: the first screen and `--plain` say which systems it could be and +the two commands that name it, and a `--dest` named with one of them takes it +as that system's, with a note. + +The full screen lists such a card on its first screen ("muOS SD card (E:\)"), +and a SHARE drive the same way ("Batocera SHARE drive (SHARE)"): its root, typed +or given with `--dest`, stands for its `bios` folder. Two cards of one system +say what their BIOS folder holds ("Onion SD card (OLDONION, 6 BIOS files)"), +the one holding fewer first. Named by its system (`--platform muos`, `--platform knulli`), a `--dest` that -is another system's card is refused. One that holds none of the system's +is another system's card is refused, and so is another system's SHARE drive +for Batocera and Recalbox. One that holds none of the system's files is refused where the card always carries them. For KNULLI, ROCKNIX and the systems no file on the card names, it gets the system's folder below it (`bios`, `roms/bios`), with a note. Named with a file list instead From 19bd88131d749b88544b6227b6bc2aa58c01b2a4 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:46:14 +0100 Subject: [PATCH 02/13] fix: show outside text, never obey it Pack and list paths with a control character are refused, the names a list records, card labels, folders and events pass one filter, and a folder a configuration names with one is not taken. --- install.ps1 | 2 +- install.py | 62 ++++++++++++++++--- install.sh | 2 +- install/launchers/index.json | 16 ++--- tests/test_install_tui.py | 114 +++++++++++++++++++++++++++++++++++ wiki/installer.md | 14 ++++- 6 files changed, 188 insertions(+), 22 deletions(-) diff --git a/install.ps1 b/install.ps1 index 49ce42b0c..58abd67e3 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "f4f2d041d923e32d46c828222f43f4359c5241fcce7ccc29bfc9b3e8565bba0b" + $defaultInstallSha256 = "455524a94cfddee646331cc5b92358ef52c06f8ed23a0a73be35355efa6528ea" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 6b6bc0fc3..26c7532b4 100644 --- a/install.py +++ b/install.py @@ -632,7 +632,11 @@ class EventStream: keys = EVENT_KEYS[kind] if set(fields) != set(keys): raise ValueError(f"event {kind}: keys {sorted(fields)} != {sorted(keys)}") - line = json.dumps({"event": kind, **{key: fields[key] for key in keys}}) + # Every string a script reads goes through the terminal's filter, + # a new line kept: a consumer printing a folder or a name obeyed + # the control characters a card label or a list held. + line = json.dumps({"event": kind, **{key: _clean_strings(fields[key], "\n") + for key in keys}}) with self._lock: self.stream.write(line + "\n") self.stream.flush() @@ -941,8 +945,11 @@ def shown_path(path: Path | str) -> str: match = (re.match(rf"^{re.escape(root)}([a-z])(?:/(.*))?$", text) if _WSL["active"] else None) if match: - return f"{match.group(1).upper()}:\\" + (match.group(2) or "").replace("/", "\\") - return text + text = f"{match.group(1).upper()}:\\" + (match.group(2) or "").replace("/", "\\") + # A folder's name comes from whoever named it: a card's label, a + # retroarch.cfg, a pack. Printed raw, a control character in it is a + # terminal command (code_audit_18 P2-1). + return screen_text(text) def _wsl_dest(args: argparse.Namespace) -> None: @@ -2009,6 +2016,8 @@ def launchbox_bios_dirs(root: Path) -> dict[str, Path]: # (folder, why in words) for each folder detection could not look in. _DETECTION_REFUSED: list[tuple[Path, str]] = [] +# The platforms whose configuration names a folder with a control character. +_UNNAMEABLE: list[str] = [] def refused_words(folder: Path, why: str, shown: str = "") -> str: @@ -2360,7 +2369,21 @@ def detect_platforms(os_type: str) -> list[tuple[str, Path]]: _detect_by_path(os_type, found) # An embedded system's config read again as the home folder's one (HOME is # /storage on the ELEC family) is the same folder. - return list(dict.fromkeys(found)) + kept = [] + for name, path in dict.fromkeys(found): + if _CONTROLS.search(str(path)): + # A config naming a folder with a control character: printed, + # the name is a terminal command, and the folder one nobody can + # type (code_audit_18 P2-1). Its default is then no answer: the + # setup reads the folder named. + if name not in _UNNAMEABLE: + _UNNAMEABLE.append(name) + print(f" {display_name(name)}: the folder its configuration names holds a " + f"control character ({shown_path(path)}). Name its BIOS folder with " + "--dest.", file=sys.stderr) + continue + kept.append((name, path)) + return kept def _emudeck_windows_bios() -> Path | None: @@ -2728,11 +2751,14 @@ def _safe_relative_path(value: object, field: str) -> PurePosixPath: raise ValueError(f"invalid {field}") if ( "\\" in value - or "\x00" in value + or _CONTROLS.search(value) or "//" in value or value.endswith("/") or re.match(r"^[A-Za-z]:", value) ): + # A control character is refused with NUL: a pack's member named + # x]0;PWN.bin set the window title in the progress line, + # and the file was written under that name (code_audit_18 P2-1). raise ValueError(f"unsafe {field}: {value!r}") path = PurePosixPath(value) if path.is_absolute() or any(part in ("", ".", "..") for part in path.parts): @@ -2759,7 +2785,7 @@ def _safe_target_dir(value: object, field: str) -> str: """ if not isinstance(value, str) or not value or len(value) > 2048: raise ValueError(f"invalid {field}") - if "\x00" in value: + if _CONTROLS.search(value): raise ValueError(f"unsafe {field}: {value!r}") parts = re.split(r"[\\/]", value) if any(part == ".." for part in parts): @@ -3126,6 +3152,21 @@ def _check_data_directory(entry: object, where: str) -> None: raise ValueError(f"{where}.files[{rel!r}] is invalid") +def _clean_strings(value: object, keep: str = "") -> object: + """value with a "?" for each control character of its strings, keys + included, those of keep left: the one filter between a list, a card or + a config and the terminal, its paths refused before (_safe_relative_path).""" + if isinstance(value, str): + return _CONTROLS.sub(lambda found: found.group() if found.group() in keep else "?", + value) + if isinstance(value, dict): + return {_clean_strings(key, keep): _clean_strings(item, keep) + for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_clean_strings(item, keep) for item in value] + return value + + def _validate_manifest(data: object, plat: str) -> dict: """Validate the untrusted install-manifest boundary using stdlib only.""" if not isinstance(data, dict): @@ -3284,7 +3325,9 @@ def _validate_manifest(data: object, plat: str) -> dict: raise ValueError(f"invalid standalone copy targets at index {index}") for value in values: _safe_target_dir(value, f"standalone_copies[{index}].targets") - return data + # Its paths checked above, every other string (a system's title, a + # core's name) is shown as the terminal shows text. + return _clean_strings(data) def _validate_targets(data: object) -> dict[str, dict]: @@ -6771,7 +6814,7 @@ def _default_dest(os_type: str, platform_name: str) -> Path | None: C:\\userdata\\bios, reported done. """ default = _built_in_dest(os_type, platform_name) - if default is not None and _refused_near(default): + if default is not None and (_refused_near(default) or platform_name in _UNNAMEABLE): # The Flatpak's own folder at 000 hid the retroarch.cfg naming # ~/Games/bios, and 6.6 GB were offered to a folder it does not read. return None @@ -13584,7 +13627,8 @@ def _mount_name(mount: Path) -> str: if (_WSL["active"] and mount.parent == Path(_WSL["root"]) and len(mount.name) == 1 and mount.name.isalpha()): return f"{mount.name.upper()}:" - return mount.name or str(mount) + # The label is whoever formatted the card's: shown, never obeyed. + return screen_text(mount.name or str(mount)) def _mounted_cards() -> list[Path]: diff --git a/install.sh b/install.sh index 1a8428252..5a5e1da33 100755 --- a/install.sh +++ b/install.sh @@ -18,7 +18,7 @@ esac TEMP_INSTALLER="" TEMP_DIRECTORY="" DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" -DEFAULT_INSTALL_SHA256="f4f2d041d923e32d46c828222f43f4359c5241fcce7ccc29bfc9b3e8565bba0b" +DEFAULT_INSTALL_SHA256="455524a94cfddee646331cc5b92358ef52c06f8ed23a0a73be35355efa6528ea" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 7710fa313..d49fbcb0e 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 784255, - "sha256": "f4f2d041d923e32d46c828222f43f4359c5241fcce7ccc29bfc9b3e8565bba0b", + "size": 786759, + "sha256": "455524a94cfddee646331cc5b92358ef52c06f8ed23a0a73be35355efa6528ea", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 836804, - "sha256": "2a46dfb5f18a6535fed35fc828078657fa09440abeafd3219ba099ca3ef4e580" + "size": 839308, + "sha256": "4fef6336d600ff82f1b0d5040d537775df190c23d9ae97e022160e60189e127a" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 784255, - "sha256": "f4f2d041d923e32d46c828222f43f4359c5241fcce7ccc29bfc9b3e8565bba0b", + "size": 786759, + "sha256": "455524a94cfddee646331cc5b92358ef52c06f8ed23a0a73be35355efa6528ea", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 784255, - "sha256": "f4f2d041d923e32d46c828222f43f4359c5241fcce7ccc29bfc9b3e8565bba0b", + "size": 786759, + "sha256": "455524a94cfddee646331cc5b92358ef52c06f8ed23a0a73be35355efa6528ea", "executable": false }, { diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index f2c8fe72d..113ff3601 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -8595,5 +8595,119 @@ class AuditEighteenAndEleven(unittest.TestCase): self.assertIn("Recalbox SHARE drive", block) self.assertEqual(generate_installer_doc.render(page), page) +class NothingFromOutsideReachesTheTerminalRaw(unittest.TestCase): + """code_audit_18 P2-1: a pack's member path, a --manifest dest, the + names a list records, a card's label and a retroarch.cfg folder reached + line mode, the events and the disk with their control characters. Each + source is fed escape sequences, and none comes out raw.""" + + OSC = "\x1b]0;PWNED\x07" + CSI = "\x1b[41m" + + def setUp(self): + (REPO_ROOT / "tmp").mkdir(exist_ok=True) + self.root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, self.root, True) + install.LOCAL.reset() + self.addCleanup(install.LOCAL.reset) + install._UNNAMEABLE.clear() + self.addCleanup(install._UNNAMEABLE.clear) + self.dest = self.root / "dest" + self.dest.mkdir() + + def assertClean(self, text: str, where: str) -> None: + self.assertNotIn("\x1b", text, where) + self.assertNotIn("\x07", text, where) + + def assertNothingRawOnDisk(self) -> None: + for path in self.root.rglob("*"): + self.assertIsNone(install._CONTROLS.search(path.name), path) + + def run_install(self, args: argparse.Namespace, mounts: list = (), + env: dict | None = None) -> str: + events = io.StringIO() + with mock.patch.object(install, "_mounted_cards", return_value=list(mounts)), \ + mock.patch.object(install, "_mount_points", return_value=frozenset()), \ + mock.patch.object(install, "detect_frontends", return_value=[]), \ + mock.patch.object(install, "detect_os", return_value="linux"), \ + mock.patch.object(install.EVENTS, "stream", events), \ + mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1", **(env or {})}), \ + contextlib.redirect_stdout(io.StringIO()) as out, \ + contextlib.redirect_stderr(io.StringIO()) as err, \ + contextlib.suppress(SystemExit): + install._install(args, io.StringIO()) + return out.getvalue() + err.getvalue() + events.getvalue() + + def test_a_pack_member_path_is_refused(self): + from test_install_offline import _pack, GBA # noqa: PLC0415 + + pack = _pack(self.root, "RetroArch_v1_BIOS_Pack.zip", {f"x{self.OSC}.bin": GBA}) + text = self.run_install(_args(platform="retroarch", dest=self.dest, + from_pack=[pack], no_input=True, declared_only=False)) + self.assertClean(text, "pack") + self.assertIn("is not valid: unsafe", text) + self.assertEqual(list(self.dest.iterdir()), []) + self.assertNothingRawOnDisk() + + def test_a_list_dest_and_its_names_are_refused_or_shown(self): + files = [{"dest": f"x{self.CSI}BAD.bin", "sha1": "0" * 40, "size": 1, + "repo_path": "bios/x", "cores": None, "systems": ["a"]}] + listing = self.root / "list.json" + listing.write_text(json.dumps({"manifest_version": 2, "platform": "retroarch", + "files": files})) + text = self.run_install(_args(platform="retroarch", dest=self.dest, manifest=listing, + no_input=True, dry_run=True, verbose=True, + declared_only=False)) + self.assertClean(text, "--manifest dest") + self.assertIn("unsafe", text) + named = {"manifest_version": 2, "platform": "retroarch", "files": [ + {**files[0], "dest": "a.bin", "systems": [f"a{self.CSI}"], + "cores": [f"core{self.OSC}"]}], + "system_titles": {f"a{self.CSI}": f"Title{self.OSC}"}} + cleaned = install._validate_manifest(named, "retroarch") + self.assertClean(json.dumps(cleaned, ensure_ascii=False), "names") + self.assertNothingRawOnDisk() + + def test_a_card_label_is_shown_and_never_obeyed(self): + card = self.root / "media" / f"S{self.CSI}RED" + (card / "system").mkdir(parents=True) + (card / "system" / "batocera.conf").write_text("system.hostname=BATOCERA\n") + listing = self.root / "batocera.json" + listing.write_text(json.dumps({"manifest_version": 2, "platform": "batocera", + "files": []})) + with mock.patch.object(install, "detect_platforms", return_value=[]): + text = self.run_install(_args(platform="batocera", manifest=listing, + no_input=True, dry_run=True, events=True, + declared_only=False), [card]) + self.assertIn("S?[41mRED", text) + self.assertClean(text, "card label") + self.assertClean(install.card_line(card), "card row") + + def test_a_folder_a_config_names_is_refused(self): + home = self.root / "home" + weird = home / f"sys{self.OSC}x" + weird.mkdir(parents=True) + cfg = home / ".config" / "retroarch" / "retroarch.cfg" + cfg.parent.mkdir(parents=True) + cfg.write_text(f'system_directory = "{weird}"\n') + listing = self.root / "retroarch.json" + listing.write_text(json.dumps({"manifest_version": 2, "platform": "retroarch", + "files": []})) + text = self.run_install( + _args(manifest=listing, no_input=True, dry_run=True, events=True, + declared_only=False), + env={"HOME": str(home), "XDG_CONFIG_HOME": str(home / ".config"), + "RETROBIOS_OS": "linux"}) + self.assertClean(text, "retroarch.cfg") + self.assertIn("holds a control character", text) + self.assertNotIn("using default path", text) + self.assertNotIn(str(home / ".config" / "retroarch" / "system"), text) + + def test_a_path_with_a_control_character_is_no_relative_path(self): + for value in ("a\x1bb.bin", "a\x07.bin", "a\x9bb.bin", "a\nb.bin"): + with self.subTest(value=value), self.assertRaises(ValueError): + install._safe_relative_path(value, "dest") + self.assertEqual(install.shown_path(Path("/a/b\x1b[2Jc")), "/a/b?[2Jc") + if __name__ == "__main__": unittest.main() diff --git a/wiki/installer.md b/wiki/installer.md index 1ded72ce0..842a3a52e 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -1382,12 +1382,20 @@ That keeps the installer's questions working when the script is piped from The manifest is fetched over the network and treated as untrusted input. Every path it carries goes through one validator that refuses an absolute path, a -drive letter, a backslash, a null byte, a doubled separator, a trailing slash -and any `..` component, so a destination cannot climb out of the BIOS -directory. Manifest, target list and payloads each have a size cap, the file +drive letter, a backslash, a null byte or any other control character, a +doubled separator, a trailing slash and any `..` component, so a destination +cannot climb out of the BIOS directory and no file is named with a terminal +command. Manifest, target list and payloads each have a size cap, the file list a count cap, and the sum of the declared sizes is checked against the total the manifest claims. +Every other string from outside is shown, never obeyed: the names a list or +a pack records, a card's label and a folder a configuration names reach the +terminal, line by line or on the screen, and the `--events` stream with a `?` +for each control character. A folder a configuration names with one is not +taken: the run says so and asks for `--dest`, its default being no answer +for a setup that reads another folder. + Symbolic links met under the BIOS root are followed on purpose: they are the user's own layout, and EmuDeck links `bios/shadps4/sys_modules` into shadPS4's data directory where the emulator actually reads it. The boundary is lexical, From 5704dd6793ae3b0280dbe6551c0ba3988ae07328 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:19:03 +0100 Subject: [PATCH 03/13] fix: offer a drive by the folder its system reads RetroArch's folder question numbered a Batocera SHARE drive and filled its root. A drive is now offered by its own system's BIOS folder, one nothing names by its bios folder, and another system's is left out and named with the options that reach it. A list or pack card setups read too opens on those setups, in both modes. --- install.ps1 | 2 +- install.py | 115 ++++++++++++++++++++++++++++------- install.sh | 2 +- install/launchers/index.json | 16 ++--- tests/test_install_tui.py | 90 ++++++++++++++++++++++++++- wiki/installer.md | 13 +++- 6 files changed, 201 insertions(+), 37 deletions(-) diff --git a/install.ps1 b/install.ps1 index 58abd67e3..1b6d71810 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "455524a94cfddee646331cc5b92358ef52c06f8ed23a0a73be35355efa6528ea" + $defaultInstallSha256 = "f7928407478aae79bd9cd7740521e5c83b4fdd26046521c1c49b2f278c3bf22d" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 26c7532b4..ae78496b2 100644 --- a/install.py +++ b/install.py @@ -6691,12 +6691,66 @@ def share_targets(plat: str) -> list[tuple[str, Path]]: targets = [] for mount in _mounted_cards(): found = card_system(mount) - if found is not None and found[0] != plat: + doubt = share_doubt(mount)[0] + if (found is not None and found[0] != plat) or (doubt and plat not in doubt): continue targets.append((f"{_mount_name(mount)}: bios", mount / "bios")) return targets +def drive_targets(plat: str) -> list[tuple[str, Path]]: + """(row label, BIOS folder) of each drive mounted here offered at the + folder question of a setup that runs from no card (RetroArch here): a + card of a system that reads plat's list by its own BIOS folder, a drive + nothing names by its bios folder. A drive's root is no answer: no setup + reads its BIOS files there, and RetroArch's list landed beside the roms/ + and system/ of a Batocera SHARE drive (code_audit_18 P2-2, tui_audit_11 + P1-2). Another system's card or drive, and one two systems fit, are + reached through their own setup (drives_left_out).""" + targets = [] + for mount in _mounted_cards(): + found = card_system(mount) + if (found is not None and found[0] != plat) or share_doubt(mount)[0]: + continue + name = _mount_name(mount) + if found is not None: + below = found[1].relative_to(mount).as_posix() if found[1] != mount else "" + what = f"{found[2]} {_card_word(found)}" + targets.append((f"{name}: {below} ({what})" if below else f"{name} ({what})", + found[1])) + else: + targets.append((f"{name}: bios (card or drive)", mount / "bios")) + return targets + + +def _card_word(system: tuple[str, Path, str]) -> str: + """A mounted drive's kind as the first screen writes it: SD card, or + SHARE drive.""" + return "SD card" if drive_kind(system) == "card" else drive_kind(system) + + +def drives_left_out(plat: str) -> str: + """Why the drives mounted here that another system reads are not offered + at plat's folder question, in words, "" for none.""" + others = [] + for mount in _mounted_cards(): + found = card_system(mount) + doubt = share_doubt(mount)[0] + # The options that reach it, which work wherever this is asked: the + # setup list is not offered once a platform is named. + if doubt: + names = " or ".join(f"--platform {name}" for name in doubt) + others.append(f"{_mount_name(mount)} ({_doubt_kinds(doubt)}: {names} " + f"--dest {shown_path(mount)})") + elif found is not None and found[0] != plat: + word = _name_key(found[2]) or found[0] + others.append(f"{_mount_name(mount)} ({found[2]} {_card_word(found)}: " + f"--platform {word} --dest {shown_path(mount)})") + if not others: + return "" + return (f"Not offered here, each reads its own system's files: {', '.join(others)}.") + + def ask_platform_folder(os_type: str, plat: str, ask: bool = True, check: bool = True, found: Path | None = None) -> Path: """The BIOS folder for a setup chosen from the list, asked as the @@ -6718,15 +6772,15 @@ def ask_platform_folder(os_type: str, plat: str, ask: bool = True, elif default is not None: state = "" if _exists(default) else " (it does not exist yet and will be created)" print(f" {name}'s default BIOS folder: {shown_path(default)}{state}.") - if shared: - targets = share_targets(plat) - for index, (label, folder) in enumerate(targets, 1): - made = "" if _is_dir(folder) else ", created" - print(f" {index}) {label} ({shown_path(folder)}{made})") - else: - targets = [(card_line(mount), mount) for mount in _mounted_cards()] - for index, (label, _mount) in enumerate(targets, 1): - print(f" {index}) {label}") + # A drive is offered by the BIOS folder its system reads, the screen's + # rows: never its root, and never for a system that reads another list. + targets = share_targets(plat) if shared else drive_targets(plat) + for index, (label, folder) in enumerate(targets, 1): + made = "" if _is_dir(folder) else ", created" + print(f" {index}) {label} ({shown_path(folder)}{made})") + left_out = "" if shared else drives_left_out(plat) + if left_out: + print(f" {left_out}") numbers = "" if not targets else (" or 1" if len(targets) == 1 else f" or 1 to {len(targets)}") why = ("" if default is not None or shared @@ -6741,10 +6795,8 @@ def ask_platform_folder(os_type: str, plat: str, ask: bool = True, if raw is None: return default if raw.isdigit() and 1 <= int(raw) <= len(targets): - folder = targets[int(raw) - 1][1] - if shared: - # The screen's row, taken as it takes it. - return folder + # The screen's row, taken as it takes it. + return targets[int(raw) - 1][1] else: # Named in full on BIOS folder:, Created and Location:, where # "q" alone said nothing of the folder it was in. @@ -10077,7 +10129,10 @@ def _open_local_sources(args: argparse.Namespace) -> None: f"{display_name(plat)}.", file=sys.stderr) sys.exit(1) elif len(spelled) == 1: + # Named by the pack, not by the user: the setups that read its list + # are offered, as for a --manifest. args.platform = spelled[0] + args.platform_from_list = True else: among = (" (" + ", ".join(spelled) + ")") if spelled else "" print(f"Error: name the platform {pack.name} is installed for with " @@ -11277,6 +11332,15 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: platforms = [(args.platform, card_install_dest( named, args.platform, interactive, not (args.check or args.dry_run or args.yes)))] + elif interactive and getattr(args, "platform_from_list", False) and len( + _line_setups(setups, mounted, os_type)) > 1: + # A list or a pack that card setups read too: they are offered, + # RetroArch first, as the screen offers them. Its folder question + # alone left ArkOS, muOS and JELOS out of reach (tui_audit_11 P1-2). + print(f" {display_name(args.platform)} was not found here.") + platforms = _prompt_manual_platform( + os_type, not (args.check or args.dry_run or args.yes), True, + f"Which setup is the {display_name(args.platform)} list for?", setups, mounted) elif interactive: # Asked as the screen asks, never taken: the default folder was # filled with a card mounted in the reader, and with nothing @@ -13064,10 +13128,10 @@ def tui_rows(state: TuiState) -> list[TuiRow]: for label, folder in share_targets(state.platform)] if not bound: # The drives mounted here, as line mode numbers them at the same - # question: a stick in the reader was offered there and not here. + # question, each by the BIOS folder its system reads. drives = [] if state.folder_kind == "share" else [ - TuiRow("drive", f"{_mount_name(mount)} ({_card_what(mount)})", value=mount) - for mount in _mounted_cards()] + TuiRow("drive", label, value=folder) + for label, folder in drive_targets(state.platform)] return rows + [TuiRow("browse", "Browse for a folder"), TuiRow("type", "Type a path")] + drives # Only the cards whose own files say they run the system: A on the @@ -13891,10 +13955,8 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: if screen == "folder": if action == "default": return _choose(state, state.platform, state.folder_default, proposed=True) - if action == "share": + if action in ("share", "drive"): return _choose(state, state.platform, row.value, proposed=True) - if action == "drive": - return _choose_card(state, row.value) if action in ("card", "rescan"): if action == "rescan": state.cards = mounted_cards_of(_tui_card_bound(state)[0], state.platform) @@ -15571,6 +15633,9 @@ def _view_folder(state: TuiState, width: int, height: int) -> tuple: else: title = "Which folder?" before = [f"No usual {name} folder here: browse for it or type it."] + if not bound and state.folder_kind != "share": + left_out = drives_left_out(state.platform) + before += [left_out] if left_out else [] return (title, _compose(state, width, height, before, tui_rows(state), _status_after(state), pinned=state.refused), ("A/Enter Choose", "B/Esc Back", "? Help")) @@ -16486,7 +16551,7 @@ def found_setups(os_type: str) -> tuple[list[tuple[str, Path, str]], set]: # than the old one, comes first (tui_audit_11 P3-15). taken.sort(key=lambda item: (systems.index(item[0][2]), counts.get(item[1], -1))) for card, mount in taken: - kind = "SHARE drive" if drive_kind(card) != "card" else "SD card" + kind = _card_word(card) held = counts.get(mount) what = ("" if held is None else ", no BIOS folder" if held < 0 else f", {_plural(held, 'BIOS file')}") @@ -16572,6 +16637,14 @@ def tui_start( if matched: state.setups = matched return state, [] + if (getattr(args, "platform_from_list", False) and not bound and len(setup_list( + state.setups, state.mounted_setups, "", os_type, LOCAL.serves())) > 1): + # A list or a pack card setups read too: the setup list, filtered + # to them and RetroArch first, as line mode asks (tui_audit_11 + # P1-2). The folder question alone left them out of reach. + state.stack = ["platforms"] + state.focus["platforms"] = _platform_focus(state, plat) + return state, [] state.platform = plat state.card_bound = bound state.stack = [] diff --git a/install.sh b/install.sh index 5a5e1da33..8bb0d655b 100755 --- a/install.sh +++ b/install.sh @@ -18,7 +18,7 @@ esac TEMP_INSTALLER="" TEMP_DIRECTORY="" DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" -DEFAULT_INSTALL_SHA256="455524a94cfddee646331cc5b92358ef52c06f8ed23a0a73be35355efa6528ea" +DEFAULT_INSTALL_SHA256="f7928407478aae79bd9cd7740521e5c83b4fdd26046521c1c49b2f278c3bf22d" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index d49fbcb0e..7912945eb 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 786759, - "sha256": "455524a94cfddee646331cc5b92358ef52c06f8ed23a0a73be35355efa6528ea", + "size": 790778, + "sha256": "f7928407478aae79bd9cd7740521e5c83b4fdd26046521c1c49b2f278c3bf22d", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 839308, - "sha256": "4fef6336d600ff82f1b0d5040d537775df190c23d9ae97e022160e60189e127a" + "size": 843327, + "sha256": "edf0948281063d1b9abf271f778a4486adf83b18924965eff68308e1540f25ab" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 786759, - "sha256": "455524a94cfddee646331cc5b92358ef52c06f8ed23a0a73be35355efa6528ea", + "size": 790778, + "sha256": "f7928407478aae79bd9cd7740521e5c83b4fdd26046521c1c49b2f278c3bf22d", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 786759, - "sha256": "455524a94cfddee646331cc5b92358ef52c06f8ed23a0a73be35355efa6528ea", + "size": 790778, + "sha256": "f7928407478aae79bd9cd7740521e5c83b4fdd26046521c1c49b2f278c3bf22d", "executable": false }, { diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 113ff3601..a2c15f480 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -6704,6 +6704,86 @@ class LineModeAndScreenDecideAlike(unittest.TestCase): self.assertIn("a run without questions installs for one", text) self.assertRegex(text, r"--platform \w+ --dest ") + def share(self, name: str = "SHARE") -> Path: + share = self.root / "media" / name + (share / "system").mkdir(parents=True) + (share / "roms").mkdir() + (share / "system" / "batocera.conf").write_text("system.hostname=BATOCERA\n") + return share + + def test_a_drive_is_offered_by_the_folder_its_system_reads(self): + """code_audit_18 P2-2, tui_audit_11 P1-2: RetroArch's folder question + numbered a Batocera SHARE drive and a KNULLI card, and one press put + RetroArch's list at their root, beside roms/ and system/. A drive + nothing names went to its root too. Now another system's drive is + left out and said, in both modes, and a drive is its bios folder.""" + share, knulli = self.share(), self.card("KNULLI", "KNULLI") + stick = self.root / "media" / "EASYROMS" + (stick / "bios").mkdir(parents=True) + onion = self.card("ONION", "Onion") + mounts = [share, knulli, stick, onion] + with mock.patch.object(install, "_mounted_cards", return_value=mounts), \ + mock.patch.object(install, "_mount_points", return_value=frozenset()), \ + contextlib.redirect_stdout(io.StringIO()) as out, \ + mock.patch.object(install, "_ask", side_effect=["q"]), \ + self.assertRaises(SystemExit): + install.ask_platform_folder("linux", "retroarch") + text = out.getvalue() + self.assertIn(f"1) EASYROMS: bios (card or drive) ({stick / 'bios'})", text) + self.assertIn(f"2) ONION: BIOS (Onion SD card) ({onion / 'BIOS'})", text) + self.assertNotIn("3)", text) + self.assertIn("Not offered here, each reads its own system's files: SHARE (Batocera " + f"SHARE drive: --platform batocera --dest {share}), KNULLI (KNULLI SD " + f"card: --platform knulli --dest {knulli}).", text) + state = install.TuiState(os_type="linux", home=HOME) + state.platform, state.stack = "retroarch", ["folder"] + state.folder_default, state.folder_kind = SYSTEM_DIR, "usual" + with mock.patch.object(install, "_mounted_cards", return_value=mounts), \ + mock.patch.object(install, "_mount_points", return_value=frozenset()): + rows = install.tui_rows(state) + screen = " ".join(install.tui_view(state, 120, 40)) + state.focus["folder"] = next(index for index, row in enumerate(rows) + if row.action == "drive") + install.tui_update(state, Key("enter")) + self.assertEqual([row.value for row in rows if row.action == "drive"], + [stick / "bios", onion / "BIOS"]) + self.assertIn("Not offered here", screen) + self.assertEqual(state.dest, stick / "bios") + + def test_a_list_card_setups_read_offers_them_first(self): + """tui_audit_11 P1-2: with a RetroArch pack or list and nothing found, + the first screen was RetroArch's folder question: ArkOS, muOS and + JELOS, which read the same list, were out of reach. Both modes now + open on the setups that read it, RetroArch first.""" + stick = self.root / "media" / "EASYROMS" + (stick / "bios").mkdir(parents=True) + args = dict(manifest=self.list, declared_only=False, offline=False) + text, asked = self.line_run(_args(**args), [], mounts=[stick]) + self.assertIn("Which setup is the RetroArch list for?", text) + self.assertIn("ArkOS", text) + self.assertNotIn("Batocera", text) + self.assertIn("[Enter for RetroArch]", asked[0]) + with mock.patch.object(install, "_mounted_cards", return_value=[stick]), \ + mock.patch.object(install, "_mount_points", return_value=frozenset()), \ + mock.patch.object(install, "detect_platforms", return_value=[]), \ + mock.patch.object(install, "detect_frontends", return_value=[]): + install._DETECTED_OS.clear() + state = screen_keys(_args(**args), []) + with contextlib.redirect_stdout(io.StringIO()): + install._open_local_sources(_args(**args)) + rows = install.tui_rows(state) + labels = [row.label for row in rows] + focused = rows[state.focus["platforms"]] + install._DETECTED_OS.clear() + ark = screen_keys(_args(**args), ["end", Key("char", "/"), *[ + Key("char", c) for c in "arkos"], "enter", "enter"]) + self.assertEqual(state.screen, "platforms") + self.assertEqual(focused.value, "retroarch") + self.assertTrue(any(label.startswith("ArkOS") for label in labels), labels) + self.assertFalse(any(label.startswith("Batocera") for label in labels), labels) + self.assertEqual(ark.screen, "folder") + self.assertEqual(install._tui_card_bound(ark)[0], "ArkOS") + def test_line_mode_says_the_card_it_found_and_names_its_folder(self): onion = self.card("ONION", "Onion") with mock.patch.object(install, "_mounted_cards", return_value=[onion]), \ @@ -6741,7 +6821,8 @@ class LineModeAndScreenDecideAlike(unittest.TestCase): mock.patch.object(install, "detect_os", return_value="linux"), \ mock.patch.object(install.sys.stdin, "isatty", return_value=True), \ mock.patch.object(install, "_is_terminal", return_value=True), \ - mock.patch.object(install, "_ask", return_value=str(self.here)), \ + mock.patch.object(install, "_ask", side_effect=lambda prompt: ( + "" if prompt.startswith(">") else str(self.here))), \ mock.patch.object(install, "_prompt_custom_selection", menu), \ contextlib.redirect_stdout(io.StringIO()) as out, \ self.assertRaises(SystemExit): @@ -7089,13 +7170,16 @@ class LineModeAndScreenDecideAlike(unittest.TestCase): mock.patch.object(install, "_ask", side_effect=["1"]), \ mock.patch.object(install, "_missing_dest", return_value=True): folder = install.ask_platform_folder("linux", "retroarch") - self.assertEqual(folder, stick) + # tui_audit_11 P1-2: a drive is offered by its bios folder, never its + # root, which no setup reads. + self.assertEqual(folder, stick / "bios") state = install.TuiState(os_type="linux", home=HOME) state.platform, state.stack = "retroarch", ["folder"] state.folder_default, state.folder_kind = SYSTEM_DIR, "usual" with mock.patch.object(install, "_mounted_cards", return_value=mounts): rows = install.tui_rows(state) - self.assertEqual([row.value for row in rows if row.action == "drive"], [stick]) + self.assertEqual([row.value for row in rows if row.action == "drive"], + [stick / "bios"]) def test_the_systems_are_listed_by_title_in_the_screen_order(self): """tui_audit_10 P3-7: line mode listed ids, nintendo-3ds before diff --git a/wiki/installer.md b/wiki/installer.md index 842a3a52e..782bd92ad 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -1031,9 +1031,16 @@ and `--plain` gives the same list numbered, in the same order, with `--dest` too. Both find the SD cards mounted here, the screen under **Found on this machine**. With a setup found, the setup screen lists it with **Another setup or folder**, and `--plain` asks the same: Enter takes the first, `o` opens the -numbered list and then the folder, `q` quits. A platform named, or read from -a list or a pack, and found nowhere gets its folder asked, its usual one under -Enter, the mounted drives numbered. A folder typed is read from the current +numbered list and then the folder, `q` quits. A platform named and found +nowhere gets its folder asked, its usual one under Enter, the mounted drives +numbered. A list or a pack read by card setups too (RetroArch's, read by Onion, +muOS, ArkOS and the others) opens the setup list instead, filtered to the +setups that read it, RetroArch first: Enter on it asks RetroArch's folder. At +that question a drive is numbered by the folder its system reads (`ONION: +BIOS`), a drive nothing names by its `bios` folder (`EASYROMS: bios`), never +by its root, which no setup reads. Another system's card or SHARE drive, and a +drive two systems fit, are left out, and a line names them: each is reached +through its own setup. A folder typed is read from the current one when it is not a full path, and named in full after. `q` quits there too (a folder named `q` is typed `./q`), and Enter with no default says what to type. On a computer Batocera and Recalbox have no default: the drives are From 0fa4552daeb3f022db317589d57cf42255076749 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:37:24 +0100 Subject: [PATCH 04/13] fix: refuse only the axis a list lacks The MinUI family's lists record cores and regions and no system: every narrowing was refused with a remedy that fetched the same list. Each option is now refused by the axis missing, with what works, and a pack names its platform's list only when that list records the axis. Line mode names what c chooses and skips a question with one answer, and Options leaves out parallel downloads when nothing is downloaded. --- install.ps1 | 2 +- install.py | 136 ++++++++++++++++++++++-------- install.sh | 2 +- install/launchers/index.json | 16 ++-- schemas/pack-manifest.schema.json | 6 ++ scripts/generate_pack.py | 18 +++- tests/test_install_cores.py | 38 ++++++--- tests/test_install_selection.py | 10 ++- tests/test_install_tui.py | 135 ++++++++++++++++++++++++++++- wiki/installer.md | 14 ++- 10 files changed, 314 insertions(+), 63 deletions(-) diff --git a/install.ps1 b/install.ps1 index 1b6d71810..ac037e590 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "f7928407478aae79bd9cd7740521e5c83b4fdd26046521c1c49b2f278c3bf22d" + $defaultInstallSha256 = "00ad7b4c93b5d69e17943e904121485da924ee681c2796a6bbd45aae6052e2b5" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index ae78496b2..38b6d8dfc 100644 --- a/install.py +++ b/install.py @@ -4000,9 +4000,31 @@ def _chosen(entry: dict, wanted: set[str]) -> bool: return bool(wanted & set(systems)) +def recorded_axes(files: list[dict], omitted: list[dict] = ()) -> frozenset: + """What a file list lets --system, --core and --region narrow by: its + systems, its cores, its regions. A list from an older release records + none. The MinUI family's records cores and regions and no system: all + three were refused on it, by a remedy that fetched the same list + (tui_audit_11 P2-1).""" + entries = [*files, *omitted] + systems = any("systems" in entry for entry in entries) + if not systems and not any("regions" in entry for entry in entries): + # Systems and regions came in one release: a list with neither is + # older, its cores read by nothing that narrows. + return frozenset() + # A current list records the regions of the files a region selects, and + # none when it has no such file: --region then keeps every file, as the + # pack built with it does. + axes = {"regions", *(["systems"] if systems else [])} + if any(copy.get("cores") for entry in files + for copy in [entry, *entry.get("alternatives", []), *entry.get("slot_choices", [])]): + axes.add("cores") + return frozenset(axes) + + def records_selection(files: list[dict]) -> bool: - """Whether a manifest carries what narrowing reads. Older ones do not.""" - return any("systems" in entry for entry in files) + """Whether a manifest records anything narrowing reads. Older ones do not.""" + return bool(recorded_axes(files)) def available_choices(files: list[dict], omitted: list[dict]) -> dict[str, list[str]]: @@ -7235,9 +7257,12 @@ def _prompt_custom_selection( print(f" Enter) install {whole}") if installed is not None and current != INSTALLED_ONLY: print(f" i) only what the installed cores read: {_sized(installed[0])}") + # What c asks, named: "systems, cores and regions" was offered with + # a pack that records systems only (tui_audit_11 P3-10). + axes = narrowing_words(files, omitted) if choosable else "" + choosable = bool(axes) if choosable: - print(" c) choose systems, cores and regions" - + (" again" if current is not given else "")) + print(f" c) choose {axes}" + (" again" if current is not given else "")) # The screen's "Copy the BIOS files from your old card or a folder". copying = not dry_run if copying: @@ -7312,11 +7337,27 @@ def _print_menu_head( print(f" {line}") +def narrowing_words(files: list[dict], omitted: list[dict]) -> str: + """What c chooses among for these files, in words, "" for nothing: a + question with one answer is not asked (tui_audit_11 P3-10).""" + axes = recorded_axes(files, omitted) + choices = available_choices(files, omitted) + named = [word for word, axis, enough in ( + ("systems", "systems", len(listed_systems(files)) > 1), + ("cores", "cores", len(choices["cores"]) > 1), + ("regions", "regions", bool(choices["regions"]))) if axis in axes and enough] + return f"{', '.join(named[:-1])} and {named[-1]}" if len(named) > 1 else "".join(named) + + def _choose_narrowing(files: list[dict], omitted: list[dict], manifest: dict | None, bios_path: Path | None) -> tuple[list[str], list[str], list[str]]: """Systems, cores and regions asked one after the other, the systems - titled and ordered as the screen's Choose systems lists them.""" - systems_listed = systems_in_order(listed_systems(files), files, manifest, bios_path) + titled and ordered as the screen's Choose systems lists them. A question + with one answer is skipped: "Cores: 1) azahar" was asked after the 3DS + alone was chosen.""" + listed = listed_systems(files) if "systems" in recorded_axes(files, omitted) else [] + systems_listed = (systems_in_order(listed, files, manifest, bios_path) + if len(listed) > 1 else []) if OTHER_SYSTEMS in systems_listed: print(f"\n'{OTHER_TITLE}' holds what cores load for systems this platform") print("does not list, such as game engines and extra computers.") @@ -7332,7 +7373,7 @@ def _choose_narrowing(files: list[dict], omitted: list[dict], manifest: dict | N cores: list[str] = [] core_names = available_choices(kept, kept_omitted)["cores"] - if core_names: + if len(core_names) > 1: print("\nThe platform's own files for these systems are always kept.") print("The extra files each core loads can be narrowed.") cores = _pick("Cores:", [ @@ -7567,6 +7608,28 @@ def _older_list_advice(plat: str = "") -> str: "installer again to fetch the current one.") +def _narrowing_advice(plat: str, axis: str, flag: str, recorded: frozenset, + pack_alone: bool) -> str: + """What to do when a list or a pack records no axis: only a remedy + that changes the outcome. The pack's remedy named install/minui.json, + refused the same way, and the list's fetched the same list again.""" + works = [name for name, field in (("--system", "systems"), ("--core", "cores"), + ("--region", "regions")) if field in recorded] + rest = f"Run it without {flag}" + (f", or narrow with {' and '.join(works)}" + if works else "") + "." + if pack_alone: + listed = LOCAL.pack.list_records if LOCAL.pack is not None else None + if listed is not None and axis not in listed: + # The pack says its platform's own list lacks the axis too. + return f" {display_name(plat)}'s own file list records no {axis} either. {rest}" + when = "which records them" if listed is not None else f"when that list records {axis}" + return f" Add --manifest FILE, a copy of install/{plat}.json, {when}. {rest}" + if recorded: + # A current list: the platform has no such axis. + return f" {rest}" + return _older_list_advice(plat) + + def _narrow_to_emulators( names: list[str], manifest: dict, plat: str, files: list[dict], omitted: list[dict], @@ -7613,25 +7676,26 @@ def _requested_narrowing( """The systems, cores and regions named on the command line, checked.""" if not (args.system or args.core or args.region): return None - if not records_selection(files): - # Without the fields, a region filter would match nothing and install - # everything: refused rather than ignored. - print( - f"Error: the {plat} file list records no systems or regions, so " - "--system, --core and --region cannot narrow it.", - file=sys.stderr, - ) - print(_older_list_advice(plat), file=sys.stderr) - sys.exit(1) - if LOCAL.pack is not None and LOCAL.manifest is None: + pack_alone = LOCAL.pack is not None and LOCAL.manifest is None + recorded = recorded_axes(files, omitted) + if pack_alone: # A pack records the systems of its files, not their cores or # regions: either filter would keep everything, and is refused. - for flag, given in (("--core", args.core), ("--region", args.region)): - if given: - print(f"Error: {LOCAL.pack.name} records the systems of its files, not " - f"what {flag} narrows by.", file=sys.stderr) - print(_older_list_advice(plat), file=sys.stderr) - sys.exit(1) + recorded = recorded & {"systems"} + asked = [(flag, axis) for flag, given, axis in ( + ("--system", args.system, "systems"), ("--core", args.core, "cores"), + ("--region", args.region, "regions")) if given] + for flag, axis in asked: + if axis in recorded: + continue + # Without the field, a filter would match nothing and install + # everything: refused rather than ignored, by the axis the list + # lacks, with what works on it. + source = LOCAL.pack.name if pack_alone else f"the {plat} file list" + print(f"Error: {source} records no {axis}, so {flag} cannot narrow it.", + file=sys.stderr) + print(_narrowing_advice(plat, axis, flag, recorded, pack_alone), file=sys.stderr) + sys.exit(1) choices = available_choices(files, omitted) try: systems = resolve_choices( @@ -7780,6 +7844,10 @@ class PackSource: self.members: dict[str, tuple[zipfile.ZipFile, zipfile.ZipInfo]] = {} # The system titles and names the pack's list records, if any. self.names: dict[str, dict] = {} + # What its platform's install list narrows by (systems, cores, + # regions), None for a pack that does not say: the remedy for an + # option the pack cannot apply names that list only when it can. + self.list_records: frozenset | None = None try: for path in self.files: archive = zipfile.ZipFile(path) @@ -7867,6 +7935,9 @@ class PackSource: # A pack is a file anyone can hand over: an id or a name holding a # control character would reach the terminal as an escape, and is # dropped. + records = data.get("list_records") + if isinstance(records, list) and all(isinstance(axis, str) for axis in records): + self.list_records = frozenset(records) & {"systems", "cores", "regions"} for key in ("system_titles", "system_names"): held = data.get(key) if isinstance(held, dict): @@ -10260,9 +10331,9 @@ def _read_from_folder(label: str) -> FolderIndex: return index -def _refuse_closed(dest: Path, named: str, then: str = "") -> None: +def _refuse_closed(dest: Path, named: str) -> None: """End the run, with its name, on a folder above dest this user cannot - open. then is a line said after the error, which it continues.""" + open.""" closed = closed_folder(dest) if closed is None: return @@ -10271,8 +10342,6 @@ def _refuse_closed(dest: Path, named: str, then: str = "") -> None: print(f"Error: {screen_text(message)}. Give this user access to it, run as " f"its owner, or choose another folder than the one {named}.", file=sys.stderr) - if then: - print(f" {then}", file=sys.stderr) sys.exit(1) @@ -11478,9 +11547,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: named = (f"used by default, since {display_name(name)} was not found" if path in assumed_dests else f"{found_in[0]} names" if found_in else "detected here") - _refuse_closed(path, f"{named} (name another with --dest)", - "Nothing was installed. Name one platform with --platform " - "to install it alone." if len(platforms) > 1 else "") + # One setup by now: two found without questions stopped above. + _refuse_closed(path, f"{named} (name another with --dest)") total_downloaded = 0 total_up_to_date = 0 @@ -13221,7 +13289,7 @@ def _plan_rows(state: TuiState) -> list[TuiRow]: *([TuiRow("install", "Install anyway")] if rows else [])] # --system given: the choice stays offered, the list widened when it is # opened. With one system named it was neither shown nor changeable. - if records_selection((state.manifest or {}).get("files", [])) and ( + if "systems" in recorded_axes((state.manifest or {}).get("files", [])) and ( len(state.systems) > 1 or state.cli_systems): rows.append(TuiRow("systems", "Choose systems")) rows.append(TuiRow("fromcard", "Copy the BIOS files from your old card or a folder")) @@ -13341,8 +13409,10 @@ def _option_rows(state: TuiState) -> list[TuiRow]: # The interface said "Name it with --save-dir" and had no row for it. rows.append(TuiRow("savedir", f"Save folder: {_save_folder_words(state)}", "--save-dir", style="path")) + if not LOCAL.offline: + # From a pack, or offline, nothing is downloaded (tui_audit_11 P3-16). + rows.append(TuiRow("jobs", f"Parallel downloads: {state.jobs}", "--jobs")) rows += [ - TuiRow("jobs", f"Parallel downloads: {state.jobs}", "--jobs"), TuiRow("folder", f"BIOS folder: {_short_path(state.dest, state.home)}", "--dest", style="path"), TuiRow("from", f"Copy from: {copied}", "--from", style="path"), diff --git a/install.sh b/install.sh index 8bb0d655b..28022ef1d 100755 --- a/install.sh +++ b/install.sh @@ -18,7 +18,7 @@ esac TEMP_INSTALLER="" TEMP_DIRECTORY="" DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" -DEFAULT_INSTALL_SHA256="f7928407478aae79bd9cd7740521e5c83b4fdd26046521c1c49b2f278c3bf22d" +DEFAULT_INSTALL_SHA256="00ad7b4c93b5d69e17943e904121485da924ee681c2796a6bbd45aae6052e2b5" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 7912945eb..250dd18c8 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 790778, - "sha256": "f7928407478aae79bd9cd7740521e5c83b4fdd26046521c1c49b2f278c3bf22d", + "size": 794767, + "sha256": "00ad7b4c93b5d69e17943e904121485da924ee681c2796a6bbd45aae6052e2b5", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 843327, - "sha256": "edf0948281063d1b9abf271f778a4486adf83b18924965eff68308e1540f25ab" + "size": 847316, + "sha256": "56cac10af6b7afaac0d1b3c0964d54caeabda46e75d35580dcedda264e2daac8" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 790778, - "sha256": "f7928407478aae79bd9cd7740521e5c83b4fdd26046521c1c49b2f278c3bf22d", + "size": 794767, + "sha256": "00ad7b4c93b5d69e17943e904121485da924ee681c2796a6bbd45aae6052e2b5", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 790778, - "sha256": "f7928407478aae79bd9cd7740521e5c83b4fdd26046521c1c49b2f278c3bf22d", + "size": 794767, + "sha256": "00ad7b4c93b5d69e17943e904121485da924ee681c2796a6bbd45aae6052e2b5", "executable": false }, { diff --git a/schemas/pack-manifest.schema.json b/schemas/pack-manifest.schema.json index e836ccd33..c2c27eb96 100644 --- a/schemas/pack-manifest.schema.json +++ b/schemas/pack-manifest.schema.json @@ -43,6 +43,12 @@ "system_names": { "type": "object", "additionalProperties": { "type": "array", "items": { "type": "string" } } + }, + "list_records": { + "description": "What the platform's install list narrows by: the installer names that list as the remedy for --core or --region given with the pack alone only when it records them.", + "type": "array", + "uniqueItems": true, + "items": { "enum": ["systems", "cores", "regions"] } } } } diff --git a/scripts/generate_pack.py b/scripts/generate_pack.py index 69b057a27..e68e1bbf6 100644 --- a/scripts/generate_pack.py +++ b/scripts/generate_pack.py @@ -4759,11 +4759,25 @@ def pack_systems(install_manifest: dict) -> dict: ("the file list comes from an older release") and offered no Choose systems, the route of a handheld without Wi-Fi. """ + files = install_manifest.get("files", []) + entries = [*files, *install_manifest.get("omitted_files", [])] + # What the install list narrows by, the installer's recorded_axes: with + # the pack alone it names that list as the way to --core and --region + # only when the list records them. It named install/misterfpga.json for + # --core, a list that records no core. + records = {"systems"} if any("systems" in entry for entry in entries) else set() + if any(copy.get("cores") for entry in files + for copy in [entry, *entry.get("alternatives", []), + *entry.get("slot_choices", [])]): + records.add("cores") + if records or any("regions" in entry for entry in entries): + records.add("regions") return { "files": {entry["dest"]: list(entry.get("systems") or []) - for entry in install_manifest.get("files", []) if "systems" in entry}, + for entry in files if "systems" in entry}, "system_titles": dict(install_manifest.get("system_titles") or {}), "system_names": dict(install_manifest.get("system_names") or {}), + "list_records": sorted(records), } @@ -4779,6 +4793,8 @@ def annotate_pack_systems(manifest: dict, systems: dict | None) -> None: for key in ("system_titles", "system_names"): if systems.get(key): manifest[key] = systems[key] + if "list_records" in systems: + manifest["list_records"] = systems["list_records"] def inject_manifest(zip_path: str, manifest: dict) -> None: diff --git a/tests/test_install_cores.py b/tests/test_install_cores.py index edd7c07df..beb738b11 100644 --- a/tests/test_install_cores.py +++ b/tests/test_install_cores.py @@ -370,18 +370,28 @@ class RefusedBeforeAnyInstall(unittest.TestCase): (tmp / "retroarch.cfg").write_text("") (tmp / "cores").mkdir() (tmp / "cores" / "mgba_libretro.so").write_bytes(b"") - with mock.patch.object(install, "detect_os", return_value="linux"), \ - mock.patch.object(install, "detect_frontends", return_value=[]), \ - mock.patch.object(install, "detect_platforms", return_value=detected), \ - mock.patch.object(install, "fetch_manifest") as fetch, \ - mock.patch.object(sys, "argv", ["install.py", "--installed-cores", "--no-input"]), \ - contextlib.redirect_stdout(io.StringIO()), \ - contextlib.redirect_stderr(io.StringIO()) as err: - with self.assertRaises(SystemExit) as stop: - install.main() - self.assertEqual(stop.exception.code, 1) - self.assertIn("batocera ships its cores", err.getvalue()) - fetch.assert_not_called() + # Two setups found stop a run without questions before either is + # read (code_audit_18 P2-3): the refusal is checked with Batocera + # named, and with both found nothing is fetched either. + for argv, said in ( + (["--platform", "batocera", "--dest", str(tmp / "bios")], + "batocera ships its cores"), + ([], "2 setups were found here")): + with self.subTest(argv=argv), \ + mock.patch.object(install, "detect_os", return_value="linux"), \ + mock.patch.object(install, "detect_frontends", return_value=[]), \ + mock.patch.object(install, "detect_platforms", return_value=detected), \ + mock.patch.object(install, "_mounted_cards", return_value=[]), \ + mock.patch.object(install, "fetch_manifest") as fetch, \ + mock.patch.object(sys, "argv", ["install.py", "--installed-cores", + "--no-input", *argv]), \ + contextlib.redirect_stdout(io.StringIO()), \ + contextlib.redirect_stderr(io.StringIO()) as err: + with self.assertRaises(SystemExit) as stop: + install.main() + self.assertEqual(stop.exception.code, 1) + self.assertIn(said, err.getvalue()) + fetch.assert_not_called() class AnUnknownTargetStopsTheRunFirst(unittest.TestCase): @@ -393,12 +403,14 @@ class AnUnknownTargetStopsTheRunFirst(unittest.TestCase): self.addCleanup(shutil.rmtree, tmp, True) detected = [("emudeck", tmp / "bios"), ("retroarch", tmp / "system")] lists = {"emudeck": {"steamos": {"cores": []}}, "retroarch": {"x86_64": {"cores": []}}} + # Listed for both platforms: a listing reads every list found. with mock.patch.object(install, "detect_os", return_value="linux"), \ mock.patch.object(install, "detect_frontends", return_value=[]), \ mock.patch.object(install, "detect_platforms", return_value=detected), \ mock.patch.object(install, "fetch_targets", side_effect=lists.get), \ mock.patch.object(install, "fetch_manifest") as fetch, \ - mock.patch.object(sys, "argv", ["install.py", "--target", "steamos", "--no-input"]), \ + mock.patch.object(sys, "argv", ["install.py", "--target", "steamos", + "--list-systems", "--no-input"]), \ contextlib.redirect_stdout(io.StringIO()), \ contextlib.redirect_stderr(io.StringIO()) as err: with self.assertRaises(SystemExit) as stop: diff --git a/tests/test_install_selection.py b/tests/test_install_selection.py index 142ed304a..69423b294 100644 --- a/tests/test_install_selection.py +++ b/tests/test_install_selection.py @@ -612,7 +612,8 @@ class TheOneLinerNarrows(unittest.TestCase): with FakeRepository(selection=False) as repo: proc = repo.run("--region", "us") self.assertEqual(proc.returncode, 1) - self.assertIn("records no systems or regions", proc.stderr) + self.assertIn("records no regions, so --region cannot narrow it", proc.stderr) + self.assertIn("older release", proc.stderr) self.assertEqual(repo.installed(), []) def test_a_pinned_ref_is_named_as_the_way_out(self): @@ -675,18 +676,19 @@ class TheOneLinerNarrows(unittest.TestCase): @unittest.skipUnless(shutil.which("script"), "script(1) provides the terminal") def test_the_custom_mode_at_a_terminal(self): - """Typed answers: customise, PlayStation only, no core, US first.""" + """Typed answers: customise, PlayStation only, US first. Its one + core is not asked about (tui_audit_11 P3-10).""" with FakeRepository() as repo: command = " ".join(shlex.quote(part) for part in repo.command()) proc = subprocess.run( ["script", "-qec", command, "/dev/null"], - input="c\n2\n\n2\n\n", env=repo.env(), capture_output=True, + input="c\n2\n2\n\n", env=repo.env(), capture_output=True, text=True, timeout=60, check=False, ) out = proc.stdout self.assertEqual(proc.returncode, 0, out + proc.stderr) self.assertIn("choose systems, cores and regions", out) - self.assertIn("pcsx_rearmed", out) + self.assertNotIn("Cores:", out) self.assertIn("--system sony-playstation --region north-america", out) self.assertEqual(repo.installed(), ["pcsx/extra.bin", "scph5501.bin"]) diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index a2c15f480..82632ca43 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -7432,7 +7432,7 @@ class AuditSeventeenAndTen(unittest.TestCase): with contextlib.redirect_stderr(err), self.assertRaises(SystemExit): install._requested_narrowing(_args(system=["nintendo-gba"]), "retroarch", install.LOCAL.pack.manifest("retroarch")["files"], []) - self.assertIn("add --manifest FILE, a copy of install/retroarch.json", err.getvalue()) + self.assertIn("Add --manifest FILE, a copy of install/retroarch.json", err.getvalue()) self.assertNotIn("older release", err.getvalue()) def test_a_pack_cannot_write_escapes_through_its_system_names(self): @@ -8660,6 +8660,139 @@ class AuditEighteenAndEleven(unittest.TestCase): ["Onion SD card (ONION, no BIOS folder)", "Onion SD card (OLDONION, 6 BIOS files)"]) + # tui_audit_11 P2-1: a list or a pack that records some axes only. + + def minui(self, plat: str = "minui", systems: bool = False, cores: bool = True, + regions: bool = True) -> tuple[Path, Path, dict]: + """A list of plat's shape (the MinUI family records cores and regions, + no system), the pack built from it and the files' payloads.""" + from test_install_offline import GBA, JP, US # noqa: PLC0415 + + payloads = {"Bios/MGBA/gba_bios.bin": (GBA, "nintendo-gba", "mgba", None), + "Bios/PS/scph5501.bin": (US, "sony-playstation", "pcsx_rearmed", + "north-america"), + "Bios/PS/scph5500.bin": (JP, "sony-playstation", "pcsx_rearmed", "japan")} + files = [] + for dest, (data, system, core, region) in payloads.items(): + entry = {"dest": dest, "sha1": hashlib.sha1(data).hexdigest(), "size": len(data), + "repo_path": f"bios/{dest}", "cores": [core] if cores else None} + if systems: + entry["systems"] = [system] + if regions and region: + entry.update(regions=[region], region_groups=["ps"]) + files.append(entry) + listing = self.root / f"{plat}.json" + install_list = {"manifest_version": 2, "platform": plat, "files": files} + listing.write_text(json.dumps(install_list)) + sys.path.insert(0, str(REPO_ROOT / "scripts")) + self.addCleanup(sys.path.remove, str(REPO_ROOT / "scripts")) + import generate_pack # noqa: PLC0415 + + pack_list = {"files": [{"path": dest, "sha1": entry["sha1"], "size": entry["size"]} + for dest, entry in zip(payloads, files)]} + generate_pack.annotate_pack_systems(pack_list, generate_pack.pack_systems(install_list)) + name = {"minui": "MinUI", "misterfpga": "MiSTer_FPGA"}[plat] + pack = self.root / f"{name}_v1_BIOS_Pack.zip" + with zipfile.ZipFile(pack, "w") as archive: + for dest, (data, *_rest) in payloads.items(): + archive.writestr(dest, data) + archive.writestr("manifest.json", json.dumps(pack_list)) + return listing, pack, pack_list + + def narrowed(self, plat: str, **given) -> tuple[int, str, list]: + """(exit code, what was said, the files kept) of a --dry-run narrowed + as given.""" + dest = self.root / "dest" + if not dest.exists(): + make_card(dest, "MinUI") + stream = io.StringIO() + code = 0 + with mock.patch.object(install.EVENTS, "stream", stream), \ + mock.patch.object(install, "detect_os", return_value="linux"), \ + mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}), \ + contextlib.redirect_stdout(io.StringIO()) as out, \ + contextlib.redirect_stderr(io.StringIO()) as err: + try: + install._install(_args(platform=plat, dest=dest, dry_run=True, events=True, + no_input=True, declared_only=False, **given), + io.StringIO()) + except SystemExit as stop: + code = stop.code + finally: + install.LOCAL.reset() + kept = [json.loads(line)["dest"] for line in stream.getvalue().splitlines() + if '"event": "file"' in line] + return code, out.getvalue() + err.getvalue(), kept + + def test_a_list_without_systems_narrows_by_what_it_records(self): + """tui_audit_11 P2-1: install/minui.json records cores and regions, + no system, and all three options were refused with a remedy that + fetched the same list.""" + listing, _pack, _listed = self.minui() + code, text, kept = self.narrowed("minui", manifest=listing, core=["mgba"]) + self.assertEqual(code, 0, text) + self.assertEqual(kept, ["Bios/MGBA/gba_bios.bin"]) + code, text, kept = self.narrowed("minui", manifest=listing, region=["us"]) + self.assertEqual(code, 0, text) + self.assertNotIn("Bios/PS/scph5500.bin", kept) + code, text, _kept = self.narrowed("minui", manifest=listing, + system=["nintendo-gba"]) + self.assertEqual(code, 1) + self.assertIn("records no systems, so --system cannot narrow it", text) + self.assertIn("Run it without --system, or narrow with --core and --region", text) + self.assertNotIn("one-line installer", text) + # The remedy followed: what it names works. + code, text, _kept = self.narrowed("minui", manifest=listing, core=["mgba"], + region=["us"]) + self.assertEqual(code, 0, text) + + def test_a_pack_names_its_list_only_when_that_list_records_it(self): + """tui_audit_11 P2-1: a MinUI pack sent --core to install/minui.json, + refused the same way, and a MiSTer pack to install/misterfpga.json, + which records no core. The pack now records what its list narrows + by, and each remedy changes the outcome.""" + listing, pack, listed = self.minui() + self.assertEqual(listed["list_records"], ["cores", "regions"]) + code, text, _kept = self.narrowed("minui", from_pack=[pack], core=["mgba"]) + self.assertEqual(code, 1) + self.assertIn("Add --manifest FILE, a copy of install/minui.json, which records them", + text) + code, text, kept = self.narrowed("minui", from_pack=[pack], manifest=listing, + core=["mgba"]) + self.assertEqual((code, kept), (0, ["Bios/MGBA/gba_bios.bin"]), text) + mister_list, mister, listed = self.minui("misterfpga", systems=True, cores=False, + regions=False) + self.assertEqual(listed["list_records"], ["regions", "systems"]) + code, text, _kept = self.narrowed("misterfpga", from_pack=[mister], core=["x"]) + self.assertEqual(code, 1) + self.assertIn("MiSTer FPGA's own file list records no cores either. Run it without " + "--core, or narrow with --system.", text) + self.assertNotIn("--manifest", text) + code, text, kept = self.narrowed("misterfpga", from_pack=[mister], + system=["nintendo-gba"]) + self.assertEqual((code, kept), (0, ["Bios/MGBA/gba_bios.bin"]), text) + self.assertTrue(mister_list.exists()) + + def test_line_mode_offers_what_can_be_chosen(self): + """tui_audit_11 P3-10: "c) choose systems, cores and regions" with a + pack that records systems only, and "Cores: 1) azahar" asked with + one answer.""" + _listing, _pack, listed = self.minui("misterfpga", systems=True, cores=False, + regions=False) + files = [{"dest": f["path"], "sha1": f["sha1"], "size": f["size"], + "repo_path": "x", "systems": f["systems"]} for f in listed["files"]] + self.assertEqual(install.narrowing_words(files, []), "systems") + listing, _pack, _listed = self.minui() + minui = json.loads(listing.read_text())["files"] + self.assertEqual(install.narrowing_words(minui, []), "cores and regions") + one_core = [{**entry, "systems": ["nintendo-3ds"], "cores": ["azahar"]} + for entry in minui[:1]] + with mock.patch.object(install, "_ask", side_effect=["", ""]) as asked, \ + contextlib.redirect_stdout(io.StringIO()) as out: + install._choose_narrowing(one_core, [], {}, None) + self.assertNotIn("Cores:", out.getvalue()) + self.assertEqual(asked.call_count, 0) + def test_the_card_marker_table_is_written_from_the_rules(self): """code_audit_18 P3-6, tui_audit_11 P2-2: the table left out the SHARE drives. It is generated: every card rule and SHARE drive file diff --git a/wiki/installer.md b/wiki/installer.md index 782bd92ad..779cc430f 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -615,7 +615,9 @@ It keeps what those cores read and drops the systems none of them emulates, the rule `--installed-cores` applies. `c` walks through three numbered lists, each answered with numbers such as -`1,4,7-9`, or Enter to keep everything on that axis. +`1,4,7-9`, or Enter to keep everything on that axis. The letter names the +lists it asks (`c) choose systems` with a MiSTer pack), and a list with one +answer is not asked. - **Systems.** The consoles and computers the platform declares, titled and ordered as the full screen's Choose systems lists them, the id `--system` @@ -637,6 +639,16 @@ without questions, every other option of the run in it, for instance --system sony-playstation --region north-america`. Enter then installs, and `c` chooses again. +An option is refused by the axis the list does not record, with what works +instead. The MinUI, NextUI and MyMinUI lists record cores and regions and no +system: `--core` and `--region` narrow them, `--system` is refused with +`Run it without --system, or narrow with --core and --region`. A pack records +the systems of its files and what its platform's list narrows by: with the +pack alone, `--core` or `--region` names that list (`--manifest FILE`, a copy +of `install/minui.json`) only when it records them, and MiSTer FPGA's pack +says its list records no cores. A list from an older release records none of +the three, and the one-line installer fetches the current one. + The same choices on the command line: ```bash From 2ab01d3eec5162590d86ad37fc48236c2fffdd55 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:39:50 +0100 Subject: [PATCH 05/13] fix: ignore Ctrl-Z when no shell can resume Started with no job-control shell, a Ctrl-Z stopped the screen and its launcher for good. The key now stops it only when its process group is a shell's job, and the bar says why otherwise. --- install.ps1 | 2 +- install.py | 22 +++++++- install.sh | 2 +- install/launchers/index.json | 16 +++--- tests/test_install_tui.py | 100 +++++++++++++++++++++++++++-------- wiki/installer.md | 5 +- 6 files changed, 112 insertions(+), 35 deletions(-) diff --git a/install.ps1 b/install.ps1 index ac037e590..19b3da3a3 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "00ad7b4c93b5d69e17943e904121485da924ee681c2796a6bbd45aae6052e2b5" + $defaultInstallSha256 = "3e97159b3ab76eb1f3cf748b1b47de23b1acf091899eca1dff1be1d2d9406dd4" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 38b6d8dfc..e9568eea3 100644 --- a/install.py +++ b/install.py @@ -17138,6 +17138,24 @@ def _closing_lines(state: TuiState) -> list[str]: return ["RetroBIOS: stopped. Nothing was installed."] +CTRL_Z_NO_SHELL = ("Ctrl-Z is ignored: no shell started this installer, and none could " + "bring it back.") + + +def tui_ctrl_z(state: TuiState) -> bool: + """Whether a Ctrl-Z typed on the screen stops it: only where a shell + with job control can resume it, the installer's process group being one + of its jobs, not its session's own. Started from a terminal app's -e, + a desktop entry or a launcher's sh -c, nothing ever sent SIGCONT, and + the screen and its launcher stayed stopped for good (code_audit_18 + P3-1). Otherwise the key is ignored and the bar says why.""" + if (hasattr(signal, "SIGSTOP") and hasattr(os, "getsid") + and os.getpgrp() != os.getsid(0)): + return True + state.status, state.status_more = CTRL_Z_NO_SHELL, "" + return False + + def tui_suspend(runs: list[_EngineRun], whole: bool) -> None: """Stop the screen and the runs it started until fg continues them. @@ -17309,8 +17327,8 @@ def run_tui(args: argparse.Namespace) -> int: for key in terminal.keys(TUI_FRAME): if key.name == "ctrl-z": # ISIG is off, so Ctrl-Z comes as a key: it stops what - # the terminal's would, where the system has job control. - if hasattr(signal, "SIGSTOP"): + # the terminal's would, where a shell can resume it. + if tui_ctrl_z(state): flags["suspend"] = "key" break continue diff --git a/install.sh b/install.sh index 28022ef1d..e90e5d961 100755 --- a/install.sh +++ b/install.sh @@ -18,7 +18,7 @@ esac TEMP_INSTALLER="" TEMP_DIRECTORY="" DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" -DEFAULT_INSTALL_SHA256="00ad7b4c93b5d69e17943e904121485da924ee681c2796a6bbd45aae6052e2b5" +DEFAULT_INSTALL_SHA256="3e97159b3ab76eb1f3cf748b1b47de23b1acf091899eca1dff1be1d2d9406dd4" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 250dd18c8..34cf8b91e 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 794767, - "sha256": "00ad7b4c93b5d69e17943e904121485da924ee681c2796a6bbd45aae6052e2b5", + "size": 795560, + "sha256": "3e97159b3ab76eb1f3cf748b1b47de23b1acf091899eca1dff1be1d2d9406dd4", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 847316, - "sha256": "56cac10af6b7afaac0d1b3c0964d54caeabda46e75d35580dcedda264e2daac8" + "size": 848109, + "sha256": "0a210d563bdb3a6f7ca87eaeb11d48a8ac07f1a1c580011f227fcccc5bba546e" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 794767, - "sha256": "00ad7b4c93b5d69e17943e904121485da924ee681c2796a6bbd45aae6052e2b5", + "size": 795560, + "sha256": "3e97159b3ab76eb1f3cf748b1b47de23b1acf091899eca1dff1be1d2d9406dd4", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 794767, - "sha256": "00ad7b4c93b5d69e17943e904121485da924ee681c2796a6bbd45aae6052e2b5", + "size": 795560, + "sha256": "3e97159b3ab76eb1f3cf748b1b47de23b1acf091899eca1dff1be1d2d9406dd4", "executable": false }, { diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 82632ca43..fe0da3021 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -5159,25 +5159,38 @@ class TheTerminalComesBack(unittest.TestCase): self.assertGivenBack(term) self.assertModesBack(term.modes) + def shell(self) -> Pty: + """An interactive bash on a pseudo-terminal, a shell with job control + as a person's terminal runs one.""" + self.repo() + env = {**self.env, "PS1": "PROMPT$ ", + "PATH": f"{Path(sys.executable).parent}{os.pathsep}{self.env.get('PATH', '')}"} + term = Pty(["--norc", "--noprofile", "-i"], env, program=["/bin/bash"]) + self.addCleanup(lambda: term.status is None and os.killpg(term.pid, 9)) + term.wait("PROMPT$") + return term + + def typed(self, term: Pty, line: str) -> None: + os.write(term.fd, line.encode() + b"\r") + + @unittest.skipUnless(Path("/bin/bash").exists(), "a shell with job control") def test_ctrl_z_then_fg(self): """ISIG is off on the screen, so Ctrl-Z reached it as a byte and did - nothing: the shell never got its terminal back.""" - import signal - - term = self.ended(lambda term: None) + nothing: the shell never got its terminal back. From a shell with + job control it stops, and fg brings the screen back.""" + term = self.shell() + self.typed(term, f"{shlex.quote(sys.executable)} {REPO_ROOT / 'install.py'} --tui") + term.wait("Where do you play?") mark = len(term.raw) term.send("\x1a") - end, stopped = time.monotonic() + 10, False - while time.monotonic() < end and not stopped: + end = time.monotonic() + 10 + while b"PROMPT$" not in term.raw[mark:] and time.monotonic() < end: term._pump(0.05) - pid, status = os.waitpid(term.pid, os.WNOHANG | os.WUNTRACED) - stopped = bool(pid) and os.WIFSTOPPED(status) - self.assertTrue(stopped, term.text()) + self.assertIn(b"Stopped", term.raw[mark:], term.raw[mark:][-300:]) self.assertGivenBack(term, mark) - self.assertModesBack(termios.tcgetattr(term.fd)) # fg: the shell's SIGCONT, and the screen is back, raw again. mark = len(term.raw) - os.kill(term.pid, signal.SIGCONT) + self.typed(term, "fg") end = time.monotonic() + 5 while b"\x1b[?1049h" not in term.raw[mark:] and time.monotonic() < end: term._pump(0.05) @@ -5185,9 +5198,43 @@ class TheTerminalComesBack(unittest.TestCase): term.wait("Where do you play?") self.assertFalse(termios.tcgetattr(term.fd)[3] & termios.ECHO) term.send("q") + term.wait("PROMPT$") + self.typed(term, "exit") self.assertEqual(term.close(), 130) self.assertGivenBack(term) - self.assertModesBack(term.modes) + + def test_ctrl_z_with_no_shell_to_resume_it_is_ignored(self): + """code_audit_18 P3-1: started straight on a terminal (a terminal + app's -e, a desktop entry) or under a launcher's sh -c, nothing ever + resumed the screen a Ctrl-Z stopped. It now goes on, and says why.""" + self.repo() + for program in (None, ["/bin/sh", "-c", f"{shlex.quote(sys.executable)} " + f"{REPO_ROOT / 'install.py'} \"$@\"", "sh"]): + with self.subTest(program=program): + term = Pty(["--tui"], self.env, program=program) + self.addCleanup(lambda term=term: term.status is None + and os.killpg(term.pid, 9)) + term.wait("Where do you play?") + term.send("\x1a") + screen = term.wait("Ctrl-Z is ignored") + self.assertIn("no shell started this installer", " ".join(screen.split())) + pid, status = os.waitpid(term.pid, os.WNOHANG | os.WUNTRACED) + self.assertEqual(pid, 0, "stopped") + term.send("q") + self.assertEqual(term.close(), 130) + self.assertGivenBack(term) + + def test_ctrl_z_needs_a_shell_that_can_resume(self): + state = install.TuiState(os_type="linux", home=HOME) + with mock.patch.object(install.os, "getpgrp", return_value=10), \ + mock.patch.object(install.os, "getsid", return_value=10): + self.assertFalse(install.tui_ctrl_z(state)) + self.assertEqual(state.status, install.CTRL_Z_NO_SHELL) + state.status = "" + with mock.patch.object(install.os, "getpgrp", return_value=12), \ + mock.patch.object(install.os, "getsid", return_value=10): + self.assertTrue(install.tui_ctrl_z(state)) + self.assertEqual(state.status, "") @staticmethod def proc_state(pid: int) -> str: @@ -5217,27 +5264,34 @@ class TheTerminalComesBack(unittest.TestCase): import signal repo = self.repo(GameDataRepository, slow=True) - self.env["PATH"] = f"{Path(sys.executable).parent}{os.pathsep}{self.env['PATH']}" - term = Pty(["--jobs", "1"], self.env, program=["/bin/sh", str(REPO_ROOT / "install.sh")]) + env = {**self.env, "PS1": "PROMPT$ ", + "PATH": f"{Path(sys.executable).parent}{os.pathsep}{self.env['PATH']}"} + # Typed at an interactive bash, as a person runs it: install.sh is a + # job of a shell that can resume it (code_audit_18 P3-1). + term = Pty(["--norc", "--noprofile", "-i"], env, program=["/bin/bash"]) self.addCleanup(lambda: term.status is None and os.killpg(term.pid, 9)) + term.wait("PROMPT$") + self.typed(term, f"sh {REPO_ROOT / 'install.sh'} --jobs 1") term.wait("Where do you play?") term.send("\r") term.wait("to download") term.send("\r") term.wait("Installing") time.sleep(0.8) - installer = self.children(term.pid) + wrapper = self.children(term.pid) + self.assertEqual(len(wrapper), 1, wrapper) + installer = self.children(wrapper[0]) self.assertEqual(len(installer), 1, installer) runs = self.children(installer[0]) self.assertTrue(runs, "no install run") + mark = len(term.raw) term.send("\x1a") - end, stopped = time.monotonic() + 10, False - while time.monotonic() < end and not stopped: + end = time.monotonic() + 10 + while b"PROMPT$" not in term.raw[mark:] and time.monotonic() < end: term._pump(0.05) - pid, status = os.waitpid(term.pid, os.WNOHANG | os.WUNTRACED) - stopped = bool(pid) and os.WIFSTOPPED(status) - # The wrapper is stopped: a shell's job of it is reported stopped. - self.assertTrue(stopped, term.text()) + # The wrapper is stopped: the shell reports its job stopped. + self.assertIn(b"Stopped", term.raw[mark:], term.raw[mark:][-300:]) + self.assertEqual(self.proc_state(wrapper[0]), "T") self.assertEqual(self.proc_state(installer[0]), "T") self.assertEqual([self.proc_state(run) for run in runs], ["T"] * len(runs)) written = sorted(repo.dest.rglob("*")) @@ -5246,13 +5300,15 @@ class TheTerminalComesBack(unittest.TestCase): # fg continues the job's group: the screen comes back and the install # carries on to its end. mark = len(term.raw) - os.killpg(term.pid, signal.SIGCONT) + self.typed(term, "fg") end = time.monotonic() + 5 while b"\x1b[?1049h" not in term.raw[mark:] and time.monotonic() < end: term._pump(0.05) self.assertIn(b"\x1b[?1049h", term.raw[mark:]) term.wait("files installed", timeout=30) term.send("\r") + term.wait("PROMPT$") + self.typed(term, "exit") self.assertEqual(term.close(), 0) diff --git a/wiki/installer.md b/wiki/installer.md index 779cc430f..6ee5aac62 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -217,7 +217,10 @@ However the screen ends (Enter on the result, `q`, Ctrl+C, Ctrl+D, SIGTERM, a hangup), the terminal is given back as the screen found it: the cursor shown, the main screen back, echo and line editing on. Ctrl+Z, or SIGTSTP from outside, gives it back the same way and stops the run, and `fg` redraws the -screen where it was. +screen where it was. Ctrl+Z stops it only when a shell with job control +started it, the one thing that can resume it: started from a terminal app's +`-e`, a desktop entry or a launcher's `sh -c`, the key is ignored and the bar +says why. - `--tui` asks for the full screen, and names the reason when it cannot open (code 2). From 992bc8ce70a51e6a3655e73b798825b9c772e137 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:50:51 +0100 Subject: [PATCH 06/13] fix: keep paths whole and spelled one way A Termux run named /sdcard and /storage/emulated/0 for one storage, read saves/ as a folder, split a backup path and the restore command inside their words and named no folder for missing game data. Paths are spelled as the BIOS folder was named, folders per core are said in words, the result keeps paths and commands whole, and the game data line names the folder it goes in. --- install.ps1 | 2 +- install.py | 198 ++++++++++++++---- install.sh | 2 +- install/launchers/index.json | 16 +- tests/fixtures/tui/result-40x12.txt | 4 +- tests/fixtures/tui/result-40x15.txt | 8 +- tests/fixtures/tui/route-evmapy.txt | 8 +- tests/fixtures/tui/route-gptokeyb.txt | 8 +- tests/fixtures/tui/route-gptokeyb_muxterm.txt | 8 +- tests/fixtures/tui/route-keys.txt | 12 +- tests/fixtures/tui/route-miyoo.txt | 8 +- tests/fixtures/tui/route-muxterm.txt | 8 +- tests/test_install_tui.py | 122 ++++++++++- wiki/installer.md | 9 +- 14 files changed, 324 insertions(+), 89 deletions(-) diff --git a/install.ps1 b/install.ps1 index 19b3da3a3..ea43715cf 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "3e97159b3ab76eb1f3cf748b1b47de23b1acf091899eca1dff1be1d2d9406dd4" + $defaultInstallSha256 = "c3dcd0682ff917866b79f7718de048e16f13d03651111601e1816ec49f5703cb" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index e9568eea3..4178c897b 100644 --- a/install.py +++ b/install.py @@ -940,7 +940,7 @@ def _host_path(text: str) -> Path: def shown_path(path: Path | str) -> str: """A path as the person names it: under WSL, /mnt/d/BIOS is D:\\BIOS, the drive the card row calls D:.""" - text = str(path) + text = str(spelled(path)) if os.path.isabs(str(path)) else str(path) root = _WSL["root"].rstrip("/") + "/" match = (re.match(rf"^{re.escape(root)}([a-z])(?:/(.*))?$", text) if _WSL["active"] else None) @@ -2820,6 +2820,38 @@ def _record_moved_folders(root: Path, retrodeck_json: Path) -> None: _MOVED_FOLDERS[root] = moved +# Resolved folder -> the same folder as the run names it: /sdcard is a link +# to /storage/emulated/0 on Android, and one result named the BIOS folder +# /sdcard/RetroArch/system and its backup in /storage/emulated/0/RetroArch. +_SPELLED: dict[Path, Path] = {} + + +def _note_spelling(root: Path) -> Path: + """root resolved, its own spelling recorded for every path shown below + the link it goes through (spelled).""" + given, resolved = Path(os.path.abspath(root)), root.resolve() + named, real = given, resolved + while named.name == real.name and named != named.parent and real != real.parent: + named, real = named.parent, real.parent + if named != real: + try: + if Path(os.path.realpath(named)) == real: + _SPELLED[real] = named + except (OSError, ValueError): + pass + return resolved + + +def spelled(path: Path | str) -> Path: + """path in the form the run names its folders: below a link the BIOS + folder was named through, the link's name (_note_spelling).""" + path = Path(path) + for real in sorted(_SPELLED, key=lambda each: len(each.parts), reverse=True): + if path == real or real in path.parents: + return _SPELLED[real] / path.relative_to(real) + return path + + def _destination_path(root: Path, value: object) -> Path: """Place a manifest destination below *root*. @@ -2833,7 +2865,7 @@ def _destination_path(root: Path, value: object) -> Path: moved = _MOVED_FOLDERS.get(root, {}) if len(relative.parts) > 1 and relative.parts[0] in moved: return moved[relative.parts[0]].resolve() / Path(*relative.parts[1:]) - return root.resolve() / Path(*relative.parts) + return _note_spelling(root) / Path(*relative.parts) class SavePlace(NamedTuple): @@ -4329,7 +4361,9 @@ def place_save_entries( place = save_directory_for(bios_path, manifest, override) if isinstance(place, SavePlace): _SAVE_DIRS[bios_path] = place - where = str(place.directory) + # Named through the link the BIOS folder was named through. + _note_spelling(bios_path) + where = str(spelled(place.directory)) if (manifest.get("save_directory") or {}).get("by_system"): where += f"{os.sep}" if place.by_core: @@ -4736,7 +4770,9 @@ class KeptFiles: def summary(self) -> list[dict]: """[{folder, files, bytes}], one per run folder, for the report and events.""" with self._lock: - return [{"folder": str(run), "files": files, "bytes": size} + # Named as the run names its BIOS folder: /sdcard, not the + # /storage/emulated/0 the link resolves to. + return [{"folder": str(spelled(run)), "files": files, "bytes": size} for run, (files, size) in sorted(self.counts.items())] @@ -4781,7 +4817,7 @@ def _kept_slot(root: Path, value: object) -> tuple[Path, PurePosixPath]: moved = _MOVED_FOLDERS.get(root, {}) if len(relative.parts) > 1 and relative.parts[0] in moved: return moved[relative.parts[0]].resolve(), PurePosixPath(*relative.parts[1:]) - return root.resolve(), relative + return _note_spelling(root), relative def plan_kept(entries: list[dict], root: Path) -> dict[Path, list[int]]: @@ -5721,7 +5757,8 @@ def _report_outcome( if omitted > len(unplaced): for line in _omission_lines(omitted - len(unplaced), [entry for entry in omitted_entries or [] - if entry.get("reason") != "no_save_directory"]): + if entry.get("reason") != "no_save_directory"], + Path(paths[0]) if len(paths) == 1 else None): print(line) for line in hints or []: print(line) @@ -5760,18 +5797,47 @@ def _offline_failure(reason: str) -> bool: return OFFLINE_WORDS in reason or "makes no network request" in reason -def _omission_lines(count: int, entries: list[dict]) -> list[str]: +# What game data is, said wherever it is counted or chosen. +GAME_DATA_WORDS = "game data: files a game or a game engine reads, of no use to the other cores" + + +def game_data_missing(names: str, count: int, folders: list[Path]) -> str: + """The game data a run could not supply, and the folder each goes in: + "Copy it from your own copy of the game" named no folder.""" + one = count == 1 + places = list(dict.fromkeys(f"{shown_path(folder)}{os.sep}" for folder in folders)) + where = (f" into {', '.join(places[:3])}" + (f" and {len(places) - 3} more folders" + if len(places) > 3 else "")) if places else "" + return (f"Not available here, {GAME_DATA_WORDS}, read by " + f"{'its game' if one else 'their games'}: {names}. Copy " + f"{'it' if one else 'them'} from your own copy of the game{where}.") + + +def _omission_lines(count: int, entries: list[dict], bios_path: Path | None = None) -> list[str]: """What was skipped, the files an emulator cannot start without first. "395 file are not in the collection yet" named nothing and gave one - reason for all, while the manifest says which file and why. + reason for all, while the manifest says which file and why. Game data + is said apart, with the folder it goes in, as the screen says it. """ one = count == 1 lines = [f"{_plural(count, 'file')} {'was' if one else 'were'} not installed: " f"the collection does not hold {'it' if one else 'them'}, or its copy " f"fails {'its' if one else 'their'} check."] - lines += _needed_lines([e for e in entries if e.get("required")], + needed = [e for e in entries if e.get("required")] + games = [e for e in needed if e.get("category") == GAME_DATA] + lines += _needed_lines([e for e in needed if e.get("category") != GAME_DATA], "Needed to start an emulator") + if games: + names = ", ".join(str(e.get("dest") or e.get("name")) for e in games[:3]) + ( + f" and {len(games) - 3} more" if len(games) > 3 else "") + folders = [] + for entry in games: + try: + folders.append(_entry_path(bios_path, entry).parent if bios_path else None) + except ValueError: + continue + lines.append(" " + game_data_missing(names, len(games), [f for f in folders if f])) return lines @@ -7165,10 +7231,32 @@ def space_words(needed: int, free: int, card: bool) -> str: "choose another folder.") +_SAVE_SPLITS = {(True, True): "a folder per system, then one per core, under", + (True, False): "a folder per system under", + (False, True): "a folder per core under"} + + +def save_where_words(where: str) -> tuple[str, str]: + """(words, folder) for where the saves event says files go: the folder + without the and it ends with, and those said in words. + "go to: /sdcard/RetroArch/saves/" read as a folder named .""" + parts = re.split(r"[\\/]", where) + by_core = bool(parts) and parts[-1] == "" + if by_core: + parts = parts[:-1] + by_system = bool(parts) and parts[-1] == "" + if by_system: + parts = parts[:-1] + folder = where[:len(os.sep.join(parts))] if (by_core or by_system) else where + return _SAVE_SPLITS.get((by_system, by_core), ""), folder + + def saves_line(display: str, count: int, where: str) -> str: """Where the files read from the frontend's save directory go.""" + words, folder = save_where_words(where) return (f"{_plural(count, 'file')} read from {display}'s save directory " - f"{'goes' if count == 1 else 'go'} to {where}") + f"{'goes' if count == 1 else 'go'} to {words + ' ' if words else ''}" + f"{shown_path(folder)}") def unplaced_line(display: str, count: int, remedy: str) -> str: @@ -12477,15 +12565,27 @@ def _glued_words(text: str) -> list[str]: return words -def _wrap(text: str, width: int, indent: str = " ") -> list[str]: - """Lines of at most width cells, the ones after the first indented.""" +# A shell word: quoted parts kept with what they quote. +_SHELL_WORD = re.compile(r"""(?:'[^']*'|"[^"]*"|[^\s'"]+|['"])+""") + + +def _wrap(text: str, width: int, indent: str = " ", whole: str = "") -> list[str]: + """Lines of at most width cells, the ones after the first indented. + + whole keeps every word whole, a longer one alone on a line wider than + width, which the terminal folds and a copy keeps in one piece: "text" + for prose and paths, "command" for a command, whose words are shell + words. A restore command was drawn split inside its URL and its folder, + and a backup folder's dated name fell on a line of its own.""" if cell_width(text) <= width: return [text] lead = text[:len(text) - len(text.lstrip(" "))] lines: list[str] = [] line = "" - for word in _glued_words(text): - for index, piece in enumerate(_split_word(word, width - len(lead + indent))): + words = (_SHELL_WORD.findall(text) if whole == "command" else _glued_words(text)) + for word in words: + pieces = [word] if whole else _split_word(word, width - len(lead + indent)) + for index, piece in enumerate(pieces): prefix = lead + indent if lines else lead # The rest of a word broken at a slash joins it without a space: # a path was drawn "retroarch/ system", and copied wrong. @@ -13435,7 +13535,7 @@ def _save_folder_words(state: TuiState) -> str: if given is not None: return _short_path(given, state.home) if state.saves and state.saves.get("folder"): - return _short_path(Path(state.saves["folder"]), state.home) + return _short_path(Path(save_where_words(state.saves["folder"])[1]), state.home) return "not known" @@ -15277,19 +15377,34 @@ def _row_line(row: TuiRow, focused: bool, width: int) -> TuiLine: return _styled(text, "focus" if focused else row.style) -def _text_lines(items: list, width: int) -> list[TuiLine]: +def _text_lines(items: list, width: int, whole: bool = False) -> list[TuiLine]: + """Items as screen lines. whole keeps every word, path and command + whole (_wrap): a line wider than width is drawn as it is, the terminal + folding it, with the rows it takes after it left to it ("cont").""" lines: list[TuiLine] = [] for item in items: style = getattr(item, "style", "") - if style == "path": + if style == "path" and not whole: # One line, cut in its middle: the end of a path tells it apart. lines.append(_styled(middle(str(item), width))) continue - for piece in _wrap(str(item), width): - lines.append(_styled(fit(piece, width), style)) + kind = ("command" if style == "command" else "text") if whole else "" + for piece in _wrap(str(item), width, whole=kind): + if cell_width(piece) <= width: + lines.append(_styled(fit(piece, width), "" if style in ("path", "command") + else style)) + continue + lines.append(_styled(piece, "long")) + lines += [_styled("", "cont")] * (-(-cell_width(piece) // width) - 1) return lines +def _whole_words(state: TuiState) -> bool: + """Whether a pager keeps paths and commands whole: the result and the + command line, the screens a person copies from.""" + return state.screen in ("result", "command") + + def _scrolled(lines: list[TuiLine], first: int, height: int, lead: int = 0) -> list[TuiLine]: """height lines of a longer list from first, the last one saying where. The lead rows (All, None, Filter) are not counted among the entries.""" @@ -15367,15 +15482,16 @@ def _pager( """ starts = _pager_starts(state, items, width) total = starts[-1] + whole = _whole_words(state) if total <= height: - return _text_lines(items, width) + return _text_lines(items, width, whole) room = max(1, height - 1) first = max(0, min(state.focus.get(state.screen, 0), total - room)) index = bisect.bisect_right(starts, first) - 1 offset = first - starts[index] lines: list[TuiLine] = [] while index < len(items) and starts[index] < first + room: - lines += _text_lines([items[index]], width) + lines += _text_lines([items[index]], width, whole) index += 1 skip = starts[lead] if lead else 0 start = max(1, first + 1 - skip) @@ -15387,13 +15503,15 @@ def _pager_starts(state: TuiState, items: list, width: int) -> list[int]: """The line each item starts on once wrapped at width, and the total last, kept while the items stay the same. An item that fits is one line without being wrapped.""" - key = (width, [(str(item), getattr(item, "style", "")) for item in items]) + whole = _whole_words(state) + key = (width, whole, [(str(item), getattr(item, "style", "")) for item in items]) if state.pager_cache is not None and state.pager_cache[0] == key: return state.pager_cache[1] starts = [0] for item in items: - fits = getattr(item, "style", "") == "path" or cell_width(str(item)) <= width - starts.append(starts[-1] + (1 if fits else len(_text_lines([item], width)))) + fits = ((getattr(item, "style", "") == "path" and not whole) + or cell_width(str(item)) <= width) + starts.append(starts[-1] + (1 if fits else len(_text_lines([item], width, whole)))) state.pager_cache = (key, starts) return starts @@ -15596,11 +15714,12 @@ def _saves_lines(state: TuiState, saves: dict | None) -> list[str]: return [] name = display_name(state.platform) if state.platform else "the frontend" count = saves["files"] + words, folder = save_where_words(saves["folder"]) # The folder alone is cut in the middle: cut with the sentence, it # lost "directory goes to" even at 80 columns. return [f"{_plural(count, 'file')} read from {name}'s save directory " - f"{'goes' if count == 1 else 'go'} to:", - _short_path(Path(saves['folder']), state.home)] + f"{'goes' if count == 1 else 'go'} to{' ' + words if words else ''}:", + _short_path(Path(folder), state.home)] def _unplaced_lines(summary: dict | None, state: TuiState, @@ -15984,7 +16103,7 @@ def _pager_items(state: TuiState) -> list: if state.screen == "result": return tui_outcome(state)[1] if state.screen == "command": - return ["The same choice without questions:", "", tui_command(state)] + return ["The same choice without questions:", "", _styled(tui_command(state), "command")] if state.running: items = ["Counting the files for these choices..."] elif state.dry is None: @@ -16198,10 +16317,8 @@ def _unavailable_lines(state: TuiState) -> list[str]: f"{'its emulator' if len(firmware) == 1 else 'their emulators'}: " f"{named(firmware)}. Your own copy goes in the BIOS folder.") if games: - lines.append(f"Game data not available here, read by " - f"{'its game' if len(games) == 1 else 'their games'}: " - f"{named(games)}. Copy {'it' if len(games) == 1 else 'them'} from " - f"your own copy of the game.") + lines.append(game_data_missing(named(games), len(games), [ + (state.dest / entry["dest"]).parent for entry in games] if state.dest else [])) return lines @@ -16309,8 +16426,9 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: for kept in summary.get("kept") or []: lines.append(f"The files it replaced are kept in {kept['folder']}") if typed: - lines.append(f"To put them back: {run.launcher()} --restore " - f"{run.word(str(kept['folder']))}") + # Broken between its words only, never inside one (_wrap). + lines.append(_styled(f"To put them back: {run.launcher()} --restore " + f"{run.word(str(kept['folder']))}", "command")) if summary and not summary.get("interrupted") and installed + up_to_date: lines += _unavailable_lines(state) for group in summary.get("left_out") or []: @@ -16318,7 +16436,7 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: lines += _unplaced_lines(summary, state) lines += _tui_app_lines(state) if typed: - lines += ["Same choice without questions:", f" {tui_command(state)}"] + lines += ["Same choice without questions:", _styled(f" {tui_command(state)}", "command")] if tui_keeps_log(state): lines.append(f"Details: {state.log}") return title, lines @@ -16527,7 +16645,10 @@ def tui_view(state: TuiState, cols: int, rows: int) -> list[str]: hint = ("Enter Done", "Esc Clear") hint = _route_hint(state, _back_hint(state, hint)) lines = [_styled(fit(f" RetroBIOS: {title}", cols), "title"), _styled("")] - lines += [_styled(fit(" " + line, cols), line.style) for line in body[:height]] + # A long line is drawn whole, the terminal folding it into the rows + # after it (_text_lines): cut to the width, a copied path lost its end. + lines += [_styled(" " + line if line.style == "long" else fit(" " + line, cols), line.style) + for line in body[:height]] lines += [_styled("")] * (rows - 1 - len(lines)) # Never the last cell of the screen: a terminal may scroll when it is written. lines.append(_styled(fit(" " + _hint(width, *hint), cols - 1), "dim")) @@ -16970,7 +17091,14 @@ class _TuiTerminal: """Rewrite the lines that changed since the last frame, and only those.""" out = [] if self.frame else ["\x1b[H\x1b[2J"] frame = [(str(line), getattr(line, "style", "")) for line in lines] + covered = 0 for index, (text, style) in enumerate(frame): + if style == "cont" and covered: + # A row the long line above folds into. + covered -= 1 + continue + covered = (max(0, -(-cell_width(text) // max(1, cols)) - 1) + if style == "long" else 0) if index < len(self.frame) and self.frame[index] == (text, style): continue text = screen_text(text) diff --git a/install.sh b/install.sh index e90e5d961..cdfb8b88d 100755 --- a/install.sh +++ b/install.sh @@ -18,7 +18,7 @@ esac TEMP_INSTALLER="" TEMP_DIRECTORY="" DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" -DEFAULT_INSTALL_SHA256="3e97159b3ab76eb1f3cf748b1b47de23b1acf091899eca1dff1be1d2d9406dd4" +DEFAULT_INSTALL_SHA256="c3dcd0682ff917866b79f7718de048e16f13d03651111601e1816ec49f5703cb" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 34cf8b91e..e0f0eee10 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 795560, - "sha256": "3e97159b3ab76eb1f3cf748b1b47de23b1acf091899eca1dff1be1d2d9406dd4", + "size": 801988, + "sha256": "c3dcd0682ff917866b79f7718de048e16f13d03651111601e1816ec49f5703cb", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 848109, - "sha256": "0a210d563bdb3a6f7ca87eaeb11d48a8ac07f1a1c580011f227fcccc5bba546e" + "size": 854537, + "sha256": "1ed15a4a76d79f5dcd3f9f53173721e3666b159defc49adbcd6622cb252d21a8" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 795560, - "sha256": "3e97159b3ab76eb1f3cf748b1b47de23b1acf091899eca1dff1be1d2d9406dd4", + "size": 801988, + "sha256": "c3dcd0682ff917866b79f7718de048e16f13d03651111601e1816ec49f5703cb", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 795560, - "sha256": "3e97159b3ab76eb1f3cf748b1b47de23b1acf091899eca1dff1be1d2d9406dd4", + "size": 801988, + "sha256": "c3dcd0682ff917866b79f7718de048e16f13d03651111601e1816ec49f5703cb", "executable": false }, { diff --git a/tests/fixtures/tui/result-40x12.txt b/tests/fixtures/tui/result-40x12.txt index b7b5d8c6b..ea7a3e5f6 100644 --- a/tests/fixtures/tui/result-40x12.txt +++ b/tests/fixtures/tui/result-40x12.txt @@ -5,8 +5,8 @@ /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) The files it replaced are kept in - /home/user/.config/retroarch/ - retrobios-backup/2026-10-23_1200 + /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 + To put them back: python3 install.py 1-8 of 17 A/Enter Exit Up/Down Scroll diff --git a/tests/fixtures/tui/result-40x15.txt b/tests/fixtures/tui/result-40x15.txt index 60138766a..683877220 100644 --- a/tests/fixtures/tui/result-40x15.txt +++ b/tests/fixtures/tui/result-40x15.txt @@ -5,11 +5,11 @@ /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) The files it replaced are kept in - /home/user/.config/retroarch/ - retrobios-backup/2026-10-23_1200 + /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 + To put them back: python3 install.py --restore - /home/user/.config/retroarch/ - retrobios-backup/2026-10-23_1200 + /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 + 1-11 of 17 A/Enter Exit Up/Down Scroll diff --git a/tests/fixtures/tui/route-evmapy.txt b/tests/fixtures/tui/route-evmapy.txt index 21813258f..4feac0d4a 100644 --- a/tests/fixtures/tui/route-evmapy.txt +++ b/tests/fixtures/tui/route-evmapy.txt @@ -978,8 +978,8 @@ /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) The files it replaced are kept in - /home/user/.config/retroarch/ - retrobios-backup/2026-10-23_1200 + /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 + Details: /tmp/retrobios-x/install.log A Exit @@ -991,8 +991,8 @@ /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) The files it replaced are kept in - /home/user/.config/retroarch/ - retrobios-backup/2026-10-23_1200 + /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 + Details: /tmp/retrobios-x/install.log diff --git a/tests/fixtures/tui/route-gptokeyb.txt b/tests/fixtures/tui/route-gptokeyb.txt index cf6839b66..c36055601 100644 --- a/tests/fixtures/tui/route-gptokeyb.txt +++ b/tests/fixtures/tui/route-gptokeyb.txt @@ -978,8 +978,8 @@ /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) The files it replaced are kept in - /home/user/.config/retroarch/ - retrobios-backup/2026-10-23_1200 + /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 + Details: /tmp/retrobios-x/install.log A Exit @@ -991,8 +991,8 @@ /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) The files it replaced are kept in - /home/user/.config/retroarch/ - retrobios-backup/2026-10-23_1200 + /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 + Details: /tmp/retrobios-x/install.log diff --git a/tests/fixtures/tui/route-gptokeyb_muxterm.txt b/tests/fixtures/tui/route-gptokeyb_muxterm.txt index cf6839b66..c36055601 100644 --- a/tests/fixtures/tui/route-gptokeyb_muxterm.txt +++ b/tests/fixtures/tui/route-gptokeyb_muxterm.txt @@ -978,8 +978,8 @@ /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) The files it replaced are kept in - /home/user/.config/retroarch/ - retrobios-backup/2026-10-23_1200 + /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 + Details: /tmp/retrobios-x/install.log A Exit @@ -991,8 +991,8 @@ /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) The files it replaced are kept in - /home/user/.config/retroarch/ - retrobios-backup/2026-10-23_1200 + /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 + Details: /tmp/retrobios-x/install.log diff --git a/tests/fixtures/tui/route-keys.txt b/tests/fixtures/tui/route-keys.txt index 6ec4b4720..ce2c5937e 100644 --- a/tests/fixtures/tui/route-keys.txt +++ b/tests/fixtures/tui/route-keys.txt @@ -924,8 +924,8 @@ /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) The files it replaced are kept in - /home/user/.config/retroarch/ - retrobios-backup/2026-10-23_1200 + /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 + To put them back: python3 install.py 1-8 of 17 Enter Exit Up/Down Scroll @@ -937,12 +937,12 @@ /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) The files it replaced are kept in - /home/user/.config/retroarch/ - retrobios-backup/2026-10-23_1200 + /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 + To put them back: python3 install.py --restore - /home/user/.config/retroarch/ - retrobios-backup/2026-10-23_1200 + /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 + 1-11 of 17 Enter Exit Up/Down Scroll === result 80x24 diff --git a/tests/fixtures/tui/route-miyoo.txt b/tests/fixtures/tui/route-miyoo.txt index 1b33c6920..7a1554714 100644 --- a/tests/fixtures/tui/route-miyoo.txt +++ b/tests/fixtures/tui/route-miyoo.txt @@ -978,8 +978,8 @@ /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) The files it replaced are kept in - /home/user/.config/retroarch/ - retrobios-backup/2026-10-23_1200 + /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 + Details: /tmp/retrobios-x/install.log A Exit @@ -991,8 +991,8 @@ /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) The files it replaced are kept in - /home/user/.config/retroarch/ - retrobios-backup/2026-10-23_1200 + /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 + Details: /tmp/retrobios-x/install.log diff --git a/tests/fixtures/tui/route-muxterm.txt b/tests/fixtures/tui/route-muxterm.txt index 5e414ed37..e754f340f 100644 --- a/tests/fixtures/tui/route-muxterm.txt +++ b/tests/fixtures/tui/route-muxterm.txt @@ -978,8 +978,8 @@ /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) The files it replaced are kept in - /home/user/.config/retroarch/ - retrobios-backup/2026-10-23_1200 + /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 + Details: /tmp/retrobios-x/install.log Start Exit @@ -991,8 +991,8 @@ /home/user/.config/retroarch/system failed: scph5500.bin (HTTP 404) The files it replaced are kept in - /home/user/.config/retroarch/ - retrobios-backup/2026-10-23_1200 + /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 + Details: /tmp/retrobios-x/install.log diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index fe0da3021..7779ce94a 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -970,7 +970,9 @@ class Views(unittest.TestCase): lines = install.tui_view(state, cols, rows) self.assertEqual(len(lines), rows) for line in lines: - self.assertLessEqual(install.cell_width(line), cols, line) + # A path or a command kept whole folds in the terminal. + if line.style != "long": + self.assertLessEqual(install.cell_width(line), cols, line) self.assertTrue(all(ord(c) < 128 or c in "ファミコン" for c in line), line) self.assertLess(install.cell_width(lines[-1]), cols) @@ -993,7 +995,8 @@ class Views(unittest.TestCase): state.cols, state.rows = cols, rows lines = install.tui_view(state, cols, rows) for line in lines: - self.assertLessEqual(install.cell_width(line), cols, line) + if line.style != "long": + self.assertLessEqual(install.cell_width(line), cols, line) self.assertLess(install.cell_width(lines[-1]), cols) text = "\n".join(line.rstrip() for line in lines) + "\n" both = (GOLDEN / f"{name}-{cols}x{rows}.txt").read_text(encoding="utf-8") @@ -1763,9 +1766,9 @@ class AuditRoundTwo(unittest.TestCase): real = install._text_lines calls = [] - def counted(items, width): + def counted(items, width, whole=False): calls.append(len(items)) - return real(items, width) + return real(items, width, whole) with mock.patch.object(install, "_text_lines", counted): press(state, "end") @@ -3438,11 +3441,14 @@ class ThePagerCountsLines(unittest.TestCase): with self.subTest(size=f"{cols}x{rows}"): state = self.result_state(cols, rows) width = min(cols, install.TUI_MAX_WIDTH) - 2 - wrapped = install._text_lines(install._pager_items(state), width) + # The result keeps a path whole (whole=True): its rows after + # it are left to the terminal's folding ("cont"). + wrapped = install._text_lines(install._pager_items(state), width, True) + last = [line for line in wrapped if line.style != "cont"][-1] press(state, "end") screen = install.tui_view(state, cols, rows) body = [line.rstrip() for line in screen[2:-1]] - self.assertIn(" " + str(wrapped[-1]).rstrip(), body) + self.assertIn(" " + str(last).rstrip(), body) if len(wrapped) > rows - 3: self.assertTrue(any(re.fullmatch(rf" \d+-{len(wrapped)} of {len(wrapped)}", line) for line in body), body) @@ -3581,8 +3587,9 @@ class AuditFive(unittest.TestCase): state = checked_state() saves = {"files": 1, "folder": str(HOME / ".config" / "retroarch" / "saves" / "")} lines = install._saves_lines(state, saves) - self.assertEqual(lines, ["1 file read from RetroArch's save directory goes to:", - "~/.config/retroarch/saves/"]) + # In words: "saves/" read as a folder named . + self.assertEqual(lines, ["1 file read from RetroArch's save directory goes to a " + "folder per core under:", "~/.config/retroarch/saves"]) def test_the_shared_note_is_one_line(self): state = self.long_list() @@ -3798,7 +3805,8 @@ class TheSaveFolderLineFollowsTheChoice(unittest.TestCase): SAVES = {"event": "saves", "platform": "retroarch", "files": 1, "folder": "/home/user/MySaves/"} - LINE = "1 file read from RetroArch's save directory goes to: ~/MySaves/" + LINE = ("1 file read from RetroArch's save directory goes to a folder per core under: " + "~/MySaves") def said(self, lines) -> str: return " ".join(" ".join(lines).split()) @@ -5403,7 +5411,11 @@ class GameDataIsNoFirmwareAlarm(unittest.TestCase): self.assertEqual(len(lines), 2, lines) self.assertIn("needed to start its emulator: missing.bin", lines[0]) self.assertNotIn("Anims.j2a", lines[0]) - self.assertIn("Game data not available here", lines[1]) + # The folder it goes in, and what game data is (the Android run of + # 2026-10-27 said neither). + self.assertIn("Not available here, game data: files a game or a game engine reads", + lines[1]) + self.assertIn(f"into {SYSTEM_DIR / 'jazz2' / 'Source'}{os.sep}", lines[1]) self.assertIn("Anims.j2a (Jazz Jackrabbit 2)", lines[1]) @@ -8868,6 +8880,96 @@ class AuditEighteenAndEleven(unittest.TestCase): self.assertIn("Recalbox SHARE drive", block) self.assertEqual(generate_installer_doc.render(page), page) +class TheAndroidResultReadsWhole(unittest.TestCase): + """A run on Android 17 under Termux: the result named /sdcard and + /storage/emulated/0 for one storage, saves/ read as a folder named + , a backup path and the restore command were split inside a word, + and the game data line named no folder.""" + + def setUp(self): + (REPO_ROOT / "tmp").mkdir(exist_ok=True) + self.root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, self.root, True) + for patcher in (mock.patch.dict(install._SPELLED, {}, clear=True),): + patcher.start() + self.addCleanup(patcher.stop) + + def test_one_storage_is_named_one_way(self): + real = self.root / "storage" / "emulated" / "0" + (real / "RetroArch" / "system").mkdir(parents=True) + link = self.root / "sdcard" + link.symlink_to(real) + bios = link / "RetroArch" / "system" + self.assertEqual(install._destination_path(bios, "a.bin"), + real.resolve() / "RetroArch" / "system" / "a.bin") + kept = install.KeptFiles() + run = kept.plan(install._kept_slot(bios, "a.bin")[0], 3) + folder = kept.summary()[0]["folder"] + self.assertTrue(folder.startswith(str(link / "RetroArch" / "retrobios-backup")), + folder) + self.assertEqual(install.shown_path(run), folder) + self.assertNotIn(str(real), install.shown_path(real.resolve() / "RetroArch" / "saves")) + + def test_a_folder_per_core_is_said_in_words(self): + where = "/sdcard/RetroArch/saves/" + line = install.saves_line("RetroArch", 106, where) + self.assertEqual(line, "106 files read from RetroArch's save directory go to a " + "folder per core under /sdcard/RetroArch/saves") + self.assertEqual(install.save_where_words("/s/saves/"), + ("a folder per system under", "/s/saves")) + state = checked_state() + screen = install._saves_lines(state, {"files": 106, "folder": where}) + self.assertNotIn("", " ".join(screen)) + self.assertIn("a folder per core under:", screen[0]) + + def result(self, cols: int) -> list: + state = _installed_state(Path("/sdcard/RetroArch/system")) + state.summary["kept"] = [{"folder": "/sdcard/RetroArch/retrobios-backup/" + "20261027-223524", "files": 3, "bytes": 10}] + state.cols, state.rows = cols, 40 + return install.tui_view(state, cols, 40) + + def test_a_path_or_a_command_is_never_split_inside_a_word(self): + for cols in (40, 50, 80): + with self.subTest(cols=cols): + lines = [str(line).strip() for line in self.result(cols)] + self.assertIn("/sdcard/RetroArch/retrobios-backup/20261027-223524", lines) + words = [word for line in lines for word in line.split()] + self.assertIn("--restore", words) + self.assertTrue(all(not word.endswith("/") for word in words), words) + state = _installed_state(Path("/sdcard/RetroArch/system")) + command = install._styled("curl -fsSL https://raw.githubusercontent.com/Abdess/" + "retrobios/main/install.sh | sh -s -- --restore " + "'/storage/emulated/0/a b/20261027-223524'", "command") + state.stack.append("result") + pieces = install._text_lines([command], 38, whole=True) + joined = [str(piece).strip() for piece in pieces if piece.style != "cont"] + self.assertIn("https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh", + joined) + self.assertIn("'/storage/emulated/0/a b/20261027-223524'", joined) + + def test_a_long_line_folds_in_the_terminal(self): + terminal = install._TuiTerminal.__new__(install._TuiTerminal) + terminal.frame, written = [], [] + terminal.write = written.append + long = install._styled("x" * 50, "long") + terminal.draw([long, install._styled("", "cont"), install._styled("next")], 40) + out = "".join(written) + self.assertIn("x" * 50, out) + self.assertNotIn("\x1b[2;1H", out) + self.assertIn("\x1b[3;1H", out) + + def test_game_data_names_the_folder_it_goes_in(self): + entry = {"dest": "jazz2/Source/Anims.j2a", "required": True, "category": "game_data", + "system": "jazz-jackrabbit-2"} + bios = Path("/sdcard/RetroArch/system") + lines = install._omission_lines(1, [entry], bios) + said = " ".join(lines) + self.assertIn(f"into {bios / 'jazz2' / 'Source'}{os.sep}", said) + self.assertIn("files a game or a game engine reads", said) + self.assertNotIn("Needed to start an emulator", said) + + class NothingFromOutsideReachesTheTerminalRaw(unittest.TestCase): """code_audit_18 P2-1: a pack's member path, a --manifest dest, the names a list records, a card's label and a retroarch.cfg folder reached diff --git a/wiki/installer.md b/wiki/installer.md index 6ee5aac62..b2f6c1fb3 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -197,7 +197,12 @@ progress, and a second one stops at once. Select+Start on a handheld ends the installer with a signal (SIGTERM), which stops it the same way. Either way the result says how many files were installed, and it stays printed in the terminal once the screen closes, with the command that repeats the run. A run that failed -keeps its log and names it, and every other run leaves nothing behind. +keeps its log and names it, and every other run leaves nothing behind. A path +or a command on the result is never split inside a word: one longer than the +window takes a line of its own, which the terminal folds, and a command breaks +only between its arguments. Every path a run names is spelled the way its BIOS +folder was named, the backup folder and the save directory included +(`/sdcard/RetroArch` on Android, not the `/storage/emulated/0` it links to). It opens only for an install, at a terminal of at least 40 columns and 12 rows (a smaller window says so, with its size) whose `TERM` is set, is not `dumb` @@ -1527,7 +1532,7 @@ other schemas under `api/v1/schemas/`. | `data` | `platform`, `files`, `bytes` | Right after `declared`: the data-tree files to fetch (the `data_missing` of `check`) and the bytes they write once in place | | `found` | `platform`, `dest`, `folder`, `path`, `member`, `reason` | Each file to install that this machine already holds, after `check` and before anything is written, in `--check` and `--dry-run` too. `folder` is the `--from` folder as given, or the `bios_dir` of the check when the BIOS folder holds the file at another place, and then `reason` says where in words (`nested folder: ...` for a misplaced file, `the same file is at ...` for one the list installs there too). `path` is the file, and `member` the ZIP member holding the bytes when `path` is an archive, an empty string otherwise. Its copy is reported by a `downloaded` event | | `unreadable` | `platform`, `folder`, `path`, `member`, `reason` | Each file in a `--from` folder that could not be read and may be one the run still needs, right after the `found` events: its size is that of a file no copy was found for, or unknown (a folder that could not be listed, a ZIP that would not open). `member` names the ZIP member, or is empty. `reason` says why in words (`Permission denied`, `it is encrypted, a password is needed`) | -| `saves` | `platform`, `files`, `folder` | After `choices` and before `checking`, when the files the run installs (once `--system` and the other choices narrowed the list) hold some read from the frontend's save directory and that directory is known: how many, and the `folder` they go to, where `` and `` stand for one folder per system or per core. When it is not known, the files are counted in the summary's `skipped` instead (`no_save_directory`) | +| `saves` | `platform`, `files`, `folder` | After `choices` and before `checking`, when the files the run installs (once `--system` and the other choices narrowed the list) hold some read from the frontend's save directory and that directory is known: how many, and the `folder` they go to, where `` and `` stand for one folder per system or per core (the screen and `--plain` say it in words: "a folder per core under /sdcard/RetroArch/saves"). When it is not known, the files are counted in the summary's `skipped` instead (`no_save_directory`) | | `download` | `platform`, `files`, `bytes` | Before the downloads start. Counts the files copied from this machine too | | `progress` | `files_done`, `files_total`, `bytes_done`, `bytes_total` | At most every half second while bytes arrive | | `transfer` | `bytes_local`, `bytes_network` | Right after each `progress` event, and once when the files of a `download` event are done. It splits `bytes_done`: `bytes_local` came from this machine (a copy found by `--from` or in the BIOS folder, the pack, a partial copy resumed), `bytes_network` from the network. A download speed is `bytes_network` over the time elapsed | From 0fb1047231bbfee6c43b5cd9036bb0c648b4a1b6 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:03:25 +0100 Subject: [PATCH 07/13] feat: count game data apart from BIOS files The installer knew BIOS files from game data and no count showed it. The plan, the line menu and the result count them apart for a list that marks game data, the plan gets a game data row and the menu a g key, each toggling it in one press, and one sentence says what game data is. --- install.ps1 | 2 +- install.py | 142 +++++++++++++++++++++++++++++++++-- install.sh | 2 +- install/launchers/index.json | 16 ++-- tests/test_install_tui.py | 72 +++++++++++++++++- wiki/first-install.md | 6 +- wiki/installer.md | 11 ++- 7 files changed, 230 insertions(+), 21 deletions(-) diff --git a/install.ps1 b/install.ps1 index ea43715cf..f23e9d5c3 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "c3dcd0682ff917866b79f7718de048e16f13d03651111601e1816ec49f5703cb" + $defaultInstallSha256 = "af62c61703cf1becc150b0244b6e13c731e062e9471a07c184693bb9cd46ece3" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 4178c897b..933ef0df5 100644 --- a/install.py +++ b/install.py @@ -769,6 +769,32 @@ def leave_out_game_data( ) +def category_split(entries: list[dict]) -> tuple[int, int, int, int]: + """(BIOS files, their bytes, game data files, their bytes) of entries, + each with its size and the category the list gives it.""" + games = [entry for entry in entries if entry.get("category") == GAME_DATA] + bios = [entry for entry in entries if entry.get("category") != GAME_DATA] + return (len(bios), sum(int(entry.get("size") or 0) for entry in bios), + len(games), sum(int(entry.get("size") or 0) for entry in games)) + + +def kinds_words(bios: int, games: int) -> str: + """BIOS files and game data files, counted apart.""" + return f"{_plural(bios, 'BIOS file')} and {_plural(games, 'game data file')}" + + +def split_words(entries: list[dict]) -> str: + """BIOS files and game data apart, in words, "" when there is no game + data: the installer tells them apart (--no-game-data), and no count + showed it. RetroArch's list is 3259 BIOS files (4.4 GB) and 5089 game + data files (2.7 GB).""" + bios, bios_bytes, games, game_bytes = category_split(entries) + if not games: + return "" + return (f"{_plural(bios, 'BIOS file')} ({format_size(bios_bytes)}) and " + f"{_plural(games, 'game data file')} ({format_size(game_bytes)})") + + def keep_declared(files: list[dict], omitted: list[dict]) -> tuple[list[dict], list[dict]]: """The files and omissions the platform's own list declares (cores: null). @@ -5607,8 +5633,10 @@ def _report_outcome( hints: list[str] | None = None, sources: str = "", offline: int = 0, + kinds: list[int] | None = None, ) -> None: """Close the run on what happened, and leave with a status that says so. + kinds is [BIOS files, game data files] installed, said apart. A run that installed nothing used to end on the word Done and a zero exit, which reads as success to a person and to a script alike. offline counts @@ -5749,6 +5777,8 @@ def _report_outcome( print(f"\nDone. {_plural(downloaded, 'file')} installed, " f"{_plural(up_to_date, 'file')} already up to date.") + if kinds and kinds[1]: + print(f" Installed: {kinds_words(*kinds)}.") if sources: print(f" {sources}") print(f"Location: {_locations(paths)}") @@ -7295,10 +7325,14 @@ def _prompt_custom_selection( bios_path: Path | None = None, choosable: bool = True, given: tuple[list[str], list[str], list[str]] | None = None, manifest: dict | None = None, narrowed: bool = False, - command: Callable[[object], str] | None = None, + command: Callable[[object], str] | None = None, cut: list[bool] | None = None, ) -> tuple[list[str], list[str], list[str]] | str | None: """Offer to narrow the install by system, core and region. + cut, given for a list that marks its game data, holds whether g left + it out: one key, as one row of the screen's plan, toggles it, and the + caller applies it as --no-game-data does. + Enter installs everything, the default most runs want. Returns None then, INSTALLED_ONLY for what `installed` (the files the cores found installed read) holds, or the three choices. Each empty list keeps @@ -7318,10 +7352,13 @@ def _prompt_custom_selection( """ trees = data_trees or [] current = given + toggled = False while True: - _print_menu_head(plat, files, omitted, installed, name, trees, bios_path, current, - given, manifest, narrowed) - if current is not given and command is not None: + shown, shown_omitted = ((leave_out_game_data(files, omitted)[:2]) + if cut and cut[0] else (files, omitted)) + _print_menu_head(plat, shown, shown_omitted, installed, name, trees, bios_path, + current, object() if toggled else given, manifest, narrowed) + if (current is not given or toggled) and command is not None: print(f" Same choice without questions: {screen_text(command(current))}") offline = _offline_only() if offline: @@ -7355,10 +7392,15 @@ def _prompt_custom_selection( copying = not dry_run if copying: print(" f) copy from a folder or an old card") + if cut is not None: + games = [entry for entry in files if entry.get("category") == GAME_DATA] + print(f" g) {'keep' if cut[0] else 'leave out'} the " + f"{_plural(len(games), 'game data file')} ({format_size(_bytes(games))}), " + f"{GAME_DATA_WORDS}") print(" q) quit") keys = ["", "q", *(["c"] if choosable else []), *(["i"] if installed is not None and current != INSTALLED_ONLY else []), - *(["f"] if copying else [])] + *(["f"] if copying else []), *(["g"] if cut is not None else [])] answer = _ask("> ").lower() while answer not in keys: print(f" Type {', '.join(['Enter', *keys[2:]])} or q.") @@ -7367,7 +7409,10 @@ def _prompt_custom_selection( return current if answer == "q": _quit() - if answer == "i": + if answer == "g" and cut is not None: + cut[0] = not cut[0] + toggled = True + elif answer == "i": current = INSTALLED_ONLY elif answer == "c": current = _choose_narrowing(files, omitted, manifest, bios_path) @@ -7413,6 +7458,9 @@ def _print_menu_head( print(f"\n{name or display_name(plat)}: {_sized(shown)}" + (f", and {_plural(data, 'data file')} ({format_size(data_size)})" if data else "") + named + ".") + split = split_words(shown) if (manifest or {}).get("categorized") else "" + if split: + print(f" {split}.") if bios_path is None: return # Named before anything is written: line mode said where only after @@ -11656,6 +11704,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: failures: list[tuple[str, str]] = [] installed_paths: list[Path] = [] bulk_lines: list[str] = [] + # BIOS files and game data files installed, of a list marking them. + kinds_done = [0, 0] # The BIOS folders read for misplaced files, one per platform. bios_indexes: list[FolderIndex] = [] @@ -11870,11 +11920,18 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: ) listed = len(manifest.get("files", [])) + len(variants if case_kept else []) + # g, the plan's game data row: offered where the list marks its + # game data and --no-game-data did not already leave it out. + cut = ([False] if manifest.get("categorized") and not args.no_game_data + and any(entry.get("category") == GAME_DATA for entry in files) else None) + def command_for(choice: object, plat: str = plat_name, folder: Path = bios_path) -> str: # The choice made at the questions, every other option of the # run kept: --dest, --from-pack and --manifest were dropped. - _INVOCATION.chosen[plat] = chosen_flags(choice) + _INVOCATION.chosen[plat] = ( + (chosen_flags(choice) if choice is not None else []) + + ([("--no-game-data", None)] if cut and cut[0] else [])) return _INVOCATION.command(plat, folder) selection = _prompt_custom_selection( @@ -11884,7 +11941,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: records_selection(files), given, manifest, bool(target or args.installed_cores or args.declared_only or len(files) + game_data_cut + len(unplaced) != listed), - command_for, + command_for, cut, ) if selection is None and LOCAL.pack is not None: smaller = " Nothing copied." @@ -11903,6 +11960,14 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: _INVOCATION.chosen[plat_name] = chosen_flags(selection) else: _INVOCATION.chosen.pop(plat_name, None) + if cut and cut[0]: + # Left out at g, as --no-game-data leaves it out. + files, omitted_files, left_out = leave_out_game_data(files, omitted_files) + left_out_seen.extend(left_out) + _INVOCATION.chosen[plat_name] = (_INVOCATION.chosen.get(plat_name, []) + + [("--no-game-data", None)]) + for line in _left_out_lines(left_out, args.verbose): + print(line) if selection is not None: before = len(files) files, omitted_files = narrow(files, omitted_files, *selection) @@ -12171,6 +12236,13 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: ) bulk_lines += hint total_downloaded += progress.installed + if not interrupted and manifest.get("categorized"): + # The end line tells BIOS files from game data, as the + # menu counted them. + gone = [str(dest) for dest, _reason in failed] + for entry in to_download: + if not any(dest.endswith(str(entry.get("dest", ""))) for dest in gone): + kinds_done[entry.get("category") == GAME_DATA] += 1 files_pending += len(to_download) - progress.installed if interrupted \ else len(failed) total_errors += len(failed) @@ -12284,6 +12356,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: omitted_entries=omitted_seen, platform_of={path: plat for plat, path in platforms}, hints=bulk_lines, + kinds=kinds_done, sources=_run_sources(bios_indexes, total_downloaded, total_errors + total_offline), offline=total_offline, @@ -13392,6 +13465,9 @@ def _plan_rows(state: TuiState) -> list[TuiRow]: if "systems" in recorded_axes((state.manifest or {}).get("files", [])) and ( len(state.systems) > 1 or state.cli_systems): rows.append(TuiRow("systems", "Choose systems")) + game_data = _game_data_row(state) + if game_data is not None: + rows.append(game_data) rows.append(TuiRow("fromcard", "Copy the BIOS files from your old card or a folder")) if _tui_misplaced(state): rows.append(TuiRow("misplaced", "Show the files in the wrong place")) @@ -13400,6 +13476,38 @@ def _plan_rows(state: TuiState) -> list[TuiRow]: return rows + [TuiRow("options", "Options"), TuiRow("quit", "Quit")] +def _tui_categories(state: TuiState) -> dict[str, str]: + """Each destination's category, from the list the screen holds, for a + list that marks its game data.""" + manifest = state.manifest or {} + if not manifest.get("categorized"): + return {} + return {str(entry.get("dest")): str(entry.get("category") or "") + for entry in manifest.get("files", [])} + + +def _tui_split(state: TuiState, dests: list[tuple[str, int]]) -> str: + """BIOS files and game data apart, for (dest, size) pairs, "" when the + list marks none or holds no game data among them.""" + categories = _tui_categories(state) + if not categories: + return "" + return split_words([{"size": size, "category": categories.get(dest, "")} + for dest, size in dests]) + + +def _game_data_row(state: TuiState) -> TuiRow | None: + """The plan's game data row, one press to leave it out or keep it, for + a list that marks game data: inside Options alone, it went unseen.""" + games = [entry for entry in (state.manifest or {}).get("files", []) + if entry.get("category") == GAME_DATA] + if not _tui_categories(state) or not games: + return None + sized = f"{_plural(len(games), 'file')}, {format_size(_bytes(games))}" + return TuiRow("gamedata", f"Game data ({sized}): " + + ("left out, change" if state.no_game_data else "kept, leave out")) + + def _offline_only() -> bool: """--offline without a pack: what is not on this machine cannot come.""" return offline_requested() and LOCAL.pack is None @@ -14219,6 +14327,10 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: return _open_from_browser(state) if action in ("misplaced", "unreadable"): return _push(state, action) + if action == "gamedata": + # One press, then counted again, as Back from Options counts. + state.no_game_data = not state.no_game_data + return _start_check(state) if action == "systems" and state.cli_systems: # The check read only what --system named: it is read again for # every system, those named ticked. Its exact plan is kept, to @@ -15956,6 +16068,13 @@ def _view_plan(state: TuiState, width: int, height: int) -> tuple: f"{check['wrong_hash']} wrong ") lines = [line for line in lines if not str(line).startswith(whole)] lines[2:2] = choice + split = _tui_split(state, [(event["dest"], event["size"]) for event in state.files + if event["state"] != "present"]) + if split or (state.no_game_data and _game_data_row(state) is not None): + # BIOS files and game data apart, and what game data is, beside the + # row that leaves it out. + lines[1:1] = [f"{split or 'No game data'}. {GAME_DATA_WORDS[0].upper()}" + f"{GAME_DATA_WORDS[1:]}."] if state.cli_systems and state.systems: # Named on the command line: said, and widened by Choose systems. lines[2:2] = [f"For {len(state.systems)} of {len(tui_systems(state))} " @@ -16376,6 +16495,13 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: title = "Done" head = "Everything is in place." lines = [head] + categories = _tui_categories(state) + done = [categories.get(dest, "") for dest, good, _why in state.finished + if good and dest in categories] + if GAME_DATA in done: + # As the plan counted them, and as line mode ends. + games = done.count(GAME_DATA) + lines.append(f"Installed: {kinds_words(len(done) - games, games)}.") if left: lines.append(_offline_remedy([_short_path(Path(folder), state.home) for folder in state.from_folders])) diff --git a/install.sh b/install.sh index cdfb8b88d..70d3ffa77 100755 --- a/install.sh +++ b/install.sh @@ -18,7 +18,7 @@ esac TEMP_INSTALLER="" TEMP_DIRECTORY="" DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" -DEFAULT_INSTALL_SHA256="c3dcd0682ff917866b79f7718de048e16f13d03651111601e1816ec49f5703cb" +DEFAULT_INSTALL_SHA256="af62c61703cf1becc150b0244b6e13c731e062e9471a07c184693bb9cd46ece3" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index e0f0eee10..522bc70ab 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 801988, - "sha256": "c3dcd0682ff917866b79f7718de048e16f13d03651111601e1816ec49f5703cb", + "size": 808521, + "sha256": "af62c61703cf1becc150b0244b6e13c731e062e9471a07c184693bb9cd46ece3", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 854537, - "sha256": "1ed15a4a76d79f5dcd3f9f53173721e3666b159defc49adbcd6622cb252d21a8" + "size": 861070, + "sha256": "b06cd724e1a4d41a6bd5ae7da6049402d374d3265cd816594c89dbb7e1593ee3" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 801988, - "sha256": "c3dcd0682ff917866b79f7718de048e16f13d03651111601e1816ec49f5703cb", + "size": 808521, + "sha256": "af62c61703cf1becc150b0244b6e13c731e062e9471a07c184693bb9cd46ece3", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 801988, - "sha256": "c3dcd0682ff917866b79f7718de048e16f13d03651111601e1816ec49f5703cb", + "size": 808521, + "sha256": "af62c61703cf1becc150b0244b6e13c731e062e9471a07c184693bb9cd46ece3", "executable": false }, { diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 7779ce94a..6230eab55 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -6852,6 +6852,73 @@ class LineModeAndScreenDecideAlike(unittest.TestCase): self.assertEqual(ark.screen, "folder") self.assertEqual(install._tui_card_bound(ark)[0], "ArkOS") + # The owner's request of 2026-10-27: BIOS files and game data counted + # apart, the game data choice on the plan itself and in the menu. + + def game_list(self) -> tuple[Path, list]: + files = [self.entry("gba_bios.bin", b"gba", ["nintendo-gba"]), + self.entry("doom/doom1.wad", b"wad" * 10, ["doom"], category="game_data")] + return self.listing("retroarch", files, categorized=True), files + + def test_line_mode_counts_game_data_apart_and_g_leaves_it_out(self): + listing, _files = self.game_list() + args = dict(manifest=listing, dest=self.here, declared_only=False, dry_run=True) + text, asked = self.line_run(_args(**args), ["g", ""]) + self.assertIn("1 BIOS file (3 B) and 1 game data file (30 B).", text) + self.assertIn("g) leave out the 1 game data file (30 B), game data: files a game or " + "a game engine reads, of no use to the other cores", text) + after = text[text.index("Counted again"):] + self.assertIn("RetroArch: 1 file, 3 B.", after) + self.assertIn("g) keep the 1 game data file", after) + self.assertIn("--no-game-data", after) + self.assertIn("Left out as game data", after) + self.assertNotIn("doom1.wad would", after) + + def test_the_plan_counts_game_data_apart_and_one_press_leaves_it_out(self): + state = checked_state() + manifest = json.loads(json.dumps(state.manifest)) + for entry in manifest["files"]: + if entry["dest"] == "pcsx/extra.bin": + entry["category"] = "game_data" + state.manifest = manifest + text = " ".join(" ".join(install.tui_view(state, 80, 24)).split()) + self.assertIn("3 BIOS files (1.0 MB) and 1 game data file (4 B). Game data: files a " + "game or a game engine reads, of no use to the other cores.", text) + rows = install.tui_rows(state) + row = next(row for row in rows if row.action == "gamedata") + self.assertIn("kept, leave out", row.label) + state.focus["plan"] = rows.index(row) + effects = install.tui_update(state, Key("enter")) + self.assertTrue(state.no_game_data) + self.assertEqual(effects, [("run", "check")]) + self.assertIn(("--no-game-data", None), install.tui_flags(state)) + + def test_the_result_counts_game_data_apart_in_both_modes(self): + state = _installed_state(Path("/x/system")) + state.manifest = {**state.manifest, "files": [ + *state.manifest["files"], + {"dest": "doom/doom1.wad", "size": 30, "category": "game_data", "cores": None}]} + state.finished = [("scph5500.bin", True, ""), ("doom/doom1.wad", True, "")] + _title, lines = install.tui_outcome(state) + self.assertIn("Installed: 1 BIOS file and 1 game data file.", lines) + listing, _files = self.game_list() + backup = self.root / "old" + (backup / "doom").mkdir(parents=True) + (backup / "gba_bios.bin").write_bytes(b"gba") + (backup / "doom" / "doom1.wad").write_bytes(b"wad" * 10) + args = _args(manifest=listing, dest=self.here, offline=True, declared_only=False, + no_input=True) + setattr(args, "from", [backup]) + real = io.StringIO() + install.LOCAL.reset() + self.addCleanup(install.LOCAL.reset) + with mock.patch.object(install, "detect_os", return_value="linux"), \ + contextlib.redirect_stdout(io.StringIO()), \ + contextlib.redirect_stderr(io.StringIO()): + install._install(args, real) + self.assertIn("Done. 2 files installed", real.getvalue()) + self.assertIn("Installed: 1 BIOS file and 1 game data file.", real.getvalue()) + def test_line_mode_says_the_card_it_found_and_names_its_folder(self): onion = self.card("ONION", "Onion") with mock.patch.object(install, "_mounted_cards", return_value=[onion]), \ @@ -7739,10 +7806,13 @@ class AuditSeventeenAndTen(unittest.TestCase): def test_the_docs_say_what_the_installer_does(self): """code_audit_17 P3-7.""" page = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8") - menu = page[page.index("RetroArch: 8069 files, 6.5 GB."):] + menu = page[page.index("RetroArch: 8348 files, 7.1 GB."):] menu = menu[:menu.index("```")] self.assertIn("BIOS folder:", menu) self.assertIn("f) copy from a folder or an old card", menu) + # The owner's request of 2026-10-27: game data counted apart, g. + self.assertIn("3259 BIOS files (4.4 GB) and 5089 game data files (2.7 GB).", menu) + self.assertIn("g) leave out the 5089 game data files", menu) env = page[page.index("## Environment overrides"):] self.assertIn("`TERMINFO`", env) self.assertIn("`TERMINFO_DIRS`", env) diff --git a/wiki/first-install.md b/wiki/first-install.md index 00df0a3af..7e19047fa 100644 --- a/wiki/first-install.md +++ b/wiki/first-install.md @@ -92,6 +92,9 @@ The next screen counts missing files and free space. Press Enter on **Install**. **Choose systems** keeps the consoles you play. +**Game data** is what a game reads, of no use to other cores. The count +shows it apart, and its row leaves it out. + **Old card?** In a card reader, choose **Copy the BIOS files from your old card or a folder**, then the card. @@ -166,7 +169,8 @@ You cannot write there. Do what the message says. ### The card is full -Nothing was written. Free some space, or choose another folder. +Nothing was written. Free some space on the card. On a computer, you can +also choose another folder. ### The game still does not start diff --git a/wiki/installer.md b/wiki/installer.md index b2f6c1fb3..abbdfdd32 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -607,15 +607,24 @@ the full screen cannot open (a terminal smaller than 40x12, `TERM=dumb`), the same choice comes as questions after detection: ``` -RetroArch: 8069 files, 6.5 GB. +RetroArch: 8348 files, 7.1 GB. + 3259 BIOS files (4.4 GB) and 5089 game data files (2.7 GB). BIOS folder: /home/me/.config/retroarch/system Enter) install everything i) only what the installed cores read: 100 files, 44.2 MB c) choose systems, cores and regions f) copy from a folder or an old card + g) leave out the 5089 game data files (2.7 GB), game data: files a game or a game engine reads, of no use to the other cores q) quit ``` +A list that marks its game data counts it apart from the BIOS files, on the +plan, in this menu and on the result (`Installed: 3259 BIOS files and 5089 game +data files.`). `g` leaves it out, as `--no-game-data` does, and counts again +(`g` then keeps it). The plan screen has the same choice as a row of its own, +**Game data (5089 files, 2.7 GB): kept, leave out**, one press toggling it, +and Options keeps its **Game data** row too. + `i` appears for RetroArch, Lakka and RetroPie when cores are found installed, and the line above is a RetroArch with Beetle PSX HW, Snes9x, mGBA, Genesis Plus GX and FCEUmm. From 302c7737b2a029e3a57c702b02851f94b542f511 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:13:10 +0100 Subject: [PATCH 08/13] fix: one remedy per card and plainer line mode A full or read-only card is freed or unlocked, checked again first, never left for another drive. Line mode names --dest where it said choose another one, numbers out of range, the folder copied from, the frontend's save directory and the command after Enter. The words name RETROBIOS_INSTALL_OFFLINE when it, not --offline, made a run offline. --- install.ps1 | 2 +- install.py | 174 ++++++++++++++++++++++++++-------- install.sh | 2 +- install/launchers/index.json | 16 ++-- tests/test_install_offline.py | 6 +- tests/test_install_tui.py | 147 +++++++++++++++++++++++++++- 6 files changed, 290 insertions(+), 57 deletions(-) diff --git a/install.ps1 b/install.ps1 index f23e9d5c3..74bf72daf 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "af62c61703cf1becc150b0244b6e13c731e062e9471a07c184693bb9cd46ece3" + $defaultInstallSha256 = "c030192b6ecfcedfc114ecf0cbcfc509fcca44e398e6d8cefedc7766065df538" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 933ef0df5..0852371ef 100644 --- a/install.py +++ b/install.py @@ -3691,9 +3691,9 @@ def fetch_manifest(plat: str) -> dict: message=words if code != "network" else f"the file list could not be fetched: {words}") if code == "offline": - print(f" No file list for {plat}: {OFFLINE_WORDS}. Give it with " + print(f" No file list for {plat}: {offline_words()}. Give it with " "--manifest FILE (a copy of install/.json) or " - "--from-pack PACK, or run without --offline.", file=sys.stderr) + f"--from-pack PACK, or run {offline_undo()}.", file=sys.stderr) sys.exit(1) if code != "network": print(f" Could not fetch the file list for {plat}.", file=sys.stderr) @@ -4990,7 +4990,7 @@ def _download_one( if LOCAL.pack is not None: return _extract_from_pack(f, bios_path, dest, progress) if offline_requested(): - return f["dest"], False, OfflineReason(f"not fetched: {OFFLINE_WORDS}") + return f["dest"], False, OfflineReason(f"not fetched: {offline_words()}") if f.get("release_asset"): url = RELEASE_URL.format(asset=urllib.parse.quote(f["release_asset"], safe="")) else: @@ -5634,6 +5634,7 @@ def _report_outcome( sources: str = "", offline: int = 0, kinds: list[int] | None = None, + same_choice: str = "", ) -> None: """Close the run on what happened, and leave with a status that says so. kinds is [BIOS files, game data files] installed, said apart. @@ -5720,8 +5721,8 @@ def _report_outcome( remedy = _offline_remedy() if not downloaded and not errors: print(f"\nNothing installed: {_plural(offline, 'file')} " - f"{'needs' if offline == 1 else 'need'} the internet, which --offline " - "does not use.") + f"{'needs' if offline == 1 else 'need'} the internet, which " + f"{offline_named()} does not use.") print(f" {remedy}") for line in kept: print(line) @@ -5732,12 +5733,12 @@ def _report_outcome( failed = f", {errors} failed" if errors else "" if downloaded: print(f"\nFinished with files missing. {_plural(downloaded, 'file')} installed, " - f"{offline} not fetched (--offline){failed}.") + f"{offline} not fetched ({offline_named()}){failed}.") else: # Nothing written: "Finished with files missing. 0 files installed" # read as a part done. print(f"\nNothing installed: {_plural(offline, 'file')} not fetched " - f"(--offline){failed}.") + f"({offline_named()}){failed}.") if sources: print(f" {sources}") # Under the files it is about: after the save-directory line, "They" @@ -5792,6 +5793,12 @@ def _report_outcome( print(line) for line in hints or []: print(line) + if same_choice: + # After the questions, as the screen's result ends: "the same + # command" was one that asked them (tui_audit_11 P3-21). + print(f"Same choice without questions: {screen_text(same_choice)}") + print("To check this later, run that command with --check.") + return print("To check this later, run the same command with --check.") @@ -5805,9 +5812,9 @@ def _offline_remedy(folders: list[str] | None = None) -> str: if labels: named = ", ".join(screen_text(shown_path(label)) for label in labels) return (f"They are not in {named}: run the installer again with a pack " - "(--from-pack) or without --offline.") + f"(--from-pack) or {offline_undo()}.") return ("Copy them from your old card or a folder (--from), or run the installer " - "again with a pack (--from-pack) or without --offline.") + f"again with a pack (--from-pack) or {offline_undo()}.") def _check_fetch_line(fetch: int, beside: str) -> str: @@ -5817,7 +5824,7 @@ def _check_fetch_line(fetch: int, beside: str) -> str: if not fetch: return " Run the same command without --check to copy them into place." return (f" {_plural(fetch, 'file')} {'needs' if fetch == 1 else 'need'} the " - f"internet, which --offline does not use. {_offline_remedy()}") + f"internet, which {offline_named()} does not use. {_offline_remedy()}") return (" Run the same command without --check to fetch them. A file with a wrong " f"hash is replaced, and the one in place kept in {BACKUP_DIR} beside {beside}.") @@ -5983,8 +5990,8 @@ def _print_plan( # internet". one = len(to_download) + tree_files == 1 print(f"\n{' and '.join(parts)} {'needs' if one else 'need'} the internet, which " - "--offline does not use. Without --offline they would go to " - f"{shown_path(bios_path)}:") + f"{offline_named()} does not use. Run {offline_undo()}, and they would go " + f"to {shown_path(bios_path)}:") elif parts: source = " from the pack" if LOCAL.pack is not None else "" print(f"\nWould {verb} {' and '.join(parts)}{source} to {shown_path(bios_path)}:") @@ -6033,7 +6040,8 @@ def _print_plan( print("\n Nothing was written. Run without --dry-run to install.") -def _check_free_space(bios_path: Path, needed: int, plat: str = "", code: int = 2) -> None: +def _check_free_space(bios_path: Path, needed: int, plat: str = "", code: int = 2, + card: bool = False) -> None: """Stop before writing when the volume cannot hold the download. Reported against the nearest existing ancestor, since the destination @@ -6053,6 +6061,12 @@ def _check_free_space(bios_path: Path, needed: int, plat: str = "", code: int = print(f"\nNot enough space on the drive holding {bios_path}.", file=sys.stderr) print(f" Needed: {format_size(needed)}. Free: {format_size(free)}.", file=sys.stderr) + if card: + # Its system reads this card alone: another drive, with RetroArch's + # list, was offered for an Onion card (code_audit_18 P3-2). + print(f" Free up {format_size(needed - free)} on the card, then run it again: " + "its system reads its BIOS files there only.", file=sys.stderr) + sys.exit(code) if not plat: print(f" Free up {format_size(needed - free)}, or use --dest to install " "to another drive.", file=sys.stderr) @@ -7065,9 +7079,10 @@ def _prompt_platform_choice( if other is not None: print(" o) another setup or folder") print(" q) quit") - usage = (f" Type a number from 1 to {len(platforms)}" - + (", o for another setup or folder" if other is not None else "") - + ", or q to quit.") + # "a number from 1 to 1" for a single row (code_audit_18 P3-5). + usage = (" Type 1" if len(platforms) == 1 else + f" Type a number from 1 to {len(platforms)}") + ( + ", o for another setup or folder" if other is not None else "") + ", or q to quit." while True: choice = _ask(f"> [Enter for {named[0]}] ").lower() if not choice: @@ -7243,14 +7258,28 @@ def folder_warnings(plat: str, bios_path: Path, files: list[dict], hint: Invocat a setup that runs from that card, whose own folder is the one it reads.""" lines = [] if not _folder_writable(bios_path): - lines.append("You cannot write to this folder: choose another one.") - lines += write_remedy(bios_path, plat, hint, commands) + lines += cannot_write_words(bios_path, plat, hint, commands, card) short = _space_short(bios_path, files) if short is not None: lines.append(space_words(short[0], short[1], card)) return lines +def cannot_write_words(bios_path: Path, plat: str, hint: Invocation, commands: bool, + card: bool, screen: bool = False, remedy: bool = True) -> list[str]: + """What the plan says of a folder this user cannot write, one remedy + for a card: unlock it, never another folder its system does not read. + Line mode has no folder row: "choose another one" there offered no way + to (code_audit_18 P3-3), so it names --dest, the screen its row.""" + said = write_remedy(bios_path, plat, hint, commands) if remedy else [] + if card and _on_read_only_mount(bios_path): + return said + another = ("choose another one" if screen + else "run it again with another --dest" if commands + else "run it again and choose another one") + return [f"You cannot write to this folder: {another}."] + said + + def space_words(needed: int, free: int, card: bool) -> str: """Not enough room, and what to do: on a card setup's own card, room is made on the card, another folder being one its system does not read.""" @@ -7364,7 +7393,7 @@ def _prompt_custom_selection( if offline: # Said before the menu: "install everything" was offered, then # nothing came and the run said Done. - print(" --offline: only the files found on this machine are installed" + print(f" {offline_named()}: only the files found on this machine are installed" + ("." if LOCAL.folders else ", and no --from names a folder to copy " "them from.")) # A dry run installs nothing: "install everything" was asked before it. @@ -7469,7 +7498,9 @@ def _print_menu_head( for line in folder_warnings(plat, bios_path, shown, _INVOCATION, True, on_its_card(plat, bios_path)): print(f" {line}") - for line in save_lines(shown, kept_omitted, bios_path, name or display_name(plat)): + # The frontend's save directory, named as the end of the run and the + # screen name it: "Onion's" before the menu, "RetroArch's" after. + for line in save_lines(shown, kept_omitted, bios_path, display_name(plat, manifest)): print(f" {line}") @@ -8206,7 +8237,30 @@ class _Offline(OSError): # full-screen interface starts inherits. RETROBIOS_OFFLINE stays the build # scripts' own: CI and the test suite set it for every process they start. OFFLINE_VARIABLE = "RETROBIOS_INSTALL_OFFLINE" -OFFLINE_WORDS = "this run makes no network request (--offline)" +OFFLINE_WORDS = "this run makes no network request" +# The value --offline gives the variable: the words then name the option, +# and the variable itself where it was set (tui_audit_11 P3-12). +OFFLINE_FLAG_VALUE = "--offline" + + +def offline_named() -> str: + """What made this run offline, as the words name it.""" + value = os.environ.get(OFFLINE_VARIABLE, "") + return (f"{OFFLINE_VARIABLE}={value}" if value not in ("", "0", OFFLINE_FLAG_VALUE) + else "--offline") + + +def offline_undo() -> str: + """How a run gets the network back: without the option, or with the + variable unset. "without --offline" told a user who never typed it to + remove it.""" + return ("without --offline" if offline_named() == "--offline" + else f"with {OFFLINE_VARIABLE} unset") + + +def offline_words() -> str: + """This run makes no network request, and what said so.""" + return f"{OFFLINE_WORDS} ({offline_named()})" def offline_requested() -> bool: @@ -8217,7 +8271,7 @@ def _network_allowed(what: str) -> None: if LOCAL.pack is not None: raise _Offline(f"{what}: this run reads a pack and makes no network request") if offline_requested(): - raise _Offline(f"{what}: {OFFLINE_WORDS}") + raise _Offline(f"{what}: {offline_words()}") def read_local_manifest(path: Path, plat: str) -> dict: @@ -10220,12 +10274,13 @@ def main() -> None: ): if given: parser.error(reason) - if args.offline: - # The runs the full-screen interface starts read it from here. - os.environ[OFFLINE_VARIABLE] = "1" + if args.offline and not offline_requested(): + # The runs the full-screen interface starts read it from here, and + # the words name the option, not a variable nobody set. + os.environ[OFFLINE_VARIABLE] = OFFLINE_FLAG_VALUE if offline_requested() and (args.target or args.list_targets): parser.error("--target and --list-targets read a core list from the network, " - "which --offline does not use") + f"which {offline_named()} does not use") install_bounded_opener() if args.from_pack and args.target: parser.error("--target reads a core list from the network, which --from-pack " @@ -10427,6 +10482,12 @@ def _ask_from_folder(files: list[dict] | None = None, bios_path: Path | None = N _quit() if raw.isdigit() and 1 <= int(raw) <= len(drives): label = str(drives[int(raw) - 1]) + elif raw.isdigit() and drives and not _is_dir(_typed_folder(raw)): + # A number out of range, said as one: "2" was read as the folder + # ./2 (tui_audit_11 P3-10). + count = "1" if len(drives) == 1 else f"a number from 1 to {len(drives)}" + print(f" Type {count}, a folder, or Enter.") + continue else: label = os.path.normpath(str(_typed_folder(raw).absolute())) refused = copy_source_refusal(Path(label), bios_path) @@ -12191,7 +12252,13 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: rest = (f" and {'copying' if LOCAL.pack is not None else 'downloading'} " f"{len(fetched)}" if fetched else "") where = f" from {LOCAL.pack.name}" if fetched and LOCAL.pack is not None else "" - print(f"\nCopying {_plural(len(copies), 'file')} found on this machine" + # Named by the folder they come from, as the line above + # names it: "found on this machine" followed "from E:\". + sources = list(dict.fromkeys(copy.index.label for copy in copies.values())) + named = ", ".join(screen_text(shown_path(label)) for label in sources) + origin = (f"copied from {named}" if all(copy.index is not bios_index for copy in copies.values()) + else "found on this machine") + print(f"\nCopying {_plural(len(copies), 'file')} {origin}" f"{rest}{where} ({format_size(dl_size)})...") elif LOCAL.pack is not None: print(f"\nCopying {len(to_download)} files ({format_size(dl_size)}) " @@ -12202,7 +12269,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: "carries on from here.") # Checked before the folder is created: a run stopped for space # left an empty BIOS folder behind. - _check_free_space(bios_path, dl_size, plat_name, 1 if total_downloaded else 2) + _check_free_space(bios_path, dl_size, plat_name, 1 if total_downloaded else 2, + on_its_card(plat_name, bios_path)) created = not bios_path.exists() try: bios_path.mkdir(parents=True, exist_ok=True) @@ -12270,7 +12338,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: # Left out as the BIOS files above are: counted missing, # not as one failure per tree. print(f" {entry['ref']}: {counts['present']} already up to date, " - f"{counts['absent']} need the internet (--offline)") + f"{counts['absent']} need the internet ({offline_named()})") total_offline += counts["absent"] files_pending += counts["absent"] continue @@ -12357,6 +12425,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: platform_of={path: plat for plat, path in platforms}, hints=bulk_lines, kinds=kinds_done, + same_choice=(_INVOCATION.command(*platforms[0]) + if interactive and len(platforms) == 1 else ""), sources=_run_sources(bios_indexes, total_downloaded, total_errors + total_offline), offline=total_offline, @@ -13455,20 +13525,35 @@ def _plan_rows(state: TuiState) -> list[TuiRow]: # Nothing here to copy, and the network refused: Install would # only fail every file, in this folder or another. rows = [] - if state.facts.get("writable") is False or _short_of_space(state, check): + if _on_its_card(state) and (state.facts.get("writable") is False + or _short_of_space(state, check)): + # Its system reads this card alone: room is made on it, or it is + # unlocked, then counted again, first in focus. Install anyway on + # a mount nobody can write would only fail (tui_audit_11 P3-1). + locked = state.facts.get("writable") is False + rows = [TuiRow("recheck", "Check again"), TuiRow("folder", "Choose another folder"), + *([TuiRow("install", "Install anyway")] if rows and not locked else [])] + elif state.facts.get("writable") is False or _short_of_space(state, check): # Every file would fail alike, or the run would stop before the # first one: the way out is another folder. rows = [TuiRow("folder", "Choose another folder"), *([TuiRow("install", "Install anyway")] if rows else [])] # --system given: the choice stays offered, the list widened when it is # opened. With one system named it was neither shown nor changeable. + copy = TuiRow("fromcard", "Copy the BIOS files from your old card or a folder") + if not rows: + # Nothing can be installed: the remedy the plan names comes first, + # in focus. Choose systems was, whose systems need the internet + # too (tui_audit_11 P3-8). + rows.append(copy) if "systems" in recorded_axes((state.manifest or {}).get("files", [])) and ( len(state.systems) > 1 or state.cli_systems): rows.append(TuiRow("systems", "Choose systems")) game_data = _game_data_row(state) if game_data is not None: rows.append(game_data) - rows.append(TuiRow("fromcard", "Copy the BIOS files from your old card or a folder")) + if copy not in rows: + rows.append(copy) if _tui_misplaced(state): rows.append(TuiRow("misplaced", "Show the files in the wrong place")) if state.unreadable: @@ -14327,6 +14412,8 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: return _open_from_browser(state) if action in ("misplaced", "unreadable"): return _push(state, action) + if action == "recheck": + return _start_check(state) if action == "gamedata": # One press, then counted again, as Back from Options counts. state.no_game_data = not state.no_game_data @@ -15452,6 +15539,7 @@ _ROW_VERBS = { "fromcard": "Choose", "from": "Add a folder", "misplaced": "Show", "zip": "Copy from it", "unreadable": "Show", "exit": "Exit", "addapp": "Put it on the card", "cardsystem": "Choose", "rescan": "Look again", "elsewhere": "Choose", "drive": "Choose", + "recheck": "Check again", "gamedata": "Change", "share": "Choose", } @@ -15686,7 +15774,8 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: # folder off a 40x15 screen. The Options row spells it out. where = ", ".join(f"{len(events)} found in {_last_name(folder)}" for folder, events in elsewhere.items()) - headline = (f"{where}, {fetch} need the internet (--offline)" if fetch and offline + headline = (f"{where}, {fetch} need the internet ({offline_named()})" + if fetch and offline else f"{where}, {fetch} to download ({format_size(fetch_bytes)})" if fetch else f"{where}, nothing to download") elif need and fetch and offline: @@ -15713,8 +15802,8 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: # they read as if they were on hand. both = fetch + data > 1 headline += ((f" and {_plural(data, 'data file')}{data_size}" if data else "") - + f" {'need' if both else 'needs'} the internet, which --offline does " - "not use") + + f" {'need' if both else 'needs'} the internet, which " + f"{offline_named()} does not use") elif need: if data: headline += f", and {_plural(data, 'data file')}{data_size}" @@ -15732,7 +15821,10 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: _styled(f"BIOS folder: {_short_path(_plan_folder(state, dest), state.home)}", "path"), ] if state.facts.get("writable") is False: - lines.append("You cannot write to this folder: choose another one.") + # The remedy itself comes below, after the counts. + lines += cannot_write_words(dest, state.platform, tui_hint(state), + state.pad not in TUI_PAD_ROUTES, _on_its_card(state), + screen=True, remedy=False) # Said again where it is the BIOS folder: the browser said it, and the # plan named ~ alone. lines += filter(None, [_no_setup_reads(state, dest)]) @@ -15801,7 +15893,7 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: # by "They are not in USBSTICK". offline_lines = [f"{', '.join(empty)} {'holds' if len(empty) == 1 else 'hold'} " "none of these files: run the installer again with a pack " - "(--from-pack) or without --offline."] + f"(--from-pack) or {offline_undo()}."] else: offline_lines = [f"{name} holds none of these files." for name in empty] \ + offline_lines @@ -16465,11 +16557,11 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: if total else "Stopped. Run again to carry on: what is done stays done.") elif left and installed: title = "Finished with files missing" - head = f"{installed} installed, {left} not fetched (--offline){errors}." + head = f"{installed} installed, {left} not fetched ({offline_named()}){errors}." elif left: title = "Nothing installed" head = (f"Nothing was installed: {_plural(left, 'file')} not fetched " - f"(--offline){errors}.") + f"({offline_named()}){errors}.") elif failed and installed: title = "Finished with errors" head = f"{installed} installed, {len(failed)} failed." @@ -17123,11 +17215,11 @@ def _tui_fetch(plat: str, inbox: queue.Queue, log: io.StringIO) -> None: # Asked for, not a failure: no Try again, and the two ways a # run without the network gets its list. inbox.put(("manifest_failed", plat, TUI_OFFLINE_TITLE, [ - "This run makes no network request (--offline), and the list of " - "BIOS files comes from the network.", + f"This run makes no network request ({offline_named()}), and the list " + "of BIOS files comes from the network.", "Run the installer again with the list saved as a file " "(--manifest FILE, a copy of install/.json) or with a pack " - "(--from-pack PACK), or without --offline."], 1)) + f"(--from-pack PACK), or {offline_undo()}."], 1)) return title = {"clock": "The clock is wrong", "no_ca": "No trusted certificates"}.get(code, "No connection") diff --git a/install.sh b/install.sh index 70d3ffa77..d228c5a4c 100755 --- a/install.sh +++ b/install.sh @@ -18,7 +18,7 @@ esac TEMP_INSTALLER="" TEMP_DIRECTORY="" DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" -DEFAULT_INSTALL_SHA256="af62c61703cf1becc150b0244b6e13c731e062e9471a07c184693bb9cd46ece3" +DEFAULT_INSTALL_SHA256="c030192b6ecfcedfc114ecf0cbcfc509fcca44e398e6d8cefedc7766065df538" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 522bc70ab..100185b7c 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 808521, - "sha256": "af62c61703cf1becc150b0244b6e13c731e062e9471a07c184693bb9cd46ece3", + "size": 813666, + "sha256": "c030192b6ecfcedfc114ecf0cbcfc509fcca44e398e6d8cefedc7766065df538", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 861070, - "sha256": "b06cd724e1a4d41a6bd5ae7da6049402d374d3265cd816594c89dbb7e1593ee3" + "size": 866215, + "sha256": "cfe8dc0a1cda5a63d810ebcc8f28e1f1797446d5ef72447d0a64b3f68639a258" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 808521, - "sha256": "af62c61703cf1becc150b0244b6e13c731e062e9471a07c184693bb9cd46ece3", + "size": 813666, + "sha256": "c030192b6ecfcedfc114ecf0cbcfc509fcca44e398e6d8cefedc7766065df538", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 808521, - "sha256": "af62c61703cf1becc150b0244b6e13c731e062e9471a07c184693bb9cd46ece3", + "size": 813666, + "sha256": "c030192b6ecfcedfc114ecf0cbcfc509fcca44e398e6d8cefedc7766065df538", "executable": false }, { diff --git a/tests/test_install_offline.py b/tests/test_install_offline.py index e836309df..fe90789b1 100644 --- a/tests/test_install_offline.py +++ b/tests/test_install_offline.py @@ -828,8 +828,8 @@ class OfflineLeavesFilesMissing(Scratch): self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) self.assertNotIn("Would download", proc.stdout) self.assertIn("4 files (", proc.stdout) - self.assertIn("need the internet, which --offline does not use. Without --offline " - "they would go to", proc.stdout) + self.assertIn("need the internet, which --offline does not use. Run without " + "--offline, and they would go to", proc.stdout) def test_an_archive_not_read_is_only_its_error(self): listed = manifest() @@ -967,7 +967,7 @@ class AuditFifteenOffline(Scratch): # The screen reads that summary as missing files, not failures. state = install.TuiState() state.summary = summary - with unittest.mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}): + with unittest.mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: install.OFFLINE_FLAG_VALUE}): title, lines = install.tui_outcome(state) self.assertEqual(title, "Finished with files missing") self.assertIn("1 installed, 3 not fetched (--offline).", lines[0]) diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 6230eab55..5abba0828 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -3468,10 +3468,26 @@ class OfflineInTheInterface(unittest.TestCase): forbids.""" def setUp(self): - patcher = mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}) + # The value --offline gives the variable. + patcher = mock.patch.dict(os.environ, + {install.OFFLINE_VARIABLE: install.OFFLINE_FLAG_VALUE}) patcher.start() self.addCleanup(patcher.stop) + def test_the_variable_is_named_when_it_made_the_run_offline(self): + """tui_audit_11 P3-12: "(--offline)" and "without --offline" with + RETROBIOS_INSTALL_OFFLINE=1 and no --offline: nothing to remove.""" + with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}): + inbox = queue.Queue() + install._tui_fetch("retroarch", inbox, io.StringIO()) + text = " ".join(inbox.get_nowait()[3]) + self.assertIn("(RETROBIOS_INSTALL_OFFLINE=1)", text) + self.assertIn("with RETROBIOS_INSTALL_OFFLINE unset", text) + self.assertNotIn("--offline", text) + plan = " ".join(" ".join(install.tui_view(checked_state(), 120, 40)).split()) + self.assertIn("which RETROBIOS_INSTALL_OFFLINE=1 does not use", plan) + self.assertNotIn("without --offline", plan) + def test_no_list_names_the_ways_that_work(self): inbox = queue.Queue() install._tui_fetch("retroarch", inbox, io.StringIO()) @@ -5962,7 +5978,7 @@ class AuditSix(unittest.TestCase): state.found = [{"event": "found", "platform": "retroarch", "dest": FILES[0]["dest"], "folder": "/media/old", "path": "/media/old/x", "member": "", "reason": ""}] - with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}): + with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: install.OFFLINE_FLAG_VALUE}): text = " ".join(" ".join(install.tui_view(state, 120, 40)).split()) self.assertNotIn("downloaded", text) state.found = [] @@ -6919,6 +6935,82 @@ class LineModeAndScreenDecideAlike(unittest.TestCase): self.assertIn("Done. 2 files installed", real.getvalue()) self.assertIn("Installed: 1 BIOS file and 1 game data file.", real.getvalue()) + # code_audit_18 P3-5, tui_audit_11 P3-10 and P3-21: line mode wording. + + def test_one_row_says_type_1(self): + with mock.patch.object(install, "_ask", side_effect=["2", "q"]), \ + contextlib.redirect_stdout(io.StringIO()) as out, \ + self.assertRaises(SystemExit): + install._prompt_platform_choice([("retroarch", self.here)], other=lambda: []) + self.assertIn("Type 1, o for another setup or folder, or q to quit.", out.getvalue()) + self.assertNotIn("1 to 1", out.getvalue()) + + def test_a_number_out_of_range_at_copy_from_is_said_as_one(self): + stick = self.root / "media" / "STICK" + stick.mkdir(parents=True) + with mock.patch.object(install, "_mounted_cards", return_value=[stick]), \ + mock.patch.object(install, "_ask", side_effect=["2", ""]), \ + contextlib.redirect_stdout(io.StringIO()) as out: + install._ask_from_folder([], self.here) + self.assertIn("Type 1, a folder, or Enter.", out.getvalue()) + self.assertNotIn("does not exist", out.getvalue()) + + def test_the_copy_line_names_the_folder_it_copies_from(self): + payload = b"gba" + files = [self.entry("gba_bios.bin", payload, ["nintendo-gba"])] + listing = self.listing("retroarch", files) + old = self.root / "OLD" + old.mkdir() + (old / "gba_bios.bin").write_bytes(payload) + args = _args(manifest=listing, dest=self.here, declared_only=False, offline=True) + setattr(args, "from", [old]) + text, _asked = self.line_run(args, [""]) + self.assertIn(f"Copying 1 file copied from {old}", text) + self.assertNotIn("found on this machine (", text) + + def test_the_save_lines_name_the_frontend_both_times(self): + onion = self.card("ONION", "Onion") + files = [self.entry("gba_bios.bin", b"g", ["nintendo-gba"]), + self.entry("x/keys.txt", b"k", ["nintendo-3ds"], root="save")] + listing = self.listing("retroarch", files, base_destination="RetroArch", + save_directory={"folder": "saves", "by_core": False}) + text, _asked = self.line_run(_args(manifest=listing, dest=onion, declared_only=False, + offline=False), [], mounts=[onion]) + self.assertNotIn("Onion's save directory", text) + self.assertIn("RetroArch's save directory", text) + + def test_line_mode_repeats_the_command_after_enter(self): + payload = b"gba" + files = [self.entry("gba_bios.bin", payload, ["nintendo-gba"])] + listing = self.listing("retroarch", files) + old = self.root / "OLD" + old.mkdir() + (old / "gba_bios.bin").write_bytes(payload) + args = _args(manifest=listing, declared_only=False, offline=True) + setattr(args, "from", [old]) + real = io.StringIO() + install.LOCAL.reset() + self.addCleanup(install.LOCAL.reset) + invocation = install.Invocation() + invocation.os_type = "linux" + invocation.record(args) + with mock.patch.object(install, "_INVOCATION", invocation), \ + mock.patch.object(install, "_mounted_cards", return_value=[]), \ + mock.patch.object(install, "detect_platforms", + return_value=[("retroarch", self.here)]), \ + mock.patch.object(install, "detect_frontends", return_value=[]), \ + mock.patch.object(install, "detect_os", return_value="linux"), \ + mock.patch.object(install.sys.stdin, "isatty", return_value=True), \ + mock.patch.object(install, "_is_terminal", return_value=True), \ + mock.patch.object(install, "_ask", return_value=""), \ + contextlib.redirect_stdout(io.StringIO()), \ + contextlib.redirect_stderr(io.StringIO()): + install._install(args, real) + said = real.getvalue() + self.assertIn("Same choice without questions: python3 install.py --platform retroarch " + f"--dest {self.here}", said) + self.assertIn("To check this later, run that command with --check.", said) + def test_line_mode_says_the_card_it_found_and_names_its_folder(self): onion = self.card("ONION", "Onion") with mock.patch.object(install, "_mounted_cards", return_value=[onion]), \ @@ -8064,7 +8156,7 @@ class AuditFifteenAndEightScreens(unittest.TestCase): old.mkdir() state = checked_state() state.from_folders = [old] - with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}): + with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: install.OFFLINE_FLAG_VALUE}): lines = [str(line) for line in install._plan_lines(state.check, state.dest, state)] said = [line for line in lines if "USBSTICK" in line] @@ -8931,6 +9023,55 @@ class AuditEighteenAndEleven(unittest.TestCase): self.assertNotIn("Cores:", out.getvalue()) self.assertEqual(asked.call_count, 0) + # code_audit_18 P3-2, P3-3, tui_audit_11 P3-1: the remedies of a card. + + def test_a_full_card_is_freed_never_left_for_another_drive(self): + with mock.patch.object(install.shutil, "disk_usage", + return_value=mock.Mock(free=10)), \ + contextlib.redirect_stderr(io.StringIO()) as err, \ + self.assertRaises(SystemExit): + install._check_free_space(self.root, 1000, "retroarch", card=True) + said = err.getvalue() + self.assertIn("on the card, then run it again", said) + self.assertNotIn("--platform", said) + self.assertNotIn("another drive", said) + + def test_line_mode_names_the_way_to_another_folder(self): + hint = install.Invocation() + with mock.patch.object(install, "_folder_writable", return_value=False), \ + mock.patch.object(install, "_on_read_only_mount", return_value=False): + lines = install.folder_warnings("retroarch", self.root, [], hint, True, False) + self.assertIn("You cannot write to this folder: run it again with another --dest.", + lines) + self.assertNotIn("choose another one", " ".join(lines)) + with mock.patch.object(install, "_folder_writable", return_value=False), \ + mock.patch.object(install, "_on_read_only_mount", return_value=True): + lines = install.folder_warnings("retroarch", self.root, [], hint, True, True) + self.assertEqual(lines, [install.read_only_words(self.root)]) + + def test_a_card_plan_offers_one_remedy_first(self): + for facts, locked in (({"free": 50 * 1024 ** 3, "writable": False, "cores": []}, True), + ({"free": 10, "writable": True, "cores": []}, False)): + with self.subTest(locked=locked): + state = checked_state(facts) + with mock.patch.object(install, "_on_its_card", return_value=True), \ + mock.patch.object(install, "_on_read_only_mount", return_value=locked): + rows = install.tui_rows(state) + text = " ".join(" ".join(install.tui_view(state, 80, 24)).split()) + effects = install.tui_update(state, Key("enter")) + self.assertEqual(rows[0].action, "recheck") + self.assertEqual("install" in [row.action for row in rows], not locked) + self.assertNotIn("choose another one", text) + self.assertEqual(effects, [("run", "check")]) + + def test_nothing_installable_focuses_the_copy(self): + """tui_audit_11 P3-8: offline with nothing to copy, the plan focused + Choose systems, whose systems need the internet too.""" + with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}): + state = checked_state() + rows = install.tui_rows(state) + self.assertEqual(rows[state.focus.get("plan", 0)].action, "fromcard") + def test_the_card_marker_table_is_written_from_the_rules(self): """code_audit_18 P3-6, tui_audit_11 P2-2: the table left out the SHARE drives. It is generated: every card rule and SHARE drive file From 39c2809a4b15da3d611d54dd71bd5f459840eb69 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:27:47 +0100 Subject: [PATCH 09/13] fix: polish the screens audit 11 named Long messages scroll with their position, a small window says why the questions come line by line, one bar rule per width, the folder a config names sits under it and wraps whole, the help while typing a filter lists what does not type, the plan names --core and --region, pc finds the computer setups, rows keep sizes near their names, a partial result counts once, a quit is said as one, the WSL browser names drives by letter and No connection names the pack. --- install.ps1 | 2 +- install.py | 141 +++++++++++++---- install.sh | 2 +- install/launchers/index.json | 16 +- tests/fixtures/tui/checking-40x12.txt | 2 +- tests/fixtures/tui/checking-40x15.txt | 2 +- tests/fixtures/tui/command-40x12.txt | 2 +- tests/fixtures/tui/command-40x15.txt | 2 +- tests/fixtures/tui/confirm-40x12.txt | 2 +- tests/fixtures/tui/confirm-40x15.txt | 2 +- tests/fixtures/tui/create-40x12.txt | 2 +- tests/fixtures/tui/create-40x15.txt | 2 +- tests/fixtures/tui/error-40x12.txt | 6 +- tests/fixtures/tui/error-40x15.txt | 2 +- tests/fixtures/tui/folder-card-40x12.txt | 2 +- tests/fixtures/tui/folder-card-40x15.txt | 2 +- tests/fixtures/tui/installing-40x12.txt | 2 +- tests/fixtures/tui/installing-40x15.txt | 2 +- tests/fixtures/tui/options-120x40.txt | 16 +- tests/fixtures/tui/platforms-120x40.txt | 26 +-- tests/fixtures/tui/preview-40x12.txt | 2 +- tests/fixtures/tui/preview-40x15.txt | 2 +- tests/fixtures/tui/quit-40x12.txt | 2 +- tests/fixtures/tui/quit-40x15.txt | 2 +- tests/fixtures/tui/result-40x12.txt | 2 +- tests/fixtures/tui/result-40x15.txt | 2 +- tests/fixtures/tui/route-evmapy.txt | 144 +---------------- tests/fixtures/tui/route-gptokeyb.txt | 144 +---------------- tests/fixtures/tui/route-gptokeyb_muxterm.txt | 142 +---------------- tests/fixtures/tui/route-keys.txt | 8 +- tests/fixtures/tui/route-miyoo.txt | 72 +-------- tests/fixtures/tui/route-muxterm.txt | 12 +- tests/fixtures/tui/setup-40x12.txt | 2 +- tests/fixtures/tui/setup-40x15.txt | 2 +- tests/fixtures/tui/systems-120x40.txt | 6 +- tests/test_install_tui.py | 148 ++++++++++++++++-- wiki/installer.md | 2 +- 37 files changed, 345 insertions(+), 584 deletions(-) diff --git a/install.ps1 b/install.ps1 index 74bf72daf..7892411d6 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "c030192b6ecfcedfc114ecf0cbcfc509fcca44e398e6d8cefedc7766065df538" + $defaultInstallSha256 = "c138739989d2ea32639c470f3af89d5038604b7fb446f08cc37659e139b76605" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 0852371ef..9d7fc2a7f 100644 --- a/install.py +++ b/install.py @@ -10299,8 +10299,17 @@ def main() -> None: "or --list-targets") # The screens have no choice for --declared-only: the questions ask the # rest, and the run keeps the platform's own list. - if args.tui or not (plain_requested(args) or args.declared_only or tui_blocker(args)): + blocker = "" if args.tui else tui_blocker(args) + if args.tui or not (plain_requested(args) or args.declared_only or blocker): sys.exit(run_tui(args)) + small = re.match(r"the terminal is (\d+x\d+), smaller than", blocker or "") + # A pseudo-terminal nobody sized (script fed from a pipe) is 0x0: no window. + if (small and "0" not in small[1].split("x") and not plain_requested(args) + and not args.declared_only): + # The questions came line by line with no word of why: the page + # says a smaller window says so (tui_audit_11 P3-4). + print(f"The window is {small[1]}: questions come line by line " + f"({TUI_MIN_COLS}x{TUI_MIN_ROWS} for the full screen).") if args.events: # The events own stdout. The text a person reads goes to stderr, so a # program never has to tell one from the other. @@ -12256,8 +12265,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: # names it: "found on this machine" followed "from E:\". sources = list(dict.fromkeys(copy.index.label for copy in copies.values())) named = ", ".join(screen_text(shown_path(label)) for label in sources) - origin = (f"copied from {named}" if all(copy.index is not bios_index for copy in copies.values()) - else "found on this machine") + origin = (f"copied from {named}" if all( + copy.index is not bios_index for copy in copies.values()) + else "found on this machine") print(f"\nCopying {_plural(len(copies), 'file')} {origin}" f"{rest}{where} ({format_size(dl_size)})...") elif LOCAL.pack is not None: @@ -12902,6 +12912,8 @@ class TuiState: self.cli_regions: list = [] self.cores: list = [] self.no_game_data: bool = False + # A quit the user chose, said as one (tui_quit). + self.quit_asked: bool = False self.installed_only: bool = False self.jobs: int = 8 self.passthrough: list = [] @@ -13787,8 +13799,11 @@ def _back(state: TuiState) -> list[tuple]: return effects -def tui_quit(state: TuiState, code: int = 130) -> list[tuple]: - """Leave now: with the install's own code once it ran, else 130.""" +def tui_quit(state: TuiState, code: int = 130, asked: bool = True) -> list[tuple]: + """Leave now: with the install's own code once it ran, else 130. asked + is a quit the user chose (q, Quit), said as one: it read "stopped", the + word of an interrupted run (tui_audit_11 P3-19).""" + state.quit_asked = asked if state.screen == "result": code = state.code if state.code is not None else 1 elif state.screen == "error": @@ -13804,7 +13819,7 @@ def tui_interrupt(state: TuiState) -> list[tuple]: return [("kill",)] state.stopping = True return [("stop",)] - return tui_quit(state) + return tui_quit(state, asked=False) def _start_run(state: TuiState, run: str) -> list[tuple]: @@ -14868,7 +14883,7 @@ _DESKTOP_LEAD: dict[str, tuple[str, ...]] = { # and a Linux one (SteamOS), RetroDECK is a Linux Flatpak, BizHawk publishes # Windows and Linux builds, RetroPie runs on Debian and Ubuntu. _COMPUTER_SYSTEMS: dict[str, tuple[tuple[str, ...], tuple[str, ...]]] = { - "Windows": (("windows",), ("retroarch", "retrobat", "emudeck", "bizhawk")), + "Windows": (("windows", "pc"), ("retroarch", "retrobat", "emudeck", "bizhawk")), "macOS": (("macos", "mac", "osx", "macbook"), ("retroarch",)), "Linux": (("linux", "ubuntu", "debian", "steamos"), ("retroarch", "emudeck", "retrodeck", "bizhawk", "retropie")), @@ -14878,7 +14893,9 @@ _COMPUTER_SYSTEMS: dict[str, tuple[tuple[str, ...], tuple[str, ...]]] = { def _computer_named(plat: str, needle: str) -> list[str]: """The computer systems the filter names that run plat.""" key = _device_key(needle) - if len(key) < 3: + # Two letters are a word on their way: "wi" said "No setup matches" + # between "w" and "win" (tui_audit_11 P3-17). "pc" is one of the words. + if len(key) < 2: return [] return [name for name, (words, plats) in _COMPUTER_SYSTEMS.items() if plat in plats and any(word.startswith(key) for word in words)] @@ -15200,6 +15217,12 @@ def tui_update(state: TuiState, key: TuiKey) -> list[tuple]: if char == "q": return tui_quit(state) rows = tui_rows(state) + if screen == "error" and key.name in ("pgup", "pgdn"): + # The message scrolls, the rows stay (_view_error). + step = max(1, state.rows - len(rows) - 5) + state.focus["error_text"] = max(0, state.focus.get("error_text", 0) + + (step if key.name == "pgdn" else -step)) + return [] if screen in _PAGERS and not rows: return _pager_key(state, key) if not rows: @@ -15500,11 +15523,17 @@ def _route_hint(state: TuiState, parts: tuple) -> tuple: _SHORT_VERBS = ((" Only this system", " Only this"), (" First system", " First")) +# Below this many columns of text the bar always uses the short key names. +TUI_LONG_BAR_WIDTH = 52 + + def _hint(width: int, *parts: str) -> str: """The keys that matter now, short names first given up, then whole entries from the right.""" full = " ".join(parts) - if cell_width(full) <= width: + # One rule for every screen of a width: at 40 columns the plan read + # "A Install" and the next screen "A/Enter Install" (tui_audit_11 P3-5). + if cell_width(full) <= width and width >= TUI_LONG_BAR_WIDTH: return full short = [] for part in parts: @@ -15543,8 +15572,15 @@ _ROW_VERBS = { } +# The widest a row's two columns spread: at 120 columns a system's size sat +# 85 columns from its name (tui_audit_11 P3-20). +TUI_ROW_SPREAD = 78 + + def _row_line(row: TuiRow, focused: bool, width: int) -> TuiLine: left = ("> " if focused else " ") + row.label + if row.right: + width = min(width, max(TUI_ROW_SPREAD, cell_width(left) + 2 + cell_width(row.right))) if row.keep_right and row.right: whole = width - cell_width(left) - 2 forms = (row.right, *row.narrow) @@ -15820,6 +15856,11 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: _styled(headline, "title"), _styled(f"BIOS folder: {_short_path(_plan_folder(state, dest), state.home)}", "path"), ] + found_in = _retroarch_config_at(dest) if dest is not None else None + if found_in: + # Right under the folder it names: after the save lines it read as + # naming them (tui_audit_11 P3-6). + lines.append(_styled(f"Named by {_short_path(Path(found_in[0]), state.home)}", "path")) if state.facts.get("writable") is False: # The remedy itself comes below, after the counts. lines += cannot_write_words(dest, state.platform, tui_hint(state), @@ -15848,9 +15889,6 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: else state.dry_saves if check is state.dry else None) saves_lines = _saves_lines(state, saves) lines += [_styled(line, "path" if index else "") for index, line in enumerate(saves_lines)] - found_in = _retroarch_config_at(dest) if dest is not None else None - if found_in: - lines.append(_styled(f"Named by {_short_path(Path(found_in[0]), state.home)}", "path")) free = state.facts.get("free") if free is not None: lines.append(f"Free space: {format_size(free)}") @@ -16048,7 +16086,9 @@ def _status_after(state: TuiState) -> list[str]: def _view_browse(state: TuiState, width: int, height: int) -> tuple: - where = "Drives" if state.browse_dir is None else middle(str(state.browse_dir), width) + # Under WSL a drive is E:\ on every screen: the title said /mnt/e + # (tui_audit_11 P3-13). + where = "Drives" if state.browse_dir is None else middle(shown_path(state.browse_dir), width) hint: list = [] if state.browse_for == "from": # Two lines at 40 columns: the long form was cut after "copied,". @@ -16159,7 +16199,22 @@ def _view_plan(state: TuiState, width: int, height: int) -> tuple: whole = (f"{check['present']} in place, {check['missing']} missing, " f"{check['wrong_hash']} wrong ") lines = [line for line in lines if not str(line).startswith(whole)] - lines[2:2] = choice + # Under the folder and what named it, in this order: the card's note, + # the choice named on the command line, the choice made here. + at = next((index + 1 for index, line in enumerate(lines) + if str(line).startswith("BIOS folder:")), 1) + if at < len(lines) and str(lines[at]).startswith("Named by "): + at += 1 + named = [*([f"for {len(state.systems)} of {len(tui_systems(state))} systems (--system)"] + if state.cli_systems and state.systems else []), + *(["--core"] if state.cores else []), *(["--region"] if state.regions else [])] + said = ", ".join(named) + # Named on the command line: said in line mode's words, and widened by + # Choose systems. --core and --region were said in line mode alone + # (tui_audit_11 P3-11). + lines[at:at] = [*([state.card_note] if state.card_note + and state.card_note != state.status else []), + *([f"{said[0].upper()}{said[1:]}."] if said else []), *choice] split = _tui_split(state, [(event["dest"], event["size"]) for event in state.files if event["state"] != "present"]) if split or (state.no_game_data and _game_data_row(state) is not None): @@ -16167,16 +16222,16 @@ def _view_plan(state: TuiState, width: int, height: int) -> tuple: # row that leaves it out. lines[1:1] = [f"{split or 'No game data'}. {GAME_DATA_WORDS[0].upper()}" f"{GAME_DATA_WORDS[1:]}."] - if state.cli_systems and state.systems: - # Named on the command line: said, and widened by Choose systems. - lines[2:2] = [f"For {len(state.systems)} of {len(tui_systems(state))} " - "systems (--system)."] - if state.card_note and state.card_note != state.status: - # Under the folder it is about: a stick no file names, taken for - # KNULLI, read as a KNULLI card. - lines.insert(2, state.card_note) if state.status: lines.append(state.status) + if width > 38: + # A folder that does not fit is wrapped, as the first screen wraps + # it: cut in its middle, the plan hid the part being checked before + # Install. Cut only on a 40-column screen (tui_audit_11 P3-7). + lines = [piece for line in lines for piece in ( + [_styled(part) for part in _wrap(str(line), width)] + if getattr(line, "style", "") == "path" and cell_width(str(line)) > width + else [line])] rows = tui_rows(state) if width < 54: rows = [row._replace(label="Copy from your old card or a folder") @@ -16594,12 +16649,17 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: # As the plan counted them, and as line mode ends. games = done.count(GAME_DATA) lines.append(f"Installed: {kinds_words(len(done) - games, games)}.") + sources = _tui_sources(state, installed, len(failed) + left) + if left and installed and sources: + # One count, then the remedy, as line mode says it: the count came + # twice, the remedy between them (tui_audit_11 P3-18). + lines[0] = sources + sources = "" + if sources: + lines.append(sources) if left: lines.append(_offline_remedy([_short_path(Path(folder), state.home) for folder in state.from_folders])) - sources = _tui_sources(state, installed, len(failed) + left) - if sources: - lines.append(sources) ok = {dest for dest, good, _why in state.finished if good} if any(reason and dest in ok for dest, (_folder, reason) in state.local_dests.items()): # A fix copies: the misplaced files are the user's, never moved. @@ -16697,9 +16757,22 @@ def _view_result(state: TuiState, width: int, height: int) -> tuple: def _view_error(state: TuiState, width: int, height: int) -> tuple: + """The message and its rows. A message longer than the room the rows + leave scrolls, with a line saying which lines are in view: at 40x12 the + offline screen dropped its remedy without a word (tui_audit_11 P3-2).""" + rows = tui_rows(state) + text = _text_lines(list(state.error), width) + room = height - len(rows) - (1 if rows else 0) + hint: tuple = ("A/Enter Choose", "B/Esc Back") + if len(text) > room >= 2: + first = max(0, min(state.focus.get("error_text", 0), len(text) - (room - 1))) + state.focus["error_text"] = first + text = _scrolled(text, first, room) + page = (TUI_ROUTES.get(state.pad, TUI_ROUTES["both"]).help["page"] + or "PgUp/PgDn").split(", ")[0] + hint += (f"{page} Scroll",) return (state.error_title or "Something went wrong", - _compose(state, width, height, state.error, tui_rows(state)), - ("A/Enter Choose", "B/Esc Back")) + _compose(state, width, height, text, rows), hint) def _view_quit(state: TuiState, width: int, height: int) -> tuple: @@ -16740,6 +16813,12 @@ def _help_lines(state: TuiState) -> list[tuple[str, str]]: return [(names["stop"], "Stop: what is done stays done"), *([(names["kill"], "Stop")] if names["kill"] else []), *_route_extras(state), *_help_key(state)] + if state.editing: + # Typing a filter: Space, a, n and q type there, and the help listed + # them as ticks, all, none and quit (tui_audit_11 P3-9). + return [("Enter", "Done"), ("Esc", "Clear the filter"), + ("Backspace", "Delete a character"), ("Ctrl-U", "Clear"), ("Ctrl-C", "Quit"), + *_route_extras(state)] if screen == "path": return [(names["enter"], "Use this folder"), ("Backspace", "Delete a character"), ("Ctrl-U", "Clear"), (names["esc"], "Back"), ("Ctrl-C", "Quit"), @@ -17228,7 +17307,11 @@ def _tui_fetch(plat: str, inbox: queue.Queue, log: io.StringIO) -> None: ", then choose Try again") + "."] if code == "network": lines = [f"The list of BIOS files could not be downloaded: {words}.", - "Check the Wi-Fi or the cable, then choose Try again."] + "Check the Wi-Fi or the cable, then choose Try again.", + # A handheld with no Wi-Fi has the pack as its way + # (tui_audit_11 P3-3). + "Or copy a pack to the card and run the installer with --from-pack " + "PACK: no network is needed then."] proxy = next((name for name in ("HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy") if os.environ.get(name)), "") if proxy: @@ -17481,6 +17564,8 @@ def _closing_lines(state: TuiState) -> list[str]: for line in state.error)] if state.exit_code == 0: return [f"RetroBIOS: everything is in place in {state.dest}."] + if state.quit_asked: + return ["RetroBIOS: quit. Nothing was installed."] return ["RetroBIOS: stopped. Nothing was installed."] diff --git a/install.sh b/install.sh index d228c5a4c..8dc8ad1b3 100755 --- a/install.sh +++ b/install.sh @@ -18,7 +18,7 @@ esac TEMP_INSTALLER="" TEMP_DIRECTORY="" DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" -DEFAULT_INSTALL_SHA256="c030192b6ecfcedfc114ecf0cbcfc509fcca44e398e6d8cefedc7766065df538" +DEFAULT_INSTALL_SHA256="c138739989d2ea32639c470f3af89d5038604b7fb446f08cc37659e139b76605" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 100185b7c..a68f0af23 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 813666, - "sha256": "c030192b6ecfcedfc114ecf0cbcfc509fcca44e398e6d8cefedc7766065df538", + "size": 818472, + "sha256": "c138739989d2ea32639c470f3af89d5038604b7fb446f08cc37659e139b76605", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 866215, - "sha256": "cfe8dc0a1cda5a63d810ebcc8f28e1f1797446d5ef72447d0a64b3f68639a258" + "size": 871021, + "sha256": "0b2f463994437ab12013039a8eec8ce94040a03594e9d820a0da58a1058ef9e1" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 813666, - "sha256": "c030192b6ecfcedfc114ecf0cbcfc509fcca44e398e6d8cefedc7766065df538", + "size": 818472, + "sha256": "c138739989d2ea32639c470f3af89d5038604b7fb446f08cc37659e139b76605", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 813666, - "sha256": "c030192b6ecfcedfc114ecf0cbcfc509fcca44e398e6d8cefedc7766065df538", + "size": 818472, + "sha256": "c138739989d2ea32639c470f3af89d5038604b7fb446f08cc37659e139b76605", "executable": false }, { diff --git a/tests/fixtures/tui/checking-40x12.txt b/tests/fixtures/tui/checking-40x12.txt index f49a3e15a..599dd3dc1 100644 --- a/tests/fixtures/tui/checking-40x12.txt +++ b/tests/fixtures/tui/checking-40x12.txt @@ -9,4 +9,4 @@ - Ctrl-C Quit B/Esc Quit + Ctrl-C Quit B Quit diff --git a/tests/fixtures/tui/checking-40x15.txt b/tests/fixtures/tui/checking-40x15.txt index 5c131dfe1..5345d7513 100644 --- a/tests/fixtures/tui/checking-40x15.txt +++ b/tests/fixtures/tui/checking-40x15.txt @@ -12,4 +12,4 @@ - Ctrl-C Quit B/Esc Quit + Ctrl-C Quit B Quit diff --git a/tests/fixtures/tui/command-40x12.txt b/tests/fixtures/tui/command-40x12.txt index f05700025..1b955d059 100644 --- a/tests/fixtures/tui/command-40x12.txt +++ b/tests/fixtures/tui/command-40x12.txt @@ -9,4 +9,4 @@ - A/Enter Back + A Back diff --git a/tests/fixtures/tui/command-40x15.txt b/tests/fixtures/tui/command-40x15.txt index 6fcca70a1..3e8530cf0 100644 --- a/tests/fixtures/tui/command-40x15.txt +++ b/tests/fixtures/tui/command-40x15.txt @@ -12,4 +12,4 @@ - A/Enter Back + A Back diff --git a/tests/fixtures/tui/confirm-40x12.txt b/tests/fixtures/tui/confirm-40x12.txt index a6829f6b0..704d86a7a 100644 --- a/tests/fixtures/tui/confirm-40x12.txt +++ b/tests/fixtures/tui/confirm-40x12.txt @@ -9,4 +9,4 @@ > Install Back - A/Enter Install B/Esc Back ? Help + A Install B Back ? Help diff --git a/tests/fixtures/tui/confirm-40x15.txt b/tests/fixtures/tui/confirm-40x15.txt index 250844ab7..6cb81ee9f 100644 --- a/tests/fixtures/tui/confirm-40x15.txt +++ b/tests/fixtures/tui/confirm-40x15.txt @@ -12,4 +12,4 @@ - A/Enter Install B/Esc Back ? Help + A Install B Back ? Help diff --git a/tests/fixtures/tui/create-40x12.txt b/tests/fixtures/tui/create-40x12.txt index c5ef8083c..e767d4777 100644 --- a/tests/fixtures/tui/create-40x12.txt +++ b/tests/fixtures/tui/create-40x12.txt @@ -9,4 +9,4 @@ - A/Enter Create B/Esc Quit + A Create B Quit diff --git a/tests/fixtures/tui/create-40x15.txt b/tests/fixtures/tui/create-40x15.txt index 600629ca8..b62b0cd1a 100644 --- a/tests/fixtures/tui/create-40x15.txt +++ b/tests/fixtures/tui/create-40x15.txt @@ -12,4 +12,4 @@ - A/Enter Create B/Esc Quit + A Create B Quit diff --git a/tests/fixtures/tui/error-40x12.txt b/tests/fixtures/tui/error-40x12.txt index 224cf89d2..d3c843856 100644 --- a/tests/fixtures/tui/error-40x12.txt +++ b/tests/fixtures/tui/error-40x12.txt @@ -5,8 +5,8 @@ -2] Name or service not known Address: https://raw.githubusercontent.com/ - Abdess/retrobios/main/install/ - retroarch.json + 1-5 of 7 + > Choose another setup or folder Quit - A/Enter Choose B/Esc Back + A Choose B Back PgUp/PgDn Scroll diff --git a/tests/fixtures/tui/error-40x15.txt b/tests/fixtures/tui/error-40x15.txt index df4e6f1c3..a7d362f90 100644 --- a/tests/fixtures/tui/error-40x15.txt +++ b/tests/fixtures/tui/error-40x15.txt @@ -12,4 +12,4 @@ Quit - A/Enter Choose B/Esc Back + A Choose B Back diff --git a/tests/fixtures/tui/folder-card-40x12.txt b/tests/fixtures/tui/folder-card-40x12.txt index 8a348dfe2..ef783de21 100644 --- a/tests/fixtures/tui/folder-card-40x12.txt +++ b/tests/fixtures/tui/folder-card-40x12.txt @@ -9,4 +9,4 @@ > Browse for a folder Type a path Look again - A/Enter Browse B/Esc Back ? Help + A Browse B Back ? Help diff --git a/tests/fixtures/tui/folder-card-40x15.txt b/tests/fixtures/tui/folder-card-40x15.txt index c791e4241..03aba349e 100644 --- a/tests/fixtures/tui/folder-card-40x15.txt +++ b/tests/fixtures/tui/folder-card-40x15.txt @@ -12,4 +12,4 @@ Look again - A/Enter Browse B/Esc Back ? Help + A Browse B Back ? Help diff --git a/tests/fixtures/tui/installing-40x12.txt b/tests/fixtures/tui/installing-40x12.txt index 5750b4633..d56a30bbd 100644 --- a/tests/fixtures/tui/installing-40x12.txt +++ b/tests/fixtures/tui/installing-40x12.txt @@ -9,4 +9,4 @@ FAILED scph5500.bin (HTTP 404) ok gba_bios.bin - B/Esc Stop ? Help + B Stop ? Help diff --git a/tests/fixtures/tui/installing-40x15.txt b/tests/fixtures/tui/installing-40x15.txt index 51aeca7c0..950ad6515 100644 --- a/tests/fixtures/tui/installing-40x15.txt +++ b/tests/fixtures/tui/installing-40x15.txt @@ -12,4 +12,4 @@ - B/Esc Stop ? Help + B Stop ? Help diff --git a/tests/fixtures/tui/options-120x40.txt b/tests/fixtures/tui/options-120x40.txt index 21aae2729..eac6c418f 100644 --- a/tests/fixtures/tui/options-120x40.txt +++ b/tests/fixtures/tui/options-120x40.txt @@ -2,14 +2,14 @@ Each row is an option of the command line, named on the right. - > Preferred regions: North America, Japan --region - Game data: kept --no-game-data - Installed cores only: no (1 found) --installed-cores - Cores: all --core - Parallel downloads: 8 --jobs - BIOS folder: ~/.config/retroarch/system --dest - Copy from: none --from - Preview the file list --dry-run + > Preferred regions: North America, Japan --region + Game data: kept --no-game-data + Installed cores only: no (1 found) --installed-cores + Cores: all --core + Parallel downloads: 8 --jobs + BIOS folder: ~/.config/retroarch/system --dest + Copy from: none --from + Preview the file list --dry-run Show the command line diff --git a/tests/fixtures/tui/platforms-120x40.txt b/tests/fixtures/tui/platforms-120x40.txt index c4f74e812..0389c94a8 100644 --- a/tests/fixtures/tui/platforms-120x40.txt +++ b/tests/fixtures/tui/platforms-120x40.txt @@ -14,26 +14,26 @@ BizHawk MiSTer FPGA RetroPie - Allium, SD card Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip - AmberELEC, SD card RG351, RG552 - Anbernic stock firmware, SD card RG35XX Plus, RG35XX H, RG35XX SP, RG28XX + Allium, SD card Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip + AmberELEC, SD card RG351, RG552 + Anbernic stock firmware, SD card RG35XX Plus, RG35XX H, RG35XX SP, RG28XX ArchR, SD card R36S, R33S, R36S Soysauce, Odroid Go Advance, Odroid Go Super, RG351V, +6 - ArkOS, SD card RG353M, RG353V, RG503, RGB30, RK2023, RG351MP, RG351V, R35S, RGB10 - CrossMix-OS, SD card TrimUI Smart Pro - dArkOS, SD card Odroid Go Advance, RGB10, RG351MP, G350 - EmuELEC, SD card Odroid Go + ArkOS, SD card RG353M, RG353V, RG503, RGB30, RK2023, RG351MP, RG351V, R35S, RGB10 + CrossMix-OS, SD card TrimUI Smart Pro + dArkOS, SD card Odroid Go Advance, RGB10, RG351MP, G350 + EmuELEC, SD card Odroid Go JELOS, SD card RG351M, RG351V, RG353M, RG353P, RG353V, RG353VS, RG503, RG552, R33S, R36S, +24 KNULLI, SD card RG ARC-S, RG28XX, RG34XX, RG35XX, RG35XX Plus, RG35XX H, RG35XX 2024, +15 - KOLOS, SD card RG353P, RG353M, RG353V, RG353VS, RG503, RK2023, RGB30, RGB10 Max 3 - Koriki, SD card Miyoo Mini + KOLOS, SD card RG353P, RG353M, RG353V, RG353VS, RG503, RK2023, RGB30, RGB10 Max 3 + Koriki, SD card Miyoo Mini MinUI, SD card RG28XX, RG34XX, RG34XX SP, RG35XX, RG35XX Plus, RG35XX H, RG35XX SP, +16 MiyooCFW, SD card BittBoy, Pocket Go, PowKiddy Q90, PowKiddy V90, PowKiddy Q20 Mini, Sup M3, +1 muOS, SD card RG35XX H, RG35XX Plus, RG35XX SP, RG35XX 2024, RG34XX, RG40XX H, RG40XX V, +4 MyMinUI, SD card RG35XX, Miyoo Mini, Miyoo Mini Flip, Miyoo Mini Plus, Miyoo A30, +16 - NextUI, SD card TrimUI Brick, TrimUI Smart Pro - Onion, SD card Miyoo Mini, Miyoo Mini Plus - plumOS, SD card MagicX XU20 V32 - RetroOZ, SD card Odroid Go Super, Odroid Go Advance, RGB10 Max, RGB10 Max 2 + NextUI, SD card TrimUI Brick, TrimUI Smart Pro + Onion, SD card Miyoo Mini, Miyoo Mini Plus + plumOS, SD card MagicX XU20 V32 + RetroOZ, SD card Odroid Go Super, Odroid Go Advance, RGB10 Max, RGB10 Max 2 ROCKNIX, SD card RG351, RG353, RG503, RG552, RG 55G1, RG ARC, RG35XX 2024, RG35XX Plus, +37 spruceOS, SD card Miyoo A30, Miyoo Flip, Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip, +5 UnofficialOS, SD card R35S, R36S, RG351M, RG351P, RG351V, RG353M, RG353P, RG353V, RG503, +5 diff --git a/tests/fixtures/tui/preview-40x12.txt b/tests/fixtures/tui/preview-40x12.txt index 507cf8b5d..55499e667 100644 --- a/tests/fixtures/tui/preview-40x12.txt +++ b/tests/fixtures/tui/preview-40x12.txt @@ -9,4 +9,4 @@ missing 16.0 KB gba_bios.bin - A/Enter Back + A Back diff --git a/tests/fixtures/tui/preview-40x15.txt b/tests/fixtures/tui/preview-40x15.txt index 110e70660..873f126d8 100644 --- a/tests/fixtures/tui/preview-40x15.txt +++ b/tests/fixtures/tui/preview-40x15.txt @@ -12,4 +12,4 @@ - A/Enter Back + A Back diff --git a/tests/fixtures/tui/quit-40x12.txt b/tests/fixtures/tui/quit-40x12.txt index 8d91077c0..fce65bb05 100644 --- a/tests/fixtures/tui/quit-40x12.txt +++ b/tests/fixtures/tui/quit-40x12.txt @@ -9,4 +9,4 @@ - A/Enter Quit B/Esc Stay + A Quit B Stay diff --git a/tests/fixtures/tui/quit-40x15.txt b/tests/fixtures/tui/quit-40x15.txt index 675109b69..1f2fd0f1c 100644 --- a/tests/fixtures/tui/quit-40x15.txt +++ b/tests/fixtures/tui/quit-40x15.txt @@ -12,4 +12,4 @@ - A/Enter Quit B/Esc Stay + A Quit B Stay diff --git a/tests/fixtures/tui/result-40x12.txt b/tests/fixtures/tui/result-40x12.txt index ea7a3e5f6..568b9dce0 100644 --- a/tests/fixtures/tui/result-40x12.txt +++ b/tests/fixtures/tui/result-40x12.txt @@ -9,4 +9,4 @@ To put them back: python3 install.py 1-8 of 17 - A/Enter Exit Up/Down Scroll + A Exit ^v Scroll diff --git a/tests/fixtures/tui/result-40x15.txt b/tests/fixtures/tui/result-40x15.txt index 683877220..45e55c93a 100644 --- a/tests/fixtures/tui/result-40x15.txt +++ b/tests/fixtures/tui/result-40x15.txt @@ -12,4 +12,4 @@ /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 1-11 of 17 - A/Enter Exit Up/Down Scroll + A Exit ^v Scroll diff --git a/tests/fixtures/tui/route-evmapy.txt b/tests/fixtures/tui/route-evmapy.txt index 4feac0d4a..89b845d3a 100644 --- a/tests/fixtures/tui/route-evmapy.txt +++ b/tests/fixtures/tui/route-evmapy.txt @@ -322,35 +322,6 @@ A Use this folder B Back -=== create 40x12 - RetroBIOS: Create the folder? - - /home/user/bios/new does not exist. - The nearest folder that does: - /home/user - - > Create it and continue - Choose another folder - - - - A Create B Quit -=== create 40x15 - RetroBIOS: Create the folder? - - /home/user/bios/new does not exist. - The nearest folder that does: - /home/user - - > Create it and continue - Choose another folder - - - - - - - A Create B Quit === create 80x24 RetroBIOS: Create the folder? @@ -604,7 +575,7 @@ Copy from: none Preview the file list Show the command line - A Open B Back Left/Right Set + A Open B Back <> Set === options 40x15 RetroBIOS: Options @@ -620,7 +591,7 @@ Sets --region - A Open B Back Left/Right Set + A Open B Back <> Set === options 80x24 RetroBIOS: Options @@ -658,7 +629,7 @@ [ ] Japan - A Tick B Done Left/Right Order + A Tick B Done <> Order === regions 40x15 RetroBIOS: Preferred regions @@ -674,7 +645,7 @@ - A Tick B Done Left/Right Order + A Tick B Done <> Order === regions 80x24 RetroBIOS: Preferred regions @@ -754,35 +725,6 @@ A Filter B Back X Tick -=== preview 40x12 - RetroBIOS: Preview - - 4 files to download, 1.0 MB. Nothing - is written. - - missing 512.0 KB scph5500.bin - wrong copy 512.0 KB scph5501.bin - missing 4 B pcsx/extra.bin - missing 16.0 KB gba_bios.bin - - - A Back -=== preview 40x15 - RetroBIOS: Preview - - 4 files to download, 1.0 MB. Nothing - is written. - - missing 512.0 KB scph5500.bin - wrong copy 512.0 KB scph5501.bin - missing 4 B pcsx/extra.bin - missing 16.0 KB gba_bios.bin - - - - - - A Back === preview 80x24 RetroBIOS: Preview @@ -807,35 +749,6 @@ - A Back -=== command 40x12 - RetroBIOS: Command line - - The same choice without questions: - - python3 install.py --platform - retroarch --dest - /home/user/.config/retroarch/system - --system nintendo-gba - - - - A Back -=== command 40x15 - RetroBIOS: Command line - - The same choice without questions: - - python3 install.py --platform - retroarch --dest - /home/user/.config/retroarch/system - --system nintendo-gba - - - - - - A Back === command 80x24 RetroBIOS: Command line @@ -1032,27 +945,11 @@ -2] Name or service not known Address: https://raw.githubusercontent.com/ - Abdess/retrobios/main/install/ - retroarch.json - > Choose another setup or folder - Quit - A Choose B Back -=== error 40x15 - RetroBIOS: Something went wrong - - Could not fetch the file list for - retroarch after 3 attempts: [Errno - -2] Name or service not known - Address: - https://raw.githubusercontent.com/ - Abdess/retrobios/main/install/ - retroarch.json + 1-5 of 7 > Choose another setup or folder Quit - - - A Choose B Back + A Choose B Back L1/R1 Scroll === error 80x24 RetroBIOS: Something went wrong @@ -1078,35 +975,6 @@ A Choose B Back -=== quit 40x12 - RetroBIOS: Quit? - - Quit without installing? Nothing was - written. - - > Quit - Stay - - - - - A Quit B Stay -=== quit 40x15 - RetroBIOS: Quit? - - Quit without installing? Nothing was - written. - - > Quit - Stay - - - - - - - - A Quit B Stay === quit 80x24 RetroBIOS: Quit? diff --git a/tests/fixtures/tui/route-gptokeyb.txt b/tests/fixtures/tui/route-gptokeyb.txt index c36055601..7ae640cbc 100644 --- a/tests/fixtures/tui/route-gptokeyb.txt +++ b/tests/fixtures/tui/route-gptokeyb.txt @@ -322,35 +322,6 @@ A Use this folder B Back -=== create 40x12 - RetroBIOS: Create the folder? - - /home/user/bios/new does not exist. - The nearest folder that does: - /home/user - - > Create it and continue - Choose another folder - - - - A Create B Quit -=== create 40x15 - RetroBIOS: Create the folder? - - /home/user/bios/new does not exist. - The nearest folder that does: - /home/user - - > Create it and continue - Choose another folder - - - - - - - A Create B Quit === create 80x24 RetroBIOS: Create the folder? @@ -604,7 +575,7 @@ Copy from: none Preview the file list Show the command line - A Open B Back Left/Right Set + A Open B Back <> Set === options 40x15 RetroBIOS: Options @@ -620,7 +591,7 @@ Sets --region - A Open B Back Left/Right Set + A Open B Back <> Set === options 80x24 RetroBIOS: Options @@ -658,7 +629,7 @@ [ ] Japan - A Tick B Done Left/Right Order + A Tick B Done <> Order === regions 40x15 RetroBIOS: Preferred regions @@ -674,7 +645,7 @@ - A Tick B Done Left/Right Order + A Tick B Done <> Order === regions 80x24 RetroBIOS: Preferred regions @@ -754,35 +725,6 @@ A Filter B Back X Tick -=== preview 40x12 - RetroBIOS: Preview - - 4 files to download, 1.0 MB. Nothing - is written. - - missing 512.0 KB scph5500.bin - wrong copy 512.0 KB scph5501.bin - missing 4 B pcsx/extra.bin - missing 16.0 KB gba_bios.bin - - - A Back -=== preview 40x15 - RetroBIOS: Preview - - 4 files to download, 1.0 MB. Nothing - is written. - - missing 512.0 KB scph5500.bin - wrong copy 512.0 KB scph5501.bin - missing 4 B pcsx/extra.bin - missing 16.0 KB gba_bios.bin - - - - - - A Back === preview 80x24 RetroBIOS: Preview @@ -807,35 +749,6 @@ - A Back -=== command 40x12 - RetroBIOS: Command line - - The same choice without questions: - - python3 install.py --platform - retroarch --dest - /home/user/.config/retroarch/system - --system nintendo-gba - - - - A Back -=== command 40x15 - RetroBIOS: Command line - - The same choice without questions: - - python3 install.py --platform - retroarch --dest - /home/user/.config/retroarch/system - --system nintendo-gba - - - - - - A Back === command 80x24 RetroBIOS: Command line @@ -1032,27 +945,11 @@ -2] Name or service not known Address: https://raw.githubusercontent.com/ - Abdess/retrobios/main/install/ - retroarch.json - > Choose another setup or folder - Quit - A Choose B Back -=== error 40x15 - RetroBIOS: Something went wrong - - Could not fetch the file list for - retroarch after 3 attempts: [Errno - -2] Name or service not known - Address: - https://raw.githubusercontent.com/ - Abdess/retrobios/main/install/ - retroarch.json + 1-5 of 7 > Choose another setup or folder Quit - - - A Choose B Back + A Choose B Back L1/R1 Scroll === error 80x24 RetroBIOS: Something went wrong @@ -1078,35 +975,6 @@ A Choose B Back -=== quit 40x12 - RetroBIOS: Quit? - - Quit without installing? Nothing was - written. - - > Quit - Stay - - - - - A Quit B Stay -=== quit 40x15 - RetroBIOS: Quit? - - Quit without installing? Nothing was - written. - - > Quit - Stay - - - - - - - - A Quit B Stay === quit 80x24 RetroBIOS: Quit? diff --git a/tests/fixtures/tui/route-gptokeyb_muxterm.txt b/tests/fixtures/tui/route-gptokeyb_muxterm.txt index c36055601..5a8efe5a0 100644 --- a/tests/fixtures/tui/route-gptokeyb_muxterm.txt +++ b/tests/fixtures/tui/route-gptokeyb_muxterm.txt @@ -322,35 +322,6 @@ A Use this folder B Back -=== create 40x12 - RetroBIOS: Create the folder? - - /home/user/bios/new does not exist. - The nearest folder that does: - /home/user - - > Create it and continue - Choose another folder - - - - A Create B Quit -=== create 40x15 - RetroBIOS: Create the folder? - - /home/user/bios/new does not exist. - The nearest folder that does: - /home/user - - > Create it and continue - Choose another folder - - - - - - - A Create B Quit === create 80x24 RetroBIOS: Create the folder? @@ -604,7 +575,7 @@ Copy from: none Preview the file list Show the command line - A Open B Back Left/Right Set + A Open B Back <> Set === options 40x15 RetroBIOS: Options @@ -620,7 +591,7 @@ Sets --region - A Open B Back Left/Right Set + A Open B Back <> Set === options 80x24 RetroBIOS: Options @@ -658,7 +629,7 @@ [ ] Japan - A Tick B Done Left/Right Order + A Tick B Done <> Order === regions 40x15 RetroBIOS: Preferred regions @@ -674,7 +645,7 @@ - A Tick B Done Left/Right Order + A Tick B Done <> Order === regions 80x24 RetroBIOS: Preferred regions @@ -754,35 +725,6 @@ A Filter B Back X Tick -=== preview 40x12 - RetroBIOS: Preview - - 4 files to download, 1.0 MB. Nothing - is written. - - missing 512.0 KB scph5500.bin - wrong copy 512.0 KB scph5501.bin - missing 4 B pcsx/extra.bin - missing 16.0 KB gba_bios.bin - - - A Back -=== preview 40x15 - RetroBIOS: Preview - - 4 files to download, 1.0 MB. Nothing - is written. - - missing 512.0 KB scph5500.bin - wrong copy 512.0 KB scph5501.bin - missing 4 B pcsx/extra.bin - missing 16.0 KB gba_bios.bin - - - - - - A Back === preview 80x24 RetroBIOS: Preview @@ -807,35 +749,6 @@ - A Back -=== command 40x12 - RetroBIOS: Command line - - The same choice without questions: - - python3 install.py --platform - retroarch --dest - /home/user/.config/retroarch/system - --system nintendo-gba - - - - A Back -=== command 40x15 - RetroBIOS: Command line - - The same choice without questions: - - python3 install.py --platform - retroarch --dest - /home/user/.config/retroarch/system - --system nintendo-gba - - - - - - A Back === command 80x24 RetroBIOS: Command line @@ -1032,26 +945,10 @@ -2] Name or service not known Address: https://raw.githubusercontent.com/ - Abdess/retrobios/main/install/ - retroarch.json - > Choose another setup or folder - Quit - A Choose B Back -=== error 40x15 - RetroBIOS: Something went wrong - - Could not fetch the file list for - retroarch after 3 attempts: [Errno - -2] Name or service not known - Address: - https://raw.githubusercontent.com/ - Abdess/retrobios/main/install/ - retroarch.json + 1-5 of 7 > Choose another setup or folder Quit - - A Choose B Back === error 80x24 RetroBIOS: Something went wrong @@ -1078,35 +975,6 @@ A Choose B Back -=== quit 40x12 - RetroBIOS: Quit? - - Quit without installing? Nothing was - written. - - > Quit - Stay - - - - - A Quit B Stay -=== quit 40x15 - RetroBIOS: Quit? - - Quit without installing? Nothing was - written. - - > Quit - Stay - - - - - - - - A Quit B Stay === quit 80x24 RetroBIOS: Quit? diff --git a/tests/fixtures/tui/route-keys.txt b/tests/fixtures/tui/route-keys.txt index ce2c5937e..e26ac816f 100644 --- a/tests/fixtures/tui/route-keys.txt +++ b/tests/fixtures/tui/route-keys.txt @@ -928,7 +928,7 @@ To put them back: python3 install.py 1-8 of 17 - Enter Exit Up/Down Scroll + Enter Exit ^v Scroll === result 40x15 RetroBIOS: Finished with errors @@ -944,7 +944,7 @@ /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200 1-11 of 17 - Enter Exit Up/Down Scroll + Enter Exit ^v Scroll === result 80x24 RetroBIOS: Finished with errors @@ -978,8 +978,8 @@ -2] Name or service not known Address: https://raw.githubusercontent.com/ - Abdess/retrobios/main/install/ - retroarch.json + 1-5 of 7 + > Choose another setup or folder Quit Enter Choose Esc Back diff --git a/tests/fixtures/tui/route-miyoo.txt b/tests/fixtures/tui/route-miyoo.txt index 7a1554714..77041c30d 100644 --- a/tests/fixtures/tui/route-miyoo.txt +++ b/tests/fixtures/tui/route-miyoo.txt @@ -604,7 +604,7 @@ Copy from: none Preview the file list Show the command line - A Open R2 Back Left/Right Set + A Open R2 Back <> Set === options 40x15 RetroBIOS: Options @@ -620,7 +620,7 @@ Sets --region - A Open R2 Back Left/Right Set + A Open R2 Back <> Set === options 80x24 RetroBIOS: Options @@ -658,7 +658,7 @@ [ ] Japan - A Tick R2 Done Left/Right Order + A Tick R2 Done <> Order === regions 40x15 RetroBIOS: Preferred regions @@ -674,7 +674,7 @@ - A Tick R2 Done Left/Right Order + A Tick R2 Done <> Order === regions 80x24 RetroBIOS: Preferred regions @@ -754,35 +754,6 @@ A Filter R2 Back -=== preview 40x12 - RetroBIOS: Preview - - 4 files to download, 1.0 MB. Nothing - is written. - - missing 512.0 KB scph5500.bin - wrong copy 512.0 KB scph5501.bin - missing 4 B pcsx/extra.bin - missing 16.0 KB gba_bios.bin - - - A Back -=== preview 40x15 - RetroBIOS: Preview - - 4 files to download, 1.0 MB. Nothing - is written. - - missing 512.0 KB scph5500.bin - wrong copy 512.0 KB scph5501.bin - missing 4 B pcsx/extra.bin - missing 16.0 KB gba_bios.bin - - - - - - A Back === preview 80x24 RetroBIOS: Preview @@ -807,35 +778,6 @@ - A Back -=== command 40x12 - RetroBIOS: Command line - - The same choice without questions: - - python3 install.py --platform - retroarch --dest - /home/user/.config/retroarch/system - --system nintendo-gba - - - - A Back -=== command 40x15 - RetroBIOS: Command line - - The same choice without questions: - - python3 install.py --platform - retroarch --dest - /home/user/.config/retroarch/system - --system nintendo-gba - - - - - - A Back === command 80x24 RetroBIOS: Command line @@ -1032,11 +974,11 @@ -2] Name or service not known Address: https://raw.githubusercontent.com/ - Abdess/retrobios/main/install/ - retroarch.json + 1-5 of 7 + > Choose another setup or folder Quit - A Choose R2 Back + A Choose R2 Back L1/R1 Scroll === error 40x15 RetroBIOS: Something went wrong diff --git a/tests/fixtures/tui/route-muxterm.txt b/tests/fixtures/tui/route-muxterm.txt index e754f340f..1e8af124d 100644 --- a/tests/fixtures/tui/route-muxterm.txt +++ b/tests/fixtures/tui/route-muxterm.txt @@ -604,7 +604,7 @@ Copy from: none Preview the file list Show the command line - Start Open B Back Left/Right Set + Start Open B Back <> Set === options 40x15 RetroBIOS: Options @@ -620,7 +620,7 @@ Sets --region - Start Open B Back Left/Right Set + Start Open B Back <> Set === options 80x24 RetroBIOS: Options @@ -658,7 +658,7 @@ [ ] Japan - Start Tick B Done Left/Right Order + Start Tick B Done <> Order === regions 40x15 RetroBIOS: Preferred regions @@ -674,7 +674,7 @@ - Start Tick B Done Left/Right Order + Start Tick B Done <> Order === regions 80x24 RetroBIOS: Preferred regions @@ -1032,8 +1032,8 @@ -2] Name or service not known Address: https://raw.githubusercontent.com/ - Abdess/retrobios/main/install/ - retroarch.json + 1-5 of 7 + > Choose another setup or folder Quit Start Choose B Back diff --git a/tests/fixtures/tui/setup-40x12.txt b/tests/fixtures/tui/setup-40x12.txt index 04e73733b..2d87a3413 100644 --- a/tests/fixtures/tui/setup-40x12.txt +++ b/tests/fixtures/tui/setup-40x12.txt @@ -9,4 +9,4 @@ BIOS folder: /home/user/.config/retroarch/system ES-DE found: its BIOS files go to each - A/Enter Continue B/Esc Quit ? Help + A Continue B Quit ? Help diff --git a/tests/fixtures/tui/setup-40x15.txt b/tests/fixtures/tui/setup-40x15.txt index d2d99ab29..faa6cbd38 100644 --- a/tests/fixtures/tui/setup-40x15.txt +++ b/tests/fixtures/tui/setup-40x15.txt @@ -12,4 +12,4 @@ emulator, not to ES-DE itself. - A/Enter Continue B/Esc Quit ? Help + A Continue B Quit ? Help diff --git a/tests/fixtures/tui/systems-120x40.txt b/tests/fixtures/tui/systems-120x40.txt index 2b859a315..0580bbaa7 100644 --- a/tests/fixtures/tui/systems-120x40.txt +++ b/tests/fixtures/tui/systems-120x40.txt @@ -3,9 +3,9 @@ All None Filter (/ to type) - > [x] nintendo-gba (gba) 1 file, 16.0 KB - [x] sony-playstation 3 files, 1.0 MB - [ ] nintendo-famicom-disk (ファミコン) in place + > [x] nintendo-gba (gba) 1 file, 16.0 KB + [x] sony-playstation 3 files, 1.0 MB + [ ] nintendo-famicom-disk (ファミコン) in place diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 5abba0828..b2a023ddc 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -1013,7 +1013,7 @@ class Views(unittest.TestCase): with self.subTest(screen=name): hint = install.tui_view(state, 40, 15)[-1].strip() self.assertTrue(hint.startswith(("A/Enter", "A ", "Enter", "Ctrl-C", "Any key", - "B/Esc Stop")), hint) + "B/Esc Stop", "B Stop")), hint) def test_focus_is_marked_without_colour(self): lines = install.tui_view(fresh_state(), 80, 24) @@ -1030,12 +1030,12 @@ def _bar_keys(line: str) -> set: # The keys each route has, as the bar may name them. "Any" opens "Any key # closes this help", <> and ^v are the narrow forms of Left/Right, Up/Down. ROUTE_KEYS = { - "keys": {"Enter", "Esc", "Space", "o", "?", "/", "Ctrl-C", "Ctrl-U"}, - "gptokeyb": {"A", "B", "X", "Y", "Select+Start"}, - "gptokeyb_muxterm": {"A", "B", "X", "Y", "Select+Start"}, - "evmapy": {"A", "B", "X", "Y"}, - "muxterm": {"Start", "B", "Y", "Menu", "Select"}, - "miyoo": {"A", "Start", "R2", "L2"}, + "keys": {"Enter", "Esc", "Space", "o", "?", "/", "Ctrl-C", "Ctrl-U", "PgUp/PgDn"}, + "gptokeyb": {"A", "B", "X", "Y", "Select+Start", "L1/R1"}, + "gptokeyb_muxterm": {"A", "B", "X", "Y", "Select+Start", "D-pad"}, + "evmapy": {"A", "B", "X", "Y", "L1/R1"}, + "muxterm": {"Start", "B", "Y", "Menu", "Select", "D-pad"}, + "miyoo": {"A", "Start", "R2", "L2", "L1/R1"}, } EVERY_ROUTE_HAS = {"Left/Right", "Up/Down", "<>", "^v", "Any"} @@ -1628,7 +1628,11 @@ class AuditRoundTwo(unittest.TestCase): kind, plat, title, lines, code = inbox.get_nowait() self.assertEqual((kind, title, code), ("manifest_failed", "No connection", 1)) self.assertIn("could not be downloaded: the server refused the connection", lines[0]) - self.assertNotIn("--", " ".join(lines)) + # The pack is the one option named: the way of a handheld with no + # Wi-Fi (tui_audit_11 P3-3). + self.assertIn("Or copy a pack to the card and run the installer with --from-pack", + " ".join(lines)) + self.assertNotIn("--", " ".join(lines).replace("--from-pack", "")) state = fresh_state() with mock.patch.object(install.Path, "exists", return_value=True): press(state, "enter") @@ -5803,7 +5807,8 @@ class AuditSix(unittest.TestCase): self.assertNotIn("before Install", text) self.assertNotIn("backup folder", text) self.assertIn("Copy them from your old card", text) - self.assertEqual(view[-1].strip(), "A/Enter Back ? Help") + # The short names at 40 columns, as every screen of that width. + self.assertEqual(view[-1].strip(), "A Back ? Help") def test_a_refusal_shows_at_40x12_on_type_a_path(self): """P2-2: Enter seemed to do nothing, the refusal drawn below the @@ -9072,6 +9077,131 @@ class AuditEighteenAndEleven(unittest.TestCase): rows = install.tui_rows(state) self.assertEqual(rows[state.focus.get("plan", 0)].action, "fromcard") + # tui_audit_11 P3-2 to P3-20: the screens' polish. + + def test_a_long_message_scrolls_with_its_position(self): + """P3-2: at 40x12 the offline screen dropped its remedy unsaid.""" + state = fresh_state() + state.stack = ["error"] + state.error_title = install.TUI_OFFLINE_TITLE + state.error = ["This run makes no network request (--offline), and the list of BIOS " + "files comes from the network.", + "Run the installer again with the list saved as a file (--manifest " + "FILE, a copy of install/.json) or with a pack (--from-pack " + "PACK), or without --offline."] + state.cols, state.rows = 40, 12 + view = [str(line) for line in install.tui_view(state, 40, 12)] + self.assertTrue(any(re.fullmatch(r" 1-\d+ of \d+", line) for line in view), view) + self.assertIn("Scroll", view[-1]) + press(state, "pgdn") + end = " ".join(" ".join(str(line) for line in install.tui_view(state, 40, 12)).split()) + self.assertIn("--offline.", end) + + def test_a_small_window_says_why_the_questions_come_line_by_line(self): + """P3-4.""" + with mock.patch.object(install, "tui_blocker", + return_value="the terminal is 39x12, smaller than 40x12"), \ + mock.patch.object(install, "_run", side_effect=SystemExit(0)), \ + mock.patch.object(install, "detect_os", return_value="linux"), \ + mock.patch.dict(install._WSL), \ + mock.patch.object(sys, "argv", ["install.py"]), \ + contextlib.redirect_stdout(io.StringIO()) as out, \ + self.assertRaises(SystemExit): + install.main() + self.assertIn("The window is 39x12: questions come line by line (40x12 for the full " + "screen).", out.getvalue()) + + def test_one_bar_rule_per_width(self): + """P3-5: at 40 columns the plan read "A Install", Confirm "A/Enter".""" + states = golden_states() + for name in ("preview", "confirm", "result", "quit"): + with self.subTest(screen=name): + bar = str(install.tui_view(states[name], 40, 12)[-1]) + self.assertNotIn("A/Enter", bar) + self.assertNotIn("B/Esc", bar) + + def test_the_folder_its_config_named_is_said_under_it(self): + """P3-6, P3-7.""" + state = checked_state() + long_dest = Path("/home/user/" + "a-long-folder-name/" * 5 + "system") + state.dest = long_dest + with mock.patch.object(install, "_retroarch_config_at", + return_value=(HOME / ".config/retroarch/retroarch.cfg", HOME)): + lines = [str(line).strip() for line in install.tui_view(state, 80, 30)] + at = next(index for index, line in enumerate(lines) if line.startswith("BIOS folder:")) + tail = next(index for index, line in enumerate(lines) if line.startswith("Named by")) + self.assertNotIn("...", " ".join(lines[at:tail])) + self.assertIn(str(long_dest).replace("/home/user", "~")[-20:], " ".join(lines[at:tail])) + self.assertLessEqual(tail - at, 3) + + def test_the_help_while_typing_lists_what_does_not_type(self): + """P3-9.""" + state = checked_state() + state.stack.append("systems") + state.editing = "systems" + state.help = True + help_text = " ".join(" ".join(install.tui_view(state, 80, 24)).split()) + self.assertIn("Esc Clear the filter", help_text) + self.assertIn("Ctrl-U Clear", help_text) + self.assertNotIn("Tick, untick", help_text) + + def test_the_plan_names_core_and_region_as_line_mode(self): + """P3-11.""" + state = checked_state() + state.cli_systems, state.cores, state.regions = ["sony-playstation"], ["x"], ["us"] + text = " ".join(" ".join(install.tui_view(state, 120, 40)).split()) + self.assertIn("(--system), --core, --region.", text) + + def test_pc_and_short_prefixes_find_the_computer_setups(self): + """P3-17.""" + for typed in ("pc", "wi", "win"): + with self.subTest(typed=typed): + found = {row.value for row in install.setup_list([], set(), typed, "linux") + if row.action == "platform"} + self.assertIn("retrobat", found) + + def test_a_partial_result_counts_once_then_says_the_remedy(self): + """P3-18.""" + state = _installed_state(Path("/x/system")) + state.summary.update(installed=6, pending=16, failed=[]) + state.from_folders = [Path("/media/me/OLD")] + with mock.patch.object(install, "_offline_only", return_value=True), \ + mock.patch.object(install, "_tui_sources", + return_value="6 files copied from OLD, 16 still missing."): + _title, lines = install.tui_outcome(state) + self.assertEqual(lines[0], "6 files copied from OLD, 16 still missing.") + self.assertIn("They are not in", lines[1]) + + def test_q_is_said_as_a_quit(self): + """P3-19: q reported "stopped", the word of an interrupted run.""" + state = checked_state() + press(state, "q") + self.assertEqual(state.exit_code, 130) + self.assertEqual(install._closing_lines(state), + ["RetroBIOS: quit. Nothing was installed."]) + other = checked_state() + install.tui_interrupt(other) + self.assertEqual(install._closing_lines(other), + ["RetroBIOS: stopped. Nothing was installed."]) + + def test_wide_rows_keep_the_size_near_the_name(self): + """P3-20.""" + row = install.TuiRow("system", "[x] 3DO 3do", "2 files, 4.9 KB") + line = str(install._row_line(row, False, 118)) + self.assertLessEqual(len(line.rstrip()), install.TUI_ROW_SPREAD) + + def test_the_wsl_browser_titles_a_drive_by_its_letter(self): + """P3-13.""" + state = fresh_state() + state.stack = ["browse"] + state.browse_dir = Path("/mnt/e") + with mock.patch.dict(install._WSL, {"active": True, "root": "/mnt"}), \ + mock.patch.object(install, "_browse_rows", return_value=([], "")): + state.browse_rows = [] + title = " ".join(str(line) for line in install.tui_view(state, 80, 24)[:4]) + self.assertIn("E:\\", title) + self.assertNotIn("/mnt/e", title) + def test_the_card_marker_table_is_written_from_the_rules(self): """code_audit_18 P3-6, tui_audit_11 P2-2: the table left out the SHARE drives. It is generated: every card rule and SHARE drive file diff --git a/wiki/installer.md b/wiki/installer.md index abbdfdd32..f18fe30b4 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -1485,7 +1485,7 @@ fields, the installer refuses to narrow rather than install everything. | `0` | everything asked for is installed or already up to date | | `1` | a failure: the one-liner could not download or verify `install.py`, or found no Python 3.8 (nothing was run), an unusable base URL, an unknown platform or target, `--installed-cores` with no core found, a manifest that fails validation, a fetch that never succeeded, no platform to install for in a non-interactive run, some files failed or were left out by `--offline` while others installed, `--check` found files to fetch, a `--from-pack` pack that cannot be read, lacks a part or is for another platform, a `--from` folder that does not exist or cannot be read, a `--dest` that is not the card of the system named (another system's card, a folder inside one, a drive with none of its files where its card always has them, or a folder that does not exist), `--restore` could not put a file back, or `--add-app` could not put every file of the app on the card | | `2` | nothing was installed: every file the run tried failed (a BIOS folder that cannot be written, for one), or `--offline` left every file out, the drive has too little free space for the download (checked before any folder is created), or the question about a folder that does not exist, named with `--dest` or typed at the folder question, was answered no. Also an option the installer refused before any download: an unknown option, `--jobs` outside 1 to 32, `--target` with `--every-board` or `--from-pack`, `--target` or `--list-targets` with `--offline` (or `RETROBIOS_INSTALL_OFFLINE` set), which read a core list from the network, `--add-app` with `--check`, `--dry-run`, a listing, `--from-pack` or `--offline`. The message then starts with `install.py: error:`. Also `--add-app` for a folder on no card that takes the app, said in an `Error:` line and an `app` event before anything is written. With `--restore`, a folder that is not a dated folder inside `retrobios-backup` | -| `130` | stopped with Ctrl+C, or with `q`, Ctrl+C or Ctrl+D at a question, or the full screen closed with no button pressed (`RETROBIOS_IDLE_EXIT`) before an install. Run the same command again to carry on | +| `130` | stopped with Ctrl+C, or with `q`, Ctrl+C or Ctrl+D at a question, or the full screen closed with no button pressed (`RETROBIOS_IDLE_EXIT`) before an install. A quit chosen (`q`, **Quit**) is code `130` too, and the terminal keeps `RetroBIOS: quit. Nothing was installed.` where a stop keeps `stopped`. Run the same command again to carry on | | `141` | the output was piped to a reader that closed early (`--list-platforms \| head`) | From dbc9da283dceb2e0734e7d10ea900bd783ec7e4a Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:29:58 +0100 Subject: [PATCH 10/13] refactor: run line mode findings for parity The line-mode findings of code audit 18 and tui audit 11, the escape sequence checks among them, run in LineModeAndScreenDecideAlike. --- tests/test_install_offline.py | 3 +- tests/test_install_tui.py | 721 +++++++++++++++++----------------- 2 files changed, 368 insertions(+), 356 deletions(-) diff --git a/tests/test_install_offline.py b/tests/test_install_offline.py index fe90789b1..23285cb55 100644 --- a/tests/test_install_offline.py +++ b/tests/test_install_offline.py @@ -967,7 +967,8 @@ class AuditFifteenOffline(Scratch): # The screen reads that summary as missing files, not failures. state = install.TuiState() state.summary = summary - with unittest.mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: install.OFFLINE_FLAG_VALUE}): + with unittest.mock.patch.dict(os.environ, + {install.OFFLINE_VARIABLE: install.OFFLINE_FLAG_VALUE}): title, lines = install.tui_outcome(state) self.assertEqual(title, "Finished with files missing") self.assertIn("1 installed, 3 not fetched (--offline).", lines[0]) diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index b2a023ddc..a90dea87e 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -5289,8 +5289,6 @@ class TheTerminalComesBack(unittest.TestCase): """code_audit_17 P2-1: Ctrl-Z stopped the installer alone. install.sh, which runs it as a child, kept waiting, the shell never got its prompt back, and the install run behind the screen kept writing.""" - import signal - repo = self.repo(GameDataRepository, slow=True) env = {**self.env, "PS1": "PROMPT$ ", "PATH": f"{Path(sys.executable).parent}{os.pathsep}{self.env['PATH']}"} @@ -6622,7 +6620,371 @@ def screen_plan(args: argparse.Namespace, manifest: dict, events: list[dict], return state, " ".join(" ".join(install.tui_view(state, 120, 40)).split()) -class LineModeAndScreenDecideAlike(unittest.TestCase): +class _CardFixtures: + """Drives, lists and packs of code_audit_18 and tui_audit_11, for the + classes that test them on the screen and in line mode.""" + + ABOUT = "system/configs/emulationstation/about.info" + + def drive(self, name: str, files: dict) -> Path: + root = self.root / "media" / name + root.mkdir(parents=True) + for path, text in files.items(): + (root / path).parent.mkdir(parents=True, exist_ok=True) + (root / path).write_text(text, encoding="utf-8") + return root + + def renamed(self) -> dict: + """The drives code_audit_18 P1-1 names: KNULLI and Batocera each + renamed in its own menu, with and without the file KNULLI alone + writes, and Recalbox's share.""" + conf = "system/batocera.conf" + return { + "KNULLI renamed": self.drive("K1", {self.ABOUT: "KNULLI\n", + conf: "system.hostname=RG40XXH\n"}), + "KNULLI as shipped": self.drive("K2", {self.ABOUT: "KNULLI\n", + conf: "system.hostname=KNULLI\n"}), + "renamed, no about.info": self.drive("K3", {conf: "system.hostname=RG40XXH\n"}), + "Batocera as shipped": self.drive("B1", {conf: "system.hostname=BATOCERA\n"}), + "Batocera, no hostname": self.drive("B2", {conf: "# system.hostname=X\n"}), + "Recalbox": self.drive("R1", {"system/recalbox.conf": "a=1\n"}), + "two shares": self.drive("T1", {conf: "system.hostname=BATOCERA\n", + "system/recalbox.conf": ""}), + } + + def minui(self, plat: str = "minui", systems: bool = False, cores: bool = True, + regions: bool = True) -> tuple[Path, Path, dict]: + """A list of plat's shape (the MinUI family records cores and regions, + no system), the pack built from it and the files' payloads.""" + from test_install_offline import GBA, JP, US # noqa: PLC0415 + + payloads = {"Bios/MGBA/gba_bios.bin": (GBA, "nintendo-gba", "mgba", None), + "Bios/PS/scph5501.bin": (US, "sony-playstation", "pcsx_rearmed", + "north-america"), + "Bios/PS/scph5500.bin": (JP, "sony-playstation", "pcsx_rearmed", "japan")} + files = [] + for dest, (data, system, core, region) in payloads.items(): + entry = {"dest": dest, "sha1": hashlib.sha1(data).hexdigest(), "size": len(data), + "repo_path": f"bios/{dest}", "cores": [core] if cores else None} + if systems: + entry["systems"] = [system] + if regions and region: + entry.update(regions=[region], region_groups=["ps"]) + files.append(entry) + listing = self.root / f"{plat}.json" + install_list = {"manifest_version": 2, "platform": plat, "files": files} + listing.write_text(json.dumps(install_list)) + sys.path.insert(0, str(REPO_ROOT / "scripts")) + self.addCleanup(sys.path.remove, str(REPO_ROOT / "scripts")) + import generate_pack # noqa: PLC0415 + + pack_list = {"files": [{"path": dest, "sha1": entry["sha1"], "size": entry["size"]} + for dest, entry in zip(payloads, files)]} + generate_pack.annotate_pack_systems(pack_list, generate_pack.pack_systems(install_list)) + name = {"minui": "MinUI", "misterfpga": "MiSTer_FPGA"}[plat] + pack = self.root / f"{name}_v1_BIOS_Pack.zip" + with zipfile.ZipFile(pack, "w") as archive: + for dest, (data, *_rest) in payloads.items(): + archive.writestr(dest, data) + archive.writestr("manifest.json", json.dumps(pack_list)) + return listing, pack, pack_list + + def narrowed(self, plat: str, **given) -> tuple[int, str, list]: + """(exit code, what was said, the files kept) of a --dry-run narrowed + as given.""" + dest = self.root / "dest" + if not dest.exists(): + make_card(dest, "MinUI") + stream = io.StringIO() + code = 0 + with mock.patch.object(install.EVENTS, "stream", stream), \ + mock.patch.object(install, "detect_os", return_value="linux"), \ + mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}), \ + contextlib.redirect_stdout(io.StringIO()) as out, \ + contextlib.redirect_stderr(io.StringIO()) as err: + try: + install._install(_args(platform=plat, dest=dest, dry_run=True, events=True, + no_input=True, declared_only=False, **given), + io.StringIO()) + except SystemExit as stop: + code = stop.code + finally: + install.LOCAL.reset() + kept = [json.loads(line)["dest"] for line in stream.getvalue().splitlines() + if '"event": "file"' in line] + return code, out.getvalue() + err.getvalue(), kept + + + +class _LineModeFindings(_CardFixtures): + """The line-mode findings of code_audit_18 and tui_audit_11, run by + LineModeAndScreenDecideAlike.""" + + def test_a_drive_two_systems_fit_is_taken_as_named_and_said(self): + """code_audit_18 P1-1: --platform knulli is never refused on a + drive whose only evidence is a batocera.conf, --platform batocera + takes its bios folder, and the doubt is said. Recalbox refuses it + with words for a SHARE drive (P3-4).""" + drives = self.renamed() + for name in ("renamed, no about.info", "Batocera as shipped"): + drive = drives[name] + with self.subTest(drive=name): + choice = install.card_choice("knulli", "knulli", drive) + self.assertEqual((choice.plat, choice.folder, choice.refused), + ("knulli", drive / "bios", "")) + self.assertIn("Taken as KNULLI's, as named", choice.note) + drive = drives["renamed, no about.info"] + choice = install.card_choice("batocera", "batocera", drive) + self.assertEqual((choice.plat, choice.folder), ("batocera", drive / "bios")) + self.assertIn("names the host RG40XXH", choice.note) + refused = install.card_choice("recalbox", "recalbox", drive) + self.assertIn("could be a KNULLI card or a Batocera SHARE drive", refused.refused) + words = install._dest_refusal_words(refused, None) + self.assertIn("Connect the Recalbox SHARE drive", words) + self.assertNotIn("Insert", words) + other = install.card_choice("recalbox", "recalbox", drives["Batocera as shipped"]) + words = install._dest_refusal_words(other, None) + self.assertIn("Connect the Recalbox SHARE drive, or name that drive's own system: " + "--platform batocera", words) + + def test_the_first_screen_and_line_mode_say_the_doubt(self): + """code_audit_18 P1-1: the screen's first row was "Batocera SHARE + drive (KNULLI2)". A renamed card with about.info is KNULLI's, one + without is listed by neither and said.""" + drives = self.renamed() + knulli, doubt = drives["KNULLI renamed"], drives["renamed, no about.info"] + with mock.patch.object(install, "_mounted_cards", return_value=[knulli, doubt]), \ + mock.patch.object(install, "detect_platforms", return_value=[]), \ + contextlib.redirect_stdout(io.StringIO()) as out: + setups, _mounted = install.found_setups("linux") + self.assertEqual([(plat, path) for plat, path, _label in setups], + [("knulli", knulli / "bios")]) + self.assertIn("KNULLI SD card", setups[0][2]) + text = out.getvalue() + self.assertIn(f"{doubt} could be a KNULLI card or a Batocera SHARE drive", text) + self.assertIn(f"--platform knulli --dest {doubt} or --platform batocera --dest {doubt}", + text) + with mock.patch.object(install, "_mounted_cards", return_value=[knulli, doubt]), \ + mock.patch.object(install, "detect_platforms", return_value=[]), \ + mock.patch.object(install, "detect_frontends", return_value=[]), \ + contextlib.redirect_stdout(io.StringIO()): + state, _effects = install.tui_start(_args(), "linux") + self.assertTrue(any("could be a KNULLI card" in note for note in state.notes)) + self.assertEqual([setup[0] for setup in state.setups], ["knulli"]) + + def test_a_list_without_systems_narrows_by_what_it_records(self): + """tui_audit_11 P2-1: install/minui.json records cores and regions, + no system, and all three options were refused with a remedy that + fetched the same list.""" + listing, _pack, _listed = self.minui() + code, text, kept = self.narrowed("minui", manifest=listing, core=["mgba"]) + self.assertEqual(code, 0, text) + self.assertEqual(kept, ["Bios/MGBA/gba_bios.bin"]) + code, text, kept = self.narrowed("minui", manifest=listing, region=["us"]) + self.assertEqual(code, 0, text) + self.assertNotIn("Bios/PS/scph5500.bin", kept) + code, text, _kept = self.narrowed("minui", manifest=listing, + system=["nintendo-gba"]) + self.assertEqual(code, 1) + self.assertIn("records no systems, so --system cannot narrow it", text) + self.assertIn("Run it without --system, or narrow with --core and --region", text) + self.assertNotIn("one-line installer", text) + # The remedy followed: what it names works. + code, text, _kept = self.narrowed("minui", manifest=listing, core=["mgba"], + region=["us"]) + self.assertEqual(code, 0, text) + + def test_a_pack_names_its_list_only_when_that_list_records_it(self): + """tui_audit_11 P2-1: a MinUI pack sent --core to install/minui.json, + refused the same way, and a MiSTer pack to install/misterfpga.json, + which records no core. The pack now records what its list narrows + by, and each remedy changes the outcome.""" + listing, pack, listed = self.minui() + self.assertEqual(listed["list_records"], ["cores", "regions"]) + code, text, _kept = self.narrowed("minui", from_pack=[pack], core=["mgba"]) + self.assertEqual(code, 1) + self.assertIn("Add --manifest FILE, a copy of install/minui.json, which records them", + text) + code, text, kept = self.narrowed("minui", from_pack=[pack], manifest=listing, + core=["mgba"]) + self.assertEqual((code, kept), (0, ["Bios/MGBA/gba_bios.bin"]), text) + mister_list, mister, listed = self.minui("misterfpga", systems=True, cores=False, + regions=False) + self.assertEqual(listed["list_records"], ["regions", "systems"]) + code, text, _kept = self.narrowed("misterfpga", from_pack=[mister], core=["x"]) + self.assertEqual(code, 1) + self.assertIn("MiSTer FPGA's own file list records no cores either. Run it without " + "--core, or narrow with --system.", text) + self.assertNotIn("--manifest", text) + code, text, kept = self.narrowed("misterfpga", from_pack=[mister], + system=["nintendo-gba"]) + self.assertEqual((code, kept), (0, ["Bios/MGBA/gba_bios.bin"]), text) + self.assertTrue(mister_list.exists()) + + def test_line_mode_offers_what_can_be_chosen(self): + """tui_audit_11 P3-10: "c) choose systems, cores and regions" with a + pack that records systems only, and "Cores: 1) azahar" asked with + one answer.""" + _listing, _pack, listed = self.minui("misterfpga", systems=True, cores=False, + regions=False) + files = [{"dest": f["path"], "sha1": f["sha1"], "size": f["size"], + "repo_path": "x", "systems": f["systems"]} for f in listed["files"]] + self.assertEqual(install.narrowing_words(files, []), "systems") + listing, _pack, _listed = self.minui() + minui = json.loads(listing.read_text())["files"] + self.assertEqual(install.narrowing_words(minui, []), "cores and regions") + one_core = [{**entry, "systems": ["nintendo-3ds"], "cores": ["azahar"]} + for entry in minui[:1]] + with mock.patch.object(install, "_ask", side_effect=["", ""]) as asked, \ + contextlib.redirect_stdout(io.StringIO()) as out: + install._choose_narrowing(one_core, [], {}, None) + self.assertNotIn("Cores:", out.getvalue()) + self.assertEqual(asked.call_count, 0) + + # code_audit_18 P3-2, P3-3, tui_audit_11 P3-1: the remedies of a card. + + def test_a_full_card_is_freed_never_left_for_another_drive(self): + with mock.patch.object(install.shutil, "disk_usage", + return_value=mock.Mock(free=10)), \ + contextlib.redirect_stderr(io.StringIO()) as err, \ + self.assertRaises(SystemExit): + install._check_free_space(self.root, 1000, "retroarch", card=True) + said = err.getvalue() + self.assertIn("on the card, then run it again", said) + self.assertNotIn("--platform", said) + self.assertNotIn("another drive", said) + + def test_line_mode_names_the_way_to_another_folder(self): + hint = install.Invocation() + with mock.patch.object(install, "_folder_writable", return_value=False), \ + mock.patch.object(install, "_on_read_only_mount", return_value=False): + lines = install.folder_warnings("retroarch", self.root, [], hint, True, False) + self.assertIn("You cannot write to this folder: run it again with another --dest.", + lines) + self.assertNotIn("choose another one", " ".join(lines)) + with mock.patch.object(install, "_folder_writable", return_value=False), \ + mock.patch.object(install, "_on_read_only_mount", return_value=True): + lines = install.folder_warnings("retroarch", self.root, [], hint, True, True) + self.assertEqual(lines, [install.read_only_words(self.root)]) + + + +class _OutsideTextFindings: + """code_audit_18 P2-1: a pack's member path, a --manifest dest, the + names a list records, a card's label and a retroarch.cfg folder reached + line mode, the events and the disk with their control characters. Each + source is fed escape sequences, and none comes out raw.""" + + OSC = "\x1b]0;PWNED\x07" + CSI = "\x1b[41m" + + @property + def dest(self) -> Path: + dest = self.root / "dest" + dest.mkdir(exist_ok=True) + return dest + + def assertClean(self, text: str, where: str) -> None: + self.assertNotIn("\x1b", text, where) + self.assertNotIn("\x07", text, where) + + def assertNothingRawOnDisk(self) -> None: + for path in self.root.rglob("*"): + self.assertIsNone(install._CONTROLS.search(path.name), path) + + def run_install(self, args: argparse.Namespace, mounts: list = (), + env: dict | None = None) -> str: + events = io.StringIO() + install.LOCAL.reset() + self.addCleanup(install.LOCAL.reset) + with mock.patch.object(install, "_UNNAMEABLE", []), \ + mock.patch.object(install, "_mounted_cards", return_value=list(mounts)), \ + mock.patch.object(install, "_mount_points", return_value=frozenset()), \ + mock.patch.object(install, "detect_frontends", return_value=[]), \ + mock.patch.object(install, "detect_os", return_value="linux"), \ + mock.patch.object(install.EVENTS, "stream", events), \ + mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1", **(env or {})}), \ + contextlib.redirect_stdout(io.StringIO()) as out, \ + contextlib.redirect_stderr(io.StringIO()) as err, \ + contextlib.suppress(SystemExit): + install._install(args, io.StringIO()) + return out.getvalue() + err.getvalue() + events.getvalue() + + def test_a_pack_member_path_is_refused(self): + from test_install_offline import _pack, GBA # noqa: PLC0415 + + pack = _pack(self.root, "RetroArch_v1_BIOS_Pack.zip", {f"x{self.OSC}.bin": GBA}) + text = self.run_install(_args(platform="retroarch", dest=self.dest, + from_pack=[pack], no_input=True, declared_only=False)) + self.assertClean(text, "pack") + self.assertIn("is not valid: unsafe", text) + self.assertEqual(list(self.dest.iterdir()), []) + self.assertNothingRawOnDisk() + + def test_a_list_dest_and_its_names_are_refused_or_shown(self): + files = [{"dest": f"x{self.CSI}BAD.bin", "sha1": "0" * 40, "size": 1, + "repo_path": "bios/x", "cores": None, "systems": ["a"]}] + listing = self.root / "list.json" + listing.write_text(json.dumps({"manifest_version": 2, "platform": "retroarch", + "files": files})) + text = self.run_install(_args(platform="retroarch", dest=self.dest, manifest=listing, + no_input=True, dry_run=True, verbose=True, + declared_only=False)) + self.assertClean(text, "--manifest dest") + self.assertIn("unsafe", text) + named = {"manifest_version": 2, "platform": "retroarch", "files": [ + {**files[0], "dest": "a.bin", "systems": [f"a{self.CSI}"], + "cores": [f"core{self.OSC}"]}], + "system_titles": {f"a{self.CSI}": f"Title{self.OSC}"}} + cleaned = install._validate_manifest(named, "retroarch") + self.assertClean(json.dumps(cleaned, ensure_ascii=False), "names") + self.assertNothingRawOnDisk() + + def test_a_card_label_is_shown_and_never_obeyed(self): + card = self.root / "media" / f"S{self.CSI}RED" + (card / "system").mkdir(parents=True) + (card / "system" / "batocera.conf").write_text("system.hostname=BATOCERA\n") + listing = self.root / "batocera.json" + listing.write_text(json.dumps({"manifest_version": 2, "platform": "batocera", + "files": []})) + with mock.patch.object(install, "detect_platforms", return_value=[]): + text = self.run_install(_args(platform="batocera", manifest=listing, + no_input=True, dry_run=True, events=True, + declared_only=False), [card]) + self.assertIn("S?[41mRED", text) + self.assertClean(text, "card label") + self.assertClean(install.card_line(card), "card row") + + def test_a_folder_a_config_names_is_refused(self): + home = self.root / "home" + weird = home / f"sys{self.OSC}x" + weird.mkdir(parents=True) + cfg = home / ".config" / "retroarch" / "retroarch.cfg" + cfg.parent.mkdir(parents=True) + cfg.write_text(f'system_directory = "{weird}"\n') + listing = self.root / "retroarch.json" + listing.write_text(json.dumps({"manifest_version": 2, "platform": "retroarch", + "files": []})) + text = self.run_install( + _args(manifest=listing, no_input=True, dry_run=True, events=True, + declared_only=False), + env={"HOME": str(home), "XDG_CONFIG_HOME": str(home / ".config"), + "RETROBIOS_OS": "linux"}) + self.assertClean(text, "retroarch.cfg") + self.assertIn("holds a control character", text) + self.assertNotIn("using default path", text) + self.assertNotIn(str(home / ".config" / "retroarch" / "system"), text) + + def test_a_path_with_a_control_character_is_no_relative_path(self): + for value in ("a\x1bb.bin", "a\x07.bin", "a\x9bb.bin", "a\nb.bin"): + with self.subTest(value=value), self.assertRaises(ValueError): + install._safe_relative_path(value, "dest") + self.assertEqual(install.shown_path(Path("/a/b\x1b[2Jc")), "/a/b?[2Jc") + + + +class LineModeAndScreenDecideAlike(_LineModeFindings, _OutsideTextFindings, + unittest.TestCase): """tui_audit_8 P1-1, P2-1 and code_audit_15 P2-2: line mode looked at this machine alone, took this computer's default folder with a card in the reader or nothing found, and the screen put RetroArch under A for a @@ -8746,7 +9108,7 @@ class EdgesTheMutantsFound(unittest.TestCase): -class AuditEighteenAndEleven(unittest.TestCase): +class AuditEighteenAndEleven(_CardFixtures, unittest.TestCase): """code_audit_18 and tui_audit_11: what the cards, the screens and the docs answer, line mode's parity being in LineModeAndScreenDecideAlike.""" @@ -8761,34 +9123,6 @@ class AuditEighteenAndEleven(unittest.TestCase): patcher.start() self.addCleanup(patcher.stop) - def drive(self, name: str, files: dict) -> Path: - root = self.root / "media" / name - root.mkdir(parents=True) - for path, text in files.items(): - (root / path).parent.mkdir(parents=True, exist_ok=True) - (root / path).write_text(text, encoding="utf-8") - return root - - ABOUT = "system/configs/emulationstation/about.info" - - def renamed(self) -> dict: - """The drives code_audit_18 P1-1 names: KNULLI and Batocera each - renamed in its own menu, with and without the file KNULLI alone - writes, and Recalbox's share.""" - conf = "system/batocera.conf" - return { - "KNULLI renamed": self.drive("K1", {self.ABOUT: "KNULLI\n", - conf: "system.hostname=RG40XXH\n"}), - "KNULLI as shipped": self.drive("K2", {self.ABOUT: "KNULLI\n", - conf: "system.hostname=KNULLI\n"}), - "renamed, no about.info": self.drive("K3", {conf: "system.hostname=RG40XXH\n"}), - "Batocera as shipped": self.drive("B1", {conf: "system.hostname=BATOCERA\n"}), - "Batocera, no hostname": self.drive("B2", {conf: "# system.hostname=X\n"}), - "Recalbox": self.drive("R1", {"system/recalbox.conf": "a=1\n"}), - "two shares": self.drive("T1", {conf: "system.hostname=BATOCERA\n", - "system/recalbox.conf": ""}), - } - def test_a_renamed_card_is_named_by_the_file_no_menu_writes(self): """code_audit_18 P1-1: a KNULLI card whose hostname was changed in its menu was a Batocera SHARE drive, Enter filled it with Batocera's @@ -8815,58 +9149,6 @@ class AuditEighteenAndEleven(unittest.TestCase): self.assertEqual(install.share_doubt(drives["two shares"])[0], ("batocera", "recalbox")) - def test_a_drive_two_systems_fit_is_taken_as_named_and_said(self): - """code_audit_18 P1-1: --platform knulli is never refused on a - drive whose only evidence is a batocera.conf, --platform batocera - takes its bios folder, and the doubt is said. Recalbox refuses it - with words for a SHARE drive (P3-4).""" - drives = self.renamed() - for name in ("renamed, no about.info", "Batocera as shipped"): - drive = drives[name] - with self.subTest(drive=name): - choice = install.card_choice("knulli", "knulli", drive) - self.assertEqual((choice.plat, choice.folder, choice.refused), - ("knulli", drive / "bios", "")) - self.assertIn("Taken as KNULLI's, as named", choice.note) - drive = drives["renamed, no about.info"] - choice = install.card_choice("batocera", "batocera", drive) - self.assertEqual((choice.plat, choice.folder), ("batocera", drive / "bios")) - self.assertIn("names the host RG40XXH", choice.note) - refused = install.card_choice("recalbox", "recalbox", drive) - self.assertIn("could be a KNULLI card or a Batocera SHARE drive", refused.refused) - words = install._dest_refusal_words(refused, None) - self.assertIn("Connect the Recalbox SHARE drive", words) - self.assertNotIn("Insert", words) - other = install.card_choice("recalbox", "recalbox", drives["Batocera as shipped"]) - words = install._dest_refusal_words(other, None) - self.assertIn("Connect the Recalbox SHARE drive, or name that drive's own system: " - "--platform batocera", words) - - def test_the_first_screen_and_line_mode_say_the_doubt(self): - """code_audit_18 P1-1: the screen's first row was "Batocera SHARE - drive (KNULLI2)". A renamed card with about.info is KNULLI's, one - without is listed by neither and said.""" - drives = self.renamed() - knulli, doubt = drives["KNULLI renamed"], drives["renamed, no about.info"] - with mock.patch.object(install, "_mounted_cards", return_value=[knulli, doubt]), \ - mock.patch.object(install, "detect_platforms", return_value=[]), \ - contextlib.redirect_stdout(io.StringIO()) as out: - setups, _mounted = install.found_setups("linux") - self.assertEqual([(plat, path) for plat, path, _label in setups], - [("knulli", knulli / "bios")]) - self.assertIn("KNULLI SD card", setups[0][2]) - text = out.getvalue() - self.assertIn(f"{doubt} could be a KNULLI card or a Batocera SHARE drive", text) - self.assertIn(f"--platform knulli --dest {doubt} or --platform batocera --dest {doubt}", - text) - with mock.patch.object(install, "_mounted_cards", return_value=[knulli, doubt]), \ - mock.patch.object(install, "detect_platforms", return_value=[]), \ - mock.patch.object(install, "detect_frontends", return_value=[]), \ - contextlib.redirect_stdout(io.StringIO()): - state, _effects = install.tui_start(_args(), "linux") - self.assertTrue(any("could be a KNULLI card" in note for note in state.notes)) - self.assertEqual([setup[0] for setup in state.setups], ["knulli"]) - def test_a_share_drive_is_never_called_a_card(self): """code_audit_18 P3-4, tui_audit_11 P3-14: "is a Batocera card" and "is a Recalbox card" for their SHARE drives.""" @@ -8897,163 +9179,6 @@ class AuditEighteenAndEleven(unittest.TestCase): # tui_audit_11 P2-1: a list or a pack that records some axes only. - def minui(self, plat: str = "minui", systems: bool = False, cores: bool = True, - regions: bool = True) -> tuple[Path, Path, dict]: - """A list of plat's shape (the MinUI family records cores and regions, - no system), the pack built from it and the files' payloads.""" - from test_install_offline import GBA, JP, US # noqa: PLC0415 - - payloads = {"Bios/MGBA/gba_bios.bin": (GBA, "nintendo-gba", "mgba", None), - "Bios/PS/scph5501.bin": (US, "sony-playstation", "pcsx_rearmed", - "north-america"), - "Bios/PS/scph5500.bin": (JP, "sony-playstation", "pcsx_rearmed", "japan")} - files = [] - for dest, (data, system, core, region) in payloads.items(): - entry = {"dest": dest, "sha1": hashlib.sha1(data).hexdigest(), "size": len(data), - "repo_path": f"bios/{dest}", "cores": [core] if cores else None} - if systems: - entry["systems"] = [system] - if regions and region: - entry.update(regions=[region], region_groups=["ps"]) - files.append(entry) - listing = self.root / f"{plat}.json" - install_list = {"manifest_version": 2, "platform": plat, "files": files} - listing.write_text(json.dumps(install_list)) - sys.path.insert(0, str(REPO_ROOT / "scripts")) - self.addCleanup(sys.path.remove, str(REPO_ROOT / "scripts")) - import generate_pack # noqa: PLC0415 - - pack_list = {"files": [{"path": dest, "sha1": entry["sha1"], "size": entry["size"]} - for dest, entry in zip(payloads, files)]} - generate_pack.annotate_pack_systems(pack_list, generate_pack.pack_systems(install_list)) - name = {"minui": "MinUI", "misterfpga": "MiSTer_FPGA"}[plat] - pack = self.root / f"{name}_v1_BIOS_Pack.zip" - with zipfile.ZipFile(pack, "w") as archive: - for dest, (data, *_rest) in payloads.items(): - archive.writestr(dest, data) - archive.writestr("manifest.json", json.dumps(pack_list)) - return listing, pack, pack_list - - def narrowed(self, plat: str, **given) -> tuple[int, str, list]: - """(exit code, what was said, the files kept) of a --dry-run narrowed - as given.""" - dest = self.root / "dest" - if not dest.exists(): - make_card(dest, "MinUI") - stream = io.StringIO() - code = 0 - with mock.patch.object(install.EVENTS, "stream", stream), \ - mock.patch.object(install, "detect_os", return_value="linux"), \ - mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}), \ - contextlib.redirect_stdout(io.StringIO()) as out, \ - contextlib.redirect_stderr(io.StringIO()) as err: - try: - install._install(_args(platform=plat, dest=dest, dry_run=True, events=True, - no_input=True, declared_only=False, **given), - io.StringIO()) - except SystemExit as stop: - code = stop.code - finally: - install.LOCAL.reset() - kept = [json.loads(line)["dest"] for line in stream.getvalue().splitlines() - if '"event": "file"' in line] - return code, out.getvalue() + err.getvalue(), kept - - def test_a_list_without_systems_narrows_by_what_it_records(self): - """tui_audit_11 P2-1: install/minui.json records cores and regions, - no system, and all three options were refused with a remedy that - fetched the same list.""" - listing, _pack, _listed = self.minui() - code, text, kept = self.narrowed("minui", manifest=listing, core=["mgba"]) - self.assertEqual(code, 0, text) - self.assertEqual(kept, ["Bios/MGBA/gba_bios.bin"]) - code, text, kept = self.narrowed("minui", manifest=listing, region=["us"]) - self.assertEqual(code, 0, text) - self.assertNotIn("Bios/PS/scph5500.bin", kept) - code, text, _kept = self.narrowed("minui", manifest=listing, - system=["nintendo-gba"]) - self.assertEqual(code, 1) - self.assertIn("records no systems, so --system cannot narrow it", text) - self.assertIn("Run it without --system, or narrow with --core and --region", text) - self.assertNotIn("one-line installer", text) - # The remedy followed: what it names works. - code, text, _kept = self.narrowed("minui", manifest=listing, core=["mgba"], - region=["us"]) - self.assertEqual(code, 0, text) - - def test_a_pack_names_its_list_only_when_that_list_records_it(self): - """tui_audit_11 P2-1: a MinUI pack sent --core to install/minui.json, - refused the same way, and a MiSTer pack to install/misterfpga.json, - which records no core. The pack now records what its list narrows - by, and each remedy changes the outcome.""" - listing, pack, listed = self.minui() - self.assertEqual(listed["list_records"], ["cores", "regions"]) - code, text, _kept = self.narrowed("minui", from_pack=[pack], core=["mgba"]) - self.assertEqual(code, 1) - self.assertIn("Add --manifest FILE, a copy of install/minui.json, which records them", - text) - code, text, kept = self.narrowed("minui", from_pack=[pack], manifest=listing, - core=["mgba"]) - self.assertEqual((code, kept), (0, ["Bios/MGBA/gba_bios.bin"]), text) - mister_list, mister, listed = self.minui("misterfpga", systems=True, cores=False, - regions=False) - self.assertEqual(listed["list_records"], ["regions", "systems"]) - code, text, _kept = self.narrowed("misterfpga", from_pack=[mister], core=["x"]) - self.assertEqual(code, 1) - self.assertIn("MiSTer FPGA's own file list records no cores either. Run it without " - "--core, or narrow with --system.", text) - self.assertNotIn("--manifest", text) - code, text, kept = self.narrowed("misterfpga", from_pack=[mister], - system=["nintendo-gba"]) - self.assertEqual((code, kept), (0, ["Bios/MGBA/gba_bios.bin"]), text) - self.assertTrue(mister_list.exists()) - - def test_line_mode_offers_what_can_be_chosen(self): - """tui_audit_11 P3-10: "c) choose systems, cores and regions" with a - pack that records systems only, and "Cores: 1) azahar" asked with - one answer.""" - _listing, _pack, listed = self.minui("misterfpga", systems=True, cores=False, - regions=False) - files = [{"dest": f["path"], "sha1": f["sha1"], "size": f["size"], - "repo_path": "x", "systems": f["systems"]} for f in listed["files"]] - self.assertEqual(install.narrowing_words(files, []), "systems") - listing, _pack, _listed = self.minui() - minui = json.loads(listing.read_text())["files"] - self.assertEqual(install.narrowing_words(minui, []), "cores and regions") - one_core = [{**entry, "systems": ["nintendo-3ds"], "cores": ["azahar"]} - for entry in minui[:1]] - with mock.patch.object(install, "_ask", side_effect=["", ""]) as asked, \ - contextlib.redirect_stdout(io.StringIO()) as out: - install._choose_narrowing(one_core, [], {}, None) - self.assertNotIn("Cores:", out.getvalue()) - self.assertEqual(asked.call_count, 0) - - # code_audit_18 P3-2, P3-3, tui_audit_11 P3-1: the remedies of a card. - - def test_a_full_card_is_freed_never_left_for_another_drive(self): - with mock.patch.object(install.shutil, "disk_usage", - return_value=mock.Mock(free=10)), \ - contextlib.redirect_stderr(io.StringIO()) as err, \ - self.assertRaises(SystemExit): - install._check_free_space(self.root, 1000, "retroarch", card=True) - said = err.getvalue() - self.assertIn("on the card, then run it again", said) - self.assertNotIn("--platform", said) - self.assertNotIn("another drive", said) - - def test_line_mode_names_the_way_to_another_folder(self): - hint = install.Invocation() - with mock.patch.object(install, "_folder_writable", return_value=False), \ - mock.patch.object(install, "_on_read_only_mount", return_value=False): - lines = install.folder_warnings("retroarch", self.root, [], hint, True, False) - self.assertIn("You cannot write to this folder: run it again with another --dest.", - lines) - self.assertNotIn("choose another one", " ".join(lines)) - with mock.patch.object(install, "_folder_writable", return_value=False), \ - mock.patch.object(install, "_on_read_only_mount", return_value=True): - lines = install.folder_warnings("retroarch", self.root, [], hint, True, True) - self.assertEqual(lines, [install.read_only_words(self.root)]) - def test_a_card_plan_offers_one_remedy_first(self): for facts, locked in (({"free": 50 * 1024 ** 3, "writable": False, "cores": []}, True), ({"free": 10, "writable": True, "cores": []}, False)): @@ -9311,119 +9436,5 @@ class TheAndroidResultReadsWhole(unittest.TestCase): self.assertNotIn("Needed to start an emulator", said) -class NothingFromOutsideReachesTheTerminalRaw(unittest.TestCase): - """code_audit_18 P2-1: a pack's member path, a --manifest dest, the - names a list records, a card's label and a retroarch.cfg folder reached - line mode, the events and the disk with their control characters. Each - source is fed escape sequences, and none comes out raw.""" - - OSC = "\x1b]0;PWNED\x07" - CSI = "\x1b[41m" - - def setUp(self): - (REPO_ROOT / "tmp").mkdir(exist_ok=True) - self.root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) - self.addCleanup(shutil.rmtree, self.root, True) - install.LOCAL.reset() - self.addCleanup(install.LOCAL.reset) - install._UNNAMEABLE.clear() - self.addCleanup(install._UNNAMEABLE.clear) - self.dest = self.root / "dest" - self.dest.mkdir() - - def assertClean(self, text: str, where: str) -> None: - self.assertNotIn("\x1b", text, where) - self.assertNotIn("\x07", text, where) - - def assertNothingRawOnDisk(self) -> None: - for path in self.root.rglob("*"): - self.assertIsNone(install._CONTROLS.search(path.name), path) - - def run_install(self, args: argparse.Namespace, mounts: list = (), - env: dict | None = None) -> str: - events = io.StringIO() - with mock.patch.object(install, "_mounted_cards", return_value=list(mounts)), \ - mock.patch.object(install, "_mount_points", return_value=frozenset()), \ - mock.patch.object(install, "detect_frontends", return_value=[]), \ - mock.patch.object(install, "detect_os", return_value="linux"), \ - mock.patch.object(install.EVENTS, "stream", events), \ - mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1", **(env or {})}), \ - contextlib.redirect_stdout(io.StringIO()) as out, \ - contextlib.redirect_stderr(io.StringIO()) as err, \ - contextlib.suppress(SystemExit): - install._install(args, io.StringIO()) - return out.getvalue() + err.getvalue() + events.getvalue() - - def test_a_pack_member_path_is_refused(self): - from test_install_offline import _pack, GBA # noqa: PLC0415 - - pack = _pack(self.root, "RetroArch_v1_BIOS_Pack.zip", {f"x{self.OSC}.bin": GBA}) - text = self.run_install(_args(platform="retroarch", dest=self.dest, - from_pack=[pack], no_input=True, declared_only=False)) - self.assertClean(text, "pack") - self.assertIn("is not valid: unsafe", text) - self.assertEqual(list(self.dest.iterdir()), []) - self.assertNothingRawOnDisk() - - def test_a_list_dest_and_its_names_are_refused_or_shown(self): - files = [{"dest": f"x{self.CSI}BAD.bin", "sha1": "0" * 40, "size": 1, - "repo_path": "bios/x", "cores": None, "systems": ["a"]}] - listing = self.root / "list.json" - listing.write_text(json.dumps({"manifest_version": 2, "platform": "retroarch", - "files": files})) - text = self.run_install(_args(platform="retroarch", dest=self.dest, manifest=listing, - no_input=True, dry_run=True, verbose=True, - declared_only=False)) - self.assertClean(text, "--manifest dest") - self.assertIn("unsafe", text) - named = {"manifest_version": 2, "platform": "retroarch", "files": [ - {**files[0], "dest": "a.bin", "systems": [f"a{self.CSI}"], - "cores": [f"core{self.OSC}"]}], - "system_titles": {f"a{self.CSI}": f"Title{self.OSC}"}} - cleaned = install._validate_manifest(named, "retroarch") - self.assertClean(json.dumps(cleaned, ensure_ascii=False), "names") - self.assertNothingRawOnDisk() - - def test_a_card_label_is_shown_and_never_obeyed(self): - card = self.root / "media" / f"S{self.CSI}RED" - (card / "system").mkdir(parents=True) - (card / "system" / "batocera.conf").write_text("system.hostname=BATOCERA\n") - listing = self.root / "batocera.json" - listing.write_text(json.dumps({"manifest_version": 2, "platform": "batocera", - "files": []})) - with mock.patch.object(install, "detect_platforms", return_value=[]): - text = self.run_install(_args(platform="batocera", manifest=listing, - no_input=True, dry_run=True, events=True, - declared_only=False), [card]) - self.assertIn("S?[41mRED", text) - self.assertClean(text, "card label") - self.assertClean(install.card_line(card), "card row") - - def test_a_folder_a_config_names_is_refused(self): - home = self.root / "home" - weird = home / f"sys{self.OSC}x" - weird.mkdir(parents=True) - cfg = home / ".config" / "retroarch" / "retroarch.cfg" - cfg.parent.mkdir(parents=True) - cfg.write_text(f'system_directory = "{weird}"\n') - listing = self.root / "retroarch.json" - listing.write_text(json.dumps({"manifest_version": 2, "platform": "retroarch", - "files": []})) - text = self.run_install( - _args(manifest=listing, no_input=True, dry_run=True, events=True, - declared_only=False), - env={"HOME": str(home), "XDG_CONFIG_HOME": str(home / ".config"), - "RETROBIOS_OS": "linux"}) - self.assertClean(text, "retroarch.cfg") - self.assertIn("holds a control character", text) - self.assertNotIn("using default path", text) - self.assertNotIn(str(home / ".config" / "retroarch" / "system"), text) - - def test_a_path_with_a_control_character_is_no_relative_path(self): - for value in ("a\x1bb.bin", "a\x07.bin", "a\x9bb.bin", "a\nb.bin"): - with self.subTest(value=value), self.assertRaises(ValueError): - install._safe_relative_path(value, "dest") - self.assertEqual(install.shown_path(Path("/a/b\x1b[2Jc")), "/a/b?[2Jc") - if __name__ == "__main__": unittest.main() From f8881e0874377f34fd8e9c0da6735030b6f7560d Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:30:24 +0100 Subject: [PATCH 11/13] docs: describe runs without questions and offline The options and environment tables say what --no-input finds and when it stops, and how the words name RETROBIOS_INSTALL_OFFLINE. A test holds the options screen to leave out parallel downloads offline. --- tests/test_install_tui.py | 9 +++++++++ wiki/installer.md | 10 ++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index a90dea87e..74304a784 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -9194,6 +9194,15 @@ class AuditEighteenAndEleven(_CardFixtures, unittest.TestCase): self.assertNotIn("choose another one", text) self.assertEqual(effects, [("run", "check")]) + def test_options_offer_no_downloads_when_nothing_is_downloaded(self): + """P3-16: Parallel downloads offered for an install from a pack.""" + state = checked_state() + state.stack.append("options") + with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: ""}): + self.assertIn("jobs", [row.action for row in install.tui_rows(state)]) + with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}): + self.assertNotIn("jobs", [row.action for row in install.tui_rows(state)]) + def test_nothing_installable_focuses_the_copy(self): """tui_audit_11 P3-8: offline with nothing to copy, the plan focused Choose systems, whose systems need the internet too.""" diff --git a/wiki/installer.md b/wiki/installer.md index f18fe30b4..f5ac09f70 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -176,7 +176,7 @@ install starts. | Back | Esc, Backspace | B, Select (B alone with muxterm's own keys, where Select shows its keyboard) | | Options | `o`, Tab | Y | | Filter a long list | `/` then type, Enter on a lone match chooses it | muxterm: Select shows a keyboard. Elsewhere none (the setup list puts the SD cards after RetroArch) | -| Keys of the screen | `?`, F1 | | +| Keys of the screen | `?`, F1 (F1 alone while typing a filter, where the help lists the keys that do not type) | | | Stop an install | Esc, `q`, Ctrl+C | B, Select+Start (B alone with muxterm's own keys) | | Quit | `q`, Ctrl+C | B on the first screen | | Pause, back with `fg` (Linux, macOS) | Ctrl+Z | | @@ -205,7 +205,9 @@ folder was named, the backup folder and the save directory included (`/sdcard/RetroArch` on Android, not the `/storage/emulated/0` it links to). It opens only for an install, at a terminal of at least 40 columns and 12 rows -(a smaller window says so, with its size) whose `TERM` is set, is not `dumb` +(a smaller window says so, with its size, before the first question: +`The window is 39x12: questions come line by line (40x12 for the full +screen).`) whose `TERM` is set, is not `dumb` and can draw it: where the terminal's description (terminfo) has no way to move the cursor, or moves it with other sequences than the ANSI ones the screen writes (`TERM=vt52`), the questions come line by @@ -775,7 +777,7 @@ what the options name, everything by default. | `--list-cores` | Print the cores whose extra files a platform installs | | `--list-emulators` | Print every name `--emulator` accepts, each buildbot name beside the emulator it stands for | | `--romm-firmware TEMPLATE` | RomM: where each platform's firmware goes in the library, as `filesystem.structure.firmware` in RomM's `config.yml` says (`{platform}/bios` for a library laid out as `{platform}/roms`). Read from `config.yml` beside the library when it is there. See [RomM firmware layout](#romm-firmware-layout) | -| `--no-input` | Never ask a question, even at a terminal. A run whose output goes to a file or a pipe asks nothing either, and says so on stderr | +| `--no-input` | Never ask a question, even at a terminal. A run whose output goes to a file or a pipe asks nothing either, and says so on stderr. It finds the setups and the mounted cards the screen finds: one is taken, as Enter takes it, and two stop the run with an `error` event (`stage` `detect`, `reason` `conflict`) naming each with its `--platform` and `--dest` | | `--check` | Report and exit without writing | | `--dry-run` | Show what would be downloaded, the totals first, then by system and with sizes, without writing anything. A plan taller than the screen names the ten largest systems, and `--verbose` lists every system and file | | `--yes`, `-y` | Do not ask before a download of 1 GB or more, nor anything else. Implies `--no-input`, so a run that needs an answer (nothing found here, two setups found) stops on what to name instead | @@ -1576,7 +1578,7 @@ RetroArch folder that holds only `scph5501.bin`, in the order written: |----------|--------| | `RETROBIOS_REF` | Branch or tag the file list and the files committed to the repository are read from. Defaults to `main`. Files the manifest marks `release_asset` always come from the `large-files` release, whatever the ref, and that release replaces an asset in place when it is rebuilt. Each release deletes the previous release tags, so a tag holds a file list until the next release. For contributors: [release process](release-process.md#cutting-a-release) | | `RETROBIOS_BASE_URL` | Base URL for the manifest and the files it declares. HTTPS only, plain HTTP allowed to loopback for end-to-end tests. Whoever controls this base controls the expected hashes, hence the check. A file list that cannot be fetched names the address, and this variable when it is set | -| `RETROBIOS_INSTALL_OFFLINE` | Set to anything but `0`: no network request, as with `--offline`. The runs the full-screen interface starts inherit it. `RETROBIOS_OFFLINE` is the build scripts' own and leaves the installer alone | +| `RETROBIOS_INSTALL_OFFLINE` | Set to anything but `0`: no network request, as with `--offline`. The runs the full-screen interface starts inherit it. The words then name the variable (`not fetched (RETROBIOS_INSTALL_OFFLINE=1)`, `run it with RETROBIOS_INSTALL_OFFLINE unset`), and `--offline` where the option was given. `RETROBIOS_OFFLINE` is the build scripts' own and leaves the installer alone | | `RETROBIOS_OS` | Names the host outright (`linux`, `wsl`, `windows`, `darwin`, `android`) instead of detecting it | | `EXTERNAL_STORAGE` | Android only, the shared storage root RetroArch builds its default directories from. Defaults to `/storage/emulated/0` | | `RETROBIOS_INSTALL_URL` | Where the bootstrap fetches `install.py`. HTTPS only | From 73f8e48d41a56faa84c9b884cc2aa329e67d493b Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:31:50 +0100 Subject: [PATCH 12/13] fix: test the save line through a found card --- tests/test_install_tui.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 74304a784..500637ac6 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -7341,8 +7341,10 @@ class LineModeAndScreenDecideAlike(_LineModeFindings, _OutsideTextFindings, self.entry("x/keys.txt", b"k", ["nintendo-3ds"], root="save")] listing = self.listing("retroarch", files, base_destination="RetroArch", save_directory={"folder": "saves", "by_core": False}) - text, _asked = self.line_run(_args(manifest=listing, dest=onion, declared_only=False, - offline=False), [], mounts=[onion]) + text, _asked = self.line_run(_args(manifest=listing, declared_only=False, + offline=False), [""], mounts=[onion]) + self.assertIn("Install for:", text) + self.assertIn("save directory left out", text) self.assertNotIn("Onion's save directory", text) self.assertIn("RetroArch's save directory", text) From a4af2ffda4469ac98f5893e7a6ef7fd3c93c32d7 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:57:34 +0100 Subject: [PATCH 13/13] fix: name --offline when given, keep page budget --offline given with the variable set is named as the option, and the runs the screen starts get it only when the user gave it. The first install page stays under its 400 words, the 3.8 runs skip only the pack test that needs generate_pack. --- install.ps1 | 2 +- install.py | 9 ++++++--- install.sh | 2 +- install/launchers/index.json | 16 ++++++++-------- scripts/generate_installer_doc.py | 2 +- tests/test_install_tui.py | 22 ++++++++++++++-------- tests/test_site_r4_merge.py | 7 ++++--- wiki/first-install.md | 16 +++++++--------- 8 files changed, 42 insertions(+), 34 deletions(-) diff --git a/install.ps1 b/install.ps1 index 7892411d6..6d870ac14 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "c138739989d2ea32639c470f3af89d5038604b7fb446f08cc37659e139b76605" + $defaultInstallSha256 = "749d14547ad7a5504ff6ee4c62aca2ec710839174e149df77759c70d3e4fe951" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 9d7fc2a7f..325e7efa1 100644 --- a/install.py +++ b/install.py @@ -10274,9 +10274,9 @@ def main() -> None: ): if given: parser.error(reason) - if args.offline and not offline_requested(): + if args.offline: # The runs the full-screen interface starts read it from here, and - # the words name the option, not a variable nobody set. + # the words name the option given. os.environ[OFFLINE_VARIABLE] = OFFLINE_FLAG_VALUE if offline_requested() and (args.target or args.list_targets): parser.error("--target and --list-targets read a core list from the network, " @@ -13246,7 +13246,10 @@ def tui_flags(state: TuiState) -> list[tuple[str, str | None]]: if state.jobs != 8: flags.append(("--jobs", str(state.jobs))) flags += [("--from", str(folder)) for folder in state.from_folders] - if offline_requested() and not any(flag == "--offline" for flag, _v in flags): + if (os.environ.get(OFFLINE_VARIABLE) == OFFLINE_FLAG_VALUE + and not any(flag == "--offline" for flag, _v in flags)): + # Given as --offline: said so in the command. Set as the variable, + # the runs inherit it and the words name it. flags.append(("--offline", None)) return flags diff --git a/install.sh b/install.sh index 8dc8ad1b3..34c845946 100755 --- a/install.sh +++ b/install.sh @@ -18,7 +18,7 @@ esac TEMP_INSTALLER="" TEMP_DIRECTORY="" DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" -DEFAULT_INSTALL_SHA256="c138739989d2ea32639c470f3af89d5038604b7fb446f08cc37659e139b76605" +DEFAULT_INSTALL_SHA256="749d14547ad7a5504ff6ee4c62aca2ec710839174e149df77759c70d3e4fe951" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index a68f0af23..77a7edb56 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 818472, - "sha256": "c138739989d2ea32639c470f3af89d5038604b7fb446f08cc37659e139b76605", + "size": 818600, + "sha256": "749d14547ad7a5504ff6ee4c62aca2ec710839174e149df77759c70d3e4fe951", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 871021, - "sha256": "0b2f463994437ab12013039a8eec8ce94040a03594e9d820a0da58a1058ef9e1" + "size": 871149, + "sha256": "cbca9dd0e3d7de656965dbff79512991856a03853c0e771da97e5e95d7d31f49" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 818472, - "sha256": "c138739989d2ea32639c470f3af89d5038604b7fb446f08cc37659e139b76605", + "size": 818600, + "sha256": "749d14547ad7a5504ff6ee4c62aca2ec710839174e149df77759c70d3e4fe951", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 818472, - "sha256": "c138739989d2ea32639c470f3af89d5038604b7fb446f08cc37659e139b76605", + "size": 818600, + "sha256": "749d14547ad7a5504ff6ee4c62aca2ec710839174e149df77759c70d3e4fe951", "executable": false }, { diff --git a/scripts/generate_installer_doc.py b/scripts/generate_installer_doc.py index 7b09eeba2..ef443d2d2 100644 --- a/scripts/generate_installer_doc.py +++ b/scripts/generate_installer_doc.py @@ -154,7 +154,7 @@ def route_keys() -> str: return _table(heading, rows) -def _marker(rule) -> str: +def _marker(rule: _install._OsRule) -> str: """A card rule's evidence in words: the file or folder, and the line it holds for a rule that reads one.""" if rule.method == "file_matches": diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 500637ac6..e9ff1c2bf 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -4636,11 +4636,11 @@ class EndToEnd(unittest.TestCase): self.assertIn("6 files to download", screen) # The second folder the run writes to, named before # anything is written, and again on the result. - self.assertIn("1 file read from RetroArch's save directory goes to:\n" - " ~/MySaves", screen) + self.assertIn("1 file read from RetroArch's save directory goes to a " + "folder per core under:\n ~/MySaves", screen) term.send("\r") screen = term.wait("6 files installed.") - self.assertIn("save directory goes to:\n ~/MySaves", screen) + self.assertIn("folder per core under:\n ~/MySaves", screen) term.send("\r") self.assertEqual(term.close(), 0) results.append(written(repo, saves)) @@ -6674,6 +6674,9 @@ class _CardFixtures: listing = self.root / f"{plat}.json" install_list = {"manifest_version": 2, "platform": plat, "files": files} listing.write_text(json.dumps(install_list)) + if sys.version_info < (3, 9): + # generate_pack.py's context managers need Python 3.9. + return listing, None, {} sys.path.insert(0, str(REPO_ROOT / "scripts")) self.addCleanup(sys.path.remove, str(REPO_ROOT / "scripts")) import generate_pack # noqa: PLC0415 @@ -6794,6 +6797,8 @@ class _LineModeFindings(_CardFixtures): region=["us"]) self.assertEqual(code, 0, text) + @unittest.skipIf(sys.version_info < (3, 9), + "generate_pack.py's context managers need Python 3.9") def test_a_pack_names_its_list_only_when_that_list_records_it(self): """tui_audit_11 P2-1: a MinUI pack sent --core to install/minui.json, refused the same way, and a MiSTer pack to install/misterfpga.json, @@ -6825,10 +6830,9 @@ class _LineModeFindings(_CardFixtures): """tui_audit_11 P3-10: "c) choose systems, cores and regions" with a pack that records systems only, and "Cores: 1) azahar" asked with one answer.""" - _listing, _pack, listed = self.minui("misterfpga", systems=True, cores=False, - regions=False) - files = [{"dest": f["path"], "sha1": f["sha1"], "size": f["size"], - "repo_path": "x", "systems": f["systems"]} for f in listed["files"]] + mister, _pack, _listed = self.minui("misterfpga", systems=True, cores=False, + regions=False) + files = json.loads(mister.read_text())["files"] self.assertEqual(install.narrowing_words(files, []), "systems") listing, _pack, _listed = self.minui() minui = json.loads(listing.read_text())["files"] @@ -7942,7 +7946,9 @@ class AuditSeventeenAndTen(unittest.TestCase): "--manifest", str(listing)]) proc = subprocess.run( ["script", "-qec", command, "/dev/null"], - input=f"{repo.dest}\nc\n2\n\n2\n\n", env=env, capture_output=True, + # Enter on RetroArch, first of the setups that read its list + # (tui_audit_11 P1-2), then its folder. + input=f"\n{repo.dest}\nc\n2\n2\n\n", env=env, capture_output=True, text=True, timeout=120, check=False) self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) installed = repo.installed() diff --git a/tests/test_site_r4_merge.py b/tests/test_site_r4_merge.py index 6879f885a..6b7d51031 100644 --- a/tests/test_site_r4_merge.py +++ b/tests/test_site_r4_merge.py @@ -181,9 +181,10 @@ class FaqInstallsFromWhatYouHold(unittest.TestCase): for name in ("muOS", "Onion", "spruceOS", "MinUI", "KNULLI", "ROCKNIX"): with self.subTest(card=name): self.assertIn(name, known) - source = (REPO_ROOT / "install.py").read_text(encoding="utf-8") - self.assertIn('f"{card[2]} {kind} ({_mount_name(mount)})"', source) - self.assertIn('else "SD card"', source) + # The label the first screen gives a card found: "Onion SD card". + self.assertEqual(install._card_word(("retroarch", Path("/x/BIOS"), "Onion")), "SD card") + self.assertEqual(install._card_word(("batocera", Path("/x/bios"), "Batocera")), + "SHARE drive") def test_a_wiped_card_links_the_copy(self): section = self._section("Lost or wiped card?") diff --git a/wiki/first-install.md b/wiki/first-install.md index 7e19047fa..29fac6118 100644 --- a/wiki/first-install.md +++ b/wiki/first-install.md @@ -88,12 +88,11 @@ Arrows move, Enter chooses, Esc goes back, as A and B on a handheld. ## 3. Install -The next screen counts missing files and free space. +It counts missing files and free space. Press Enter on **Install**. **Choose systems** keeps the consoles you play. -**Game data** is what a game reads, of no use to other cores. The count -shows it apart, and its row leaves it out. +**Game data** (only a game reads it) has its own row. **Old card?** In a card reader, choose **Copy the BIOS files from your old card or a folder**, then the card. @@ -105,11 +104,11 @@ Esc or Ctrl+C stops, B on a handheld. Run again to resume. ## 5. Read the result - **Done**: all files are in place. -- **Finished with errors**: run the command again. It retries only those. +- **Finished with errors**: run it again. It retries only those. - **Finished with files missing**: run it again online. - **Nothing installed**: the screen says why. See [If it stops](#if-it-stops). -For questions line by line (screen readers), add `--plain`. +For line-by-line questions (screen readers), add `--plain`. ## What the command does @@ -132,8 +131,8 @@ All good: ## Undo -Files it replaces go to a `retrobios-backup` folder beside your BIOS folder. -The result says how to restore them. +Replaced files go to `retrobios-backup` beside your BIOS folder. The result +says how to restore them. ## If it stops @@ -169,8 +168,7 @@ You cannot write there. Do what the message says. ### The card is full -Nothing was written. Free some space on the card. On a computer, you can -also choose another folder. +Nothing was written. Free some space there. ### The game still does not start