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` |