mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-24 12:49:03 -05:00
chore: merge the round 5 installer fixes
This commit is contained in:
commit
a94050c1a3
7 files changed
+604
-25
No files matched your search
+1
-1
@@ -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 }
|
||||
|
||||
+167
-21
@@ -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:
|
||||
|
||||
+1
-1
@@ -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() {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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/<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 -------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+11
-2
@@ -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
|
||||
|
||||
Reference in new issue
Block a user