fix: offer a drive by the folder its system reads

RetroArch's folder question numbered a Batocera SHARE drive and filled
its root. A drive is now offered by its own system's BIOS folder, one
nothing names by its bios folder, and another system's is left out and
named with the options that reach it. A list or pack card setups read
too opens on those setups, in both modes.
This commit is contained in:
Abdessamad Derraz committed 2026-10-27 15:19:03 +01:00
1 parent 19bd88131d
commit 5704dd6793
6 files changed
+201 -37

No files matched your search

+1 -1
View File
@@ -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 }
+94 -21
View File
@@ -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 = []
+1 -1
View File
@@ -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() {
+8 -8
View File
@@ -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
},
{
+87 -3
View File
@@ -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
+10 -3
View File
@@ -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