diff --git a/install.ps1 b/install.ps1 index 58abd67e3..1b6d71810 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "455524a94cfddee646331cc5b92358ef52c06f8ed23a0a73be35355efa6528ea" + $defaultInstallSha256 = "f7928407478aae79bd9cd7740521e5c83b4fdd26046521c1c49b2f278c3bf22d" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 26c7532b4..ae78496b2 100644 --- a/install.py +++ b/install.py @@ -6691,12 +6691,66 @@ def share_targets(plat: str) -> list[tuple[str, Path]]: targets = [] for mount in _mounted_cards(): found = card_system(mount) - if found is not None and found[0] != plat: + doubt = share_doubt(mount)[0] + if (found is not None and found[0] != plat) or (doubt and plat not in doubt): continue targets.append((f"{_mount_name(mount)}: bios", mount / "bios")) return targets +def drive_targets(plat: str) -> list[tuple[str, Path]]: + """(row label, BIOS folder) of each drive mounted here offered at the + folder question of a setup that runs from no card (RetroArch here): a + card of a system that reads plat's list by its own BIOS folder, a drive + nothing names by its bios folder. A drive's root is no answer: no setup + reads its BIOS files there, and RetroArch's list landed beside the roms/ + and system/ of a Batocera SHARE drive (code_audit_18 P2-2, tui_audit_11 + P1-2). Another system's card or drive, and one two systems fit, are + reached through their own setup (drives_left_out).""" + targets = [] + for mount in _mounted_cards(): + found = card_system(mount) + if (found is not None and found[0] != plat) or share_doubt(mount)[0]: + continue + name = _mount_name(mount) + if found is not None: + below = found[1].relative_to(mount).as_posix() if found[1] != mount else "" + what = f"{found[2]} {_card_word(found)}" + targets.append((f"{name}: {below} ({what})" if below else f"{name} ({what})", + found[1])) + else: + targets.append((f"{name}: bios (card or drive)", mount / "bios")) + return targets + + +def _card_word(system: tuple[str, Path, str]) -> str: + """A mounted drive's kind as the first screen writes it: SD card, or + SHARE drive.""" + return "SD card" if drive_kind(system) == "card" else drive_kind(system) + + +def drives_left_out(plat: str) -> str: + """Why the drives mounted here that another system reads are not offered + at plat's folder question, in words, "" for none.""" + others = [] + for mount in _mounted_cards(): + found = card_system(mount) + doubt = share_doubt(mount)[0] + # The options that reach it, which work wherever this is asked: the + # setup list is not offered once a platform is named. + if doubt: + names = " or ".join(f"--platform {name}" for name in doubt) + others.append(f"{_mount_name(mount)} ({_doubt_kinds(doubt)}: {names} " + f"--dest {shown_path(mount)})") + elif found is not None and found[0] != plat: + word = _name_key(found[2]) or found[0] + others.append(f"{_mount_name(mount)} ({found[2]} {_card_word(found)}: " + f"--platform {word} --dest {shown_path(mount)})") + if not others: + return "" + return (f"Not offered here, each reads its own system's files: {', '.join(others)}.") + + def ask_platform_folder(os_type: str, plat: str, ask: bool = True, check: bool = True, found: Path | None = None) -> Path: """The BIOS folder for a setup chosen from the list, asked as the @@ -6718,15 +6772,15 @@ def ask_platform_folder(os_type: str, plat: str, ask: bool = True, elif default is not None: state = "" if _exists(default) else " (it does not exist yet and will be created)" print(f" {name}'s default BIOS folder: {shown_path(default)}{state}.") - if shared: - targets = share_targets(plat) - for index, (label, folder) in enumerate(targets, 1): - made = "" if _is_dir(folder) else ", created" - print(f" {index}) {label} ({shown_path(folder)}{made})") - else: - targets = [(card_line(mount), mount) for mount in _mounted_cards()] - for index, (label, _mount) in enumerate(targets, 1): - print(f" {index}) {label}") + # A drive is offered by the BIOS folder its system reads, the screen's + # rows: never its root, and never for a system that reads another list. + targets = share_targets(plat) if shared else drive_targets(plat) + for index, (label, folder) in enumerate(targets, 1): + made = "" if _is_dir(folder) else ", created" + print(f" {index}) {label} ({shown_path(folder)}{made})") + left_out = "" if shared else drives_left_out(plat) + if left_out: + print(f" {left_out}") numbers = "" if not targets else (" or 1" if len(targets) == 1 else f" or 1 to {len(targets)}") why = ("" if default is not None or shared @@ -6741,10 +6795,8 @@ def ask_platform_folder(os_type: str, plat: str, ask: bool = True, if raw is None: return default if raw.isdigit() and 1 <= int(raw) <= len(targets): - folder = targets[int(raw) - 1][1] - if shared: - # The screen's row, taken as it takes it. - return folder + # The screen's row, taken as it takes it. + return targets[int(raw) - 1][1] else: # Named in full on BIOS folder:, Created and Location:, where # "q" alone said nothing of the folder it was in. @@ -10077,7 +10129,10 @@ def _open_local_sources(args: argparse.Namespace) -> None: f"{display_name(plat)}.", file=sys.stderr) sys.exit(1) elif len(spelled) == 1: + # Named by the pack, not by the user: the setups that read its list + # are offered, as for a --manifest. args.platform = spelled[0] + args.platform_from_list = True else: among = (" (" + ", ".join(spelled) + ")") if spelled else "" print(f"Error: name the platform {pack.name} is installed for with " @@ -11277,6 +11332,15 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: platforms = [(args.platform, card_install_dest( named, args.platform, interactive, not (args.check or args.dry_run or args.yes)))] + elif interactive and getattr(args, "platform_from_list", False) and len( + _line_setups(setups, mounted, os_type)) > 1: + # A list or a pack that card setups read too: they are offered, + # RetroArch first, as the screen offers them. Its folder question + # alone left ArkOS, muOS and JELOS out of reach (tui_audit_11 P1-2). + print(f" {display_name(args.platform)} was not found here.") + platforms = _prompt_manual_platform( + os_type, not (args.check or args.dry_run or args.yes), True, + f"Which setup is the {display_name(args.platform)} list for?", setups, mounted) elif interactive: # Asked as the screen asks, never taken: the default folder was # filled with a card mounted in the reader, and with nothing @@ -13064,10 +13128,10 @@ def tui_rows(state: TuiState) -> list[TuiRow]: for label, folder in share_targets(state.platform)] if not bound: # The drives mounted here, as line mode numbers them at the same - # question: a stick in the reader was offered there and not here. + # question, each by the BIOS folder its system reads. drives = [] if state.folder_kind == "share" else [ - TuiRow("drive", f"{_mount_name(mount)} ({_card_what(mount)})", value=mount) - for mount in _mounted_cards()] + TuiRow("drive", label, value=folder) + for label, folder in drive_targets(state.platform)] return rows + [TuiRow("browse", "Browse for a folder"), TuiRow("type", "Type a path")] + drives # Only the cards whose own files say they run the system: A on the @@ -13891,10 +13955,8 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: if screen == "folder": if action == "default": return _choose(state, state.platform, state.folder_default, proposed=True) - if action == "share": + if action in ("share", "drive"): return _choose(state, state.platform, row.value, proposed=True) - if action == "drive": - return _choose_card(state, row.value) if action in ("card", "rescan"): if action == "rescan": state.cards = mounted_cards_of(_tui_card_bound(state)[0], state.platform) @@ -15571,6 +15633,9 @@ def _view_folder(state: TuiState, width: int, height: int) -> tuple: else: title = "Which folder?" before = [f"No usual {name} folder here: browse for it or type it."] + if not bound and state.folder_kind != "share": + left_out = drives_left_out(state.platform) + before += [left_out] if left_out else [] return (title, _compose(state, width, height, before, tui_rows(state), _status_after(state), pinned=state.refused), ("A/Enter Choose", "B/Esc Back", "? Help")) @@ -16486,7 +16551,7 @@ def found_setups(os_type: str) -> tuple[list[tuple[str, Path, str]], set]: # than the old one, comes first (tui_audit_11 P3-15). taken.sort(key=lambda item: (systems.index(item[0][2]), counts.get(item[1], -1))) for card, mount in taken: - kind = "SHARE drive" if drive_kind(card) != "card" else "SD card" + kind = _card_word(card) held = counts.get(mount) what = ("" if held is None else ", no BIOS folder" if held < 0 else f", {_plural(held, 'BIOS file')}") @@ -16572,6 +16637,14 @@ def tui_start( if matched: state.setups = matched return state, [] + if (getattr(args, "platform_from_list", False) and not bound and len(setup_list( + state.setups, state.mounted_setups, "", os_type, LOCAL.serves())) > 1): + # A list or a pack card setups read too: the setup list, filtered + # to them and RetroArch first, as line mode asks (tui_audit_11 + # P1-2). The folder question alone left them out of reach. + state.stack = ["platforms"] + state.focus["platforms"] = _platform_focus(state, plat) + return state, [] state.platform = plat state.card_bound = bound state.stack = [] diff --git a/install.sh b/install.sh index 5a5e1da33..8bb0d655b 100755 --- a/install.sh +++ b/install.sh @@ -18,7 +18,7 @@ esac TEMP_INSTALLER="" TEMP_DIRECTORY="" DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" -DEFAULT_INSTALL_SHA256="455524a94cfddee646331cc5b92358ef52c06f8ed23a0a73be35355efa6528ea" +DEFAULT_INSTALL_SHA256="f7928407478aae79bd9cd7740521e5c83b4fdd26046521c1c49b2f278c3bf22d" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index d49fbcb0e..7912945eb 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 786759, - "sha256": "455524a94cfddee646331cc5b92358ef52c06f8ed23a0a73be35355efa6528ea", + "size": 790778, + "sha256": "f7928407478aae79bd9cd7740521e5c83b4fdd26046521c1c49b2f278c3bf22d", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 839308, - "sha256": "4fef6336d600ff82f1b0d5040d537775df190c23d9ae97e022160e60189e127a" + "size": 843327, + "sha256": "edf0948281063d1b9abf271f778a4486adf83b18924965eff68308e1540f25ab" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 786759, - "sha256": "455524a94cfddee646331cc5b92358ef52c06f8ed23a0a73be35355efa6528ea", + "size": 790778, + "sha256": "f7928407478aae79bd9cd7740521e5c83b4fdd26046521c1c49b2f278c3bf22d", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 786759, - "sha256": "455524a94cfddee646331cc5b92358ef52c06f8ed23a0a73be35355efa6528ea", + "size": 790778, + "sha256": "f7928407478aae79bd9cd7740521e5c83b4fdd26046521c1c49b2f278c3bf22d", "executable": false }, { diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 113ff3601..a2c15f480 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -6704,6 +6704,86 @@ class LineModeAndScreenDecideAlike(unittest.TestCase): self.assertIn("a run without questions installs for one", text) self.assertRegex(text, r"--platform \w+ --dest ") + def share(self, name: str = "SHARE") -> Path: + share = self.root / "media" / name + (share / "system").mkdir(parents=True) + (share / "roms").mkdir() + (share / "system" / "batocera.conf").write_text("system.hostname=BATOCERA\n") + return share + + def test_a_drive_is_offered_by_the_folder_its_system_reads(self): + """code_audit_18 P2-2, tui_audit_11 P1-2: RetroArch's folder question + numbered a Batocera SHARE drive and a KNULLI card, and one press put + RetroArch's list at their root, beside roms/ and system/. A drive + nothing names went to its root too. Now another system's drive is + left out and said, in both modes, and a drive is its bios folder.""" + share, knulli = self.share(), self.card("KNULLI", "KNULLI") + stick = self.root / "media" / "EASYROMS" + (stick / "bios").mkdir(parents=True) + onion = self.card("ONION", "Onion") + mounts = [share, knulli, stick, onion] + with mock.patch.object(install, "_mounted_cards", return_value=mounts), \ + mock.patch.object(install, "_mount_points", return_value=frozenset()), \ + contextlib.redirect_stdout(io.StringIO()) as out, \ + mock.patch.object(install, "_ask", side_effect=["q"]), \ + self.assertRaises(SystemExit): + install.ask_platform_folder("linux", "retroarch") + text = out.getvalue() + self.assertIn(f"1) EASYROMS: bios (card or drive) ({stick / 'bios'})", text) + self.assertIn(f"2) ONION: BIOS (Onion SD card) ({onion / 'BIOS'})", text) + self.assertNotIn("3)", text) + self.assertIn("Not offered here, each reads its own system's files: SHARE (Batocera " + f"SHARE drive: --platform batocera --dest {share}), KNULLI (KNULLI SD " + f"card: --platform knulli --dest {knulli}).", text) + state = install.TuiState(os_type="linux", home=HOME) + state.platform, state.stack = "retroarch", ["folder"] + state.folder_default, state.folder_kind = SYSTEM_DIR, "usual" + with mock.patch.object(install, "_mounted_cards", return_value=mounts), \ + mock.patch.object(install, "_mount_points", return_value=frozenset()): + rows = install.tui_rows(state) + screen = " ".join(install.tui_view(state, 120, 40)) + state.focus["folder"] = next(index for index, row in enumerate(rows) + if row.action == "drive") + install.tui_update(state, Key("enter")) + self.assertEqual([row.value for row in rows if row.action == "drive"], + [stick / "bios", onion / "BIOS"]) + self.assertIn("Not offered here", screen) + self.assertEqual(state.dest, stick / "bios") + + def test_a_list_card_setups_read_offers_them_first(self): + """tui_audit_11 P1-2: with a RetroArch pack or list and nothing found, + the first screen was RetroArch's folder question: ArkOS, muOS and + JELOS, which read the same list, were out of reach. Both modes now + open on the setups that read it, RetroArch first.""" + stick = self.root / "media" / "EASYROMS" + (stick / "bios").mkdir(parents=True) + args = dict(manifest=self.list, declared_only=False, offline=False) + text, asked = self.line_run(_args(**args), [], mounts=[stick]) + self.assertIn("Which setup is the RetroArch list for?", text) + self.assertIn("ArkOS", text) + self.assertNotIn("Batocera", text) + self.assertIn("[Enter for RetroArch]", asked[0]) + with mock.patch.object(install, "_mounted_cards", return_value=[stick]), \ + mock.patch.object(install, "_mount_points", return_value=frozenset()), \ + mock.patch.object(install, "detect_platforms", return_value=[]), \ + mock.patch.object(install, "detect_frontends", return_value=[]): + install._DETECTED_OS.clear() + state = screen_keys(_args(**args), []) + with contextlib.redirect_stdout(io.StringIO()): + install._open_local_sources(_args(**args)) + rows = install.tui_rows(state) + labels = [row.label for row in rows] + focused = rows[state.focus["platforms"]] + install._DETECTED_OS.clear() + ark = screen_keys(_args(**args), ["end", Key("char", "/"), *[ + Key("char", c) for c in "arkos"], "enter", "enter"]) + self.assertEqual(state.screen, "platforms") + self.assertEqual(focused.value, "retroarch") + self.assertTrue(any(label.startswith("ArkOS") for label in labels), labels) + self.assertFalse(any(label.startswith("Batocera") for label in labels), labels) + self.assertEqual(ark.screen, "folder") + self.assertEqual(install._tui_card_bound(ark)[0], "ArkOS") + def test_line_mode_says_the_card_it_found_and_names_its_folder(self): onion = self.card("ONION", "Onion") with mock.patch.object(install, "_mounted_cards", return_value=[onion]), \ @@ -6741,7 +6821,8 @@ class LineModeAndScreenDecideAlike(unittest.TestCase): mock.patch.object(install, "detect_os", return_value="linux"), \ mock.patch.object(install.sys.stdin, "isatty", return_value=True), \ mock.patch.object(install, "_is_terminal", return_value=True), \ - mock.patch.object(install, "_ask", return_value=str(self.here)), \ + mock.patch.object(install, "_ask", side_effect=lambda prompt: ( + "" if prompt.startswith(">") else str(self.here))), \ mock.patch.object(install, "_prompt_custom_selection", menu), \ contextlib.redirect_stdout(io.StringIO()) as out, \ self.assertRaises(SystemExit): @@ -7089,13 +7170,16 @@ class LineModeAndScreenDecideAlike(unittest.TestCase): mock.patch.object(install, "_ask", side_effect=["1"]), \ mock.patch.object(install, "_missing_dest", return_value=True): folder = install.ask_platform_folder("linux", "retroarch") - self.assertEqual(folder, stick) + # tui_audit_11 P1-2: a drive is offered by its bios folder, never its + # root, which no setup reads. + self.assertEqual(folder, stick / "bios") state = install.TuiState(os_type="linux", home=HOME) state.platform, state.stack = "retroarch", ["folder"] state.folder_default, state.folder_kind = SYSTEM_DIR, "usual" with mock.patch.object(install, "_mounted_cards", return_value=mounts): rows = install.tui_rows(state) - self.assertEqual([row.value for row in rows if row.action == "drive"], [stick]) + self.assertEqual([row.value for row in rows if row.action == "drive"], + [stick / "bios"]) def test_the_systems_are_listed_by_title_in_the_screen_order(self): """tui_audit_10 P3-7: line mode listed ids, nintendo-3ds before diff --git a/wiki/installer.md b/wiki/installer.md index 842a3a52e..782bd92ad 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -1031,9 +1031,16 @@ and `--plain` gives the same list numbered, in the same order, with `--dest` too. Both find the SD cards mounted here, the screen under **Found on this machine**. With a setup found, the setup screen lists it with **Another setup or folder**, and `--plain` asks the same: Enter takes the first, `o` opens the -numbered list and then the folder, `q` quits. A platform named, or read from -a list or a pack, and found nowhere gets its folder asked, its usual one under -Enter, the mounted drives numbered. A folder typed is read from the current +numbered list and then the folder, `q` quits. A platform named and found +nowhere gets its folder asked, its usual one under Enter, the mounted drives +numbered. A list or a pack read by card setups too (RetroArch's, read by Onion, +muOS, ArkOS and the others) opens the setup list instead, filtered to the +setups that read it, RetroArch first: Enter on it asks RetroArch's folder. At +that question a drive is numbered by the folder its system reads (`ONION: +BIOS`), a drive nothing names by its `bios` folder (`EASYROMS: bios`), never +by its root, which no setup reads. Another system's card or SHARE drive, and a +drive two systems fit, are left out, and a line names them: each is reached +through its own setup. A folder typed is read from the current one when it is not a full path, and named in full after. `q` quits there too (a folder named `q` is typed `./q`), and Enter with no default says what to type. On a computer Batocera and Recalbox have no default: the drives are