diff --git a/install.ps1 b/install.ps1 index 06732bcc..0cd73eaa 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "aa47489286d9f69a13b5e526fbf573eba9c1edc76ece9ca403b2a1952090c89a" + $defaultInstallSha256 = "dcf0c308a5a3176b66360e196d10b2ac678bda7f9245b22bc0cf43b0459a7d4d" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index ba823e75..27c1b40f 100644 --- a/install.py +++ b/install.py @@ -8767,6 +8767,19 @@ def _open_from_folders(folders: list[Path]) -> None: other_drives=[str(drive) for drive in index.other_drives]) +def _refuse_closed(dest: Path, named: str) -> None: + """End the run, with its name, on a folder above dest this user cannot open.""" + closed = closed_folder(dest) + if closed is None: + return + message = closed_folder_words(dest, closed) + EVENTS.emit("error", stage="dest", reason="unreadable", platform="", message=message) + print(f"Error: {screen_text(message)}. Give this user access to it, run as " + f"its owner, or choose another folder than the one {named}.", + file=sys.stderr) + sys.exit(1) + + def _missing_dest(dest: Path, ask: bool, named: str = "named with --dest") -> None: """Say that a --dest folder does not exist before anything goes there. @@ -8777,14 +8790,7 @@ def _missing_dest(dest: Path, ask: bool, named: str = "named with --dest") -> No at a terminal the run asks first, and the answer defaults to no. A folder above it this user cannot open ends the run with its name. """ - closed = closed_folder(dest) - if closed is not None: - message = closed_folder_words(dest, closed) - EVENTS.emit("error", stage="dest", reason="unreadable", platform="", message=message) - print(f"Error: {screen_text(message)}. Give this user access to it, run as " - f"its owner, or choose another folder than the one {named}.", - file=sys.stderr) - sys.exit(1) + _refuse_closed(dest, named) if dest.exists(): return nearest = dest.absolute() @@ -9207,6 +9213,11 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: if args.dest is not None and not listing: _missing_dest(args.dest, interactive and not (args.check or args.dry_run or args.yes)) + elif not listing: + # A system_directory under /root read from retroarch.cfg, run without + # sudo: the case probe raised PermissionError before any line. + for _name, path in platforms: + _refuse_closed(path, "detected here") total_downloaded = 0 total_up_to_date = 0 @@ -12495,7 +12506,7 @@ def _install_kind(path: Path) -> str: ("/snap/", "Snap")): if marker in text: return word - if (path.parent / "retroarch.cfg").is_file(): + if _is_file(path.parent / "retroarch.cfg"): return "portable" return "" diff --git a/install.sh b/install.sh index 77f62f89..c901cd7a 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="aa47489286d9f69a13b5e526fbf573eba9c1edc76ece9ca403b2a1952090c89a" +DEFAULT_INSTALL_SHA256="dcf0c308a5a3176b66360e196d10b2ac678bda7f9245b22bc0cf43b0459a7d4d" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/ports/shared/tui.txt b/ports/shared/tui.txt index 0686399a..e7f2a4bb 100644 --- a/ports/shared/tui.txt +++ b/ports/shared/tui.txt @@ -876,6 +876,12 @@ rb_here() { # Ended by Start + Select or a signal: the player asked to leave, and no # button may be left to close a message. : + elif [ "$status" != "0" ] && [ "$(head -n 1 "$RB_STATE/tui.err" 2>/dev/null)" = "No button pressed, closing." ]; then + # Closed for want of a button: the mapper never opened the pad, so no A + # is coming. The error it closed is the next line, shown for + # RB_DEAD_WAIT seconds. + RB_MESSAGE_WAIT="$RB_DEAD_WAIT" rb_message \ + "$(sed -n 2p "$RB_STATE/tui.err") The messages are in $RB_LOG." elif [ "$status" != "0" ] && [ -s "$RB_STATE/tui.err" ]; then # The first line of the farewell: the error screen it closes was read. rb_message "$(head -n 1 "$RB_STATE/tui.err") The messages are in $RB_LOG." diff --git a/tests/test_handheld_launchers.py b/tests/test_handheld_launchers.py index 7f3d71c2..3eef936d 100644 --- a/tests/test_handheld_launchers.py +++ b/tests/test_handheld_launchers.py @@ -484,6 +484,10 @@ if os.environ.get("FAKE_HOLD"): note("ui.ready") time.sleep(60) sys.exit(0) +if os.environ.get("FAKE_IDLE_ERROR"): + # RETROBIOS_IDLE_EXIT on an error screen: the idle line, then the error. + print("No button pressed, closing.", file=sys.stderr) + print("Error: " + os.environ["FAKE_IDLE_ERROR"], file=sys.stderr) if os.environ.get("FAKE_ERROR"): print("Error: " + os.environ["FAKE_ERROR"], file=sys.stderr) sys.exit(int(os.environ.get("FAKE_CODE", "0"))) @@ -1197,6 +1201,19 @@ class ThePortMasterPort(unittest.TestCase): device.assert_mapper_gone() device.assert_console_restored() + def test_an_idle_close_on_an_error_names_the_error_at_once(self): + """The interface closed for want of a button: the mapper never + opened the pad, so no A can close a message. The error it closed is + named, for RB_DEAD_WAIT seconds, not the idle line for 300.""" + device = self.device() + proc = device.run(["bash", self.script], FAKE_IDLE_ERROR="could not reach the file list", + FAKE_CODE="1", RB_MESSAGE_WAIT="300", RB_DEAD_WAIT="1") + self.assertEqual(proc.returncode, 1) + self.assertLess(device.elapsed, 10) + self.assertIn("could not reach the file list", device.screen) + self.assertNotIn("No button pressed", device.screen) + device.assert_console_restored() + def test_start_select_before_the_interface_handles_signals(self): """A SIGTERM before run_tui installs its handlers ends Python on the default action, status 143.""" diff --git a/tests/test_install_experience.py b/tests/test_install_experience.py index 825ce6ee..5e15ae63 100644 --- a/tests/test_install_experience.py +++ b/tests/test_install_experience.py @@ -595,6 +595,44 @@ class AFolderThisUserCannotEnter(unittest.TestCase): self.assertIn(str(lock), errors[0]["message"]) +class ADetectedFolderUnderAClosedOne(unittest.TestCase): + """retroarch.cfg naming a system_directory under a folder this user + cannot open, with no --dest: the case probe raised PermissionError and + the run, and the interface before its first screen, ended on a + traceback. The refusal --dest gets is the one detection gets.""" + + @unittest.skipIf(os.name == "nt" or os.geteuid() == 0, + "a folder without search permission is POSIX") + def test_it_is_refused_in_words(self): + with FakeRepository() as repo: + home = repo.root / "home" + lock = repo.root / "lock" + (home / ".config" / "retroarch").mkdir(parents=True) + lock.mkdir() + (home / ".config" / "retroarch" / "retroarch.cfg").write_text( + f'system_directory = "{lock / "system"}"\n') + env = {**repo.env(), "HOME": str(home), "XDG_CONFIG_HOME": str(home / ".config"), + "RETROBIOS_OS": "linux"} + command = [sys.executable, str(REPO_ROOT / "install.py"), "--platform", + "retroarch", "--no-input"] + os.chmod(lock, 0o000) + try: + runs = {name: subprocess.run(command + extra, env=env, capture_output=True, + text=True, timeout=60, stdin=subprocess.DEVNULL) + for name, extra in (("check", ["--check"]), ("install", []))} + kinds = install.tell_apart([("retroarch", lock / "system", "RetroArch"), + ("retroarch", repo.dest, "RetroArch")]) + finally: + os.chmod(lock, 0o755) + for name, proc in runs.items(): + with self.subTest(run=name): + self.assertNotIn("Traceback", proc.stderr) + self.assertEqual(proc.returncode, 1, proc.stdout + proc.stderr) + self.assertIn(f"this user cannot open {lock} (Permission denied)", + proc.stderr) + self.assertEqual(len(kinds), 2) + + 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/wiki/installer.md b/wiki/installer.md index 51f061a0..63c0693d 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -1387,7 +1387,7 @@ PowerShell as Administrator only for a protected folder. When a folder above the BIOS folder cannot be opened at all (`/root/...` run without `sudo`, a card mounted for another user), the run stops before it -starts: `this user cannot open /root/.config (Permission denied), which holds +starts: `this user cannot open /root (Permission denied), which holds /root/.config/retroarch/system`. The full-screen interface says the same on the folder screen and asks for another folder.