From abba99d352f2925444ceb37c67697d8f3e49f02c Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:47:36 +0200 Subject: [PATCH] fix: find the save directory for a named folder A run given --dest detected nothing, so retroarch.cfg was never read: the full screen, which names every folder, sent Azahar's keys.txt to a guessed RetroArch/saves or dropped it without a word, where line mode wrote it to savefile_directory. The configs are now read once when a save directory is asked for, and the plan and the result say what was left out. --- install.ps1 | 2 +- install.py | 43 +++++++++++++++++- install.sh | 2 +- tests/test_install_tui.py | 96 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 140 insertions(+), 3 deletions(-) diff --git a/install.ps1 b/install.ps1 index 11bffe1d..2bdb4e8d 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "2103f380bac5e2dc6ab91183283d268cccce81971da89f2963bab752d1518ef2" + $defaultInstallSha256 = "7065a9d52aa9016066c273ec8a5907b09fcce7eaad5a423583c79515f09edb99" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 8ddbcfc1..33955194 100644 --- a/install.py +++ b/install.py @@ -3949,6 +3949,27 @@ def _cfg_true(value: str | None) -> bool: return (value or "").strip().lower() == "true" +_CONFIGS_READ: list[bool] = [] + + +def _retroarch_config_for(bios_path: Path) -> tuple[Path, Path] | None: + """The retroarch.cfg whose system_directory is bios_path, and the + 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. + """ + found = _RETROARCH_CONFIGS.get(bios_path) + 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()) + return _RETROARCH_CONFIGS.get(bios_path) + + def save_directory_for( bios_path: Path, manifest: dict, override: Path | None = None, ) -> SavePlace | str: @@ -3975,7 +3996,7 @@ def save_directory_for( folder = layout["folder"] listed = bool(layout["by_core"]) by_core = listed - found = None if layout.get("by_system") else _RETROARCH_CONFIGS.get(bios_path) + found = None if layout.get("by_system") else _retroarch_config_for(bios_path) if found is not None: cfg_path, app_dir = found for key in ("savefiles_in_content_dir", "sort_savefiles_by_content_enable"): @@ -9920,6 +9941,8 @@ class TuiState: self.dry: dict | None = None self.dry_files: list = [] self.dry_summary: dict | None = None + # The check run's summary: what it left out and why. + self.check_summary: 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 @@ -10427,6 +10450,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.checked_flags = tui_flags(state) elif run in ("confirm", "preview"): state.dry, state.dry_files, state.dry_summary = None, [], None @@ -11060,6 +11084,8 @@ def _take_event(state: TuiState, event: dict) -> None: state.files.append(event) elif kind == "system": state.systems.append(event) + elif kind == "summary": + state.check_summary = event elif state.run in ("confirm", "preview"): if kind == "check": state.dry = event @@ -11413,9 +11439,23 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: lines += _scan_lines(state) if check.get("not_installable"): lines += _unavailable_lines(state) + if check is state.check: + lines += _unplaced_lines(state.check_summary, state) return lines +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.""" + count = sum(group.get("files", 0) for group in (summary or {}).get("skipped") or [] + if group.get("reason") == "no_save_directory") + if not count: + 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."] + + def _view_setup(state: TuiState, width: int, height: int) -> tuple: rows = tui_rows(state) focus = rows[min(state.focus.get("setup", 0), len(rows) - 1)] @@ -11895,6 +11935,7 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: lines += _unavailable_lines(state) for group in summary.get("left_out") or []: lines.append(f"Left out as {group['label']}: {_plural(group['files'], 'file')}.") + lines += _unplaced_lines(summary, state) lines += ["Same choice without questions:", f" {tui_command(state)}"] if tui_keeps_log(state): lines.append(f"Details: {state.log}") diff --git a/install.sh b/install.sh index b0c2495d..559077c8 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="2103f380bac5e2dc6ab91183283d268cccce81971da89f2963bab752d1518ef2" +DEFAULT_INSTALL_SHA256="7065a9d52aa9016066c273ec8a5907b09fcce7eaad5a423583c79515f09edb99" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 73eee5ea..f9bd298d 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -2525,6 +2525,36 @@ class AuditRoundThree(unittest.TestCase): sorted(path.name for path in (running, failed, unknown, other))) +class AuditRoundFour(unittest.TestCase): + """tui_audit_3.md, each finding held where it was found.""" + + UNPLACED = {"reason": "no_save_directory", "label": "no save directory", "files": 2, + "required": 0} + + def test_p1_1_files_left_out_of_the_save_directory_are_said(self): + state = fresh_state() + state.stack = ["setup"] + with mock.patch.object(install.Path, "exists", return_value=True): + install.tui_update(state, Key("enter")) + install.tui_receive(state, ("manifest", "retroarch", MANIFEST)) + summary = {"event": "summary", "mode": "check", "installed": 0, "up_to_date": 0, + "pending": 5, "failed": [], "skipped": [self.UNPLACED], "left_out": [], + "kept": [], "interrupted": False} + for event in [*FILES, *SYSTEMS, CHECK, summary]: + install.tui_receive(state, ("event", state.run_id, event)) + with mock.patch.object(install, "_tui_folder_facts", return_value={ + "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.") + plan = " ".join(" ".join(install.tui_view(state, 120, 40)).split()) + self.assertIn(line, plan) + state.stack.append("result") + state.code = 0 + state.summary = {**summary, "mode": "install", "installed": 5} + self.assertIn(line, install.tui_outcome(state)[1]) + + class Crash(unittest.TestCase): """An unexpected error ends on one sentence and a log, never a traceback.""" @@ -2684,6 +2714,29 @@ class GameDataRepository(FakeRepository): return self +class SaveDirectoryRepository(FakeRepository): + """The fake repository with one file read from RetroArch's save + directory, as Azahar's keys.txt and 105 others of the real list.""" + + KEYS = b"azahar keys" + + def __enter__(self) -> SaveDirectoryRepository: + super().__enter__() + path = self.root / "install" / "retroarch.json" + manifest = json.loads(path.read_text()) + (self.root / "bios" / "keys.txt").write_bytes(self.KEYS) + manifest["files"].append({ + "dest": "Azahar/Azahar/sysdata/keys.txt", "root": "save", + "core_folder": "Azahar", "sha1": hashlib.sha1(self.KEYS).hexdigest(), + "sha256": hashlib.sha256(self.KEYS).hexdigest(), "size": len(self.KEYS), + "repo_path": "bios/keys.txt", "cores": ["azahar"], + "systems": ["nintendo-3ds"]}) + manifest["save_directory"] = {"folder": "saves", "by_core": True} + manifest["base_destination"] = "system" + path.write_text(json.dumps(manifest)) + return self + + @unittest.skipUnless(pyte and pty and hasattr(pty, "fork"), "screen tests need pyte (pip install pyte==0.8.2) and a pty") class EndToEnd(unittest.TestCase): @@ -2735,6 +2788,49 @@ class EndToEnd(unittest.TestCase): # The cursor and the main screen come back before the result is printed. self.assertIn(b"\x1b[?25h\x1b[?1049l", term.raw) + def test_the_save_directory_is_the_one_line_mode_finds(self): + """The interface named the BIOS folder with --dest, so its runs never + read retroarch.cfg: keys.txt went to a guessed RetroArch/saves, or + was dropped without a word, where line mode writes it to the + savefile_directory RetroArch reads.""" + import subprocess + + def written(repo, saves: Path) -> list[str]: + return sorted([*repo.installed(), *( + "saves/" + p.relative_to(saves).as_posix() + for p in saves.rglob("*") if p.is_file())]) + + results = [] + for mode in ("line", "interface"): + with self.subTest(mode=mode): + repo = self.repo(SaveDirectoryRepository) + home = Path(self.env["HOME"]) + saves = home / "MySaves" + saves.mkdir() + cfg = home / ".config" / "retroarch" / "retroarch.cfg" + cfg.write_text(f'system_directory = "{repo.dest}"\n' + f'savefile_directory = "{saves}"\n') + if mode == "line": + proc = subprocess.run( + [sys.executable, str(REPO_ROOT / "install.py"), "--platform", + "retroarch", "--yes"], env=self.env, capture_output=True, + text=True, timeout=60, check=False, stdin=subprocess.DEVNULL) + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + self.assertIn("6 files installed", proc.stdout) + else: + term = self.start() + term.wait("Where do you play?") + term.send("\r") + screen = term.wait("files to download") + self.assertIn("6 files to download", screen) + term.send("\r") + term.wait("6 files installed.") + term.send("\r") + self.assertEqual(term.close(), 0) + results.append(written(repo, saves)) + self.assertIn("saves/Azahar/Azahar/sysdata/keys.txt", results[-1]) + self.assertEqual(results[0], results[1]) + def test_one_system_from_the_checklist(self): repo = self.repo() term = self.start("--platform", "retroarch", "--dest", str(repo.dest), cols=40, rows=15)