chore: merge the round 5 installer fixes

This commit is contained in:
Abdessamad Derraz committed 2026-10-23 23:49:58 +02:00
commit a94050c1a3
7 files changed
+604 -25

No files matched your search

+1 -1
View File
@@ -11,7 +11,7 @@
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
$defaultInstallSha256 = "d4e7f003bde7580e8d66e24396d9b9c62b7042373d2c7313cc5b69cf87c601cb" $defaultInstallSha256 = "aa47489286d9f69a13b5e526fbf573eba9c1edc76ece9ca403b2a1952090c89a"
$maximumInstallerBytes = 2MB $maximumInstallerBytes = 2MB
$installerExit = 0 $installerExit = 0
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
+167 -21
View File
@@ -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 sent Azahar's keys.txt to a guessed folder where line mode read
savefile_directory, and found no core where line mode found them. 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: if found is not None or _CONFIGS_READ:
return found return found
_CONFIGS_READ.append(True) _CONFIGS_READ.append(True)
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
detect_platforms(_INVOCATION.os_type or detect_os()) 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( def save_directory_for(
@@ -4144,6 +4173,14 @@ def _exists(path: Path) -> bool:
return False 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: def _is_file(path: Path) -> bool:
"""Path.is_file(), False where the place cannot be looked at.""" """Path.is_file(), False where the place cannot be looked at."""
try: try:
@@ -4152,6 +4189,35 @@ def _is_file(path: Path) -> bool:
return False 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: def unreachable_words(exc: OSError) -> str:
"""Why the place of a file could not be looked at, in words.""" """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 " 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 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 --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: 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(): if dest.exists():
return return
nearest = dest.absolute() nearest = dest.absolute()
@@ -8948,6 +9023,18 @@ def _is_terminal(stream: TextIO) -> bool:
return False 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: def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
"""One run of the installer, once the options are read.""" """One run of the installer, once the options are read."""
KEPT.reset() KEPT.reset()
@@ -8960,8 +9047,12 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
# to a prompt nobody could see. # to a prompt nobody could see.
interactive = False interactive = False
if not listing and not args.check: if not listing and not args.check:
print("Output goes to a file, so nothing is asked: the run goes on as " kind = _output_kind(real_stdout)
"with --no-input.", file=sys.stderr) 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") print(f"{term.bold('RetroBIOS')} {term.dim(installer_id())}\n")
os_type = detect_os() os_type = detect_os()
@@ -10090,6 +10181,10 @@ class TuiState:
self.check_summary: dict | None = None self.check_summary: dict | None = None
# The saves event of the check: the files that go to the save folder. # The saves event of the check: the files that go to the save folder.
self.saves: dict | None = None 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: # The confirm screen waits for A even when the plan is unchanged:
# it was opened by Space, which never installs by itself. # it was opened by Space, which never installs by itself.
self.must_confirm: bool = False self.must_confirm: bool = False
@@ -10647,7 +10742,9 @@ def _start_run(state: TuiState, run: str) -> list[tuple]:
state.checked_flags = tui_flags(state) state.checked_flags = tui_flags(state)
elif run in ("confirm", "preview"): elif run in ("confirm", "preview"):
state.dry, state.dry_files, state.dry_summary = None, [], None state.dry, state.dry_files, state.dry_summary = None, [], None
state.dry_saves = None
else: else:
state.done_saves = None
state.download = state.progress = state.reading = None state.download = state.progress = state.reading = None
state.samples, state.local_dests, state.network = [], {}, 0 state.samples, state.local_dests, state.network = [], {}, 0
state.finished, state.summary, state.code, state.stopping = [], None, None, False 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)] 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]: 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) card = card_system(dest)
if card and card[0] == plat and card[1] != 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. # 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: def _nearest_folder(path: Path) -> Path:
"""The deepest folder on path that exists and can be looked at."""
probe = path.absolute() probe = path.absolute()
while not probe.is_dir() and probe.parent != probe: while not _is_dir(probe) and probe.parent != probe:
probe = probe.parent probe = probe.parent
return probe return probe
@@ -11072,6 +11185,10 @@ def _path_key(state: TuiState, key: TuiKey) -> list[tuple]:
if path is None: if path is None:
state.status = "Type a folder first." state.status = "Type a folder first."
return [] return []
closed = closed_folder(path)
if closed is not None:
state.status = _closed_status(state, closed)
return []
state.stack.pop() state.stack.pop()
return _choose(state, state.platform, path) return _choose(state, state.platform, path)
return [] return []
@@ -11270,6 +11387,10 @@ def _take_event(state: TuiState, event: dict) -> None:
state.unreadable.append(event) state.unreadable.append(event)
elif kind == "saves" and state.run == "check": elif kind == "saves" and state.run == "check":
state.saves = event 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": elif kind == "found" and state.run == "install":
state.local_dests[event["dest"]] = (event["folder"], event["reason"]) state.local_dests[event["dest"]] = (event["folder"], event["reason"])
elif state.run == "check": 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("You cannot write to this folder: choose another one.")
lines.append(f"{check['present']} in place, {check['missing']} missing, " lines.append(f"{check['present']} in place, {check['missing']} missing, "
f"{wrong} wrong {'copy' if wrong == 1 else 'copies'}") f"{wrong} wrong {'copy' if wrong == 1 else 'copies'}")
if check is state.check: saves = (state.saves if check is state.check
lines += [_styled(line, "path") for line in _saves_lines(state)] else state.dry_saves if check is state.dry else None)
found_in = _RETROARCH_CONFIGS.get(dest) if dest is not None 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: if found_in:
lines.append(_styled(f"Named by {_short_path(Path(found_in[0]), state.home)}", "path")) lines.append(_styled(f"Named by {_short_path(Path(found_in[0]), state.home)}", "path"))
free = state.facts.get("free") 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:] 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 """Where the files read from the frontend's save directory go: a second
folder this run writes to, named before anything is written.""" folder this run writes to, named before anything is written. saves is
if not state.saves or not state.saves.get("files"): the event of the run the screen shows."""
if not saves or not saves.get("files"):
return [] return []
name = display_name(state.platform) if state.platform else "the frontend" 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 " return [f"{_plural(count, 'file')} read from {name}'s save directory "
f"{'goes' if count == 1 else 'go'} to " 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]: 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: else:
title = "Which folder?" title = "Which folder?"
before = [f"No usual {name} folder here: browse for it or type it."] 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")) ("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(sources)
lines.append(f"BIOS folder: {state.dest}") lines.append(f"BIOS folder: {state.dest}")
if installed + up_to_date: if installed + up_to_date:
lines += _saves_lines(state) lines += _saves_lines(state, state.done_saves)
if space: if space:
lines.append("Free some space, or run again and choose another folder.") lines.append("Free some space, or run again and choose another folder.")
causes: dict[str, list[str]] = {} 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.""" """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 # The config detection read it from says how it was installed: the
# Flatpak's system directory can be anywhere its config names. # 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 "") text = path.as_posix() + (" " + Path(found[0]).as_posix() if found else "")
for marker, word in (("/.var/app/", "Flatpak"), ("/steamapps/", "Steam"), for marker, word in (("/.var/app/", "Flatpak"), ("/steamapps/", "Steam"),
("/snap/", "Snap")): ("/snap/", "Snap")):
@@ -12817,9 +12941,27 @@ def tui_farewell(state: TuiState) -> list[str]:
return [screen_text(line) for line in _farewell_lines(state)] 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]: def _farewell_lines(state: TuiState) -> list[str]:
if state.idle_closed: if not state.idle_closed:
return [f"RetroBIOS: {TUI_IDLE_WORDS}"] 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: if state.screen != "result" and state.download is not None:
# An install ran: what it wrote is said, whatever ended it. # An install ran: what it wrote is said, whatever ended it.
title, lines = tui_outcome(state) title, lines = tui_outcome(state)
@@ -12990,7 +13132,7 @@ def run_tui(args: argparse.Namespace) -> int:
quiet_since = time.monotonic() quiet_since = time.monotonic()
elif (idle is not None and not pressed and state.exit_code is None elif (idle is not None and not pressed and state.exit_code is None
and time.monotonic() - quiet_since >= idle): 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"]: if flags["interrupt"]:
flags["interrupt"] = False flags["interrupt"] = False
if time.monotonic() >= ignore_interrupt_until[0]: 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: def _terminal_gone(terminal: _TuiTerminal) -> bool:
"""Whether the terminal hung up: a closed window answers EIO to """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 import termios # noqa: PLC0415
try: try:
+1 -1
View File
@@ -18,7 +18,7 @@ esac
TEMP_INSTALLER="" TEMP_INSTALLER=""
TEMP_DIRECTORY="" TEMP_DIRECTORY=""
DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
DEFAULT_INSTALL_SHA256="d4e7f003bde7580e8d66e24396d9b9c62b7042373d2c7313cc5b69cf87c601cb" DEFAULT_INSTALL_SHA256="aa47489286d9f69a13b5e526fbf573eba9c1edc76ece9ca403b2a1952090c89a"
MAX_INSTALLER_BYTES=2097152 MAX_INSTALLER_BYTES=2097152
cleanup() { cleanup() {
+63
View File
@@ -565,6 +565,35 @@ class AFolderThisUserCannotEnter(unittest.TestCase):
os.chmod(locked, 0o755) os.chmod(locked, 0o755)
self.assertEqual(state, "wrong_hash") 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): class OutputToAFileAsksNothing(unittest.TestCase):
"""`install.py > log.txt` at a terminal: the menu went into the file """`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.assertNotIn("Enter) install everything", proc.stdout)
self.assertEqual(len(installed), len(FakeRepository.PAYLOADS)) 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): class _Tty(io.StringIO):
def isatty(self) -> bool: def isatty(self) -> bool:
+306
View File
@@ -29,6 +29,7 @@ import tempfile
import termios import termios
import threading import threading
import time import time
import types
import unittest import unittest
import warnings import warnings
from pathlib import Path, PureWindowsPath from pathlib import Path, PureWindowsPath
@@ -2594,6 +2595,60 @@ class IdleExit(unittest.TestCase):
self.assertEqual(install.tui_farewell(state), self.assertEqual(install.tui_farewell(state),
["RetroBIOS: No button pressed, closing."]) ["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): class AReadErrorIsNotAClosedWindow(unittest.TestCase):
"""Any EIO read as the window closing: a failing SD card answers EIO """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()) 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/<core>"}
LINE = "1 file read from RetroArch's save directory goes to ~/MySaves/<core>"
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 ------------------------------------- # --- End to end, on a pseudo-terminal -------------------------------------
+55
View File
@@ -25,6 +25,7 @@ import threading
import unittest import unittest
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from unittest import mock
REPO_ROOT = Path(__file__).resolve().parent.parent REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT / "scripts")) sys.path.insert(0, str(REPO_ROOT / "scripts"))
@@ -552,6 +553,60 @@ class TheInstallerFindsTheSaveDirectory(unittest.TestCase):
False, False)) False, False))
install._check_save_directory(manifest) 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): def test_the_entry_path(self):
saves = (self.app / "saves").resolve() saves = (self.app / "saves").resolve()
install._SAVE_DIRS[self.bios] = install.SavePlace(self.app / "saves", True) install._SAVE_DIRS[self.bios] = install.SavePlace(self.app / "saves", True)
+11 -2
View File
@@ -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 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 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 `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 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. 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 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` | | `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 | | `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 | | `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 | | `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` | | `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 | | `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_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_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 | | `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 ## 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 root, so the same command with `sudo` in front of it succeeds. On Windows, run
PowerShell as Administrator only for a protected folder. 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 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, 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 an unclean removal for one: check it (`fsck`, or `chkdsk` on Windows) and