From 2f8416e5fed69a4eb262d22b124689fba162963d Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:03:38 +0200 Subject: [PATCH 01/13] fix: report a file under a folder no one can enter Path.exists() lets PermissionError through, so a BIOS subfolder without search permission stopped --check and installs before any file. The file is now reported unreadable and the run goes on with the others. --- install.py | 84 ++++++++++++++++++++++++-------- tests/test_install_experience.py | 59 ++++++++++++++++++++++ 2 files changed, 123 insertions(+), 20 deletions(-) diff --git a/install.py b/install.py index 9a8d3020..01a1b03c 100644 --- a/install.py +++ b/install.py @@ -696,9 +696,9 @@ def _declared_state(copy: dict, entry: dict, bios_path: Path) -> str: path = _entry_path(bios_path, entry) except ValueError: return "missing" - if not path.is_file(): - return "missing" if not path.exists() else "wrong_hash" try: + if not path.is_file(): + return "missing" if not path.exists() else "wrong_hash" verdict = _classify_local(copy, path) except OSError: return "wrong_hash" @@ -1443,7 +1443,7 @@ def _rule_matches(rule: _OsRule, release: dict[str, str]) -> bool: except OSError: return False return re.search(pattern, text, re.IGNORECASE | re.MULTILINE) is not None - return all(_on_device(value).exists() for value in rule.values) + return all(_exists(_on_device(value)) for value in rule.values) def _config_files(pattern: str) -> list[tuple[Path, Path]]: @@ -4090,7 +4090,13 @@ def _classify_local(entry: dict, dest: Path) -> str: for name in ("sha256", "sha1") if entry.get(name) } - if not dest.is_file(): + try: + is_file = dest.is_file() + except OSError: + # A folder above it this user cannot enter: what sits there is not + # known to be the right file. + return "mismatched" + if not is_file: # A folder at the file's place: --check says so, an install fails # on that file alone. return "mismatched" @@ -4107,6 +4113,39 @@ def _classify_local(entry: dict, dest: Path) -> str: return "mismatched" +def _held(path: Path) -> bool: + """Whether something may sit at path. A place that cannot be looked at + (a folder above it this user cannot enter, a card that answers a read + error) counts as held, so the file is reported unreadable and the run + goes on with the others: Path.exists() lets PermissionError through.""" + try: + return path.exists() + except OSError: + return True + + +def _exists(path: Path) -> bool: + """Path.exists(), False where the place cannot be looked at.""" + try: + return path.exists() + except OSError: + return False + + +def _is_file(path: Path) -> bool: + """Path.is_file(), False where the place cannot be looked at.""" + try: + return path.is_file() + except OSError: + return False + + +def unreachable_words(exc: OSError) -> str: + """Why the place of a file could not be looked at, in words.""" + return (f"could not be read ({exc.strerror or exc}): this user may not " + f"have access to its folder") + + def check_local( files: list[dict], bios_path: Path, jobs: int = 8, term: "Terminal | None" = None, @@ -4122,7 +4161,7 @@ def check_local( for f in files: dest = _entry_path(bios_path, f) - if dest.exists(): + if _held(dest): present.append((f, dest)) else: to_download.append(f) @@ -4362,7 +4401,7 @@ def _move_aside(dest: Path, kept: Path) -> None: os.rename(dest, kept) return except OSError: - if not dest.is_file(): + if not _is_file(dest): raise scratch = kept.parent / f".{kept.name}.part" try: @@ -4497,7 +4536,11 @@ def _download_one( if _write_refused(exc): return f["dest"], False, NOT_WRITABLE return f["dest"], False, exc.strerror or str(exc) - if dest.is_dir(): + try: + in_place_folder = dest.is_dir() + except OSError as exc: + return f["dest"], False, unreachable_words(exc) + if in_place_folder: # Fetched, it failed only at the rename, on an errno and two paths. return f["dest"], False, FOLDER_IN_PLACE @@ -4752,7 +4795,7 @@ def _replace_released(source: Path, dest: Path, expected: dict[str, str]) -> Non os.replace(source, dest) return except FileNotFoundError: - if expected and dest.is_file() and _digest_file(dest, tuple(expected)) == expected: + if expected and _is_file(dest) and _digest_file(dest, tuple(expected)) == expected: return raise except PermissionError as exc: @@ -4771,7 +4814,7 @@ def _drop_full_partial(dest: Path, expected_size: int) -> None: left to it. """ path = dest.parent / f".{dest.name}.part" - if not path.is_file(): + if not _is_file(path): return try: handle = _claim_partial(path) @@ -4913,7 +4956,7 @@ def _clear_read_only(dest: Path) -> None: def _read_only_file(dest: Path) -> bool: - return dest.is_file() and not os.access(dest, os.W_OK) and _folder_writable(dest.parent) + return _is_file(dest) and not os.access(dest, os.W_OK) and _folder_writable(dest.parent) def _folder_writable(path: Path) -> bool: @@ -5017,7 +5060,7 @@ def _on_read_only_mount(path: Path) -> bool: if not hasattr(os, "statvfs"): return False probe = path - while not probe.exists() and probe.parent != probe: + while not _held(probe) and probe.parent != probe: probe = probe.parent try: return bool(os.statvfs(probe).f_flag & os.ST_RDONLY) @@ -5247,7 +5290,7 @@ def _absent_required(omitted: list[dict], bios_path: Path) -> list[dict]: if not entry.get("required", True) or not dest: continue try: - placed = _entry_path(bios_path, {**entry, "dest": dest}).is_file() + placed = _is_file(_entry_path(bios_path, {**entry, "dest": dest})) except ValueError: # A path climbing out of the folder is never one we read, and one # below a save directory this run does not know is not looked at. @@ -5576,11 +5619,12 @@ def data_directory_state(entry: dict, bios_path: Path) -> tuple[Path, dict[str, present = 0 for rel, meta in entry["files"].items(): target = _destination_path(root, rel) - if ( - target.is_file() - and target.stat().st_size == meta["size"] - and _sha1_file(target) == meta["sha1"] - ): + try: + held = (target.is_file() and target.stat().st_size == meta["size"] + and _sha1_file(target) == meta["sha1"]) + except OSError: + held = False + if held: present += 1 else: needed[rel] = meta @@ -7701,9 +7745,9 @@ def _wrong_contents( owners: dict[str, list[dict]] | None = None, ) -> str: """Why the file at an entry's place is not the one the list installs.""" - if path.is_dir(): - return FOLDER_IN_PLACE try: + if path.is_dir(): + return FOLDER_IN_PLACE size = path.stat().st_size with open(path, "rb") as handle: head = handle.read(4) @@ -8721,7 +8765,7 @@ def _card_rule_matches(rule: _OsRule, root: Path) -> bool: except OSError: return False return re.search(pattern, text, re.MULTILINE) is not None - return all((root / value).exists() for value in rule.values) + return all(_exists(root / value) for value in rule.values) def card_system(root: Path) -> tuple[str, Path, str] | None: diff --git a/tests/test_install_experience.py b/tests/test_install_experience.py index 5f64b1e0..76dc68f4 100644 --- a/tests/test_install_experience.py +++ b/tests/test_install_experience.py @@ -507,6 +507,65 @@ class ARefusedFolderReportsEachFile(unittest.TestCase): list(range(1, len(downloaded) + 1))) +class AFolderThisUserCannotEnter(unittest.TestCase): + """A subfolder of the BIOS folder without search permission (made 0700 + by an emulator that ran as root, or denied by a Windows ACL) raised + PermissionError from Path.exists() before any file was handled, in + --check and in an install alike.""" + + def _locked(self, repo: FakeRepository) -> Path: + (repo.dest / "pcsx").mkdir(parents=True) + (repo.dest / "pcsx" / "extra.bin").write_bytes(b"old") + os.chmod(repo.dest / "pcsx", 0o000) + return repo.dest / "pcsx" + + @unittest.skipIf(os.name == "nt" or os.geteuid() == 0, + "a folder without search permission is POSIX") + def test_check_names_the_file_and_reads_the_others(self): + with FakeRepository() as repo: + locked = self._locked(repo) + try: + proc = repo.run("--check") + finally: + os.chmod(locked, 0o755) + self.assertNotIn("Traceback", proc.stderr) + self.assertEqual(proc.returncode, 1, proc.stdout + proc.stderr) + self.assertIn("wrong hash: pcsx/extra.bin", proc.stdout) + self.assertIn("could not be read (Permission denied)", proc.stdout) + self.assertIn("missing: scph5501.bin", proc.stdout) + + @unittest.skipIf(os.name == "nt" or os.geteuid() == 0, + "a folder without search permission is POSIX") + def test_install_fails_that_file_alone(self): + with FakeRepository() as repo: + locked = self._locked(repo) + try: + proc = repo.run("--events") + finally: + os.chmod(locked, 0o755) + installed = repo.installed() + self.assertNotIn("Traceback", proc.stderr) + events = [json.loads(line) for line in proc.stdout.splitlines()] + failed = {e["dest"]: e["reason"] for e in events + if e["event"] == "downloaded" and not e["ok"]} + self.assertEqual(list(failed), ["pcsx/extra.bin"], proc.stderr) + self.assertIn("this user may not have access", failed["pcsx/extra.bin"]) + self.assertIn("scph5501.bin", installed) + self.assertIn("mgba/extra.bin", installed) + + @unittest.skipIf(os.name == "nt" or os.geteuid() == 0, + "a folder without search permission is POSIX") + def test_declared_state_reads_it_as_wrong(self): + with FakeRepository() as repo: + locked = self._locked(repo) + entry = {"dest": "pcsx/extra.bin", "sha1": "0" * 40, "size": 3} + try: + state = install._declared_state(entry, entry, repo.dest) + finally: + os.chmod(locked, 0o755) + self.assertEqual(state, "wrong_hash") + + class _Tty(io.StringIO): def isatty(self) -> bool: return True From 89cfaeef5e1e73666cb05648b69c0c66609c9b22 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:06:01 +0200 Subject: [PATCH 02/13] fix: never hold open an archive the run replaces A --from folder holding the BIOS folder kept the list's own archive open after listing its members, and Windows then refused to move it aside or replace it. The index now notes the places a run writes, closes the archives among them and opens them only for the read at hand. --- install.py | 33 +++++++++++++++++++-- tests/test_install_from.py | 61 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/install.py b/install.py index 01a1b03c..4b1b1ba8 100644 --- a/install.py +++ b/install.py @@ -7198,6 +7198,10 @@ class FolderIndex: # was opened again, its directory parsed again, for each member. self._zips: dict[Path, zipfile.ZipFile] = {} self._zip_lock = threading.Lock() + # The places this run writes, resolved. An archive among them is + # never kept open: Windows refuses to move aside or replace a file + # a process holds, and a --from folder can hold the BIOS folder. + self._written: set[Path] = set() # (path, member or "") -> (its size or None when unknown, why) of each # file, member or folder below root that could not be read. self.unreadable: dict[tuple[Path, str], tuple[int | None, str]] = {} @@ -7275,7 +7279,7 @@ class FolderIndex: A few stay open, the least recently read closed first: a card of MAME sets would otherwise hold a descriptor per archive. Closing one leaves a member being read open until it is closed itself. - Only archives the list does not install are read this way. + An archive this run writes is never read this way (see writes). """ archive = self._zips.pop(path, None) if archive is None: @@ -7285,15 +7289,38 @@ class FolderIndex: self._zips[path] = archive return archive + def writes(self, paths: Collection[Path]) -> None: + """Note the places this run writes, resolved, and close the archives + among them: the next read of one opens it and closes it at once.""" + with self._zip_lock: + self._written.update(paths) + for held in [held for held in self._zips if self._is_written(held)]: + self._zips.pop(held).close() + + def _is_written(self, path: Path) -> bool: + return bool(self._written) and ( + path in self._written or Path(os.path.realpath(path)) in self._written) + def infolist(self, path: Path) -> list[zipfile.ZipInfo]: with self._zip_lock: + if self._is_written(path): + with zipfile.ZipFile(path) as archive: + return archive.infolist() return self._archive(path).infolist() @contextlib.contextmanager def open_member(self, path: Path, member: str) -> Iterator[BinaryIO]: """A member of an archive under the folder, read from any thread.""" with self._zip_lock: - handle = self._archive(path).open(member) + if self._is_written(path): + archive = zipfile.ZipFile(path) + try: + handle = archive.open(member) + finally: + # The member holds the file until it is closed itself. + archive.close() + else: + handle = self._archive(path).open(member) try: yield handle finally: @@ -7913,6 +7940,8 @@ def plan_local_copies( written.add(Path(os.path.realpath(_entry_path(bios_path, entry)))) except ValueError: continue + for index in ([bios_index] if bios_index is not None else []) + folders: + index.writes(written) copies: dict[int, LocalCopy] = {} for entry in entries: copy = None diff --git a/tests/test_install_from.py b/tests/test_install_from.py index 2f5f2880..ef445ca5 100644 --- a/tests/test_install_from.py +++ b/tests/test_install_from.py @@ -854,6 +854,67 @@ class AFolderAroundTheBiosFolder(FromFolder): ["old card/copy.zip, inside as inner.bin"]) + @unittest.skipUnless(Path("/proc/self/fd").is_dir(), "reads the open files from /proc") + def test_an_archive_the_list_replaces_is_not_held_open(self): + """Windows refuses to rename, replace or delete a file a process + holds open. The --from index kept the list's own wrong archive open + once its members had been listed, and the run failed to move it + aside, every run.""" + good = io.BytesIO() + with zipfile.ZipFile(good, "w") as archive: + archive.writestr("a.bin", b"A" * 300) + good = good.getvalue() + card = self.root / "card" + bios = card / "bios" + _zip(bios / "neogeo.zip", {"x.bin": b"X" * 77}) + (card / "backup").mkdir() + (card / "backup" / "neogeo.zip").write_bytes(good) + _zip(card / "backup" / "pack.zip", {"renamed.bin": GBA}) + self.list.write_text(json.dumps({ + "manifest_version": 2, "platform": "retroarch", + "files": [_entry("neogeo.zip", good, ["snk-neogeo"]), + _entry("gba_bios.bin", GBA, ["nintendo-gba"])]})) + + def held() -> set[str]: + out = set() + for fd in os.listdir("/proc/self/fd"): + with contextlib.suppress(OSError): + out.add(os.path.realpath(os.readlink(f"/proc/self/fd/{fd}"))) + return out + + real_replace, real_rename, real_unlink = os.replace, os.rename, os.unlink + + def refusing(real): + def call(*paths, **kwargs): + files = paths[:2] if real is not real_unlink else paths[:1] + if any(os.path.realpath(path) in held() for path in files): + raise PermissionError(13, "The process cannot access the file", + str(files[0])) + return real(*paths, **kwargs) + return call + + argv = ["install.py", "--platform", "retroarch", "--dest", str(bios), + "--manifest", str(self.list), "--from", str(card), "--no-input", "--yes"] + out = io.StringIO() + with mock.patch.object(sys, "argv", argv), \ + mock.patch.dict(os.environ, {"RETROBIOS_INSTALL_OFFLINE": "1"}), \ + mock.patch.object(os, "replace", refusing(real_replace)), \ + mock.patch.object(os, "rename", refusing(real_rename)), \ + mock.patch.object(os, "unlink", refusing(real_unlink)), \ + mock.patch.object(os, "remove", refusing(real_unlink)), \ + contextlib.redirect_stdout(out), contextlib.redirect_stderr(out): + try: + install.main() + code = 0 + except SystemExit as exc: + code = exc.code + finally: + install.LOCAL.reset() + self.assertEqual(code, 0, out.getvalue()) + self.assertEqual((bios / "neogeo.zip").read_bytes(), good) + self.assertEqual((bios / "gba_bios.bin").read_bytes(), GBA) + + class ManySameSizedFiles(FromFolder): """A card of 16 KB ROMs and a list of 16 KB entries: each entry sorted and walked every file of its size.""" From b26ce7cbdc76ab63d6f5ff2c7fb90f9367f72d56 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:07:31 +0200 Subject: [PATCH 03/13] fix: close an idle interface past its first screen RETROBIOS_IDLE_EXIT only counted while the first screen was up. Given --platform, the check runs and the plan follows without a key, so the close never came. It now counts from the end of the last run, as long as no key has been pressed. --- install.py | 18 +++++++++++------- tests/test_install_tui.py | 12 ++++++++++++ wiki/installer.md | 13 ++++++++----- 3 files changed, 31 insertions(+), 12 deletions(-) diff --git a/install.py b/install.py index 4b1b1ba8..cb4001f3 100644 --- a/install.py +++ b/install.py @@ -10118,9 +10118,9 @@ TUI_PAD_ROUTES = ("gptokeyb", "gptokeyb_muxterm", "muxterm", "evmapy", "miyoo") _STEAM_MARKERS = ("SteamGamepadUI", "SteamDeck", "SteamGameId", "GAMESCOPE_WAYLAND_DISPLAY") -# Seconds the first screen waits for a first key before it closes, set by a -# launcher whose mapper may never open the pad: the screen would otherwise -# hold a handheld that has no other way out. +# Seconds the interface waits for a first key, no run going, before it +# closes, set by a launcher whose mapper may never open the pad: the screen +# would otherwise hold a handheld that has no other way out. TUI_IDLE_ENV = "RETROBIOS_IDLE_EXIT" TUI_IDLE_WORDS = "No button pressed, closing." @@ -12895,7 +12895,10 @@ def run_tui(args: argparse.Namespace) -> int: if number is not None: handlers[number] = signal.signal(number, handler) idle = tui_idle_exit() - first_screen, opened, pressed = state.screen, time.monotonic(), False + # Counted from the moment the screen last waited for a key with no + # run going: given --platform, the first screen moves on by itself + # to the plan once the check is read, and no key comes either. + quiet_since, pressed = time.monotonic(), False sys.stdout = sys.stderr = buffer try: _prune_work_folders(work.parent, time.time()) @@ -12931,9 +12934,10 @@ def run_tui(args: argparse.Namespace) -> int: perform(tui_update(state, key)) if state.exit_code is not None: break - if (idle is not None and not pressed and state.exit_code is None - and state.screen == first_screen - and time.monotonic() - opened >= idle): + if state.running: + quiet_since = time.monotonic() + elif (idle is not None and not pressed and state.exit_code is None + and time.monotonic() - quiet_since >= idle): state.idle_closed, state.exit_code = True, 0 if flags["interrupt"]: flags["interrupt"] = False diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 5e59b0d4..121104eb 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -3176,6 +3176,18 @@ class EndToEnd(unittest.TestCase): term.send("q") self.assertEqual(term.close(), 130) + def test_no_key_on_a_screen_reached_by_itself_closes_it(self): + """Given --platform, as the offline launcher route is, the check + runs at once and the plan follows without a key: the close counted + only on the first screen, and never came.""" + repo = self.repo() + 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.assertIn("No button pressed, closing.", term.after()) + self.assertEqual(repo.installed(), []) + def test_a_desktop_names_its_keys_and_a_pad_its_buttons(self): self.repo() for env, bar in (({"DISPLAY": ":0"}, "Enter Continue Esc Quit ? Help"), diff --git a/wiki/installer.md b/wiki/installer.md index db937aa1..a99bd781 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -1072,7 +1072,8 @@ page there: muxterm keeps PgUp and PgDn for its own scrollback, so a list moves with the D-pad. Each launcher also sets `RETROBIOS_IDLE_EXIT` to 300: with no button pressed -on its first screen for 5 minutes, the interface closes by itself. A pad the +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. @@ -1089,9 +1090,11 @@ Each launcher tells the interface which buttons reach it, in 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 reaches the first screen for that long, before any choice, the -interface closes with `No button pressed, closing.` and exit status 0. A -mapper that never opened the pad would otherwise hold the device on that +no button has reached the interface since it opened, and nothing has run for +that long, it closes with `No button pressed, closing.` and exit status 0. +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 @@ -1342,7 +1345,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's first screen waits for a first button. With none pressed, it closes with exit status 0 | +| `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 | | `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 76da554da92ece2310a79cd314351381c40b3986 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:12:49 +0200 Subject: [PATCH 04/13] fix: read retroarch.cfg for cores in a --dest run A run given --dest, as every interface run is, never read the config naming libretro_directory, so only installed cores found none where line mode found them. core_dirs now asks _retroarch_config_for, as the save directory does, and the advice to drop --dest goes. --- install.py | 25 ++++++++++++------------- tests/test_install_cores.py | 34 ++++++++++++++++++++++++++++++++++ wiki/installer.md | 7 ++++--- 3 files changed, 50 insertions(+), 16 deletions(-) diff --git a/install.py b/install.py index cb4001f3..26d639ca 100644 --- a/install.py +++ b/install.py @@ -2302,11 +2302,12 @@ def core_dirs(plat: str, bios_path: Path) -> list[Path]: """The directories the RetroArch writing to bios_path loads cores from. The config detection read for this system directory names them in - libretro_directory. Without one, a retroarch.cfg beside the system + libretro_directory, read here when the run was given --dest, as the + interface's runs all are. Without one, a retroarch.cfg beside the system directory is the portable layout. Unset, the setting is cores/ under RetroArch's own directory. """ - found = _RETROARCH_CONFIGS.get(bios_path) + found = _retroarch_config_for(bios_path) if found is None and (bios_path.parent / "retroarch.cfg").is_file(): found = (bios_path.parent / "retroarch.cfg", bios_path.parent) dirs: list[Path] = [] @@ -3979,9 +3980,10 @@ def _retroarch_config_for(bios_path: Path) -> tuple[Path, Path] | None: folder ':' expands to there. A run given --dest detects nothing, so the configs are read here, once - and silently, the first time a save directory is asked for: the - interface names every folder with --dest, and its runs sent Azahar's - keys.txt to a guessed folder where line mode read savefile_directory. + and silently, the first time a save directory or the cores folder is + asked for: the interface names every folder with --dest, and its runs + sent Azahar's keys.txt to a guessed folder where line mode read + savefile_directory, and found no core where line mode found them. """ found = _RETROARCH_CONFIGS.get(bios_path) if found is not None or _CONFIGS_READ: @@ -8713,12 +8715,12 @@ def _missing_dest(dest: Path, ask: bool, named: str = "named with --dest") -> No sys.exit(2) -def _no_core_found(plat: str, bios_path: Path, searched: list[Path], dest_given: bool) -> None: +def _no_core_found(plat: str, bios_path: Path, searched: list[Path]) -> None: """Refuse --installed-cores with no core, and say how to point at them. - With --dest, detection does not run, and without a retroarch.cfg beside - the folder nothing names the cores folder: the run stopped on "no known - directory" with no way forward. + With no retroarch.cfg naming the folder as its system_directory, and + none beside it, nothing names the cores folder: the run stopped on "no + known directory" with no way forward. """ where = (f"none in {', '.join(map(str, searched))}" if searched else f"no retroarch.cfg was found for {bios_path} to name their folder") @@ -8728,9 +8730,6 @@ def _no_core_found(plat: str, bios_path: Path, searched: list[Path], dest_given: "retroarch.cfg, cores/ beside RetroArch by default):", file=sys.stderr) command = _INVOCATION.command(plat, bios_path, without=("--installed-cores", "--cores-dir")) print(f" {command} --cores-dir ", file=sys.stderr) - if dest_given: - print(" Or run it without --dest: detection reads retroarch.cfg, which names " - "that folder.", file=sys.stderr) print(" Or run it without --installed-cores to install every file.", file=sys.stderr) sys.exit(1) @@ -9156,7 +9155,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: ) sys.exit(1) if args.installed_cores and not cores_found[(plat_name, bios_path)]: - _no_core_found(plat_name, bios_path, searched, bool(args.dest)) + _no_core_found(plat_name, bios_path, searched) for plat_name, bios_path in platforms: if bios_path not in installed_paths: diff --git a/tests/test_install_cores.py b/tests/test_install_cores.py index 7a855642..6c0ec22c 100644 --- a/tests/test_install_cores.py +++ b/tests/test_install_cores.py @@ -488,5 +488,39 @@ class TheOneLinerReadsInstalledCores(unittest.TestCase): self.assertEqual(repo.installed(), ["gba_bios.bin", "mgba/extra.bin"]) +class TheInterfaceFindsTheCoresLineModeFinds(unittest.TestCase): + """The interface names the BIOS folder with --dest, so its runs never + read retroarch.cfg: "only installed cores" found none where line mode, + detecting the same config, found them (macOS, Flatpak, any custom + system_directory).""" + + def test_a_config_away_from_the_bios_folder(self): + with _CoreRepository() as repo: + (repo.root / "retroarch.cfg").unlink() + home = repo.root / "home" + config = home / ".config" / "retroarch" + config.mkdir(parents=True) + cores = repo.root / "elsewhere" / "my cores" + cores.mkdir(parents=True) + (cores / "mgba_libretro.so").write_bytes(b"") + (config / "retroarch.cfg").write_text( + f'system_directory = "{repo.dest}"\n' + f'libretro_directory = "{cores}"\n') + env = {**repo.env(), "HOME": str(home), "RETROBIOS_OS": "linux", + "XDG_CONFIG_HOME": str(home / ".config")} + repo.dest.mkdir() + found = [] + for given in ((), ("--dest", str(repo.dest))): + command = [sys.executable, str(REPO_ROOT / "install.py"), "--platform", + "retroarch", *given, "--installed-cores", "--check"] + proc = subprocess.run(command, env=env, capture_output=True, text=True, + timeout=60, check=False, stdin=subprocess.DEVNULL) + self.assertNotIn("no installed core found", proc.stderr) + found.append([line for line in proc.stdout.splitlines() + if "core installed" in line or "Narrowed" in line]) + self.assertEqual(found[0], found[1]) + self.assertIn(" 1 core installed", found[1]) + + if __name__ == "__main__": unittest.main() diff --git a/wiki/installer.md b/wiki/installer.md index a99bd781..cb1dcedf 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -628,9 +628,10 @@ rather than read as having no game data. `--installed-cores` reads the cores a RetroArch already has and installs what they need. The cores are the `_libretro.so` (`.dll`, `.dylib`, `_libretro_android.so`) files in the `libretro_directory` of the -`retroarch.cfg` detection found, `cores/` beside RetroArch when the setting is -unset, a `retroarch.cfg` next to the system directory for a portable install -given with `--dest`, and the folders RetroPie (`/opt/retropie/libretrocores`) +`retroarch.cfg` whose `system_directory` is the BIOS folder, found the same +way with `--dest` or without, `cores/` beside RetroArch when the setting is +unset, a `retroarch.cfg` next to the system directory for a portable install, +and the folders RetroPie (`/opt/retropie/libretrocores`) and Lakka (`/usr/lib/libretro`, `/storage/cores`) install into. A core file is named the buildbot's way (`mednafen_psx`), and the manifest maps each name to the profile its files are filed under (`beetle_psx`). From bc4bcadcd38c3cefe6371503ce5d2163e21299bb Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:13:42 +0200 Subject: [PATCH 05/13] fix: keep a crash log for a card read error Any EIO was read as the window closing, so a failing SD card ended the interface with 129 and no error.log. The error now counts as a closed window only once the terminal itself no longer answers tcgetattr. --- install.py | 28 ++++++++++++++++++++++------ tests/test_install_tui.py | 23 +++++++++++++++++++++++ 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/install.py b/install.py index 26d639ca..e8aa1050 100644 --- a/install.py +++ b/install.py @@ -12966,7 +12966,7 @@ def run_tui(args: argparse.Namespace) -> int: # The result is printed in the terminal on the way out. state.exit_code = state.code if state.code is not None else 1 except Exception as exc: # noqa: BLE001 - if flags["term"] or _closed_terminal(exc): + if flags["term"] or _closed_terminal(exc, terminal.fd): # The window closed under a screen that was changing, or the # SIGCONT that comes with its SIGHUP put the screen back: the # write failed before the signal was read. That is its exit. @@ -13030,13 +13030,29 @@ def _tui_startup(args: argparse.Namespace) -> tuple[TuiState, list[tuple]] | Non _CLOSED_TERMINAL = frozenset({errno.EIO, errno.EPIPE, errno.ENXIO}) -def _closed_terminal(exc: BaseException) -> bool: +def _closed_terminal(exc: BaseException, fd: int | None = None) -> bool: """Whether exc is the terminal gone: an OSError, or a termios.error, - whose first argument is the errno, answering EIO, EPIPE or ENXIO.""" + whose first argument is the errno, answering EIO, EPIPE or ENXIO, and, + given the terminal's fd, that terminal no longer answering. A failing + SD card answers EIO too, and that read error is kept in a crash log.""" if isinstance(exc, OSError): - return exc.errno in _CLOSED_TERMINAL - if type(exc).__name__ == "error" and type(exc).__module__ == "termios": - return bool(exc.args) and exc.args[0] in _CLOSED_TERMINAL + closed = exc.errno in _CLOSED_TERMINAL + elif type(exc).__name__ == "error" and type(exc).__module__ == "termios": + closed = bool(exc.args) and exc.args[0] in _CLOSED_TERMINAL + else: + closed = False + return closed and (fd is None or _terminal_gone(fd)) + + +def _terminal_gone(fd: int) -> bool: + """Whether the terminal at fd hung up: a closed window answers EIO to + tcgetattr, a live one its settings.""" + import termios # noqa: PLC0415 + + try: + termios.tcgetattr(fd) + except (termios.error, OSError): + return True return False diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 121104eb..2b03d2d8 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -2557,6 +2557,29 @@ class IdleExit(unittest.TestCase): ["RetroBIOS: No button pressed, closing."]) +class AReadErrorIsNotAClosedWindow(unittest.TestCase): + """Any EIO read as the window closing: a failing SD card answers EIO + too, and that run ended 129, no error.log, no launcher message.""" + + def test_eio_counts_only_once_the_terminal_hung_up(self): + import pty + + master, slave = pty.openpty() + try: + card = OSError(errno.EIO, "Input/output error", "/media/card/bios/x.bin") + self.assertFalse(install._closed_terminal(card, slave)) + self.assertFalse(install._closed_terminal( + OSError(errno.ENOENT, "No such file"), slave)) + os.close(master) + master = -1 + self.assertTrue(install._closed_terminal(card, slave)) + self.assertTrue(install._closed_terminal(termios.error(errno.EIO, "gone"), slave)) + finally: + os.close(slave) + if master >= 0: + os.close(master) + + class AuditRoundFour(unittest.TestCase): """tui_audit_3.md, each finding held where it was found.""" From c67dd2ad46d9c4805d0916baee25af4c4b40d4fd Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:14:17 +0200 Subject: [PATCH 06/13] fix: drop install anyway when nothing can come Offline with nothing here to copy, Install was removed, and a folder short of space or read-only then put Install anyway back: a run that could only fail every file. --- install.py | 6 +++--- tests/test_install_tui.py | 11 +++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/install.py b/install.py index e8aa1050..15ec648c 100644 --- a/install.py +++ b/install.py @@ -10428,13 +10428,13 @@ def _plan_rows(state: TuiState) -> list[TuiRow]: rows = [TuiRow("install", label)] if _offline_only() and not _plan_copies(state, check): # Nothing here to copy, and the network refused: Install would - # only fail every file. + # only fail every file, in this folder or another. rows = [] if state.facts.get("writable") is False or _short_of_space(state, check): # Every file would fail alike, or the run would stop before the # first one: the way out is another folder. - rows = [TuiRow("folder", "Choose another folder"), TuiRow("install", "Install anyway"), - *rows[1:]] + rows = [TuiRow("folder", "Choose another folder"), + *([TuiRow("install", "Install anyway")] if rows else [])] if records_selection((state.manifest or {}).get("files", [])) and len(state.systems) > 1: rows.append(TuiRow("systems", "Choose systems")) rows.append(TuiRow("fromcard", "Copy the BIOS files from your old card or a folder")) diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 2b03d2d8..1b004c19 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -2212,6 +2212,17 @@ class AuditRoundThree(unittest.TestCase): self.assertEqual(press(state, "enter"), []) self.assertEqual(state.screen, "folder") + def test_offline_with_nothing_to_copy_offers_no_install_anyway(self): + """Offline with nothing here to copy, Install is removed: a folder + short of space then put "Install anyway" back, a run that could + only fail every file.""" + state = checked_state({"free": 1024, "writable": False, "cores": []}) + with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}): + labels = [row.label for row in install.tui_rows(state)] + self.assertEqual(labels[0], "Choose another folder") + self.assertNotIn("Install anyway", labels) + self.assertFalse(any(label.startswith("Install") for label in labels), labels) + FULL_TAIL = [ " Checking existing files...", " 0/50 present (0 verified, 0 wrong hash)", From 19c57b3d68ee16266308113246e0897dd4c0c6cf Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:14:34 +0200 Subject: [PATCH 07/13] docs: say a connect timeout counts as a stall --- install.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/install.py b/install.py index 15ec648c..e92b92e6 100644 --- a/install.py +++ b/install.py @@ -3328,13 +3328,16 @@ def install_bounded_opener() -> None: # stuck proxy) held the run 93 s on three waits of 30 s. A list is a few # MB, never a slow stream. LIST_TIMEOUT = 15.0 -# Attempts after a server that took the connection went silent: once more, -# a stall is rarely a blip. +# Attempts after a wait ran out, for the connection (a TLS handshake a +# captive portal never finishes) or for the answer once connected (a stuck +# proxy): once more, a stall is rarely a blip. STALLED_ATTEMPTS = 2 def _stalled(exc: BaseException) -> bool: - """Whether exc is a wait that ran out, the connection made.""" + """Whether exc is a wait that ran out: urllib raises a connect timeout + as a URLError whose reason is the timeout, a silent answer as the + timeout itself.""" reason = getattr(exc, "reason", None) return isinstance(exc, socket.timeout) or isinstance(reason, socket.timeout) From c53715d7b947d5102c2e9807505b9a1871e5ce32 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:29:00 +0200 Subject: [PATCH 08/13] fix: a refused write also marks a closed window A window can still answer tcgetattr while refusing writes. The check now tries both, as the audit asked, and takes the terminal itself so a stand-in without a descriptor is never read. --- install.py | 22 ++++++++++++---------- tests/test_install_tui.py | 13 +++++++++---- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/install.py b/install.py index e92b92e6..e857d7ac 100644 --- a/install.py +++ b/install.py @@ -12969,7 +12969,7 @@ def run_tui(args: argparse.Namespace) -> int: # The result is printed in the terminal on the way out. state.exit_code = state.code if state.code is not None else 1 except Exception as exc: # noqa: BLE001 - if flags["term"] or _closed_terminal(exc, terminal.fd): + if flags["term"] or _closed_terminal(exc, terminal): # The window closed under a screen that was changing, or the # SIGCONT that comes with its SIGHUP put the screen back: the # write failed before the signal was read. That is its exit. @@ -13033,28 +13033,30 @@ def _tui_startup(args: argparse.Namespace) -> tuple[TuiState, list[tuple]] | Non _CLOSED_TERMINAL = frozenset({errno.EIO, errno.EPIPE, errno.ENXIO}) -def _closed_terminal(exc: BaseException, fd: int | None = None) -> bool: +def _closed_terminal(exc: BaseException, terminal: _TuiTerminal | None = None) -> bool: """Whether exc is the terminal gone: an OSError, or a termios.error, whose first argument is the errno, answering EIO, EPIPE or ENXIO, and, - given the terminal's fd, that terminal no longer answering. A failing - SD card answers EIO too, and that read error is kept in a crash log.""" + given the terminal, that terminal no longer answering. A failing SD + card answers EIO too, and that read error is kept in a crash log.""" if isinstance(exc, OSError): closed = exc.errno in _CLOSED_TERMINAL elif type(exc).__name__ == "error" and type(exc).__module__ == "termios": closed = bool(exc.args) and exc.args[0] in _CLOSED_TERMINAL else: closed = False - return closed and (fd is None or _terminal_gone(fd)) + return closed and (terminal is None or _terminal_gone(terminal)) -def _terminal_gone(fd: int) -> bool: - """Whether the terminal at fd hung up: a closed window answers EIO to - tcgetattr, a live one its settings.""" +def _terminal_gone(terminal: _TuiTerminal) -> bool: + """Whether the terminal hung up: a closed window answers EIO to + tcgetattr and to a write, a live one its settings.""" import termios # noqa: PLC0415 try: - termios.tcgetattr(fd) - except (termios.error, OSError): + termios.tcgetattr(terminal.fd) + terminal.stdout.write("") + terminal.stdout.flush() + except (termios.error, OSError, ValueError): return True return False diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 1b004c19..8233fc59 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -2576,16 +2576,21 @@ class AReadErrorIsNotAClosedWindow(unittest.TestCase): import pty master, slave = pty.openpty() + screen = open(os.dup(slave), "w", encoding="utf-8") # noqa: SIM115 + terminal = mock.Mock(fd=slave, stdout=screen) try: card = OSError(errno.EIO, "Input/output error", "/media/card/bios/x.bin") - self.assertFalse(install._closed_terminal(card, slave)) + self.assertFalse(install._closed_terminal(card, terminal)) self.assertFalse(install._closed_terminal( - OSError(errno.ENOENT, "No such file"), slave)) + OSError(errno.ENOENT, "No such file"), terminal)) os.close(master) master = -1 - self.assertTrue(install._closed_terminal(card, slave)) - self.assertTrue(install._closed_terminal(termios.error(errno.EIO, "gone"), slave)) + self.assertTrue(install._closed_terminal(card, terminal)) + self.assertTrue(install._closed_terminal(termios.error(errno.EIO, "gone"), + terminal)) finally: + with contextlib.suppress(OSError): + screen.close() os.close(slave) if master >= 0: os.close(master) From e003828e26186538eec7ebe2757b3eb20612dc69 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:30:47 +0200 Subject: [PATCH 09/13] fix: ask nothing when the output goes to a file install.py > log.txt at a terminal wrote the menu into the file and waited for an answer nobody saw. A question now needs stdin and stdout on a terminal, otherwise the run goes on as with --no-input and says so. --- install.py | 15 +++++++++++++++ tests/test_install_experience.py | 24 ++++++++++++++++++++++++ wiki/installer.md | 2 +- 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/install.py b/install.py index e857d7ac..cc258bf0 100644 --- a/install.py +++ b/install.py @@ -8933,6 +8933,14 @@ def _unknown_target(target: str, plat: str, targets: dict) -> None: print(f" {plat} publishes no target list", file=sys.stderr) +def _is_terminal(stream: TextIO) -> bool: + isatty = getattr(stream, "isatty", None) + try: + return bool(isatty and isatty()) + except (OSError, ValueError): + return False + + def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: """One run of the installer, once the options are read.""" KEPT.reset() @@ -8940,6 +8948,13 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: interactive = sys.stdin.isatty() and not args.no_input and not args.quiet listing = (args.list_targets or args.list_systems or args.list_cores or args.list_emulators) + if interactive and not _is_terminal(real_stdout): + # A question goes to stdout: sent to a file, it waited on an answer + # to a prompt nobody could see. + interactive = False + if not listing and not args.check: + print("Output goes to a file, so nothing is asked: the run goes on as " + "with --no-input.", file=sys.stderr) print(f"{term.bold('RetroBIOS')} {term.dim(installer_id())}\n") os_type = detect_os() diff --git a/tests/test_install_experience.py b/tests/test_install_experience.py index 76dc68f4..2a74b539 100644 --- a/tests/test_install_experience.py +++ b/tests/test_install_experience.py @@ -566,6 +566,30 @@ class AFolderThisUserCannotEnter(unittest.TestCase): self.assertEqual(state, "wrong_hash") +class OutputToAFileAsksNothing(unittest.TestCase): + """`install.py > log.txt` at a terminal: the menu went into the file + and the run waited on an answer to a prompt nobody could see.""" + + @unittest.skipIf(os.name == "nt", "a pseudo-terminal for stdin") + def test_the_run_goes_on_as_with_no_input(self): + import pty + + master, slave = pty.openpty() + try: + with FakeRepository() as repo: + proc = subprocess.run( + repo.command(), env=repo.env(), stdin=slave, capture_output=True, + text=True, timeout=60, check=False) + installed = repo.installed() + finally: + os.close(master) + os.close(slave) + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + self.assertIn("nothing is asked", proc.stderr) + self.assertNotIn("Enter) install everything", proc.stdout) + self.assertEqual(len(installed), len(FakeRepository.PAYLOADS)) + + class _Tty(io.StringIO): def isatty(self) -> bool: return True diff --git a/wiki/installer.md b/wiki/installer.md index cb1dcedf..a3efbc96 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -701,7 +701,7 @@ what the options name, everything by default. | `--list-cores` | Print the cores whose extra files a platform installs | | `--list-emulators` | Print every name `--emulator` accepts, each buildbot name beside the emulator it stands for | | `--romm-firmware TEMPLATE` | RomM: where each platform's firmware goes in the library, as `filesystem.structure.firmware` in RomM's `config.yml` says (`{platform}/bios` for a library laid out as `{platform}/roms`). Read from `config.yml` beside the library when it is there. See [RomM firmware layout](#romm-firmware-layout) | -| `--no-input` | Never ask a question, even at a terminal | +| `--no-input` | Never ask a question, even at a terminal. A run whose output goes to a file or a pipe asks nothing either, and says so on stderr | | `--check` | Report and exit without writing | | `--dry-run` | Show what would be downloaded, the totals first, then by system and with sizes, without writing anything. A plan taller than the screen names the ten largest systems, and `--verbose` lists every system and file | | `--yes`, `-y` | Do not ask before a download of 1 GB or more | From 3db8ddae2b01d6065b3fa63e6efabd153cc6cfd2 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:34:24 +0200 Subject: [PATCH 10/13] feat: name the save folder and the pack on screen The plan and the result now say how many files go to the save folder and where, from a new saves event. With --from-pack they say the files come from the pack, not a download. A stop asked for (Start+Select, SIGHUP) ends on the plain outcome, without the log's last lines. --- install.py | 41 +++++++++++++++++++++++++++--- schemas/install-events.schema.json | 29 +++++++++++++++++++++ tests/test_install_tui.py | 37 ++++++++++++++++++++++++++- wiki/installer.md | 1 + 4 files changed, 104 insertions(+), 4 deletions(-) diff --git a/install.py b/install.py index cc258bf0..74148f58 100644 --- a/install.py +++ b/install.py @@ -546,6 +546,11 @@ EVENT_KEYS: dict[str, tuple[str, ...]] = { # size of a file no copy was found for, or of no known size (a folder, # a ZIP that would not open). member as in found, reason in words. "unreadable": ("platform", "folder", "path", "member", "reason"), + # The files of the list read from the frontend's save directory, and + # where they go ( and stand for a folder per system or + # per core), once that directory is known. Not written when it is not: + # the summary's skipped then counts them (no_save_directory). + "saves": ("platform", "files", "folder"), "download": ("platform", "files", "bytes"), "progress": ("files_done", "files_total", "bytes_done", "bytes_total"), # Right after each progress event, and once when the files of a @@ -4074,6 +4079,8 @@ def place_save_entries( where += f"{os.sep}" print(f" {_plural(len(saved), 'file')} read from {display}'s save directory " f"go to {where}") + EVENTS.emit("saves", platform=str(manifest.get("platform", "")), + files=len(saved), folder=where) return files, [] print(f" {_plural(len(saved), 'file')} read from {display}'s save directory " f"left out: {place}") @@ -10081,6 +10088,8 @@ class TuiState: self.idle_closed = False # The check run's summary: what it left out and why. self.check_summary: dict | None = None + # The saves event of the check: the files that go to the save folder. + self.saves: dict | None = None # The confirm screen waits for A even when the plan is unchanged: # it was opened by Space, which never installs by itself. self.must_confirm: bool = False @@ -10634,7 +10643,7 @@ def _start_run(state: TuiState, run: str) -> list[tuple]: # dropped the --system the command line gave. state.check, state.files, state.systems, state.reading = None, [], [], None state.found, state.scanned, state.unreadable = [], [], [] - state.check_summary = None + state.check_summary = state.saves = None state.checked_flags = tui_flags(state) elif run in ("confirm", "preview"): state.dry, state.dry_files, state.dry_summary = None, [], None @@ -11259,6 +11268,8 @@ def _take_event(state: TuiState, event: dict) -> None: state.scanned.append(event) elif kind == "unreadable" and state.run == "check": state.unreadable.append(event) + elif kind == "saves" and state.run == "check": + state.saves = event elif kind == "found" and state.run == "install": state.local_dests[event["dest"]] = (event["folder"], event["reason"]) elif state.run == "check": @@ -11590,6 +11601,11 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: elif need and fetch and offline: headline = (f"{_plural(fetch, 'file')} {'needs' if fetch == 1 else 'need'} the " "internet, which --offline does not use") + elif need and fetch and LOCAL.pack is not None: + # Nothing comes from the network: "to download" on a handheld with + # no Wi-Fi read as a failure on its way. + headline = (f"{_plural(fetch, 'file')} to install from the pack, " + f"{format_size(fetch_bytes)}") elif need and fetch: headline = f"{_plural(fetch, 'file')} to download, {format_size(fetch_bytes)}" elif need: @@ -11614,6 +11630,8 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: 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'}") + if check is state.check: + lines += [_styled(line, "path") for line in _saves_lines(state)] found_in = _RETROARCH_CONFIGS.get(dest) if dest is not None else None if found_in: lines.append(_styled(f"Named by {_short_path(Path(found_in[0]), state.home)}", "path")) @@ -11651,6 +11669,18 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: return lines[:2] + offline_lines + lines[2:] +def _saves_lines(state: TuiState) -> list[str]: + """Where the files read from the frontend's save directory go: a second + folder this run writes to, named before anything is written.""" + if not state.saves or not state.saves.get("files"): + return [] + name = display_name(state.platform) if state.platform else "the frontend" + count = state.saves["files"] + return [f"{_plural(count, 'file')} read from {name}'s save directory " + f"{'goes' if count == 1 else 'go'} to " + f"{_short_path(Path(state.saves['folder']), state.home)}"] + + def _unplaced_lines(summary: dict | None, state: TuiState) -> list[str]: """The files read from the frontend's save directory that the run left out, that directory being unknown: said, never silent.""" @@ -12102,7 +12132,8 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: head = f"The installer stopped with code {state.code}." elif installed: title = "Done" - head = f"{_plural(installed, 'file')} installed." + source = " from the pack" if LOCAL.pack is not None else "" + head = f"{_plural(installed, 'file')} installed{source}." if up_to_date: head += f" {_plural(up_to_date, 'file')} already in place." else: @@ -12113,6 +12144,8 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: if sources: lines.append(sources) lines.append(f"BIOS folder: {state.dest}") + if installed + up_to_date: + lines += _saves_lines(state) if space: lines.append("Free some space, or run again and choose another folder.") causes: dict[str, list[str]] = {} @@ -12131,7 +12164,9 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: shown += 1 if shown > 20: lines.append(f" ... and {shown - 20} more") - if not summary and not space: + if not summary and not space and state.code != 130: + # A stop asked for (Ctrl-C, Start+Select, the window closed) is said + # by the line above: the log's last lines are for a run that died. lines += [f" {line}" for line in state.tail[-8:]] run = Invocation() run.os_type = state.os_type diff --git a/schemas/install-events.schema.json b/schemas/install-events.schema.json index 13debfab..a9cb3fc5 100644 --- a/schemas/install-events.schema.json +++ b/schemas/install-events.schema.json @@ -21,6 +21,7 @@ "declared", "found", "unreadable", + "saves", "download", "progress", "transfer", @@ -64,6 +65,9 @@ { "$ref": "#/$defs/unreadable" }, + { + "$ref": "#/$defs/saves" + }, { "$ref": "#/$defs/download" }, @@ -486,6 +490,31 @@ } } }, + "saves": { + "type": "object", + "additionalProperties": false, + "required": [ + "event", + "platform", + "files", + "folder" + ], + "properties": { + "event": { + "const": "saves" + }, + "platform": { + "type": "string" + }, + "files": { + "type": "integer", + "minimum": 0 + }, + "folder": { + "type": "string" + } + } + }, "download": { "type": "object", "additionalProperties": false, diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 8233fc59..cccddfa2 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -2205,6 +2205,33 @@ class AuditRoundThree(unittest.TestCase): # P2-1: a card too small for the download. + def test_a_stop_asked_for_says_only_the_outcome(self): + """Start+Select (SIGTERM) or SIGHUP stops the child before it writes + a summary: the farewell then listed the log's last lines.""" + state = checked_state() + state.download = {"files": 50} + state.finished = [("scph5501.bin", True, "")] + state.tail = [" [2/50] scph5501.bin ok", + " Stopping. Files already installed are kept."] + for code, shown in ((130, False), (1, True)): + with self.subTest(code=code): + state.code = code + _title, lines = install.tui_outcome(state) + self.assertEqual(any("[2/50]" in line for line in lines), shown, lines) + + def test_a_pack_installs_from_the_pack_not_the_network(self): + """With --from-pack and no Wi-Fi, the plan said "to download", which + on a handheld with no network read as a failure on its way.""" + state = checked_state() + with mock.patch.object(install.LOCAL, "pack", object()): + headline = str(install.tui_view(state, 80, 24)[0]) + plan = "\n".join(str(line) for line in install.tui_view(state, 80, 24)) + state.summary = {"installed": 3, "failed": [], "up_to_date": 0} + title, lines = install.tui_outcome(state) + self.assertIn("to install from the pack", plan, headline) + self.assertNotIn("to download", plan) + self.assertEqual(lines[0], "3 files installed from the pack.") + def test_p2_1_another_folder_comes_first_when_space_is_short(self): state = checked_state({"free": 1024, "writable": True, "cores": []}) self.assertEqual([row.label for row in install.tui_rows(state)[:2]], @@ -2974,8 +3001,13 @@ class EndToEnd(unittest.TestCase): term.send("\r") screen = term.wait("files to download") self.assertIn("6 files to download", screen) + # The second folder the run writes to, named before + # anything is written, and again on the result. + self.assertIn("1 file read from RetroArch's save directory goes to " + "~/MySaves", screen) term.send("\r") - term.wait("6 files installed.") + screen = term.wait("6 files installed.") + self.assertIn("save directory goes to ~/MySaves", screen) term.send("\r") self.assertEqual(term.close(), 0) results.append(written(repo, saves)) @@ -3074,6 +3106,9 @@ class EndToEnd(unittest.TestCase): after = term.after() self.assertIn(f"Stopped: {on_disk} of 6 installed, run again to finish.", after) self.assertNotIn("Nothing was installed", after) + # The plain outcome only: the log's last lines are for a run that died. + self.assertIsNone(re.search(r"\[\d+/6\]", after), after) + self.assertNotIn("Stopping.", after) time.sleep(1) self.assertEqual(len(repo.installed()), on_disk) diff --git a/wiki/installer.md b/wiki/installer.md index a3efbc96..14eac483 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -1304,6 +1304,7 @@ other schemas under `api/v1/schemas/`. | `declared` | `platform`, `files`, `present`, `missing`, `wrong_hash`, `bytes_needed`, `dests` | Right after `check`, the same counts over the files the platform's own list names (`cores: null`), and their `dests` in the order of the `file` events. A setup that checks its own list reads its answer here, with or without `--declared-only` | | `found` | `platform`, `dest`, `folder`, `path`, `member`, `reason` | Each file to install that this machine already holds, after `check` and before anything is written, in `--check` and `--dry-run` too. `folder` is the `--from` folder as given, or the `bios_dir` of the check when the BIOS folder holds the file at another place, and then `reason` says where in words (`nested folder: ...` for a misplaced file, `the same file is at ...` for one the list installs there too). `path` is the file, and `member` the ZIP member holding the bytes when `path` is an archive, an empty string otherwise. Its copy is reported by a `downloaded` event | | `unreadable` | `platform`, `folder`, `path`, `member`, `reason` | Each file in a `--from` folder that could not be read and may be one the run still needs, right after the `found` events: its size is that of a file no copy was found for, or unknown (a folder that could not be listed, a ZIP that would not open). `member` names the ZIP member, or is empty. `reason` says why in words (`Permission denied`, `it is encrypted, a password is needed`) | +| `saves` | `platform`, `files`, `folder` | After `detect` and before `checking`, when the list holds files read from the frontend's save directory and that directory is known: how many, and the `folder` they go to, where `` and `` stand for one folder per system or per core. When it is not known, the files are counted in the summary's `skipped` instead (`no_save_directory`) | | `download` | `platform`, `files`, `bytes` | Before the downloads start. Counts the files copied from this machine too | | `progress` | `files_done`, `files_total`, `bytes_done`, `bytes_total` | At most every half second while bytes arrive | | `transfer` | `bytes_local`, `bytes_network` | Right after each `progress` event, and once when the files of a `download` event are done. It splits `bytes_done`: `bytes_local` came from this machine (a copy found by `--from` or in the BIOS folder, the pack, a partial copy resumed), `bytes_network` from the network. A download speed is `bytes_network` over the time elapsed | From 849991e8001d46b94b4a3ed3b8312f72622c2e26 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:38:08 +0200 Subject: [PATCH 11/13] docs: name the stop keys a computer has First install said B or Ctrl+C stops the run, where a computer's screen says Esc or Ctrl-C and the b key does nothing. --- tests/test_doc_pages.py | 10 ++++++++++ wiki/first-install.md | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/test_doc_pages.py b/tests/test_doc_pages.py index 73d75bfd..44007174 100644 --- a/tests/test_doc_pages.py +++ b/tests/test_doc_pages.py @@ -682,6 +682,16 @@ class FirstInstallWalkthrough(unittest.TestCase): self.text = _page("first-install.md") self.run = _page("installer.md") + def test_the_stop_keys_are_the_ones_the_screen_names(self): + """The page said "B or Ctrl+C stops it" for a computer, whose screen + says Esc or Ctrl-C, and where the b key does nothing.""" + import install + + section = _section(self.text, "4. Wait") + computer = install._STOP_KEYS["keys"][0].replace("Ctrl-C", "Ctrl+C") + self.assertIn(f"{computer} stops", section) + self.assertIn(f"{install._STOP_KEYS['gptokeyb'][0]} on a handheld", section) + def test_no_hand_kept_size_before_the_run(self): import install diff --git a/wiki/first-install.md b/wiki/first-install.md index 1537570c..5d993b17 100644 --- a/wiki/first-install.md +++ b/wiki/first-install.md @@ -87,7 +87,7 @@ Arrows move, Enter chooses, Esc goes back. A handheld's A and B do the same. ## 3. Install -The next screen counts the missing files and the free space. +The next screen counts missing files and free space. Press Enter on **Install**. **Choose systems** keeps the consoles you play. @@ -96,7 +96,7 @@ card or a folder**, then the card. It stays unchanged. ## 4. Wait -B or Ctrl+C stops it. Run it again to carry on. +Esc or Ctrl+C stops, B on a handheld. Run again to resume. ## 5. Read the result From 60245d1f1d5ac4799d120afb27db668a0327ff84 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:45:43 +0200 Subject: [PATCH 12/13] fix: give the question tests a terminal for output A question now needs stdout on a terminal as well as stdin. The tests that answer one give the run that terminal. --- tests/test_install_selection.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/tests/test_install_selection.py b/tests/test_install_selection.py index 3b4e2353..3b514a60 100644 --- a/tests/test_install_selection.py +++ b/tests/test_install_selection.py @@ -371,6 +371,7 @@ class DecliningTheSizePointsAtTheChoice(unittest.TestCase): repo.env()["RETROBIOS_BASE_URL"] + "/{path}"), \ mock.patch.object(install, "CONFIRM_FROM_BYTES", 1), \ mock.patch("sys.stdin", mock.Mock(isatty=lambda: True)), \ + mock.patch.object(install, "_is_terminal", return_value=True), \ mock.patch("builtins.input", lambda _prompt="": next(replies)), \ contextlib.redirect_stdout(out): try: @@ -741,14 +742,26 @@ class TheOneLinerNarrows(unittest.TestCase): env = dict(repo.env(), HOME=str(home), USERPROFILE=str(home), XDG_CONFIG_HOME=str(home), RETROBIOS_OS="linux") leader, follower = pty.openpty() + # The question needs the output on the terminal too: sent to a + # pipe, the run asks nothing. proc = subprocess.Popen( [sys.executable, str(REPO_ROOT / "install.py")], stdin=follower, - stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, text=True, + stdout=follower, stderr=subprocess.PIPE, env=env, text=True, ) os.close(follower) os.write(leader, b"\x04") - out, err = proc.communicate(timeout=60) + err = proc.communicate(timeout=60)[1] + chunks = [] + while True: + try: + chunk = os.read(leader, 65536) + except OSError: + break + if not chunk: + break + chunks.append(chunk) os.close(leader) + out = b"".join(chunks).decode("utf-8", "replace") self.assertEqual(proc.returncode, 130, out + err) self.assertIn("Stopped. Nothing was installed.", out) self.assertNotIn("carry on", out) From 28d83cbc0f6d36d2cae103011608e9117421b918 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:45:43 +0200 Subject: [PATCH 13/13] 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 d7da0866..55ef1e06 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "0047a2f49b22271b69e9a4731165c3230fffa85fa0045a2f725a8bb8668ea9f5" + $defaultInstallSha256 = "d4e7f003bde7580e8d66e24396d9b9c62b7042373d2c7313cc5b69cf87c601cb" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.sh b/install.sh index c002ce0e..e626d8ba 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="0047a2f49b22271b69e9a4731165c3230fffa85fa0045a2f725a8bb8668ea9f5" +DEFAULT_INSTALL_SHA256="d4e7f003bde7580e8d66e24396d9b9c62b7042373d2c7313cc5b69cf87c601cb" MAX_INSTALLER_BYTES=2097152 cleanup() {