diff --git a/install.ps1 b/install.ps1 index 55ef1e06..06732bcc 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "d4e7f003bde7580e8d66e24396d9b9c62b7042373d2c7313cc5b69cf87c601cb" + $defaultInstallSha256 = "aa47489286d9f69a13b5e526fbf573eba9c1edc76ece9ca403b2a1952090c89a" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 74148f58..ba823e75 100644 --- a/install.py +++ b/install.py @@ -3993,13 +3993,42 @@ def _retroarch_config_for(bios_path: Path) -> tuple[Path, Path] | None: 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) + found = _retroarch_config_at(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) + return _retroarch_config_at(bios_path) + + +def _folder_key(path: Path) -> str: + """The folder as the system names it: absolute, links followed, and + case folded where the system's default filesystem folds it.""" + real = os.path.realpath(path) + return real.casefold() if sys.platform in ("win32", "darwin") else real + + +def _retroarch_config_at(bios_path: Path) -> tuple[Path, Path] | None: + """The config detection read for bios_path, however the folder is + written: --dest relative, through a link, or in another case. The + configs are keyed by the folder as each retroarch.cfg writes it.""" + found = _RETROARCH_CONFIGS.get(bios_path) + if found is not None or not _RETROARCH_CONFIGS: + return found + wanted = _folder_key(bios_path) + for system_dir, config in _RETROARCH_CONFIGS.items(): + if _folder_key(system_dir) == wanted: + return config + # A drive whose filesystem folds case where the system's default does + # not (or the reverse): the filesystem answers for folders that exist. + for system_dir, config in _RETROARCH_CONFIGS.items(): + try: + if os.path.samefile(system_dir, bios_path): + return config + except (OSError, ValueError): + continue + return None def save_directory_for( @@ -4144,6 +4173,14 @@ def _exists(path: Path) -> bool: return False +def _is_dir(path: Path) -> bool: + """Path.is_dir(), False where the place cannot be looked at.""" + try: + return path.is_dir() + except OSError: + return False + + def _is_file(path: Path) -> bool: """Path.is_file(), False where the place cannot be looked at.""" try: @@ -4152,6 +4189,35 @@ def _is_file(path: Path) -> bool: return False +def closed_folder(path: Path) -> tuple[Path, str] | None: + """The folder above path this user cannot open, and why in words, or + None when path can be looked at, there or not. + + A BIOS folder under /root run without sudo, or a card mounted at 0700 + for another user: Path.exists() raised PermissionError before the + first line said where. + """ + try: + path.exists() + return None + except OSError as exc: + failure = exc + probe = path.absolute() + for folder in (*reversed(probe.parents), probe): + try: + os.stat(folder) + except OSError as exc: + if exc.errno in (errno.ENOENT, errno.ENOTDIR): + return None + return folder.parent, exc.strerror or str(exc) + return probe, failure.strerror or str(failure) + + +def closed_folder_words(dest: Path, closed: tuple[Path, str]) -> str: + folder, why = closed + return f"this user cannot open {folder} ({why}), which holds {dest}" + + 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 " @@ -8708,8 +8774,17 @@ def _missing_dest(dest: Path, ask: bool, named: str = "named with --dest") -> No parent, filled, and reported done in a folder no platform reads, and --check said only "0/7 present". One missing level is a BIOS folder not made yet and is created. Several make a path that may be mistyped: - at a terminal the run asks first, and the answer defaults to 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) if dest.exists(): return nearest = dest.absolute() @@ -8948,6 +9023,18 @@ def _is_terminal(stream: TextIO) -> bool: return False +def _output_kind(stream: TextIO) -> str: + """"pipe" or "file" for what stream writes to, "" when neither is known. + `| tee log.txt` shows every line, `> log.txt` none of them.""" + try: + mode = os.fstat(stream.fileno()).st_mode + except (OSError, ValueError, AttributeError, io.UnsupportedOperation): + return "" + if stat.S_ISFIFO(mode) or stat.S_ISSOCK(mode): + return "pipe" + return "file" if stat.S_ISREG(mode) else "" + + def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: """One run of the installer, once the options are read.""" KEPT.reset() @@ -8960,8 +9047,12 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: # 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) + kind = _output_kind(real_stdout) + where = f"Output goes to a {kind}" if kind else "Output is not a terminal" + undo = "the pipe" if kind == "pipe" else "the redirection" + print(f"{where}, so nothing is asked: the run goes on as with --no-input. " + f"Add --no-input to say so, or run it without {undo} to answer.", + file=sys.stderr) print(f"{term.bold('RetroBIOS')} {term.dim(installer_id())}\n") os_type = detect_os() @@ -10090,6 +10181,10 @@ class TuiState: 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 same of the confirm or preview run, and of the install: a + # choice of systems can leave the save file out, or keep it. + self.dry_saves: dict | None = None + self.done_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 @@ -10647,7 +10742,9 @@ def _start_run(state: TuiState, run: str) -> list[tuple]: state.checked_flags = tui_flags(state) elif run in ("confirm", "preview"): state.dry, state.dry_files, state.dry_summary = None, [], None + state.dry_saves = None else: + state.done_saves = None state.download = state.progress = state.reading = None state.samples, state.local_dests, state.network = [], {}, 0 state.finished, state.summary, state.code, state.stopping = [], None, None, False @@ -10670,7 +10767,22 @@ def _start_check(state: TuiState) -> list[tuple]: return [("fetch", state.platform)] +def _closed_status(state: TuiState, closed: tuple[Path, str]) -> str: + return (f"This user cannot open {_short_path(closed[0], state.home)} ({closed[1]}): " + "choose another folder, or give this user access to it.") + + def _choose(state: TuiState, plat: str, dest: Path) -> list[tuple]: + closed = closed_folder(dest) + if closed is not None: + # Nothing can be checked or written there: the folder is asked + # again, on the screen that chose it when it can say why. + state.platform, state.status = plat, _closed_status(state, closed) + if state.stack and state.screen in ("folder", "browse", "path"): + return [] + state.folder_default = (None if plat in CARD_PLATFORMS + else _default_dest(state.os_type, plat)) + return _push(state, "folder") card = card_system(dest) if card and card[0] == plat and card[1] != dest: # A muOS or KNULLI card's root: that system reads a folder below it. @@ -10772,8 +10884,9 @@ def _browse_to(state: TuiState, folder: Path | None, came_from: Path | None = No def _nearest_folder(path: Path) -> Path: + """The deepest folder on path that exists and can be looked at.""" probe = path.absolute() - while not probe.is_dir() and probe.parent != probe: + while not _is_dir(probe) and probe.parent != probe: probe = probe.parent return probe @@ -11072,6 +11185,10 @@ def _path_key(state: TuiState, key: TuiKey) -> list[tuple]: if path is None: state.status = "Type a folder first." return [] + closed = closed_folder(path) + if closed is not None: + state.status = _closed_status(state, closed) + return [] state.stack.pop() return _choose(state, state.platform, path) return [] @@ -11270,6 +11387,10 @@ def _take_event(state: TuiState, event: dict) -> None: state.unreadable.append(event) elif kind == "saves" and state.run == "check": state.saves = event + elif kind == "saves" and state.run in ("confirm", "preview"): + state.dry_saves = event + elif kind == "saves" and state.run == "install": + state.done_saves = event elif kind == "found" and state.run == "install": state.local_dests[event["dest"]] = (event["folder"], event["reason"]) elif state.run == "check": @@ -11630,9 +11751,10 @@ 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 + saves = (state.saves if check is state.check + else state.dry_saves if check is state.dry else None) + lines += [_styled(line, "path") for line in _saves_lines(state, saves)] + found_in = _retroarch_config_at(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")) free = state.facts.get("free") @@ -11669,16 +11791,17 @@ 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]: +def _saves_lines(state: TuiState, saves: dict | None) -> 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"): + folder this run writes to, named before anything is written. saves is + the event of the run the screen shows.""" + if not saves or not saves.get("files"): return [] name = display_name(state.platform) if state.platform else "the frontend" - count = state.saves["files"] + count = 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)}"] + f"{_short_path(Path(saves['folder']), state.home)}"] def _unplaced_lines(summary: dict | None, state: TuiState) -> list[str]: @@ -11730,7 +11853,8 @@ def _view_folder(state: TuiState, width: int, height: int) -> tuple: else: title = "Which folder?" before = [f"No usual {name} folder here: browse for it or type it."] - return (title, _compose(state, width, height, before, tui_rows(state)), + after = [state.status] if state.status else [] + return (title, _compose(state, width, height, before, tui_rows(state), after), ("A/Enter Choose", "B/Esc Back", "? Help")) @@ -12145,7 +12269,7 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: lines.append(sources) lines.append(f"BIOS folder: {state.dest}") if installed + up_to_date: - lines += _saves_lines(state) + lines += _saves_lines(state, state.done_saves) if space: lines.append("Free some space, or run again and choose another folder.") causes: dict[str, list[str]] = {} @@ -12365,7 +12489,7 @@ def _install_kind(path: Path) -> str: """What sets one install of a frontend apart from another of the same.""" # The config detection read it from says how it was installed: the # Flatpak's system directory can be anywhere its config names. - found = _RETROARCH_CONFIGS.get(path) + found = _retroarch_config_at(path) text = path.as_posix() + (" " + Path(found[0]).as_posix() if found else "") for marker, word in (("/.var/app/", "Flatpak"), ("/steamapps/", "Steam"), ("/snap/", "Snap")): @@ -12817,9 +12941,27 @@ def tui_farewell(state: TuiState) -> list[str]: return [screen_text(line) for line in _farewell_lines(state)] +def tui_idle_code(state: TuiState) -> int: + """The exit code of an idle close: an error's or a result's on those + screens, so a launcher tells them from a close with nothing to say.""" + if state.screen == "error": + return state.error_code + if state.screen == "result" and state.code is not None: + return state.code + return 0 + + def _farewell_lines(state: TuiState) -> list[str]: - if state.idle_closed: - return [f"RetroBIOS: {TUI_IDLE_WORDS}"] + if not state.idle_closed: + return _closing_lines(state) + idle = f"RetroBIOS: {TUI_IDLE_WORDS}" + # What an error or a result screen said stays said. + if state.screen in ("error", "result"): + return [idle, *_closing_lines(state)] + return [idle] + + +def _closing_lines(state: TuiState) -> list[str]: if state.screen != "result" and state.download is not None: # An install ran: what it wrote is said, whatever ended it. title, lines = tui_outcome(state) @@ -12990,7 +13132,7 @@ def run_tui(args: argparse.Namespace) -> int: 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 + state.idle_closed, state.exit_code = True, tui_idle_code(state) if flags["interrupt"]: flags["interrupt"] = False if time.monotonic() >= ignore_interrupt_until[0]: @@ -13099,7 +13241,11 @@ def _closed_terminal(exc: BaseException, terminal: _TuiTerminal | None = None) - 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.""" + tcgetattr and to a write, a live one its settings. A Windows console + has no settings to ask, and an empty write to it reaches nothing: the + error is taken as it reads.""" + if os.name == "nt": + return True import termios # noqa: PLC0415 try: diff --git a/install.sh b/install.sh index e626d8ba..77f62f89 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="d4e7f003bde7580e8d66e24396d9b9c62b7042373d2c7313cc5b69cf87c601cb" +DEFAULT_INSTALL_SHA256="aa47489286d9f69a13b5e526fbf573eba9c1edc76ece9ca403b2a1952090c89a" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/tests/test_install_experience.py b/tests/test_install_experience.py index 2a74b539..825ce6ee 100644 --- a/tests/test_install_experience.py +++ b/tests/test_install_experience.py @@ -565,6 +565,35 @@ class AFolderThisUserCannotEnter(unittest.TestCase): os.chmod(locked, 0o755) self.assertEqual(state, "wrong_hash") + @unittest.skipIf(os.name == "nt" or os.geteuid() == 0, + "a folder without search permission is POSIX") + def test_a_bios_folder_under_it_is_refused_in_words(self): + """--dest /root/.config/retroarch/system without sudo: _missing_dest + ended --check and the install on a PermissionError traceback.""" + with FakeRepository() as repo: + lock = repo.root / "lock" + lock.mkdir() + repo.dest = lock / "system" + os.chmod(lock, 0o000) + try: + runs = {"check": repo.run("--check"), "install": repo.run(), + "events": repo.run("--events")} + finally: + os.chmod(lock, 0o755) + self.assertFalse(repo.dest.exists()) + 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.assertIn("choose another folder", proc.stderr) + events = [json.loads(line) for line in runs["events"].stdout.splitlines()] + errors = [e for e in events if e["event"] == "error"] + self.assertEqual([(e["stage"], e["reason"]) for e in errors], + [("dest", "unreadable")]) + self.assertIn(str(lock), errors[0]["message"]) + class OutputToAFileAsksNothing(unittest.TestCase): """`install.py > log.txt` at a terminal: the menu went into the file @@ -589,6 +618,40 @@ class OutputToAFileAsksNothing(unittest.TestCase): self.assertNotIn("Enter) install everything", proc.stdout) self.assertEqual(len(installed), len(FakeRepository.PAYLOADS)) + @unittest.skipIf(os.name == "nt", "a pseudo-terminal for stdin") + def test_a_pipe_is_named_a_pipe(self): + """`install.py | tee log.txt` was told its output went to a file.""" + import pty + + master, slave = pty.openpty() + self.addCleanup(os.close, master) + self.addCleanup(os.close, slave) + with FakeRepository() as repo: + piped = subprocess.run( + [*repo.command(), "--dry-run"], env=repo.env(), stdin=slave, + capture_output=True, text=True, timeout=60, check=False) + log = repo.root / "log.txt" + with open(log, "w", encoding="utf-8") as handle: + filed = subprocess.run( + [*repo.command(), "--dry-run"], env=repo.env(), stdin=slave, + stdout=handle, stderr=subprocess.PIPE, text=True, timeout=60, + check=False) + self.assertIn("Output goes to a pipe, so nothing is asked", piped.stderr) + self.assertIn("run it without the pipe to answer", piped.stderr) + self.assertIn("Output goes to a file, so nothing is asked", filed.stderr) + self.assertIn("run it without the redirection to answer", filed.stderr) + for proc in (piped, filed): + self.assertIn("Add --no-input to say so", proc.stderr) + + def test_the_kind_of_output(self): + self.assertEqual(install._output_kind(io.StringIO()), "") + read, write = os.pipe() + with open(read, "rb") as reader, open(write, "w") as writer: + self.assertEqual(install._output_kind(writer), "pipe") + del reader + with tempfile.TemporaryFile("w", dir=REPO_ROOT / "tmp") as handle: + self.assertEqual(install._output_kind(handle), "file") + class _Tty(io.StringIO): def isatty(self) -> bool: diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 93363b04..f1c2c6c4 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -29,6 +29,7 @@ import tempfile import termios import threading import time +import types import unittest import warnings from pathlib import Path, PureWindowsPath @@ -2594,6 +2595,60 @@ class IdleExit(unittest.TestCase): self.assertEqual(install.tui_farewell(state), ["RetroBIOS: No button pressed, closing."]) + def test_an_error_screen_closes_with_its_code(self): + """Closed idle on "No internet: offline mode", the launcher read 0 + and showed nothing.""" + state = fresh_state() + state.stack = ["setup", "checking"] + install._fail(state, ["Give the file list with --manifest FILE."], 1) + state.error_title = "No internet: offline mode" + self.assertEqual(self.idle_close(state), 1) + self.assertEqual(install.tui_farewell(state), [ + "RetroBIOS: No button pressed, closing.", + "RetroBIOS: No internet: offline mode", + "Give the file list with --manifest FILE."]) + + def test_a_result_closes_with_the_install_code(self): + state = fresh_state() + state.stack = ["setup", "result"] + state.code = 2 + state.summary = {"event": "summary", "mode": "install", "installed": 1, + "up_to_date": 0, "pending": 0, + "failed": [{"dest": "a.bin", "reason": "disk full"}], + "skipped": [], "left_out": [], "kept": [], "interrupted": False} + state.dest = SYSTEM_DIR + self.assertEqual(self.idle_close(state), 2) + farewell = install.tui_farewell(state) + self.assertEqual(farewell[:2], ["RetroBIOS: No button pressed, closing.", + "RetroBIOS: Finished with errors"]) + + def test_any_other_screen_closes_with_0(self): + state = fresh_state() + state.stack = ["setup"] + self.assertEqual(self.idle_close(state), 0) + + def idle_close(self, state: install.TuiState) -> int: + """run_tui on state, no key ever pressed, closed by the idle timer.""" + class Quiet(Crash.FakeTerminal): + def keys(self, timeout): + time.sleep(0.01) + return [] + + out = io.StringIO() + work = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, work, True) + with mock.patch.object(install, "_TuiTerminal", Quiet), \ + mock.patch.object(install.tempfile, "mkdtemp", return_value=str(work)), \ + mock.patch.object(install, "tui_start", return_value=(state, [])), \ + mock.patch.object(install, "_open_local_sources"), \ + mock.patch.object(install, "detect_os", return_value="linux"), \ + mock.patch.dict(os.environ, {install.TUI_IDLE_ENV: "0.05"}), \ + mock.patch.object(sys, "stdout", out), mock.patch.object(sys, "stderr", out): + code = install.run_tui(_args()) + self.assertTrue(state.idle_closed) + self.assertIn("No button pressed, closing.", out.getvalue()) + return code + class AReadErrorIsNotAClosedWindow(unittest.TestCase): """Any EIO read as the window closing: a failing SD card answers EIO @@ -2777,6 +2832,257 @@ class Crash(unittest.TestCase): self.assertNotIn("Traceback", err.getvalue() + out.getvalue()) +class TheSaveFolderLineFollowsTheChoice(unittest.TestCase): + """The save-folder line was the check's: with the system owning the + save file unticked, the result named a folder nothing was written to, + and kept, the Confirm screen did not name it.""" + + SAVES = {"event": "saves", "platform": "retroarch", "files": 1, + "folder": "/home/user/MySaves/"} + LINE = "1 file read from RetroArch's save directory goes to ~/MySaves/" + + def said(self, lines) -> str: + return " ".join(" ".join(lines).split()) + + def run_to_result(self, confirm_saves: bool, install_saves: bool) -> install.TuiState: + state = checked_state() + state.saves = dict(self.SAVES) + state.must_confirm = True + state.stack.append("confirm") + install._start_run(state, "confirm") + dry = {**CHECK, "files": 3, "missing": 3, "wrong_hash": 0} + for event in [*([self.SAVES] if confirm_saves else []), dry]: + install.tui_receive(state, ("event", state.run_id, event)) + install.tui_receive(state, ("ended", state.run_id, 1, [])) + self.confirm = self.said(install.tui_view(state, 200, 40)) + install._start_run(state, "install") + summary = {"event": "summary", "mode": "install", "installed": 3, "up_to_date": 0, + "pending": 0, "failed": [], "skipped": [], "left_out": [], "kept": [], + "interrupted": False} + for event in [*([self.SAVES] if install_saves else []), summary]: + install.tui_receive(state, ("event", state.run_id, event)) + install.tui_receive(state, ("ended", state.run_id, 0, [])) + return state + + def test_unticked_it_is_named_nowhere(self): + state = self.run_to_result(confirm_saves=False, install_saves=False) + self.assertNotIn("save directory", self.confirm) + self.assertEqual(state.screen, "result") + self.assertNotIn("save directory", self.said(install.tui_outcome(state)[1])) + + def test_kept_the_confirm_screen_and_the_result_name_it(self): + state = self.run_to_result(confirm_saves=True, install_saves=True) + self.assertIn(self.LINE, self.confirm) + self.assertIn(self.LINE, self.said(install.tui_outcome(state)[1])) + + +@unittest.skipIf(os.name == "nt" or os.geteuid() == 0, + "a folder without search permission is POSIX") +class AFolderThisUserCannotOpen(unittest.TestCase): + """A BIOS folder under a folder this user cannot enter: given with + --dest the interface ended on a traceback before its first screen, and + typed on the folder screen it ended on a crash log.""" + + def setUp(self): + base = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, base, True) + self.lock = base / "lock" + self.lock.mkdir() + os.chmod(self.lock, 0o000) + self.addCleanup(os.chmod, self.lock, 0o755) + self.dest = self.lock / "system" + + def said(self, state: install.TuiState) -> str: + return " ".join(" ".join(install.tui_view(state, 200, 40)).split()) + + def test_typed_it_stays_on_the_path_screen(self): + state = fresh_state() + state.platform = "retroarch" + state.stack = ["setup", "folder", "path"] + state.path_text = str(self.dest) + self.assertEqual(install.tui_update(state, Key("enter")), []) + self.assertEqual(state.screen, "path") + self.assertEqual(state.path_text, str(self.dest)) + self.assertIn(f"This user cannot open {install._short_path(self.lock, state.home)} " + "(Permission denied)", self.said(state)) + + def test_given_with_dest_opens_the_folder_screen(self): + args = _args(platform="retroarch", dest=str(self.dest)) + with mock.patch.object(install, "detect_platforms", return_value=[]), \ + mock.patch.object(install, "_mounted_cards", return_value=[]): + state, effects = install.tui_start(args, "linux") + self.assertEqual(effects, []) + self.assertEqual(state.screen, "folder") + self.assertIn(f"This user cannot open {install._short_path(self.lock, state.home)} " + "(Permission denied)", self.said(state)) + self.assertIn("choose another folder", self.said(state)) + + def test_chosen_from_the_setups(self): + state = fresh_state() + state.setups = [("retroarch", self.dest, "RetroArch")] + state.stack = ["setup"] + self.assertEqual(install.tui_update(state, Key("enter")), []) + self.assertEqual(state.screen, "folder") + self.assertIn(f"This user cannot open {install._short_path(self.lock, state.home)} " + "(Permission denied)", self.said(state)) + + def test_the_nearest_folder_stops_above_it(self): + self.assertEqual(install._nearest_folder(self.dest / "deeper"), self.lock) + + +# POSIX modules a Windows Python does not have: importing one raises there. +POSIX_ONLY = ("termios", "tty", "pty", "fcntl", "select", "resource", "pwd", "grp") +# The signal names a Windows Python defines. +WINDOWS_SIGNALS = ("SIGABRT", "SIGBREAK", "SIGFPE", "SIGILL", "SIGINT", "SIGSEGV", + "SIGTERM") + + +class WindowsOs(types.ModuleType): + """The os module with os.name "nt", everything else delegated.""" + + def __init__(self) -> None: + super().__init__("os") + self.name = "nt" + + def __getattr__(self, attribute: str) -> object: + return getattr(os, attribute) + + +class SimulatedWindows: + """os.name "nt", the POSIX modules unimportable, the signal module cut + down to the names Windows has, and msvcrt reading the given keys.""" + + def __init__(self, keys: str = "") -> None: + self.keys = list(keys) + self.handlers: dict[int, object] = {} + self.locked: list[int] = [] + self.msvcrt = types.SimpleNamespace( + kbhit=lambda: bool(self.keys), getwch=lambda: self.keys.pop(0), + locking=lambda fd, mode, size: self.locked.append(fd), LK_NBLCK=2) + self.signal = types.ModuleType("signal") + for number, name in enumerate(WINDOWS_SIGNALS, start=100): + setattr(self.signal, name, number) + self.signal.SIG_DFL, self.signal.SIG_IGN = 0, 1 + self.signal.CTRL_C_EVENT, self.signal.CTRL_BREAK_EVENT = 0, 1 + self.signal.signal = self._install + self.signal.getsignal = lambda number: self.handlers.get(number, 0) + self.stack = contextlib.ExitStack() + + def _install(self, number: int, handler: object) -> object: + if number not in {getattr(self.signal, name) for name in WINDOWS_SIGNALS}: + raise ValueError(f"invalid signal value {number}") + previous = self.handlers.get(number, 0) + self.handlers[number] = handler + return previous + + def __enter__(self) -> SimulatedWindows: + self.stack.enter_context(mock.patch.dict( + sys.modules, {**{name: None for name in POSIX_ONLY}, "msvcrt": self.msvcrt})) + # install.py alone sees "nt": pathlib here keeps making PosixPaths. + self.stack.enter_context(mock.patch.object(install, "os", WindowsOs())) + self.stack.enter_context(mock.patch.object(install, "signal", self.signal)) + return self + + def __exit__(self, *exc: object) -> None: + self.stack.close() + + +class WindowsHasNoPosixModules(unittest.TestCase): + """_terminal_gone imported termios unguarded: on Windows a closed console + raised ModuleNotFoundError inside run_tui's except block, a traceback + instead of an exit code. Every path the interface takes on Windows runs + here with the POSIX modules gone.""" + + class Console(io.StringIO): + broken = False + + def fileno(self) -> int: + return 0 + + def isatty(self) -> bool: + return True + + def write(self, text: str) -> int: + if self.broken: + raise OSError(errno.EPIPE, "The pipe is being closed") + return super().write(text) + + def test_the_simulation_refuses_posix_imports(self): + with SimulatedWindows(): + for name in POSIX_ONLY: + with self.subTest(module=name), self.assertRaises(ImportError): + __import__(name) + self.assertFalse(hasattr(install.signal, "SIGTSTP")) + + def test_a_closed_console_is_a_closed_terminal(self): + console = self.Console() + with SimulatedWindows(): + terminal = install._TuiTerminal(console, console) + self.assertTrue(install._closed_terminal( + OSError(errno.EPIPE, "The pipe is being closed"), terminal)) + self.assertFalse(install._closed_terminal( + OSError(errno.ENOENT, "No such file"), terminal)) + + def test_keys_enter_leave_and_cleanup(self): + console = self.Console() + with SimulatedWindows("a\xe0H") as windows: + terminal = install._TuiTerminal(console, console) + terminal.enter() + self.assertEqual([(key.name, key.text) for key in terminal.keys(0.05)], + [("char", "a"), ("up", "")]) + handlers = {windows.signal.SIGINT: windows.signal.SIG_DFL} + self.assertEqual(install._tui_cleanup(terminal, [], handlers, fresh_state()), []) + self.assertIn(install._SCREEN_OFF, console.getvalue()) + + def test_a_child_is_interrupted_through_the_console(self): + run = install._EngineRun.__new__(install._EngineRun) + run.proc = mock.Mock(**{"poll.return_value": None}) + with SimulatedWindows(), mock.patch.object(install.os, "kill") as kill: + run.interrupt(from_key=False) + kill.assert_not_called() + run.proc.send_signal.assert_not_called() + run.interrupt(from_key=True) + kill.assert_called_once_with(0, install.signal.CTRL_C_EVENT) + + def test_work_folder_owner_and_partial_lock(self): + work = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, work, True) + (work / install.TUI_OWNER).write_text(str(os.getpid()), encoding="utf-8") + with SimulatedWindows() as windows: + self.assertIsNone(install._owner_alive(work)) + handle = install._claim_partial(work / "x.bin.part") + self.assertIsNotNone(handle) + handle.close() + self.assertEqual(len(windows.locked), 1) + + def test_run_tui_ends_on_the_closed_console_code(self): + console = self.Console() + real_draw = install._TuiTerminal.draw + + def breaking_draw(terminal, lines, cols): + console.broken = True + real_draw(terminal, lines, cols) + + work = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, work, True) + with SimulatedWindows() as windows, \ + mock.patch.object(install._TuiTerminal, "draw", breaking_draw), \ + mock.patch.object(install, "_drop_closed_terminal"), \ + mock.patch.object(install.tempfile, "mkdtemp", return_value=str(work)), \ + mock.patch.object(install, "tui_start", return_value=(fresh_state(), [])), \ + mock.patch.object(install, "_open_local_sources"), \ + mock.patch.object(install, "detect_os", return_value="windows"), \ + mock.patch.object(sys, "stdin", console), \ + mock.patch.object(sys, "stdout", console), \ + mock.patch.object(sys, "stderr", console): + code = install.run_tui(_args()) + # SIGHUP does not exist there: the closed window's code is 129. + self.assertEqual(code, 129) + self.assertFalse((work / "error.log").exists()) + self.assertTrue(all(handler == 0 for handler in windows.handlers.values()), + windows.handlers) + + # --- End to end, on a pseudo-terminal ------------------------------------- diff --git a/tests/test_save_root.py b/tests/test_save_root.py index f3c7eebc..e3cc58f8 100644 --- a/tests/test_save_root.py +++ b/tests/test_save_root.py @@ -25,6 +25,7 @@ import threading import unittest import zipfile from pathlib import Path +from unittest import mock REPO_ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(REPO_ROOT / "scripts")) @@ -552,6 +553,60 @@ class TheInstallerFindsTheSaveDirectory(unittest.TestCase): False, False)) install._check_save_directory(manifest) + def other_spelling(self, spelled: Path) -> install.SavePlace | str: + """save_directory_for the BIOS folder written another way, the config + keyed as detection writes it, with savefile_directory set.""" + saves = self.root / "MySaves" + saves.mkdir(exist_ok=True) + self.cfg.write_text(f'savefile_directory = "{saves}"\n') + install._RETROARCH_CONFIGS[self.bios] = (self.cfg, self.app) + self.addCleanup(install._SAVE_DIRS.pop, spelled, None) + with mock.patch.object(install, "_CONFIGS_READ", [True]): + return install.save_directory_for(spelled, self.MANIFEST) + + def test_a_relative_bios_folder_finds_its_config(self): + """cd ~/.config/retroarch && install.py --dest system: the save files + went to a guessed saves/ beside it.""" + self.addCleanup(os.chdir, os.getcwd()) + os.chdir(self.app) + self.assertEqual(self.other_spelling(Path("system")), + install.SavePlace(self.root / "MySaves", True)) + + def test_a_bios_folder_through_a_link_finds_its_config(self): + """EmuDeck links RetroArch's system folder to ~/Emulation/bios.""" + link = self.root / "bios-link" + link.symlink_to(self.bios, target_is_directory=True) + self.assertEqual(self.other_spelling(link), + install.SavePlace(self.root / "MySaves", True)) + linked_app = self.root / "RA-link" + linked_app.symlink_to(self.app, target_is_directory=True) + self.assertEqual(self.other_spelling(linked_app / "system"), + install.SavePlace(self.root / "MySaves", True)) + + def test_case_only_differs_where_the_system_folds_it(self): + spelled = Path(str(self.bios).upper()) + with mock.patch.object(install.sys, "platform", "linux"): + self.assertNotEqual(self.other_spelling(spelled), + install.SavePlace(self.root / "MySaves", True)) + install._SAVE_DIRS.pop(spelled, None) + with mock.patch.object(install.sys, "platform", "darwin"): + self.assertEqual(self.other_spelling(spelled), + install.SavePlace(self.root / "MySaves", True)) + + def test_the_cores_folder_through_another_spelling(self): + cores = self.root / "MyCores" + cores.mkdir() + self.cfg.write_text(f'libretro_directory = "{cores}"\n') + install._RETROARCH_CONFIGS[self.bios] = (self.cfg, self.app) + link = self.root / "bios-link" + link.symlink_to(self.bios, target_is_directory=True) + self.addCleanup(os.chdir, os.getcwd()) + os.chdir(self.app) + with mock.patch.object(install, "_CONFIGS_READ", [True]): + for spelled in (Path("system"), link, Path("./system/")): + with self.subTest(spelled=str(spelled)): + self.assertEqual(install.core_dirs("retroarch", spelled), [cores]) + def test_the_entry_path(self): saves = (self.app / "saves").resolve() install._SAVE_DIRS[self.bios] = install.SavePlace(self.app / "saves", True) diff --git a/wiki/installer.md b/wiki/installer.md index f92d3d65..51f061a0 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -1087,6 +1087,9 @@ 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. +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. 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 @@ -1289,7 +1292,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` | | `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`). `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`). `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` | @@ -1341,7 +1344,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 waits for a first button, counted while nothing runs. 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, or with the code of the error or result screen it closed on | | `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 @@ -1382,6 +1385,12 @@ owns it (`ls -la`). On Batocera, Recalbox and ROCKNIX the folder belongs to root, so the same command with `sudo` in front of it succeeds. On Windows, run 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 +/root/.config/retroarch/system`. The full-screen interface says the same on +the folder screen and asks for another folder. + A file system mounted read-only refuses root too, so the installer does not suggest `sudo` for it. Linux remounts a card or drive read-only after an error, an unclean removal for one: check it (`fsck`, or `chkdsk` on Windows) and