From 88324d595e6eb01d04c307bf3a80979d1b656722 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:10:31 +0100 Subject: [PATCH 1/3] fix: keep the host system out of a from test On a WSL host the in-process run turned drive letters on for every test after it, and three TUI tests failed in the combined suite. --- tests/test_install_from.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_install_from.py b/tests/test_install_from.py index 75651b95a..2ddb97e7e 100644 --- a/tests/test_install_from.py +++ b/tests/test_install_from.py @@ -896,7 +896,10 @@ class AFolderAroundTheBiosFolder(FromFolder): argv = ["install.py", "--platform", "retroarch", "--dest", str(bios), "--manifest", str(self.list), "--from", str(card), "--no-input", "--yes"] out = io.StringIO() + # The host's own system: on WSL the run turned drive letters on for + # every test after it. with mock.patch.object(sys, "argv", argv), \ + mock.patch.object(install, "detect_os", return_value="linux"), \ mock.patch.dict(os.environ, {"RETROBIOS_INSTALL_OFFLINE": "1"}), \ mock.patch.object(os, "replace", refusing(real_replace)), \ mock.patch.object(os, "rename", refusing(real_rename)), \ From 74f4e301a65747cb9b31c0cf6a35db05e5380c05 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:10:48 +0100 Subject: [PATCH 2/3] fix: one card reading and one offline wording card_choice is the one reading of a card's root for --dest, the setup list with --dest, a card listed, browsed, typed or numbered: a blank stick named for KNULLI, ROCKNIX or a system no card file names gets its folder below the root and the same note on the screen and in line mode, kept on Create the folder? and the plan. The MinUI family refuses a blank --dest. A card mounted as BIOS is that card, a folder inside another system's card is refused up to the drive's mount point, a list named for another system's card gets a note, a refused --dest emits an error event. AmberELEC and EmuELEC get a card setup. Offline, every surface says missing, not failed: no retry line, no --from after --from, Nothing installed when nothing was, --check counts what needs the internet, a suggested command keeps --offline. Drive letters under WSL for every path option, shown as D:\. The setup list names each card system's devices and puts the cards after RetroArch when nothing is found (Onion: 4 presses at 40x12, was 10), the filter and the line menu take a device name, one match is chosen in one press. Line mode names the BIOS folder and the card picked, leaves the save directory files out before its menu, offers f) to copy from a folder. Confirm shows no Install while it counts, Back from the systems lands on Install, help keeps its column, one spelling per device. --- install.ps1 | 2 +- install.py | 853 +++++++++++++----- install.sh | 2 +- install/launchers/index.json | 16 +- platforms/_guides.yml | 4 +- tests/fixtures/tui/plan-120x40.txt | 2 +- tests/fixtures/tui/plan-40x12.txt | 2 +- tests/fixtures/tui/plan-40x15.txt | 4 +- tests/fixtures/tui/plan-80x24.txt | 2 +- tests/fixtures/tui/platforms-120x40.txt | 44 +- tests/fixtures/tui/platforms-40x12.txt | 2 +- tests/fixtures/tui/platforms-40x15.txt | 4 +- tests/fixtures/tui/platforms-80x24.txt | 18 +- tests/fixtures/tui/route-evmapy.txt | 26 +- tests/fixtures/tui/route-gptokeyb.txt | 26 +- tests/fixtures/tui/route-gptokeyb_muxterm.txt | 26 +- tests/fixtures/tui/route-keys.txt | 32 +- tests/fixtures/tui/route-miyoo.txt | 26 +- tests/fixtures/tui/route-muxterm.txt | 26 +- tests/test_install.py | 2 +- tests/test_install_offline.py | 95 +- tests/test_install_tui.py | 530 ++++++++++- tests/test_minui_platform.py | 10 +- 23 files changed, 1356 insertions(+), 398 deletions(-) diff --git a/install.ps1 b/install.ps1 index 2fdb5701b..8d82cdd0d 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "2127f3c7a1646cc408520e364d2a08c6aa32a8905990c1a2581ce977b39017ab" + $defaultInstallSha256 = "2e88f22d2f3cd933d9b198b841367d6e2d7084de228fa0b12dc2f11c71c0c1e9" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 722821d28..5776b0233 100644 --- a/install.py +++ b/install.py @@ -39,6 +39,7 @@ import subprocess import sys import tarfile import tempfile +import textwrap import threading import time import traceback @@ -295,12 +296,14 @@ def _os_default_dests(os_type: str) -> dict[str, Path]: "retroarch": home / ".config" / "retroarch" / "system", } + # Set when the run is interrupted, so queued downloads end instead of draining # the pool: every future is submitted before the first one completes. _stop = threading.Event() # Set once a run starts writing: Ctrl+C before that has nothing to carry on. _writing = threading.Event() + class _Interrupted(Exception): """Raised inside a worker once the run has been stopped.""" @@ -912,11 +915,28 @@ def _host_path(text: str) -> Path: return Path(text) +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) + root = _WSL["root"].rstrip("/") + "/" + 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 + + def _wsl_dest(args: argparse.Namespace) -> None: - """--dest D:\\ as WSL opens it: the screen names that card D:, and the - letter typed was refused as a folder that does not exist.""" - if args.dest is not None: - args.dest = _host_path(str(args.dest)) + """Every path option as WSL opens it: the screen names a card D:, and + D:\\ given to --dest was refused as a folder that does not exist, then + E:\\ given to --from once --dest took it.""" + for name in ("dest", "save_dir", "cores_dir"): + if getattr(args, name, None) is not None: + setattr(args, name, _host_path(str(getattr(args, name)))) + for name in ("from", "from_pack"): + if getattr(args, name, None): + setattr(args, name, [_host_path(str(path)) for path in getattr(args, name)]) def _enter_wsl() -> None: @@ -4237,6 +4257,7 @@ def place_save_entries( unplaced = [ {"dest": entry["dest"], "name": PurePosixPath(entry["dest"]).name, "system": "", "required": False, "reason": "no_save_directory", + "frontend": display, "cores": entry.get("cores"), "root": "save", **({"core_folder": entry["core_folder"]} if entry.get("core_folder") else {})} for entry in saved @@ -5131,7 +5152,7 @@ def _retry_delay(attempt: int, retry_after: str | None) -> float: def _locations(paths: list[Path]) -> str: """Where the run wrote, naming every destination when it served several.""" - return ", ".join(str(p) for p in paths) if paths else "nowhere" + return ", ".join(shown_path(p) for p in paths) if paths else "nowhere" # A write refused by the file system: retrying cannot change it, and the @@ -5222,7 +5243,9 @@ class Invocation: ("--no-game-data", args.no_game_data), ("--declared-only", args.declared_only), ("--standalone-copies", args.standalone_copies), - ("--offline", getattr(args, "offline", False)), + # RETROBIOS_INSTALL_OFFLINE too: sudo does not pass it on, and the + # command it was told to run fetched what the run had left out. + ("--offline", getattr(args, "offline", False) or offline_requested()), ("--check", args.check), ("--dry-run", args.dry_run), ): @@ -5399,6 +5422,15 @@ def _report_outcome( that failed do, 1 when some were installed and 2 when none were, the codes the full screen's run gives. """ + # A file --offline left out is not a failure, whether the run asked or + # not: without questions it was "14698 files failed", and "Run the same + # command again to retry only those", which cannot fetch them. + left = [failure for failure in failures or [] if isinstance(failure[1], OfflineReason)] + if left: + failures = [failure for failure in failures or [] + if not isinstance(failure[1], OfflineReason)] + errors = max(0, errors - len(left)) + offline += len(left) if transferred and seconds > 0: verb = "copied from the pack" if LOCAL.pack is not None else "downloaded" print(f"\n{format_size(transferred)} {verb} in " @@ -5418,7 +5450,14 @@ def _report_outcome( print(f"\nNothing installed: {_plural(declined, 'file')} not downloaded, as answered.") print(f"{_plural(up_to_date, 'file')} already up to date at {_locations(paths)}.") return - kept = _kept_lines() + # Left out for an unknown save directory, said with that reason in + # every ending: "the collection does not hold it" blamed the collection. + unplaced = [entry for entry in omitted_entries or [] + if entry.get("reason") == "no_save_directory"] + kept = _kept_lines() + ([ + f"{_plural(len(unplaced), 'file')} read from " + f"{unplaced[0].get('frontend') or 'the frontend'}'s save directory left out: " + "its place is not known. Name it with --save-dir next time."] if unplaced else []) if interrupted: print(f"\nStopped. {_plural(downloaded, 'file')} installed.") for line in kept: @@ -5445,9 +5484,6 @@ def _report_outcome( "(or delete them), then run again.") if any(isinstance(reason, NetworkReason) for _dest, reason in failures or []): advice.append(NETWORK_FAILURE_ADVICE) - if any(isinstance(reason, OfflineReason) for _dest, reason in failures or []): - advice.append(" These files come from the network, which --offline does not " - "use: run without --offline, or name a pack with --from-pack.") paths = paths + [folder for folder in elsewhere if folder not in paths] if offline: @@ -5456,14 +5492,22 @@ def _report_outcome( print(f"\nNothing installed: {_plural(offline, 'file')} " f"{'needs' if offline == 1 else 'need'} the internet, which --offline " "does not use.") + for line in kept: + print(line) print(f" {remedy}") if up_to_date: print(f"{_plural(up_to_date, 'file')} already up to date at " f"{_locations(paths)}.") sys.exit(2) failed = f", {errors} failed" if errors else "" - print(f"\nFinished with files missing. {_plural(downloaded, 'file')} installed, " - f"{offline} not fetched (--offline){failed}.") + if downloaded: + print(f"\nFinished with files missing. {_plural(downloaded, 'file')} installed, " + f"{offline} not fetched (--offline){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}.") if sources: print(f" {sources}") print(f"Location: {_locations(paths)}") @@ -5505,25 +5549,48 @@ def _report_outcome( print(f"Location: {_locations(paths)}") for line in kept: print(line) - if omitted: - for line in _omission_lines(omitted, omitted_entries or []): + 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"]): print(line) for line in hints or []: print(line) print("To check this later, run the same command with --check.") -def _offline_remedy() -> str: +def _offline_remedy(folders: list[str] | None = None) -> str: """Where files --offline does not fetch can still come from: the - folders --from named are not suggested again.""" - if LOCAL.folders: - named = ", ".join(screen_text(index.label) for index in LOCAL.folders) + folders --from named (folders, on the screen) are not suggested again. + One wording for line mode, a run without questions, --check and the + screen.""" + labels = (folders if folders is not None + else [index.label for index in LOCAL.folders]) + 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.") return ("Copy them from your old card or a folder (--from), or run the installer " "again with a pack (--from-pack) or without --offline.") +def _check_fetch_line(fetch: int, beside: str) -> str: + """What --check says a run without it does with the files not in place: + offline, those it cannot fetch are said so, not "run it to fetch them".""" + if _offline_only(): + 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()}") + 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}.") + + +def _offline_failure(reason: str) -> bool: + """Whether a failure a run reported is a file --offline left out.""" + return OFFLINE_WORDS in reason or "makes no network request" in reason + + def _omission_lines(count: int, entries: list[dict]) -> list[str]: """What was skipped, the files an emulator cannot start without first. @@ -5648,9 +5715,10 @@ 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 " - f"--offline does not use. Without --offline they would go to {bios_path}:") + "--offline does not use. Without --offline they would go to " + f"{shown_path(bios_path)}:") elif parts: - print(f"\nWould download {' and '.join(parts)} to {bios_path}:") + print(f"\nWould download {' and '.join(parts)} to {shown_path(bios_path)}:") for line in copied: print(line) if not parts: @@ -6154,9 +6222,7 @@ def _install_standalone_folders( break if args.check: if pending: - print("\n Run the same command without --check to fetch them; " - "a file with a wrong hash is replaced, and the one in place " - f"kept in {BACKUP_DIR} beside its folder.") + print("\n" + _check_fetch_line(pending, "its folder")) sys.exit(1) return True if args.dry_run: @@ -6310,14 +6376,43 @@ def _line_setups() -> list[tuple[str, str]]: platforms, then each system that runs from its SD card, by its own name, as the screen lists them. Onion was reachable only as RetroArch, whose folder on a computer is the computer's.""" - choices = [(plat, display_name(plat) + (", SD card" if card_setup(plat, plat) else "")) - for plat in AVAILABLE_PLATFORMS] - choices += [(setup.name, f"{setup.name}, SD card ({display_name(setup.plat)} files)") - for setup in sorted(card_setups(), key=lambda item: item.name.casefold()) - if setup.name != display_name(setup.plat)] + def devices(name: str) -> str: + # The devices that run it, three at most: the name its owner knows. + names = list(_SETUP_DEVICES.get(name, ())) + more = f", +{len(names) - 3}" if len(names) > 3 else "" + return ", ".join(names[:3]) + more + + choices = [] + for plat in AVAILABLE_PLATFORMS: + label = display_name(plat) + if card_setup(plat, plat): + label += ", SD card" + (f" ({devices(plat)})" if devices(plat) else "") + choices.append((plat, label)) + for setup in sorted(card_setups(), key=lambda item: item.name.casefold()): + if setup.name != display_name(setup.plat): + shown = devices(setup.name) or f"{display_name(setup.plat)} files" + choices.append((setup.name, f"{setup.name}, SD card ({shown})")) return choices +def _line_matches(choices: list[tuple[str, str]], typed: str) -> list[int]: + """The indexes of the choices a typed device or system name finds, with + the matcher of the screen's filter: "miyoo" was refused, the line menu + knowing only setup names.""" + systems = _systems_reading() + cards = {setup.name for setup in card_setups()} + found = [] + for index, (name, _label) in enumerate(choices): + if name in AVAILABLE_PLATFORMS: + others = [system for system in systems.get(name, []) if system not in cards] + named = _setup_matches(name, others, typed) + else: + named = _system_matches(name, typed) + if named is not None: + found.append(index) + return found + + def _choose_platform(title: str, default: str = "") -> str: """One setup from the numbered list, Enter taking the default: a platform id, or the name of a system that runs from its SD card, which @@ -6327,7 +6422,7 @@ def _choose_platform(title: str, default: str = "") -> str: for i, (_name, label) in enumerate(choices, 1): print(f" {i}) {label}") print(" q) quit") - print() + print("Type a number, a setup's name, or your device's name (Miyoo Mini, R36S).") prompt = f"> [Enter for {display_name(default)}] " if default else "> " while True: choice = _ask(prompt) @@ -6341,8 +6436,17 @@ def _choose_platform(title: str, default: str = "") -> str: for name, _label in choices: if key and key in (_name_key(name), _name_key(display_name(name))): return name - print(f" Type a number from 1 to {len(choices)}, a platform name, " - "or q to quit.") + found = _line_matches(choices, choice) if key else [] + if len(found) == 1: + print(f" {choices[found[0]][1]}") + return choices[found[0]][0] + if found: + print(f" {len(found)} setups run on '{choice.strip()}':") + for index in found: + print(f" {index + 1}) {choices[index][1]}") + continue + print(f" Type a number from 1 to {len(choices)}, a setup name or your " + "device's name, or q to quit.") def _prompt_manual_platform(os_type: str, ask: bool = True, @@ -6588,6 +6692,7 @@ def _prompt_custom_selection( plat: str, files: list[dict], omitted: list[dict], installed: tuple[list[dict], list[dict]] | None = None, name: str = "", dry_run: bool = False, data_trees: list[dict] | None = None, + bios_path: Path | None = None, ) -> tuple[list[str], list[str], list[str]] | str | None: """Offer to narrow the install by system, core and region. @@ -6603,33 +6708,53 @@ def _prompt_custom_selection( print(f"\n{name or display_name(plat)}: {_sized(files)}" + (f", and {_plural(data, 'data file')} ({format_size(data_size)})" if data else "") + ".") - offline = _offline_only() - 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" - + ("." 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. - print(" Enter) list everything, nothing is written" if dry_run - else " Enter) install what is found on this machine" if offline - else " Enter) install everything") - if installed is not None: - print(f" i) only what the installed cores read: {_sized(installed[0])}") - print(" c) choose systems, cores and regions") - print(" q) quit") + if bios_path is not None: + # Named before anything is written: line mode said where only after + # Done, where the screen names it on every plan. + print(f" BIOS folder: {shown_path(bios_path)}") while True: + offline = _offline_only() + 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" + + ("." 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. + # Offline with nothing to copy from, nothing can be installed but + # what is in place already. + if dry_run: + print(" Enter) list everything, nothing is written") + elif offline and LOCAL.folders: + print(" Enter) install what is found in " + + ", ".join(screen_text(index.label) for index in LOCAL.folders)) + elif offline: + print(" Enter) check the BIOS folder, nothing can be downloaded") + else: + print(" Enter) install everything") + if installed is not None: + print(f" i) only what the installed cores read: {_sized(installed[0])}") + print(" c) choose systems, cores and regions") + # The screen's "Copy the BIOS files from your old card or a folder". + copying = not dry_run + if copying: + print(" f) copy from a folder or an old card") + print(" q) quit") + keys = ["", "q", "c", *(["i"] if installed is not None else []), + *(["f"] if copying else [])] answer = _ask("> ").lower() + while answer not in keys: + print(f" Type {', '.join(['Enter', *keys[2:]])} or q.") + answer = _ask("> ").lower() if answer == "": return None if answer == "q": _quit() - if answer == "i" and installed is not None: + if answer == "i": return INSTALLED_ONLY if answer == "c": break - keys = "Enter, i, c or q" if installed is not None else "Enter, c or q" - print(f" Type {keys}.") + _ask_from_folder() choices = available_choices(files, omitted) if OTHER_SYSTEMS in choices["systems"]: @@ -9342,6 +9467,7 @@ def _open_local_sources(args: argparse.Namespace) -> None: named = local_manifest_platform(args.manifest) if args.platform is None and named: args.platform = named + args.platform_from_list = True print(f" File list: {args.manifest} ({display_name(named)})") if not args.from_pack: return @@ -9425,17 +9551,39 @@ def _open_from_folders(folders: list[Path]) -> None: EVENTS.emit("error", stage="from", reason=reason, platform="", message=message) print(f"Error: {screen_text(message)}.", file=sys.stderr) sys.exit(1) - index = FolderIndex(Path(os.path.expanduser(label)), label) - LOCAL.folders.append(index) - print(f" Copying BIOS files from: {screen_text(label)}") - if index.truncated: - print(f" Read the first {MAX_SCANNED_FILES:,} files of {screen_text(label)} " - "only. Name the folder that holds the BIOS files to search it all.") - for drive in index.other_drives: - print(f" Did not go into {screen_text(str(drive))}: it is another drive. Name " - "it with --from to read it too.") - EVENTS.emit("scanned", folder=label, files=index.files, truncated=index.truncated, - other_drives=[str(drive) for drive in index.other_drives]) + _read_from_folder(label) + + +def _ask_from_folder() -> None: + """A folder to copy from, asked at the menu: the screen offers it, the + line menu had no way to it.""" + while True: + raw = _ask("Folder, old card or ZIP to copy from (Enter to go back): ").strip() + if not raw: + return + label = str(_typed_folder(raw)) + problem = from_folder_problem(label) + if problem is None: + _read_from_folder(label) + _INVOCATION.options.append(("--from", label)) + return + print(f" {screen_text(label)} {problem[1]}.") + + +def _read_from_folder(label: str) -> FolderIndex: + """Read a folder to copy BIOS files from, one from_folder_problem took.""" + index = FolderIndex(Path(os.path.expanduser(label)), label) + LOCAL.folders.append(index) + print(f" Copying BIOS files from: {screen_text(shown_path(label))}") + if index.truncated: + print(f" Read the first {MAX_SCANNED_FILES:,} files of {screen_text(label)} " + "only. Name the folder that holds the BIOS files to search it all.") + for drive in index.other_drives: + print(f" Did not go into {screen_text(str(drive))}: it is another drive. Name " + "it with --from to read it too.") + EVENTS.emit("scanned", folder=label, files=index.files, truncated=index.truncated, + other_drives=[str(drive) for drive in index.other_drives]) + return index def _refuse_closed(dest: Path, named: str, then: str = "") -> None: @@ -9534,6 +9682,7 @@ def _card_mount(path: str) -> str: """The card mount a device path lies under, "" for none.""" return next((mount for mount in _CARD_MOUNTS if path.startswith(mount)), "") + # 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 @@ -9626,6 +9775,13 @@ class CardChoice(NamedTuple): short: str = "" # The system's folder below its card. below: str = "" + # The card's root, when the folder refused lies inside it. + root: Path | None = None + # The card found and where its files go, said where nothing else names + # the folder (line mode): the screen's plan names it. + found: str = "" + # The system whose card the folder refused is, when one is. + other: str = "" class CardSetup(NamedTuple): @@ -9663,6 +9819,15 @@ def card_setups() -> list[CardSetup]: if plat in CARD_PLATFORMS] setups += [CardSetup(rule.name, rule.manifest, rule.bios, (rule,), True) for rule in _SHARE_RULES] + # 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/ + # fs-resize:64), autostart.sh:93 fills it as "the GAMES volume". EmuELEC + # d4c7b2f1 makes EEROMS (fs-resize:127-140), mount_romfs.sh:10-14 mounts + # it there. A computer sees that part's root. No file either writes + # there names it. + setups += [CardSetup(name, plat, "") for name, plat in _STANDIN_MANIFESTS.items() + if name in ("AmberELEC", "EmuELEC")] for name in _systems_reading().get("retroarch", []): below = next((rule.bios[len(_card_mount(rule.bios)):] for rule in _EMBEDDED_RULES if rule.name == name and rule.bios and _card_mount(rule.bios)), "") @@ -9710,7 +9875,7 @@ def _not_the_card(setup: CardSetup, folder: Path, card: Path, other: str) -> Car if other: return CardChoice(setup.plat, folder, refused=f"{card} is {_a(other)} card, not " f"{_a(name)} card", short=f"That is {_a(other)} card, not " - f"{_a(name)} card.", below=setup.below) + f"{_a(name)} card.", below=setup.below, other=other) marker = (CARD_MARKER if setup.plat in CARD_PLATFORMS else setup.rules[0].values[0].split("/")[0]) return CardChoice(setup.plat, folder, refused=f"{card} is not {_a(name)} SD card: no " @@ -9719,6 +9884,18 @@ def _not_the_card(setup: CardSetup, folder: Path, card: Path, other: str) -> Car below=setup.below) +def _refusal_words(choice: CardChoice, setup: CardSetup) -> str: + """Why a card typed or numbered in line mode is refused, and what to do: + another system's card is the top folder of a card, only not this one.""" + detail = f" {choice.detail}." if choice.detail else "" + if choice.other: + return (f"{choice.refused}.{detail} Insert the {setup.name} card, or choose " + f"{choice.other} for that one.") + if choice.root is not None: + return f"{choice.refused}.{detail} Type its root: {shown_path(choice.root)}" + return f"{choice.refused}.{detail} Type the top folder of the {setup.name} card." + + def _unknown_card_note(setup: CardSetup, card: Path, folder: Path) -> str: """Said when a card nothing names is taken for setup's.""" if setup.fresh: @@ -9730,58 +9907,123 @@ def _unknown_card_note(setup: CardSetup, card: Path, folder: Path) -> str: def _card_and_below(setup: CardSetup, path: Path) -> tuple[Path, bool]: """(card root, whether path is the BIOS folder below it) for a folder - named for setup: its BIOS folder named outright stands for its card.""" + named for setup: its BIOS folder named outright stands for its card. + + A folder whose own files name a system is that card, whatever its + name: an Onion card mounted as BIOS was read as a BIOS folder, its + parent checked as the card and refused.""" below = setup.below - if below and _ends_with(path, below): - return path.parents[len(PurePosixPath(below).parts) - 1], True + depth = len(PurePosixPath(below).parts) + if (below and _ends_with(path, below) and _card_is(setup, path)[0] == "unknown" + and not any(_card_boundary(folder) + for folder in [path, *list(path.parents)[:depth - 1]])): + return path.parents[depth - 1], True return path, False -def card_choice(named: str, plat: str, dest: Path) -> CardChoice: - """The file list and folder for a --dest that may be a handheld's card. +def _card_boundary(folder: Path) -> bool: + """Whether folder is where a drive is mounted: no folder above it is on + the same card. /media/me holds a card mounted as MUOS, and is no muOS + card for it.""" + return os.path.ismount(folder) or folder in _mounted_cards() - The card's root is where the user points, its BIOS folder is below it: - `--platform muos --dest /media/me/SD1` wrote to the root, which muOS - never reads. A card of another system is refused for every card system: - KNULLI took a muOS card and wrote its layout into it. A folder nothing - names is refused for a system whose card always carries its files, and - is taken as named for one that fills its card on first start or leaves - no file there that names it. The folder screen and the platform rows - read --dest here too, so the same --dest gives the same folder. + +def _card_above(setup: CardSetup, card: Path) -> tuple[str, str, Path] | None: + """(kind, name, root) of the card a folder nothing names lies inside, + when one of the folders above it, up to the drive's mount point, is a + card: ROCKNIX's roms/bios named for KNULLI read as a KNULLI card not + started once.""" + folder = card.absolute() + for parent in list(folder.parents)[:3]: + if _card_boundary(folder): + return None + kind, what = _card_is(setup, parent) + if kind != "unknown": + return kind, what, parent + folder = parent + return None + + +def _list_card_choice(plat: str, dest: Path) -> CardChoice: + """card_choice for a list named, not a card: a card's root it recognises + is read as that card, with the list's own system, and another system's + card is said, the user having named both.""" + found = card_system(dest) + if found and (not plat or found[0] == plat) and found[1] != dest: + return CardChoice(found[0], found[1], + found=f"{found[2]} card: {display_name(found[0])} files, " + f"in {found[1]}") + if found and plat and found[0] != plat: + return CardChoice(plat, dest, f"Note: {dest} is {_a(found[2])} card, which reads " + f"the {display_name(found[0])} file list in {found[1]}: " + f"--platform {_name_key(found[2])} --dest {dest}. The " + f"{display_name(plat)} list goes into it as named.") + 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})) + return CardChoice(plat, dest, f"{screen_text(str(dest))} holds the files of " + f"{names}: installing into it as named. Name the card's BIOS " + "folder with --dest to write there.") + return CardChoice(plat, dest) + + +def card_choice(named: str, plat: str, root: Path) -> CardChoice: + """The file list and BIOS folder for a folder named for what --platform + or a row of the setup list names, or why it is not that system's card. + + The one reading of a card's root, wherever it is named: --dest, the + setup list with --dest, a card listed, browsed or typed on the folder + screen, a card numbered or typed in line mode. Two readings gave a blank + stick named for KNULLI two folders, the root with --dest and bios below + it typed. The card's root is where the user points, its BIOS folder is + below it: `--platform muos --dest /media/me/SD1` wrote to the root, + which muOS never reads. A card of another system is refused for every + card system: KNULLI took a muOS card and wrote its layout into it. A + folder nothing names is refused for a system whose card always carries + its files (the MinUI family among them), and gets the system's folder + and a note for one that fills its card on first start or leaves no + file there that names it. A folder that does not exist is refused: a + typo four folders deep was created and filled. """ setup = card_setup(named, plat) if setup is None: - # A list named, not a card: a card's root it recognises is read as - # that card, with the list's own system. - found = card_system(dest) - if found and (not plat or found[0] == plat) and found[1] != dest: - return CardChoice(found[0], found[1], - f"{found[2]} card: {display_name(found[0])} files, in {found[1]}") - 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})) - return CardChoice(plat, dest, f"{screen_text(str(dest))} holds the files of " - f"{names}: installing into it as named. Name the card's BIOS " - "folder with --dest to write there.") - return CardChoice(plat, dest) + return _list_card_choice(plat, root) # A folder its device reads (/run/muos/storage/bios): on the device. - if dest.as_posix() in {rule.bios for rule in _EMBEDDED_RULES if rule.name == setup.name}: - return CardChoice(plat, dest) - card, inside = _card_and_below(setup, dest) + if root.as_posix() in {rule.bios for rule in _EMBEDDED_RULES if rule.name == setup.name}: + return CardChoice(plat, root) + card, inside = _card_and_below(setup, root) + if not _is_dir(card): + return CardChoice(plat, root, refused=f"{card} does not exist, or is not a folder", + short="That folder does not exist.", below=setup.below) + if plat in CARD_PLATFORMS: + above = card_root_above(root) + if above: + name = _a(display_name(above[0])) + return CardChoice(plat, root, refused=f"{root} is inside {name} card, whose " + f"root is {above[1]}", short=f"That is inside {name} card.", + root=above[1]) kind, what = _card_is(setup, card) + if kind == "unknown": + holder = _card_above(setup, card) + if holder and holder[0] == "other": + return _not_the_card(setup, root, holder[2], holder[1]) + if holder: + return CardChoice(plat, root, refused=f"{root} is inside the {setup.name} card, " + f"whose root is {holder[2]}", short=f"That is inside " + f"{_a(setup.name)} card.", below=setup.below, root=holder[2]) + folder = root if inside or not setup.below else card / setup.below if kind == "other": - return _not_the_card(setup, dest, card, what) - if inside or plat in CARD_PLATFORMS: - return CardChoice(plat, dest) - folder = dest / setup.below + return _not_the_card(setup, root, card, what) if kind == "own": - return CardChoice(plat, folder, f"{setup.name} card: {display_name(plat)} files, " - f"in {folder}") + note = ("" if what == setup.name else f"Note: this card holds {what} " + f"({CARD_MARKER}), and the {display_name(plat)} file list was asked for.") + found = (f"{setup.name} card: {display_name(plat)} files, in {folder}" + if folder != root else "") + return CardChoice(plat, folder, note, below=setup.below, found=found) if not setup.fresh and setup.known(): - return _not_the_card(setup, dest, card, "") - return CardChoice(plat, dest, f"Nothing on {dest} says it is {_a(setup.name)} card: " - f"installing into it as named. {setup.name}'s folder on its card is " - f"{setup.below}: --dest /{setup.below}", below=setup.below) + return _not_the_card(setup, root, card, "") + return CardChoice(plat, folder, _unknown_card_note(setup, card, folder), + below=setup.below) def card_dest(named: str, plat: str, dest: Path) -> tuple[str, Path]: @@ -9790,12 +10032,21 @@ def card_dest(named: str, plat: str, dest: Path) -> tuple[str, Path]: choice = card_choice(named, plat, dest) if choice.refused: detail = f" {choice.detail}." if choice.detail else "" - where = f" or that folder: --dest /{choice.below}" if choice.below else "" - print(f"Error: {choice.refused}.{detail} Name the card's root{where}", - file=sys.stderr) + where = (f": --dest {choice.root}" if choice.root + else f", or that folder: --dest /{choice.below}" if choice.below + else "") + message = f"{choice.refused}.{detail} Name the card's root{where}" + # A script reading the stream learned code 1 and no reason. + EVENTS.emit("error", stage="detect", reason="not_found", platform=choice.plat, + message=message) + print(f"Error: {message}", file=sys.stderr) sys.exit(1) - if choice.note: - print(f" {choice.note}") + for line in (choice.found, choice.note): + if line: + print(f" {line}") + system = card_bound(named, plat) + if system is not None: + _DETECTED_OS.setdefault((choice.plat, choice.folder), system[0]) return choice.plat, choice.folder @@ -9811,23 +10062,6 @@ def card_root_above(dest: Path) -> tuple[str, Path] | None: return None -def _check_card_root(plat: str, dest: Path) -> None: - """Refuse a folder inside the card where its root is meant. - - The file list writes Bios// below --dest: given the card's Bios - folder, every file would land in Bios/Bios//, which no core reads. - """ - above = card_root_above(dest) - if above: - print(f"Error: {dest} is inside {_a(display_name(above[0]))} card. " - f"Name the card's root: --dest {above[1]}", file=sys.stderr) - sys.exit(1) - found = card_platform(dest) - if found is not None and found != plat: - print(f" Note: this card holds {display_name(found)} ({CARD_MARKER}), and the " - f"{display_name(plat)} file list was asked for.") - - def card_bound(named: str, plat: str) -> tuple[str, str] | None: """(system name, BIOS folder below the card root) when what --platform named runs from an SD card, else None. @@ -9853,44 +10087,6 @@ def mounted_cards_of(system: str, plat: str) -> list[Path]: return [mount for mount in _mounted_cards() if _card_is(setup, mount)[0] == wanted] -def card_root_folder(named: str, plat: str, root: Path) -> CardChoice: - """The BIOS folder for root, chosen as the card of the card system - named, or why root is not that card. - - The one check of a card's root, for a card listed, browsed or typed on - the folder screen and for the line question. A typed root was taken as - it was: a typo four folders deep was created and filled, a menu answer - (8) became a relative card, a muOS card typed for KNULLI got KNULLI's - layout. Each is refused here, with its reason, for the question to be - asked again. --dest is read by card_choice, with the same identity. - """ - setup = card_setup(named, plat) - if setup is None: - return CardChoice(plat, root) - card, inside = _card_and_below(setup, root) - if not _is_dir(card): - return CardChoice(plat, root, refused=f"{card} does not exist, or is not a folder", - short="That folder does not exist.") - if plat in CARD_PLATFORMS: - above = card_root_above(root) - if above: - name = _a(display_name(above[0])) - return CardChoice(plat, root, refused=f"{root} is inside {name} card, whose " - f"root is {above[1]}", short=f"That is inside {name} card.") - kind, what = _card_is(setup, card) - folder = root if inside or not setup.below else card / setup.below - if kind == "other": - return _not_the_card(setup, root, card, what) - if kind == "own": - note = ("" if what == setup.name else f"Note: this card holds {what} " - f"({CARD_MARKER}), and the {display_name(plat)} file list was asked for.") - return CardChoice(plat, folder, note, below=setup.below) - if not setup.fresh and setup.known(): - return _not_the_card(setup, root, card, "") - return CardChoice(plat, folder, _unknown_card_note(setup, card, folder), - below=setup.below) - - def card_line(mount: Path) -> str: """A mounted drive in the line questions, named as the screen names it, its path after: D: (Onion card, /mnt/d).""" @@ -9914,13 +10110,17 @@ def card_install_dest(named: str, plat: str, interactive: bool, ask: bool = True cards = mounted_cards_of(system, plat) if setup.known() and len(cards) == 1: dest = cards[0] / setup.below if setup.below else cards[0] - print(f" {system} card found at {cards[0]}: {display_name(plat)} files, in {dest}") + print(f" {system} card found at {shown_path(cards[0])}: {display_name(plat)} " + f"files, in {shown_path(dest)}") + _DETECTED_OS.setdefault((plat, dest), system) return dest - example = (f"--platform {named.strip() or plat} --dest /media/$USER/ " + # Pasted, the name of a system in words is two arguments: its key is one. + word = named.strip() if _name_key(named) == named.strip().lower() else _name_key(named) + example = (f"--platform {word or plat} --dest /media/$USER/ " "(Windows: --dest E:\\)") if setup.known() and len(cards) > 1: print(f" {len(cards)} {system} cards are mounted: " - + ", ".join(str(card) for card in cards) + ".", file=sys.stderr) + + ", ".join(_mount_name(card) for card in cards) + ".", file=sys.stderr) if not interactive: EVENTS.emit("error", stage="detect", reason="not_found", platform=plat, message=f"{system} keeps its BIOS files on its SD card, and no " @@ -9934,7 +10134,8 @@ def card_install_dest(named: str, plat: str, interactive: bool, ask: bool = True print(f" {system} keeps its BIOS files {where}, not on this machine. " + ("" if setup.known() else f"RetroBIOS cannot tell {_a(system)} card by its files. ") - + "Mount the card, then type its top folder" + (" or its number:" if cards else ".")) + + ("Type its number or its top folder:" if cards + else "Mount the card, then type its top folder.")) for index, mount in enumerate(cards, 1): print(f" {index}) {card_line(mount)}") numbers = "1" if len(cards) == 1 else f"1 to {len(cards)}" @@ -9947,13 +10148,16 @@ def card_install_dest(named: str, plat: str, interactive: bool, ask: bool = True print(f" Type {numbers}, or a folder.") continue root = cards[int(raw) - 1] if chosen else _typed_folder(raw) - choice = card_root_folder(system, plat, root) + choice = card_choice(system, plat, root) if choice.refused: - detail = f" {choice.detail}." if choice.detail else "" - print(f" {choice.refused}.{detail} Type the top folder of the {system} card.") + print(f" {_refusal_words(choice, setup)}") continue + # The card picked, echoed: the run went on to "RetroArch: 23 files" + # with no word of which card. + print(f" {_mount_name(root)}: {system} files go to {shown_path(choice.folder)}") if choice.note: print(f" {choice.note}") + _DETECTED_OS.setdefault((choice.plat, choice.folder), system) if _missing_dest(choice.folder, ask, "typed", again=True): return choice.folder @@ -10078,13 +10282,11 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: assumed_dests: set[Path] = set() named = args.platform or "" if args.platform: - args.platform = normalize_platform(args.platform) + args.platform = normalize_platform(args.platform, say=args.verbose) if args.dest: plat, args.dest = card_dest(named, args.platform or "", args.dest) args.platform = plat or args.platform if args.platform and args.dest: - if args.platform in CARD_PLATFORMS: - _check_card_root(args.platform, args.dest) platforms = [(args.platform, args.dest)] elif args.platform: print("Detecting platform...") @@ -10427,6 +10629,15 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: args.emulator, manifest, plat_name, files, omitted_files ) + # Before the menu: it offered 23 files, then 22 were checked, the + # one read from an unknown save directory left out. + files, unplaced = place_save_entries( + files, bios_path, manifest, args.save_dir, display_name(plat_name, manifest) + ) + if unplaced: + total_omitted += len(unplaced) + omitted_seen.extend(unplaced) + # What --core resolves against, for advice that names one. core_choices = set(available_choices(files, omitted_files)["cores"]) selection = _requested_narrowing(args, plat_name, files, omitted_files, manifest) @@ -10445,8 +10656,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: ) selection = _prompt_custom_selection( plat_name, files, omitted_files, installed, - display_name(plat_name, manifest), args.dry_run, - manifest.get("data_directories") or [], + detected_os(plat_name, bios_path) or display_name(plat_name, manifest), + args.dry_run, manifest.get("data_directories") or [], bios_path, ) if selection is None: smaller = (" Nothing downloaded. To install less, run the same command " @@ -10518,13 +10729,6 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: f"under {library}") bios_path = library - files, unplaced = place_save_entries( - files, bios_path, manifest, args.save_dir, display_name(plat_name, manifest) - ) - if unplaced: - total_omitted += len(unplaced) - omitted_seen.extend(unplaced) - print("\nChecking existing files...") to_download, up_to_date, mismatched = check_local( files, bios_path, jobs=args.jobs, term=term @@ -10598,15 +10802,15 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: # before the hundreds of files it holds. for line in split_pack_lines(bios_path): print(line) - print(f"\n {_plural(len(to_download), 'file')} to download:") + offline = _offline_only() + print(f"\n {_plural(len(to_download), 'file')} " + + ("not in place:" if offline else "to download:")) for line in check_lines(missing, mismatched, files, manifest, bios_path, args.verbose, up_to_date, bios_index, copies): print(line) for line in unreadable_lines(unread, args.verbose): print(line) - print(" Run the same command without --check to fetch them; " - "a file with a wrong hash is replaced, and the one in place " - f"kept in {BACKUP_DIR} beside the BIOS folder.") + print(_check_fetch_line(len(to_download) - len(copies), "the BIOS folder")) check_pending += len(to_download) else: print("\n All files up to date.") @@ -10649,9 +10853,10 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: if interactive and not args.yes and _offline_only() and fetched: # The screen offers no Install for these: the question asked # to download 6.6 GB that --offline never fetches. + # The remedy once, in the summary: it was printed twice in a row. print(f"\n {_plural(len(fetched), 'file')} " f"{'needs' if len(fetched) == 1 else 'need'} the internet, which " - f"--offline does not use. {_offline_remedy()}") + "--offline does not use.") # Not done: the summary counts them as missing. files_pending += len(fetched) total_offline += len(fetched) @@ -11160,7 +11365,7 @@ def _short_path(path: Path | None, home: Path) -> str: if home_text not in ("", "/", ".") and (text == home_text or text.startswith(home_text + os.sep)): return "~" + text[len(home_text):] - return text + return shown_path(text) def _folded(text: str) -> str: @@ -11213,6 +11418,9 @@ class TuiState: # (system, BIOS folder below the card root) when the setup asked for # runs from an SD card: the folder screen offers the mounted cards. self.card_bound: tuple[str, str] | None = None + # What card_choice said of the card chosen (a stick nothing names + # taken for KNULLI), kept from Create the folder? to the plan. + self.card_note: str = "" # The mounted cards of that system, as last looked for. self.cards: list | None = None # (width and text, lines) of the last pager wrapped. @@ -11518,6 +11726,7 @@ def _app_offer(state: TuiState) -> bool: return (state.screen == "result" and state.app is None and not state.app_running and _tui_installed(state) and _tui_app_ready(state)) + class TuiRow(NamedTuple): """One row a screen lists: what Enter does on it, and what it shows.""" @@ -11577,6 +11786,10 @@ def _filter_row(state: TuiState) -> TuiRow: how = {"muxterm": "Select shows a keyboard", "gptokeyb": "with a keyboard", "gptokeyb_muxterm": "with a keyboard", "evmapy": "with a keyboard", "miyoo": "with a keyboard"}.get(state.pad, "/ to type") + if state.screen == "platforms": + # A device name finds its setups: said where it is typed. + return TuiRow("filter", "Filter: setup or device (/)" if how == "/ to type" + else f"Filter ({how})", style="dim") return TuiRow("filter", f"Filter ({how})", style="dim") @@ -11881,7 +12094,15 @@ def _back(state: TuiState) -> list[tuple]: # Enter on None, then Esc: the plan kept its headline over an # Install the run refused. state.ticked = set(state.ticked_before) + chose = state.screen == "systems" and state.ticked != (state.ticked_before or set()) state.stack.pop() + if chose and state.screen == "plan": + # The systems chosen, the next press is Install: Back left the focus + # on Choose systems. + rows = tui_rows(state) + state.focus["plan"] = next((index for index, row in enumerate(rows) + if row.action == "install"), + state.focus.get("plan", 0)) if state.screen == "checking" and not state.running: # Stay after Esc on a check started from the command line: a # Checking screen nothing feeds counted seconds forever. @@ -11960,6 +12181,7 @@ def _closed_status(state: TuiState, closed: tuple[Path, str]) -> str: def _choose(state: TuiState, plat: str, dest: Path) -> list[tuple]: + state.card_note = "" closed = closed_folder(dest) if closed is not None: # Nothing can be checked or written there: the folder is asked @@ -12006,7 +12228,7 @@ def _card_folder(state: TuiState, folder: Path) -> CardChoice | None: return None if state.folder_kind == "found" and state.folder_default is not None: return None - return card_root_folder(bound[0], state.platform, folder) + return card_choice(bound[0], state.platform, folder) def _card_status(state: TuiState, choice: CardChoice, given: bool = False) -> None: @@ -12014,9 +12236,20 @@ def _card_status(state: TuiState, choice: CardChoice, given: bool = False) -> No 40-column screen shows whole, the folder and what was looked for on it after, shown where there is room and in the help. given is a folder --dest names, which no choice here can change.""" - then = ("--dest names it: choose another setup." if given - else "Choose the card's top folder.") - state.status = f"{choice.short or choice.refused + '.'} {then}" + if given: + then = "--dest names it: choose another setup." + short = choice.short or choice.refused + "." + elif choice.other: + # The top folder of a card, only another system's: "Choose the + # card's top folder" sent the user back to the same one. + bound = _tui_card_bound(state) + then = (f"Insert the {bound[0] if bound else 'right'} card, or go back and choose " + f"{choice.other}.") + short = f"That is {_a(choice.other)} card." + else: + then = "Choose the card's top folder." + short = choice.short or choice.refused + "." + state.status = f"{short} {then}" state.status_more = f"{choice.refused}." + (f" {choice.detail}." if choice.detail else "") @@ -12043,13 +12276,22 @@ def _choose_card(state: TuiState, folder: Path) -> list[tuple]: return [] state.folder_default, state.folder_kind = None, "usual" return _push(state, "folder") - card = card_system(folder) + return _take_card(state, choice, folder) + + +def _take_card(state: TuiState, choice: CardChoice, root: Path) -> list[tuple]: + """_choose for the folder card_choice gave root, its note kept on the + screens up to the plan: --dest, the setup list with --dest and a folder + picked here said nothing of a stick no file names, where line mode did.""" + card = card_system(root) if card and card[1] == choice.folder: # Titled as the same card found by itself: "Onion (RetroArch files)". _DETECTED_OS.setdefault((choice.plat, choice.folder), card[2]) + elif state.card_bound: + # A card no file names, chosen as ArkOS's: titled ArkOS, not RetroArch. + _DETECTED_OS.setdefault((choice.plat, choice.folder), state.card_bound[0]) effects = _choose(state, choice.plat, choice.folder) - if choice.note and not state.status: - state.status = choice.note + state.card_note = choice.note return effects @@ -12189,10 +12431,16 @@ def _browse_to(state: TuiState, folder: Path | None, came_from: Path | None = No and row.label == f"{row.value.name}/" and not _os_folder(row.value, folder)), 0) if not focus and not state.status: - state.status = ("This is your home folder: no setup reads its BIOS files " - "here. Go back to type a path." if folder == state.home - else "This is the root of a drive: no setup reads its BIOS " - "files here. Go back to type a path.") + state.status = ("No setup reads BIOS files in your home folder." if + folder == state.home else + "No setup reads BIOS files at a drive's root.") + if (folder is not None and state.browse_for == "from" and came_from is None + and folder in (state.home, Path(folder.anchor))): + # The old card is most often a mounted one: the docs say the copy + # browser opens on them, and A on Use this folder read all of HOME. + mounts = _mounted_cards() + focus = next((index for index, row in enumerate(state.browse_rows) + if row.action == "dir" and row.value in mounts), focus) for index, row in enumerate(state.browse_rows): if came_from is not None and row.action == "dir" and row.value == came_from: focus = index @@ -12231,6 +12479,11 @@ def _tui_folder_facts(state: TuiState) -> dict: def _activate(state: TuiState, row: TuiRow) -> list[tuple]: screen, action = state.screen, row.action if action == "filter": + only = _only_match(state) + if only is not None: + # One setup matches: A chooses it, a second press found nothing + # more to do. + return _activate(state, only) state.editing = screen return [] if screen == "setup": @@ -12253,7 +12506,7 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: _card_status(state, choice, given=True) state.platform, state.card_bound = "", None return [] - return _choose(state, choice.plat, choice.folder) + return _take_card(state, choice, state.dest_given) if state.card_bound: # On the device itself, the folder detection found there: a # KNULLI was asked for its own card. A card in a reader is @@ -12290,11 +12543,18 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: # A card is asked for: the drives come first, and the home # folder's "go back to type a path" sent the user away from # the cards listed under it. - drives = [index for index, item in enumerate(state.browse_rows) - if item.action == "dir" and item.value in _mounted_cards()] + # Its own cards first: the muOS card listed first was focused + # for Onion. + bound = _tui_card_bound(state) + own = mounted_cards_of(bound[0], state.platform) + mounts = _mounted_cards() + drives = ([index for index, item in enumerate(state.browse_rows) + if item.action == "dir" and item.value in own] + or [index for index, item in enumerate(state.browse_rows) + if item.action == "dir" and item.value in mounts]) if drives: state.focus["browse"] = drives[0] - state.status = "Choose the card below, or open the folder that holds it." + state.status = f"Choose the {bound[0]} card below." return [] state.path_text = str(state.folder_default or "") state.path_selected = bool(state.path_text) @@ -12429,10 +12689,10 @@ _SETUP_DEVICES: dict[str, tuple[str, ...]] = { "RGB10"), "muOS": ("RG35XX H", "RG35XX Plus", "RG35XX SP", "RG35XX 2024", "RG34XX H", "RG40XX H", "RG40XX V", "RG28XX H", "RGCUBEXX", "TrimUI Brick", "TrimUI Smart Pro"), - "Onion": ("Miyoo Mini", "Miyoo Mini+"), + "Onion": ("Miyoo Mini", "Miyoo Mini Plus"), "spruceOS": ("Miyoo A30", "Miyoo Flip", "Miyoo Mini", "Miyoo Mini Plus", "Miyoo Mini Flip", "TrimUI Smart Pro", "TrimUI Brick", - "Anbernic RG35XX Plus", "RG40XX H", "Powkiddy RGB30"), + "RG35XX Plus", "RG40XX H", "Powkiddy RGB30"), "Allium": ("Miyoo Mini", "Miyoo Mini Plus", "Miyoo Mini Flip"), "ArchR": ("R36S", "R33S", "R36S Soysauce", "Odroid Go Advance", "Odroid Go Super", "RG351V", "RG351M", "GameForce Chi", "MagicX XU10", "Powkiddy RGB10", @@ -12490,7 +12750,10 @@ def _device_needle(needle: str) -> str: """The filter text with a model's short form spelled out: owners write RP5 for the Retroid Pocket 5, as the store names it.""" short = re.fullmatch(r"\s*rp\s*(\d+)\s*", needle, re.IGNORECASE) - return f"Retroid Pocket {short.group(1)}" if short else needle + if short: + return f"Retroid Pocket {short.group(1)}" + # One spelling in the lists, Miyoo Mini Plus: the box writes Mini+. + return re.sub(r"\s*\+", " Plus", needle) def _setup_matches(plat: str, systems: list[str], needle: str) -> list[str] | None: @@ -12538,13 +12801,18 @@ def _platform_rows(state: TuiState) -> list[TuiRow]: Every card system is a row of its own that asks for its card, with or without the filter, from card_setups(): RetroArch's row listed muOS, JELOS and fourteen more, and A on it led an R36S owner to this - computer's RetroArch folder. RetroArch's row is this computer's. + computer's RetroArch folder. RetroArch's row is this computer's. A card + row names the devices that run it, the name a handheld's owner knows: + "Onion, SD card" beside "RetroArch files" said nothing to a Miyoo Mini + owner. With nothing found here the card rows come right after RetroArch + (_cards_first). """ needle = state.filters.get("platforms", "") setups = card_setups() cards = {setup.name for setup in setups} systems = _systems_reading() - rows = [_filter_row(state)] + desktop: list[TuiRow] = [] + handheld: list[tuple[str, TuiRow]] = [] for plat in AVAILABLE_PLATFORMS: others = [name for name in systems.get(plat, []) if name not in cards] named = _setup_matches(plat, others, needle) @@ -12554,12 +12822,14 @@ def _platform_rows(state: TuiState) -> list[TuiRow]: label = display_name(plat) if setup and _device_setup(state, plat, setup.name) is None: label += ", SD card" - # The systems the filter named, else every system its list stands - # for that is no card: AmberELEC's PAN4ELEC. - shown = named or others - rows.append(TuiRow("platform", label, ", ".join(shown), plat, - narrow=_comma_forms(shown))) - for setup in sorted(setups, key=lambda item: item.name.casefold()): + # The systems and devices the filter named, else a card's devices. + shown = named or (list(_SETUP_DEVICES.get(plat, ())) if setup else []) + row = TuiRow("platform", label, ", ".join(shown), plat, narrow=_comma_forms(shown)) + if setup: + handheld.append((label, row)) + else: + desktop.append(row) + for setup in setups: if setup.name == display_name(setup.plat): continue named = _system_matches(setup.name, needle) @@ -12568,15 +12838,35 @@ def _platform_rows(state: TuiState) -> list[TuiRow]: label = setup.name if _device_setup(state, setup.plat, setup.name) is None: label += ", SD card" - rows.append(TuiRow("cardsystem", label, ", ".join(named) - or f"{display_name(setup.plat)} files", (setup.plat, setup.name), - narrow=_comma_forms(named))) - return rows + shown = named or list(_SETUP_DEVICES.get(setup.name, ())) + handheld.append((label, TuiRow("cardsystem", label, ", ".join(shown) + or f"{display_name(setup.plat)} files", + (setup.plat, setup.name), + narrow=_comma_forms(shown)))) + handheld.sort(key=lambda item: item[0].casefold()) + cards_rows = [row for _label, row in handheld] + if _cards_first(state): + lead = [row for row in desktop if row.value == "retroarch"] + rest = [row for row in desktop if row.value != "retroarch"] + return [_filter_row(state), *lead, *cards_rows, *rest] + return [_filter_row(state), *desktop, *cards_rows] + + +def _cards_first(state: TuiState) -> bool: + """Whether the card rows follow RetroArch: nothing was found here, so + this is a computer a handheld's card is prepared on, or a console the + detection does not know. A pad cannot type in the filter, and Onion was + row 30 of 34: 29 presses of Down. Right after RetroArch it is row 19, + and four presses with L1/R1 at 40x12. A row that opened a list of the + cards cost one more press than the list it opened.""" + return not state.setups def _platform_focus(state: TuiState, plat: str) -> int: """The platform list's row for plat, below its filter row.""" - return 1 + AVAILABLE_PLATFORMS.index(plat) if plat in AVAILABLE_PLATFORMS else 1 + rows = _platform_rows(state) + return next((index for index, row in enumerate(rows) + if row.action == "platform" and row.value == plat), 1) def _option(state: TuiState, action: str, step: int) -> list[tuple]: @@ -12717,6 +13007,14 @@ def _edit_text(text: str, key: TuiKey, selected: bool = False) -> str | None: return None +def _only_match(state: TuiState) -> TuiRow | None: + """The one setup the filter leaves, when it leaves one.""" + if state.screen != "platforms" or not state.filters.get("platforms"): + return None + rows = [row for row in tui_rows(state) if row.action != "filter"] + return rows[0] if len(rows) == 1 else None + + def _filter_key(state: TuiState, key: TuiKey) -> list[tuple]: """Typing in a list's filter. It never ticks or unticks anything, and focus never lands on All or None: the next A or X there undid a choice.""" @@ -12732,6 +13030,9 @@ def _filter_key(state: TuiState, key: TuiKey) -> list[tuple]: state.filters[screen] = "" state.editing = "" rows = tui_rows(state) + only = _only_match(state) + if key.name == "enter" and only is not None: + return _activate(state, only) if key.name in ("enter", "down") and len(rows) > filter_row + 1: state.focus[screen] = filter_row + 1 else: @@ -13396,8 +13697,8 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: headline = (f"{_plural(data, 'data file')} to fetch{data_size}" if data else "Everything is in place") wrong = check["wrong_hash"] - offline_lines = (["Copy them from your old card or a folder, or run the installer " - "again with a pack (--from-pack) or without --offline."] + offline_lines = ([_offline_remedy([_last_name(str(folder)) + for folder in state.from_folders])] if offline and fetch else []) # The folder comes second: on a 40x12 screen it is what is left once # the rows are drawn, and nothing is written before it is seen. @@ -13436,7 +13737,8 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: if wrong and not (offline and not copied): # With its subject: "They are replaced" sat lines under the count. # Not where nothing can be installed: nothing is replaced then. - lines.append(f"The {wrong} wrong {'copy' if wrong == 1 else 'copies'} " + # Under the count it explains: last, a 40x12 plan cut it. + lines.insert(after_counts, f"The {wrong} wrong {'copy' if wrong == 1 else 'copies'} " f"{'is' if wrong == 1 else 'are'} replaced, the old " f"{'one' if wrong == 1 else 'ones'} kept in a backup folder.") misplaced = _tui_misplaced(state) if check is state.check else [] @@ -13456,6 +13758,7 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: # the folder is named so root writes here. hint = Invocation() hint.os_type = state.os_type + hint.options = [("--offline", None)] if offline_requested() else [] lines.append("Or run it as root, this folder named: " + hint.command(state.platform, dest, elevated=True)) if check is state.check: @@ -13465,6 +13768,13 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: if check is state.check: lines += _unplaced_lines(state.check_summary, state, "Choose it in Options, Save folder (--save-dir).") + if check is state.check and state.from_folders: + # A folder copied from that holds none of them: the plan came back + # the same, still saying to copy from a folder. + held = {Path(folder).absolute() for folder in elsewhere} + offline_lines = [f"{_last_name(str(folder))} holds none of these files." + for folder in state.from_folders + if Path(folder).absolute() not in held] + offline_lines # On Confirm the remedy comes right after the headline: at 40x12 it was # what got cut, Back left alone below it. at = 1 if check is state.dry else 2 @@ -13503,7 +13813,7 @@ def _unplaced_lines(summary: dict | None, state: TuiState, def _view_setup(state: TuiState, width: int, height: int) -> tuple: rows = tui_rows(state) focus = rows[min(state.focus.get("setup", 0), len(rows) - 1)] - after = ([f"BIOS folder: {state.setups[focus.value][1]}"] + after = ([f"BIOS folder: {shown_path(state.setups[focus.value][1])}"] if focus.action == "setup" else []) before = ["Found on this machine:" if state.setups else "Nothing was found here."] status = [state.status] if state.status else [] @@ -13525,8 +13835,13 @@ def _view_platforms(state: TuiState, width: int, height: int) -> tuple: if state.dest_given is None and not state.setups and len(state.stack) == 1: # The first screen when nothing was found: at 40x15 its 18 rows # left no line to say so, or what to do. - pinned.append("Nothing was found here: choose the setup that will use the " - "BIOS files.") + # A handheld's owner knows its device, not its system's name: the + # filter finds it, and a pad that cannot type finds the cards right + # after RetroArch (_cards_first). + pinned.append("Nothing was found here. Choose the setup, or type your device's " + "name (/)." if state.pad in ("both", "keys") else + "Nothing was found here. Choose the setup: handhelds come after " + "RetroArch.") before = [] needle = state.filters.get("platforms", "") after = [f"No setup matches '{needle}'."] if len(rows) == 1 and needle else [] @@ -13584,7 +13899,13 @@ def _status_after(state: TuiState) -> list[str]: """The status under a screen's rows, its full form after it.""" if not state.status: return [] - return [state.status] + ([state.status_more] if state.status_more else []) + if not state.status_more: + return [state.status] + # Said once: the full form repeated the short one under it. The help + # holds it, and a pad, with no button for the help, reads it here. + if state.pad in ("both", "keys"): + return [f"{state.status} ? says more."] + return [state.status, state.status_more] def _view_browse(state: TuiState, width: int, height: int) -> tuple: @@ -13625,11 +13946,13 @@ def _view_path(state: TuiState, width: int, height: int) -> tuple: def _view_create(state: TuiState, width: int, height: int) -> tuple: - before = [f"{state.dest} does not exist.", + before = [f"{shown_path(state.dest)} does not exist.", f"The nearest folder that does: {state.facts.get('nearest', '')}"] if state.facts.get("risky"): before.append("Every folder of this path would be made: check its start before " "creating it.") + if state.card_note: + before.append(state.card_note) return ("Create the folder?", _compose(state, width, height, before, tui_rows(state)), ("A/Enter Choose", "B/Esc Back")) @@ -13684,12 +14007,23 @@ def _view_plan(state: TuiState, width: int, height: int) -> tuple: f"{format_size(sum(e['size'] for e in chosen))}, for " f"{len(state.ticked)} of {len(state.systems)} systems", "title") lines[2:2] = choice + 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) rows = tui_rows(state) if width < 54: rows = [row._replace(label="Copy from your old card or a folder") if row.action == "fromcard" else row for row in rows] + # One line: in two, a 40x12 plan dropped it, "1 wrong copy" left + # alone to say a file would be replaced. + wrong = check.get("wrong_hash", 0) + long_form = f"The {wrong} wrong {'copy' if wrong == 1 else 'copies'} " + lines = [_styled(f"{wrong} wrong {'copy' if wrong == 1 else 'copies'}: backed up, " + "replaced.") if str(line).startswith(long_form) else line + for line in lines] # A muOS card gets RetroArch's files: the title says both. system = detected_os(state.platform, state.dest) if state.dest is not None else "" name = display_name(state.platform, state.manifest) @@ -13857,7 +14191,11 @@ def _view_command(state: TuiState, width: int, height: int) -> tuple: def _view_confirm(state: TuiState, width: int, height: int) -> tuple: if state.running or state.dry is None: - lines: list = ["Counting the files for these choices..."] + # Nothing to install yet: "A/Enter Install" sat over a press that + # did nothing, then a screen with Back alone. + return ("Confirm", _compose(state, width, height, + ["Counting the files for these choices..."], []), + ("B/Esc Back", "? Help")) else: # Why this screen: the numbers above are counted again for the # systems and options chosen, the last look before anything is @@ -14020,7 +14358,8 @@ def _unavailable_lines(state: TuiState) -> list[str]: 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 from your own copy of the game.") + f"{named(games)}. Copy {'it' if len(games) == 1 else 'them'} from " + f"your own copy of the game.") return lines @@ -14031,13 +14370,24 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: # reported finished is what is installed. installed = summary.get("installed", sum(1 for _dest, ok, _why in state.finished if ok)) failed = summary.get("failed") or [] + # Left out by --offline: missing, never failed, as line mode says it. + left = [failure for failure in failed if _offline_failure(failure["reason"])] + failed = [failure for failure in failed if not _offline_failure(failure["reason"])] up_to_date = summary.get("up_to_date", 0) total = (state.download or {}).get("files", 0) space = "" if summary else _space_words(state) + errors = f", {len(failed)} failed" if failed else "" if summary.get("interrupted") or state.code == 130: title = "Stopped" head = (f"Stopped: {installed} of {total} installed, run again to finish." 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, {len(left)} not fetched (--offline){errors}." + elif left: + title = "Nothing installed" + head = (f"Nothing was installed: {_plural(len(left), 'file')} not fetched " + f"(--offline){errors}.") elif failed and installed: title = "Finished with errors" head = f"{installed} installed, {len(failed)} failed." @@ -14063,14 +14413,17 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: title = "Done" head = "Everything is in place." lines = [head] - sources = _tui_sources(state, installed, len(failed)) + 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) + len(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. lines.append("Your copies in the wrong place were left as they are.") - lines.append(f"BIOS folder: {state.dest}") + lines.append(f"BIOS folder: {shown_path(state.dest)}") if installed + up_to_date: lines += _saves_lines(state, state.done_saves) if space: @@ -14240,13 +14593,19 @@ def _help_lines(state: TuiState) -> list[tuple[str, str]]: ok = names["ok"] if state.pad == "miyoo" and screen in _CHECKLISTS: ok = names["enter"] - keys.append((ok, primary.split(" ", 1)[-1])) + if primary.startswith("A/"): + # A bar that offers no A (Confirm while it counts) names none here. + keys.append((ok, primary.split(" ", 1)[-1])) if screen in _CHECKLISTS: keys.append((names["tick"], "Tick, untick")) if screen == "systems": keys.append(("a, n", "All, none")) - if screen in ("platforms", "systems", "cores"): - keys.append(("/", "Filter (Esc clears)")) + if screen == "platforms": + keys.append(("/", "Filter by setup or device name")) + elif screen in ("systems", "cores"): + keys.append(("/", "Filter")) + if screen in ("platforms", "systems", "cores") and state.filters.get(screen): + keys.append((names["esc"], "Clear the filter")) if screen == "regions": keys.append(("Left/Right", "Move a region up or down the order")) if screen == "options": @@ -14272,6 +14631,8 @@ def _primary(state: TuiState) -> str | None: return None row = rows[max(0, min(state.focus.get(state.screen, 0), len(rows) - 1))] verb = _ROW_VERBS.get(row.action) + if row.action == "filter" and _only_match(state) is not None: + verb = "Choose" if state.screen == "systems" and row.action == "system" and not state.ticked: verb = "Only this system" if state.screen == "systems" and row.action == "none" and not state.ticked: @@ -14285,17 +14646,23 @@ def _view_help(state: TuiState, width: int, height: int) -> tuple: column = min(22, max(cell_width(key) for key, _what in keys) + 2) # Two spaces at least: a key as wide as the column touched its text # ("Select Keyboard on or off" on muxterm). - lines = [f"{_pad(key, column)}{what}" if cell_width(key) <= column - 2 - else f"{key} {what}" for key, what in keys] + lines = [] + for key, what in keys: + if cell_width(key) > column - 2: + lines.append(f"{key} {what}") + continue + # Wrapped under its own column: wrapped by the screen, a long text + # ran back to the left edge ("/ Filter (Esc clears)" at 40x12). + words = textwrap.wrap(what, max(8, width - column)) or [""] + lines.append(f"{_pad(key, column)}{words[0]}") + lines += [" " * column + word for word in words[1:]] if state.screen in ("setup", "plan", "browse", "folder") and state.dest is not None: - lines += ["", f"BIOS folder: {state.dest}"] + lines += ["", f"BIOS folder: {shown_path(state.dest)}"] if state.status_more: lines += ["", state.status_more] return ("Keys", _compose(state, width, height, lines, []), ("Any key closes this help",)) - - def tui_view(state: TuiState, cols: int, rows: int) -> list[str]: """The screen as rows lines of at most cols cells, from the state alone.""" if cols < TUI_MIN_COLS or rows < TUI_MIN_ROWS: @@ -14319,6 +14686,7 @@ def tui_view(state: TuiState, cols: int, rows: int) -> list[str]: lines.append(_styled(fit(" " + _hint(width, *hint), cols - 1), "dim")) return lines + def _setup_label(plat: str, path: Path) -> str: system = detected_os(plat, path) name = display_name(plat) @@ -14394,7 +14762,7 @@ def tui_start( if args.manifest is not None: state.manifest_path = Path(args.manifest).absolute() state.manifest_given = True - plat = normalize_platform(args.platform) if args.platform else "" + plat = normalize_platform(args.platform, say=False) if args.platform else "" if detect: captured = io.StringIO() with contextlib.redirect_stdout(captured), contextlib.redirect_stderr(captured): @@ -14426,22 +14794,23 @@ def tui_start( state.stack = [] state.platform, state.card_bound = plat, card_bound(args.platform, plat) dest = Path(args.dest) - if state.card_bound is None: - return state, _choose(state, plat, dest) # Read as the line mode reads --dest: an Onion card's root gets its - # BIOS folder, another system's card is refused. + # BIOS folder, another system's card is refused, a list named for + # another system's card is said. choice = card_choice(args.platform, plat, dest) if choice.refused: _card_status(state, choice) return state, _open_folder_screen(state, None) - return state, _choose(state, choice.plat, choice.folder) + return state, _take_card(state, choice, dest) if plat: bound = card_bound(args.platform, plat) # A system that runs from its card is its card or the device, never # this machine's own folder for the same list. matched = [setup for setup in state.setups if setup[0] == plat and (bound is None or detected_os(setup[0], setup[1]) == bound[0])] - if len(matched) == 1: + # Named by the file list alone, a lone card found is asked about + # first, as without the list: its folder was the first screen. + if len(matched) == 1 and not getattr(args, "platform_from_list", False): state.stack = [] return state, _choose(state, plat, matched[0][1]) if matched: diff --git a/install.sh b/install.sh index d7fabfa68..25dc09b43 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="2127f3c7a1646cc408520e364d2a08c6aa32a8905990c1a2581ce977b39017ab" +DEFAULT_INSTALL_SHA256="2e88f22d2f3cd933d9b198b841367d6e2d7084de228fa0b12dc2f11c71c0c1e9" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index e1c34015f..419af635b 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": 673359, - "sha256": "2127f3c7a1646cc408520e364d2a08c6aa32a8905990c1a2581ce977b39017ab", + "size": 692187, + "sha256": "2e88f22d2f3cd933d9b198b841367d6e2d7084de228fa0b12dc2f11c71c0c1e9", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 725908, - "sha256": "da7fa84e69575d43fbf529c053f43f8f665963c19e25ed44923183a072ed919d" + "size": 744736, + "sha256": "17daef144acc01204912de201bbb6bbf8996d1a866a37074e7cbaffe879e911b" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 673359, - "sha256": "2127f3c7a1646cc408520e364d2a08c6aa32a8905990c1a2581ce977b39017ab", + "size": 692187, + "sha256": "2e88f22d2f3cd933d9b198b841367d6e2d7084de228fa0b12dc2f11c71c0c1e9", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 673359, - "sha256": "2127f3c7a1646cc408520e364d2a08c6aa32a8905990c1a2581ce977b39017ab", + "size": 692187, + "sha256": "2e88f22d2f3cd933d9b198b841367d6e2d7084de228fa0b12dc2f11c71c0c1e9", "executable": false }, { diff --git a/platforms/_guides.yml b/platforms/_guides.yml index 2730cc33f..3b5706151 100644 --- a/platforms/_guides.yml +++ b/platforms/_guides.yml @@ -160,13 +160,13 @@ handheld_guide: - name: Onion folder: '`BIOS` at the root of the card' folder_source: https://onionui.github.io/docs/emulators - devices: [Miyoo Mini, Miyoo Mini+] + devices: [Miyoo Mini, Miyoo Mini Plus] devices_source: https://github.com/OnionUI/Onion - name: spruceOS folder: '`BIOS` at the root of the card' folder_source: https://github.com/spruceUI/spruceOS/wiki/10.-Adding-BIOS devices: [Miyoo A30, Miyoo Flip, Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip, - TrimUI Smart Pro, TrimUI Brick, Anbernic RG35XX Plus, RG40XX H, Powkiddy RGB30] + TrimUI Smart Pro, TrimUI Brick, RG35XX Plus, RG40XX H, Powkiddy RGB30] devices_source: https://github.com/spruceUI/spruceOS/wiki/Supported-Devices - name: Allium folder: '`BIOS` at the root of the card' diff --git a/tests/fixtures/tui/plan-120x40.txt b/tests/fixtures/tui/plan-120x40.txt index 25e7bbce7..3e51059e0 100644 --- a/tests/fixtures/tui/plan-120x40.txt +++ b/tests/fixtures/tui/plan-120x40.txt @@ -3,8 +3,8 @@ 4 files to download, 1.0 MB BIOS folder: ~/.config/retroarch/system 1 in place, 3 missing, 1 wrong copy - Free space: 50.0 GB The 1 wrong copy is replaced, the old one kept in a backup folder. + Free space: 50.0 GB > Install Choose systems diff --git a/tests/fixtures/tui/plan-40x12.txt b/tests/fixtures/tui/plan-40x12.txt index 80132bf29..c4c967a9d 100644 --- a/tests/fixtures/tui/plan-40x12.txt +++ b/tests/fixtures/tui/plan-40x12.txt @@ -3,7 +3,7 @@ 4 files to download, 1.0 MB BIOS folder: ~/.co.../retroarch/system 1 in place, 3 missing, 1 wrong copy - Free space: 50.0 GB + 1 wrong copy: backed up, replaced. > Install Choose systems Copy from your old card or a folder diff --git a/tests/fixtures/tui/plan-40x15.txt b/tests/fixtures/tui/plan-40x15.txt index 5f7240348..fff88bc27 100644 --- a/tests/fixtures/tui/plan-40x15.txt +++ b/tests/fixtures/tui/plan-40x15.txt @@ -3,13 +3,13 @@ 4 files to download, 1.0 MB BIOS folder: ~/.co.../retroarch/system 1 in place, 3 missing, 1 wrong copy + 1 wrong copy: backed up, replaced. Free space: 50.0 GB - The 1 wrong copy is replaced, the old - one kept in a backup folder. > Install Choose systems Copy from your old card or a folder Options Quit + A Install B Back Y Options ? Help diff --git a/tests/fixtures/tui/plan-80x24.txt b/tests/fixtures/tui/plan-80x24.txt index 6b26353e0..5a583340e 100644 --- a/tests/fixtures/tui/plan-80x24.txt +++ b/tests/fixtures/tui/plan-80x24.txt @@ -3,8 +3,8 @@ 4 files to download, 1.0 MB BIOS folder: ~/.config/retroarch/system 1 in place, 3 missing, 1 wrong copy - Free space: 50.0 GB The 1 wrong copy is replaced, the old one kept in a backup folder. + Free space: 50.0 GB > Install Choose systems diff --git a/tests/fixtures/tui/platforms-120x40.txt b/tests/fixtures/tui/platforms-120x40.txt index 066b357cd..344f372e9 100644 --- a/tests/fixtures/tui/platforms-120x40.txt +++ b/tests/fixtures/tui/platforms-120x40.txt @@ -2,7 +2,7 @@ Choose the setup that will use the BIOS files. - Filter (/ to type) + Filter: setup or device (/) > RetroArch Batocera Recalbox @@ -10,31 +10,31 @@ EmuDeck Lakka RetroDECK - ROCKNIX, SD card RomM BizHawk MiSTer FPGA RetroPie - AmberELEC PAN4ELEC - EmuELEC - KNULLI, SD card - MinUI, SD card - NextUI, SD card - MyMinUI, SD card - Allium, SD card RetroArch files + Allium, SD card Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip + AmberELEC, SD card Anbernic RG351, RG552 Anbernic stock firmware, SD card RetroArch files - ArchR, SD card RetroArch files - ArkOS, SD card RetroArch files - CrossMix-OS, SD card RetroArch files - dArkOS, SD card RetroArch files + 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 JELOS, SD card RetroArch files - KOLOS, SD card RetroArch files - Koriki, SD card RetroArch files - MiyooCFW, SD card RetroArch files - muOS, SD card RetroArch files - Onion, SD card RetroArch files - plumOS, SD card RetroArch files - RetroOZ, SD card RetroArch files - spruceOS, SD card RetroArch files - UnofficialOS, SD card RetroArch files + KNULLI, SD card Anbernic RG-Arc-S, RG28XX, RG34XX, RG35XX Original, RG35XX Plus, RG35XX H, +16 + KOLOS, SD card RG353P, RG353M, RG353V, RG353VS, RG503, Powkiddy RK2023, RGB30, RGB10 Max 3 + Koriki, SD card Miyoo Mini + MinUI, SD card Anbernic, Miyoo, TrimUI + 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 H, RG40XX H, RG40XX V, +4 + MyMinUI, SD card Anbernic, Miyoo + 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 OGS, OGA, RGB10Max, RGB10Max2 + ROCKNIX, SD card Anbernic RG351, RG353, RG503, RG552, RG 55G1, RG ARC, RG35XX 2024, +38 + 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 A/Enter Choose B/Esc Back / Filter ? Help diff --git a/tests/fixtures/tui/platforms-40x12.txt b/tests/fixtures/tui/platforms-40x12.txt index 08d03c876..715542018 100644 --- a/tests/fixtures/tui/platforms-40x12.txt +++ b/tests/fixtures/tui/platforms-40x12.txt @@ -1,6 +1,6 @@ RetroBIOS: Which setup? - Filter (/ to type) + Filter: setup or device (/) > RetroArch Batocera Recalbox diff --git a/tests/fixtures/tui/platforms-40x15.txt b/tests/fixtures/tui/platforms-40x15.txt index f41c417a0..d0ebd799c 100644 --- a/tests/fixtures/tui/platforms-40x15.txt +++ b/tests/fixtures/tui/platforms-40x15.txt @@ -1,6 +1,6 @@ RetroBIOS: Which setup? - Filter (/ to type) + Filter: setup or device (/) > RetroArch Batocera Recalbox @@ -8,8 +8,8 @@ EmuDeck Lakka RetroDECK - ROCKNIX, SD card RomM BizHawk + MiSTer FPGA 1-10 of 34 A Choose B Back / Filter ? Help diff --git a/tests/fixtures/tui/platforms-80x24.txt b/tests/fixtures/tui/platforms-80x24.txt index 1f0f566b1..eb0d2967b 100644 --- a/tests/fixtures/tui/platforms-80x24.txt +++ b/tests/fixtures/tui/platforms-80x24.txt @@ -1,6 +1,6 @@ RetroBIOS: Which setup? - Filter (/ to type) + Filter: setup or device (/) > RetroArch Batocera Recalbox @@ -8,17 +8,17 @@ EmuDeck Lakka RetroDECK - ROCKNIX, SD card RomM BizHawk MiSTer FPGA RetroPie - AmberELEC PAN4ELEC - EmuELEC - KNULLI, SD card - MinUI, SD card - NextUI, SD card - MyMinUI, SD card - Allium, SD card RetroArch files + Allium, SD card Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip + AmberELEC, SD card Anbernic RG351, RG552 + Anbernic stock firmware, SD card RetroArch files + ArchR, SD card R36S, R33S, R36S Soysauce, Odroid Go Advance, +8 + ArkOS, SD card RG353M, RG353V, RG503, RGB30, RK2023, RG351MP, RG351V, +2 + CrossMix-OS, SD card TrimUI Smart Pro + dArkOS, SD card Odroid Go Advance, RGB10, RG351MP, G350 + EmuELEC, SD card Odroid Go 1-19 of 34 A/Enter Choose B/Esc Back / Filter ? Help diff --git a/tests/fixtures/tui/route-evmapy.txt b/tests/fixtures/tui/route-evmapy.txt index 02b389947..d64b120b2 100644 --- a/tests/fixtures/tui/route-evmapy.txt +++ b/tests/fixtures/tui/route-evmapy.txt @@ -76,9 +76,9 @@ EmuDeck Lakka RetroDECK - ROCKNIX, SD card RomM BizHawk + MiSTer FPGA 1-10 of 34 A Choose B Back === platforms 80x24 @@ -92,18 +92,18 @@ EmuDeck Lakka RetroDECK - ROCKNIX, SD card RomM BizHawk MiSTer FPGA RetroPie - AmberELEC PAN4ELEC - EmuELEC - KNULLI, SD card - MinUI, SD card - NextUI, SD card - MyMinUI, SD card - Allium, SD card RetroArch files + Allium, SD card Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip + AmberELEC, SD card Anbernic RG351, RG552 + Anbernic stock firmware, SD card RetroArch files + ArchR, SD card R36S, R33S, R36S Soysauce, Odroid Go Advance, +8 + ArkOS, SD card RG353M, RG353V, RG503, RGB30, RK2023, RG351MP, RG351V, +2 + CrossMix-OS, SD card TrimUI Smart Pro + dArkOS, SD card Odroid Go Advance, RGB10, RG351MP, G350 + EmuELEC, SD card Odroid Go 1-19 of 34 A Choose B Back === folder 40x12 @@ -436,7 +436,7 @@ 4 files to download, 1.0 MB BIOS folder: ~/.co.../retroarch/system 1 in place, 3 missing, 1 wrong copy - Free space: 50.0 GB + 1 wrong copy: backed up, replaced. > Install Choose systems Copy from your old card or a folder @@ -449,15 +449,15 @@ 4 files to download, 1.0 MB BIOS folder: ~/.co.../retroarch/system 1 in place, 3 missing, 1 wrong copy + 1 wrong copy: backed up, replaced. Free space: 50.0 GB - The 1 wrong copy is replaced, the old - one kept in a backup folder. > Install Choose systems Copy from your old card or a folder Options Quit + A Install B Back Y Options === plan 80x24 RetroBIOS: RetroArch @@ -465,8 +465,8 @@ 4 files to download, 1.0 MB BIOS folder: ~/.config/retroarch/system 1 in place, 3 missing, 1 wrong copy - Free space: 50.0 GB The 1 wrong copy is replaced, the old one kept in a backup folder. + Free space: 50.0 GB > Install Choose systems diff --git a/tests/fixtures/tui/route-gptokeyb.txt b/tests/fixtures/tui/route-gptokeyb.txt index 9d7abb3ac..9ca9597f3 100644 --- a/tests/fixtures/tui/route-gptokeyb.txt +++ b/tests/fixtures/tui/route-gptokeyb.txt @@ -76,9 +76,9 @@ EmuDeck Lakka RetroDECK - ROCKNIX, SD card RomM BizHawk + MiSTer FPGA 1-10 of 34 A Choose B Back === platforms 80x24 @@ -92,18 +92,18 @@ EmuDeck Lakka RetroDECK - ROCKNIX, SD card RomM BizHawk MiSTer FPGA RetroPie - AmberELEC PAN4ELEC - EmuELEC - KNULLI, SD card - MinUI, SD card - NextUI, SD card - MyMinUI, SD card - Allium, SD card RetroArch files + Allium, SD card Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip + AmberELEC, SD card Anbernic RG351, RG552 + Anbernic stock firmware, SD card RetroArch files + ArchR, SD card R36S, R33S, R36S Soysauce, Odroid Go Advance, +8 + ArkOS, SD card RG353M, RG353V, RG503, RGB30, RK2023, RG351MP, RG351V, +2 + CrossMix-OS, SD card TrimUI Smart Pro + dArkOS, SD card Odroid Go Advance, RGB10, RG351MP, G350 + EmuELEC, SD card Odroid Go 1-19 of 34 A Choose B Back === folder 40x12 @@ -436,7 +436,7 @@ 4 files to download, 1.0 MB BIOS folder: ~/.co.../retroarch/system 1 in place, 3 missing, 1 wrong copy - Free space: 50.0 GB + 1 wrong copy: backed up, replaced. > Install Choose systems Copy from your old card or a folder @@ -449,15 +449,15 @@ 4 files to download, 1.0 MB BIOS folder: ~/.co.../retroarch/system 1 in place, 3 missing, 1 wrong copy + 1 wrong copy: backed up, replaced. Free space: 50.0 GB - The 1 wrong copy is replaced, the old - one kept in a backup folder. > Install Choose systems Copy from your old card or a folder Options Quit + A Install B Back Y Options === plan 80x24 RetroBIOS: RetroArch @@ -465,8 +465,8 @@ 4 files to download, 1.0 MB BIOS folder: ~/.config/retroarch/system 1 in place, 3 missing, 1 wrong copy - Free space: 50.0 GB The 1 wrong copy is replaced, the old one kept in a backup folder. + Free space: 50.0 GB > Install Choose systems diff --git a/tests/fixtures/tui/route-gptokeyb_muxterm.txt b/tests/fixtures/tui/route-gptokeyb_muxterm.txt index 9d7abb3ac..9ca9597f3 100644 --- a/tests/fixtures/tui/route-gptokeyb_muxterm.txt +++ b/tests/fixtures/tui/route-gptokeyb_muxterm.txt @@ -76,9 +76,9 @@ EmuDeck Lakka RetroDECK - ROCKNIX, SD card RomM BizHawk + MiSTer FPGA 1-10 of 34 A Choose B Back === platforms 80x24 @@ -92,18 +92,18 @@ EmuDeck Lakka RetroDECK - ROCKNIX, SD card RomM BizHawk MiSTer FPGA RetroPie - AmberELEC PAN4ELEC - EmuELEC - KNULLI, SD card - MinUI, SD card - NextUI, SD card - MyMinUI, SD card - Allium, SD card RetroArch files + Allium, SD card Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip + AmberELEC, SD card Anbernic RG351, RG552 + Anbernic stock firmware, SD card RetroArch files + ArchR, SD card R36S, R33S, R36S Soysauce, Odroid Go Advance, +8 + ArkOS, SD card RG353M, RG353V, RG503, RGB30, RK2023, RG351MP, RG351V, +2 + CrossMix-OS, SD card TrimUI Smart Pro + dArkOS, SD card Odroid Go Advance, RGB10, RG351MP, G350 + EmuELEC, SD card Odroid Go 1-19 of 34 A Choose B Back === folder 40x12 @@ -436,7 +436,7 @@ 4 files to download, 1.0 MB BIOS folder: ~/.co.../retroarch/system 1 in place, 3 missing, 1 wrong copy - Free space: 50.0 GB + 1 wrong copy: backed up, replaced. > Install Choose systems Copy from your old card or a folder @@ -449,15 +449,15 @@ 4 files to download, 1.0 MB BIOS folder: ~/.co.../retroarch/system 1 in place, 3 missing, 1 wrong copy + 1 wrong copy: backed up, replaced. Free space: 50.0 GB - The 1 wrong copy is replaced, the old - one kept in a backup folder. > Install Choose systems Copy from your old card or a folder Options Quit + A Install B Back Y Options === plan 80x24 RetroBIOS: RetroArch @@ -465,8 +465,8 @@ 4 files to download, 1.0 MB BIOS folder: ~/.config/retroarch/system 1 in place, 3 missing, 1 wrong copy - Free space: 50.0 GB The 1 wrong copy is replaced, the old one kept in a backup folder. + Free space: 50.0 GB > Install Choose systems diff --git a/tests/fixtures/tui/route-keys.txt b/tests/fixtures/tui/route-keys.txt index c47fe74ab..40d31955f 100644 --- a/tests/fixtures/tui/route-keys.txt +++ b/tests/fixtures/tui/route-keys.txt @@ -55,7 +55,7 @@ === platforms 40x12 RetroBIOS: Which setup? - Filter (/ to type) + Filter: setup or device (/) > RetroArch Batocera Recalbox @@ -68,7 +68,7 @@ === platforms 40x15 RetroBIOS: Which setup? - Filter (/ to type) + Filter: setup or device (/) > RetroArch Batocera Recalbox @@ -76,15 +76,15 @@ EmuDeck Lakka RetroDECK - ROCKNIX, SD card RomM BizHawk + MiSTer FPGA 1-10 of 34 Enter Choose Esc Back / Filter === platforms 80x24 RetroBIOS: Which setup? - Filter (/ to type) + Filter: setup or device (/) > RetroArch Batocera Recalbox @@ -92,18 +92,18 @@ EmuDeck Lakka RetroDECK - ROCKNIX, SD card RomM BizHawk MiSTer FPGA RetroPie - AmberELEC PAN4ELEC - EmuELEC - KNULLI, SD card - MinUI, SD card - NextUI, SD card - MyMinUI, SD card - Allium, SD card RetroArch files + Allium, SD card Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip + AmberELEC, SD card Anbernic RG351, RG552 + Anbernic stock firmware, SD card RetroArch files + ArchR, SD card R36S, R33S, R36S Soysauce, Odroid Go Advance, +8 + ArkOS, SD card RG353M, RG353V, RG503, RGB30, RK2023, RG351MP, RG351V, +2 + CrossMix-OS, SD card TrimUI Smart Pro + dArkOS, SD card Odroid Go Advance, RGB10, RG351MP, G350 + EmuELEC, SD card Odroid Go 1-19 of 34 Enter Choose Esc Back / Filter ? Help === folder 40x12 @@ -382,7 +382,7 @@ 4 files to download, 1.0 MB BIOS folder: ~/.co.../retroarch/system 1 in place, 3 missing, 1 wrong copy - Free space: 50.0 GB + 1 wrong copy: backed up, replaced. > Install Choose systems Copy from your old card or a folder @@ -395,15 +395,15 @@ 4 files to download, 1.0 MB BIOS folder: ~/.co.../retroarch/system 1 in place, 3 missing, 1 wrong copy + 1 wrong copy: backed up, replaced. Free space: 50.0 GB - The 1 wrong copy is replaced, the old - one kept in a backup folder. > Install Choose systems Copy from your old card or a folder Options Quit + Enter Install Esc Back o Options === plan 80x24 RetroBIOS: RetroArch @@ -411,8 +411,8 @@ 4 files to download, 1.0 MB BIOS folder: ~/.config/retroarch/system 1 in place, 3 missing, 1 wrong copy - Free space: 50.0 GB The 1 wrong copy is replaced, the old one kept in a backup folder. + Free space: 50.0 GB > Install Choose systems diff --git a/tests/fixtures/tui/route-miyoo.txt b/tests/fixtures/tui/route-miyoo.txt index 5c12696fa..8356becf5 100644 --- a/tests/fixtures/tui/route-miyoo.txt +++ b/tests/fixtures/tui/route-miyoo.txt @@ -76,9 +76,9 @@ EmuDeck Lakka RetroDECK - ROCKNIX, SD card RomM BizHawk + MiSTer FPGA 1-10 of 34 A Choose R2 Back === platforms 80x24 @@ -92,18 +92,18 @@ EmuDeck Lakka RetroDECK - ROCKNIX, SD card RomM BizHawk MiSTer FPGA RetroPie - AmberELEC PAN4ELEC - EmuELEC - KNULLI, SD card - MinUI, SD card - NextUI, SD card - MyMinUI, SD card - Allium, SD card RetroArch files + Allium, SD card Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip + AmberELEC, SD card Anbernic RG351, RG552 + Anbernic stock firmware, SD card RetroArch files + ArchR, SD card R36S, R33S, R36S Soysauce, Odroid Go Advance, +8 + ArkOS, SD card RG353M, RG353V, RG503, RGB30, RK2023, RG351MP, RG351V, +2 + CrossMix-OS, SD card TrimUI Smart Pro + dArkOS, SD card Odroid Go Advance, RGB10, RG351MP, G350 + EmuELEC, SD card Odroid Go 1-19 of 34 A Choose R2 Back === folder 40x12 @@ -436,7 +436,7 @@ 4 files to download, 1.0 MB BIOS folder: ~/.co.../retroarch/system 1 in place, 3 missing, 1 wrong copy - Free space: 50.0 GB + 1 wrong copy: backed up, replaced. > Install Choose systems Copy from your old card or a folder @@ -449,15 +449,15 @@ 4 files to download, 1.0 MB BIOS folder: ~/.co.../retroarch/system 1 in place, 3 missing, 1 wrong copy + 1 wrong copy: backed up, replaced. Free space: 50.0 GB - The 1 wrong copy is replaced, the old - one kept in a backup folder. > Install Choose systems Copy from your old card or a folder Options Quit + A Install R2 Back L2 Options === plan 80x24 RetroBIOS: RetroArch @@ -465,8 +465,8 @@ 4 files to download, 1.0 MB BIOS folder: ~/.config/retroarch/system 1 in place, 3 missing, 1 wrong copy - Free space: 50.0 GB The 1 wrong copy is replaced, the old one kept in a backup folder. + Free space: 50.0 GB > Install Choose systems diff --git a/tests/fixtures/tui/route-muxterm.txt b/tests/fixtures/tui/route-muxterm.txt index 8ac8657a4..c29516cdb 100644 --- a/tests/fixtures/tui/route-muxterm.txt +++ b/tests/fixtures/tui/route-muxterm.txt @@ -76,9 +76,9 @@ EmuDeck Lakka RetroDECK - ROCKNIX, SD card RomM BizHawk + MiSTer FPGA 1-10 of 34 Start Choose B Back === platforms 80x24 @@ -92,18 +92,18 @@ EmuDeck Lakka RetroDECK - ROCKNIX, SD card RomM BizHawk MiSTer FPGA RetroPie - AmberELEC PAN4ELEC - EmuELEC - KNULLI, SD card - MinUI, SD card - NextUI, SD card - MyMinUI, SD card - Allium, SD card RetroArch files + Allium, SD card Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip + AmberELEC, SD card Anbernic RG351, RG552 + Anbernic stock firmware, SD card RetroArch files + ArchR, SD card R36S, R33S, R36S Soysauce, Odroid Go Advance, +8 + ArkOS, SD card RG353M, RG353V, RG503, RGB30, RK2023, RG351MP, RG351V, +2 + CrossMix-OS, SD card TrimUI Smart Pro + dArkOS, SD card Odroid Go Advance, RGB10, RG351MP, G350 + EmuELEC, SD card Odroid Go 1-19 of 34 Start Choose B Back === folder 40x12 @@ -436,7 +436,7 @@ 4 files to download, 1.0 MB BIOS folder: ~/.co.../retroarch/system 1 in place, 3 missing, 1 wrong copy - Free space: 50.0 GB + 1 wrong copy: backed up, replaced. > Install Choose systems Copy from your old card or a folder @@ -449,15 +449,15 @@ 4 files to download, 1.0 MB BIOS folder: ~/.co.../retroarch/system 1 in place, 3 missing, 1 wrong copy + 1 wrong copy: backed up, replaced. Free space: 50.0 GB - The 1 wrong copy is replaced, the old - one kept in a backup folder. > Install Choose systems Copy from your old card or a folder Options Quit + Start Install B Back === plan 80x24 RetroBIOS: RetroArch @@ -465,8 +465,8 @@ 4 files to download, 1.0 MB BIOS folder: ~/.config/retroarch/system 1 in place, 3 missing, 1 wrong copy - Free space: 50.0 GB The 1 wrong copy is replaced, the old one kept in a backup folder. + Free space: 50.0 GB > Install Choose systems diff --git a/tests/test_install.py b/tests/test_install.py index c31218d2f..a51b6825f 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -4249,7 +4249,7 @@ class DestAloneFollowsWhatIsFoundHere(unittest.TestCase): plat, folder = install._platform_for_dest("linux", root, True) self.assertEqual((plat, folder), ("retroarch", root)) self.assertIn(f"{root} is not an Onion SD card", out.getvalue()) - self.assertIn("Onion, SD card (RetroArch files)", out.getvalue()) + self.assertIn("Onion, SD card (Miyoo Mini, Miyoo Mini Plus)", out.getvalue()) def test_nothing_found_is_retroarch_said_aloud(self): plat, out = self._pick([]) diff --git a/tests/test_install_offline.py b/tests/test_install_offline.py index 459e8218d..74dcff817 100644 --- a/tests/test_install_offline.py +++ b/tests/test_install_offline.py @@ -663,7 +663,7 @@ class OfflineMakesNoRequest(Scratch): self.assertEqual((self.dest / "scph5501.bin").read_bytes(), US) self.assertFalse((self.dest / "gba_bios.bin").exists()) self.assertIn("no network request", proc.stdout + proc.stderr) - self.assertIn("run without --offline", proc.stdout + proc.stderr) + self.assertIn("without --offline", proc.stdout + proc.stderr) self.assertNotIn("trying again", proc.stderr) def test_modes_that_need_the_network_refuse_it(self): @@ -843,5 +843,98 @@ class OfflineLeavesFilesMissing(Scratch): self.assertNotIn("no longer in the source", proc.stdout) +class OneOfflineVocabulary(Scratch): + """code_audit_14 P3-1 to P3-4, tui_audit_7 P3-4: --offline said one + thing at a terminal and another without questions, on the screen and + under --check.""" + + def old(self) -> Path: + old = self.root / "old" + old.mkdir() + (old / "scph5501.bin").write_bytes(US) + return old + + def test_a_run_without_questions_says_missing_not_failed(self): + """P3-2: "Finished with errors ... 3 files failed" and "Run the + same command again to retry only those".""" + proc = self.run_installer("--offline", "--manifest", str(self.list), + "--from", str(self.old()), "--no-input") + out = proc.stdout + proc.stderr + self.assertEqual(proc.returncode, 1, out) + self.assertIn("Finished with files missing. 1 file installed, 3 not fetched " + "(--offline).", out) + self.assertIn(f"They are not in {self.root / 'old'}", out) + for said in ("Finished with errors", "files failed", "retry", + "copy them from your old card"): + self.assertNotIn(said, out) + + def test_nothing_installed_is_said_so(self): + """P3-3: "Finished with files missing. 0 files installed".""" + failures = [("a.bin", install.NOT_WRITABLE), ("b.bin", install.NOT_WRITABLE)] + with contextlib.redirect_stdout(io.StringIO()) as out, \ + self.assertRaises(SystemExit) as stop: + install._report_outcome([self.dest], 0, 0, 2, 0, False, failures=failures, + offline=20) + self.assertEqual(stop.exception.code, 2) + self.assertIn("Nothing installed: 20 files not fetched (--offline), 2 failed.", + out.getvalue()) + self.assertNotIn("0 files installed", out.getvalue()) + + def test_the_command_suggested_keeps_the_offline_setting(self): + """P3-3: sudo does not pass RETROBIOS_INSTALL_OFFLINE on.""" + import test_install_tui as tui + + args = tui._args(platform="retroarch", declared_only=False, offline=False) + hint = install.Invocation() + with unittest.mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}): + hint.record(args) + self.assertIn("--offline", hint.command("retroarch", self.dest, elevated=True)) + + def test_check_offline_says_what_cannot_come(self): + """P3-4: "Run the same command without --check to fetch them".""" + proc = self.run_installer("--check", "--offline", "--manifest", str(self.list)) + out = proc.stdout + proc.stderr + self.assertEqual(proc.returncode, 1, out) + self.assertNotIn("to fetch them", out) + self.assertIn("4 files need the internet, which --offline does not use.", out) + self.assertIn("Copy them from your old card or a folder (--from)", out) + + def test_the_screen_says_missing_not_failed(self): + """P3-2 on the full screen: "Finished with errors, 20 failed".""" + import test_install_tui as tui + + reason = f"not fetched: {install.OFFLINE_WORDS}" + state = tui._installed_state(self.dest, 1, [{"dest": f"f{n}.bin", "reason": reason} + for n in range(20)]) + state.summary["installed"] = 2 + state.from_folders = [self.root / "from1"] + title, lines = install.tui_outcome(state) + self.assertEqual(title, "Finished with files missing") + text = " ".join(lines) + self.assertIn("2 installed, 20 not fetched (--offline).", text) + self.assertIn("They are not in", text) + self.assertNotIn("failed", text) + state.summary["installed"] = 0 + self.assertEqual(install.tui_outcome(state)[0], "Nothing installed") + + def test_the_line_menu_copies_from_a_folder(self): + """tui_audit_7 P3-4: no way to a folder, and an install of nothing + offered.""" + old = self.old() + install.LOCAL.reset() + self.addCleanup(install.LOCAL.reset) + answers = iter(["f", str(old), ""]) + with unittest.mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}), \ + unittest.mock.patch.object(install, "_ask", lambda _prompt: next(answers)), \ + contextlib.redirect_stdout(io.StringIO()) as out: + self.assertIsNone(install._prompt_custom_selection("retroarch", [], [])) + text = out.getvalue() + self.assertIn("Enter) check the BIOS folder, nothing can be downloaded", text) + self.assertIn("f) copy from a folder or an old card", text) + self.assertIn(f"Enter) install what is found in {old}", text) + self.assertEqual([index.label for index in install.LOCAL.folders], [str(old)]) + self.assertNotIn("install everything", text) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index cd337436a..a714fd8e5 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -594,7 +594,8 @@ class Flow(unittest.TestCase): self.assertEqual(install.tui_rows(state)[state.focus["platforms"]].value, "retroarch") press(state, "/", "b", "a", "t", "o") self.assertEqual([row.value for row in install.tui_rows(state)][1:], ["batocera"]) - press(state, "enter", "enter") + # tui_audit_7 P3-9.2: the one match is chosen with the first Enter. + press(state, "enter") self.assertEqual(state.screen, "folder") self.assertEqual(state.platform, "batocera") state.focus["folder"] = 2 @@ -2194,9 +2195,13 @@ class AuditRoundThree(unittest.TestCase): "MUOS at the root of its card.", proc.stderr) self.assertIn("--dest /MUOS/bios", proc.stderr) self.assertEqual(list(root.iterdir()), [root / "roms"]) - # Its BIOS folder named outright is taken as given. - self.assertEqual(install.card_dest("muos", "retroarch", root / "MUOS" / "bios"), - ("retroarch", root / "MUOS" / "bios")) + # Its BIOS folder named outright is its card's, checked as the card + # is: on a folder with no MUOS it is refused, as typed it was. + with self.assertRaises(SystemExit), contextlib.redirect_stderr(io.StringIO()): + install.card_dest("muos", "retroarch", root / "MUOS" / "bios") + card = self.card({"MUOS/bios/": ""}) + self.assertEqual(install.card_dest("muos", "retroarch", card / "MUOS" / "bios"), + ("retroarch", card / "MUOS" / "bios")) # On the device, the folder muOS binds it to. bound = Path("/run/muos/storage/bios") self.assertEqual(install.card_dest("muos", "retroarch", bound), ("retroarch", bound)) @@ -2212,9 +2217,10 @@ class AuditRoundThree(unittest.TestCase): with self.assertRaises(SystemExit), \ contextlib.redirect_stderr(io.StringIO()) as err: install.card_dest("muos", "retroarch", stray) - self.assertIn("is not a muOS SD card", err.getvalue()) - self.assertEqual(install.card_dest("muos", "retroarch", root / "muos" / "BIOS"), - ("retroarch", root / "muos" / "BIOS")) + self.assertIn("does not exist", err.getvalue()) + card = self.card({"MUOS/bios/": ""}) + self.assertEqual(install.card_dest("muos", "retroarch", card / "muos" / "BIOS"), + ("retroarch", card / "muos" / "BIOS")) def test_p1_1_a_card_two_systems_claim_says_so(self): root = self.card({"MUOS/": "", "spruce/": ""}) @@ -3042,8 +3048,9 @@ class ACardSystemNamedOnAComputer(unittest.TestCase): # without a word. install._browse_to(state, muos) press(state, "enter") - self.assertEqual(state.status, "That is a muOS card, not an Onion card. Choose " - "the card's top folder.") + # tui_audit_7 P3-1: it is a card's top folder, of another card. + self.assertEqual(state.status, "That is a muOS card. Insert the Onion card, or " + "go back and choose muOS.") self.assertIsNone(state.dest) def test_a_typed_card_root_is_checked_on_the_path_screen(self): @@ -3614,7 +3621,9 @@ class LineModeOffline(unittest.TestCase): out = term.raw.decode("utf-8", "replace") self.assertIn("--offline: only the files found on this machine are installed, and no " "--from names a folder to copy them from.", out) - self.assertIn("Enter) install what is found on this machine", out) + # tui_audit_7 P3-4: nothing to copy from, nothing to install. + self.assertIn("Enter) check the BIOS folder, nothing can be downloaded", out) + self.assertIn("f) copy from a folder or an old card", out) self.assertIn("5 files need the internet, which --offline does not use.", out) self.assertNotIn("Downloading", out) self.assertNotIn("failed", out.lower()) @@ -3945,10 +3954,9 @@ class AGoesIntoASubfolder(unittest.TestCase): with mock.patch.object(install, "_mounted_cards", return_value=[self.card]): install._browse_to(self.state, self.home) self.assertEqual(self.focused().action, "use") - self.assertIn("This is your home folder", self.state.status) + self.assertIn("No setup reads BIOS files in your home folder", self.state.status) screen = " ".join(" ".join(install.tui_view(self.state, 80, 24)).split()) - self.assertIn("This is your home folder: no setup reads its BIOS files here.", - screen) + self.assertIn("No setup reads BIOS files in your home folder.", screen) def test_on_windows_the_other_drives_are_passed_over(self): """The card rows are drive roots there (E:\\): at D:\\ the first @@ -5222,8 +5230,9 @@ class EveryCardSetupGoesToItsCard(unittest.TestCase): for index, setup in enumerate(self.setups): with self.subTest(system=setup.name): card = _card_from_rules(self.root / f"card{index}", setup) - want = (_setup_folder(setup, card) if setup.known() or not setup.below - else card) + # code_audit_14 P2-1: a card no file names gets its folder + # below the root with --dest too, as typed. + want = _setup_folder(setup, card) with contextlib.redirect_stdout(io.StringIO()): by_dest = install.card_dest(setup.name, setup.plat, card) self.assertEqual(by_dest, (setup.plat, want)) @@ -5244,7 +5253,7 @@ class EveryCardSetupGoesToItsCard(unittest.TestCase): self.assertEqual(state.dest, want) # Its card chosen as a card's root: its BIOS folder below it. if setup.known(): - choice = install.card_root_folder(setup.name, setup.plat, card) + choice = install.card_choice(setup.name, setup.plat, card) self.assertEqual((choice.refused, choice.folder), ("", _setup_folder(setup, card))) @@ -5261,7 +5270,7 @@ class EveryCardSetupGoesToItsCard(unittest.TestCase): and setup.plat in install.CARD_PLATFORMS): continue with self.subTest(system=setup.name, card=name): - choice = install.card_root_folder(setup.name, setup.plat, card) + choice = install.card_choice(setup.name, setup.plat, card) self.assertIn(f"is {install._a(name)} card, not " f"{install._a(setup.name)} card", choice.refused) self.assertTrue(install.card_choice(setup.name, setup.plat, @@ -5276,7 +5285,7 @@ class EveryCardSetupGoesToItsCard(unittest.TestCase): blank.mkdir() for setup in self.setups: with self.subTest(system=setup.name): - choice = install.card_root_folder(setup.name, setup.plat, blank) + choice = install.card_choice(setup.name, setup.plat, blank) if setup.known() and not setup.fresh: self.assertIn(f"is not {install._a(setup.name)} SD card", choice.refused) else: @@ -5462,7 +5471,7 @@ class AuditSix(unittest.TestCase): rows = install.tui_rows(state) self.assertEqual(state.screen, "browse") self.assertEqual(rows[state.focus["browse"]].value, card) - self.assertEqual(state.status, "Choose the card below, or open the folder that holds it.") + self.assertEqual(state.status, "Choose the Onion card below.") def test_a_number_outside_the_list_is_said(self): """tui_audit_6 P3-1: 9 was read as a folder.""" @@ -5551,5 +5560,488 @@ class AuditSix(unittest.TestCase): self.assertIn(" +2 shared: counted once", install.tui_view(state, 40, 12)) +class AuditSevenOneCardReading(unittest.TestCase): + """code_audit_14 P2-1 to P3-12 and tui_audit_7 P2-5, P3-1: one reading + of a card's root, said the same on the screen and in line mode.""" + + def setUp(self): + self.root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, self.root, True) + patcher = mock.patch.dict(install._DETECTED_OS, {}) + patcher.start() + self.addCleanup(patcher.stop) + + def blank(self, name: str = "USBSTICK") -> Path: + stick = self.root / name + (stick / "photos").mkdir(parents=True) + return stick + + def start(self, **given) -> install.TuiState: + with mock.patch.object(install, "_mounted_cards", return_value=[]), \ + mock.patch.object(install, "detect_platforms", return_value=[]), \ + mock.patch.object(install, "detect_frontends", return_value=[]): + state, _effects = install.tui_start(_args(**given), "linux") + return state + + def test_a_blank_card_gets_one_folder_and_one_note_everywhere(self): + """code_audit_14 P2-1, tui_audit_7 P2-5: --dest kept the stick's + root, typed it got bios below it, and the screen said nothing.""" + stick = self.blank() + for named, plat, below in (("knulli", "knulli", "bios"), + ("rocknix", "rocknix", "roms/bios"), + ("ArkOS", "retroarch", "bios")): + with self.subTest(system=named): + with contextlib.redirect_stdout(io.StringIO()) as out: + by_dest = install.card_dest(named, plat, stick) + self.assertEqual(by_dest, (plat, stick / below)) + note = install.card_choice(named, plat, stick).note + self.assertTrue(note) + self.assertIn(note, out.getvalue()) + state = self.start(platform=named, dest=str(stick)) + self.assertEqual(state.dest, stick / below) + self.assertEqual(state.card_note, note) + # Kept on Create the folder? and on the plan. + self.assertEqual(state.screen, "create") + self.assertIn(note.split(":")[0], + " ".join(" ".join(install.tui_view(state, 120, 40)).split())) + install.tui_update(state, Key("enter")) + self.assertEqual(state.card_note, note) + + def test_the_minui_family_refuses_a_blank_dest(self): + """code_audit_14 P2-2: taken with --dest, refused typed.""" + stick = self.blank() + for plat in ("minui", "nextui", "myminui"): + with self.subTest(platform=plat): + choice = install.card_choice(plat, plat, stick) + self.assertIn("SD card", choice.refused) + with self.assertRaises(SystemExit), \ + contextlib.redirect_stderr(io.StringIO()): + install.card_dest(plat, plat, stick) + + def test_a_card_mounted_as_its_bios_folder_is_the_card(self): + """code_audit_14 P3-7: an Onion card mounted as BIOS was refused, + ROCKNIX's roms/bios was taken for KNULLI, a muOS card's folder + too.""" + onion = self.root / "BIOS" + make_card(onion, "Onion") + choice = install.card_choice("Onion", "retroarch", onion) + self.assertEqual((choice.refused, choice.folder), ("", onion / "BIOS")) + knulli = self.root / "k" / "bios" + make_card(knulli, "KNULLI") + self.assertEqual(install.card_choice("knulli", "knulli", knulli).folder, + knulli / "bios") + rocknix = self.root / "ROCKNIX" + make_card(rocknix, "ROCKNIX") + refused = install.card_choice("knulli", "knulli", rocknix / "roms" / "bios") + self.assertIn("is a ROCKNIX card, not a KNULLI card", refused.refused) + muos = self.root / "MUOS" + make_card(muos, "muOS") + refused = install.card_choice("knulli", "knulli", muos / "MUOS" / "bios") + self.assertIn("is a muOS card", refused.refused) + + def test_no_folder_above_a_mounted_drive_is_its_card(self): + """A card mounted as MUOS beside a stick: /media/me, which holds + it, is no muOS card for the stick.""" + stick = self.blank() + (self.root / "MUOS" / "bios").mkdir(parents=True) + with mock.patch.object(install, "_mounted_cards", + return_value=[stick, self.root / "MUOS"]): + choice = install.card_choice("knulli", "knulli", stick) + self.assertEqual((choice.refused, choice.folder), ("", stick / "bios")) + bios = self.root / "mnt" / "bios" + (bios / "x").mkdir(parents=True) + with mock.patch.object(install, "_mounted_cards", return_value=[bios]): + # A drive mounted at a folder named bios is the card itself. + self.assertEqual(install._card_and_below( + install.card_setup("knulli", "knulli"), bios), (bios, False)) + + def test_a_refused_dest_says_why_in_the_events(self): + """code_audit_14 P3-8: code 1 and no reason in the stream.""" + stick = self.blank() + proc = install_run("--events", "--platform", "onion", "--dest", str(stick), + "--manifest", str(REPO_ROOT / "install" / "retroarch.json"), + "--dry-run", env={install.OFFLINE_VARIABLE: "1"}) + self.assertEqual(proc.returncode, 1, proc.stderr) + events = [json.loads(line) for line in proc.stdout.splitlines()] + error = next(event for event in events if event["event"] == "error") + self.assertEqual((error["stage"], error["reason"]), ("detect", "not_found")) + self.assertIn("is not an Onion SD card", error["message"]) + self.assertEqual(events[-1], {"event": "end", "code": 1}) + table = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8") + self.assertIn("not the card of the system named", table) + + def test_a_card_no_file_names_is_titled_by_its_system(self): + """code_audit_14 P3-9: an ArkOS card chosen read as RetroArch.""" + stick = self.blank() + state = self.start(platform="ArkOS", dest=str(stick)) + self.assertEqual(install.detected_os("retroarch", state.dest), "ArkOS") + + def test_a_list_named_for_another_systems_card_says_so(self): + """code_audit_14 P3-10: --platform batocera on an Onion card.""" + onion = self.root / "ONION" + make_card(onion, "Onion") + choice = install.card_choice("batocera", "batocera", onion) + self.assertEqual((choice.refused, choice.folder), ("", onion)) + self.assertIn("is an Onion card", choice.note) + self.assertIn("--platform onion", choice.note) + state = self.start(platform="batocera", dest=str(onion)) + self.assertEqual(state.card_note, choice.note) + + def test_amberelec_and_emuelec_are_asked_for_their_card(self): + """code_audit_14 P3-11: "Use this folder" on /storage/roms of a PC.""" + for plat, name in (("amberelec", "AmberELEC"), ("emuelec", "EmuELEC")): + with self.subTest(system=name): + setup = install.card_setup(plat, plat) + self.assertEqual((setup.name, setup.below), (name, "")) + state = self.start(platform=plat) + self.assertEqual(state.screen, "folder") + self.assertIsNone(state.folder_default) + self.assertIn("Where is the SD card?", + " ".join(install.tui_view(state, 80, 24))) + stick = self.blank(f"{name}STICK") + choice = install.card_choice(plat, plat, stick) + self.assertEqual(choice.folder, stick) + self.assertIn("cannot tell", choice.note) + + def test_install_py_has_two_blank_lines_around_each_definition(self): + """code_audit_14 P3-12: E305 after _card_mount, and the same slip + before _Interrupted, TuiRow and _setup_label (pycodestyle E302, + E303, E305 at top level).""" + import ast + + lines = (REPO_ROOT / "install.py").read_text(encoding="utf-8").splitlines() + body = ast.parse("\n".join(lines)).body + + def blank_before(node: ast.stmt) -> int: + # The longest run of blank lines between the code above and the + # statement, its decorators and comments in between, as + # pycodestyle counts them. + index = min([node.lineno] + [item.lineno for item in + getattr(node, "decorator_list", [])]) - 2 + runs, run = [], 0 + while index >= 0 and (not lines[index].strip() or lines[index].startswith("#")): + if lines[index].strip(): + runs.append(run) + run = 0 + else: + run += 1 + index -= 1 + return max(runs + [run]) + + definitions = (ast.FunctionDef, ast.ClassDef, ast.AsyncFunctionDef) + for before, after in zip(body, body[1:]): + count = blank_before(after) + with self.subTest(line=after.lineno): + if isinstance(after, definitions): + self.assertEqual(count, 2) + elif isinstance(before, definitions): + self.assertEqual(count, 2) + + def test_a_refusal_is_said_once_and_another_card_names_its_remedy(self): + """tui_audit_7 P3-1: said twice, and "Choose the card's top folder" + for the top folder of another card.""" + stick = self.blank() + state = self.start(platform="onion") + state.pad = "keys" + install._card_status(state, install.card_choice("Onion", "retroarch", stick)) + after = install._status_after(state) + self.assertEqual(len(after), 1) + self.assertTrue(after[0].endswith("? says more.")) + state.help = True + self.assertIn("Onion keeps .tmp_update", + " ".join(" ".join(install.tui_view(state, 80, 24)).split())) + state.help = False + muos = self.root / "MUOS" + make_card(muos, "muOS") + install._card_status(state, install.card_choice("Onion", "retroarch", muos)) + self.assertEqual(state.status, "That is a muOS card. Insert the Onion card, or go " + "back and choose muOS.") + with mock.patch.object(install, "_ask", side_effect=[str(muos), str(stick), + KeyboardInterrupt]), \ + mock.patch.object(install, "_mounted_cards", return_value=[]), \ + contextlib.redirect_stdout(io.StringIO()) as out, \ + self.assertRaises(KeyboardInterrupt): + install.card_install_dest("Onion", "retroarch", True) + self.assertIn("Insert the Onion card, or choose muOS for that one.", out.getvalue()) + + def test_line_mode_names_the_card_picked_and_its_folder(self): + """tui_audit_7 P2-3, P3-5: no echo of the card picked, "Mount the + card" under two mounted, /mnt/d where the list says D:.""" + cards = [self.root / "ONION", self.root / "ONION2"] + for card in cards: + make_card(card, "Onion") + with mock.patch.object(install, "_ask", return_value="2"), \ + mock.patch.object(install, "_mounted_cards", return_value=cards), \ + mock.patch.object(install, "_missing_dest", return_value=True), \ + contextlib.redirect_stdout(io.StringIO()) as out, \ + contextlib.redirect_stderr(io.StringIO()): + dest = install.card_install_dest("Onion", "retroarch", True) + self.assertEqual(dest, cards[1] / "BIOS") + text = out.getvalue() + self.assertIn("Type its number or its top folder:", text) + self.assertNotIn("Mount the card", text) + self.assertIn(f"ONION2: Onion files go to {cards[1] / 'BIOS'}", text) + self.assertEqual(install.detected_os("retroarch", dest), "Onion") + with mock.patch.object(install, "_ask", return_value=""), \ + contextlib.redirect_stdout(io.StringIO()) as out: + install._prompt_custom_selection("retroarch", [], [], name="Onion", + bios_path=dest) + self.assertIn("Onion: 0 files", out.getvalue()) + self.assertIn(f"BIOS folder: {dest}", out.getvalue()) + proc = install_run("--platform", "onion", "--dest", str(cards[0]), "--dry-run", + "--manifest", str(REPO_ROOT / "install" / "retroarch.json"), + env={install.OFFLINE_VARIABLE: "1"}) + self.assertNotIn("gets the RetroArch file list", proc.stdout) + + def test_wsl_names_the_folder_by_its_drive_letter(self): + """tui_audit_7 P3-6, code_audit_14 P3-5.""" + with mock.patch.dict(install._WSL, {"active": True, "root": "/mnt"}): + self.assertEqual(install._short_path(Path("/mnt/d/BIOS"), HOME), "D:\\BIOS") + self.assertEqual(install.shown_path(Path("/mnt/d")), "D:\\") + args = _args(dest=Path("D:\\"), save_dir=Path("F:\\saves")) + setattr(args, "from", [Path("E:\\")]) + args.from_pack = [Path("E:\\pack.zip")] + install._wsl_dest(args) + self.assertEqual((args.dest, getattr(args, "from"), args.from_pack, args.save_dir), + (Path("/mnt/d"), [Path("/mnt/e")], [Path("/mnt/e/pack.zip")], + Path("/mnt/f/saves"))) + with mock.patch.dict(install._WSL, {"active": False}): + self.assertEqual(install._short_path(Path("/mnt/d/BIOS"), HOME), "/mnt/d/BIOS") + + def test_a_multi_word_system_example_can_be_pasted(self): + """code_audit_14 P3-6.""" + with mock.patch.object(install, "_mounted_cards", return_value=[]), \ + contextlib.redirect_stderr(io.StringIO()) as err, \ + self.assertRaises(SystemExit): + install.card_install_dest("Anbernic stock firmware", "retroarch", False) + self.assertIn("--platform anbernicstockfirmware --dest", err.getvalue()) + + +class AuditSevenScreens(unittest.TestCase): + """tui_audit_7 P2-1 to P3-9: what each screen offers and says.""" + + def setUp(self): + self.root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, self.root, True) + + def setups(self, pad: str = "keys") -> install.TuiState: + state = install.TuiState(os_type="linux", home=HOME) + state.pad = pad + state.stack = ["platforms"] + state.focus["platforms"] = 1 + return state + + def test_a_handheld_is_found_by_its_device_in_few_presses(self): + """P2-1: Onion was row 30 of 34, its row said "RetroArch files".""" + state = self.setups("gptokeyb") + rows = install.tui_rows(state) + labels = [row.label for row in rows] + self.assertEqual(labels[1], "RetroArch") + cards = [index for index, row in enumerate(rows) if row.label.endswith(", SD card")] + # One block, right after RetroArch. + self.assertEqual(cards, list(range(2, 2 + len(cards)))) + onion = labels.index("Onion, SD card") + self.assertIn("Miyoo Mini", rows[onion].right) + # At 40x12 a page is 6 rows: L1/R1 then Down, then A. + page = 12 - 6 + presses = (onion - 1) // page + (onion - 1) % page + 1 + self.assertLessEqual(presses, 4) + text = " ".join(install.tui_view(state, 40, 12)) + self.assertIn("handhelds come after RetroArch", " ".join(text.split())) + state.pad = "keys" + text = " ".join(" ".join(install.tui_view(state, 80, 24)).split()) + self.assertIn("type your device's name (/)", text) + self.assertIn("Filter by setup or device name", + [what for _key, what in install._help_lines(state)]) + # Something found here: the list keeps its order. + found = fresh_state() + found.stack = ["setup", "platforms"] + self.assertEqual([row.label for row in install.tui_rows(found)][2], "Batocera") + + def test_line_mode_takes_a_device_name(self): + """P2-1: "miyoo" refused at the line menu.""" + with mock.patch.object(install, "_ask", side_effect=["bittboy"]), \ + contextlib.redirect_stdout(io.StringIO()) as out: + self.assertEqual(install._choose_platform("Which?"), "MiyooCFW") + self.assertIn("your device's name", out.getvalue()) + choices = install._line_setups() + onion = [name for name, _label in choices].index("Onion") + 1 + with mock.patch.object(install, "_ask", side_effect=["miyoo mini+", str(onion)]), \ + contextlib.redirect_stdout(io.StringIO()) as out: + self.assertEqual(install._choose_platform("Which?"), "Onion") + self.assertIn("setups run on 'miyoo mini+'", out.getvalue()) + self.assertIn(f"{onion}) Onion, SD card (Miyoo Mini, Miyoo Mini Plus)", out.getvalue()) + + def test_one_spelling_per_device_and_one_press_for_one_match(self): + """P3-9.2, P3-9.3, P3-9.4.""" + for name, devices in install._SETUP_DEVICES.items(): + with self.subTest(system=name): + self.assertFalse(any("+" in device for device in devices)) + self.assertFalse(any(device.startswith("Anbernic RG35XX") + for device in install._SETUP_DEVICES["spruceOS"])) + state = self.setups() + state.filters["platforms"] = "mini+" + self.assertIn("Onion, SD card", [row.label for row in install.tui_rows(state)]) + state.filters["platforms"] = "koriki" + state.focus["platforms"] = 0 + self.assertEqual(install._primary(state), "A/Enter Choose") + with mock.patch.object(install, "_mounted_cards", return_value=[]): + press(state, "enter") + self.assertEqual(state.screen, "folder") + self.assertEqual(state.card_bound[0], "Koriki") + rows = {row.value: row for row in install.tui_rows(self.setups())} + self.assertNotIn("PAN4ELEC", rows["amberelec"].right) + + def test_help_rows_keep_their_column_at_40(self): + """P3-9.1: "/ Filter (Esc clears)" ran back to the left edge.""" + for pad in ("gptokeyb", "gptokeyb_muxterm", "miyoo", "evmapy"): + with self.subTest(pad=pad): + state = self.setups(pad) + state.filters["platforms"] = "x" + state.help = True + lines = [line[1:] for line in install.tui_view(state, 40, 12)[2:-1] + if line.strip()] + column = lines[0].index("Move") + for line in lines: + self.assertTrue(line[:column].strip() == "" or line[column - 2:column] + == " ", line) + + def test_copy_from_a_folder_with_none_says_so(self): + """P2-2: the plan came back the same.""" + state = checked_state() + old = self.root / "old" + old.mkdir() + state.from_folders = [old] + text = " ".join(" ".join(install.tui_view(state, 80, 24)).split()) + self.assertIn("old holds none of these files.", text) + state.found = [found("gba_bios.bin", str(old))] + text = " ".join(" ".join(install.tui_view(state, 80, 24)).split()) + self.assertNotIn("holds none", text) + stick = self.root / "STICK" + stick.mkdir() + state.home = self.root + with mock.patch.object(install, "_mounted_cards", return_value=[stick]), \ + mock.patch.object(install, "_listed", return_value=[]): + install._open_from_browser(state) + rows = install.tui_rows(state) + self.assertEqual(rows[state.focus["browse"]].value, stick) + + def test_offline_plan_names_the_folder_already_copied_from(self): + """code_audit_14 P3-1.""" + state = checked_state() + state.from_folders = [self.root / "from1"] + state.found = [found("gba_bios.bin", str(self.root / "from1"))] + with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}): + text = " ".join(" ".join(install.tui_view(state, 100, 40)).split()) + self.assertIn("They are not in from1", text) + self.assertNotIn("Copy them from your old card", text) + + def test_the_card_browser_focuses_the_card_asked_for(self): + """P3-2: the muOS card, listed first, was focused for Onion.""" + muos, onion, second = self.root / "A_MUOS", self.root / "B_ONION", self.root / "C" + make_card(muos, "muOS") + make_card(onion, "Onion") + make_card(second, "Onion") + with mock.patch.object(install, "_mounted_cards", return_value=[]), \ + mock.patch.object(install, "detect_platforms", return_value=[]), \ + mock.patch.object(install, "detect_frontends", return_value=[]): + state, _effects = install.tui_start(_args(platform="onion"), "linux") + state.home = self.root + state.focus["folder"] = [row.action for row in install.tui_rows(state)].index( + "browse") + with mock.patch.object(install, "_mounted_cards", + return_value=[muos, onion, second]): + press(state, "enter") + self.assertEqual(install.tui_rows(state)[state.focus["browse"]].value, onion) + self.assertEqual(state.status, "Choose the Onion card below.") + for line in install.tui_view(state, 40, 12): + self.assertNotIn("Go back", line) + + def test_confirm_offers_no_install_while_it_counts(self): + """P3-3.""" + state = checked_state() + state.stack.append("confirm") + state.running, state.dry = True, None + self.assertEqual(install.tui_view(state, 80, 24)[-1].split(), ["B/Esc", "Back", "?", + "Help"]) + self.assertNotIn("Install", [what for _key, what in install._help_lines(state)]) + + def test_back_after_choosing_systems_lands_on_install(self): + """P3-8.""" + state = checked_state() + state.focus["plan"] = [row.action for row in install.tui_rows(state)].index("systems") + press(state, "enter") + self.assertEqual(state.screen, "systems") + state.focus["systems"] = next(index for index, row in + enumerate(install.tui_rows(state)) + if row.action == "system") + press(state, " ", "esc") + self.assertEqual(state.screen, "plan") + rows = install.tui_rows(state) + self.assertEqual(rows[state.focus["plan"]].action, "install") + + def test_a_lone_card_opens_the_first_screen_with_a_list(self): + """P3-7: with --manifest, the card's folder was the first screen.""" + onion = self.root / "ONIONB" + make_card(onion, "Onion") + shutil.rmtree(onion / "BIOS") + args = _args(platform="retroarch", manifest=str(REPO_ROOT / "install" / + "retroarch.json")) + args.platform_from_list = True + with mock.patch.object(install, "_mounted_cards", return_value=[onion]), \ + mock.patch.object(install, "detect_platforms", return_value=[]), \ + mock.patch.object(install, "detect_frontends", return_value=[]), \ + mock.patch.dict(install._DETECTED_OS, {}): + state, effects = install.tui_start(args, "linux") + self.assertEqual((state.screen, effects), ("setup", [])) + + def test_a_file_left_for_its_save_directory_is_said_so(self): + """P2-4: the menu counted 23, 22 were checked, and the end blamed + the collection for the file whose save directory is unknown.""" + menus = [] + + def menu(_plat, files, *_rest, **_named): + menus.append([entry["dest"] for entry in files]) + return None + + with SaveDirectoryRepository() as repo: + argv = ["install.py", "--platform", "retroarch", "--dest", str(repo.dest), + "--manifest", str(repo.root / "install" / "retroarch.json")] + with mock.patch.dict(os.environ, repo.env()), \ + mock.patch.object(sys, "argv", argv), \ + mock.patch.object(sys.stdin, "isatty", return_value=True), \ + mock.patch.object(install, "_is_terminal", return_value=True), \ + mock.patch.object(install, "detect_os", return_value="linux"), \ + mock.patch.object(install, "records_selection", return_value=True), \ + mock.patch.object(install, "_prompt_custom_selection", menu), \ + mock.patch.object(install, "_ask", return_value="y"), \ + contextlib.redirect_stdout(io.StringIO()) as out, \ + contextlib.redirect_stderr(io.StringIO()): + try: + install.main() + except SystemExit: + pass + finally: + install.LOCAL.reset() + self.assertEqual(len(menus), 1) + self.assertNotIn("Azahar/Azahar/sysdata/keys.txt", menus[0]) + text = out.getvalue() + self.assertIn("1 file read from RetroArch's save directory left out: its place is " + "not known. Name it with --save-dir next time.", text) + self.assertNotIn("the collection does not hold", text) + + def test_plural_game_data_and_the_backup_line_at_40x12(self): + """P3-9.5, P3-9.6.""" + state = checked_state() + games = [{"dest": f"g{index}.dat", "system": "", "category": install.GAME_DATA} + for index in range(2)] + with mock.patch.object(install, "_unavailable", return_value=(games, 0)): + self.assertIn("Copy them from", " ".join(install._unavailable_lines(state))) + with mock.patch.object(install, "_unavailable", return_value=(games[:1], 0)): + self.assertIn("Copy it from", " ".join(install._unavailable_lines(state))) + view = " ".join(" ".join(install.tui_view(state, 40, 12)).split()) + self.assertIn("1 wrong copy: backed up, replaced.", view) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_minui_platform.py b/tests/test_minui_platform.py index baa345125..c75d47b1e 100644 --- a/tests/test_minui_platform.py +++ b/tests/test_minui_platform.py @@ -11,7 +11,9 @@ profiles, placed below that folder. from __future__ import annotations +import contextlib import hashlib +import io import json import os import shutil @@ -406,9 +408,11 @@ class InstallerReadsTheCard(unittest.TestCase): (self.root / ".system" / "version.txt").write_text("MinUI-20251127-0\n") (self.root / "Bios").mkdir() - with self.assertRaises(SystemExit): - install._check_card_root("minui", self.root / "Bios") - install._check_card_root("minui", self.root) + with self.assertRaises(SystemExit), contextlib.redirect_stderr(io.StringIO()): + install.card_dest("minui", "minui", self.root / "Bios") + self.assertEqual(install.card_dest("minui", "minui", self.root), + ("minui", self.root)) + class TheSitePageOfACardPlatform(unittest.TestCase): """A platform that names no file showed "0/0 Coverage (0%)" and an empty From 6afa9b97d14726566e05a81b470a9881a4379f5e Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:10:48 +0100 Subject: [PATCH 3/3] docs: card setups, devices and offline endings installer.md: one card reading, AmberELEC and EmuELEC cards, device names in the setup list and the line menu, the copy browser on the first mounted card, offline endings, the refused --dest in the exit table. first-install.md: a handheld found by its name, Finished with files missing. --- wiki/first-install.md | 4 +-- wiki/installer.md | 84 +++++++++++++++++++++++++++---------------- 2 files changed, 55 insertions(+), 33 deletions(-) diff --git a/wiki/first-install.md b/wiki/first-install.md index 162762c90..ab6df09c7 100644 --- a/wiki/first-install.md +++ b/wiki/first-install.md @@ -81,8 +81,7 @@ It opens a menu: - **Right folder?** Press Enter. - **Wrong folder?** Choose **Another setup or folder**, then browse to yours. -- **A handheld's SD card?** Under **Another setup or folder**, choose its - system: **Onion, SD card**. +- **A handheld's SD card?** Type its name (Miyoo Mini). Arrows move, Enter chooses, Esc goes back, as A and B on a handheld. @@ -103,6 +102,7 @@ Esc or Ctrl+C stops, B on a handheld. Run again to resume. - **Done**: all files are in place. - **Finished with errors**: run the command 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`. diff --git a/wiki/installer.md b/wiki/installer.md index 9776bdef8..3f93d5b23 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -120,10 +120,16 @@ they do online, and a file of the list the pack does not hold fails as `--offline` forbids every request, for a device with no Wi-Fi: `--check` still names the platform and its folder, then stops at the file list unless -`--manifest` or `--from-pack` gives it. At a terminal the run installs the -copies found on this machine and downloads nothing. The files left out are -missing, not declined: it ends `Finished with files missing` (code 1), or -`Nothing installed` (code 2) when none was found, as the full screen does. +`--manifest` or `--from-pack` gives it, and counts the files that need the +internet instead of offering to fetch them. The run installs the copies found +on this machine and downloads nothing. The files left out are missing, not +failed: it ends `Finished with files missing` (code 1), or `Nothing installed` +(code 2) when nothing was installed, at a terminal, without questions and on +the full screen alike. The advice that follows names a pack or a run without +`--offline`, and no `--from` folder already read. A command it suggests keeps +`--offline` when `RETROBIOS_INSTALL_OFFLINE` set it, sudo not passing that on. +The `--plain` menu then offers `f) copy from a folder or an old card`, and its +Enter checks the BIOS folder when nothing can be copied. ## The full-screen interface { #the-full-screen-interface } @@ -144,9 +150,10 @@ downloads, the folder and, for a list with files read from the frontend's save directory, that directory. Each row names the option it sets, and **Show the command line** gives the same choice as one command for a script. **Preview the file list** shows what would be downloaded and writes nothing. **Copy the BIOS -files from your old card or a folder** opens the folder browser, mounted cards -first, and adds [`--from`](#copy-from-an-old-card): the plan then says how many -files that folder holds and how many are left to download. After an install +files from your old card or a folder** opens the folder browser on the first +mounted card, and adds [`--from`](#copy-from-an-old-card): the plan then says how +many files that folder holds and how many are left to download, or that it +holds none of them. After an install on a muOS, KNULLI or ROCKNIX card, the result offers **Put RetroBIOS on this card (update from the console later)**, and **Options** has the same row ([On a handheld](#on-a-handheld-the-retrobios-port)). Files the BIOS @@ -165,7 +172,7 @@ install starts. | Tick, untick | Space, Right, Left | X, D-pad Right and Left | | Back | Esc, Backspace | B, Select | | Options | `o`, Tab | Y | -| Filter a long list | `/` then type | the Filter row | +| Filter a long list | `/` then type, Enter on a lone match chooses it | none: a pad cannot type (the setup list puts the SD cards after RetroArch) | | Keys of the screen | `?`, F1 | | | Stop an install | Esc, `q`, Ctrl+C | B, Start+Select | | Quit | `q`, Ctrl+C | B on the first screen | @@ -838,8 +845,11 @@ gives for the device, below the card's root instead of its mount point: The full screen lists such a card on its first screen ("muOS SD card (E:\)"). 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 -files is refused where the card always carries them, and is taken as named -for KNULLI, ROCKNIX and the systems no file on the card names. +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 +(`--platform batocera --dest` an Onion card), another system's card takes the +files as named, and a note names the system and its folder. Desktop installs are read from their own configuration, and several can be found on one machine. @@ -910,8 +920,7 @@ When a platform named with `--platform` is not found on the machine, the destination falls back to the directory its detection looks for: `/userdata/bios` for Batocera, `/recalbox/share/bios` for Recalbox, `/storage/system` for Lakka, `~/retrodeck` for RetroDECK, `~/Emulation/bios` -for EmuDeck (`C:\Emulation\bios` on Windows), `/storage/roms` for AmberELEC and -EmuELEC, `/media/fat/games` for MiSTer, +for EmuDeck (`C:\Emulation\bios` on Windows), `/media/fat/games` for MiSTer, `~/RetroPie/BIOS` for RetroPie, `/romm/library/bios` for RomM, `C:\RetroBat\bios` for RetroBat, and RetroArch's own default system directory for RetroArch. BizHawk has no such folder: it reads `Firmware` @@ -919,29 +928,42 @@ beside its executable wherever the archive was extracted, so a forced `--platform bizhawk` that finds no install stops and asks for `--dest`. The systems that run from an SD card keep their files there: MinUI, NextUI, -MyMinUI, KNULLI, ROCKNIX, and each system of the +MyMinUI, KNULLI, ROCKNIX, AmberELEC and EmuELEC (the card's games part, which +they mount as `/storage/roms`), and each system of the [Linux handhelds](../platforms/linux-handhelds.md) that gets RetroArch's list (Onion, muOS, spruceOS, ArkOS, UnofficialOS and the others). Run on the device, the installer finds its folder. From a computer, each has a row of its own in the setup list (**Onion, SD card**), without the filter, and in the -`--plain` menu. That row, `--platform onion` and the menu all ask for the -card: one card of that system mounted is taken, otherwise the screen opens -**Where is the SD card?**, which lists only that system's cards and offers -**Look again**, and `--plain` asks `Card root`, listing the same cards by -number. The RetroArch row is this computer's RetroArch. +`--plain` menu, beside the devices that run it (Miyoo Mini, Miyoo Mini Plus). +The filter takes a device name, and so does the `--plain` menu, which lists +the setups it matches by number. With nothing found here, the SD card rows +come right after RetroArch: Onion is four presses away with L1 and R1 at +40x12, where it was row 30 of 34. That row, `--platform onion` and the menu +all ask for the card: one card of that system mounted is taken, otherwise +the screen opens **Where is the SD card?**, which lists only that system's +cards and offers **Look again**, and `--plain` asks `Card root`, listing the +same cards by number and naming the one picked and its folder. The RetroArch +row is this computer's RetroArch. -The card's top folder is checked the same way wherever it is chosen, listed, -browsed, typed or named with `--dest`, and gives the same folder. A folder -that does not exist, or that is another system's card, is refused for every -system. One without the system's own files is refused for the MinUI family, -Onion, muOS, spruceOS, CrossMix-OS, Allium and Koriki. KNULLI and ROCKNIX -fill their card when they first start, and no file on the card names ArkOS, -dArkOS, JELOS, KOLOS, UnofficialOS, ArchR, RetroOZ, plumOS, MiyooCFW or the -Anbernic firmware: such a folder is taken, and a note says so. No to -creating its folders asks for the card again. Under WSL a drive letter -typed (`D:`, `D:\`) is that Windows drive. Without questions (`--no-input`) -the run stops with a `--dest` example. A `--dest` that is a folder inside a -MinUI card (its `Bios/`) is refused with the root to name instead. On Android a forced `--platform retroarch` falls back to +The card's top folder is read by one function wherever it is chosen, listed, +browsed, typed, numbered or named with `--dest`, and gives the same folder and +the same note on the screen and in `--plain`. A folder that does not exist, +or that is another system's card, is refused for every system, and so is a +folder inside another system's card (ROCKNIX's `roms/bios` named for KNULLI). +A card whose own files name it is that card whatever its name: an Onion card +mounted as `BIOS` gets `BIOS/BIOS`. One without the system's own files is +refused for the MinUI family, Onion, muOS, spruceOS, CrossMix-OS, Allium and +Koriki. KNULLI and ROCKNIX fill their card when they first start, and no file +on the card names ArkOS, dArkOS, JELOS, KOLOS, UnofficialOS, ArchR, RetroOZ, +plumOS, MiyooCFW, AmberELEC, EmuELEC or the Anbernic firmware: such a folder +gets the system's folder below it, and a note says so, kept on **Create the +folder?** and the plan. No to creating its folders asks for the card again. +Under WSL a drive letter (`D:`, `D:\`) is that Windows drive, typed or given +to `--dest`, `--from`, `--from-pack`, `--save-dir` and `--cores-dir`, and the +screens write the folder `D:\BIOS`. Without questions (`--no-input`) the +run stops with a `--dest` example. A `--dest` that is a folder inside a MinUI +card (its `Bios/`) is refused with the root to name instead. A refused +`--dest` ends with code 1 and an `error` event (`stage` `detect`). On Android a forced `--platform retroarch` falls back to `/RetroArch/system` instead, a home directory there belonging to Termux rather than to the frontend. Pass `--dest` to say where instead of relying on that. When detection met a folder this user cannot open, under the @@ -1311,7 +1333,7 @@ fields, the installer refuses to narrow rather than install everything. | Code | Meaning | |------|---------| | `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, `--restore` could not put a file back, or `--add-app` could not put every file of the app on the card | +| `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, or a drive with none of its files where its card always has them), `--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 | | `141` | the output was piped to a reader that closed early (`--list-platforms \| head`) |