chore: merge the round 4 installer fixes

This commit is contained in:
Abdessamad Derraz committed 2026-10-23 22:48:24 +02:00
commit 14e3923533
12 files changed
+541 -73

No files matched your search

+1 -1
View File
@@ -11,7 +11,7 @@
$ErrorActionPreference = "Stop"
$defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
$defaultInstallSha256 = "0047a2f49b22271b69e9a4731165c3230fffa85fa0045a2f725a8bb8668ea9f5"
$defaultInstallSha256 = "d4e7f003bde7580e8d66e24396d9b9c62b7042373d2c7313cc5b69cf87c601cb"
$maximumInstallerBytes = 2MB
$installerExit = 0
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
+204 -57
View File
@@ -546,6 +546,11 @@ EVENT_KEYS: dict[str, tuple[str, ...]] = {
# size of a file no copy was found for, or of no known size (a folder,
# a ZIP that would not open). member as in found, reason in words.
"unreadable": ("platform", "folder", "path", "member", "reason"),
# The files of the list read from the frontend's save directory, and
# where they go (<system> and <core> stand for a folder per system or
# per core), once that directory is known. Not written when it is not:
# the summary's skipped then counts them (no_save_directory).
"saves": ("platform", "files", "folder"),
"download": ("platform", "files", "bytes"),
"progress": ("files_done", "files_total", "bytes_done", "bytes_total"),
# Right after each progress event, and once when the files of a
@@ -696,9 +701,9 @@ def _declared_state(copy: dict, entry: dict, bios_path: Path) -> str:
path = _entry_path(bios_path, entry)
except ValueError:
return "missing"
if not path.is_file():
return "missing" if not path.exists() else "wrong_hash"
try:
if not path.is_file():
return "missing" if not path.exists() else "wrong_hash"
verdict = _classify_local(copy, path)
except OSError:
return "wrong_hash"
@@ -1443,7 +1448,7 @@ def _rule_matches(rule: _OsRule, release: dict[str, str]) -> bool:
except OSError:
return False
return re.search(pattern, text, re.IGNORECASE | re.MULTILINE) is not None
return all(_on_device(value).exists() for value in rule.values)
return all(_exists(_on_device(value)) for value in rule.values)
def _config_files(pattern: str) -> list[tuple[Path, Path]]:
@@ -2302,11 +2307,12 @@ def core_dirs(plat: str, bios_path: Path) -> list[Path]:
"""The directories the RetroArch writing to bios_path loads cores from.
The config detection read for this system directory names them in
libretro_directory. Without one, a retroarch.cfg beside the system
libretro_directory, read here when the run was given --dest, as the
interface's runs all are. Without one, a retroarch.cfg beside the system
directory is the portable layout. Unset, the setting is cores/ under
RetroArch's own directory.
"""
found = _RETROARCH_CONFIGS.get(bios_path)
found = _retroarch_config_for(bios_path)
if found is None and (bios_path.parent / "retroarch.cfg").is_file():
found = (bios_path.parent / "retroarch.cfg", bios_path.parent)
dirs: list[Path] = []
@@ -3327,13 +3333,16 @@ def install_bounded_opener() -> None:
# stuck proxy) held the run 93 s on three waits of 30 s. A list is a few
# MB, never a slow stream.
LIST_TIMEOUT = 15.0
# Attempts after a server that took the connection went silent: once more,
# a stall is rarely a blip.
# Attempts after a wait ran out, for the connection (a TLS handshake a
# captive portal never finishes) or for the answer once connected (a stuck
# proxy): once more, a stall is rarely a blip.
STALLED_ATTEMPTS = 2
def _stalled(exc: BaseException) -> bool:
"""Whether exc is a wait that ran out, the connection made."""
"""Whether exc is a wait that ran out: urllib raises a connect timeout
as a URLError whose reason is the timeout, a silent answer as the
timeout itself."""
reason = getattr(exc, "reason", None)
return isinstance(exc, socket.timeout) or isinstance(reason, socket.timeout)
@@ -3979,9 +3988,10 @@ def _retroarch_config_for(bios_path: Path) -> tuple[Path, Path] | None:
folder ':' expands to there.
A run given --dest detects nothing, so the configs are read here, once
and silently, the first time a save directory is asked for: the
interface names every folder with --dest, and its runs sent Azahar's
keys.txt to a guessed folder where line mode read savefile_directory.
and silently, the first time a save directory or the cores folder is
asked for: the interface names every folder with --dest, and its runs
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)
if found is not None or _CONFIGS_READ:
@@ -4069,6 +4079,8 @@ def place_save_entries(
where += f"{os.sep}<core>"
print(f" {_plural(len(saved), 'file')} read from {display}'s save directory "
f"go to {where}")
EVENTS.emit("saves", platform=str(manifest.get("platform", "")),
files=len(saved), folder=where)
return files, []
print(f" {_plural(len(saved), 'file')} read from {display}'s save directory "
f"left out: {place}")
@@ -4090,7 +4102,13 @@ def _classify_local(entry: dict, dest: Path) -> str:
for name in ("sha256", "sha1")
if entry.get(name)
}
if not dest.is_file():
try:
is_file = dest.is_file()
except OSError:
# A folder above it this user cannot enter: what sits there is not
# known to be the right file.
return "mismatched"
if not is_file:
# A folder at the file's place: --check says so, an install fails
# on that file alone.
return "mismatched"
@@ -4107,6 +4125,39 @@ def _classify_local(entry: dict, dest: Path) -> str:
return "mismatched"
def _held(path: Path) -> bool:
"""Whether something may sit at path. A place that cannot be looked at
(a folder above it this user cannot enter, a card that answers a read
error) counts as held, so the file is reported unreadable and the run
goes on with the others: Path.exists() lets PermissionError through."""
try:
return path.exists()
except OSError:
return True
def _exists(path: Path) -> bool:
"""Path.exists(), False where the place cannot be looked at."""
try:
return path.exists()
except OSError:
return False
def _is_file(path: Path) -> bool:
"""Path.is_file(), False where the place cannot be looked at."""
try:
return path.is_file()
except OSError:
return False
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 "
f"have access to its folder")
def check_local(
files: list[dict], bios_path: Path, jobs: int = 8,
term: "Terminal | None" = None,
@@ -4122,7 +4173,7 @@ def check_local(
for f in files:
dest = _entry_path(bios_path, f)
if dest.exists():
if _held(dest):
present.append((f, dest))
else:
to_download.append(f)
@@ -4362,7 +4413,7 @@ def _move_aside(dest: Path, kept: Path) -> None:
os.rename(dest, kept)
return
except OSError:
if not dest.is_file():
if not _is_file(dest):
raise
scratch = kept.parent / f".{kept.name}.part"
try:
@@ -4497,7 +4548,11 @@ def _download_one(
if _write_refused(exc):
return f["dest"], False, NOT_WRITABLE
return f["dest"], False, exc.strerror or str(exc)
if dest.is_dir():
try:
in_place_folder = dest.is_dir()
except OSError as exc:
return f["dest"], False, unreachable_words(exc)
if in_place_folder:
# Fetched, it failed only at the rename, on an errno and two paths.
return f["dest"], False, FOLDER_IN_PLACE
@@ -4752,7 +4807,7 @@ def _replace_released(source: Path, dest: Path, expected: dict[str, str]) -> Non
os.replace(source, dest)
return
except FileNotFoundError:
if expected and dest.is_file() and _digest_file(dest, tuple(expected)) == expected:
if expected and _is_file(dest) and _digest_file(dest, tuple(expected)) == expected:
return
raise
except PermissionError as exc:
@@ -4771,7 +4826,7 @@ def _drop_full_partial(dest: Path, expected_size: int) -> None:
left to it.
"""
path = dest.parent / f".{dest.name}.part"
if not path.is_file():
if not _is_file(path):
return
try:
handle = _claim_partial(path)
@@ -4913,7 +4968,7 @@ def _clear_read_only(dest: Path) -> None:
def _read_only_file(dest: Path) -> bool:
return dest.is_file() and not os.access(dest, os.W_OK) and _folder_writable(dest.parent)
return _is_file(dest) and not os.access(dest, os.W_OK) and _folder_writable(dest.parent)
def _folder_writable(path: Path) -> bool:
@@ -5017,7 +5072,7 @@ def _on_read_only_mount(path: Path) -> bool:
if not hasattr(os, "statvfs"):
return False
probe = path
while not probe.exists() and probe.parent != probe:
while not _held(probe) and probe.parent != probe:
probe = probe.parent
try:
return bool(os.statvfs(probe).f_flag & os.ST_RDONLY)
@@ -5247,7 +5302,7 @@ def _absent_required(omitted: list[dict], bios_path: Path) -> list[dict]:
if not entry.get("required", True) or not dest:
continue
try:
placed = _entry_path(bios_path, {**entry, "dest": dest}).is_file()
placed = _is_file(_entry_path(bios_path, {**entry, "dest": dest}))
except ValueError:
# A path climbing out of the folder is never one we read, and one
# below a save directory this run does not know is not looked at.
@@ -5576,11 +5631,12 @@ def data_directory_state(entry: dict, bios_path: Path) -> tuple[Path, dict[str,
present = 0
for rel, meta in entry["files"].items():
target = _destination_path(root, rel)
if (
target.is_file()
and target.stat().st_size == meta["size"]
and _sha1_file(target) == meta["sha1"]
):
try:
held = (target.is_file() and target.stat().st_size == meta["size"]
and _sha1_file(target) == meta["sha1"])
except OSError:
held = False
if held:
present += 1
else:
needed[rel] = meta
@@ -7154,6 +7210,10 @@ class FolderIndex:
# was opened again, its directory parsed again, for each member.
self._zips: dict[Path, zipfile.ZipFile] = {}
self._zip_lock = threading.Lock()
# The places this run writes, resolved. An archive among them is
# never kept open: Windows refuses to move aside or replace a file
# a process holds, and a --from folder can hold the BIOS folder.
self._written: set[Path] = set()
# (path, member or "") -> (its size or None when unknown, why) of each
# file, member or folder below root that could not be read.
self.unreadable: dict[tuple[Path, str], tuple[int | None, str]] = {}
@@ -7231,7 +7291,7 @@ class FolderIndex:
A few stay open, the least recently read closed first: a card of
MAME sets would otherwise hold a descriptor per archive. Closing
one leaves a member being read open until it is closed itself.
Only archives the list does not install are read this way.
An archive this run writes is never read this way (see writes).
"""
archive = self._zips.pop(path, None)
if archive is None:
@@ -7241,15 +7301,38 @@ class FolderIndex:
self._zips[path] = archive
return archive
def writes(self, paths: Collection[Path]) -> None:
"""Note the places this run writes, resolved, and close the archives
among them: the next read of one opens it and closes it at once."""
with self._zip_lock:
self._written.update(paths)
for held in [held for held in self._zips if self._is_written(held)]:
self._zips.pop(held).close()
def _is_written(self, path: Path) -> bool:
return bool(self._written) and (
path in self._written or Path(os.path.realpath(path)) in self._written)
def infolist(self, path: Path) -> list[zipfile.ZipInfo]:
with self._zip_lock:
if self._is_written(path):
with zipfile.ZipFile(path) as archive:
return archive.infolist()
return self._archive(path).infolist()
@contextlib.contextmanager
def open_member(self, path: Path, member: str) -> Iterator[BinaryIO]:
"""A member of an archive under the folder, read from any thread."""
with self._zip_lock:
handle = self._archive(path).open(member)
if self._is_written(path):
archive = zipfile.ZipFile(path)
try:
handle = archive.open(member)
finally:
# The member holds the file until it is closed itself.
archive.close()
else:
handle = self._archive(path).open(member)
try:
yield handle
finally:
@@ -7701,9 +7784,9 @@ def _wrong_contents(
owners: dict[str, list[dict]] | None = None,
) -> str:
"""Why the file at an entry's place is not the one the list installs."""
if path.is_dir():
return FOLDER_IN_PLACE
try:
if path.is_dir():
return FOLDER_IN_PLACE
size = path.stat().st_size
with open(path, "rb") as handle:
head = handle.read(4)
@@ -7869,6 +7952,8 @@ def plan_local_copies(
written.add(Path(os.path.realpath(_entry_path(bios_path, entry))))
except ValueError:
continue
for index in ([bios_index] if bios_index is not None else []) + folders:
index.writes(written)
copies: dict[int, LocalCopy] = {}
for entry in entries:
copy = None
@@ -8640,12 +8725,12 @@ def _missing_dest(dest: Path, ask: bool, named: str = "named with --dest") -> No
sys.exit(2)
def _no_core_found(plat: str, bios_path: Path, searched: list[Path], dest_given: bool) -> None:
def _no_core_found(plat: str, bios_path: Path, searched: list[Path]) -> None:
"""Refuse --installed-cores with no core, and say how to point at them.
With --dest, detection does not run, and without a retroarch.cfg beside
the folder nothing names the cores folder: the run stopped on "no known
directory" with no way forward.
With no retroarch.cfg naming the folder as its system_directory, and
none beside it, nothing names the cores folder: the run stopped on "no
known directory" with no way forward.
"""
where = (f"none in {', '.join(map(str, searched))}" if searched
else f"no retroarch.cfg was found for {bios_path} to name their folder")
@@ -8655,9 +8740,6 @@ def _no_core_found(plat: str, bios_path: Path, searched: list[Path], dest_given:
"retroarch.cfg, cores/ beside RetroArch by default):", file=sys.stderr)
command = _INVOCATION.command(plat, bios_path, without=("--installed-cores", "--cores-dir"))
print(f" {command} --cores-dir <cores folder>", file=sys.stderr)
if dest_given:
print(" Or run it without --dest: detection reads retroarch.cfg, which names "
"that folder.", file=sys.stderr)
print(" Or run it without --installed-cores to install every file.", file=sys.stderr)
sys.exit(1)
@@ -8721,7 +8803,7 @@ def _card_rule_matches(rule: _OsRule, root: Path) -> bool:
except OSError:
return False
return re.search(pattern, text, re.MULTILINE) is not None
return all((root / value).exists() for value in rule.values)
return all(_exists(root / value) for value in rule.values)
def card_system(root: Path) -> tuple[str, Path, str] | None:
@@ -8858,6 +8940,14 @@ def _unknown_target(target: str, plat: str, targets: dict) -> None:
print(f" {plat} publishes no target list", file=sys.stderr)
def _is_terminal(stream: TextIO) -> bool:
isatty = getattr(stream, "isatty", None)
try:
return bool(isatty and isatty())
except (OSError, ValueError):
return False
def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
"""One run of the installer, once the options are read."""
KEPT.reset()
@@ -8865,6 +8955,13 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
interactive = sys.stdin.isatty() and not args.no_input and not args.quiet
listing = (args.list_targets or args.list_systems or args.list_cores
or args.list_emulators)
if interactive and not _is_terminal(real_stdout):
# A question goes to stdout: sent to a file, it waited on an answer
# 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)
print(f"{term.bold('RetroBIOS')} {term.dim(installer_id())}\n")
os_type = detect_os()
@@ -9083,7 +9180,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
)
sys.exit(1)
if args.installed_cores and not cores_found[(plat_name, bios_path)]:
_no_core_found(plat_name, bios_path, searched, bool(args.dest))
_no_core_found(plat_name, bios_path, searched)
for plat_name, bios_path in platforms:
if bios_path not in installed_paths:
@@ -9991,6 +10088,8 @@ class TuiState:
self.idle_closed = False
# The check run's summary: what it left out and why.
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 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
@@ -10045,9 +10144,9 @@ TUI_PAD_ROUTES = ("gptokeyb", "gptokeyb_muxterm", "muxterm", "evmapy", "miyoo")
_STEAM_MARKERS = ("SteamGamepadUI", "SteamDeck", "SteamGameId", "GAMESCOPE_WAYLAND_DISPLAY")
# Seconds the first screen waits for a first key before it closes, set by a
# launcher whose mapper may never open the pad: the screen would otherwise
# hold a handheld that has no other way out.
# Seconds the interface waits for a first key, no run going, before it
# closes, set by a launcher whose mapper may never open the pad: the screen
# would otherwise hold a handheld that has no other way out.
TUI_IDLE_ENV = "RETROBIOS_IDLE_EXIT"
TUI_IDLE_WORDS = "No button pressed, closing."
@@ -10356,13 +10455,13 @@ def _plan_rows(state: TuiState) -> list[TuiRow]:
rows = [TuiRow("install", label)]
if _offline_only() and not _plan_copies(state, check):
# Nothing here to copy, and the network refused: Install would
# only fail every file.
# only fail every file, in this folder or another.
rows = []
if state.facts.get("writable") is False or _short_of_space(state, check):
# Every file would fail alike, or the run would stop before the
# first one: the way out is another folder.
rows = [TuiRow("folder", "Choose another folder"), TuiRow("install", "Install anyway"),
*rows[1:]]
rows = [TuiRow("folder", "Choose another folder"),
*([TuiRow("install", "Install anyway")] if rows else [])]
if records_selection((state.manifest or {}).get("files", [])) and len(state.systems) > 1:
rows.append(TuiRow("systems", "Choose systems"))
rows.append(TuiRow("fromcard", "Copy the BIOS files from your old card or a folder"))
@@ -10544,7 +10643,7 @@ def _start_run(state: TuiState, run: str) -> list[tuple]:
# dropped the --system the command line gave.
state.check, state.files, state.systems, state.reading = None, [], [], None
state.found, state.scanned, state.unreadable = [], [], []
state.check_summary = None
state.check_summary = state.saves = None
state.checked_flags = tui_flags(state)
elif run in ("confirm", "preview"):
state.dry, state.dry_files, state.dry_summary = None, [], None
@@ -11169,6 +11268,8 @@ def _take_event(state: TuiState, event: dict) -> None:
state.scanned.append(event)
elif kind == "unreadable" and state.run == "check":
state.unreadable.append(event)
elif kind == "saves" and state.run == "check":
state.saves = event
elif kind == "found" and state.run == "install":
state.local_dests[event["dest"]] = (event["folder"], event["reason"])
elif state.run == "check":
@@ -11500,6 +11601,11 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list:
elif need and fetch and offline:
headline = (f"{_plural(fetch, 'file')} {'needs' if fetch == 1 else 'need'} the "
"internet, which --offline does not use")
elif need and fetch and LOCAL.pack is not None:
# Nothing comes from the network: "to download" on a handheld with
# no Wi-Fi read as a failure on its way.
headline = (f"{_plural(fetch, 'file')} to install from the pack, "
f"{format_size(fetch_bytes)}")
elif need and fetch:
headline = f"{_plural(fetch, 'file')} to download, {format_size(fetch_bytes)}"
elif need:
@@ -11524,6 +11630,8 @@ 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
if found_in:
lines.append(_styled(f"Named by {_short_path(Path(found_in[0]), state.home)}", "path"))
@@ -11561,6 +11669,18 @@ 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]:
"""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"):
return []
name = display_name(state.platform) if state.platform else "the frontend"
count = state.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)}"]
def _unplaced_lines(summary: dict | None, state: TuiState) -> list[str]:
"""The files read from the frontend's save directory that the run left
out, that directory being unknown: said, never silent."""
@@ -12012,7 +12132,8 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]:
head = f"The installer stopped with code {state.code}."
elif installed:
title = "Done"
head = f"{_plural(installed, 'file')} installed."
source = " from the pack" if LOCAL.pack is not None else ""
head = f"{_plural(installed, 'file')} installed{source}."
if up_to_date:
head += f" {_plural(up_to_date, 'file')} already in place."
else:
@@ -12023,6 +12144,8 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]:
if sources:
lines.append(sources)
lines.append(f"BIOS folder: {state.dest}")
if installed + up_to_date:
lines += _saves_lines(state)
if space:
lines.append("Free some space, or run again and choose another folder.")
causes: dict[str, list[str]] = {}
@@ -12041,7 +12164,9 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]:
shown += 1
if shown > 20:
lines.append(f" ... and {shown - 20} more")
if not summary and not space:
if not summary and not space and state.code != 130:
# A stop asked for (Ctrl-C, Start+Select, the window closed) is said
# by the line above: the log's last lines are for a run that died.
lines += [f" {line}" for line in state.tail[-8:]]
run = Invocation()
run.os_type = state.os_type
@@ -12822,7 +12947,10 @@ def run_tui(args: argparse.Namespace) -> int:
if number is not None:
handlers[number] = signal.signal(number, handler)
idle = tui_idle_exit()
first_screen, opened, pressed = state.screen, time.monotonic(), False
# Counted from the moment the screen last waited for a key with no
# run going: given --platform, the first screen moves on by itself
# to the plan once the check is read, and no key comes either.
quiet_since, pressed = time.monotonic(), False
sys.stdout = sys.stderr = buffer
try:
_prune_work_folders(work.parent, time.time())
@@ -12858,9 +12986,10 @@ def run_tui(args: argparse.Namespace) -> int:
perform(tui_update(state, key))
if state.exit_code is not None:
break
if (idle is not None and not pressed and state.exit_code is None
and state.screen == first_screen
and time.monotonic() - opened >= idle):
if state.running:
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
if flags["interrupt"]:
flags["interrupt"] = False
@@ -12890,7 +13019,7 @@ def run_tui(args: argparse.Namespace) -> int:
# The result is printed in the terminal on the way out.
state.exit_code = state.code if state.code is not None else 1
except Exception as exc: # noqa: BLE001
if flags["term"] or _closed_terminal(exc):
if flags["term"] or _closed_terminal(exc, terminal):
# The window closed under a screen that was changing, or the
# SIGCONT that comes with its SIGHUP put the screen back: the
# write failed before the signal was read. That is its exit.
@@ -12954,13 +13083,31 @@ def _tui_startup(args: argparse.Namespace) -> tuple[TuiState, list[tuple]] | Non
_CLOSED_TERMINAL = frozenset({errno.EIO, errno.EPIPE, errno.ENXIO})
def _closed_terminal(exc: BaseException) -> bool:
def _closed_terminal(exc: BaseException, terminal: _TuiTerminal | None = None) -> bool:
"""Whether exc is the terminal gone: an OSError, or a termios.error,
whose first argument is the errno, answering EIO, EPIPE or ENXIO."""
whose first argument is the errno, answering EIO, EPIPE or ENXIO, and,
given the terminal, that terminal no longer answering. A failing SD
card answers EIO too, and that read error is kept in a crash log."""
if isinstance(exc, OSError):
return exc.errno in _CLOSED_TERMINAL
if type(exc).__name__ == "error" and type(exc).__module__ == "termios":
return bool(exc.args) and exc.args[0] in _CLOSED_TERMINAL
closed = exc.errno in _CLOSED_TERMINAL
elif type(exc).__name__ == "error" and type(exc).__module__ == "termios":
closed = bool(exc.args) and exc.args[0] in _CLOSED_TERMINAL
else:
closed = False
return closed and (terminal is None or _terminal_gone(terminal))
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."""
import termios # noqa: PLC0415
try:
termios.tcgetattr(terminal.fd)
terminal.stdout.write("")
terminal.stdout.flush()
except (termios.error, OSError, ValueError):
return True
return False
+1 -1
View File
@@ -18,7 +18,7 @@ esac
TEMP_INSTALLER=""
TEMP_DIRECTORY=""
DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
DEFAULT_INSTALL_SHA256="0047a2f49b22271b69e9a4731165c3230fffa85fa0045a2f725a8bb8668ea9f5"
DEFAULT_INSTALL_SHA256="d4e7f003bde7580e8d66e24396d9b9c62b7042373d2c7313cc5b69cf87c601cb"
MAX_INSTALLER_BYTES=2097152
cleanup() {
+29
View File
@@ -21,6 +21,7 @@
"declared",
"found",
"unreadable",
"saves",
"download",
"progress",
"transfer",
@@ -64,6 +65,9 @@
{
"$ref": "#/$defs/unreadable"
},
{
"$ref": "#/$defs/saves"
},
{
"$ref": "#/$defs/download"
},
@@ -486,6 +490,31 @@
}
}
},
"saves": {
"type": "object",
"additionalProperties": false,
"required": [
"event",
"platform",
"files",
"folder"
],
"properties": {
"event": {
"const": "saves"
},
"platform": {
"type": "string"
},
"files": {
"type": "integer",
"minimum": 0
},
"folder": {
"type": "string"
}
}
},
"download": {
"type": "object",
"additionalProperties": false,
+10
View File
@@ -682,6 +682,16 @@ class FirstInstallWalkthrough(unittest.TestCase):
self.text = _page("first-install.md")
self.run = _page("installer.md")
def test_the_stop_keys_are_the_ones_the_screen_names(self):
"""The page said "B or Ctrl+C stops it" for a computer, whose screen
says Esc or Ctrl-C, and where the b key does nothing."""
import install
section = _section(self.text, "4. Wait")
computer = install._STOP_KEYS["keys"][0].replace("Ctrl-C", "Ctrl+C")
self.assertIn(f"{computer} stops", section)
self.assertIn(f"{install._STOP_KEYS['gptokeyb'][0]} on a handheld", section)
def test_no_hand_kept_size_before_the_run(self):
import install
+34
View File
@@ -488,5 +488,39 @@ class TheOneLinerReadsInstalledCores(unittest.TestCase):
self.assertEqual(repo.installed(), ["gba_bios.bin", "mgba/extra.bin"])
class TheInterfaceFindsTheCoresLineModeFinds(unittest.TestCase):
"""The interface names the BIOS folder with --dest, so its runs never
read retroarch.cfg: "only installed cores" found none where line mode,
detecting the same config, found them (macOS, Flatpak, any custom
system_directory)."""
def test_a_config_away_from_the_bios_folder(self):
with _CoreRepository() as repo:
(repo.root / "retroarch.cfg").unlink()
home = repo.root / "home"
config = home / ".config" / "retroarch"
config.mkdir(parents=True)
cores = repo.root / "elsewhere" / "my cores"
cores.mkdir(parents=True)
(cores / "mgba_libretro.so").write_bytes(b"")
(config / "retroarch.cfg").write_text(
f'system_directory = "{repo.dest}"\n'
f'libretro_directory = "{cores}"\n')
env = {**repo.env(), "HOME": str(home), "RETROBIOS_OS": "linux",
"XDG_CONFIG_HOME": str(home / ".config")}
repo.dest.mkdir()
found = []
for given in ((), ("--dest", str(repo.dest))):
command = [sys.executable, str(REPO_ROOT / "install.py"), "--platform",
"retroarch", *given, "--installed-cores", "--check"]
proc = subprocess.run(command, env=env, capture_output=True, text=True,
timeout=60, check=False, stdin=subprocess.DEVNULL)
self.assertNotIn("no installed core found", proc.stderr)
found.append([line for line in proc.stdout.splitlines()
if "core installed" in line or "Narrowed" in line])
self.assertEqual(found[0], found[1])
self.assertIn(" 1 core installed", found[1])
if __name__ == "__main__":
unittest.main()
+83
View File
@@ -507,6 +507,89 @@ class ARefusedFolderReportsEachFile(unittest.TestCase):
list(range(1, len(downloaded) + 1)))
class AFolderThisUserCannotEnter(unittest.TestCase):
"""A subfolder of the BIOS folder without search permission (made 0700
by an emulator that ran as root, or denied by a Windows ACL) raised
PermissionError from Path.exists() before any file was handled, in
--check and in an install alike."""
def _locked(self, repo: FakeRepository) -> Path:
(repo.dest / "pcsx").mkdir(parents=True)
(repo.dest / "pcsx" / "extra.bin").write_bytes(b"old")
os.chmod(repo.dest / "pcsx", 0o000)
return repo.dest / "pcsx"
@unittest.skipIf(os.name == "nt" or os.geteuid() == 0,
"a folder without search permission is POSIX")
def test_check_names_the_file_and_reads_the_others(self):
with FakeRepository() as repo:
locked = self._locked(repo)
try:
proc = repo.run("--check")
finally:
os.chmod(locked, 0o755)
self.assertNotIn("Traceback", proc.stderr)
self.assertEqual(proc.returncode, 1, proc.stdout + proc.stderr)
self.assertIn("wrong hash: pcsx/extra.bin", proc.stdout)
self.assertIn("could not be read (Permission denied)", proc.stdout)
self.assertIn("missing: scph5501.bin", proc.stdout)
@unittest.skipIf(os.name == "nt" or os.geteuid() == 0,
"a folder without search permission is POSIX")
def test_install_fails_that_file_alone(self):
with FakeRepository() as repo:
locked = self._locked(repo)
try:
proc = repo.run("--events")
finally:
os.chmod(locked, 0o755)
installed = repo.installed()
self.assertNotIn("Traceback", proc.stderr)
events = [json.loads(line) for line in proc.stdout.splitlines()]
failed = {e["dest"]: e["reason"] for e in events
if e["event"] == "downloaded" and not e["ok"]}
self.assertEqual(list(failed), ["pcsx/extra.bin"], proc.stderr)
self.assertIn("this user may not have access", failed["pcsx/extra.bin"])
self.assertIn("scph5501.bin", installed)
self.assertIn("mgba/extra.bin", installed)
@unittest.skipIf(os.name == "nt" or os.geteuid() == 0,
"a folder without search permission is POSIX")
def test_declared_state_reads_it_as_wrong(self):
with FakeRepository() as repo:
locked = self._locked(repo)
entry = {"dest": "pcsx/extra.bin", "sha1": "0" * 40, "size": 3}
try:
state = install._declared_state(entry, entry, repo.dest)
finally:
os.chmod(locked, 0o755)
self.assertEqual(state, "wrong_hash")
class OutputToAFileAsksNothing(unittest.TestCase):
"""`install.py > log.txt` at a terminal: the menu went into the file
and the run waited on an answer to a prompt nobody could see."""
@unittest.skipIf(os.name == "nt", "a pseudo-terminal for stdin")
def test_the_run_goes_on_as_with_no_input(self):
import pty
master, slave = pty.openpty()
try:
with FakeRepository() as repo:
proc = subprocess.run(
repo.command(), env=repo.env(), stdin=slave, capture_output=True,
text=True, timeout=60, check=False)
installed = repo.installed()
finally:
os.close(master)
os.close(slave)
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
self.assertIn("nothing is asked", proc.stderr)
self.assertNotIn("Enter) install everything", proc.stdout)
self.assertEqual(len(installed), len(FakeRepository.PAYLOADS))
class _Tty(io.StringIO):
def isatty(self) -> bool:
return True
+61
View File
@@ -854,6 +854,67 @@ class AFolderAroundTheBiosFolder(FromFolder):
["old card/copy.zip, inside as inner.bin"])
@unittest.skipUnless(Path("/proc/self/fd").is_dir(), "reads the open files from /proc")
def test_an_archive_the_list_replaces_is_not_held_open(self):
"""Windows refuses to rename, replace or delete a file a process
holds open. The --from index kept the list's own wrong archive open
once its members had been listed, and the run failed to move it
aside, every run."""
good = io.BytesIO()
with zipfile.ZipFile(good, "w") as archive:
archive.writestr("a.bin", b"A" * 300)
good = good.getvalue()
card = self.root / "card"
bios = card / "bios"
_zip(bios / "neogeo.zip", {"x.bin": b"X" * 77})
(card / "backup").mkdir()
(card / "backup" / "neogeo.zip").write_bytes(good)
_zip(card / "backup" / "pack.zip", {"renamed.bin": GBA})
self.list.write_text(json.dumps({
"manifest_version": 2, "platform": "retroarch",
"files": [_entry("neogeo.zip", good, ["snk-neogeo"]),
_entry("gba_bios.bin", GBA, ["nintendo-gba"])]}))
def held() -> set[str]:
out = set()
for fd in os.listdir("/proc/self/fd"):
with contextlib.suppress(OSError):
out.add(os.path.realpath(os.readlink(f"/proc/self/fd/{fd}")))
return out
real_replace, real_rename, real_unlink = os.replace, os.rename, os.unlink
def refusing(real):
def call(*paths, **kwargs):
files = paths[:2] if real is not real_unlink else paths[:1]
if any(os.path.realpath(path) in held() for path in files):
raise PermissionError(13, "The process cannot access the file",
str(files[0]))
return real(*paths, **kwargs)
return call
argv = ["install.py", "--platform", "retroarch", "--dest", str(bios),
"--manifest", str(self.list), "--from", str(card), "--no-input", "--yes"]
out = io.StringIO()
with mock.patch.object(sys, "argv", argv), \
mock.patch.dict(os.environ, {"RETROBIOS_INSTALL_OFFLINE": "1"}), \
mock.patch.object(os, "replace", refusing(real_replace)), \
mock.patch.object(os, "rename", refusing(real_rename)), \
mock.patch.object(os, "unlink", refusing(real_unlink)), \
mock.patch.object(os, "remove", refusing(real_unlink)), \
contextlib.redirect_stdout(out), contextlib.redirect_stderr(out):
try:
install.main()
code = 0
except SystemExit as exc:
code = exc.code
finally:
install.LOCAL.reset()
self.assertEqual(code, 0, out.getvalue())
self.assertEqual((bios / "neogeo.zip").read_bytes(), good)
self.assertEqual((bios / "gba_bios.bin").read_bytes(), GBA)
class ManySameSizedFiles(FromFolder):
"""A card of 16 KB ROMs and a list of 16 KB entries: each entry sorted
and walked every file of its size."""
+15 -2
View File
@@ -374,6 +374,7 @@ class DecliningTheSizePointsAtTheChoice(unittest.TestCase):
repo.env()["RETROBIOS_BASE_URL"] + "/{path}"), \
mock.patch.object(install, "CONFIRM_FROM_BYTES", 1), \
mock.patch("sys.stdin", mock.Mock(isatty=lambda: True)), \
mock.patch.object(install, "_is_terminal", return_value=True), \
mock.patch("builtins.input", lambda _prompt="": next(replies)), \
contextlib.redirect_stdout(out):
try:
@@ -744,14 +745,26 @@ class TheOneLinerNarrows(unittest.TestCase):
env = dict(repo.env(), HOME=str(home), USERPROFILE=str(home),
XDG_CONFIG_HOME=str(home), RETROBIOS_OS="linux")
leader, follower = pty.openpty()
# The question needs the output on the terminal too: sent to a
# pipe, the run asks nothing.
proc = subprocess.Popen(
[sys.executable, str(REPO_ROOT / "install.py")], stdin=follower,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, text=True,
stdout=follower, stderr=subprocess.PIPE, env=env, text=True,
)
os.close(follower)
os.write(leader, b"\x04")
out, err = proc.communicate(timeout=60)
err = proc.communicate(timeout=60)[1]
chunks = []
while True:
try:
chunk = os.read(leader, 65536)
except OSError:
break
if not chunk:
break
chunks.append(chunk)
os.close(leader)
out = b"".join(chunks).decode("utf-8", "replace")
self.assertEqual(proc.returncode, 130, out + err)
self.assertIn("Stopped. Nothing was installed.", out)
self.assertNotIn("carry on", out)
+87 -1
View File
@@ -2205,6 +2205,33 @@ class AuditRoundThree(unittest.TestCase):
# P2-1: a card too small for the download.
def test_a_stop_asked_for_says_only_the_outcome(self):
"""Start+Select (SIGTERM) or SIGHUP stops the child before it writes
a summary: the farewell then listed the log's last lines."""
state = checked_state()
state.download = {"files": 50}
state.finished = [("scph5501.bin", True, "")]
state.tail = [" [2/50] scph5501.bin ok",
" Stopping. Files already installed are kept."]
for code, shown in ((130, False), (1, True)):
with self.subTest(code=code):
state.code = code
_title, lines = install.tui_outcome(state)
self.assertEqual(any("[2/50]" in line for line in lines), shown, lines)
def test_a_pack_installs_from_the_pack_not_the_network(self):
"""With --from-pack and no Wi-Fi, the plan said "to download", which
on a handheld with no network read as a failure on its way."""
state = checked_state()
with mock.patch.object(install.LOCAL, "pack", object()):
headline = str(install.tui_view(state, 80, 24)[0])
plan = "\n".join(str(line) for line in install.tui_view(state, 80, 24))
state.summary = {"installed": 3, "failed": [], "up_to_date": 0}
title, lines = install.tui_outcome(state)
self.assertIn("to install from the pack", plan, headline)
self.assertNotIn("to download", plan)
self.assertEqual(lines[0], "3 files installed from the pack.")
def test_p2_1_another_folder_comes_first_when_space_is_short(self):
state = checked_state({"free": 1024, "writable": True, "cores": []})
self.assertEqual([row.label for row in install.tui_rows(state)[:2]],
@@ -2212,6 +2239,17 @@ class AuditRoundThree(unittest.TestCase):
self.assertEqual(press(state, "enter"), [])
self.assertEqual(state.screen, "folder")
def test_offline_with_nothing_to_copy_offers_no_install_anyway(self):
"""Offline with nothing here to copy, Install is removed: a folder
short of space then put "Install anyway" back, a run that could
only fail every file."""
state = checked_state({"free": 1024, "writable": False, "cores": []})
with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}):
labels = [row.label for row in install.tui_rows(state)]
self.assertEqual(labels[0], "Choose another folder")
self.assertNotIn("Install anyway", labels)
self.assertFalse(any(label.startswith("Install") for label in labels), labels)
FULL_TAIL = [
" Checking existing files...",
" 0/50 present (0 verified, 0 wrong hash)",
@@ -2557,6 +2595,34 @@ class IdleExit(unittest.TestCase):
["RetroBIOS: No button pressed, closing."])
class AReadErrorIsNotAClosedWindow(unittest.TestCase):
"""Any EIO read as the window closing: a failing SD card answers EIO
too, and that run ended 129, no error.log, no launcher message."""
def test_eio_counts_only_once_the_terminal_hung_up(self):
import pty
master, slave = pty.openpty()
screen = open(os.dup(slave), "w", encoding="utf-8") # noqa: SIM115
terminal = mock.Mock(fd=slave, stdout=screen)
try:
card = OSError(errno.EIO, "Input/output error", "/media/card/bios/x.bin")
self.assertFalse(install._closed_terminal(card, terminal))
self.assertFalse(install._closed_terminal(
OSError(errno.ENOENT, "No such file"), terminal))
os.close(master)
master = -1
self.assertTrue(install._closed_terminal(card, terminal))
self.assertTrue(install._closed_terminal(termios.error(errno.EIO, "gone"),
terminal))
finally:
with contextlib.suppress(OSError):
screen.close()
os.close(slave)
if master >= 0:
os.close(master)
class AuditRoundFour(unittest.TestCase):
"""tui_audit_3.md, each finding held where it was found."""
@@ -2935,8 +3001,13 @@ class EndToEnd(unittest.TestCase):
term.send("\r")
screen = term.wait("files to download")
self.assertIn("6 files to download", screen)
# The second folder the run writes to, named before
# anything is written, and again on the result.
self.assertIn("1 file read from RetroArch's save directory goes to "
"~/MySaves", screen)
term.send("\r")
term.wait("6 files installed.")
screen = term.wait("6 files installed.")
self.assertIn("save directory goes to ~/MySaves", screen)
term.send("\r")
self.assertEqual(term.close(), 0)
results.append(written(repo, saves))
@@ -3035,6 +3106,9 @@ class EndToEnd(unittest.TestCase):
after = term.after()
self.assertIn(f"Stopped: {on_disk} of 6 installed, run again to finish.", after)
self.assertNotIn("Nothing was installed", after)
# The plain outcome only: the log's last lines are for a run that died.
self.assertIsNone(re.search(r"\[\d+/6\]", after), after)
self.assertNotIn("Stopping.", after)
time.sleep(1)
self.assertEqual(len(repo.installed()), on_disk)
@@ -3176,6 +3250,18 @@ class EndToEnd(unittest.TestCase):
term.send("q")
self.assertEqual(term.close(), 130)
def test_no_key_on_a_screen_reached_by_itself_closes_it(self):
"""Given --platform, as the offline launcher route is, the check
runs at once and the plan follows without a key: the close counted
only on the first screen, and never came."""
repo = self.repo()
self.env[install.TUI_IDLE_ENV] = "1"
term = self.start("--platform", "retroarch", "--dest", str(repo.dest))
term.wait("> Install")
self.assertEqual(term.close(), 0)
self.assertIn("No button pressed, closing.", term.after())
self.assertEqual(repo.installed(), [])
def test_a_desktop_names_its_keys_and_a_pad_its_buttons(self):
self.repo()
for env, bar in (({"DISPLAY": ":0"}, "Enter Continue Esc Quit ? Help"),
+2 -2
View File
@@ -87,7 +87,7 @@ Arrows move, Enter chooses, Esc goes back. A handheld's A and B do the same.
## 3. Install
The next screen counts the missing files and the free space.
The next screen counts missing files and free space.
Press Enter on **Install**. **Choose systems** keeps the consoles you
play.
@@ -96,7 +96,7 @@ card or a folder**, then the card. It stays unchanged.
## 4. Wait
B or Ctrl+C stops it. Run it again to carry on.
Esc or Ctrl+C stops, B on a handheld. Run again to resume.
## 5. Read the result
+14 -9
View File
@@ -622,9 +622,10 @@ rather than read as having no game data.
`--installed-cores` reads the cores a RetroArch already has and installs what
they need. The cores are the `<name>_libretro.so` (`.dll`, `.dylib`,
`_libretro_android.so`) files in the `libretro_directory` of the
`retroarch.cfg` detection found, `cores/` beside RetroArch when the setting is
unset, a `retroarch.cfg` next to the system directory for a portable install
given with `--dest`, and the folders RetroPie (`/opt/retropie/libretrocores`)
`retroarch.cfg` whose `system_directory` is the BIOS folder, found the same
way with `--dest` or without, `cores/` beside RetroArch when the setting is
unset, a `retroarch.cfg` next to the system directory for a portable install,
and the folders RetroPie (`/opt/retropie/libretrocores`)
and Lakka (`/usr/lib/libretro`, `/storage/cores`) install into. A core file
is named the buildbot's way (`mednafen_psx`), and the manifest maps each name to
the profile its files are filed under (`beetle_psx`).
@@ -694,7 +695,7 @@ what the options name, everything by default.
| <span id="option-list-cores"></span>`--list-cores` | Print the cores whose extra files a platform installs |
| <span id="option-list-emulators"></span>`--list-emulators` | Print every name `--emulator` accepts, each buildbot name beside the emulator it stands for |
| <span id="option-romm-firmware"></span>`--romm-firmware TEMPLATE` | RomM: where each platform's firmware goes in the library, as `filesystem.structure.firmware` in RomM's `config.yml` says (`{platform}/bios` for a library laid out as `{platform}/roms`). Read from `config.yml` beside the library when it is there. See [RomM firmware layout](#romm-firmware-layout) |
| <span id="option-no-input"></span>`--no-input` | Never ask a question, even at a terminal |
| <span id="option-no-input"></span>`--no-input` | Never ask a question, even at a terminal. A run whose output goes to a file or a pipe asks nothing either, and says so on stderr |
| <span id="option-check"></span>`--check` | Report and exit without writing |
| <span id="option-dry-run"></span>`--dry-run` | Show what would be downloaded, the totals first, then by system and with sizes, without writing anything. A plan taller than the screen names the ten largest systems, and `--verbose` lists every system and file |
| <span id="option-yes"></span>`--yes`, `-y` | Do not ask before a download of 1 GB or more |
@@ -1066,7 +1067,8 @@ page there: muxterm keeps PgUp and PgDn for its own scrollback, so a list
moves with the D-pad.
Each launcher also sets `RETROBIOS_IDLE_EXIT` to 300: with no button pressed
on its first screen for 5 minutes, the interface closes by itself. A pad the
for 5 minutes since it opened, and no check or install running, the interface
closes by itself. A pad the
system has no mapping for sends nothing, and would otherwise leave the
console stuck on the interface with no way out.
@@ -1083,9 +1085,11 @@ Each launcher tells the interface which buttons reach it, in
A launcher with no mapper at all (a keyboard) leaves it unset.
A launcher can also set `RETROBIOS_IDLE_EXIT` to a number of seconds: when
no button reaches the first screen for that long, before any choice, the
interface closes with `No button pressed, closing.` and exit status 0. A
mapper that never opened the pad would otherwise hold the device on that
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.
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
screen. On a Miyoo, B,
X, Y and Select are Left Ctrl, Shift, Alt and Right Ctrl: pressed alone, a
modifier sends nothing to a terminal, so the interface goes back with R2
@@ -1294,6 +1298,7 @@ other schemas under `api/v1/schemas/`.
| `declared` | `platform`, `files`, `present`, `missing`, `wrong_hash`, `bytes_needed`, `dests` | Right after `check`, the same counts over the files the platform's own list names (`cores: null`), and their `dests` in the order of the `file` events. A setup that checks its own list reads its answer here, with or without `--declared-only` |
| `found` | `platform`, `dest`, `folder`, `path`, `member`, `reason` | Each file to install that this machine already holds, after `check` and before anything is written, in `--check` and `--dry-run` too. `folder` is the `--from` folder as given, or the `bios_dir` of the check when the BIOS folder holds the file at another place, and then `reason` says where in words (`nested folder: ...` for a misplaced file, `the same file is at ...` for one the list installs there too). `path` is the file, and `member` the ZIP member holding the bytes when `path` is an archive, an empty string otherwise. Its copy is reported by a `downloaded` event |
| `unreadable` | `platform`, `folder`, `path`, `member`, `reason` | Each file in a `--from` folder that could not be read and may be one the run still needs, right after the `found` events: its size is that of a file no copy was found for, or unknown (a folder that could not be listed, a ZIP that would not open). `member` names the ZIP member, or is empty. `reason` says why in words (`Permission denied`, `it is encrypted, a password is needed`) |
| `saves` | `platform`, `files`, `folder` | After `detect` and before `checking`, when the list holds files read from the frontend's save directory and that directory is known: how many, and the `folder` they go to, where `<system>` and `<core>` stand for one folder per system or per core. When it is not known, the files are counted in the summary's `skipped` instead (`no_save_directory`) |
| `download` | `platform`, `files`, `bytes` | Before the downloads start. Counts the files copied from this machine too |
| `progress` | `files_done`, `files_total`, `bytes_done`, `bytes_total` | At most every half second while bytes arrive |
| `transfer` | `bytes_local`, `bytes_network` | Right after each `progress` event, and once when the files of a `download` event are done. It splits `bytes_done`: `bytes_local` came from this machine (a copy found by `--from` or in the BIOS folder, the pack, a partial copy resumed), `bytes_network` from the network. A download speed is `bytes_network` over the time elapsed |
@@ -1336,7 +1341,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's first screen waits for a first button. 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 |
| `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