diff --git a/install.ps1 b/install.ps1 index aadf7a7c..68776bf3 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "bf5fd291aeffd7759153f2cfc168ef153a8806e20d708b00e5287fdd63036b20" + $defaultInstallSha256 = "04070f071adf988291854cf4ad8fb6e7a34f8044cfbcb710ba660863c2c6b19e" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 24789925..0a81416f 100644 --- a/install.py +++ b/install.py @@ -1922,6 +1922,36 @@ def launchbox_bios_dirs(root: Path) -> dict[str, Path]: return dirs +_DETECTION_REFUSED: list[str] = [] + + +def _detection_guarded(detect: Callable[..., list]) -> Callable[..., list]: + """Detection that finds nothing, and says why once, where this user + cannot look. + + HOME kept across `su` without `-`: every probe of a config under it + raised PermissionError, and the run ended on a traceback before its + first line. A named --dest still installs. + """ + def guarded(*args: object, **kwargs: object) -> list: + try: + return detect(*args, **kwargs) + except PermissionError as exc: + closed = closed_folder(Path(exc.filename)) if exc.filename else None + folder, why = closed or (Path(str(exc.filename or "")), exc.strerror or str(exc)) + words = (f"Could not look for setups: this user cannot open {folder} ({why}). " + "Run as its owner (su - or sudo -H), or name the BIOS folder " + "with --dest.") + if words not in _DETECTION_REFUSED: + _DETECTION_REFUSED.append(words) + print(f" {screen_text(words)}", file=sys.stderr) + return [] + guarded.__doc__ = detect.__doc__ + guarded.__name__ = detect.__name__ + return guarded + + +@_detection_guarded def detect_frontends(os_type: str, home: Path | None = None) -> list[str]: """Detect frontends that reference emulators without owning a BIOS dir. @@ -2072,6 +2102,7 @@ def _detect_by_path(os_type: str, found: list[tuple[str, Path]]) -> None: found.append((platform_name, _rule_path(bios or path))) +@_detection_guarded def detect_platforms(os_type: str) -> list[tuple[str, Path]]: """Detect installed emulator platforms and their BIOS directories.""" found: list[tuple[str, Path]] = [] @@ -11838,8 +11869,9 @@ def _view_setup(state: TuiState, width: int, height: int) -> tuple: after = ([f"BIOS folder: {state.setups[focus.value][1]}"] if focus.action == "setup" else []) before = ["Found on this machine:" if state.setups else "Nothing was found here."] + status = [state.status] if state.status else [] return ("Where do you play?", - _compose(state, width, height, before, rows, after + state.notes), + _compose(state, width, height, before, rows, after + status + state.notes), ("A/Enter Continue", "B/Esc Quit", "? Help")) @@ -11853,6 +11885,7 @@ def _view_platforms(state: TuiState, width: int, height: int) -> tuple: before = ["Choose the setup that will use the BIOS files."] 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), ("A/Enter Choose", "B/Esc Back", "/ Filter", "? Help")) @@ -12576,6 +12609,9 @@ def tui_start( found = detect_platforms(os_type) frontends = detect_frontends(os_type) cards = [(card_system(mount), mount) for mount in _mounted_cards()] + if _DETECTION_REFUSED: + # Said on the normal screen before this one covered it. + state.status = _DETECTION_REFUSED[0] state.setups = [(name, path, _setup_label(name, path)) for name, path in found] # On the device itself its own card is the system found, by its # own path (muOS reads /run/muos/storage/bios, a bind of MUOS/bios). diff --git a/install.sh b/install.sh index 732c351a..0dd9260a 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="bf5fd291aeffd7759153f2cfc168ef153a8806e20d708b00e5287fdd63036b20" +DEFAULT_INSTALL_SHA256="04070f071adf988291854cf4ad8fb6e7a34f8044cfbcb710ba660863c2c6b19e" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/tests/test_handheld_launchers.py b/tests/test_handheld_launchers.py index 75705624..93351f06 100644 --- a/tests/test_handheld_launchers.py +++ b/tests/test_handheld_launchers.py @@ -1218,7 +1218,9 @@ class ThePortMasterPort(unittest.TestCase): """The launcher keyed on the words without install.py's prefix, and its fake printed them the same way: the test passed, the device still waited 300 s.""" - line = f"RetroBIOS: {install.TUI_IDLE_WORDS}" + state = install.TuiState(os_type="linux", home=Path.home()) + state.idle_closed = True + line = install._farewell_lines(state)[0] tui = (REPO_ROOT / "ports" / "shared" / "tui.txt").read_text(encoding="utf-8") self.assertIn(f'= "{line}" ]', tui) self.assertIn(f'print("{line}", file=sys.stderr)', FAKE_INSTALL) diff --git a/tests/test_install_experience.py b/tests/test_install_experience.py index 5e15ae63..d43ca58d 100644 --- a/tests/test_install_experience.py +++ b/tests/test_install_experience.py @@ -633,6 +633,39 @@ class ADetectedFolderUnderAClosedOne(unittest.TestCase): self.assertEqual(len(kinds), 2) +class AHomeThisUserCannotOpen(unittest.TestCase): + """HOME kept across `su` without `-`: detection stat'ed files under it + and ended the run on a PermissionError traceback before any line.""" + + @unittest.skipIf(os.name == "nt" or os.geteuid() == 0, + "a folder without search permission is POSIX") + def test_detection_says_so_and_the_run_goes_on(self): + with FakeRepository() as repo: + home = repo.root / "home" + home.mkdir() + env = {**repo.env(), "HOME": str(home), "RETROBIOS_OS": "linux"} + runs = {} + os.chmod(home, 0o000) + try: + for name, extra in (("named", ["--platform", "retroarch", "--dest", + str(repo.dest)]), + ("detected", [])): + runs[name] = subprocess.run( + [sys.executable, str(REPO_ROOT / "install.py"), "--check", + "--no-input", *extra], + env=env, capture_output=True, text=True, timeout=60, + stdin=subprocess.DEVNULL) + finally: + os.chmod(home, 0o755) + for name, proc in runs.items(): + with self.subTest(run=name): + self.assertNotIn("Traceback", proc.stderr) + # A named folder needs no detection and is checked. + self.assertIn("0/5 present", runs["named"].stdout) + self.assertIn(f"Could not look for setups: this user cannot open {home}", + runs["detected"].stderr) + + 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.""" diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 2a496a29..64c6c3c3 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -2930,6 +2930,20 @@ class AFolderThisUserCannotOpen(unittest.TestCase): self.assertIn(f"This user cannot open {install._short_path(self.lock, state.home)} " "(Permission denied)", self.said(state)) + def test_a_home_it_cannot_open_is_said_on_the_first_screen(self): + """HOME kept across su without -: detection raised PermissionError + before the first screen.""" + home = self.lock.parent / "home" + home.mkdir() + os.chmod(home, 0o000) + self.addCleanup(os.chmod, home, 0o755) + install._DETECTION_REFUSED.clear() + self.addCleanup(install._DETECTION_REFUSED.clear) + with mock.patch.dict(os.environ, {"HOME": str(home)}), \ + mock.patch.object(install, "_mounted_cards", return_value=[]): + state, _effects = install.tui_start(_args(), "linux") + self.assertIn("Could not look for setups: this user cannot open", self.said(state)) + def test_the_nearest_folder_stops_above_it(self): self.assertEqual(install._nearest_folder(self.dest / "deeper"), self.lock) diff --git a/wiki/installer.md b/wiki/installer.md index 63c0693d..2abe2c5d 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -1086,7 +1086,7 @@ 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 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. +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.