mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-24 04:39:06 -05:00
chore: merge the round 4 installer fixes
This commit is contained in:
commit
14e3923533
12 files changed
+541
-73
No files matched your search
+1
-1
@@ -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
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user