From 78de6252942d9b516d06f1e0bfc76e2f850597b3 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:51:02 +0200 Subject: [PATCH 01/27] fix: name the closed folder behind a link --- install.py | 12 +++++++++- tests/test_install_experience.py | 41 ++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/install.py b/install.py index 35dc45189..664a57d62 100644 --- a/install.py +++ b/install.py @@ -4305,7 +4305,17 @@ def closed_folder(path: Path) -> tuple[Path, str] | None: except OSError as exc: if exc.errno in (errno.ENOENT, errno.ENOTDIR): return None - return folder.parent, exc.strerror or str(exc) + why = exc.strerror or str(exc) + if exc.errno == errno.ELOOP: + # The link that loops, not the open folder holding it. + return folder, why + real = Path(os.path.realpath(folder)) + if real != folder: + # ~/Emulation linked into a closed card: the closed folder + # is under the link's target, and naming the link's + # parent told the user they cannot open their own HOME. + return closed_folder(real) or (real.parent, why) + return folder.parent, why return probe, failure.strerror or str(failure) diff --git a/tests/test_install_experience.py b/tests/test_install_experience.py index 4cba5a56a..f396a8351 100644 --- a/tests/test_install_experience.py +++ b/tests/test_install_experience.py @@ -8,6 +8,7 @@ honours Retry-After, and large files that resume where they stopped. from __future__ import annotations import contextlib +import errno import hashlib import http.server import io @@ -978,5 +979,45 @@ class WindowsMandatoryLock(unittest.TestCase): self.assertEqual((ok, reason), (False, install.NOT_WRITABLE)) +@unittest.skipIf(os.name == "nt" or os.geteuid() == 0, + "a folder without search permission is POSIX") +class AClosedFolderBehindALink(unittest.TestCase): + """~/Emulation linked into a card at 000: the line named the link's + parent, telling the user they cannot open their own HOME. A link + that loops was named by its open parent.""" + + def setUp(self): + self.base = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, self.base, True) + self.home = self.base / "home" + self.home.mkdir() + self.card = self.base / "card" + (self.card / "Emulation" / "bios").mkdir(parents=True) + + def closed(self, path: Path): + os.chmod(self.card, 0o000) + try: + return install.closed_folder(path) + finally: + os.chmod(self.card, 0o755) + + def test_a_link_into_the_closed_folder_names_that_folder(self): + (self.home / "Emulation").symlink_to(self.card / "Emulation") + self.assertEqual(self.closed(self.home / "Emulation" / "bios"), + (self.card, "Permission denied")) + + def test_a_link_to_the_closed_folder_itself_names_it(self): + (self.home / "Emulation").symlink_to(self.card) + self.assertEqual(self.closed(self.home / "Emulation" / "Emulation" / "bios"), + (self.card, "Permission denied")) + + def test_a_loop_names_the_link(self): + loop = self.base / "loop" + loop.symlink_to("loop") + folder, why = install.closed_folder(loop / "system") + self.assertEqual(folder, loop) + self.assertEqual(why, os.strerror(errno.ELOOP)) + + if __name__ == "__main__": unittest.main() From 44093ba20a2cc21327d355d7fd3c92ce57651f85 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:51:35 +0200 Subject: [PATCH 02/27] fix: no unsearched event from a quiet config read --- install.py | 14 +++++++++++--- tests/test_install_experience.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/install.py b/install.py index 664a57d62..f7964d3a3 100644 --- a/install.py +++ b/install.py @@ -1969,7 +1969,8 @@ def _note_refused(path: Path, exc: OSError) -> None: retroarch.cfg was readable, and the run fell back to a folder it does not read. """ - if not getattr(_SETUP_SEARCH, "active", False) or not isinstance(exc, PermissionError): + if (not getattr(_SETUP_SEARCH, "active", False) or getattr(_SETUP_SEARCH, "quiet", False) + or not isinstance(exc, PermissionError)): return refused = closed_folder(path) or (path, exc.strerror or str(exc)) if refused in _DETECTION_REFUSED: @@ -4082,8 +4083,15 @@ def _retroarch_config_for(bios_path: Path) -> tuple[Path, Path] | None: if found is not None or _CONFIGS_READ: return found _CONFIGS_READ.append(True) - with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): - detect_platforms(_INVOCATION.os_type or detect_os()) + # Silent, the unsearched event too: this run searched for no setup, and + # the event came after detect, the line it stands for never shown. + _SETUP_SEARCH.quiet = True + try: + with contextlib.redirect_stdout(io.StringIO()), \ + contextlib.redirect_stderr(io.StringIO()): + detect_platforms(_INVOCATION.os_type or detect_os()) + finally: + _SETUP_SEARCH.quiet = False return _retroarch_config_at(bios_path) diff --git a/tests/test_install_experience.py b/tests/test_install_experience.py index f396a8351..0eb3b4b51 100644 --- a/tests/test_install_experience.py +++ b/tests/test_install_experience.py @@ -1019,5 +1019,35 @@ class AClosedFolderBehindALink(unittest.TestCase): self.assertEqual(why, os.strerror(errno.ELOOP)) +@unittest.skipIf(os.name == "nt" or os.geteuid() == 0, + "a folder without search permission is POSIX") +class TheQuietConfigReadSaysNothing(unittest.TestCase): + """With --dest the run searches for no setup, and the configs read for + the save directory wrote an unsearched event after detect, for a line + the human output never showed.""" + + def test_no_event_and_no_line(self): + base = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, base, True) + closed = base / "home" / ".config" / "EmuDeck" + closed.mkdir(parents=True) + os.chmod(closed, 0o000) + self.addCleanup(os.chmod, closed, 0o755) + emitted = [] + install._DETECTION_REFUSED.clear() + self.addCleanup(install._DETECTION_REFUSED.clear) + with mock.patch.dict(os.environ, {"HOME": str(base / "home"), + "XDG_CONFIG_HOME": str(base / "home" / ".config")}), \ + mock.patch.object(install, "_CONFIGS_READ", []), \ + mock.patch.object(install, "_RETROARCH_CONFIGS", {}), \ + mock.patch.object(install.EVENTS, "emit", + lambda kind, **fields: emitted.append(kind)), \ + contextlib.redirect_stderr(io.StringIO()) as err: + install._retroarch_config_for(base / "bios") + self.assertNotIn("unsearched", emitted) + self.assertEqual(err.getvalue(), "") + self.assertEqual(install._DETECTION_REFUSED, []) + + if __name__ == "__main__": unittest.main() From 378487eca8e43ab4306707bcdc0b27e41a286337 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:53:10 +0200 Subject: [PATCH 03/27] fix: tell a default set aside from none at all --- install.py | 47 +++++++++++++++------ tests/test_install_experience.py | 70 ++++++++++++++++++++++++++++++++ wiki/installer.md | 2 +- 3 files changed, 106 insertions(+), 13 deletions(-) diff --git a/install.py b/install.py index f7964d3a3..30921d9a6 100644 --- a/install.py +++ b/install.py @@ -521,7 +521,8 @@ EVENT_KEYS: dict[str, tuple[str, ...]] = { # stage: manifest (reason: network, not_found, invalid, clock for a # refused certificate under a clock set in the past, or no_ca for no # trusted certificates on the machine) or detect (reason: conflict, two - # systems found on one machine) or from (a folder named with --from, + # systems found on one machine, or not_found, the platform named with + # --platform found nowhere and no folder to assume) or from (a folder named with --from, # reason: not_found, not_a_folder, unreadable, or system for the root # of the system and /proc, /sys, /dev). "error": ("stage", "reason", "platform", "message"), @@ -6255,6 +6256,8 @@ def _prompt_manual_platform(os_type: str) -> list[tuple[str, Path]]: while not raw: raw = _ask( f"BIOS directory [{default_dest}]: " if default_dest + else "BIOS directory (no default, a folder above could not be opened): " + if _default_set_aside(os_type, plat) else "BIOS directory (no default on this system): " ) if default_dest is not None: @@ -6296,14 +6299,7 @@ def _default_dest(os_type: str, platform_name: str) -> Path | None: path on Windows resolved to the root of the current drive: 8.7 GB under C:\\userdata\\bios, reported done. """ - if os_type == "android" and platform_name == "retroarch": - default = _android_storage_root() / "RetroArch" / "system" - elif platform_name in PORTABLE_ONLY or (os_type == "windows" and platform_name - not in _os_default_dests(os_type)): - return None - else: - default = (_os_default_dests(os_type).get(platform_name) - or (None if platform_name == "retrobat" else DEFAULT_DESTS.get(platform_name))) + default = _built_in_dest(os_type, platform_name) if default is not None and _refused_near(default): # The Flatpak's own folder at 000 hid the retroarch.cfg naming # ~/Games/bios, and 6.6 GB were offered to a folder it does not read. @@ -6311,6 +6307,24 @@ def _default_dest(os_type: str, platform_name: str) -> Path | None: return default +def _built_in_dest(os_type: str, platform_name: str) -> Path | None: + """The platform's usual folder on this OS, whatever detection met.""" + if os_type == "android" and platform_name == "retroarch": + return _android_storage_root() / "RetroArch" / "system" + if platform_name in PORTABLE_ONLY or (os_type == "windows" and platform_name + not in _os_default_dests(os_type)): + return None + return (_os_default_dests(os_type).get(platform_name) + or (None if platform_name == "retrobat" else DEFAULT_DESTS.get(platform_name))) + + +def _default_set_aside(os_type: str, platform_name: str) -> bool: + """Whether the platform's usual folder was set aside because detection + met a closed folder, as against having none on this OS.""" + return (_default_dest(os_type, platform_name) is None + and _built_in_dest(os_type, platform_name) is not None) + + def _refused_near(default: Path) -> bool: """Whether detection met a closed folder under HOME, where configs that could name another folder live, or above the default itself.""" @@ -9236,6 +9250,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: elif args.platform in CARD_PLATFORMS: # Its files go on its SD card, which is never this machine's # own folder: the card's mount point is the user's to name. + EVENTS.emit("error", stage="detect", reason="not_found", platform=args.platform, + message=f"{display_name(args.platform)} keeps its BIOS files on " + "its SD card, and no card was named with --dest") print( f" {display_name(args.platform)} keeps its BIOS files on its SD card. " f"Mount the card and name its root with --dest, for example: " @@ -9247,6 +9264,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: elif args.platform in PORTABLE_ONLY: # A portable emulator reads the folder beside its executable: # any default would be a folder it never opens. + EVENTS.emit("error", stage="detect", reason="not_found", platform=args.platform, + message=f"Platform '{args.platform}' not found. " + f"{PORTABLE_ONLY[args.platform]}") print( f" Platform '{args.platform}' not found. {PORTABLE_ONLY[args.platform]} " f"Name it with --dest, for example: --platform {args.platform} " @@ -9258,11 +9278,14 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: default_dest = _default_dest(os_type, args.platform) if default_dest is None: where = ("where this user can look, and its default folder may not be " - "the one it reads" if _DETECTION_REFUSED + "the one it reads" if _default_set_aside(os_type, args.platform) else "and no folder on this system is one it reads") + message = f"Platform '{args.platform}' not found {where}" + EVENTS.emit("error", stage="detect", reason="not_found", + platform=args.platform, message=message) print( - f" Platform '{args.platform}' not found {where}. Name it with " - f"--dest, for example: --platform {args.platform} --dest ", + f" {message}. Name it with --dest, for example: " + f"--platform {args.platform} --dest ", file=sys.stderr, ) sys.exit(1) diff --git a/tests/test_install_experience.py b/tests/test_install_experience.py index 0eb3b4b51..e2e79da53 100644 --- a/tests/test_install_experience.py +++ b/tests/test_install_experience.py @@ -1049,5 +1049,75 @@ class TheQuietConfigReadSaysNothing(unittest.TestCase): self.assertEqual(install._DETECTION_REFUSED, []) +@unittest.skipIf(os.name == "nt" or os.geteuid() == 0, + "a folder without search permission is POSIX") +class NotFoundWithAClosedFolder(unittest.TestCase): + """The not-found line spoke of a default set aside for RetroBat on + Linux, which has none, as soon as any folder was closed, and the exit + wrote no error event.""" + + def run_named(self, platform: str, closed_home: bool) -> tuple: + with FakeRepository() as repo: + home = repo.root / "home" + closed = home / ".config" / "EmuDeck" + closed.mkdir(parents=True) + env = {**repo.env(), "HOME": str(home), "RETROBIOS_OS": "linux"} + if closed_home: + os.chmod(closed, 0o000) + try: + proc = subprocess.run( + [sys.executable, str(REPO_ROOT / "install.py"), "--platform", platform, + "--dry-run", "--no-input", "--events"], + env=env, capture_output=True, text=True, timeout=60, + stdin=subprocess.DEVNULL) + finally: + os.chmod(closed, 0o755) + events = [json.loads(line) for line in proc.stdout.splitlines()] + return proc, events + + def test_a_platform_with_no_folder_here_says_so(self): + for closed_home in (False, True): + with self.subTest(closed=closed_home): + proc, events = self.run_named("retrobat", closed_home) + self.assertEqual(proc.returncode, 1, proc.stderr) + said = " ".join(proc.stderr.split()) + self.assertIn("no folder on this system is one it reads", said) + self.assertNotIn("its default folder may not be", said) + [error] = [e for e in events if e["event"] == "error"] + self.assertEqual((error["stage"], error["reason"], error["platform"]), + ("detect", "not_found", "retrobat")) + + def test_a_default_set_aside_says_so(self): + proc, events = self.run_named("retroarch", True) + self.assertEqual(proc.returncode, 1, proc.stderr) + self.assertIn("its default folder may not be the one it reads", + " ".join(proc.stderr.split())) + self.assertEqual([e["reason"] for e in events if e["event"] == "error"], + ["not_found"]) + + def test_a_card_system_and_a_portable_one_write_the_event(self): + for platform in ("minui", "bizhawk"): + with self.subTest(platform=platform): + proc, events = self.run_named(platform, False) + self.assertEqual(proc.returncode, 1, proc.stderr) + self.assertEqual([(e["stage"], e["reason"]) for e in events + if e["event"] == "error"], [("detect", "not_found")]) + + def test_the_manual_prompt_says_why_there_is_no_default(self): + home = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, home, True) + asked = [] + install._DETECTION_REFUSED[:] = [(home / ".config" / "EmuDeck", "Permission denied")] + self.addCleanup(install._DETECTION_REFUSED.clear) + with mock.patch.dict(os.environ, {"HOME": str(home)}), \ + mock.patch.object(install, "_choose_platform", return_value="retroarch"), \ + mock.patch.object(install, "_ask", + lambda prompt: asked.append(prompt) or str(home / "b")): + install._prompt_manual_platform("linux") + self.assertEqual(len(asked), 1) + self.assertNotIn("no default on this system", asked[0]) + self.assertIn("could not be opened", asked[0]) + + if __name__ == "__main__": unittest.main() diff --git a/wiki/installer.md b/wiki/installer.md index 987dc54c6..d0d3b5192 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -1290,7 +1290,7 @@ other schemas under `api/v1/schemas/`. | `start` | `schema`, `installer`, `mode` | First line. `schema` is 1, `installer` the identity `--version` prints, `mode` one of `install`, `check`, `dry-run`, `list` | | `unsearched` | `folder`, `reason`, `message` | Before `detect`, once for each folder the search for setups could not look in, this user being refused it. What it would hold counts as absent, and the other places are still searched: `detect` then lists what they hold, and an empty `platforms` after an `unsearched` line means nothing was found where the installer could look. `reason` says why in words (`Permission denied`), `message` says it in a sentence | | `detect` | `os`, `os_name`, `platforms` | Once the platforms are known. `os_name` comes from `/etc/os-release`. `platforms` lists `{platform, name, bios_dir}`, and is empty when nothing supported was found | -| `error` | `stage`, `reason`, `platform`, `message` | The file list could not be read (`stage` is `manifest`): `reason` is `network`, `not_found` or `invalid`, `clock` when the server's certificate was refused and the system clock reads a date before the installer was written, `no_ca` when the machine holds no list of trusted certificates (`SSL_CERT_FILE` names one), or `offline` when `--offline` forbids the request. A folder named with `--from` cannot be read (`stage` is `from`, `reason` `not_found`, `not_a_folder` for a file that is no ZIP, `unreadable`, or `system` for the root of the system, `/proc`, `/sys` or `/dev`). The BIOS folder sits under a folder this user cannot open (`stage` is `dest`, `reason` `unreadable`), and the run ends with code 1 before anything is written. Two systems are detected on one machine and no `--dest` names the folder (`stage` is `detect`, `reason` `conflict`). `message` says it in words | +| `error` | `stage`, `reason`, `platform`, `message` | The file list could not be read (`stage` is `manifest`): `reason` is `network`, `not_found` or `invalid`, `clock` when the server's certificate was refused and the system clock reads a date before the installer was written, `no_ca` when the machine holds no list of trusted certificates (`SSL_CERT_FILE` names one), or `offline` when `--offline` forbids the request. A folder named with `--from` cannot be read (`stage` is `from`, `reason` `not_found`, `not_a_folder` for a file that is no ZIP, `unreadable`, or `system` for the root of the system, `/proc`, `/sys` or `/dev`). The BIOS folder sits under a folder this user cannot open (`stage` is `dest`, `reason` `unreadable`), and the run ends with code 1 before anything is written. Two systems are detected on one machine and no `--dest` names the folder (`stage` is `detect`, `reason` `conflict`). The platform named with `--platform` is found nowhere and no folder can be assumed for it: a card system with no card named, a portable emulator, or no usual folder on this system or one set aside behind a folder this user cannot open (`stage` is `detect`, `reason` `not_found`). `message` says it in words | | `scanned` | `folder`, `files`, `truncated`, `other_drives` | Each folder named with `--from`, once read and before `detect`. `files` counts the files found, `truncated` is true when the search stopped at 200,000 of them, and `other_drives` lists the folders below it on another drive, which were not entered | | `checking` | `files_done`, `files_total` | While the files already in the folder are read and hashed, at most every half second and once at the end. `files_total` counts the files found in place | | `file` | `platform`, `dest`, `systems`, `size`, `state` | Each file of the list after the check. `state` is `present` (hash verified), `wrong_hash` or `missing` | From d43e5832010543b209a821a35b01520d98f31609 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:53:59 +0200 Subject: [PATCH 04/27] fix: name the cfg behind a closed detected folder --- install.py | 21 +++++++------ tests/test_install_experience.py | 53 ++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 9 deletions(-) diff --git a/install.py b/install.py index 30921d9a6..f73c0d7f4 100644 --- a/install.py +++ b/install.py @@ -8905,8 +8905,9 @@ def _open_from_folders(folders: list[Path]) -> None: other_drives=[str(drive) for drive in index.other_drives]) -def _refuse_closed(dest: Path, named: str) -> None: - """End the run, with its name, on a folder above dest this user cannot open.""" +def _refuse_closed(dest: Path, named: str, then: str = "") -> None: + """End the run, with its name, on a folder above dest this user cannot + open. then is a line said after the error, which it continues.""" closed = closed_folder(dest) if closed is None: return @@ -8915,6 +8916,8 @@ def _refuse_closed(dest: Path, named: str) -> None: print(f"Error: {screen_text(message)}. Give this user access to it, run as " f"its owner, or choose another folder than the one {named}.", file=sys.stderr) + if then: + print(f" {then}", file=sys.stderr) sys.exit(1) @@ -9366,13 +9369,13 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: # A system_directory under /root read from retroarch.cfg, run without # sudo: the case probe raised PermissionError before any line. for name, path in platforms: - if closed_folder(path) is not None and len(platforms) > 1: - print(" Nothing was installed. Name one platform with --platform " - "to install it alone.", file=sys.stderr) - _refuse_closed(path, ( - f"taken as {display_name(name)}'s default, {display_name(name)} " - "not being found here" if path in assumed_dests - else "detected here") + " (name another with --dest)") + found_in = _retroarch_config_at(path) + named = (f"used by default, since {display_name(name)} was not found" + if path in assumed_dests + else f"{found_in[0]} names" if found_in else "detected here") + _refuse_closed(path, f"{named} (name another with --dest)", + "Nothing was installed. Name one platform with --platform " + "to install it alone." if len(platforms) > 1 else "") total_downloaded = 0 total_up_to_date = 0 diff --git a/tests/test_install_experience.py b/tests/test_install_experience.py index e2e79da53..00a3cffb2 100644 --- a/tests/test_install_experience.py +++ b/tests/test_install_experience.py @@ -1119,5 +1119,58 @@ class NotFoundWithAClosedFolder(unittest.TestCase): self.assertIn("could not be opened", asked[0]) +@unittest.skipIf(os.name == "nt" or os.geteuid() == 0, + "a folder without search permission is POSIX") +class ADetectedClosedFolderSaysWhereItCameFrom(unittest.TestCase): + """The refusal said "the one detected here" for a folder retroarch.cfg + names, and with two setups found the line saying nothing was installed + came above the error it continues.""" + + def run_detected(self, emudeck: bool) -> subprocess.CompletedProcess: + with FakeRepository() as repo: + home = repo.root / "home" + lock = repo.root / "lock" + cfg = home / ".config" / "retroarch" / "retroarch.cfg" + cfg.parent.mkdir(parents=True) + (lock / "system").mkdir(parents=True) + cfg.write_text(f'system_directory = "{lock / "system"}"\n') + if emudeck: + (home / "Emulation" / "bios").mkdir(parents=True) + env = {**repo.env(), "HOME": str(home), "XDG_CONFIG_HOME": str(home / ".config"), + "RETROBIOS_OS": "linux"} + os.chmod(lock, 0o000) + try: + proc = subprocess.run( + [sys.executable, str(REPO_ROOT / "install.py"), "--check", "--no-input"], + env=env, capture_output=True, text=True, timeout=60, + stdin=subprocess.DEVNULL) + finally: + os.chmod(lock, 0o755) + self.cfg = cfg + return proc + + def test_the_config_is_named(self): + proc = self.run_detected(False) + self.assertEqual(proc.returncode, 1, proc.stdout + proc.stderr) + said = " ".join(proc.stderr.split()) + self.assertIn(f"than the one {self.cfg} names (name another with --dest)", said) + + def test_nothing_installed_comes_after_the_error(self): + proc = self.run_detected(True) + self.assertEqual(proc.returncode, 1, proc.stdout + proc.stderr) + error = proc.stderr.index("Error: ") + self.assertGreater(proc.stderr.index("Nothing was installed."), error, proc.stderr) + + def test_an_assumed_default_reads_plainly(self): + self.assertIn("used by default, since {display_name(name)} was not found", + _source_of(install._run_text)) + + +def _source_of(function) -> str: + import inspect + + return " ".join(inspect.getsource(function).split()) + + if __name__ == "__main__": unittest.main() From 30b516fe5a124cf94c5a7c101877f14e2618197c Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:54:13 +0200 Subject: [PATCH 05/27] fix: read the refusal wording from _install --- tests/test_install_experience.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_install_experience.py b/tests/test_install_experience.py index 00a3cffb2..1c7dba215 100644 --- a/tests/test_install_experience.py +++ b/tests/test_install_experience.py @@ -1163,7 +1163,7 @@ class ADetectedClosedFolderSaysWhereItCameFrom(unittest.TestCase): def test_an_assumed_default_reads_plainly(self): self.assertIn("used by default, since {display_name(name)} was not found", - _source_of(install._run_text)) + _source_of(install._install)) def _source_of(function) -> str: From 3bb25065789abfde3c92067f6712da015f4d302a Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:56:06 +0200 Subject: [PATCH 06/27] fix: say which folder the folder screen offers --- install.py | 41 ++++++++++++++++++++------ tests/test_install_tui.py | 60 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 9 deletions(-) diff --git a/install.py b/install.py index f73c0d7f4..57a39c40b 100644 --- a/install.py +++ b/install.py @@ -10304,6 +10304,11 @@ class TuiState: # A --dest given without --platform: the list asks whose files go there. self.dest_given: Path | None = None self.folder_default: Path | None = None + # What folder_default is, for the words above it: "usual" (the + # platform's built-in folder), "found" (the one detected here), + # "chosen" (the run's folder, from Change folder) or "closed" (the + # one chosen could not be opened, none is offered). + self.folder_kind: str = "usual" self.path_text: str = "" self.browse_dir: Path | None = None self.browse_rows: list = [] @@ -10954,7 +10959,7 @@ def _choose(state: TuiState, plat: str, dest: Path) -> list[tuple]: # The folder found here is the one the frontend reads: the built-in # default beside it is one it does not, offered as "reads its BIOS # files from" and filled with Enter. - state.folder_default = None + state.folder_default, state.folder_kind = None, "closed" return _push(state, "folder") card = card_system(dest) if card and card[0] == plat and card[1] != dest: @@ -10979,9 +10984,14 @@ def _choose(state: TuiState, plat: str, dest: Path) -> list[tuple]: return _start_check(state) -def _open_folder_screen(state: TuiState, default: Path | None) -> list[tuple]: - state.folder_default = default - return _push(state, "folder") +def _open_folder_screen(state: TuiState, default: Path | None, + kind: str = "usual") -> list[tuple]: + state.folder_default, state.folder_kind = default, kind + refused = kind == "chosen" and (state.facts.get("writable") is False + or _short_of_space(state, state.check or {})) + # A folder just refused as full or read-only: Browse, not Use this + # folder, is under A, or A, A came back to the same warning. + return _push(state, "folder", 1 if refused and default is not None else 0) def _mount_name(mount: Path) -> str: @@ -11115,8 +11125,15 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: if state.dest_given is not None: return _choose(state, row.value, state.dest_given) state.platform = row.value - default = None if row.value in CARD_PLATFORMS else _default_dest(state.os_type, row.value) - return _open_folder_screen(state, default) + if row.value in CARD_PLATFORMS: + return _open_folder_screen(state, None) + # The folder detected here before the built-in one: the built-in + # default was offered as where RetroArch reads, when the + # retroarch.cfg found here names another. + found = next((path for plat, path, _label in state.setups if plat == row.value), None) + if found is not None: + return _open_folder_screen(state, found, "found") + return _open_folder_screen(state, _default_dest(state.os_type, row.value)) if screen == "folder": if action == "default": return _choose(state, state.platform, state.folder_default) @@ -11165,7 +11182,7 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: if action == "quit": return tui_quit(state) if action == "folder": - return _open_folder_screen(state, state.dest) + return _open_folder_screen(state, state.dest, "chosen") if action == "fromcard": return _open_from_browser(state) if action in ("misplaced", "unreadable"): @@ -11236,7 +11253,7 @@ def _option(state: TuiState, action: str, step: int) -> list[tuple]: elif action in ("regions", "command"): return _push(state, action) elif action == "folder": - return _open_folder_screen(state, state.dest) + return _open_folder_screen(state, state.dest, "chosen") elif action == "from" and step < 0: # Said, and the screen stays: Back to the plan counts again. if state.from_folders: @@ -12052,7 +12069,13 @@ def _view_folder(state: TuiState, width: int, height: int) -> tuple: "root folder, the one holding .system."] elif state.folder_default is not None: title = "Which folder?" - before = [f"{name} usually reads its BIOS files from:", str(state.folder_default)] + lead = {"found": f"{name} reads its BIOS files from:", + "chosen": f"The BIOS folder chosen for {name}:"}.get( + state.folder_kind, f"{name} usually reads its BIOS files from:") + before = [lead, str(state.folder_default)] + elif state.folder_kind == "closed": + title = "Which folder?" + before = [f"Choose another folder for {name}: browse for it or type it."] else: title = "Which folder?" before = [f"No usual {name} folder here: browse for it or type it."] diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index aa81f1b87..6dfde1c36 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -3783,5 +3783,65 @@ class EndToEnd(unittest.TestCase): self.assertNotIn(b"\x1b", term.raw) +class TheFolderScreenSaysWhatItOffers(unittest.TestCase): + """The folder screen said "usually reads its BIOS files from" for any + folder it offered: the folder named with --dest, reached by Change + folder, and the built-in default while the retroarch.cfg found here + names another. A closed folder found here opened on "No usual + RetroArch folder here" under the setup that listed it.""" + + def said(self, state: install.TuiState) -> str: + return " ".join(" ".join(install.tui_view(state, 200, 40)).split()) + + def test_change_folder_names_the_chosen_folder(self): + state = checked_state() + state.stack = ["setup", "plan"] + mine = Path("/home/user/typed/mybios") + state.dest = mine + install._activate(state, install.TuiRow("folder", "Change folder")) + self.assertEqual(state.screen, "folder") + screen = self.said(state) + self.assertNotIn("usually reads", screen) + self.assertIn(f"The BIOS folder chosen for RetroArch: {mine}", screen) + + def test_another_setup_offers_the_folder_found_here(self): + state = fresh_state() + found = Path("/home/user/Games/bios") + state.setups = [("retroarch", found, "RetroArch")] + state.stack = ["setup", "platforms"] + install._activate(state, install.TuiRow("platform", "RetroArch", value="retroarch")) + self.assertEqual(state.folder_default, found) + self.assertIn(f"RetroArch reads its BIOS files from: {found}", self.said(state)) + + def test_with_none_found_the_usual_folder_is_offered(self): + state = fresh_state() + state.setups = [] + state.stack = ["setup", "platforms"] + install._activate(state, install.TuiRow("platform", "RetroArch", value="retroarch")) + self.assertIn("RetroArch usually reads its BIOS files from:", self.said(state)) + + def test_a_closed_folder_found_here_asks_for_another(self): + state = fresh_state() + state.stack = ["setup"] + with mock.patch.object(install, "closed_folder", + return_value=(Path("/home/user/lock"), "Permission denied")): + install.tui_update(state, Key("enter")) + self.assertEqual(state.screen, "folder") + screen = self.said(state) + self.assertNotIn("No usual RetroArch folder here", screen) + self.assertIn("Choose another folder for RetroArch", screen) + + def test_a_refused_folder_puts_browse_under_a(self): + for facts in ({"free": 10, "writable": True, "cores": []}, + {"free": 50 * 1024 ** 3, "writable": False, "cores": []}): + with self.subTest(facts=facts): + state = checked_state(facts) + self.assertEqual(install.tui_rows(state)[0].action, "folder") + install.tui_update(state, Key("enter")) + self.assertEqual(state.screen, "folder") + rows = install.tui_rows(state) + self.assertEqual(rows[state.focus["folder"]].action, "browse") + + if __name__ == "__main__": unittest.main() From 2d9db59c73641a939b38435b465a4bbb03bb40c2 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:56:53 +0200 Subject: [PATCH 07/27] fix: let A pass over system and recycle folders --- install.py | 29 +++++++++++++++++++++++- tests/test_install_tui.py | 47 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/install.py b/install.py index 57a39c40b..41381f47e 100644 --- a/install.py +++ b/install.py @@ -11056,6 +11056,33 @@ def _browse_rows(folder: Path | None, zips: bool = False) -> tuple[list[TuiRow], return rows, "" +# The folders of a system root that hold no one's files: A went into +# /bin from /, the first name in order. +_POSIX_ROOT_SYSTEM = frozenset(( + "bin", "boot", "dev", "efi", "etc", "lib", "lib32", "lib64", "libx32", "lost+found", + "proc", "root", "run", "sbin", "snap", "srv", "sys", "tmp", "usr", "var", +)) +# FILE_ATTRIBUTE_HIDDEN and FILE_ATTRIBUTE_SYSTEM: Explorer shows neither. +_WINDOWS_HIDDEN = 0x2 | 0x4 + + +def _system_folder(path: Path, folder: Path) -> bool: + """A folder of the system, not one a person keeps files in: the + recycle bin and restore points of a Windows drive ($RECYCLE.BIN, System + Volume Information, hidden by Explorer), and /bin, /etc, /usr and the + like at the root of a Unix system.""" + name = path.name + if name.startswith(("$", ".")) or name.casefold() == "system volume information": + return True + try: + attributes = getattr(os.stat(path, follow_symlinks=False), "st_file_attributes", 0) + except OSError: + attributes = 0 + if attributes & _WINDOWS_HIDDEN: + return True + return folder == Path("/") and name in _POSIX_ROOT_SYSTEM + + def _browse_to(state: TuiState, folder: Path | None, came_from: Path | None = None) -> None: state.browse_dir = folder state.browse_rows, state.status = _browse_rows(folder, state.browse_for == "from") @@ -11070,7 +11097,7 @@ def _browse_to(state: TuiState, folder: Path | None, came_from: Path | None = No focus = next((index for index, row in enumerate(state.browse_rows) if row.action == "dir" and isinstance(row.value, Path) and row.label == f"{row.value.name}/" - and not row.value.name.startswith(".")), 0) + and not _system_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 diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 6dfde1c36..45f0173a7 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -3843,5 +3843,52 @@ class TheFolderScreenSaysWhatItOffers(unittest.TestCase): self.assertEqual(rows[state.focus["folder"]].action, "browse") +class AFocusesNoSystemFolder(unittest.TestCase): + """At / A went into bin/, and at a drive root holding $RECYCLE.BIN into + the recycle bin, which Use this folder then took as the BIOS folder.""" + + def setUp(self): + self.home = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, self.home, True) + self.state = install.TuiState(os_type="linux", home=self.home) + self.state.platform = "retroarch" + self.state.stack = ["folder", "browse"] + + def focused(self) -> install.TuiRow: + return self.state.browse_rows[self.state.focus["browse"]] + + def test_the_recycle_bin_and_restore_points_are_passed_over(self): + for name in ("$RECYCLE.BIN", "System Volume Information", "Games"): + (self.home / name).mkdir() + with mock.patch.object(install, "_mounted_cards", return_value=[]): + install._browse_to(self.state, self.home) + self.assertEqual(self.focused().label, "Games/") + + def test_explorer_hidden_folders_are_passed_over(self): + for name in ("Boot", "Games"): + (self.home / name).mkdir() + real_stat = os.stat + + def stat(path, *args, **kwargs): + result = real_stat(path, *args, **kwargs) + hidden = 0x2 | 0x4 if Path(path).name == "Boot" else 0 + return types.SimpleNamespace(st_file_attributes=hidden, st_mode=result.st_mode) + + with mock.patch.object(install, "_mounted_cards", return_value=[]), \ + mock.patch.object(install.os, "stat", stat): + install._browse_to(self.state, self.home) + self.assertEqual(self.focused().label, "Games/") + + def test_at_the_root_no_system_folder_is_focused(self): + with mock.patch.object(install, "_mounted_cards", return_value=[]): + install._browse_to(self.state, Path("/")) + row = self.focused() + if row.action == "dir": + self.assertNotIn(row.value.name, install._POSIX_ROOT_SYSTEM) + names = {r.value.name for r in self.state.browse_rows if r.action == "dir"} + if "bin" in names and names - install._POSIX_ROOT_SYSTEM - {""}: + self.assertEqual(row.action, "dir") + + if __name__ == "__main__": unittest.main() From d24adf95920d6413eb9c15329ef222c0ae94cd9f Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:57:33 +0200 Subject: [PATCH 08/27] fix: ask no standalone list in an offline run --- install.py | 5 +++++ tests/test_install_experience.py | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/install.py b/install.py index 41381f47e..bf4488127 100644 --- a/install.py +++ b/install.py @@ -6011,6 +6011,11 @@ def _install_standalone_folders( False when this machine has none, the run then going on to the platform question or hint. """ + if offline_requested(): + # Their folders are named by file lists this run cannot fetch: the + # first refusal ended an --offline run that asked nothing of EmuDeck + # on "No file list for emudeck". + return False manifests = [] for plat in STANDALONE_SOURCES: manifests.append(fetch_manifest(plat)) diff --git a/tests/test_install_experience.py b/tests/test_install_experience.py index 1c7dba215..43ac058b2 100644 --- a/tests/test_install_experience.py +++ b/tests/test_install_experience.py @@ -1166,6 +1166,28 @@ class ADetectedClosedFolderSaysWhereItCameFrom(unittest.TestCase): _source_of(install._install)) +class OfflineWithNothingFound(unittest.TestCase): + """An --offline run on a machine with nothing installed ended on "No + file list for emudeck", a platform nobody asked about: the standalone + folders are named by file lists that run cannot fetch.""" + + def test_the_hint_is_given_and_no_other_platform_named(self): + with FakeRepository() as repo: + home = repo.root / "home" + home.mkdir() + env = {**repo.env(), "HOME": str(home), "XDG_CONFIG_HOME": str(home / ".config"), + "RETROBIOS_OS": "linux"} + proc = subprocess.run( + [sys.executable, str(REPO_ROOT / "install.py"), "--offline", "--check", + "--no-input"], + env=env, capture_output=True, text=True, timeout=60, + stdin=subprocess.DEVNULL) + said = proc.stdout + proc.stderr + self.assertEqual(proc.returncode, 1, said) + self.assertNotIn("emudeck", said.lower()) + self.assertIn("Use --platform and --dest", said) + + def _source_of(function) -> str: import inspect From 3797b482d2a5d5e7e7aec1aa87ed142ad439cdc1 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:59:21 +0200 Subject: [PATCH 09/27] feat: set the save folder from the options --- install.py | 43 +++++++++++++++++++++++++++++++++++---- tests/test_install_tui.py | 40 +++++++++++++++++++++++++++++++++--- wiki/installer.md | 3 ++- 3 files changed, 78 insertions(+), 8 deletions(-) diff --git a/install.py b/install.py index bf4488127..5822a0ee4 100644 --- a/install.py +++ b/install.py @@ -10843,6 +10843,10 @@ def _option_rows(state: TuiState) -> list[TuiRow]: chosen = f"{len(state.cores)} chosen" if state.cores else "all" rows.append(TuiRow("cores", f"Cores: {chosen}", "--core")) copied = ", ".join(str(folder) for folder in state.from_folders) or "none" + if manifest.get("save_directory"): + # The interface said "Name it with --save-dir" and had no row for it. + rows.append(TuiRow("savedir", f"Save folder: {_save_folder_words(state)}", + "--save-dir", style="path")) rows += [ TuiRow("jobs", f"Parallel downloads: {state.jobs}", "--jobs"), TuiRow("folder", f"BIOS folder: {_short_path(state.dest, state.home)}", "--dest", @@ -10854,6 +10858,21 @@ def _option_rows(state: TuiState) -> list[TuiRow]: return rows +def _save_dir_given(state: TuiState) -> Path | None: + return next((Path(value) for flag, value in state.passthrough + if flag == "--save-dir" and value), None) + + +def _save_folder_words(state: TuiState) -> str: + """The frontend's save directory as the Options row shows it.""" + given = _save_dir_given(state) + if given is not None: + return _short_path(given, state.home) + if state.saves and state.saves.get("folder"): + return _short_path(Path(state.saves["folder"]), state.home) + return "not known" + + def _push(state: TuiState, screen: str, focus: int = 0) -> list[tuple]: state.stack.append(screen) state.focus[screen] = focus @@ -11178,6 +11197,12 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: state.path_selected = bool(state.path_text) return _push(state, "path") if screen == "browse": + if action == "use" and state.browse_for == "save": + state.passthrough = [flag for flag in state.passthrough + if flag[0] != "--save-dir"] + state.passthrough.append(("--save-dir", str(state.browse_dir))) + state.browse_for = "dest" + return _start_check(state) if action in ("use", "zip") and state.browse_for == "from": chosen = state.browse_dir if action == "use" else row.value if chosen not in state.from_folders: @@ -11293,6 +11318,10 @@ def _option(state: TuiState, action: str, step: int) -> list[tuple]: state.status = f"No longer copying from {_last_name(str(removed))}." elif action == "from": return _open_from_browser(state) + elif action == "savedir": + state.browse_for = "save" + _push(state, "browse") + _browse_to(state, _nearest_folder(_save_dir_given(state) or state.home)) elif action == "preview": _push(state, "preview") return _start_run(state, "preview") @@ -12036,7 +12065,8 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: if check.get("not_installable"): lines += _unavailable_lines(state) if check is state.check: - lines += _unplaced_lines(state.check_summary, state) + lines += _unplaced_lines(state.check_summary, state, + "Choose it in Options, Save folder.") return lines[:2] + offline_lines + lines[2:] @@ -12053,7 +12083,8 @@ def _saves_lines(state: TuiState, saves: dict | None) -> list[str]: f"{_short_path(Path(saves['folder']), state.home)}"] -def _unplaced_lines(summary: dict | None, state: TuiState) -> list[str]: +def _unplaced_lines(summary: dict | None, state: TuiState, + remedy: str = "Name it with --save-dir.") -> list[str]: """The files read from the frontend's save directory that the run left out, that directory being unknown: said, never silent.""" count = sum(group.get("files", 0) for group in (summary or {}).get("skipped") or [] @@ -12062,7 +12093,7 @@ def _unplaced_lines(summary: dict | None, state: TuiState) -> list[str]: return [] name = display_name(state.platform) if state.platform else "the frontend" return [f"{_plural(count, 'file')} read from {name}'s save directory left out: " - "its place is not known. Name it with --save-dir."] + f"its place is not known. {remedy}"] def _view_setup(state: TuiState, width: int, height: int) -> tuple: @@ -12123,7 +12154,11 @@ def _view_browse(state: TuiState, width: int, height: int) -> tuple: if state.browse_for == "from": after.insert(0, "Choose the old card, a backup folder or a ZIP: its BIOS files " "are copied, each one checked, nothing on it is changed.") - return ("Copy from" if state.browse_for == "from" else "Browse for a folder", + elif state.browse_for == "save": + after.insert(0, f"Choose {display_name(state.platform)}'s save directory: some " + "cores read files there.") + return ("Copy from" if state.browse_for == "from" + else "Save folder" if state.browse_for == "save" else "Browse for a folder", _compose(state, width, height, [where], tui_rows(state), after), ("A/Enter Open", "B/Esc Back", "? Help")) diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 45f0173a7..170e7008d 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -2719,13 +2719,47 @@ class AuditRoundFour(unittest.TestCase): "free": 50 * 1024 ** 3, "writable": True, "cores": []}): install.tui_receive(state, ("ended", state.run_id, 1, [])) line = ("2 files read from RetroArch's save directory left out: its place is " - "not known. Name it with --save-dir.") + "not known. ") plan = " ".join(" ".join(install.tui_view(state, 120, 40)).split()) - self.assertIn(line, plan) + # The plan names the row that sets it, the result the option a + # command carries. + self.assertIn(line + "Choose it in Options, Save folder.", plan) state.stack.append("result") state.code = 0 state.summary = {**summary, "mode": "install", "installed": 5} - self.assertIn(line, install.tui_outcome(state)[1]) + self.assertIn(line + "Name it with --save-dir.", install.tui_outcome(state)[1]) + + +class TheSaveFolderHasARow(unittest.TestCase): + """A card on a PC left the save-directory files out and named + --save-dir, which Options had no row for: the interface user had to + leave it and type a command.""" + + def state(self) -> install.TuiState: + state = checked_state() + state.manifest = {**MANIFEST, "save_directory": {"folder": "saves", "by_core": True}} + return state + + def test_options_list_it_and_the_browser_sets_it(self): + state = self.state() + rows = install._option_rows(state) + [row] = [row for row in rows if row.action == "savedir"] + self.assertEqual((row.label, row.right), ("Save folder: not known", "--save-dir")) + saves = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, saves, True) + state.stack = ["setup", "plan", "options"] + install._option(state, "savedir", 0) + self.assertEqual(state.screen, "browse") + self.assertIn("save directory", " ".join(" ".join(install.tui_view(state, 120, 40)) + .split())) + install._browse_to(state, saves) + effects = install._activate(state, install.TuiRow("use", "Use this folder")) + self.assertIn(("--save-dir", str(saves)), install.tui_flags(state)) + self.assertTrue(any(effect[0] == "run" for effect in effects), effects) + self.assertEqual(install._save_folder_words(state), str(saves)) + + def test_a_list_without_a_save_directory_has_no_row(self): + self.assertNotIn("savedir", [row.action for row in install._option_rows(checked_state())]) class OfflineInTheInterface(unittest.TestCase): diff --git a/wiki/installer.md b/wiki/installer.md index d0d3b5192..ac5d9cf22 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -133,7 +133,8 @@ much space is free, before anything is written. The other choices sit one press away. **Choose systems** ticks the consoles you play, with the total to download under the list. **Options** sets the preferred regions, game data, the installed cores only, the cores, the parallel -downloads and the folder. Each row names the option it sets, and **Show the +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 From 6a3471378ec4b5471838791054e686de29ac7e6b Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Sat, 24 Oct 2026 04:02:57 +0200 Subject: [PATCH 10/27] fix: one spelling of shared files per system The focused system's size is spelled out below the list when its name leaves no room for it. The browser's system folder test is renamed _os_folder: _system_folder already names the --from refusal. --- install.py | 20 +++++++++++++++----- tests/test_install_tui.py | 33 +++++++++++++++++++++++++++++++-- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/install.py b/install.py index 5822a0ee4..fad91a913 100644 --- a/install.py +++ b/install.py @@ -10785,12 +10785,13 @@ def _system_rows(state: TuiState) -> list[TuiRow]: right = f"{_plural(need, 'file')}, {format_size(row['bytes_needed'])}" narrow = (format_size(row["bytes_needed"]),) elif not own: + # Two spellings of shared, never cut: "+ 28 sh." and ", shared" + # sat beside "+ 1689 shared" on one 40-column screen. right = f"{_plural(need, 'file')}, {format_size(size)}, all shared" - narrow = (f"{format_size(size)}, shared", format_size(size)) + narrow = (f"{format_size(size)}, all shared", format_size(size)) else: right = f"{_plural(own, 'file')}, {format_size(own_size)} + {count} shared" - narrow = (f"{format_size(own_size)} + {count} shared", - f"{format_size(own_size)} + {count} sh.", format_size(own_size)) + narrow = (f"{format_size(own_size)} + {count} shared", format_size(own_size)) rows.append(TuiRow("system", f"{box} {label}", right, row["system"], "" if need else "dim", narrow)) return rows @@ -11090,7 +11091,7 @@ _POSIX_ROOT_SYSTEM = frozenset(( _WINDOWS_HIDDEN = 0x2 | 0x4 -def _system_folder(path: Path, folder: Path) -> bool: +def _os_folder(path: Path, folder: Path) -> bool: """A folder of the system, not one a person keeps files in: the recycle bin and restore points of a Windows drive ($RECYCLE.BIN, System Volume Information, hidden by Explorer), and /bin, /etc, /usr and the @@ -11121,7 +11122,7 @@ def _browse_to(state: TuiState, folder: Path | None, came_from: Path | None = No focus = next((index for index, row in enumerate(state.browse_rows) if row.action == "dir" and isinstance(row.value, Path) and row.label == f"{row.value.name}/" - and not _system_folder(row.value, folder)), 0) + 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 @@ -12256,6 +12257,7 @@ def _ticked_files(state: TuiState) -> list[dict]: def _view_systems(state: TuiState, width: int, height: int) -> tuple: rows = tui_rows(state) + whole_rows = rows if width < 60: # The name first: the size alone stays beside it, the count is in # the total below. @@ -12273,6 +12275,14 @@ def _view_systems(state: TuiState, width: int, height: int) -> tuple: f"{format_size(sum(e['size'] for e in chosen))}" + (" before your options" if _counts_predate_options(state) else "")] focused = rows[min(state.focus.get("systems", 0), len(rows) - 1)] + drawn = str(_row_line(focused, True, width)) + if focused.action == "system" and not ( + f"> {focused.label}" in drawn + and any(form and drawn.endswith(form) for form in (focused.right, *focused.narrow))): + # A long name kept whole leaves no room for its size, and a name + # longer than the screen is cut: both are spelled out below. + whole = whole_rows[min(state.focus.get("systems", 0), len(whole_rows) - 1)] + footer.insert(0, f"{whole.label[4:]}: {whole.right}") if focused.value == OTHER_SYSTEMS: footer.insert(0, OTHER_HELP) elif "shared" in focused.right: diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 170e7008d..7b8021979 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -447,7 +447,7 @@ class Flow(unittest.TestCase): self.assertEqual(press(state, "enter"), []) self.assertIn("No system is ticked", state.status) focus = next(i for i, row in enumerate(install.tui_rows(state)) - if row.value == "nintendo-gba") + if row.value == "epoch-scv") state.focus["systems"] = focus press(state, "right") self.assertEqual(press(state, "enter"), [("run", "confirm")]) @@ -1513,7 +1513,7 @@ class AuditRoundTwo(unittest.TestCase): state = self.systems() press(state, "n") state.focus["systems"] = next(i for i, row in enumerate(install.tui_rows(state)) - if row.value == "nintendo-gba") + if row.value == "epoch-scv") press(state, "right") self.assertEqual(state.ticked, {"nintendo-gba"}) press(state, "/", "p", "l", "a", "y") @@ -2841,6 +2841,35 @@ class NarrowSystemNames(unittest.TestCase): self.assertIn("3 files, 30.0 MB + 27 shared", "\n".join(install.tui_view(state, 120, 40))) + def test_one_spelling_of_shared_and_the_focused_size_below(self): + """At 40 columns the shared part read "+ 1689 shared", "+ 28 sh.", + ", shared" and ", all shared" on one screen, and a long name kept + whole lost its size.""" + state = checked_state() + names = {"epoch-scv": "Epoch Super Cassette Vision", "sega-dc": "Sega Dreamcast", + "snk-ng": "SNK Neo Geo"} + state.manifest = {**state.manifest, "system_titles": names} + state.systems = [ + {"event": "system", "platform": "retroarch", "system": system, "name": system, + "files": count, "present": 0, "missing": count, "wrong_hash": 0, + "bytes_needed": count * 1024} + for system, count in (("epoch-scv", 30), ("sega-dc", 1690), ("snk-ng", 3))] + state.files = [{"event": "file", "platform": "retroarch", "dest": f"s{n}.bin", + "systems": ["sega-dc", "snk-ng"], "size": 1024, "state": "missing"} + for n in range(3)] + state.ticked = set(names) + state.stack.append("systems") + spellings = set() + for cols in (40, 46, 52, 80): + text = "\n".join(install.tui_view(state, cols, 15)) + spellings |= set(re.findall(r"\+ \d+ sh\w*\.?|, (?:all )?shared", text)) + self.assertLessEqual(spellings, {"+ 3 shared", ", all shared"}, spellings) + state.focus["systems"] = next( + index for index, row in enumerate(install.tui_rows(state)) + if row.value == "epoch-scv") + text = " ".join(" ".join(install.tui_view(state, 40, 15)).split()) + self.assertIn("Epoch Super Cassette Vision: 30 files, 30.0 KB", text) + class Crash(unittest.TestCase): """An unexpected error ends on one sentence and a log, never a traceback.""" From 46f00f011016479bf6b19372c3691ef96f10f6ad Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Sat, 24 Oct 2026 04:03:46 +0200 Subject: [PATCH 11/27] fix: no second file count on the checking screen --- install.py | 5 +++-- tests/fixtures/tui/checking-120x40.txt | 2 +- tests/fixtures/tui/checking-40x12.txt | 2 +- tests/fixtures/tui/checking-40x15.txt | 2 +- tests/fixtures/tui/checking-80x24.txt | 2 +- tests/fixtures/tui/route-evmapy.txt | 6 +++--- tests/fixtures/tui/route-gptokeyb.txt | 6 +++--- tests/fixtures/tui/route-gptokeyb_muxterm.txt | 6 +++--- tests/fixtures/tui/route-keys.txt | 6 +++--- tests/fixtures/tui/route-miyoo.txt | 6 +++--- tests/fixtures/tui/route-muxterm.txt | 6 +++--- 11 files changed, 25 insertions(+), 24 deletions(-) diff --git a/install.py b/install.py index fad91a913..8f884bc3f 100644 --- a/install.py +++ b/install.py @@ -12201,8 +12201,9 @@ def _view_checking(state: TuiState, width: int, height: int) -> tuple: if state.manifest is None: lines.append(f"Getting the file list... {elapsed}") else: - files = len(state.manifest.get("files", [])) - lines += [f"File list: {_plural(files, 'file')}.", _reading_line(state, elapsed)] + # Not its count: the check counts the files it checks, which the + # plan then gives (8179 after "File list: 8169 files"). + lines += ["File list received.", _reading_line(state, elapsed)] return ("Checking", _compose(state, width, height, lines, []), ("Ctrl-C Quit", "B/Esc Back")) diff --git a/tests/fixtures/tui/checking-120x40.txt b/tests/fixtures/tui/checking-120x40.txt index 197711197..2f2a29e8f 100644 --- a/tests/fixtures/tui/checking-120x40.txt +++ b/tests/fixtures/tui/checking-120x40.txt @@ -2,7 +2,7 @@ RetroArch: ~/.config/retroarch/system - File list: 5 files. + File list received. Reading the files already in the folder... 0:04 diff --git a/tests/fixtures/tui/checking-40x12.txt b/tests/fixtures/tui/checking-40x12.txt index 362bc5cb0..c68b6638e 100644 --- a/tests/fixtures/tui/checking-40x12.txt +++ b/tests/fixtures/tui/checking-40x12.txt @@ -2,7 +2,7 @@ RetroArch: ~/.config/retroarch/system - File list: 5 files. + File list received. Reading the files already in the folder... 0:04 diff --git a/tests/fixtures/tui/checking-40x15.txt b/tests/fixtures/tui/checking-40x15.txt index 35fcf8822..e6bd727e9 100644 --- a/tests/fixtures/tui/checking-40x15.txt +++ b/tests/fixtures/tui/checking-40x15.txt @@ -2,7 +2,7 @@ RetroArch: ~/.config/retroarch/system - File list: 5 files. + File list received. Reading the files already in the folder... 0:04 diff --git a/tests/fixtures/tui/checking-80x24.txt b/tests/fixtures/tui/checking-80x24.txt index 21e561008..d5b190cb1 100644 --- a/tests/fixtures/tui/checking-80x24.txt +++ b/tests/fixtures/tui/checking-80x24.txt @@ -2,7 +2,7 @@ RetroArch: ~/.config/retroarch/system - File list: 5 files. + File list received. Reading the files already in the folder... 0:04 diff --git a/tests/fixtures/tui/route-evmapy.txt b/tests/fixtures/tui/route-evmapy.txt index 179fa02f6..1e46aebf1 100644 --- a/tests/fixtures/tui/route-evmapy.txt +++ b/tests/fixtures/tui/route-evmapy.txt @@ -381,7 +381,7 @@ RetroArch: ~/.config/retroarch/system - File list: 5 files. + File list received. Reading the files already in the folder... 0:04 @@ -394,7 +394,7 @@ RetroArch: ~/.config/retroarch/system - File list: 5 files. + File list received. Reading the files already in the folder... 0:04 @@ -410,7 +410,7 @@ RetroArch: ~/.config/retroarch/system - File list: 5 files. + File list received. Reading the files already in the folder... 0:04 diff --git a/tests/fixtures/tui/route-gptokeyb.txt b/tests/fixtures/tui/route-gptokeyb.txt index 946460274..5fc8c13d6 100644 --- a/tests/fixtures/tui/route-gptokeyb.txt +++ b/tests/fixtures/tui/route-gptokeyb.txt @@ -381,7 +381,7 @@ RetroArch: ~/.config/retroarch/system - File list: 5 files. + File list received. Reading the files already in the folder... 0:04 @@ -394,7 +394,7 @@ RetroArch: ~/.config/retroarch/system - File list: 5 files. + File list received. Reading the files already in the folder... 0:04 @@ -410,7 +410,7 @@ RetroArch: ~/.config/retroarch/system - File list: 5 files. + File list received. Reading the files already in the folder... 0:04 diff --git a/tests/fixtures/tui/route-gptokeyb_muxterm.txt b/tests/fixtures/tui/route-gptokeyb_muxterm.txt index 946460274..5fc8c13d6 100644 --- a/tests/fixtures/tui/route-gptokeyb_muxterm.txt +++ b/tests/fixtures/tui/route-gptokeyb_muxterm.txt @@ -381,7 +381,7 @@ RetroArch: ~/.config/retroarch/system - File list: 5 files. + File list received. Reading the files already in the folder... 0:04 @@ -394,7 +394,7 @@ RetroArch: ~/.config/retroarch/system - File list: 5 files. + File list received. Reading the files already in the folder... 0:04 @@ -410,7 +410,7 @@ RetroArch: ~/.config/retroarch/system - File list: 5 files. + File list received. Reading the files already in the folder... 0:04 diff --git a/tests/fixtures/tui/route-keys.txt b/tests/fixtures/tui/route-keys.txt index f2bed0ac5..5d7e60d8a 100644 --- a/tests/fixtures/tui/route-keys.txt +++ b/tests/fixtures/tui/route-keys.txt @@ -327,7 +327,7 @@ RetroArch: ~/.config/retroarch/system - File list: 5 files. + File list received. Reading the files already in the folder... 0:04 @@ -340,7 +340,7 @@ RetroArch: ~/.config/retroarch/system - File list: 5 files. + File list received. Reading the files already in the folder... 0:04 @@ -356,7 +356,7 @@ RetroArch: ~/.config/retroarch/system - File list: 5 files. + File list received. Reading the files already in the folder... 0:04 diff --git a/tests/fixtures/tui/route-miyoo.txt b/tests/fixtures/tui/route-miyoo.txt index 99103df10..4b8e2e4aa 100644 --- a/tests/fixtures/tui/route-miyoo.txt +++ b/tests/fixtures/tui/route-miyoo.txt @@ -381,7 +381,7 @@ RetroArch: ~/.config/retroarch/system - File list: 5 files. + File list received. Reading the files already in the folder... 0:04 @@ -394,7 +394,7 @@ RetroArch: ~/.config/retroarch/system - File list: 5 files. + File list received. Reading the files already in the folder... 0:04 @@ -410,7 +410,7 @@ RetroArch: ~/.config/retroarch/system - File list: 5 files. + File list received. Reading the files already in the folder... 0:04 diff --git a/tests/fixtures/tui/route-muxterm.txt b/tests/fixtures/tui/route-muxterm.txt index 7c360043f..aa7509a7c 100644 --- a/tests/fixtures/tui/route-muxterm.txt +++ b/tests/fixtures/tui/route-muxterm.txt @@ -381,7 +381,7 @@ RetroArch: ~/.config/retroarch/system - File list: 5 files. + File list received. Reading the files already in the folder... 0:04 @@ -394,7 +394,7 @@ RetroArch: ~/.config/retroarch/system - File list: 5 files. + File list received. Reading the files already in the folder... 0:04 @@ -410,7 +410,7 @@ RetroArch: ~/.config/retroarch/system - File list: 5 files. + File list received. Reading the files already in the folder... 0:04 From 831e187f840e6f55faf51b4ba29be5def5cbd525 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Sat, 24 Oct 2026 04:08:23 +0200 Subject: [PATCH 12/27] fix: close idle before an install as a quit --- install.ps1 | 2 +- install.py | 6 ++++-- install.sh | 2 +- tests/test_install_tui.py | 5 +++-- wiki/installer.md | 33 +++++++++++++++------------------ 5 files changed, 24 insertions(+), 24 deletions(-) diff --git a/install.ps1 b/install.ps1 index 24449baef..6e90563be 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "be9d37048e800072d6d5d2a40afaa5a46d5b062577f83c0fa633fc15159de66a" + $defaultInstallSha256 = "39d4fba29587c16931a85033319ad785b9f37deaf806ad31e37eed48bfb6d178" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 8f884bc3f..7f2f31310 100644 --- a/install.py +++ b/install.py @@ -13253,12 +13253,14 @@ def tui_farewell(state: TuiState) -> list[str]: def tui_idle_code(state: TuiState) -> int: """The exit code of an idle close: an error's or a result's on those - screens, so a launcher tells them from a close with nothing to say.""" + screens, so a launcher tells them from a close with nothing to say. + Elsewhere nothing was installed, and 0 said it all was: 130, as a quit + before the install.""" if state.screen == "error": return state.error_code if state.screen == "result" and state.code is not None: return state.code - return 0 + return 130 def _farewell_lines(state: TuiState) -> list[str]: diff --git a/install.sh b/install.sh index 99e21a37b..4e10d5dd8 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="be9d37048e800072d6d5d2a40afaa5a46d5b062577f83c0fa633fc15159de66a" +DEFAULT_INSTALL_SHA256="39d4fba29587c16931a85033319ad785b9f37deaf806ad31e37eed48bfb6d178" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 7b8021979..dd564447a 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -2642,10 +2642,11 @@ class IdleExit(unittest.TestCase): self.assertEqual(farewell[:2], ["RetroBIOS: No button pressed, closing.", "RetroBIOS: Finished with errors"]) - def test_any_other_screen_closes_with_0(self): + def test_any_other_screen_closes_as_a_quit(self): + """0 read as "everything asked for is installed" with nothing done.""" state = fresh_state() state.stack = ["setup"] - self.assertEqual(self.idle_close(state), 0) + self.assertEqual(self.idle_close(state), 130) def idle_close(self, state: install.TuiState) -> int: """run_tui on state, no key ever pressed, closed by the idle timer.""" diff --git a/wiki/installer.md b/wiki/installer.md index ac5d9cf22..85efac14f 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -1066,9 +1066,15 @@ moves with the D-pad. Each launcher also sets `RETROBIOS_IDLE_EXIT` to 300: with no button pressed for 5 minutes since it opened, and no check or install running, the interface -closes by itself. A pad the -system has no mapping for sends nothing, and would otherwise leave the -console stuck on the interface with no way out. +closes by itself. A pad the system has no mapping for sends nothing, and would +otherwise leave the console stuck on the interface with no way out. It closes +with `RetroBIOS: No button pressed, closing.` and exit status 130, as a quit +before the install. Closed on an error or a result screen, it prints that +screen's words after the line and exits with its code (1 for `No internet: +offline mode`), so the launcher can tell an error from a close with nothing to +report. The time counts from the end of the last check, so a launcher that +names the platform, whose interface moves on to the plan by itself, closes the +same way. Any launcher can set it to another number of seconds. Each launcher tells the interface which buttons reach it, in `RETROBIOS_PAD`, so the hints can name them: @@ -1082,19 +1088,10 @@ Each launcher tells the interface which buttons reach it, in | `miyoo` | buttons that are already keys: A as Space, Start as Enter, L1 and R1 as E and T, L2 as Tab, R2 as Backspace | no launcher yet | A launcher with no mapper at all (a keyboard) leaves it unset. -A launcher can also set `RETROBIOS_IDLE_EXIT` to a number of seconds: when -no button has reached the interface since it opened, and nothing has run for -that long, it closes with `RetroBIOS: No button pressed, closing.` and exit status 0. -Closed on an error or a result screen, it prints that screen's words after -the line and exits with its code (1 for `No internet: offline mode`), so -the launcher can tell an error from a close with nothing to report. -The time counts from the end of the last check, so a launcher that names the -platform, whose interface moves on to the plan by itself, closes the same way. -A mapper that never opened the pad would otherwise hold the device on that -screen. On a Miyoo, B, -X, Y and Select are Left Ctrl, Shift, Alt and Right Ctrl: pressed alone, a -modifier sends nothing to a terminal, so the interface goes back with R2 -instead, and the bottom line says so. + +On a Miyoo, B, X, Y and Select are Left Ctrl, Shift, Alt and Right Ctrl: +pressed alone, a modifier sends nothing to a terminal, so the interface goes +back with R2 instead, and the bottom line says so. **Without Wi-Fi.** On a computer, download the BIOS pack that serves your system (every part, for a pack in parts): RetroArch's on muOS, ArkOS, @@ -1250,7 +1247,7 @@ fields, the installer refuses to narrow rather than install everything. | `0` | everything asked for is installed or already up to date | | `1` | a failure: the one-liner could not download or verify `install.py`, or found no Python 3.8 (nothing was run), an unusable base URL, an unknown platform or target, `--installed-cores` with no core found, a manifest that fails validation, a fetch that never succeeded, no platform to install for in a non-interactive run, some files failed 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, or `--restore` could not put a file back | | `2` | nothing was installed: every file the run tried failed (a BIOS folder that cannot be written, for one), 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. The message then starts with `install.py: error:`. 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. Run the same command again to carry on | +| `130` | stopped with Ctrl+C, or with `q`, Ctrl+C or Ctrl+D at a question, or the full screen closed with no button pressed (`RETROBIOS_IDLE_EXIT`) before an install. Run the same command again to carry on | | `141` | the output was piped to a reader that closed early (`--list-platforms \| head`) | The full screen ended by SIGTERM (Start+Select on a handheld) before any @@ -1343,7 +1340,7 @@ RetroArch folder that holds only `scph5501.bin`, in the order written: | `SSL_CERT_FILE`, `SSL_CERT_DIR` | The trusted certificates to check servers with, for a Python that finds none of its own | | `RETROBIOS_PLAIN`, `ACCESSIBLE` | Set to anything but `0`: the questions come line by line, as with `--plain`, instead of the full-screen interface | | `RETROBIOS_PAD` | The buttons a handheld launcher's mapper sends to the full screen (`gptokeyb`, `gptokeyb_muxterm`, `muxterm`, `evmapy`, `miyoo`), so its hints name them. See [On a handheld](#on-a-handheld-the-retrobios-port) | -| `RETROBIOS_IDLE_EXIT` | Seconds the full screen waits for a first button, counted while nothing runs. With none pressed, it closes with exit status 0, or with the code of the error or result screen it closed on | +| `RETROBIOS_IDLE_EXIT` | Seconds the full screen waits for a first button, counted while nothing runs. With none pressed, it closes with exit status 130, or with the code of the error or result screen it closed on | | `HTTPS_PROXY`, `NO_PROXY` | Honoured for every download through the standard library's default proxy handling: one names the proxy, the other the hosts to reach directly | ## When something goes wrong From c3978ec4670fa287c1d33f2d2117251c8cd9e8d1 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Sat, 24 Oct 2026 04:08:28 +0200 Subject: [PATCH 13/27] fix: count the tries a stalled list server gets --- install.py | 7 ++++++- tests/test_install.py | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/install.py b/install.py index 7f2f31310..d80688e62 100644 --- a/install.py +++ b/install.py @@ -3447,6 +3447,9 @@ def _fetch_json( _network_allowed(label) timeout = LIST_TIMEOUT if timeout is None else timeout for attempt in range(1, attempts + 1): + # The tries this failure gets: "(2/3)" then "after 2 attempts" for + # a server gone silent, asked once more only. + tries = attempts try: with urllib.request.urlopen(url, timeout=timeout) as resp: return _read_limited_json(resp, limit, label) @@ -3466,9 +3469,11 @@ def _fetch_json( exc.attempts = attempt # type: ignore[union-attr] raise delay = _retry_delay(attempt, None) + if _stalled(exc): + tries = min(attempts, STALLED_ATTEMPTS) # Said, or three 30-second attempts read as a frozen program. print(f" The {label} did not arrive ({problem}), trying again " - f"({attempt + 1}/{attempts}) in {delay:.0f} s.", file=sys.stderr) + f"({attempt + 1}/{tries}) in {delay:.0f} s.", file=sys.stderr) time.sleep(delay) raise AssertionError("unreachable") diff --git a/tests/test_install.py b/tests/test_install.py index 62287a03c..d8295017a 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -2411,6 +2411,10 @@ class ASilentServerIsBounded(unittest.TestCase): self.assertLess(elapsed, 5) self.assertEqual(len(held), install.STALLED_ATTEMPTS) self.assertIn(f"after {install.STALLED_ATTEMPTS} attempts", err.getvalue()) + # The retry line counts the tries this failure gets: "(2/3)" promised + # a third that never came. + self.assertIn(f"(2/{install.STALLED_ATTEMPTS})", err.getvalue()) + self.assertNotIn(f"/{install.MAX_RETRIES})", err.getvalue()) # A download keeps its own, longer wait between chunks. self.assertGreater(install.LIST_TIMEOUT, 1) From 536081ffb1a1e977fcacf2deb4866ce5c8de21e0 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Sat, 24 Oct 2026 04:09:01 +0200 Subject: [PATCH 14/27] fix: name the folder whose files could not be read --- install.py | 5 ++++- tests/test_install_tui.py | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/install.py b/install.py index d80688e62..53f7e1cdf 100644 --- a/install.py +++ b/install.py @@ -11974,7 +11974,10 @@ def _scan_lines(state: TuiState) -> list[str]: for drive in scan["other_drives"]] if state.unreadable: count = len(state.unreadable) - lines.append(f"{_plural(count, 'file')} on your old card could not be read.") + # Named as it was given: "your old card" was said of a folder. + where = ", ".join(_last_name(folder) for folder in + dict.fromkeys(event["folder"] for event in state.unreadable)) + lines.append(f"{_plural(count, 'file')} in {where} could not be read.") return lines diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index dd564447a..6cea29ff2 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -2535,7 +2535,8 @@ class AuditRoundThree(unittest.TestCase): text = " ".join(" ".join(install.tui_view(state, 120, 40)).split()) self.assertIn("Read the first 200,000 files of E:\\.", text) self.assertIn("E:\\D is another drive: add it to copy from it too.", text) - self.assertIn("2 files on your old card could not be read.", text) + # The folder by its name: "your old card" was said of any folder. + self.assertIn("2 files in E:\\ could not be read.", text) rows = install.tui_rows(state) state.focus["plan"] = next(i for i, row in enumerate(rows) if row.action == "unreadable") press(state, "enter") From a37e99a4347951d384d4dc3649928c5330f50f92 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:03:27 +0100 Subject: [PATCH 15/27] fix: hold the idle quit code in the screen tests --- tests/test_install_tui.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 6cea29ff2..99e125baf 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -447,7 +447,7 @@ class Flow(unittest.TestCase): self.assertEqual(press(state, "enter"), []) self.assertIn("No system is ticked", state.status) focus = next(i for i, row in enumerate(install.tui_rows(state)) - if row.value == "epoch-scv") + if row.value == "nintendo-gba") state.focus["systems"] = focus press(state, "right") self.assertEqual(press(state, "enter"), [("run", "confirm")]) @@ -1513,7 +1513,7 @@ class AuditRoundTwo(unittest.TestCase): state = self.systems() press(state, "n") state.focus["systems"] = next(i for i, row in enumerate(install.tui_rows(state)) - if row.value == "epoch-scv") + if row.value == "nintendo-gba") press(state, "right") self.assertEqual(state.ticked, {"nintendo-gba"}) press(state, "/", "p", "l", "a", "y") @@ -3768,7 +3768,7 @@ class EndToEnd(unittest.TestCase): self.env[install.TUI_IDLE_ENV] = "1" term = self.start() term.wait("Where do you play?") - self.assertEqual(term.close(), 0) + self.assertEqual(term.close(), 130) self.assertIn("No button pressed, closing.", term.after()) # A key pressed, the screen stays: the person is there. term = self.start() @@ -3789,7 +3789,7 @@ class EndToEnd(unittest.TestCase): self.env[install.TUI_IDLE_ENV] = "1" term = self.start("--platform", "retroarch", "--dest", str(repo.dest)) term.wait("> Install") - self.assertEqual(term.close(), 0) + self.assertEqual(term.close(), 130) self.assertIn("No button pressed, closing.", term.after()) self.assertEqual(repo.installed(), []) From 78c68210693ef929f487295b3245cba32d71b626 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:03:27 +0100 Subject: [PATCH 16/27] fix: say why confirm shows, and whose backup --- install.py | 11 +++++++++-- tests/fixtures/tui/confirm-120x40.txt | 2 +- tests/fixtures/tui/confirm-40x12.txt | 4 ++-- tests/fixtures/tui/confirm-40x15.txt | 4 ++-- tests/fixtures/tui/confirm-80x24.txt | 2 +- tests/fixtures/tui/plan-120x40.txt | 2 +- tests/fixtures/tui/plan-40x15.txt | 4 ++-- tests/fixtures/tui/plan-80x24.txt | 2 +- tests/fixtures/tui/route-evmapy.txt | 16 ++++++++-------- tests/fixtures/tui/route-gptokeyb.txt | 16 ++++++++-------- tests/fixtures/tui/route-gptokeyb_muxterm.txt | 16 ++++++++-------- tests/fixtures/tui/route-keys.txt | 16 ++++++++-------- tests/fixtures/tui/route-miyoo.txt | 16 ++++++++-------- tests/fixtures/tui/route-muxterm.txt | 16 ++++++++-------- 14 files changed, 67 insertions(+), 60 deletions(-) diff --git a/install.py b/install.py index 53f7e1cdf..5688ca014 100644 --- a/install.py +++ b/install.py @@ -12051,7 +12051,10 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: lines.append(f"Not enough free space: {format_size(check['bytes_needed'])} " "needed. Free some, or choose another folder.") if wrong: - lines.append("They are replaced, and the old copies are kept in a backup folder.") + # With its subject: "They are replaced" sat lines under the count. + lines.append(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 [] if misplaced: lines.insert(3, f"{_plural(len(misplaced), 'file')} " @@ -12403,7 +12406,11 @@ 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..."] else: - lines = _plan_lines(state.dry, state.dest, state) + # Why this screen: the numbers above are counted again for the + # systems and options chosen, the last look before anything is + # written. + lines = ["Counted again for your choices. Nothing is written before Install.", + *_plan_lines(state.dry, state.dest, state)] return ("Confirm", _compose(state, width, height, lines, tui_rows(state)), ("A/Enter Install", "B/Esc Back", "? Help")) diff --git a/tests/fixtures/tui/confirm-120x40.txt b/tests/fixtures/tui/confirm-120x40.txt index 89ee7e0e4..ec7e73637 100644 --- a/tests/fixtures/tui/confirm-120x40.txt +++ b/tests/fixtures/tui/confirm-120x40.txt @@ -1,5 +1,6 @@ RetroBIOS: Confirm + Counted again for your choices. Nothing is written before Install. 1 file to download, 16.0 KB BIOS folder: ~/.config/retroarch/system 0 in place, 1 missing, 0 wrong copies @@ -34,7 +35,6 @@ - A/Enter Install B/Esc Back ? Help diff --git a/tests/fixtures/tui/confirm-40x12.txt b/tests/fixtures/tui/confirm-40x12.txt index e6b28e26d..a6829f6b0 100644 --- a/tests/fixtures/tui/confirm-40x12.txt +++ b/tests/fixtures/tui/confirm-40x12.txt @@ -1,5 +1,7 @@ RetroBIOS: Confirm + Counted again for your choices. + Nothing is written before Install. 1 file to download, 16.0 KB BIOS folder: ~/.co.../retroarch/system 0 in place, 1 missing, 0 wrong copies @@ -7,6 +9,4 @@ > Install Back - - A/Enter Install B/Esc Back ? Help diff --git a/tests/fixtures/tui/confirm-40x15.txt b/tests/fixtures/tui/confirm-40x15.txt index d44fb0005..250844ab7 100644 --- a/tests/fixtures/tui/confirm-40x15.txt +++ b/tests/fixtures/tui/confirm-40x15.txt @@ -1,5 +1,7 @@ RetroBIOS: Confirm + Counted again for your choices. + Nothing is written before Install. 1 file to download, 16.0 KB BIOS folder: ~/.co.../retroarch/system 0 in place, 1 missing, 0 wrong copies @@ -10,6 +12,4 @@ - - A/Enter Install B/Esc Back ? Help diff --git a/tests/fixtures/tui/confirm-80x24.txt b/tests/fixtures/tui/confirm-80x24.txt index 6e2101480..b373fe4db 100644 --- a/tests/fixtures/tui/confirm-80x24.txt +++ b/tests/fixtures/tui/confirm-80x24.txt @@ -1,5 +1,6 @@ RetroBIOS: Confirm + Counted again for your choices. Nothing is written before Install. 1 file to download, 16.0 KB BIOS folder: ~/.config/retroarch/system 0 in place, 1 missing, 0 wrong copies @@ -20,5 +21,4 @@ - A/Enter Install B/Esc Back ? Help diff --git a/tests/fixtures/tui/plan-120x40.txt b/tests/fixtures/tui/plan-120x40.txt index 0c0ad949d..25e7bbce7 100644 --- a/tests/fixtures/tui/plan-120x40.txt +++ b/tests/fixtures/tui/plan-120x40.txt @@ -4,7 +4,7 @@ BIOS folder: ~/.config/retroarch/system 1 in place, 3 missing, 1 wrong copy Free space: 50.0 GB - They are replaced, and the old copies are kept in a backup folder. + The 1 wrong copy is replaced, the old one kept in a backup folder. > Install Choose systems diff --git a/tests/fixtures/tui/plan-40x15.txt b/tests/fixtures/tui/plan-40x15.txt index 709f289fb..5f7240348 100644 --- a/tests/fixtures/tui/plan-40x15.txt +++ b/tests/fixtures/tui/plan-40x15.txt @@ -4,8 +4,8 @@ BIOS folder: ~/.co.../retroarch/system 1 in place, 3 missing, 1 wrong copy Free space: 50.0 GB - They are replaced, and the old copies - are kept in a backup folder. + The 1 wrong copy is replaced, the old + one kept in a backup folder. > Install Choose systems diff --git a/tests/fixtures/tui/plan-80x24.txt b/tests/fixtures/tui/plan-80x24.txt index ee2684f49..6b26353e0 100644 --- a/tests/fixtures/tui/plan-80x24.txt +++ b/tests/fixtures/tui/plan-80x24.txt @@ -4,7 +4,7 @@ BIOS folder: ~/.config/retroarch/system 1 in place, 3 missing, 1 wrong copy Free space: 50.0 GB - They are replaced, and the old copies are kept in a backup folder. + The 1 wrong copy is replaced, the old one kept in a backup folder. > Install Choose systems diff --git a/tests/fixtures/tui/route-evmapy.txt b/tests/fixtures/tui/route-evmapy.txt index 1e46aebf1..c9f54b1b5 100644 --- a/tests/fixtures/tui/route-evmapy.txt +++ b/tests/fixtures/tui/route-evmapy.txt @@ -450,8 +450,8 @@ BIOS folder: ~/.co.../retroarch/system 1 in place, 3 missing, 1 wrong copy Free space: 50.0 GB - They are replaced, and the old copies - are kept in a backup folder. + The 1 wrong copy is replaced, the old + one kept in a backup folder. > Install Choose systems @@ -466,7 +466,7 @@ BIOS folder: ~/.config/retroarch/system 1 in place, 3 missing, 1 wrong copy Free space: 50.0 GB - They are replaced, and the old copies are kept in a backup folder. + The 1 wrong copy is replaced, the old one kept in a backup folder. > Install Choose systems @@ -865,6 +865,8 @@ === confirm 40x12 RetroBIOS: Confirm + Counted again for your choices. + Nothing is written before Install. 1 file to download, 16.0 KB BIOS folder: ~/.co.../retroarch/system 0 in place, 1 missing, 0 wrong copies @@ -872,12 +874,12 @@ > Install Back - - A Install B Back === confirm 40x15 RetroBIOS: Confirm + Counted again for your choices. + Nothing is written before Install. 1 file to download, 16.0 KB BIOS folder: ~/.co.../retroarch/system 0 in place, 1 missing, 0 wrong copies @@ -888,12 +890,11 @@ - - A Install B Back === confirm 80x24 RetroBIOS: Confirm + Counted again for your choices. Nothing is written before Install. 1 file to download, 16.0 KB BIOS folder: ~/.config/retroarch/system 0 in place, 1 missing, 0 wrong copies @@ -914,7 +915,6 @@ - A Install B Back === installing 40x12 RetroBIOS: Installing diff --git a/tests/fixtures/tui/route-gptokeyb.txt b/tests/fixtures/tui/route-gptokeyb.txt index 5fc8c13d6..9812144e8 100644 --- a/tests/fixtures/tui/route-gptokeyb.txt +++ b/tests/fixtures/tui/route-gptokeyb.txt @@ -450,8 +450,8 @@ BIOS folder: ~/.co.../retroarch/system 1 in place, 3 missing, 1 wrong copy Free space: 50.0 GB - They are replaced, and the old copies - are kept in a backup folder. + The 1 wrong copy is replaced, the old + one kept in a backup folder. > Install Choose systems @@ -466,7 +466,7 @@ BIOS folder: ~/.config/retroarch/system 1 in place, 3 missing, 1 wrong copy Free space: 50.0 GB - They are replaced, and the old copies are kept in a backup folder. + The 1 wrong copy is replaced, the old one kept in a backup folder. > Install Choose systems @@ -865,6 +865,8 @@ === confirm 40x12 RetroBIOS: Confirm + Counted again for your choices. + Nothing is written before Install. 1 file to download, 16.0 KB BIOS folder: ~/.co.../retroarch/system 0 in place, 1 missing, 0 wrong copies @@ -872,12 +874,12 @@ > Install Back - - A Install B Back === confirm 40x15 RetroBIOS: Confirm + Counted again for your choices. + Nothing is written before Install. 1 file to download, 16.0 KB BIOS folder: ~/.co.../retroarch/system 0 in place, 1 missing, 0 wrong copies @@ -888,12 +890,11 @@ - - A Install B Back === confirm 80x24 RetroBIOS: Confirm + Counted again for your choices. Nothing is written before Install. 1 file to download, 16.0 KB BIOS folder: ~/.config/retroarch/system 0 in place, 1 missing, 0 wrong copies @@ -914,7 +915,6 @@ - A Install B Back === installing 40x12 RetroBIOS: Installing diff --git a/tests/fixtures/tui/route-gptokeyb_muxterm.txt b/tests/fixtures/tui/route-gptokeyb_muxterm.txt index 5fc8c13d6..9812144e8 100644 --- a/tests/fixtures/tui/route-gptokeyb_muxterm.txt +++ b/tests/fixtures/tui/route-gptokeyb_muxterm.txt @@ -450,8 +450,8 @@ BIOS folder: ~/.co.../retroarch/system 1 in place, 3 missing, 1 wrong copy Free space: 50.0 GB - They are replaced, and the old copies - are kept in a backup folder. + The 1 wrong copy is replaced, the old + one kept in a backup folder. > Install Choose systems @@ -466,7 +466,7 @@ BIOS folder: ~/.config/retroarch/system 1 in place, 3 missing, 1 wrong copy Free space: 50.0 GB - They are replaced, and the old copies are kept in a backup folder. + The 1 wrong copy is replaced, the old one kept in a backup folder. > Install Choose systems @@ -865,6 +865,8 @@ === confirm 40x12 RetroBIOS: Confirm + Counted again for your choices. + Nothing is written before Install. 1 file to download, 16.0 KB BIOS folder: ~/.co.../retroarch/system 0 in place, 1 missing, 0 wrong copies @@ -872,12 +874,12 @@ > Install Back - - A Install B Back === confirm 40x15 RetroBIOS: Confirm + Counted again for your choices. + Nothing is written before Install. 1 file to download, 16.0 KB BIOS folder: ~/.co.../retroarch/system 0 in place, 1 missing, 0 wrong copies @@ -888,12 +890,11 @@ - - A Install B Back === confirm 80x24 RetroBIOS: Confirm + Counted again for your choices. Nothing is written before Install. 1 file to download, 16.0 KB BIOS folder: ~/.config/retroarch/system 0 in place, 1 missing, 0 wrong copies @@ -914,7 +915,6 @@ - A Install B Back === installing 40x12 RetroBIOS: Installing diff --git a/tests/fixtures/tui/route-keys.txt b/tests/fixtures/tui/route-keys.txt index 5d7e60d8a..51fc6cdfd 100644 --- a/tests/fixtures/tui/route-keys.txt +++ b/tests/fixtures/tui/route-keys.txt @@ -396,8 +396,8 @@ BIOS folder: ~/.co.../retroarch/system 1 in place, 3 missing, 1 wrong copy Free space: 50.0 GB - They are replaced, and the old copies - are kept in a backup folder. + The 1 wrong copy is replaced, the old + one kept in a backup folder. > Install Choose systems @@ -412,7 +412,7 @@ BIOS folder: ~/.config/retroarch/system 1 in place, 3 missing, 1 wrong copy Free space: 50.0 GB - They are replaced, and the old copies are kept in a backup folder. + The 1 wrong copy is replaced, the old one kept in a backup folder. > Install Choose systems @@ -811,6 +811,8 @@ === confirm 40x12 RetroBIOS: Confirm + Counted again for your choices. + Nothing is written before Install. 1 file to download, 16.0 KB BIOS folder: ~/.co.../retroarch/system 0 in place, 1 missing, 0 wrong copies @@ -818,12 +820,12 @@ > Install Back - - Enter Install Esc Back ? Help === confirm 40x15 RetroBIOS: Confirm + Counted again for your choices. + Nothing is written before Install. 1 file to download, 16.0 KB BIOS folder: ~/.co.../retroarch/system 0 in place, 1 missing, 0 wrong copies @@ -834,12 +836,11 @@ - - Enter Install Esc Back ? Help === confirm 80x24 RetroBIOS: Confirm + Counted again for your choices. Nothing is written before Install. 1 file to download, 16.0 KB BIOS folder: ~/.config/retroarch/system 0 in place, 1 missing, 0 wrong copies @@ -860,7 +861,6 @@ - Enter Install Esc Back ? Help === installing 40x12 RetroBIOS: Installing diff --git a/tests/fixtures/tui/route-miyoo.txt b/tests/fixtures/tui/route-miyoo.txt index 4b8e2e4aa..faaed9394 100644 --- a/tests/fixtures/tui/route-miyoo.txt +++ b/tests/fixtures/tui/route-miyoo.txt @@ -450,8 +450,8 @@ BIOS folder: ~/.co.../retroarch/system 1 in place, 3 missing, 1 wrong copy Free space: 50.0 GB - They are replaced, and the old copies - are kept in a backup folder. + The 1 wrong copy is replaced, the old + one kept in a backup folder. > Install Choose systems @@ -466,7 +466,7 @@ BIOS folder: ~/.config/retroarch/system 1 in place, 3 missing, 1 wrong copy Free space: 50.0 GB - They are replaced, and the old copies are kept in a backup folder. + The 1 wrong copy is replaced, the old one kept in a backup folder. > Install Choose systems @@ -865,6 +865,8 @@ === confirm 40x12 RetroBIOS: Confirm + Counted again for your choices. + Nothing is written before Install. 1 file to download, 16.0 KB BIOS folder: ~/.co.../retroarch/system 0 in place, 1 missing, 0 wrong copies @@ -872,12 +874,12 @@ > Install Back - - A Install R2 Back === confirm 40x15 RetroBIOS: Confirm + Counted again for your choices. + Nothing is written before Install. 1 file to download, 16.0 KB BIOS folder: ~/.co.../retroarch/system 0 in place, 1 missing, 0 wrong copies @@ -888,12 +890,11 @@ - - A Install R2 Back === confirm 80x24 RetroBIOS: Confirm + Counted again for your choices. Nothing is written before Install. 1 file to download, 16.0 KB BIOS folder: ~/.config/retroarch/system 0 in place, 1 missing, 0 wrong copies @@ -914,7 +915,6 @@ - A Install R2 Back === installing 40x12 RetroBIOS: Installing diff --git a/tests/fixtures/tui/route-muxterm.txt b/tests/fixtures/tui/route-muxterm.txt index aa7509a7c..8394f7eaf 100644 --- a/tests/fixtures/tui/route-muxterm.txt +++ b/tests/fixtures/tui/route-muxterm.txt @@ -450,8 +450,8 @@ BIOS folder: ~/.co.../retroarch/system 1 in place, 3 missing, 1 wrong copy Free space: 50.0 GB - They are replaced, and the old copies - are kept in a backup folder. + The 1 wrong copy is replaced, the old + one kept in a backup folder. > Install Choose systems @@ -466,7 +466,7 @@ BIOS folder: ~/.config/retroarch/system 1 in place, 3 missing, 1 wrong copy Free space: 50.0 GB - They are replaced, and the old copies are kept in a backup folder. + The 1 wrong copy is replaced, the old one kept in a backup folder. > Install Choose systems @@ -865,6 +865,8 @@ === confirm 40x12 RetroBIOS: Confirm + Counted again for your choices. + Nothing is written before Install. 1 file to download, 16.0 KB BIOS folder: ~/.co.../retroarch/system 0 in place, 1 missing, 0 wrong copies @@ -872,12 +874,12 @@ > Install Back - - Start Install B Back === confirm 40x15 RetroBIOS: Confirm + Counted again for your choices. + Nothing is written before Install. 1 file to download, 16.0 KB BIOS folder: ~/.co.../retroarch/system 0 in place, 1 missing, 0 wrong copies @@ -888,12 +890,11 @@ - - Start Install B Back === confirm 80x24 RetroBIOS: Confirm + Counted again for your choices. Nothing is written before Install. 1 file to download, 16.0 KB BIOS folder: ~/.config/retroarch/system 0 in place, 1 missing, 0 wrong copies @@ -914,7 +915,6 @@ - Start Install B Back === installing 40x12 RetroBIOS: Installing From 42304f6df843053ffd19140032f495ba7efe9653 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:07:41 +0100 Subject: [PATCH 17/27] fix: a dry run asks no install, counts systems --- install.py | 16 ++++++++++++---- tests/test_install_experience.py | 27 +++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/install.py b/install.py index 5688ca014..f6b5a5419 100644 --- a/install.py +++ b/install.py @@ -5608,7 +5608,13 @@ def _print_plan( print(line) else: largest = sorted(by_system, key=lambda s: (-_bytes(by_system[s]), s)) - print(f" {_plural(len(largest), 'system')}, the largest first:") + # A file several systems share is a group of its own: "88 systems" + # were 66 systems, as the full screen counts them, and 22 groups. + systems = {name for entry in to_download for name in entry_systems(entry)} + groups = (f"{_plural(len(systems), 'system')} in {len(largest)} groups" + if systems and len(systems) != len(largest) + else _plural(len(largest), 'system')) + print(f" {groups}, the largest first:") for system in largest[:PLAN_LARGEST_SYSTEMS]: print(f" {term.bold(system)} {_sized(by_system[system])}") if len(largest) > PLAN_LARGEST_SYSTEMS: @@ -6486,7 +6492,7 @@ INSTALLED_ONLY = "installed" def _prompt_custom_selection( plat: str, files: list[dict], omitted: list[dict], installed: tuple[list[dict], list[dict]] | None = None, - name: str = "", + name: str = "", dry_run: bool = False, ) -> tuple[list[str], list[str], list[str]] | str | None: """Offer to narrow the install by system, core and region. @@ -6496,7 +6502,9 @@ def _prompt_custom_selection( everything on its axis. """ print(f"\n{name or display_name(plat)}: {_sized(files)}.") - print(" Enter) install everything") + # A dry run installs nothing: "install everything" was asked before it. + print(" Enter) list everything, nothing is written" if dry_run + 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") @@ -9610,7 +9618,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: ) selection = _prompt_custom_selection( plat_name, files, omitted_files, installed, - display_name(plat_name, manifest), + display_name(plat_name, manifest), args.dry_run, ) if selection is None: smaller = (" Nothing downloaded. To install less, run the same command " diff --git a/tests/test_install_experience.py b/tests/test_install_experience.py index 16c384161..9b858c365 100644 --- a/tests/test_install_experience.py +++ b/tests/test_install_experience.py @@ -1191,6 +1191,33 @@ class OfflineWithNothingFound(unittest.TestCase): self.assertIn("Use --platform and --dest", said) +class TheDryRunWords(unittest.TestCase): + """The FAQ's dry run asked "Enter) install everything" before a run + that installs nothing, then counted 88 systems where the full screen + lists 66: a shared file made a group of its own.""" + + def test_the_question_says_nothing_is_written(self): + out = io.StringIO() + files = [{"dest": "a.bin", "size": 1, "systems": ["x"]}] + with mock.patch.object(install, "_ask", return_value=""), \ + contextlib.redirect_stdout(out): + install._prompt_custom_selection("retroarch", files, [], None, "RetroArch", + dry_run=True) + self.assertNotIn("install everything", out.getvalue()) + self.assertIn("nothing is written", out.getvalue()) + + def test_the_largest_systems_are_counted_as_systems(self): + files = [{"dest": f"{n}.bin", "size": n + 1, "systems": [f"s{n}"]} + for n in range(12)] + files.append({"dest": "shared.bin", "size": 1, "systems": ["s0", "s1"]}) + out = io.StringIO() + with mock.patch.object(install.shutil, "get_terminal_size", + return_value=os.terminal_size((80, 10))), \ + contextlib.redirect_stdout(out): + install._print_plan(files, Path("dest"), install.Terminal(io.StringIO())) + self.assertIn("12 systems in 13 groups, the largest first", out.getvalue()) + + def _source_of(function) -> str: import inspect From 7e96e12a4fb18c405901bfcd124e55dac17e70cc Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:08:55 +0100 Subject: [PATCH 18/27] fix: say nothing was found on the first screen --- install.py | 9 ++++++++- tests/test_install_tui.py | 19 +++++++++++++++++++ wiki/first-install.md | 2 +- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/install.py b/install.py index f6b5a5419..268b691a0 100644 --- a/install.py +++ b/install.py @@ -12137,10 +12137,17 @@ def _view_platforms(state: TuiState, width: int, height: int) -> tuple: else: title = "Which setup?" before = ["Choose the setup that will use the BIOS files."] + pinned = list(state.refused) + 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.") + before = [] needle = state.filters.get("platforms", "") after = [f"No setup matches '{needle}'."] if len(rows) == 1 and needle else [] after += [state.status] if state.status else [] - return (title, _compose(state, width, height, before, rows, after, pinned=state.refused), + return (title, _compose(state, width, height, before, rows, after, pinned=pinned), ("A/Enter Choose", "B/Esc Back", "/ Filter", "? Help")) diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 99e125baf..b3c586710 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -3908,6 +3908,25 @@ class TheFolderScreenSaysWhatItOffers(unittest.TestCase): self.assertEqual(rows[state.focus["folder"]].action, "browse") +class NothingFoundIsSaid(unittest.TestCase): + """With nothing detected the first screen was the list of 18 setups, + with no line at 40x15 saying nothing was found, which the beginner's + page names.""" + + def test_at_every_size(self): + with mock.patch.object(install, "detect_platforms", return_value=[]), \ + mock.patch.object(install, "detect_frontends", return_value=[]), \ + mock.patch.object(install, "_mounted_cards", return_value=[]): + state, _effects = install.tui_start(_args(), "linux") + self.assertEqual(state.screen, "platforms") + for cols, rows in ((40, 12), (40, 15), (80, 24)): + with self.subTest(size=f"{cols}x{rows}"): + screen = " ".join(" ".join(install.tui_view(state, cols, rows)).split()) + self.assertIn("Nothing was found here", screen) + page = (REPO_ROOT / "wiki" / "first-install.md").read_text(encoding="utf-8") + self.assertIn("Nothing was found here", page) + + class AFocusesNoSystemFolder(unittest.TestCase): """At / A went into bin/, and at a drive root holding $RECYCLE.BIN into the recycle bin, which Use this folder then took as the BIOS folder.""" diff --git a/wiki/first-install.md b/wiki/first-install.md index ef62456af..1a8b2cf53 100644 --- a/wiki/first-install.md +++ b/wiki/first-install.md @@ -134,7 +134,7 @@ The result says how to restore them. ### Nothing was found here. -Choose **Another setup or folder**, then your setup and its folder. +The screen lists every setup: choose yours, then its folder. ### Error: Python 3.8 or newer is required. From 381ec6de69e2c369fd898338c73d2d04b54fa266 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:09:53 +0100 Subject: [PATCH 19/27] fix: say why the plan's numbers differ --- install.py | 10 ++++++++-- tests/test_install_tui.py | 13 +++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/install.py b/install.py index 268b691a0..d5732b4f7 100644 --- a/install.py +++ b/install.py @@ -12044,8 +12044,14 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: ] if state.facts.get("writable") is False: lines.append("You cannot write to this folder: choose another one.") - lines.append(f"{check['present']} in place, {check['missing']} missing, " - f"{wrong} wrong {'copy' if wrong == 1 else 'copies'}") + counts = (f"{check['present']} in place, {check['missing']} missing, " + f"{wrong} wrong {'copy' if wrong == 1 else 'copies'}") + if copied and fetch: + # 47 to download above 49 missing and 1 wrong copy: the 3 found on + # this machine are the difference, said where the sum is read. + counts += (f": {len(copied)} of them {'is' if len(copied) == 1 else 'are'} " + f"copied from this machine, {fetch} downloaded") + lines.append(counts) saves = (state.saves if check is state.check else state.dry_saves if check is state.dry else None) lines += [_styled(line, "path") for line in _saves_lines(state, saves)] diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index b3c586710..e3d8b2e65 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -3908,6 +3908,19 @@ class TheFolderScreenSaysWhatItOffers(unittest.TestCase): self.assertEqual(rows[state.focus["folder"]].action, "browse") +class ThePlanNumbersAddUp(unittest.TestCase): + """"47 files to download" sat above "49 missing, 1 wrong copy": the 3 + found on this machine were the difference, left to the reader.""" + + def test_the_copied_files_are_the_difference(self): + state = checked_state() + state.found = [found("scph5500.bin", str(SYSTEM_DIR), "one folder too deep")] + screen = " ".join(" ".join(install.tui_view(state, 120, 40)).split()) + self.assertIn("3 files to download", screen) + self.assertIn("1 in place, 3 missing, 1 wrong copy: 1 of them is copied from this " + "machine, 3 downloaded", screen) + + class NothingFoundIsSaid(unittest.TestCase): """With nothing detected the first screen was the list of 18 setups, with no line at 40x15 saying nothing was found, which the beginner's From e537a031d0d276c762bf6e21325995b3128305a7 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:11:02 +0100 Subject: [PATCH 20/27] fix: name missing game data apart from firmware --- install.py | 30 +++++++++++++++++++++++------- tests/test_install_tui.py | 19 +++++++++++++++++++ 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/install.py b/install.py index d5732b4f7..883ab3164 100644 --- a/install.py +++ b/install.py @@ -12559,14 +12559,30 @@ def _unavailable_lines(state: TuiState) -> list[str]: rows = {row["system"]: row for row in state.systems} def system(name: str) -> str: - return _tui_system_label(rows.get(name) or {"system": name}, titles) + label = _tui_system_label(rows.get(name) or {"system": name}, titles) + # An id no title names (jazz-jackrabbit-2) read as a raw key. + return label.replace("-", " ").title() if label == name else label - names = [f"{entry['dest']} ({system(entry['system'])})" if entry.get("system") - else entry["dest"] for entry in needed[:3]] - more = f" and {len(needed) - 3} more" if len(needed) > 3 else "" - return [f"Not available here, and needed to start " - f"{'its emulator' if len(needed) == 1 else 'their emulators'}: " - f"{', '.join(names)}{more}. Your own copy goes in the BIOS folder."] + def named(entries: list[dict]) -> str: + names = [f"{entry['dest']} ({system(entry['system'])})" if entry.get("system") + else entry["dest"] for entry in entries[:3]] + more = f" and {len(entries) - 3} more" if len(entries) > 3 else "" + return f"{', '.join(names)}{more}" + + # A game's own data is not firmware: on every RetroArch plan the one + # alarm named Jazz Jackrabbit 2's Anims.j2a, read as a broken install. + games = [entry for entry in needed if entry.get("category") == GAME_DATA] + firmware = [entry for entry in needed if entry.get("category") != GAME_DATA] + lines = [] + if firmware: + lines.append(f"Not available here, and needed to start " + f"{'its emulator' if len(firmware) == 1 else 'their emulators'}: " + f"{named(firmware)}. Your own copy goes in the BIOS folder.") + if games: + lines.append(f"Game data not available here, read by " + f"{'its game' if len(games) == 1 else 'their games'}: " + f"{named(games)}. Copy it from your own copy of the game.") + return lines def tui_outcome(state: TuiState) -> tuple[str, list[str]]: diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index e3d8b2e65..740dba734 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -3921,6 +3921,25 @@ class ThePlanNumbersAddUp(unittest.TestCase): "machine, 3 downloaded", screen) +class GameDataIsNoFirmwareAlarm(unittest.TestCase): + """Every RetroArch plan named jazz2/Source/Anims.j2a (jazz-jackrabbit-2) + as the file needed to start an emulator: a game's data, under a raw id.""" + + def test_game_data_has_its_own_line(self): + state = checked_state() + state.systems = [] + state.manifest = {**MANIFEST, "omitted_files": [ + {"dest": "jazz2/Source/Anims.j2a", "system": "jazz-jackrabbit-2", + "required": True, "category": "game_data"}, + {"dest": "missing.bin", "system": "sony-playstation", "required": True}]} + lines = install._unavailable_lines(state) + self.assertEqual(len(lines), 2, lines) + self.assertIn("needed to start its emulator: missing.bin", lines[0]) + self.assertNotIn("Anims.j2a", lines[0]) + self.assertIn("Game data not available here", lines[1]) + self.assertIn("Anims.j2a (Jazz Jackrabbit 2)", lines[1]) + + class NothingFoundIsSaid(unittest.TestCase): """With nothing detected the first screen was the list of 18 setups, with no line at 40x15 saying nothing was found, which the beginner's From 9181b774268180ed4b493ddd45f52e7470fc0ce6 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:11:47 +0100 Subject: [PATCH 21/27] fix: put back only the font a firmware restores --- ports/RetroBIOS/RetroBIOS.sh | 2 ++ ports/shared/tui.txt | 11 ++++++++--- ports/tools/RetroBIOS.sh | 2 ++ tests/test_handheld_launchers.py | 16 +++++++++++++--- 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/ports/RetroBIOS/RetroBIOS.sh b/ports/RetroBIOS/RetroBIOS.sh index 24610f480..4eccb5ac0 100755 --- a/ports/RetroBIOS/RetroBIOS.sh +++ b/ports/RetroBIOS/RetroBIOS.sh @@ -60,6 +60,8 @@ if [ -e "${RB_ARK_MARKER:-/usr/share/plymouth/themes/text.plymouth}" ]; then RB_BIOS_DEST=$(awk -F'"' '/^system_directory *=/ { print $2; exit }' \ "${RB_RETROARCH_CFG:-/home/ark/.config/retroarch/retroarch.cfg}" 2>/dev/null) RB_BIOS_DEST="${RB_BIOS_DEST:-/$directory/bios}" + # The font ArkOS's own tools put back on leaving (dArkOS_Tools/Wifi.sh). + RB_FONT_DEFAULT="Lat7-Terminus20x10" fi # shellcheck source=../shared/tui.txt source "$GAMEDIR/tui.txt" diff --git a/ports/shared/tui.txt b/ports/shared/tui.txt index f01e3cc54..b8350fbe1 100644 --- a/ports/shared/tui.txt +++ b/ports/shared/tui.txt @@ -30,6 +30,9 @@ # RB_BIOS_DEST the one of them the firmware reads, never offered as the # other card # RB_FALLBACK 1 when the launcher has screens of its own to fall back to +# RB_FONT_DEFAULT the console font the firmware itself puts back, by +# name (ArkOS: Lat7-Terminus20x10), for a setfont that +# cannot save the one in place. Unset, the font is kept. # RB_PAD the input route, exported to the interface as # RETROBIOS_PAD: gptokeyb (retrobios.gptk, the default when # RB_MAPPER is set), muxterm (muxterm's own keys), evmapy @@ -493,8 +496,9 @@ rb_route_ready() { # The largest Terminus font that keeps 56 columns and 20 rows, the way # dArkOS's own tools pick one per screen (Lat7-Terminus16 on 480x320, # TerminusBold22x11 on 640x480, 32x16 on 1024x768). Only changed when the -# font in place can be put back: saved with setfont -O, or the -# Lat7-Terminus20x10 ArkOS tools restore. +# font in place can be put back: saved with setfont -O, or RB_FONT_DEFAULT, +# the one the firmware's own tools restore. A fixed fallback gave a device +# whose default differs another font after the run. rb_font_set() { command -v setfont >/dev/null 2>&1 || return 0 local size width height spec fh fw name file="" fallback @@ -524,7 +528,8 @@ rb_font_set() { RB_FONT_BACK="$RB_STATE/font.psf" return 0 fi - fallback="$RB_FONTS/Lat7-Terminus20x10.psf.gz" + [ -n "${RB_FONT_DEFAULT:-}" ] || return 0 + fallback="$RB_FONTS/$RB_FONT_DEFAULT.psf.gz" if [ -f "$fallback" ] && $ESUDO setfont -C "$RB_TTY" "$file" >> "$RB_LOG" 2>&1; then RB_FONT_BACK="$fallback" fi diff --git a/ports/tools/RetroBIOS.sh b/ports/tools/RetroBIOS.sh index b3bc34854..15e278284 100755 --- a/ports/tools/RetroBIOS.sh +++ b/ports/tools/RetroBIOS.sh @@ -40,6 +40,8 @@ if [ -d "$ROOT/opt/system" ] && [ -d "$ROOT/opt/inttools" ]; then fi RB_ROUTES="vt" RB_CONSOLE="${RB_CONSOLE:-/dev/tty1}" + # The font ArkOS's own tools put back on leaving (dArkOS_Tools/Wifi.sh). + RB_FONT_DEFAULT="Lat7-Terminus20x10" RB_PACK_PATH="$RB_DIR:$HERE:/roms:/roms2" # The second card is /roms2, and "Switch to SD2 for Roms" moves RetroArch's # system folder there (dArkOS scripts/Switch to SD2 for Roms.sh:91-92): the diff --git a/tests/test_handheld_launchers.py b/tests/test_handheld_launchers.py index 93351f06c..1427af7ac 100644 --- a/tests/test_handheld_launchers.py +++ b/tests/test_handheld_launchers.py @@ -1334,13 +1334,15 @@ class ThePortMasterPort(unittest.TestCase): device.assert_console_restored() def test_the_font_is_put_back_without_setfont_o(self): - """busybox setfont has no -O: the font is changed only when the - Lat7-Terminus20x10 ArkOS tools restore exists, and that one is put + """busybox setfont has no -O: on ArkOS the font is changed only when + the Lat7-Terminus20x10 its tools restore exists, and that one is put back.""" device = self.device(FAKE_NO_SAVE="1") fonts = device.env["RB_FONTS"] (Path(fonts) / "Lat7-Terminus20x10.psf.gz").write_bytes(b"") - device.run(["bash", self.script]) + marker = device.root / "text.plymouth" + marker.write_text("") + device.run(["bash", self.script], RB_ARK_MARKER=str(marker)) chosen = f"{fonts}/Lat7-TerminusBold22x11.psf.gz" saved = str(self.gamedir / "run" / "font.psf") self.assertEqual(device.tools(), [ @@ -1349,6 +1351,14 @@ class ThePortMasterPort(unittest.TestCase): f"setfont -C {device.console} {fonts}/Lat7-Terminus20x10.psf.gz", ]) + def test_elsewhere_a_font_that_cannot_be_saved_is_left(self): + """Lat7-Terminus20x10 is ArkOS's font: put back on another device, + it left the console in a font it never had.""" + device = self.device(FAKE_NO_SAVE="1") + (Path(device.env["RB_FONTS"]) / "Lat7-Terminus20x10.psf.gz").write_bytes(b"") + device.run(["bash", self.script]) + self.assertEqual(len([t for t in device.tools() if t.startswith("setfont")]), 1) + def test_without_the_fallback_font_the_font_is_left(self): device = self.device(FAKE_NO_SAVE="1") device.run(["bash", self.script]) From fad5b10665f447960e0d3d16922c32894dab73e9 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:14:24 +0100 Subject: [PATCH 22/27] feat: ship the muos glyph the app header names --- ports/muos/glyph/retrobios.png | Bin 0 -> 1354 bytes scripts/build_port.py | 4 ++++ tests/test_handheld_launchers.py | 22 ++++++++++++++++++++++ 3 files changed, 26 insertions(+) create mode 100644 ports/muos/glyph/retrobios.png diff --git a/ports/muos/glyph/retrobios.png b/ports/muos/glyph/retrobios.png new file mode 100644 index 0000000000000000000000000000000000000000..e869124c65889f2807aa5afdae6735df5c4d276b GIT binary patch literal 1354 zcmV-Q1-1H#P)-y&UV=i@nuv+fNc6SF2OmWGQetY$n*<;9 zNhL-TGz1e9lm~^jyK9An5H2{^x=d`}JDNV?h`MFpy z6grwP)3YL4Y|!gFup2l(Cq6rU2RenU)>CpsQRvU0DyMVXP)-Y3ji>ammEIizAWR)M zYyimJGJr{46g4JtzNGhNt@Q2)0JCdpqtBFVU(49!JcpF<{4onXQzBLaUf=tB35D0t z(!;uuNM?lFfP)+P6f!Y=x*O-&a5dzmTp%ph^?u_Fz6=icfB&xmcsqK!DDV@oj>H*x zF)-Bk9hd{?r7i6#A7g1sslo`bHxc<8Vx;P!P1q%nT28{kwOMf2R)DKwgVYL7{Dnu% zz*b!*`E)D}Z1J`^01ijc+=;A>z@Tjy?J{`{TiTNQ(8kvo$zrPb_Y3(0ds?7r-;;6y z#!xtL3?ALT7fL- zPO}^+JVWsjCy^yNp2u^}EUhE7S`gp_Bj|rbiHs-&KZi?Y8 zfRlvs3t1a1>G(Jp>|*`FD? z3`?^&3&uGv>Dsy(h)t0BLNEwlv}Cr( z-AqJ!5EfQLC=xd1T|Q8&GSx7ONv6KO7Mfq*E&0gQ91Qn;0nZ+N7ZkfHQ}^HppTVu) z|CHk`?LF-)tD#I+nQF+-ig%r8QPX1uKwdAM#4S-Aw%l$U4j_smjmx>{Z`Enn4*>4V zw}I90dAWwJYg!G>DDNSL=@q@o@(LFAR~96mD^{k}aLotFN}}a%P+Sh#Pt7laimA8! z{yNFWBT-nI3q$#qdQgQ6@Gm?zodLo5ryS^4D_!t^d6Joc$UaK(VWf+6H*t~d%1{&U z7fI+c2g+P1TaZ~lL#28m_b84qAzDxE4&S_~&OC3v7^*lao z!r6HmgSYUJQ%RY2)k@EbiaulS2K@s70RR8yAmT*;000I_L_t&o0N#3dE)u`Ca{vGU M07*qoM6N<$f}y8&e*gdg literal 0 HcmV?d00001 diff --git a/scripts/build_port.py b/scripts/build_port.py index b8ca5d671..6259433d8 100644 --- a/scripts/build_port.py +++ b/scripts/build_port.py @@ -109,6 +109,10 @@ def archive_members(name: str, root: Path = REPO_ROOT) -> list[tuple[str, Path | members = [ ("RetroBIOS/mux_launch.sh", source / "mux_launch.sh"), ("RetroBIOS/README.md", source / "README.md"), + # muOS draws an application's list glyph from glyph/.png + # in its own folder (apply_app_glyph, frontend common/ui/glyph.c): + # `# ICON: retrobios` resolved to nothing without it. + ("RetroBIOS/glyph/retrobios.png", source / "glyph" / "retrobios.png"), *_engine("RetroBIOS", root), ] elif name == TOOLS_ZIP: diff --git a/tests/test_handheld_launchers.py b/tests/test_handheld_launchers.py index 1427af7ac..3db152f52 100644 --- a/tests/test_handheld_launchers.py +++ b/tests/test_handheld_launchers.py @@ -309,6 +309,28 @@ class TheArchives(unittest.TestCase): self.assertEqual(top, []) self.assertIn("RetroBIOS/retrobios.gptk", names) + def test_the_muos_icon_is_the_glyph_its_header_names(self): + """muOS reads glyph/.png in the application's folder, and the + muxapp named an icon it did not carry.""" + header = (REPO_ROOT / "ports" / "muos" / "mux_launch.sh").read_text() + icon = re.search(r"^# ICON: (\S+)$", header, re.MULTILINE).group(1) + name = build_port.MUOS_APP + with zipfile.ZipFile(build_port.build_archive(name, self.out / name)) as zf: + data = zf.read(f"RetroBIOS/glyph/{icon}.png") + self.assertEqual(data[:8], b"\x89PNG\r\n\x1a\n") + + def test_no_tools_support_file_is_a_menu_entry(self): + """EmulationStation lists a folder only when it holds a file of the + system's extension (.sh for Tools and Ports): batocera-emulationstation + and EmulationStation-fcamod SystemData.cpp drop a folder with none. + The support folders must keep none, or each shows up in the menu.""" + for name in (build_port.TOOLS_ZIP, build_port.PORT_ZIP, build_port.BATOCERA_ZIP): + with self.subTest(archive=name): + with zipfile.ZipFile(build_port.build_archive(name, self.out / name)) as zf: + inside = [n for n in zf.namelist() + if "/" in n and n.lower().endswith(".sh")] + self.assertEqual(inside, []) + def test_each_carries_the_pinned_installer(self): pinned = build_port.pinned_installer_sha256() for name, (_script, folder) in self.LAYOUT.items(): From ae3faec86bee6b5d4b91d0ba7f554c148eb22f2c Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:18:30 +0100 Subject: [PATCH 23/27] fix: help names only keys the route sends --- install.py | 15 ++++++++++--- tests/fixtures/tui/route-evmapy.txt | 6 ++--- tests/fixtures/tui/route-gptokeyb.txt | 8 +++---- tests/fixtures/tui/route-gptokeyb_muxterm.txt | 8 +++---- tests/fixtures/tui/route-miyoo.txt | 8 +++---- tests/fixtures/tui/route-muxterm.txt | 6 ++--- tests/test_install_tui.py | 22 +++++++++++++++++++ 7 files changed, 52 insertions(+), 21 deletions(-) diff --git a/install.py b/install.py index 883ab3164..715771444 100644 --- a/install.py +++ b/install.py @@ -12758,7 +12758,7 @@ def _help_lines(state: TuiState) -> list[tuple[str, str]]: if screen == "installing": return [(names["stop"], "Stop: what is done stays done"), *([(names["kill"], "Stop")] if names["kill"] else []), - *_route_extras(state), ("?, F1", "This help")] + *_route_extras(state), *_help_key(state)] if screen == "path": return [(names["enter"], "Use this folder"), ("Backspace", "Delete a character"), ("Ctrl-U", "Clear"), (names["esc"], "Back"), ("Ctrl-C", "Quit"), @@ -12786,10 +12786,16 @@ def _help_lines(state: TuiState) -> list[tuple[str, str]]: keys.append((names["options"], "Options")) back = "Quit" if len(state.stack) == 1 else "Back" keys += [(names["back"], back), ("q, Ctrl-C", "Quit"), *kill, *_route_extras(state), - ("?, F1", "This help")] + *_help_key(state)] return keys +def _help_key(state: TuiState) -> list[tuple[str, str]]: + """? and F1, named only where a keyboard may send them: no pad route + maps a button to either.""" + return [("?, F1", "This help")] if state.pad in ("both", "keys") else [] + + def _primary(state: TuiState) -> str | None: """What A does on the row in focus, for the first entry of the bar.""" if state.screen in _PAGERS: @@ -12805,7 +12811,10 @@ def _primary(state: TuiState) -> str | None: def _view_help(state: TuiState, width: int, height: int) -> tuple: keys = _help_lines(state) column = min(22, max(cell_width(key) for key, _what in keys) + 2) - lines = [f"{_pad(key, column)}{what}" for key, what in keys] + # 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] if state.screen in ("setup", "plan", "browse", "folder") and state.dest is not None: lines += ["", f"BIOS folder: {state.dest}"] return ("Keys", _compose(state, width, height, lines, []), ("Any key closes this help",)) diff --git a/tests/fixtures/tui/route-evmapy.txt b/tests/fixtures/tui/route-evmapy.txt index c9f54b1b5..0a1bcb769 100644 --- a/tests/fixtures/tui/route-evmapy.txt +++ b/tests/fixtures/tui/route-evmapy.txt @@ -1140,10 +1140,10 @@ Y, Tab, o Options B, Select, Esc Back q, Ctrl-C Quit - ?, F1 This help BIOS folder: /home/user/.config/retroarch/system + Any key closes this help === help 40x15 RetroBIOS: Keys @@ -1153,13 +1153,13 @@ Y, Tab, o Options B, Select, Esc Back q, Ctrl-C Quit - ?, F1 This help BIOS folder: /home/user/.config/retroarch/system + Any key closes this help === help 80x24 RetroBIOS: Keys @@ -1169,7 +1169,6 @@ Y, Tab, o Options B, Select, Esc Back q, Ctrl-C Quit - ?, F1 This help BIOS folder: /home/user/.config/retroarch/system @@ -1185,4 +1184,5 @@ + Any key closes this help diff --git a/tests/fixtures/tui/route-gptokeyb.txt b/tests/fixtures/tui/route-gptokeyb.txt index 9812144e8..133c20d34 100644 --- a/tests/fixtures/tui/route-gptokeyb.txt +++ b/tests/fixtures/tui/route-gptokeyb.txt @@ -1141,9 +1141,9 @@ B, Select, Esc Back q, Ctrl-C Quit Select, then Start Quit at once - ?, F1 This help - + BIOS folder: + /home/user/.config/retroarch/system Any key closes this help === help 40x15 RetroBIOS: Keys @@ -1154,12 +1154,12 @@ B, Select, Esc Back q, Ctrl-C Quit Select, then Start Quit at once - ?, F1 This help BIOS folder: /home/user/.config/retroarch/system + Any key closes this help === help 80x24 RetroBIOS: Keys @@ -1170,7 +1170,6 @@ B, Select, Esc Back q, Ctrl-C Quit Select, then Start Quit at once - ?, F1 This help BIOS folder: /home/user/.config/retroarch/system @@ -1185,4 +1184,5 @@ + Any key closes this help diff --git a/tests/fixtures/tui/route-gptokeyb_muxterm.txt b/tests/fixtures/tui/route-gptokeyb_muxterm.txt index 9812144e8..133c20d34 100644 --- a/tests/fixtures/tui/route-gptokeyb_muxterm.txt +++ b/tests/fixtures/tui/route-gptokeyb_muxterm.txt @@ -1141,9 +1141,9 @@ B, Select, Esc Back q, Ctrl-C Quit Select, then Start Quit at once - ?, F1 This help - + BIOS folder: + /home/user/.config/retroarch/system Any key closes this help === help 40x15 RetroBIOS: Keys @@ -1154,12 +1154,12 @@ B, Select, Esc Back q, Ctrl-C Quit Select, then Start Quit at once - ?, F1 This help BIOS folder: /home/user/.config/retroarch/system + Any key closes this help === help 80x24 RetroBIOS: Keys @@ -1170,7 +1170,6 @@ B, Select, Esc Back q, Ctrl-C Quit Select, then Start Quit at once - ?, F1 This help BIOS folder: /home/user/.config/retroarch/system @@ -1185,4 +1184,5 @@ + Any key closes this help diff --git a/tests/fixtures/tui/route-miyoo.txt b/tests/fixtures/tui/route-miyoo.txt index faaed9394..65bcc4bcf 100644 --- a/tests/fixtures/tui/route-miyoo.txt +++ b/tests/fixtures/tui/route-miyoo.txt @@ -1141,9 +1141,9 @@ R2, Esc, Backspace Back q, Ctrl-C Quit B Sends no key here - ?, F1 This help - + BIOS folder: + /home/user/.config/retroarch/system Any key closes this help === help 40x15 RetroBIOS: Keys @@ -1154,12 +1154,12 @@ R2, Esc, Backspace Back q, Ctrl-C Quit B Sends no key here - ?, F1 This help BIOS folder: /home/user/.config/retroarch/system + Any key closes this help === help 80x24 RetroBIOS: Keys @@ -1170,7 +1170,6 @@ R2, Esc, Backspace Back q, Ctrl-C Quit B Sends no key here - ?, F1 This help BIOS folder: /home/user/.config/retroarch/system @@ -1185,4 +1184,5 @@ + Any key closes this help diff --git a/tests/fixtures/tui/route-muxterm.txt b/tests/fixtures/tui/route-muxterm.txt index 8394f7eaf..f381ab561 100644 --- a/tests/fixtures/tui/route-muxterm.txt +++ b/tests/fixtures/tui/route-muxterm.txt @@ -1142,7 +1142,7 @@ q, Ctrl-C Quit Select Keyboard on or off Menu Close the terminal - ?, F1 This help + Any key closes this help === help 40x15 @@ -1155,11 +1155,11 @@ q, Ctrl-C Quit Select Keyboard on or off Menu Close the terminal - ?, F1 This help BIOS folder: /home/user/.config/retroarch/system + Any key closes this help === help 80x24 RetroBIOS: Keys @@ -1171,7 +1171,6 @@ q, Ctrl-C Quit Select Keyboard on or off Menu Close the terminal - ?, F1 This help BIOS folder: /home/user/.config/retroarch/system @@ -1185,4 +1184,5 @@ + Any key closes this help diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 740dba734..1d5dd4086 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -3940,6 +3940,28 @@ class GameDataIsNoFirmwareAlarm(unittest.TestCase): self.assertIn("Anims.j2a (Jazz Jackrabbit 2)", lines[1]) +class TheHelpNamesKeysThatCome(unittest.TestCase): + """Every route's help listed "?, F1 This help", which no pad sends, and + a key as wide as the column touched its text.""" + + def test_pad_routes_name_no_help_key(self): + for pad in ROUTES: + with self.subTest(route=pad): + state = checked_state() + state.pad = pad + keys = [key for key, _what in install._help_lines(state)] + self.assertEqual("?, F1" in keys, pad == "keys") + + def test_a_wide_key_keeps_two_spaces(self): + state = checked_state() + state.help = True + with mock.patch.object(install, "_help_lines", + return_value=[("A, Start, Enter, Space, x", "Choose"), + ("q", "Quit")]): + lines = install._view_help(state, 80, 24)[1] + self.assertTrue(any("A, Start, Enter, Space, x Choose" in line for line in lines)) + + class NothingFoundIsSaid(unittest.TestCase): """With nothing detected the first screen was the list of 18 setups, with no line at 40x15 saying nothing was found, which the beginner's From ad60a88deba9abb39cd5d9be075860e9e57cc70d Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:19:26 +0100 Subject: [PATCH 24/27] fix: open the copy browser at a zip in downloads --- install.py | 13 +++++++++++++ tests/test_install_tui.py | 19 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/install.py b/install.py index 715771444..2a3e78e73 100644 --- a/install.py +++ b/install.py @@ -11347,10 +11347,23 @@ def _open_from_browser(state: TuiState) -> list[tuple]: state.browse_for = "from" _push(state, "browse") start = state.from_folders[-1].parent if state.from_folders else state.home + downloads = state.home / "Downloads" + if not state.from_folders and _is_dir(downloads) and any( + name.lower().endswith(".zip") for name in _listed(downloads)): + # A pack or a backup ZIP is most often there: 18 presses from the + # first screen reached it through HOME, past every mounted drive. + start = downloads _browse_to(state, _nearest_folder(start)) return [] +def _listed(folder: Path) -> list[str]: + try: + return os.listdir(folder) + except OSError: + return [] + + def _toggle_region(state: TuiState, name: str) -> None: if name in state.regions: state.regions.remove(name) diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 1d5dd4086..4c8f1e9c1 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -3962,6 +3962,25 @@ class TheHelpNamesKeysThatCome(unittest.TestCase): self.assertTrue(any("A, Start, Enter, Space, x Choose" in line for line in lines)) +class CopyFromStartsInDownloads(unittest.TestCase): + """A ZIP in Downloads took 18 presses from the first screen: the copy + browser opened at HOME, under every mounted drive.""" + + def test_a_zip_in_downloads_opens_there(self): + home = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, home, True) + (home / "Downloads").mkdir() + state = checked_state() + state.home = home + with mock.patch.object(install, "_mounted_cards", return_value=[]): + install._open_from_browser(state) + self.assertEqual(state.browse_dir, home) + state.stack.pop() + (home / "Downloads" / "PSX BIOS.zip").write_bytes(b"") + install._open_from_browser(state) + self.assertEqual(state.browse_dir, home / "Downloads") + + class NothingFoundIsSaid(unittest.TestCase): """With nothing detected the first screen was the list of 18 setups, with no line at 40x15 saying nothing was found, which the beginner's From e1f8b5af368f7ffa558d343b64db27734c73dc75 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:19:33 +0100 Subject: [PATCH 25/27] chore: pin the current installer in the bootstraps --- install.ps1 | 2 +- install.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/install.ps1 b/install.ps1 index 6e90563be..eb5874971 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "39d4fba29587c16931a85033319ad785b9f37deaf806ad31e37eed48bfb6d178" + $defaultInstallSha256 = "6a20ca4df2ca1ce3fb219b9a7610421b3850aecd0581587bdea207992396b16f" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.sh b/install.sh index 4e10d5dd8..84d74374b 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="39d4fba29587c16931a85033319ad785b9f37deaf806ad31e37eed48bfb6d178" +DEFAULT_INSTALL_SHA256="6a20ca4df2ca1ce3fb219b9a7610421b3850aecd0581587bdea207992396b16f" MAX_INSTALLER_BYTES=2097152 cleanup() { From b7896433ec9775a71e1c978a8c177a175ff94172 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:33:10 +0100 Subject: [PATCH 26/27] chore: list the current launcher files for cards --- install/launchers/index.json | 39 +++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/install/launchers/index.json b/install/launchers/index.json index 968e4f42f..2e79cfd2e 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": 611006, - "sha256": "cec348d9aa6c9f940a2c9dc9bc6414a923acea8137e4a3624d4710c9d88c38ad", + "size": 622744, + "sha256": "c63a84da36e10657838dc359f4258db75e098d4902ee27312296ccd22a6994f4", "executable": false }, { @@ -62,8 +62,8 @@ { "dest": "roms/ports/retrobios/tui.txt", "repo_path": "ports/shared/tui.txt", - "size": 34998, - "sha256": "6e22292fc3f0688e0509bebd55f242fb3eecf9e149d5dc365ea75447e921bf78", + "size": 35356, + "sha256": "d2d56e2c67f0a5d0d11fc75e28f337516006cc4414e18662e100359caa642a97", "executable": false } ] @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 661660, - "sha256": "681ae3d8eb8572640b57dd4ca8699cb2cb8f011d1071ba5ef286c1b2eeb1877d" + "size": 675244, + "sha256": "447a2eb9863dd1dad2108d22132bbb8d553adca5ebf1b2bddfbce698df42cf3f" }, "members": [ { @@ -82,11 +82,18 @@ "sha256": "c0cfafad5fae8e6764b263b14a06195b267f66c4fe042333941ae6bcc6dab265", "executable": false }, + { + "name": "RetroBIOS/glyph/retrobios.png", + "repo_path": "ports/muos/glyph/retrobios.png", + "size": 1354, + "sha256": "93d0d1248a3bb78d1222b113276137de971a33468da72b91f79248100f81cc72", + "executable": false + }, { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 611006, - "sha256": "cec348d9aa6c9f940a2c9dc9bc6414a923acea8137e4a3624d4710c9d88c38ad", + "size": 622744, + "sha256": "c63a84da36e10657838dc359f4258db75e098d4902ee27312296ccd22a6994f4", "executable": false }, { @@ -134,8 +141,8 @@ { "name": "RetroBIOS/tui.txt", "repo_path": "ports/shared/tui.txt", - "size": 34998, - "sha256": "6e22292fc3f0688e0509bebd55f242fb3eecf9e149d5dc365ea75447e921bf78", + "size": 35356, + "sha256": "d2d56e2c67f0a5d0d11fc75e28f337516006cc4414e18662e100359caa642a97", "executable": false } ] @@ -145,8 +152,8 @@ { "dest": ".config/modules/RetroBIOS.sh", "repo_path": "ports/tools/RetroBIOS.sh", - "size": 4732, - "sha256": "556c83159111a2071097a38590e3335aea90f76201473e064ba8365882fb2a6f", + "size": 4846, + "sha256": "bf5aabfe4f73d39cb35bdc2868b18850fc71154ed53f43aabf582e8870f7b227", "executable": true }, { @@ -159,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 611006, - "sha256": "cec348d9aa6c9f940a2c9dc9bc6414a923acea8137e4a3624d4710c9d88c38ad", + "size": 622744, + "sha256": "c63a84da36e10657838dc359f4258db75e098d4902ee27312296ccd22a6994f4", "executable": false }, { @@ -201,8 +208,8 @@ { "dest": ".config/modules/retrobios/tui.txt", "repo_path": "ports/shared/tui.txt", - "size": 34998, - "sha256": "6e22292fc3f0688e0509bebd55f242fb3eecf9e149d5dc365ea75447e921bf78", + "size": 35356, + "sha256": "d2d56e2c67f0a5d0d11fc75e28f337516006cc4414e18662e100359caa642a97", "executable": false } ] From 075ebae349281e11da2b20ea8e822a20ccfca495 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:41:09 +0100 Subject: [PATCH 27/27] docs: keep the setup row in the nothing-found step --- wiki/first-install.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/wiki/first-install.md b/wiki/first-install.md index 1a8b2cf53..c86875d18 100644 --- a/wiki/first-install.md +++ b/wiki/first-install.md @@ -134,7 +134,8 @@ The result says how to restore them. ### Nothing was found here. -The screen lists every setup: choose yours, then its folder. +Choose your setup in the list, then its folder. Where the screen offers +**Another setup or folder**, choose it first. ### Error: Python 3.8 or newer is required.