diff --git a/install.ps1 b/install.ps1 index d7da0866..55ef1e06 100644 --- a/install.ps1 +++ b/install.ps1 @@ -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 } diff --git a/install.py b/install.py index 9a8d3020..74148f58 100644 --- a/install.py +++ b/install.py @@ -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 ( and 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}" 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 ", 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 diff --git a/install.sh b/install.sh index c002ce0e..e626d8ba 100755 --- a/install.sh +++ b/install.sh @@ -18,7 +18,7 @@ esac TEMP_INSTALLER="" TEMP_DIRECTORY="" DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" -DEFAULT_INSTALL_SHA256="0047a2f49b22271b69e9a4731165c3230fffa85fa0045a2f725a8bb8668ea9f5" +DEFAULT_INSTALL_SHA256="d4e7f003bde7580e8d66e24396d9b9c62b7042373d2c7313cc5b69cf87c601cb" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/schemas/install-events.schema.json b/schemas/install-events.schema.json index 13debfab..a9cb3fc5 100644 --- a/schemas/install-events.schema.json +++ b/schemas/install-events.schema.json @@ -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, diff --git a/tests/test_doc_pages.py b/tests/test_doc_pages.py index 73d75bfd..44007174 100644 --- a/tests/test_doc_pages.py +++ b/tests/test_doc_pages.py @@ -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 diff --git a/tests/test_install_cores.py b/tests/test_install_cores.py index 7a855642..6c0ec22c 100644 --- a/tests/test_install_cores.py +++ b/tests/test_install_cores.py @@ -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() diff --git a/tests/test_install_experience.py b/tests/test_install_experience.py index 5f64b1e0..2a74b539 100644 --- a/tests/test_install_experience.py +++ b/tests/test_install_experience.py @@ -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 diff --git a/tests/test_install_from.py b/tests/test_install_from.py index 2f5f2880..ef445ca5 100644 --- a/tests/test_install_from.py +++ b/tests/test_install_from.py @@ -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.""" diff --git a/tests/test_install_selection.py b/tests/test_install_selection.py index 1d935989..27aa7772 100644 --- a/tests/test_install_selection.py +++ b/tests/test_install_selection.py @@ -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) diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 5e59b0d4..cccddfa2 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -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"), diff --git a/wiki/first-install.md b/wiki/first-install.md index 1537570c..5d993b17 100644 --- a/wiki/first-install.md +++ b/wiki/first-install.md @@ -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 diff --git a/wiki/installer.md b/wiki/installer.md index b4a203ff..f92d3d65 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -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 `_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. | `--list-cores` | Print the cores whose extra files a platform installs | | `--list-emulators` | Print every name `--emulator` accepts, each buildbot name beside the emulator it stands for | | `--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) | -| `--no-input` | Never ask a question, even at a terminal | +| `--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 | | `--check` | Report and exit without writing | | `--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 | | `--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 `` and `` 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