diff --git a/install.ps1 b/install.ps1 index 75033ba8b..07014e7cc 100644 --- a/install.ps1 +++ b/install.ps1 @@ -16,7 +16,7 @@ # and Invoke-WebRequest throws on a cut transfer before the hash is read: # the site's build rewrites this line there (scripts/sitebootstrap.py). $mismatchAdvice = "The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that." - $defaultInstallSha256 = "456a32e79668e125f2ebe573b549247210c59fed6389e78dc1482b57d3a36712" + $defaultInstallSha256 = "37442a7c0d307b0b95a40d7630cf57025a6498fea6223076892371b2d6363147" # The pin above refuses any changed byte: this cap only stops a body that # cannot be the installer (a sign-in portal's page) before it is hashed. $maximumInstallerBytes = 8MB diff --git a/install.py b/install.py index 3bd093ddc..0d0031846 100644 --- a/install.py +++ b/install.py @@ -814,6 +814,15 @@ EVENT_KEYS: dict[str, tuple[str, ...]] = { # when all were in place), kept the backup folders as in summary. # ok false with reason when it was refused or failed. "app": ("system", "folder", "ok", "reason", "dests", "written", "kept"), + # The install record of the BIOS folder (path, beside it in + # retrobios-backup). status: applied (before choices, its choice taken + # as the run's because nothing on the command line chose), written + # (before the summary, once the install placed files), unreadable (said + # once and ignored) or not_written (the install itself unharmed). choice is + # the recorded options as words, date when it was written, files the + # files it holds for the platform. A kind of its own, since schema 1 + # never adds a key to an existing kind. + "record": ("platform", "folder", "path", "status", "choice", "date", "files", "message"), "end": ("code",), } # Seconds between two progress events while bytes arrive. @@ -6099,6 +6108,410 @@ def _kept_lines() -> list[str]: return lines +# The install record: what the runs placed in a BIOS folder, each file with +# its hash, and the choice they made, which the next run's plan takes. It +# lives in the backup holder beside the folder, never loose in it, for the +# reason the backups do. On a card it travels with the card. +RECORD_SCHEMA = 1 +RECORD_SUFFIX = ".install.json" +# The options a record keeps as the choice: what the list was narrowed to. +RECORD_CHOICE = ("--system", "--core", "--emulator", "--region", "--for-games", + "--required-only", "--all-files", "--target", "--every-board", + "--no-game-data", "--installed-cores", "--declared-only") +# The record's choice options that narrow the list, --all-files aside. +RECORD_NARROWING = tuple(flag for flag in RECORD_CHOICE if flag != "--all-files") +_MONTHS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", + "Dec") +# The records already said unreadable in this run, said once each. +_RECORD_SAID: set[str] = set() +# Per platform, the record whose choice this run took (apply_record). +_RECORD_TAKEN: dict[str, dict] = {} + + +def record_paths(folder: Path) -> list[Path]: + """Where the record of folder is looked for, in order: the holder beside + it, then the one inside it that a folder with no writable parent gets.""" + folder = Path(os.path.abspath(folder)) + name = f"{folder.name or 'folder'}{RECORD_SUFFIX}" + return [holder / name for holder, _prefix in KeptFiles._holders(folder)] + + +# Read whole, never past this: a record of every file of the longest list +# holds 1.6 MB per setup, and a few setups share one folder. +MAX_RECORD_BYTES = 16 * 1024 * 1024 +# The choice options that take a value. --for-games takes one or none. +_RECORD_VALUED = ("--system", "--core", "--emulator", "--region", "--target") + + +def games_folder_kept(value: str, folder: Path) -> bool: + """Whether a recorded --for-games folder may be read again: an absolute + path under the folder that holds the BIOS folder (a card's root, the + setup's own folder), never another drive or a network share.""" + if not value or value.startswith(("\\\\", "//")) or not os.path.isabs(value): + return False + parent = os.path.normcase(os.path.normpath(os.path.dirname(os.path.abspath(folder)))) + games = os.path.normcase(os.path.normpath(value)) + try: + return os.path.commonpath([parent, games]) == parent + except ValueError: + # Two drives on Windows: no common path. + return False + + +def valid_choice(choice: list[str], folder: Path) -> bool: + """Whether every word of a recorded choice is an option of the choice or + the value right after one: a record naming --dest, --from or --manifest + would put them in the commands printed for the user to copy.""" + words = list(choice) + while words: + flag = words.pop(0) + if flag not in RECORD_CHOICE: + return False + if flag in _RECORD_VALUED: + if not words or words[0].startswith("-") or not words[0].strip(): + return False + value = words.pop(0) + if any(ord(char) < 32 or ord(char) == 127 for char in value): + return False + elif flag == "--for-games" and words and not words[0].startswith("--"): + if not games_folder_kept(words.pop(0), folder): + return False + return True + + +def _valid_setup(setup: object, folder: Path) -> bool: + if not isinstance(setup, dict): + return False + choice, files = setup.get("choice"), setup.get("files") + return (isinstance(setup.get("written"), str) and isinstance(choice, list) + and all(isinstance(word, str) for word in choice) and valid_choice(choice, folder) + and isinstance(files, list) + and all(isinstance(item, dict) and isinstance(item.get("dest"), str) + and isinstance(item.get("sha1"), str) and isinstance(item.get("size"), int) + for item in files)) + + +class RecordRead(NamedTuple): + """What reading a record found: the record with its usable setups, where + it is, why it cannot be used, whether a newer installer wrote it, and + the setups it holds that cannot be used.""" + + data: dict | None + path: Path | None + problem: str + newer: bool = False + dropped: tuple = () + + +def _read_record_bytes(path: Path) -> bytes: + """The bytes of a record that is a regular file of a record's size. A + FIFO, a device or a folder is refused before it is read: reading one + waited forever.""" + info = os.stat(path) + if not stat.S_ISREG(info.st_mode): + raise OSError(errno.EINVAL, "it is not a file") + if info.st_size > MAX_RECORD_BYTES: + raise OSError(errno.EFBIG, "it is larger than any record") + flags = os.O_RDONLY | getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_BINARY", 0) + handle = os.open(path, flags) + try: + # Opened without waiting, then checked again: what is open now is + # what is read, whatever took the name since the stat. + if not stat.S_ISREG(os.fstat(handle).st_mode): + raise OSError(errno.EINVAL, "it is not a file") + chunks, left = [], MAX_RECORD_BYTES + 1 + while left > 0: + chunk = os.read(handle, min(left, 1 << 20)) + if not chunk: + break + chunks.append(chunk) + left -= len(chunk) + finally: + os.close(handle) + data = b"".join(chunks) + if len(data) > MAX_RECORD_BYTES: + raise OSError(errno.EFBIG, "it is larger than any record") + return data + + +def load_record(folder: Path) -> RecordRead: + """The record of folder, read once (RecordRead).""" + for path in record_paths(folder): + try: + text = _read_record_bytes(path).decode("utf-8") + except (FileNotFoundError, NotADirectoryError): + continue + except (OSError, UnicodeDecodeError) as exc: + reason = getattr(exc, "strerror", None) or str(exc) + return RecordRead(None, path, f"The install record {path} cannot be read ({reason})") + try: + data = json.loads(text) + except ValueError: + return RecordRead(None, path, f"The install record {path} is damaged") + schema = data.get("schema") if isinstance(data, dict) else None + if isinstance(schema, int) and not isinstance(schema, bool) and schema > RECORD_SCHEMA: + # Its shape is the newer installer's: kept for it, never read. + return RecordRead(None, path, f"The install record {path} was written by a newer " + "installer", newer=True) + if (not isinstance(data, dict) or not isinstance(data.get("setups"), dict) + or not isinstance(data.get("folder"), str) or schema != RECORD_SCHEMA): + return RecordRead(None, path, f"The install record {path} is damaged") + setups = {name: setup for name, setup in data["setups"].items() + if _valid_setup(setup, folder)} + dropped = tuple(sorted(str(name) for name in data["setups"] if name not in setups)) + return RecordRead({**data, "setups": setups}, path, "", dropped=dropped) + return RecordRead(None, None, "") + + +def read_record(folder: Path) -> tuple[dict | None, Path | None, str]: + """(the record of folder, where it is, why it cannot be used). Neither a + record nor a reason where there is none.""" + found = load_record(folder) + return found.data, found.path, found.problem + + +def setup_record(plat: str, folder: Path) -> tuple[dict | None, Path | None, str]: + """The record of plat in folder, where it is, and why it cannot be used. + A record written for a folder of another name (copied, or renamed with + it) is not this folder's. A setup of it that cannot be used (an option + the choice has no place for, a games folder elsewhere) is damaged, the + others still read.""" + found = load_record(folder) + if found.data is None: + return None, found.path, found.problem + written_for = str(found.data.get("folder") or "") + if Path(written_for).name != Path(os.path.abspath(folder)).name: + return None, found.path, "" + if plat in found.dropped: + return None, found.path, f"The install record {found.path} is damaged" + return found.data["setups"].get(plat), found.path, "" + + +def record_day(setup: dict) -> str: + """The day a record was written, as the plan says it: 3 Nov.""" + try: + when = datetime.datetime.fromisoformat(setup["written"].replace("Z", "+00:00")) + # Year 1 or 9999 cannot be moved to local time: an unknown day. + local = when.astimezone() + except (KeyError, TypeError, AttributeError, ValueError, OverflowError, OSError): + return "" + day = f"{local.day} {_MONTHS[local.month - 1]}" + return day if local.year == datetime.datetime.now().year else f"{day} {local.year}" + + +def record_options(argv: list[str]) -> list[tuple[str, str | None]]: + """A record's choice as (option, value) pairs.""" + options: list[tuple[str, str | None]] = [] + words = list(argv) + while words: + flag = words.pop(0) + valued = flag in _VALUED_OPTIONS or ( + flag == "--for-games" and words and not words[0].startswith("--")) + options.append((flag, words.pop(0) if valued and words else None)) + return options + + +def record_argv(options: Iterable[tuple[str, str | None]]) -> list[str]: + """The choice options a record keeps, as words, in the command's order.""" + kept = sorted((option for option in options if option[0] in RECORD_CHOICE), + key=_command_rank) + if not any(flag in RECORD_NARROWING for flag, _value in kept): + # The whole list, chosen as such: a card that holds games later + # still gets what was installed before. + kept = [("--all-files", None)] + return [word for flag, value in kept for word in ([flag] if value is None else [flag, value])] + + +def record_says(setup: dict, manifest: dict | None = None) -> str: + """The recorded choice in words: Game Boy Advance, the games, the + options chosen, every file.""" + options = record_options(setup.get("choice") or []) + systems = [system_title(value, manifest) for flag, value in options + if flag == "--system" and value] + if systems: + shown = ", ".join(systems[:3]) + return shown + (f" and {len(systems) - 3} more" if len(systems) > 3 else "") + if any(flag == "--for-games" for flag, _value in options): + return "the games" + if any(flag in RECORD_NARROWING for flag, _value in options): + return "the options chosen" + return "every file" + + +def record_changes(setup: dict, entries: Iterable[dict]) -> tuple[int, int]: + """Of the files a choice keeps now, those the list changed since the + record and those it did not hold then: (updated, new).""" + recorded = {(str(item.get("root") or ""), item["dest"]): item["sha1"] + for item in setup.get("files") or []} + updated = new = 0 + for entry in entries: + sha1 = recorded.get((str(entry.get("root") or ""), str(entry.get("dest")))) + if sha1 is None: + new += 1 + elif entry.get("sha1") and sha1 != entry.get("sha1"): + updated += 1 + return updated, new + + +def record_file(entry: dict, origin: str, root: str = "") -> dict: + """One file a run wrote, as the record keeps it.""" + return {"dest": str(entry["dest"]), "root": root or str(entry.get("root") or ""), + "sha1": str(entry.get("sha1") or ""), "size": int(entry.get("size") or 0), + "from": origin} + + +def write_record( + folder: Path, plat: str, manifest: dict, choice: list[str], files: list[dict], +) -> tuple[Path | None, dict, str]: + """Write what this run placed in folder for plat, merged with what + earlier runs placed: (where, the record of plat, why it was not + written). A record that cannot be read is replaced.""" + folder = Path(os.path.abspath(folder)) + read = load_record(folder) + if read.newer: + # What a newer installer wrote is kept for it: rewritten here, the + # other setups' history would go. + if str(read.path) not in _RECORD_SAID: + _RECORD_SAID.add(str(read.path)) + print(f" {read.problem}: left as it is.") + return None, {"files": [], "written": ""}, f"{read.problem}: left as it is" + found = read.path + # A record that cannot be read is replaced, said when it was read. One + # with a setup that cannot be used keeps its others. + data = read.data if read.data is not None else {"setups": {}} + previous = data["setups"].get(plat) or {} + merged = {(str(item.get("root") or ""), item["dest"]): item + for item in previous.get("files") or []} + for item in files: + merged[(item["root"], item["dest"])] = item + setup = { + "written": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "list": {"version": manifest.get("version"), "generated": manifest.get("generated")}, + "choice": choice, + "files": [merged[key] for key in sorted(merged)], + } + data = {"schema": RECORD_SCHEMA, "installer": installer_id(), "folder": str(folder), + "setups": {**data["setups"], plat: setup}} + text = json.dumps(data, separators=(",", ":"), sort_keys=True) + "\n" + places = record_paths(folder) + if found is not None and found in places: + # Rewritten where it was read: never a second record of one folder. + places = [found] + [place for place in places if place != found] + failure = "nothing could be written beside the folder" + for path in places: + scratch: Path | None = None + try: + path.parent.mkdir(exist_ok=True) + _check_holder(path.parent) + with tempfile.NamedTemporaryFile( + "w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", + suffix=".part", delete=False) as handle: + scratch = Path(handle.name) + handle.write(text) + os.replace(scratch, path) + except OSError as exc: + failure = exc.strerror or str(exc) + if scratch is not None and os.path.lexists(scratch): + try: + scratch.unlink() + except OSError as left: + failure += f", and {scratch} stays ({left.strerror or left})" + continue + return path, setup, "" + return None, setup, f"The install record could not be written ({failure})" + + +def record_menu_words(setup: dict | None) -> str: + """What line mode's menu says of a recorded choice it opens on.""" + if setup is None: + return "" + day = record_day(setup) + return f"Same choice as on {day}." if day else "Same choice as last time." + + +def choice_given(args: argparse.Namespace) -> bool: + """Whether the command line chose what to install, which a record then + never replaces: an option of the choice, or a file list of its own.""" + # A namespace built by a caller that predates an option lacks it. + return (getattr(args, "for_games", None) is not None or LOCAL.manifest is not None + or any(getattr(args, flag[2:].replace("-", "_"), None) + for flag in RECORD_CHOICE if flag != "--for-games")) + + +def say_record_problem(plat: str, folder: Path, path: Path | None, problem: str) -> None: + """A record that cannot be used, said once and ignored.""" + key = str(path) + if not problem or key in _RECORD_SAID: + return + _RECORD_SAID.add(key) + message = f"{problem}, so it is ignored." + print(f" {message}") + EVENTS.emit("record", platform=plat, folder=str(folder), path=key, status="unreadable", + choice=[], date="", files=0, message=message) + + +def by_record(args: argparse.Namespace) -> bool: + """Whether the run's choice came from the install record: nothing of + it was typed, so a part that cannot apply is left out, never refused.""" + return bool(getattr(args, "from_record", False)) + + +def drop_recorded(args: argparse.Namespace, plat: str, flag: str, why: str, + keep: list[str] | None = None) -> None: + """Leave out a recorded option that cannot apply, in one line: the run + goes on, and the record written after it no longer holds it. keep, for + an option of several names, is the names that still apply.""" + name = flag[2:].replace("-", "_") + chosen = _INVOCATION.chosen.get(plat, []) + if keep: + setattr(args, name, list(keep)) + _INVOCATION.chosen[plat] = [option for option in chosen if option[0] != flag] + [ + (flag, value) for value in keep] + else: + setattr(args, name, build_parser().get_default(name)) + _INVOCATION.chosen[plat] = [option for option in chosen if option[0] != flag] + print(f" Left out of the recorded choice: {flag} ({why}).") + + +def apply_record(args: argparse.Namespace, plat: str, folder: Path) -> dict | None: + """Take the choice recorded for plat in folder as the run's, as if its + options had been typed: the plan's first choice. Returns the record, + None where none is taken.""" + setup, path, problem = setup_record(plat, folder) + say_record_problem(plat, folder, path, problem) + if setup is None: + return None + try: + parsed = build_parser().parse_args(setup["choice"]) + except SystemExit: + say_record_problem(plat, folder, path, + f"The install record {path} names options this installer lacks") + return None + for flag in RECORD_CHOICE: + name = flag[2:].replace("-", "_") + setattr(args, name, getattr(parsed, name)) + if args.for_games is not None: + args.all_files = False + args.from_record = True + options = record_options(setup["choice"]) + if any(flag in RECORD_NARROWING for flag, _value in options): + # Said where it narrows the list: the whole list again is the run + # a person expects, and said nothing new. + _RECORD_TAKEN[plat] = setup + _INVOCATION.chosen[plat] = [option for option in options if option[0] != "--all-files"] + if isinstance(args.for_games, Path) and not _is_dir(args.for_games): + # The games folder recorded is gone: the card's own games are read. + _INVOCATION.chosen[plat] = [(flag, None if flag == "--for-games" else value) + for flag, value in _INVOCATION.chosen[plat]] + args.for_games = True + print(" Left out of the recorded choice: its games folder (no longer there).") + message = record_menu_words(setup) + EVENTS.emit("record", platform=plat, folder=str(folder), path=str(path), status="applied", + choice=list(setup["choice"]), date=setup["written"], + files=len(setup["files"]), message=message) + return setup + + def restore_kept(run: Path, dry_run: bool = False) -> int: """Put every file of a backup run folder back where it was. @@ -6769,6 +7182,14 @@ class Invocation: words += ["--dest", self.word(str(dest.absolute()))] elif dest: words += ["--dest", dest] + for flag, value in self.flags(plat): + if flag in without: + continue + words += [flag] if value is None else [flag, self.word(value)] + return " ".join(words) + + def flags(self, plat: str) -> list[tuple[str, str | None]]: + """The options of the run for plat, in the command's order.""" options = self.options if plat in self.chosen: # A choice made at the questions replaces the narrowing the @@ -6776,11 +7197,7 @@ class Invocation: # the systems of the first run and of the second. replaced = {*_CHOICE_OPTIONS, *(flag for flag, _value in self.chosen[plat])} options = [option for option in options if option[0] not in replaced] - for flag, value in sorted([*options, *self.chosen.get(plat, [])], key=_command_rank): - if flag in without: - continue - words += [flag] if value is None else [flag, self.word(value)] - return " ".join(words) + return sorted([*options, *self.chosen.get(plat, [])], key=_command_rank) _INVOCATION = Invocation() @@ -7956,13 +8373,15 @@ def install_data_directory(entry: dict, bios_path: Path, Returns counts: present, installed, changed (the source no longer holds the recorded bytes), absent (not in the archive), error, the reason - the archive could not be read, and not_kept, the (path, reason) of each - file in place that differs and could not be moved aside, left as it is. + the archive could not be read, not_kept, the (path, reason) of each + file in place that differs and could not be moved aside, left as it is, + and written, each file written (dest below the BIOS folder, sha1, size). """ root, needed, present = state or data_directory_state(entry, bios_path) needed = dict(needed) + written: list[dict] = [] counts = {"present": present, "installed": 0, "changed": 0, "absent": 0, "error": "", - "not_kept": []} + "not_kept": [], "written": written} if not needed: return counts _writing.set() @@ -7999,12 +8418,13 @@ def install_data_directory(entry: dict, bios_path: Path, continue target = _destination_path(root, rel) target.parent.mkdir(parents=True, exist_ok=True) + slot = f"{entry['dest']}/{rel}" if entry.get("dest") else rel outcome = _install_data_member( - opener, meta, target, bios_path, - f"{entry['dest']}/{rel}" if entry.get("dest") else rel, - rel, counts["not_kept"]) + opener, meta, target, bios_path, slot, rel, counts["not_kept"]) if outcome: counts[outcome] += 1 + if outcome == "installed": + written.append({"dest": slot, "sha1": meta["sha1"], "size": meta["size"]}) except _Offline as exc: # Kept apart, so the advice is the offline one, not the network's. counts["error"] = OfflineReason(str(exc)) @@ -9595,6 +10015,11 @@ def _prompt_custom_selection( elif playing and played is not None: kept_games = menu_choice(files, omitted, installed, GamesChoice(played), cutting)[0] print(f" h) only for the games {played.where()}: {_sized(kept_games)}") + # The screen's "Same choice as last time: no": the whole list. + widen = (plat in _RECORD_TAKEN and current is not None + and not (playing and isinstance(current, GamesChoice))) + if widen: + print(f" e) everything: {_sized(shown)}") if installed is not None and current != INSTALLED_ONLY: print(f" i) only what the installed cores read: {_sized(installed[0])}") # What c asks, named: "systems, cores and regions" was offered with @@ -9614,6 +10039,7 @@ def _prompt_custom_selection( print(" q) quit") keys = [*([] if emptied else [""]), "q", *(["c"] if choosable else []), *((["r", "e"] if isinstance(current, GamesChoice) else ["h"]) if playing else []), + *(["e"] if widen else []), *(["i"] if installed is not None and current != INSTALLED_ONLY else []), *(["f"] if copying else []), *(["g"] if games.games else [])] others = [key for key in keys if key not in ("", "q")] @@ -9668,10 +10094,12 @@ def _print_menu_head( if current == INSTALLED_ONLY: named = ", only what the installed cores read" elif isinstance(current, GamesChoice): - named = f", {current.words()}" + (" (--for-games)" if current is given else "") + named = f", {current.words()}" + (" (--for-games)" if current is given + and plat not in _RECORD_TAKEN else "") elif isinstance(current, tuple): systems, cores, regions = current - flag = current is given + # A recorded choice was typed by nobody: no option is named. + flag = current is given and plat not in _RECORD_TAKEN listed = choosable_systems(files, omitted, cores, regions) total = len(listed) # The systems chosen that still hold a file: Sega Dreamcast holding @@ -10206,6 +10634,55 @@ def _narrow_to_emulators( return files, omitted +def _known_names(values: list[str] | None, known: list[str], label: str, + aliases: dict[str, str] | None = None) -> list[str]: + """The names of values that resolve against known, the others left out.""" + kept = [] + for value in values or []: + try: + resolve_choices([value], known, label, aliases) + except ValueError: + continue + kept.append(value) + return kept + + +def _recorded_names(args: argparse.Namespace, plat: str, flag: str, known: list[str], + aliases: dict[str, str] | None = None) -> None: + """A recorded --system, --core or --emulator kept to the names the list + still holds, the others left out in one line.""" + name = flag[2:].replace("-", "_") + values = list(getattr(args, name) or []) + kept = _known_names(values, known, flag[2:], aliases) + if kept != values: + gone = ", ".join(value for value in values if value not in kept) + drop_recorded(args, plat, flag, f"{gone} no longer on the list", kept) + + +def _recorded_emulators(args: argparse.Namespace, plat: str, manifest: dict, + files: list[dict], omitted: list[dict]) -> None: + """A recorded --emulator that cannot narrow this list, left out.""" + if "system_cores" not in manifest: + drop_recorded(args, plat, "--emulator", "the list does not say which emulators " + "read which systems") + return + _recorded_names(args, plat, "--emulator", sorted(emulator_names(manifest, files, omitted))) + + +def _recorded_regions(args: argparse.Namespace, plat: str) -> None: + """A recorded --region kept to the regions this installer knows.""" + values = list(args.region or []) + kept = [] + for value in values: + try: + resolve_regions([value]) + except ValueError: + continue + kept.append(value) + if kept != values: + drop_recorded(args, plat, "--region", "a region this installer does not know", kept) + + def _requested_narrowing( args: argparse.Namespace, plat: str, files: list[dict], omitted: list[dict], manifest: dict | None = None, @@ -10222,6 +10699,22 @@ def _requested_narrowing( asked = [(flag, axis) for flag, given, axis in ( ("--system", args.system, "systems"), ("--core", args.core, "cores"), ("--region", args.region, "regions")) if given] + if getattr(args, "from_record", False): + # A recorded option that cannot narrow this list is left out: nobody + # typed it, and every run without options would stop on it. + for flag, axis in asked: + if axis not in recorded: + drop_recorded(args, plat, flag, f"the list records no {axis}") + asked = [(flag, axis) for flag, axis in asked if axis in recorded] + _recorded_regions(args, plat) + choices = available_choices(files, omitted) + if args.system: + _recorded_names(args, plat, "--system", choices["systems"], + system_aliases(manifest)) + if args.core: + _recorded_names(args, plat, "--core", choices["cores"]) + if not (args.system or args.core or args.region): + return None for flag, axis in asked: if axis in recorded: continue @@ -14267,6 +14760,8 @@ def main() -> None: # the menu. Yes to every question is no question. args.no_input = True _INVOCATION.record(args) + if os.environ.get(TUI_RECORD_ENV): + args.from_record = True if os.environ.get(TUI_LIST_ENV): # The full screen's own copy of the list, deleted when it closes: a # command naming it fails once the screen is gone. @@ -16101,10 +16596,58 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: # The BIOS folders read for misplaced files, one per platform. bios_indexes: list[FolderIndex] = [] + # The choice the folder's record keeps is the plan's first choice, taken + # as its options would be: before the checks of --target and + # --installed-cores, which it may hold. Never where the command line + # chose, nor for a run over several setups. + _RECORD_TAKEN.clear() + + def remember(plat: str, folder: Path, manifest: dict, written: list[dict], + games: bool) -> None: + """Record what this install placed in folder and the choice it made, + games being the card's games applied with no --for-games.""" + if not written and setup_record(plat, folder)[0] is None: + # Nothing of ours is there to record, and no choice to keep. + return + # A games folder is kept only where the next run reads it from a + # record (games_folder_kept): elsewhere the card's own games are. + options = [(flag, value if flag != "--for-games" or not value + else os.path.abspath(value) + if games_folder_kept(os.path.abspath(value), folder) else None) + for flag, value in _INVOCATION.flags(plat)] + if games: + options += [("--for-games", None), + *([("--required-only", None)] if args.required_only else [])] + choice = record_argv(options) + path, setup, problem = write_record(folder, plat, manifest, choice, written) + if problem: + detail(f" {problem}.") + count = len(setup["files"]) + EVENTS.emit("record", platform=plat, folder=str(folder), + path=str(path or record_paths(folder)[0]), + status="not_written" if problem else "written", choice=choice, + date=setup["written"], files=count, + message=f"{problem}." if problem else + f"Recorded {_plural(count, 'file')} and this choice in {path}.") + + if len(platforms) == 1 and not listing and not choice_given(args): + apply_record(args, *platforms[0]) + # --target is checked for every platform before anything is written, as # --installed-cores is below: EmuDeck installed 3.2 GB and only then was # steamos refused for RetroArch, with no summary of what had landed. target_lists: dict[str, dict] = {} + if args.target and by_record(args) and len(platforms) == 1: + # The screen's runs carry the record's options as typed ones. + plat = platforms[0][0] + if LOCAL.offline and local_targets(plat) is None and plat in TARGET_PLATFORMS: + drop_recorded(args, plat, "--target", "the board list needs the internet") + else: + with contextlib.redirect_stderr(io.StringIO()): + known = fetch_targets(plat) + if known is None or args.target not in known: + drop_recorded(args, plat, "--target", "the board list does not name it" + if known is not None else "the board list could not be read") if args.target: for plat_name, _bios_path in platforms: targets = fetch_targets(plat_name) @@ -16139,6 +16682,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: searched = [args.cores_dir] if args.cores_dir else core_dirs(plat_name, bios_path) cores_found[(plat_name, bios_path)] = installed_cores(searched) cores_searched[(plat_name, bios_path)] = searched + elif args.installed_cores and by_record(args): + drop_recorded(args, plat_name, "--installed-cores", + f"{plat_name} ships its cores with the system") elif args.installed_cores: print( f"Error: {plat_name} ships its cores with the system. " @@ -16147,7 +16693,10 @@ 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) + if by_record(args): + drop_recorded(args, plat_name, "--installed-cores", "no core is installed") + else: + _no_core_found(plat_name, bios_path, searched) for plat_name, bios_path in platforms: if bios_path not in installed_paths: @@ -16190,6 +16739,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: ) game_data_cut = 0 + if args.no_game_data and not manifest.get("categorized") and by_record(args): + drop_recorded(args, plat_name, "--no-game-data", "the list marks no game data") if args.no_game_data and not manifest.get("categorized"): # Without the field nothing is marked, and the run would # install the game data it was asked to leave out. @@ -16273,6 +16824,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: found_cores = [] elif found_cores: detail(f" {_plural(len(found_cores), 'core')} installed") + if args.installed_cores and "system_cores" not in manifest and by_record(args): + drop_recorded(args, plat_name, "--installed-cores", + "the list does not say which cores read which systems") if args.installed_cores: if "system_cores" not in manifest: print( @@ -16298,6 +16852,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: else (files, omitted_files))) continue + if args.emulator and by_record(args): + _recorded_emulators(args, plat_name, manifest, files, omitted_files) if args.emulator: files, omitted_files = _narrow_to_emulators( args.emulator, manifest, plat_name, files, omitted_files @@ -16409,6 +16965,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: + ([("--no-game-data", None)] if cut and cut[0] else [])) return _INVOCATION.command(plat, folder) + # The record's options, which the menu's commands overwrite. + record_options_kept = _INVOCATION.chosen.get(plat_name) selection = _prompt_custom_selection( plat_name, bare, bare_omitted + unplaced, installed, detected_os(plat_name, bios_path) or display, @@ -16417,14 +16975,18 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: bool(target or args.installed_cores or args.declared_only or len(files) + game_data_cut + len(unplaced) != listed), command_for, cut, - game_data_only_words([system_title(system, manifest) for system in - game_data_only(given[0] if isinstance(given, tuple) - else [], files)]) - if args.no_game_data else "", + " ".join(filter(None, [ + record_menu_words(_RECORD_TAKEN.get(plat_name)), + game_data_only_words([system_title(system, manifest) for system in + game_data_only(given[0] if isinstance(given, tuple) + else [], files)]) + if args.no_game_data else ""])), None if games_applied else games_plan, ) # Its head named the BIOS folder: the result does not again. OUTPUT.where_said = True + kept_record = plat_name in _RECORD_TAKEN and selection is not None and ( + selection is given or selection == given) if selection is None and LOCAL.pack is not None: smaller = " Nothing copied." elif selection is None: @@ -16456,6 +17018,12 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: _INVOCATION.chosen[plat_name] = [] else: _INVOCATION.chosen.pop(plat_name, None) + if kept_record and record_options_kept is not None: + # Enter on the recorded choice: still the record's options. + _INVOCATION.chosen[plat_name] = record_options_kept + elif selection is None and plat_name in _RECORD_TAKEN and not games_applied: + # e: every file, the recorded narrowing given up. + args.system = args.core = args.region = None if selection is not None: before = len(files) files, omitted_files = narrow(files, omitted_files, *selection) @@ -16567,6 +17135,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: bios_path = library detail("\nChecking existing files...") + record_files: list[dict] = [] to_download, up_to_date, mismatched = check_local( files, bios_path, jobs=args.jobs, term=term ) @@ -16672,6 +17241,10 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: else: print(f"\n {EVERYTHING_IN_PLACE}") print(f" BIOS folder: {shown_path(bios_path)}") + if plat_name in _RECORD_TAKEN: + # The files counted are the recorded choice's, not the list's. + print(f" {record_menu_words(_RECORD_TAKEN[plat_name])} " + "--all-files checks every file.") absent = _absent_required(omitted_files, bios_path) # The collection cannot supply these, so they leave the exit # code alone, but a user asking why a game will not start needs @@ -16710,6 +17283,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: trees, kept, copy_plan_lines(to_download, copies, bios_index, bios_path, files, args.verbose, True), verbose=args.verbose, manifest=manifest) + if plat_name in _RECORD_TAKEN: + print(f" {record_menu_words(_RECORD_TAKEN[plat_name])} " + "--all-files lists every file.") for line in unreadable_lines(unread, args.verbose): print(line) for line in hint: @@ -16784,9 +17360,11 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: coming = len(to_download) - len(lacking) + trees_missing weight = dl_size + sum(data_fetch_bytes(entry, state[1]) for entry, state in data_states) + taken = _RECORD_TAKEN.get(plat_name) print(f"\nInstalling {_plural(coming, 'file')} ({format_size(weight)}) for " f"{detected_os(plat_name, bios_path) or display}" - + (", what the games need" if games_applied else "") + ".") + + (", what the games need" if games_applied else "") + + (f", same choice as on {record_day(taken)}" if taken else "") + ".") print(f"BIOS folder: {shown_path(bios_path)}") OUTPUT.where_said = True if unread: @@ -16878,6 +17456,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: # counted them, over the files that landed: matched by the end # of a failed path, a.bin was dropped when x/a.bin failed. landed.extend(progress.landed) + record_files += [record_file(entry, "copy" if id(entry) in copies + else "pack" if LOCAL.pack is not None else "download") + for entry in progress.landed] files_pending += len(to_download) - progress.installed if interrupted \ else len(failed) total_errors += len(failed) @@ -16886,6 +17467,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: total_seconds += progress.elapsed() if interrupted: total_interrupted = True + remember(plat_name, bios_path, manifest, record_files, + games_applied and args.for_games is None) break elif len(platforms) > 1: # One setup's run ends on "Done. Everything is in place.": said @@ -16931,6 +17514,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: total_seconds += progress.elapsed() total_downloaded += counts["installed"] data_landed += counts["installed"] + record_files += [record_file(item, "pack" if LOCAL.pack is not None + else "download", "data") + for item in counts.get("written", [])] total_up_to_date += counts["present"] if isinstance(counts["error"], OfflineReason): # Left out as the BIOS files above are: counted missing, @@ -16971,6 +17557,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: failures.append((where, f"{NOT_KEPT} ({reason})")) total_errors += 1 + remember(plat_name, bios_path, manifest, record_files, + games_applied and args.for_games is None) + if os_type == "android" and not args.check and hide_from_media_scanner(bios_path): detail(f" Added {NOMEDIA} to {shown_path(bios_path)}: the gallery and music " "apps leave its images, sounds and videos out") @@ -17086,6 +17675,10 @@ TUI_ESC_WAIT = 0.04 TUI_FRAME = 0.1 # Set for a child run handed the interface's own copy of the file list. TUI_LIST_ENV = "RETROBIOS_TUI_LIST" +# Set for a child run whose choice options are the install record's, kept +# on the screen: a part of them that cannot apply is left out, as line mode +# leaves it out of a record it reads, never refused. +TUI_RECORD_ENV = "RETROBIOS_TUI_RECORDED" _PASTE_START = "\x1b[200~" _PASTE_END = "\x1b[201~" # Turned on by the interface, so a pasted path arrives as one piece and its @@ -18019,6 +18612,16 @@ class TuiState: self.games: dict | None = None self.games_reading: tuple | None = None self.games_unavailable: dict | None = None + # The install record of the setup and folder chosen (read for + # record_for), whose choice the plan opens on when the command line + # chose nothing (cli_choice), and the options of it no row shows. + self.record: dict | None = None + self.record_for: tuple | None = None + self.record_flags: list = [] + self.cli_choice: bool = False + # Opened on the plan of the one setup found: its Change where row + # goes back to the setups. + self.opened_on_plan: bool = False @property def screen(self) -> str: @@ -18475,6 +19078,12 @@ def tui_flags(state: TuiState) -> list[tuple[str, str | None]]: flags.append(("--installed-cores", None)) if state.jobs != 8: flags.append(("--jobs", str(state.jobs))) + flags += state.record_flags + if (state.record is not None and not state.for_games + and not any(flag in RECORD_NARROWING for flag, _value in flags)): + # The whole list, chosen over a recorded narrower one: said so to + # the runs, whose own reading of the record would narrow it again. + flags.append(("--all-files", None)) flags += [("--from", str(folder)) for folder in state.from_folders] if (os.environ.get(OFFLINE_VARIABLE) == OFFLINE_FLAG_VALUE and not any(flag == "--offline" for flag, _v in flags)): @@ -18505,6 +19114,19 @@ def tui_argv(state: TuiState, run: str, python: str, script: str) -> list[str]: return argv + _TUI_MODES[run] +def tui_run_env(state: TuiState, own_list: bool) -> dict | None: + """The environment of a child run: None for this one's own, else with + the markers the run reads (TUI_LIST_ENV, TUI_RECORD_ENV).""" + marks = {TUI_LIST_ENV: "1"} if own_list else {} + if tui_record_kept(state): + marks[TUI_RECORD_ENV] = "1" + if not marks and TUI_RECORD_ENV not in os.environ: + return None + env = {key: value for key, value in os.environ.items() if key != TUI_RECORD_ENV} + env.update(marks) + return env + + def tui_hint(state: TuiState) -> Invocation: """The command the screen's choices make, every option of the run in it: what "Same choice without questions" and the sudo advice give.""" @@ -18960,14 +19582,12 @@ def _plan_rows(state: TuiState) -> list[TuiRow]: return rows + ([game_data] if game_data is not None else []) + [ *_details_row(state), TuiRow("options", "Options"), TuiRow("quit", "Quit")] if not (_needed(check) if check else 0) and not check.get("data_missing"): - return [TuiRow("close", "Close"), *_games_rows(state), *_details_row(state), - TuiRow("options", "Options")] - # The size the pack can give: "Install 6.6 GB" from a pack holding - # none of the files. - lacking = set(_tui_pack_lacks(state, [event["dest"] for event in state.files])) - size = (_ticked_estimate(state)[1] if _tui_estimating(state) - else check.get("bytes_needed", 0) - sum(event["size"] for event in state.files - if event["dest"] in lacking)) + return [TuiRow("close", "Close"), *_games_rows(state), *_record_rows(state), + *_where_rows(state), *_details_row(state), TuiRow("options", "Options")] + # The size the headline says: "Install 6.6 GB" stood under "14930 files + # to download, 6.8 GB", the copies counted in one and the data files in + # the other. + size = _ticked_estimate(state)[1] if _tui_estimating(state) else _plan_fetch(state, check)[1] label = (f"{TUI_INSTALL_ROW} {format_size(size)}" if size >= CONFIRM_FROM_BYTES else TUI_INSTALL_ROW) rows = [TuiRow("install", label)] @@ -19006,6 +19626,7 @@ def _plan_rows(state: TuiState) -> list[TuiRow]: # Right under Install: the choice the plan opened on, the whole list one # press away. rows += _games_rows(state) + rows += _record_rows(state) if "systems" in recorded_axes((state.manifest or {}).get("files", [])) and ( len(state.systems) > 1 or state.cli_systems): rows.append(TuiRow("systems", TUI_SYSTEMS_ROW)) @@ -19018,7 +19639,27 @@ def _plan_rows(state: TuiState) -> list[TuiRow]: rows.append(TuiRow("misplaced", "Show the files in the wrong place")) if state.unreadable: rows.append(TuiRow("unreadable", "Show the files that could not be read")) - return rows + [*_details_row(state), TuiRow("options", "Options"), TuiRow("quit", "Quit")] + return rows + [*_where_rows(state), *_details_row(state), TuiRow("options", "Options"), + TuiRow("quit", "Quit")] + + +# The plan's row for the record's choice, and the one back to the setups. +TUI_RECORD_ROW = "Same choice as last time" +TUI_WHERE_ROW = TuiRow("where", "Change where") + + +def _record_rows(state: TuiState) -> list[TuiRow]: + """Same choice as last time: yes or no, where the folder has a record.""" + if state.record is None or state.cli_choice: + return [] + day = record_day(state.record) + label = f"Same choice as on {day}" if day else TUI_RECORD_ROW + return [TuiRow("record", f"{label}: {_yes(tui_record_kept(state))}")] + + +def _where_rows(state: TuiState) -> list[TuiRow]: + """Change where, on a plan opened straight on the one setup found.""" + return [TUI_WHERE_ROW] if state.opened_on_plan and state.stack[:1] == ["setup"] else [] # The plan's explanations, counts and reasons, one press away: a pad has @@ -19503,9 +20144,88 @@ def _start_run(state: TuiState, run: str) -> list[tuple]: return [("run", run)] +def _record_key(options: Iterable[tuple[str, str | None]]) -> list: + """A choice in a form two spellings of it share: one name per --system + and --core, in any order.""" + pairs = record_options(record_argv(options)) + return sorted((flag, name.strip()) for flag, value in pairs + for name in ((value or "").split(",") if flag in ("--system", "--core") + else [value or ""]) if name.strip() or value is None) + + +def tui_record_kept(state: TuiState) -> bool: + """Whether the plan's choice is the one the record keeps.""" + if state.record is None: + return False + return _record_key(tui_flags(state)) == _record_key( + record_options(state.record["choice"])) + + +def _tui_record_take(state: TuiState) -> None: + """The recorded choice made the screen's, as its options would make it.""" + setup = state.record or {} + try: + parsed = build_parser().parse_args(setup.get("choice") or []) + except SystemExit: + state.status = "The install record names options this installer lacks, so it is ignored." + state.record = None + return + state.cli_systems = list(parsed.system or []) + state.cores = [token.strip() for value in parsed.core or [] for token in value.split(",") + if token.strip()] + try: + state.regions = resolve_regions(parsed.region) if parsed.region else [] + except ValueError: + state.regions = [] + state.no_game_data = bool(parsed.no_game_data) + state.installed_only = bool(parsed.installed_cores) + state.for_games = parsed.for_games is not None + state.games_folder = parsed.for_games if isinstance(parsed.for_games, Path) else None + state.required_only = bool(parsed.required_only) and state.for_games + state.games_choice = "record" + state.record_flags = [(flag, value) for flag, value in record_options(setup["choice"]) + if flag in ("--target", "--emulator", "--every-board", + "--declared-only")] + + +def _tui_record_drop(state: TuiState) -> None: + """The recorded choice given up: the choice nothing narrows.""" + state.cli_systems, state.cores, state.regions, state.record_flags = [], [], [], [] + state.no_game_data = state.installed_only = state.required_only = False + state.for_games, state.games_folder, state.games_choice = None, None, "" + # The systems of the recorded check go, and with them its ticks. + state.systems, state.ticked, state.keep_ticked, state.widened_from = [], set(), None, None + + +def _tui_record_load(state: TuiState) -> None: + """Read the record of the setup and folder chosen, once for each, and + take its choice where nothing chose: the command line, or the screen + for an earlier folder.""" + key = (state.platform, state.dest) + if state.dest is None or state.record_for == key: + return + taken = tui_record_kept(state) + state.record_for = key + setup, path, problem = setup_record(state.platform, state.dest) + if problem and str(path) not in _RECORD_SAID: + _RECORD_SAID.add(str(path)) + state.status = f"{problem}, so it is ignored." + if taken: + _tui_record_drop(state) + # Games read on a card are what the plan found, not a choice. + found = ("--for-games", "--required-only") if state.games_choice in ("", "probe") else () + state.record = None + untouched = not any(flag in RECORD_NARROWING and flag not in found + for flag, _value in tui_flags(state)) + state.record = setup + if setup is not None and not state.cli_choice and untouched: + _tui_record_take(state) + + def _start_check(state: TuiState) -> list[tuple]: """Check the chosen folder, after fetching the file list once, the systems ticked kept (_keep_choice).""" + _tui_record_load(state) _keep_choice(state) _games_look(state) state.refocus = "" @@ -19528,7 +20248,7 @@ def _games_look(state: TuiState) -> None: looked at too, from what this run already read (no network): its games make "For the games on this card" the choice, unless the user or the command line decided.""" - if (state.games_choice in ("user", "cli") or state.games_probed is None + if (state.games_choice in ("user", "cli", "record") or state.games_probed is None or state.dest is None or state.games_probed == (state.platform, state.dest)): return count = games_probe(state.platform, state.dest, network=False, os_type=state.os_type) @@ -20130,6 +20850,28 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: return _push(state, action) if action == "recheck": return _start_check(state) + if action == "where": + effects = [("kill",)] if state.running else [] + state.running, state.run_id = False, state.run_id + 1 + del state.stack[1:] + return effects + if action == "record": + # No: the choice nothing narrows, the card's games when it holds + # some. Yes: the recorded choice again. Counted again either way. + if tui_record_kept(state): + _tui_record_drop(state) + if state.dest is not None: + count = games_probe(state.platform, state.dest, network=False, + os_type=state.os_type) + state.games_probed = (state.platform, state.dest) + state.games_here = count or 0 + state.for_games, state.games_choice = bool(count), "probe" + else: + _tui_record_drop(state) + _tui_record_take(state) + effects = _start_check(state) + state.refocus = "record" + return effects if action in ("games", "startonly"): # One press, then counted again: the games decide the systems, # so the ticks start again from every system they need. @@ -21485,7 +22227,7 @@ def tui_receive(state: TuiState, message: tuple) -> list[tuple]: _kind, plat, dest, count = message if plat == state.platform and dest == state.dest: state.games_probed = (plat, dest) - if state.games_choice not in ("user", "cli"): + if state.games_choice not in ("user", "cli", "record"): state.games_here = count or 0 state.for_games, state.games_choice = bool(count), "probe" return [] @@ -21985,25 +22727,36 @@ def _last_name(folder: str) -> str: return Path(folder).name or folder -def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: - need = _needed(check) - data = check.get("data_missing", 0) - # A file found on this machine is copied, not downloaded: the count and - # the size to download leave it out, from the check's own events. On - # Confirm the dry run's files are matched the same way: it called the - # files a --from folder holds downloads, and sent an offline user for - # a pack. +def _plan_fetch(state: TuiState, check: dict) -> tuple[int, int, list, list, dict]: + """(files to fetch, bytes the install fetches with the data files', + dests copied from this machine, dests the pack lacks, the size of each + file): the one size the plan's headline and its Install row say. + + A file found on this machine is copied, not downloaded: the count and + the size to download leave it out, from the check's own events. On + Confirm the dry run's files are matched the same way: it called the + files a --from folder holds downloads, and sent an offline user for a + pack. The files the pack does not hold are left out too: the plan + offered "Install 6.6 GB" from a pack that held none of them. + """ dry = check is state.dry and check is not state.check sizes = {event["dest"]: event["size"] for event in (state.dry_files if dry else state.files)} copied = _plan_copies(state, check) if dry or check is state.check else [] - # The files the pack does not hold are said before a size is promised, - # and left out of it: the plan offered "Install 6.6 GB" from a pack that - # held none of them. lacking = _tui_pack_lacks(state, [dest_ for dest_ in sizes if dest_ not in copied]) - fetch = max(0, need - len(copied) - len(lacking)) - fetch_bytes = max(0, check["bytes_needed"] - sum(sizes[dest_] for dest_ in copied) + fetch = max(0, _needed(check) - len(copied) - len(lacking)) + fetch_bytes = max(0, check.get("bytes_needed", 0) - sum(sizes[dest_] for dest_ in copied) - sum(sizes[dest_] for dest_ in lacking)) + data_bytes = check.get("data_bytes", 0) if check.get("data_missing", 0) else 0 + return fetch, fetch_bytes + data_bytes, copied, lacking, sizes + + +def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: + need = _needed(check) + data = check.get("data_missing", 0) + dry = check is state.dry and check is not state.check + fetch, fetched, copied, lacking, sizes = _plan_fetch(state, check) + fetch_bytes = fetched - (check.get("data_bytes", 0) if data else 0) elsewhere = (_tui_found_elsewhere(state, set(copied) if dry else None) if check is state.check or dry else {}) offline = _offline_only() @@ -22149,6 +22902,8 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: if check is state.check: lines += _unplaced_lines(_choice_summary(state), state, "Choose it in Options, Save folder (--save-dir).") + if check is state.check: + lines += _found_lines(state) if check is state.check and state.from_folders: # A folder copied from that holds none of them: the plan came back # the same, still saying to copy from a folder. @@ -22603,6 +23358,23 @@ def _plan_text(state: TuiState) -> list: said = ", ".join(named) if said and not said.startswith("for "): said = f"for this choice: {said}" + since: list = [] + if tui_record_kept(state) and not state.cli_choice and state.record is not None: + # The record's systems by name, its row saying whose choice it is: + # nobody typed --system. The games and the other options are said + # as the plan says them. + what = record_says(state.record, state.manifest) + if what == "every file": + said = "" + elif what not in ("the games", "the options chosen"): + said = f"for {what}" + updated, new = record_changes(state.record, [ + entry for entry in (state.manifest or {}).get("files") or [] + if entry.get("dest") in state.checked_dests]) + changes = [*([f"{_plural(updated, 'file')} updated in the list"] if updated else []), + *([f"{new} new"] if new else [])] + if changes: + since = [_detail(f"Since then: {', '.join(changes)}.")] # A system --system named that --no-game-data left empty, in line mode's # words. listed = {row["system"] for row in state.systems} @@ -22641,7 +23413,7 @@ def _plan_text(state: TuiState) -> list: f"{parts}") lines[at:at] = [*([_detail(state.card_note)] if state.card_note and state.card_note != state.status else []), - *([f"{said[0].upper()}{said[1:]}."] if said else []), + *([f"{said[0].upper()}{said[1:]}."] if said else []), *since, *([emptied_named] if emptied_named else []), *choice, *([_detail(parts)] if parts else []), *games_plan_words(state)] if state.version: @@ -23179,6 +23951,20 @@ def _unavailable(state: TuiState) -> tuple[list[dict], int]: return needed, others +def _found_lines(state: TuiState) -> list[TuiLine]: + """What the setups screen said of this machine, on a plan that opened + in its place: the setup found by its label, and the notes (ES-DE found, + what detection printed) on Details, one line on the plan saying so.""" + if not state.opened_on_plan or not state.setups: + return [] + lines = _details([f"Found here: {state.setups[0][2]}.", *state.notes]) + if state.notes: + brief = _styled(f"{_plural(len(state.notes), 'note')} on this machine: Details.") + brief.brief = True + lines.insert(0, brief) + return lines + + def _unavailable_brief(state: TuiState) -> list[TuiLine]: """The count of the files an emulator needs that cannot be supplied, on the screen itself: their names are on Details, and in place with @@ -24065,6 +24851,7 @@ def tui_start( if args.manifest is not None: state.manifest_path = Path(args.manifest).absolute() state.manifest_given = True + state.cli_choice = choice_given(args) or state.manifest_given plat = normalize_platform(args.platform, say=False) if args.platform else "" if detect: captured = io.StringIO() @@ -24137,6 +24924,14 @@ def tui_start( if state.dest_given is None and not args.platform: state.doubts = [(plat, mount) for mount in _mounted_cards() for plat in share_doubt(mount)[0]] + if (state.dest_given is None and not state.doubts and not state.refused + and len(state.setups) == 1): + # One setup found and no card in doubt: its plan, Change where (or + # Back) one press away. A folder detection could not look in may + # hold another setup: the setups are listed then, with it. + found, folder, _label = state.setups[0] + state.opened_on_plan = True + return state, _choose(state, found, folder, proposed=True) if state.dest_given is not None or not (state.setups or state.doubts): state.stack = ["platforms"] state.focus["platforms"] = _platform_focus(state, dest_default(state.setups)) @@ -24732,7 +25527,7 @@ def run_tui(args: argparse.Namespace) -> int: if kind == "fetch": threading.Thread(target=_tui_fetch_games, args=(effect[1], inbox, buffer, state.dest, - state.games_choice not in ("user", "cli")), + state.games_choice not in ("user", "cli", "record")), daemon=True).start() elif kind == "run": for run in current: @@ -24751,7 +25546,7 @@ def run_tui(args: argparse.Namespace) -> int: try: current.append(_EngineRun( tui_argv(state, effect[1], sys.executable, script), log, inbox, - state.run_id, {**os.environ, TUI_LIST_ENV: "1"} if own_list else None)) + state.run_id, tui_run_env(state, own_list))) except OSError as exc: inbox.put(("ended", state.run_id, 1, [f"The installer could not be started: {exc}"])) diff --git a/install.sh b/install.sh index a606cca06..461ba515b 100755 --- a/install.sh +++ b/install.sh @@ -23,7 +23,7 @@ DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/ins # curl or wget stop on a cut transfer before the hash is read: the site's # build rewrites this line there (scripts/sitebootstrap.py). MISMATCH_ADVICE="The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that." -DEFAULT_INSTALL_SHA256="456a32e79668e125f2ebe573b549247210c59fed6389e78dc1482b57d3a36712" +DEFAULT_INSTALL_SHA256="37442a7c0d307b0b95a40d7630cf57025a6498fea6223076892371b2d6363147" # The pin above refuses any changed byte: this cap only stops a body that # cannot be the installer (a sign-in portal's page) before it is hashed. MAX_INSTALLER_BYTES=8388608 diff --git a/install/launchers/index.json b/install/launchers/index.json index 8cee38296..e6f2a2d46 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 1162452, - "sha256": "456a32e79668e125f2ebe573b549247210c59fed6389e78dc1482b57d3a36712", + "size": 1201450, + "sha256": "37442a7c0d307b0b95a40d7630cf57025a6498fea6223076892371b2d6363147", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1216077, - "sha256": "b9e623839a8cf71d929a860b53a6c4c82cbfde551151912e1aba0347532be965" + "size": 1255075, + "sha256": "23393eb6b88f7a7875bd034e79c02d81e80409c9403290bb96a75166bf7a60b6" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 1162452, - "sha256": "456a32e79668e125f2ebe573b549247210c59fed6389e78dc1482b57d3a36712", + "size": 1201450, + "sha256": "37442a7c0d307b0b95a40d7630cf57025a6498fea6223076892371b2d6363147", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 1162452, - "sha256": "456a32e79668e125f2ebe573b549247210c59fed6389e78dc1482b57d3a36712", + "size": 1201450, + "sha256": "37442a7c0d307b0b95a40d7630cf57025a6498fea6223076892371b2d6363147", "executable": false }, { diff --git a/schemas/install-events.schema.json b/schemas/install-events.schema.json index a252c8fce..0a6713ed3 100644 --- a/schemas/install-events.schema.json +++ b/schemas/install-events.schema.json @@ -38,6 +38,7 @@ "downloaded", "summary", "app", + "record", "end" ] } @@ -127,6 +128,9 @@ { "$ref": "#/$defs/app" }, + { + "$ref": "#/$defs/record" + }, { "$ref": "#/$defs/end" } @@ -1091,6 +1095,59 @@ } } }, + "record": { + "type": "object", + "additionalProperties": false, + "required": [ + "event", + "platform", + "folder", + "path", + "status", + "choice", + "date", + "files", + "message" + ], + "properties": { + "event": { + "const": "record" + }, + "platform": { + "type": "string" + }, + "folder": { + "type": "string" + }, + "path": { + "type": "string" + }, + "status": { + "enum": [ + "applied", + "written", + "unreadable", + "not_written" + ] + }, + "choice": { + "type": "array", + "items": { + "type": "string" + } + }, + "date": { + "type": "string" + }, + "files": { + "type": "integer", + "minimum": 0 + }, + "message": { + "type": "string" + } + } + }, "end": { "type": "object", "additionalProperties": false, diff --git a/scripts/generate_event_schema.py b/scripts/generate_event_schema.py index 8b9eab202..0242aae8d 100644 --- a/scripts/generate_event_schema.py +++ b/scripts/generate_event_schema.py @@ -37,10 +37,11 @@ KEY_TYPES: dict[str, dict] = { **dict.fromkeys(( "installer", "os", "os_name", "stage", "reason", "platform", "message", "folder", "dest", "system", "name", "bios_dir", "path", "member", - "list_version", "installed_version", "board", "core", + "list_version", "installed_version", "board", "core", "date", ), _TEXT), "mode": {"enum": ["install", "check", "dry-run", "list"]}, "state": {"enum": ["present", "wrong_hash", "missing"]}, + "status": {"enum": ["applied", "written", "unreadable", "not_written"]}, **dict.fromkeys(( "files", "files_done", "files_total", "bytes", "bytes_done", "bytes_total", "bytes_local", "bytes_network", "size", "present", "missing", "wrong_hash", @@ -48,7 +49,7 @@ KEY_TYPES: dict[str, dict] = { "pending", "code", "games", "recognised", "not_games", ), _COUNT), **dict.fromkeys(("truncated", "ok", "interrupted", "required_only", "applied", "info"), _FLAG), - **dict.fromkeys(("systems", "other_drives", "dests", "written"), _NAMES), + **dict.fromkeys(("systems", "other_drives", "dests", "written", "choice"), _NAMES), **dict.fromkeys(("platforms", "failed", "skipped", "left_out", "kept", "found", "fallback"), _RECORDS), } diff --git a/tests/fixtures/tui/colour-120x40.txt b/tests/fixtures/tui/colour-120x40.txt index 9cf8474af..08b62e689 100644 --- a/tests/fixtures/tui/colour-120x40.txt +++ b/tests/fixtures/tui/colour-120x40.txt @@ -1064,3 +1064,44 @@ ^[[0;1;35mA/Enter^[[0m Back +=== plan-record +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m + +^[[0;1m ^[[0;1;35m1^[[0;1m file to download, ^[[0;1;35m16.0 KB^[[0m + BIOS folder: ^[[0;1m~/.config/retroarch/system^[[0m + For nintendo-gba. + +^[[0;7;35m > Install^[[0m + Same choice as on 3 Nov: yes + Choose systems + Copy the BIOS files from an old card or a folder + Change where + Details + Options + Quit + + + + + + + + + + + + + + + + + + + + + + + + + + ^[[0;1;35mA/Enter^[[0m Install ^[[0;2mB/Esc^[[0m Back ^[[0;2mY/o^[[0m Options ^[[0;2m?^[[0m Help diff --git a/tests/fixtures/tui/colour-40x12.txt b/tests/fixtures/tui/colour-40x12.txt index 2c14f4510..dd8c187aa 100644 --- a/tests/fixtures/tui/colour-40x12.txt +++ b/tests/fixtures/tui/colour-40x12.txt @@ -336,3 +336,16 @@ ^[[0;2m Shortened to fit this window.^[[0m ^[[0;1;35mA^[[0m Back +=== plan-record +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m + +^[[0;1m ^[[0;1;35m1^[[0;1m file to download, ^[[0;1;35m16.0 KB^[[0m + BIOS folder: + ^[[0;1m~/.config/retroarch/system^[[0m + +^[[0;7;35m > Install^[[0m + Same choice as on 3 Nov: yes + Choose systems + Copy from an old card or a folder +^[[0;2m 1-4 of 8^[[0m + ^[[0;1;35mA^[[0m Install ^[[0;2mB^[[0m Back ^[[0;2mY^[[0m Options ^[[0;2m?^[[0m Help diff --git a/tests/fixtures/tui/colour-40x15.txt b/tests/fixtures/tui/colour-40x15.txt index 9ec9e5490..981d77e67 100644 --- a/tests/fixtures/tui/colour-40x15.txt +++ b/tests/fixtures/tui/colour-40x15.txt @@ -414,3 +414,19 @@ ^[[0;1;35mA^[[0m Back +=== plan-record +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m + +^[[0;1m ^[[0;1;35m1^[[0;1m file to download, ^[[0;1;35m16.0 KB^[[0m + BIOS folder: + ^[[0;1m~/.config/retroarch/system^[[0m + For nintendo-gba. +^[[0;7;35m > Install^[[0m + Same choice as on 3 Nov: yes + Choose systems + Copy from an old card or a folder + Change where + Details + Options + Quit + ^[[0;1;35mA^[[0m Install ^[[0;2mB^[[0m Back ^[[0;2mY^[[0m Options ^[[0;2m?^[[0m Help diff --git a/tests/fixtures/tui/colour-80x24.txt b/tests/fixtures/tui/colour-80x24.txt index 64a66ec3e..efa50239c 100644 --- a/tests/fixtures/tui/colour-80x24.txt +++ b/tests/fixtures/tui/colour-80x24.txt @@ -648,3 +648,28 @@ ^[[0;1;35mA/Enter^[[0m Back +=== plan-record +^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m + +^[[0;1m ^[[0;1;35m1^[[0;1m file to download, ^[[0;1;35m16.0 KB^[[0m + BIOS folder: ^[[0;1m~/.config/retroarch/system^[[0m + For nintendo-gba. + +^[[0;7;35m > Install^[[0m + Same choice as on 3 Nov: yes + Choose systems + Copy the BIOS files from an old card or a folder + Change where + Details + Options + Quit + + + + + + + + + + ^[[0;1;35mA/Enter^[[0m Install ^[[0;2mB/Esc^[[0m Back ^[[0;2mY/o^[[0m Options ^[[0;2m?^[[0m Help diff --git a/tests/fixtures/tui/plan-record-120x40.txt b/tests/fixtures/tui/plan-record-120x40.txt new file mode 100644 index 000000000..acea8e5bc --- /dev/null +++ b/tests/fixtures/tui/plan-record-120x40.txt @@ -0,0 +1,40 @@ + RetroBIOS: RetroArch + + 1 file to download, 16.0 KB + BIOS folder: ~/.config/retroarch/system + For nintendo-gba. + + > Install + Same choice as on 3 Nov: yes + Choose systems + Copy the BIOS files from an old card or a folder + Change where + Details + Options + Quit + + + + + + + + + + + + + + + + + + + + + + + + + + A/Enter Install B/Esc Back Y/o Options ? Help diff --git a/tests/fixtures/tui/plan-record-40x12.txt b/tests/fixtures/tui/plan-record-40x12.txt new file mode 100644 index 000000000..439d2a286 --- /dev/null +++ b/tests/fixtures/tui/plan-record-40x12.txt @@ -0,0 +1,12 @@ + RetroBIOS: RetroArch + + 1 file to download, 16.0 KB + BIOS folder: + ~/.config/retroarch/system + + > Install + Same choice as on 3 Nov: yes + Choose systems + Copy from an old card or a folder + 1-4 of 8 + A Install B Back Y Options ? Help diff --git a/tests/fixtures/tui/plan-record-40x15.txt b/tests/fixtures/tui/plan-record-40x15.txt new file mode 100644 index 000000000..0ca0559e4 --- /dev/null +++ b/tests/fixtures/tui/plan-record-40x15.txt @@ -0,0 +1,15 @@ + RetroBIOS: RetroArch + + 1 file to download, 16.0 KB + BIOS folder: + ~/.config/retroarch/system + For nintendo-gba. + > Install + Same choice as on 3 Nov: yes + Choose systems + Copy from an old card or a folder + Change where + Details + Options + Quit + A Install B Back Y Options ? Help diff --git a/tests/fixtures/tui/plan-record-80x24.txt b/tests/fixtures/tui/plan-record-80x24.txt new file mode 100644 index 000000000..cacafb755 --- /dev/null +++ b/tests/fixtures/tui/plan-record-80x24.txt @@ -0,0 +1,24 @@ + RetroBIOS: RetroArch + + 1 file to download, 16.0 KB + BIOS folder: ~/.config/retroarch/system + For nintendo-gba. + + > Install + Same choice as on 3 Nov: yes + Choose systems + Copy the BIOS files from an old card or a folder + Change where + Details + Options + Quit + + + + + + + + + + A/Enter Install B/Esc Back Y/o Options ? Help diff --git a/tests/fixtures/tui/route-evmapy.txt b/tests/fixtures/tui/route-evmapy.txt index d799d290a..c25b7c0e5 100644 --- a/tests/fixtures/tui/route-evmapy.txt +++ b/tests/fixtures/tui/route-evmapy.txt @@ -1183,3 +1183,57 @@ A Back +=== plan-record 40x12 + RetroBIOS: RetroArch + + 1 file to download, 16.0 KB + BIOS folder: + ~/.config/retroarch/system + + > Install + Same choice as on 3 Nov: yes + Choose systems + Copy from an old card or a folder + 1-4 of 8 + A Install B Back Y Options +=== plan-record 40x15 + RetroBIOS: RetroArch + + 1 file to download, 16.0 KB + BIOS folder: + ~/.config/retroarch/system + For nintendo-gba. + > Install + Same choice as on 3 Nov: yes + Choose systems + Copy from an old card or a folder + Change where + Details + Options + Quit + A Install B Back Y Options +=== plan-record 80x24 + RetroBIOS: RetroArch + + 1 file to download, 16.0 KB + BIOS folder: ~/.config/retroarch/system + For nintendo-gba. + + > Install + Same choice as on 3 Nov: yes + Choose systems + Copy the BIOS files from an old card or a folder + Change where + Details + Options + Quit + + + + + + + + + + A Install B Back Y Options diff --git a/tests/fixtures/tui/route-gptokeyb.txt b/tests/fixtures/tui/route-gptokeyb.txt index 9e9647ae4..5816841a0 100644 --- a/tests/fixtures/tui/route-gptokeyb.txt +++ b/tests/fixtures/tui/route-gptokeyb.txt @@ -1183,3 +1183,57 @@ A Back +=== plan-record 40x12 + RetroBIOS: RetroArch + + 1 file to download, 16.0 KB + BIOS folder: + ~/.config/retroarch/system + + > Install + Same choice as on 3 Nov: yes + Choose systems + Copy from an old card or a folder + 1-4 of 8 + A Install B Back Y Options +=== plan-record 40x15 + RetroBIOS: RetroArch + + 1 file to download, 16.0 KB + BIOS folder: + ~/.config/retroarch/system + For nintendo-gba. + > Install + Same choice as on 3 Nov: yes + Choose systems + Copy from an old card or a folder + Change where + Details + Options + Quit + A Install B Back Y Options +=== plan-record 80x24 + RetroBIOS: RetroArch + + 1 file to download, 16.0 KB + BIOS folder: ~/.config/retroarch/system + For nintendo-gba. + + > Install + Same choice as on 3 Nov: yes + Choose systems + Copy the BIOS files from an old card or a folder + Change where + Details + Options + Quit + + + + + + + + + + A Install B Back Y Options diff --git a/tests/fixtures/tui/route-gptokeyb_muxterm.txt b/tests/fixtures/tui/route-gptokeyb_muxterm.txt index 9390cd9a6..526fa0b87 100644 --- a/tests/fixtures/tui/route-gptokeyb_muxterm.txt +++ b/tests/fixtures/tui/route-gptokeyb_muxterm.txt @@ -1183,3 +1183,57 @@ A Back +=== plan-record 40x12 + RetroBIOS: RetroArch + + 1 file to download, 16.0 KB + BIOS folder: + ~/.config/retroarch/system + + > Install + Same choice as on 3 Nov: yes + Choose systems + Copy from an old card or a folder + 1-4 of 8 + A Install B Back Y Options +=== plan-record 40x15 + RetroBIOS: RetroArch + + 1 file to download, 16.0 KB + BIOS folder: + ~/.config/retroarch/system + For nintendo-gba. + > Install + Same choice as on 3 Nov: yes + Choose systems + Copy from an old card or a folder + Change where + Details + Options + Quit + A Install B Back Y Options +=== plan-record 80x24 + RetroBIOS: RetroArch + + 1 file to download, 16.0 KB + BIOS folder: ~/.config/retroarch/system + For nintendo-gba. + + > Install + Same choice as on 3 Nov: yes + Choose systems + Copy the BIOS files from an old card or a folder + Change where + Details + Options + Quit + + + + + + + + + + A Install B Back Y Options diff --git a/tests/fixtures/tui/route-keys.txt b/tests/fixtures/tui/route-keys.txt index 571fc7b59..7e0713cf4 100644 --- a/tests/fixtures/tui/route-keys.txt +++ b/tests/fixtures/tui/route-keys.txt @@ -1348,3 +1348,57 @@ Enter Back +=== plan-record 40x12 + RetroBIOS: RetroArch + + 1 file to download, 16.0 KB + BIOS folder: + ~/.config/retroarch/system + + > Install + Same choice as on 3 Nov: yes + Choose systems + Copy from an old card or a folder + 1-4 of 8 + Enter Install Esc Back o Options +=== plan-record 40x15 + RetroBIOS: RetroArch + + 1 file to download, 16.0 KB + BIOS folder: + ~/.config/retroarch/system + For nintendo-gba. + > Install + Same choice as on 3 Nov: yes + Choose systems + Copy from an old card or a folder + Change where + Details + Options + Quit + Enter Install Esc Back o Options +=== plan-record 80x24 + RetroBIOS: RetroArch + + 1 file to download, 16.0 KB + BIOS folder: ~/.config/retroarch/system + For nintendo-gba. + + > Install + Same choice as on 3 Nov: yes + Choose systems + Copy the BIOS files from an old card or a folder + Change where + Details + Options + Quit + + + + + + + + + + Enter Install Esc Back o Options ? Help diff --git a/tests/fixtures/tui/route-miyoo.txt b/tests/fixtures/tui/route-miyoo.txt index d828a7dac..96e86e306 100644 --- a/tests/fixtures/tui/route-miyoo.txt +++ b/tests/fixtures/tui/route-miyoo.txt @@ -1286,3 +1286,57 @@ A Back +=== plan-record 40x12 + RetroBIOS: RetroArch + + 1 file to download, 16.0 KB + BIOS folder: + ~/.config/retroarch/system + + > Install + Same choice as on 3 Nov: yes + Choose systems + Copy from an old card or a folder + 1-4 of 8 + A Install R2 Back L2 Options +=== plan-record 40x15 + RetroBIOS: RetroArch + + 1 file to download, 16.0 KB + BIOS folder: + ~/.config/retroarch/system + For nintendo-gba. + > Install + Same choice as on 3 Nov: yes + Choose systems + Copy from an old card or a folder + Change where + Details + Options + Quit + A Install R2 Back L2 Options +=== plan-record 80x24 + RetroBIOS: RetroArch + + 1 file to download, 16.0 KB + BIOS folder: ~/.config/retroarch/system + For nintendo-gba. + + > Install + Same choice as on 3 Nov: yes + Choose systems + Copy the BIOS files from an old card or a folder + Change where + Details + Options + Quit + + + + + + + + + + A Install R2 Back L2 Options diff --git a/tests/fixtures/tui/route-muxterm.txt b/tests/fixtures/tui/route-muxterm.txt index 397254a58..47490ee63 100644 --- a/tests/fixtures/tui/route-muxterm.txt +++ b/tests/fixtures/tui/route-muxterm.txt @@ -1402,3 +1402,57 @@ Start Back +=== plan-record 40x12 + RetroBIOS: RetroArch + + 1 file to download, 16.0 KB + BIOS folder: + ~/.config/retroarch/system + + > Install + Same choice as on 3 Nov: yes + Choose systems + Copy from an old card or a folder + 1-4 of 8 + Start Install B Back +=== plan-record 40x15 + RetroBIOS: RetroArch + + 1 file to download, 16.0 KB + BIOS folder: + ~/.config/retroarch/system + For nintendo-gba. + > Install + Same choice as on 3 Nov: yes + Choose systems + Copy from an old card or a folder + Change where + Details + Options + Quit + Start Install B Back +=== plan-record 80x24 + RetroBIOS: RetroArch + + 1 file to download, 16.0 KB + BIOS folder: ~/.config/retroarch/system + For nintendo-gba. + + > Install + Same choice as on 3 Nov: yes + Choose systems + Copy the BIOS files from an old card or a folder + Change where + Details + Options + Quit + + + + + + + + + + Start Install B Back diff --git a/tests/test_install.py b/tests/test_install.py index b2a6d4734..ab31cb1df 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -6954,10 +6954,19 @@ class EventsDescribeTheRun(unittest.TestCase, _SyntheticTree): # The last transfer: every byte came over the network. self.assertEqual([e for e in events if e["event"] == "transfer"][-1], {"event": "transfer", "bytes_local": 0, "bytes_network": 245}) + record = next(e for e in events if e["event"] == "record") + # What the run placed, and its choice, the whole list: written + # right before the summary. + self.assertEqual({key: record[key] for key in ("folder", "status", "choice", "files")}, + {"folder": str(self.bios), "status": "written", + "choice": ["--all-files"], "files": 3}) + self.assertEqual(record["path"], str(self.home / install.BACKUP_DIR + / f"{self.bios.name}.install.json")) self.assertEqual([e for e in events if e["event"] not in ("progress", "transfer")], self._head("install", installer) + [ {"event": "download", "platform": "retroarch", "files": 3, "bytes": 245}, *done, + record, {"event": "summary", "mode": "install", "installed": 3, "up_to_date": 1, "pending": 0, "failed": [], "skipped": self.SKIPPED, "left_out": [], "kept": [{"folder": kept[0]["folder"], "files": 1, "bytes": 11}], @@ -7565,7 +7574,11 @@ class KeptFilesEndToEnd(unittest.TestCase, _SyntheticTree): "--no-input", "--jobs", "1", "--verbose") self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) self.assertEqual((self.bios / "b.bin").read_bytes(), self.FILES["bios/A/b.bin"]) - runs = list((self.home / install.BACKUP_DIR).iterdir()) + # The install record sits beside the run folders, a file of its own. + held = list((self.home / install.BACKUP_DIR).iterdir()) + runs = [path for path in held if path.is_dir()] + self.assertEqual(sorted(path.name for path in held if not path.is_dir()), + [f"{self.bios.name}.install.json"]) self.assertEqual(len(runs), 1) self.assertIn("Kept 1 file already there (11 B), which the collection's copies " f"replaced, in {runs[0]}", proc.stdout) @@ -7594,7 +7607,8 @@ class KeptFilesEndToEnd(unittest.TestCase, _SyntheticTree): again = _install_run(self.env, "--platform", "retroarch", "--dest", str(self.bios), "--no-input", "--jobs", "1") self.assertEqual(again.returncode, 0, again.stderr) - self.assertEqual(len(list((self.home / install.BACKUP_DIR).iterdir())), 2) + self.assertEqual(len([path for path in (self.home / install.BACKUP_DIR).iterdir() + if path.is_dir()]), 2) diff --git a/tests/test_install_journeys.py b/tests/test_install_journeys.py index ee3595afa..5d95df491 100644 --- a/tests/test_install_journeys.py +++ b/tests/test_install_journeys.py @@ -25,6 +25,7 @@ import subprocess import sys import tempfile import threading +import time import unittest from pathlib import Path @@ -41,15 +42,17 @@ ESC = "\x1b" # (keys to start the install or reach the answer, keys in all). One system # is reached through None and the filter, the other device's folder typed -# as ~/bios. +# as ~/bios. With one setup found the screen opens on its plan: one key +# fewer, and one more for another device, whose Esc goes back to the +# setups. The second run of one system takes its install record's choice. JOURNEY_KEYS = { - "first_install": (2, 3), - "card_with_games": (2, 3), - "update": (2, 3), - "one_system": (14, 15), - "one_system_again": (2, 3), - "what_is_missing": (5, 6), - "another_device": (19, 20), + "first_install": (1, 2), + "card_with_games": (1, 2), + "update": (1, 2), + "one_system": (13, 14), + "one_system_again": (1, 2), + "what_is_missing": (4, 5), + "another_device": (20, 21), } # Words each journey's screens show by default, summed over the screens it @@ -58,13 +61,15 @@ JOURNEY_KEYS = { # word: its length is the machine's. A change that adds words raises a # number here and says why. JOURNEY_WORDS = { - "first_install": 63, - "card_with_games": 106, - "update": 79, - "one_system": 193, - "one_system_again": 68, - "what_is_missing": 223, - "another_device": 230, + "first_install": 47, + "card_with_games": 87, + "update": 70, + "one_system": 179, + "one_system_again": 67, + "what_is_missing": 207, + # The plan of the setup found comes first now (33 words), the setups + # one Esc away, and the last plan names Change where (2 words). + "another_device": 265, } # Which setup? names every setup, Preview every file: lists, not prose. SCREEN_CEILING = 90 @@ -75,7 +80,9 @@ LINE_TEXT = { "card_with_games": (4, 29), "update": (5, 36), "one_system": (4, 18), - "what_is_missing": (3, 15), + # After one system installed, the check counts the install record's + # choice: one line says so, and how to count every file. + "what_is_missing": (4, 25), "another_device": (4, 18), } @@ -221,10 +228,9 @@ class Journeys(unittest.TestCase): repo = Repository(self) dest = repo.retroarch_here() term = self.start(repo) - screen = term.wait("Where to install?", timeout=40) - self.assertIn("> RetroArch", screen) - self.press(term, "\r") - term.wait("files to download", timeout=40) + # The one setup found: its plan first. + screen = term.wait("files to download", timeout=40) + self.assertIn("Change where", screen) self.press(term, "\r") started = self.keys self.assertEqual(self.finish(term), 0) @@ -241,8 +247,6 @@ class Journeys(unittest.TestCase): (card / "ROMS" / "GBA").mkdir(parents=True) (card / "ROMS" / "GBA" / "unknown.gba").write_bytes(b"not in any catalog") term = self.start(repo, env={"JOURNEY_CARD": str(card)}) - term.wait("Where to install?", timeout=40) - self.press(term, "\r") screen = term.wait("For the games on this card: yes", timeout=60) self.assertIn("For the 1 game on this card.", screen) self.press(term, "\r") @@ -265,8 +269,6 @@ class Journeys(unittest.TestCase): changed["size"] += 1 repo.write_list() term = self.start(repo) - term.wait("Where to install?", timeout=40) - self.press(term, "\r") term.wait("1 file to download", timeout=40) self.press(term, "\r") started = self.keys @@ -278,8 +280,6 @@ class Journeys(unittest.TestCase): repo = Repository(self) dest = repo.retroarch_here() term = self.start(repo) - term.wait("Where to install?", timeout=40) - self.press(term, "\r") term.wait("files to download", timeout=40) for _step in range(12): if "> Choose systems" in term.settle(): @@ -298,24 +298,25 @@ class Journeys(unittest.TestCase): self.assertEqual(self.finish(term), 0) self.counted("one_system", started, self.keys) self.assertEqual(written(dest), ["gba_bios.bin"]) - # The next release: the earlier choice is not kept, and two keys - # install the whole list. + # The next release changes its file: the plan opens on the same + # choice, read from the install record, and one key installs it. + changed = next(e for e in repo.listed["files"] if e["dest"] == "gba_bios.bin") + changed["size"] += 1 + repo.write_list() term = self.start(repo) - term.wait("Where to install?", timeout=40) - self.press(term, "\r") - term.wait("files to download", timeout=40) + screen = term.wait("1 file to download", timeout=40) + self.assertRegex(screen, r"Same choice as on \d+ [A-Z][a-z]{2}: yes") self.press(term, "\r") started = self.keys self.assertEqual(self.finish(term), 0) self.counted("one_system_again", started, self.keys) - self.assertEqual(written(dest), sorted(e["dest"] for e in repo.listed["files"])) + self.assertEqual(written(dest), ["gba_bios.bin"]) + self.assertEqual((dest / "gba_bios.bin").stat().st_size, changed["size"]) def test_what_is_missing(self): repo = Repository(self) repo.retroarch_here() term = self.start(repo) - term.wait("Where to install?", timeout=40) - self.press(term, "\r") term.wait("files to download", timeout=40) self.press(term, "o") term.wait("Each row is an option") @@ -332,6 +333,9 @@ class Journeys(unittest.TestCase): repo = Repository(self) repo.retroarch_here() term = self.start(repo) + # The plan of the one setup found first: Esc goes back to the setups. + term.wait("files to download", timeout=40) + self.press(term, ESC) term.wait("Where to install?", timeout=40) for _step in range(12): if "> Another setup or folder" in term.settle(): @@ -478,5 +482,282 @@ class LineModeJourneys(unittest.TestCase): self.run_line(repo, *base, "--check", "--verbose").stdout) +def events(proc: subprocess.CompletedProcess, kind: str | None = None) -> list[dict]: + said = [json.loads(line) for line in proc.stdout.splitlines() if line.startswith("{")] + return [event for event in said if kind is None or event["event"] == kind] + + +class InstallRecord(unittest.TestCase): + """What an install placed, recorded beside the BIOS folder, and its + choice taken by the next run when nothing on the command line chose.""" + + def setUp(self): + self.repo = Repository(self) + self.dest = self.repo.root / "retroarch" / "system" + self.record = self.dest.parent / install.BACKUP_DIR / "system.install.json" + + def run_line(self, *args: str, dest: Path | None = None) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, str(REPO_ROOT / "install.py"), "--platform", "retroarch", + "--dest", str(dest or self.dest), *args], + env=self.repo.env, capture_output=True, text=True, timeout=120, check=False, + stdin=subprocess.DEVNULL) + + def dests(self, proc: subprocess.CompletedProcess) -> list[str]: + return sorted(event["dest"] for event in events(proc, "file")) + + def everything(self) -> list[str]: + return sorted(entry["dest"] for entry in self.repo.listed["files"]) + + def test_the_record_holds_each_file_written_and_its_hash(self): + proc = self.run_line("--system", "nintendo-gba", "--yes", "--events") + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + said = events(proc, "record") + self.assertEqual([event["status"] for event in said], ["written"]) + self.assertEqual(said[0]["choice"], ["--system", "nintendo-gba"]) + self.assertEqual((said[0]["path"], said[0]["files"]), (str(self.record), 1)) + # Before the summary, its keys those of EVENT_KEYS. + kinds = [event["event"] for event in events(proc)] + self.assertEqual(kinds.index("record") + 1, kinds.index("summary")) + self.assertEqual(list(said[0])[1:], list(install.EVENT_KEYS["record"])) + kept = json.loads(self.record.read_text(encoding="utf-8")) + files = kept["setups"]["retroarch"]["files"] + self.assertEqual([item["dest"] for item in files], ["gba_bios.bin"]) + self.assertEqual(files[0]["sha1"], hashlib.sha1( + (self.dest / "gba_bios.bin").read_bytes()).hexdigest()) + self.assertEqual((files[0]["from"], files[0]["root"]), ("download", "")) + self.assertEqual(kept["folder"], str(self.dest)) + # Nothing of it in the BIOS folder, which some cores read whole. + self.assertEqual(written(self.dest), ["gba_bios.bin"]) + + def test_the_next_run_takes_the_recorded_choice(self): + self.run_line("--system", "nintendo-gba", "--yes") + (self.dest / "gba_bios.bin").unlink() + proc = self.run_line("--yes", "--events") + self.assertEqual(proc.returncode, 0, proc.stderr) + kinds = [event["event"] for event in events(proc)] + self.assertLess(kinds.index("record"), kinds.index("choices")) + applied = events(proc, "record")[0] + self.assertEqual((applied["status"], applied["choice"]), + ("applied", ["--system", "nintendo-gba"])) + self.assertEqual(self.dests(proc), ["gba_bios.bin"]) + self.assertEqual(written(self.dest), ["gba_bios.bin"]) + (self.dest / "gba_bios.bin").unlink() + proc = self.run_line("--yes") + self.assertRegex(proc.stdout, r"Installing 1 file \(.*\) for RetroArch, same choice " + r"as on \d+ [A-Z][a-z]{2}\.") + + def test_the_command_line_replaces_the_record(self): + self.run_line("--system", "nintendo-gba", "--yes") + self.assertEqual(self.dests(self.run_line("--system", "sega-megadrive", "--check", + "--events")), + sorted(e["dest"] for e in self.repo.listed["files"] + if "sega-megadrive" in e.get("systems", []))) + whole = self.run_line("--all-files", "--check", "--events") + self.assertEqual(self.dests(whole), self.everything()) + self.assertEqual(events(whole, "record"), []) + + def test_a_record_that_cannot_be_read_is_said_once_and_ignored(self): + self.dest.mkdir(parents=True) + self.record.parent.mkdir() + self.record.write_text("{not json", encoding="utf-8") + proc = self.run_line("--yes", "--events") + self.assertEqual(proc.returncode, 0, proc.stderr) + said = events(proc, "record") + self.assertEqual([event["status"] for event in said], ["unreadable", "written"]) + self.assertIn("is damaged, so it is ignored.", said[0]["message"]) + # Said once to a person (standard error under --events), once as an event. + self.assertEqual(proc.stderr.count("so it is ignored"), 1) + self.assertEqual(self.dests(proc), self.everything()) + # Replaced by a record that reads. + self.assertEqual(json.loads(self.record.read_text())["schema"], install.RECORD_SCHEMA) + for text in ('{"schema": 99, "setups": {}}', '{"schema": 1, "setups": {"retroarch": 3}}', + "\xff\xfe"): + with self.subTest(record=text): + self.record.write_bytes(text.encode("latin-1")) + proc = self.run_line("--check", "--events") + self.assertEqual(events(proc, "record")[0]["status"], "unreadable") + self.assertEqual(self.dests(proc), self.everything()) + + def test_a_record_for_another_folder_is_not_applied(self): + self.run_line("--system", "nintendo-gba", "--yes") + # A sibling folder shares the holder, never the record. + other = self.dest.parent / "bios" + proc = self.run_line("--check", "--events", dest=other) + self.assertEqual(events(proc, "record"), []) + self.assertEqual(self.dests(proc), self.everything()) + # The record copied under the sibling's name still names the first. + shutil.copy(self.record, self.record.with_name("bios.install.json")) + proc = self.run_line("--check", "--events", dest=other) + self.assertEqual(events(proc, "record"), []) + self.assertEqual(self.dests(proc), self.everything()) + # The record of another setup in the same folder is not this one's. + proc = subprocess.run( + [sys.executable, str(REPO_ROOT / "install.py"), "--platform", "batocera", + "--dest", str(self.dest), "--check", "--events"], + env=self.repo.env, capture_output=True, text=True, timeout=120, check=False, + stdin=subprocess.DEVNULL) + self.assertEqual(events(proc, "record"), []) + self.assertEqual(self.dests(proc), self.everything()) + + def test_line_mode_chooses_what_the_screen_chooses(self): + """The plan's choice from the record is the same set of files on the + screen and in line mode: the screen's options, given to a run, and a + run given none.""" + self.run_line("--system", "nintendo-gba", "--yes") + (self.dest / "gba_bios.bin").unlink() + state = install.TuiState() + state.platform, state.dest = "retroarch", self.dest + install._tui_record_load(state) + self.assertTrue(install.tui_record_kept(state)) + flags = [flag if value is None else f"{flag}={value}" + for flag, value in install.tui_flags(state)] + screen = self.run_line(*flags, "--dry-run", "--events") + line = self.run_line("--dry-run", "--events") + self.assertEqual(self.dests(screen), ["gba_bios.bin"]) + self.assertEqual(self.dests(line), self.dests(screen)) + self.assertEqual(events(line, "check")[0]["bytes_needed"], + events(screen, "check")[0]["bytes_needed"]) + + +def record_text(folder: Path, choice: list[str], **setups: object) -> str: + """A record of folder whose retroarch setup chose choice.""" + setup = {"written": "2026-11-03T12:00:00Z", "list": {}, "choice": choice, + "files": [{"dest": "gba_bios.bin", "root": "", "sha1": "0" * 40, "size": 1, + "from": "download"}]} + return json.dumps({"schema": install.RECORD_SCHEMA, "installer": "x", "folder": str(folder), + "setups": {"retroarch": setup, **setups}}) + + +class RecordReview(unittest.TestCase): + """A record is data another program or a person may have written: what + it holds is checked before any of it is used or printed.""" + + setUp = InstallRecord.setUp + run_line = InstallRecord.run_line + dests = InstallRecord.dests + everything = InstallRecord.everything + + def write(self, choice: list[str], **setups: object) -> None: + self.dest.mkdir(parents=True, exist_ok=True) + self.record.parent.mkdir(exist_ok=True) + self.record.write_text(record_text(self.dest, choice, **setups), encoding="utf-8") + + def test_a_word_outside_the_choice_rejects_the_whole_record(self): + for choice in (["--system", "nintendo-gba", "--from", "/mnt/x"], + ["--manifest", "http://example.invalid/list.json"], + ["--system", "nintendo-gba", "--dest", "/etc"], + ["--system", "--yes"], ["--system", "a\nb"], ["nintendo-gba"]): + with self.subTest(choice=choice): + self.write(choice) + proc = self.run_line("--check", "--events") + said = events(proc, "record") + self.assertEqual([event["status"] for event in said], ["unreadable"], said) + self.assertEqual(self.dests(proc), self.everything()) + text = proc.stdout + proc.stderr + for word in ("/mnt/x", "example.invalid", "/etc"): + self.assertNotIn(word, text) + # The screen reads it through the same function. + state = install.TuiState() + state.platform, state.dest = "retroarch", self.dest + install._tui_record_load(state) + self.assertIsNone(state.record) + self.assertEqual(install.tui_flags(state), []) + + def test_a_recorded_games_folder_stays_beside_the_bios_folder(self): + parent = self.dest.parent + self.assertTrue(install.games_folder_kept(str(parent / "roms"), self.dest)) + self.assertTrue(install.games_folder_kept(str(parent), self.dest)) + for value in ("/etc", str(parent.parent / "other"), "roms", "\\\\server\\share", + "//server/share", ""): + with self.subTest(value=value): + self.assertFalse(install.games_folder_kept(value, self.dest)) + self.write(["--for-games", "/etc"]) + proc = self.run_line("--check", "--events") + self.assertEqual(events(proc, "record")[0]["status"], "unreadable") + self.assertEqual(self.dests(proc), self.everything()) + + def test_a_recorded_option_that_cannot_apply_is_left_out(self): + """A run nobody typed options for went on failing: the list has no + cores, no categories and no board list, and a region is unknown.""" + for choice, typed in ((["--system", "nintendo-gba", "--region", "atlantis"], + ["--region", "atlantis"]), + (["--system", "nintendo-gba", "--emulator", "nosuch"], + ["--emulator", "nosuch"]), + (["--system", "nintendo-gba", "--installed-cores"], + ["--installed-cores"]), + (["--system", "nintendo-gba", "--target", "nosuch"], + ["--target", "nosuch"]), + (["--system", "nintendo-gba", "--no-game-data"], + ["--no-game-data"])): + with self.subTest(choice=choice): + self.write(choice) + proc = self.run_line("--yes", "--events") + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + self.assertEqual(proc.stderr.count("Left out of the recorded choice"), 1, + proc.stderr) + self.assertEqual(self.dests(proc), ["gba_bios.bin"]) + # Written again without it: the next run says nothing. + kept = json.loads(self.record.read_text())["setups"]["retroarch"]["choice"] + self.assertEqual(kept, ["--system", "nintendo-gba"]) + # Typed, it is refused as before. + self.assertNotEqual(self.run_line(*typed, "--check").returncode, 0) + + def test_a_date_no_clock_can_show_is_an_unknown_day(self): + if not hasattr(time, "tzset"): + self.skipTest("no tzset") + zone = os.environ.get("TZ") + + def back() -> None: + if zone is None: + os.environ.pop("TZ", None) + else: + os.environ["TZ"] = zone + time.tzset() + + self.addCleanup(back) + for tz, written in (("UTC+5", "0001-01-01T00:00:00Z"), + ("UTC-5", "9999-12-31T23:59:59Z")): + with self.subTest(written=written): + os.environ["TZ"] = tz + time.tzset() + self.assertEqual(install.record_day({"written": written}), "") + + def test_a_record_that_is_no_file_is_never_read(self): + self.dest.mkdir(parents=True) + self.record.parent.mkdir() + os.mkfifo(self.record) + proc = self.run_line("--check", "--events") + self.assertIn("it is not a file", events(proc, "record")[0]["message"]) + self.assertEqual(self.dests(proc), self.everything()) + self.record.unlink() + with open(self.record, "wb") as handle: + handle.truncate(install.MAX_RECORD_BYTES + 1) + self.assertIn("larger than any record", install.load_record(self.dest).problem) + + def test_a_newer_record_is_left_as_it_is(self): + self.dest.mkdir(parents=True) + self.record.parent.mkdir() + newer = json.dumps({"schema": install.RECORD_SCHEMA + 1, "setups": {"x": {}}}) + self.record.write_text(newer, encoding="utf-8") + proc = self.run_line("--yes", "--events") + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertEqual([event["status"] for event in events(proc, "record")], + ["unreadable", "not_written"]) + self.assertEqual(self.record.read_text(encoding="utf-8"), newer) + self.assertEqual(proc.stderr.count("written by a newer installer"), 1) + + def test_a_bad_setup_is_dropped_and_the_others_kept(self): + good = {"written": "2026-11-03T12:00:00Z", "list": {}, "choice": ["--all-files"], + "files": []} + self.write(["--system", "nintendo-gba"], lakka=good, batocera={"choice": 3}) + path, _setup, problem = install.write_record(self.dest, "retropie", {}, ["--all-files"], + []) + self.assertEqual((path, problem), (self.record, "")) + kept = json.loads(self.record.read_text())["setups"] + self.assertEqual(sorted(kept), ["lakka", "retroarch", "retropie"]) + self.assertEqual(kept["retroarch"]["choice"], ["--system", "nintendo-gba"]) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index eae7a1ed3..e8e69cf95 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -378,6 +378,14 @@ def screen_text(lines) -> str: return re.sub(r"(?<=[/\\]) ", "", " ".join(" ".join(lines).split())) +def listed(state: install.TuiState) -> install.TuiState: + """The setups found, as Back shows them from the plan the screen opens + on when it found one setup.""" + if state.opened_on_plan and state.screen != "setup": + install.tui_update(state, Key("esc")) + return state + + def fresh_state() -> install.TuiState: state = install.TuiState(os_type="linux", home=HOME) state.setups = [("retroarch", SYSTEM_DIR, "RetroArch"), @@ -761,7 +769,9 @@ class Flow(unittest.TestCase): mock.patch.object(install, "detect_platforms", return_value=[]), \ mock.patch.object(install, "detect_frontends", return_value=[]): state, effects = install.tui_start(_args(), "linux") - self.assertEqual((state.screen, effects), ("setup", [])) + # The one setup found: its plan first, the list one Back away. + self.assertEqual((state.screen, effects), ("checking", [("fetch", "minui")])) + self.assertEqual(listed(state).screen, "setup") label = f"MinUI SD card ({card.name})" self.assertEqual(state.setups, [("minui", card, label)]) self.assertEqual([row.label for row in install.tui_rows(state)], @@ -1032,9 +1042,55 @@ def golden_states() -> dict: told = golden_states_result() told.stack.append("details") states["result-details"] = told + states["plan-record"] = record_state() return states +def record_setup(choice: list[str] | None = None) -> dict: + """A record kept on 3 November of this year: the plan says 3 Nov in + any year it runs.""" + year = time.localtime().tm_year + return {"written": f"{year}-11-03T12:00:00Z", "list": {"version": "v1", "generated": ""}, + "choice": ["--system", "nintendo-gba"] if choice is None else choice, + "files": [{"dest": "gba_bios.bin", "root": "", "sha1": "0" * 40, "size": 16384, + "from": "download"}]} + + +def record_state(setup: dict | None = None) -> install.TuiState: + """The plan of a RetroArch folder whose record kept Game Boy Advance, + its file changed by a release since, opened on the one setup found.""" + setup = record_setup() if setup is None else setup + state = fresh_state() + state.setups = state.setups[:1] + manifest = json.loads(json.dumps(MANIFEST)) + manifest["files"][2]["sha1"] = "1" * 40 + with mock.patch.object(install, "setup_record", + return_value=(setup, SYSTEM_DIR.parent / "x", "")), \ + mock.patch.object(install.Path, "exists", return_value=True), \ + mock.patch.object(install, "found_setups", + return_value=(state.setups, set())), \ + mock.patch.object(install, "detect_frontends", return_value=[]), \ + mock.patch.object(install, "_mounted_cards", return_value=[]), \ + mock.patch.object(install, "_DETECTION_REFUSED", []), \ + mock.patch.object(install.Path, "home", return_value=HOME): + state, effects = install.tui_start(_args(), "linux") + assert effects == [("fetch", "retroarch")], effects + # Drawn as the other screens are, whatever the shell running the tests. + state.home, state.pad = HOME, "both" + effects = install.tui_receive(state, ("manifest", "retroarch", manifest)) + assert effects == [("run", "check")], effects + gba = [event for event in FILES if event["dest"] == "gba_bios.bin"] + systems = [event for event in SYSTEMS if event["system"] == "nintendo-gba"] + check = {**CHECK, "files": 1, "present": 0, "missing": 1, "wrong_hash": 0, + "bytes_needed": 16384, "not_installable": 0} + for event in [*gba, *systems, check]: + install.tui_receive(state, ("event", state.run_id, event)) + with mock.patch.object(install, "_tui_folder_facts", return_value={ + "free": 50 * 1024 ** 3, "writable": True, "cores": ["pcsx_rearmed"]}): + install.tui_receive(state, ("ended", state.run_id, 1, [])) + return state + + def golden_states_result() -> install.TuiState: """The result golden_states draws, a file failed and one kept.""" result = checked_state() @@ -1075,6 +1131,132 @@ def result_states() -> dict: } +class RecordedPlan(unittest.TestCase): + """The plan of a folder with an install record: opened on the one setup + found, on the recorded choice, one press from installing it again.""" + + def row(self, state: install.TuiState, action: str) -> install.TuiRow: + return next(row for row in install.tui_rows(state) if row.action == action) + + def test_opens_on_the_plan_of_the_one_setup_found(self): + state = record_state() + self.assertTrue(state.opened_on_plan) + self.assertEqual(state.stack, ["setup", "plan"]) + self.assertEqual(install.tui_rows(state)[0].action, "install") + press(state, "end", "up", "up", "up") + self.assertEqual(install.tui_rows(state)[state.focus["plan"]].label, "Change where") + press(state, "enter") + self.assertEqual(state.screen, "setup") + + def test_the_notes_of_the_setups_screen_are_on_the_plan(self): + """Opened on the plan, the ES-DE note and the setup's label, which + only the setups screen showed, were never seen.""" + setups = fresh_state().setups[:1] + with mock.patch.object(install, "found_setups", return_value=(setups, set())), \ + mock.patch.object(install, "detect_frontends", return_value=["esde"]), \ + mock.patch.object(install, "_mounted_cards", return_value=[]), \ + mock.patch.object(install, "_DETECTION_REFUSED", []), \ + mock.patch.object(install.Path, "exists", return_value=True): + state, _effects = install.tui_start(_args(), "linux") + self.assertTrue(state.opened_on_plan) + install.tui_receive(state, ("manifest", "retroarch", MANIFEST)) + for event in [*FILES, *SYSTEMS, CHECK]: + install.tui_receive(state, ("event", state.run_id, event)) + with mock.patch.object(install, "_tui_folder_facts", return_value={ + "free": 50 * 1024 ** 3, "writable": True}): + install.tui_receive(state, ("ended", state.run_id, 1, [])) + state.pad = "both" + plan = screen_text(install.tui_view(state, 80, 24)) + self.assertIn("1 note on this machine: Details.", plan) + self.assertNotIn(install.TUI_ESDE_NOTE, plan) + # The plan's budget, plus the note line (6 words) and Change where (2). + self.assertLessEqual(screen_words(install.tui_view(state, 80, 24)), + SCREEN_WORDS["plan"][2] + 8) + state.focus["plan"] = [row.action for row in install.tui_rows(state)].index("details") + press(state, "enter") + details = screen_text(install.tui_view(state, 120, 40)) + self.assertIn(screen_text([install.TUI_ESDE_NOTE]), details) + self.assertIn("Found here: RetroArch.", details) + + def test_several_setups_open_the_list(self): + with mock.patch.object(install, "found_setups", return_value=( + fresh_state().setups, set())), \ + mock.patch.object(install, "detect_frontends", return_value=[]), \ + mock.patch.object(install, "_mounted_cards", return_value=[]), \ + mock.patch.object(install, "_DETECTION_REFUSED", []): + state, effects = install.tui_start(_args(), "linux") + self.assertEqual((state.screen, effects, state.opened_on_plan), ("setup", [], False)) + + def test_one_press_installs_the_recorded_choice(self): + state = record_state() + self.assertEqual(self.row(state, "record").label, "Same choice as on 3 Nov: yes") + self.assertIn("For nintendo-gba.", screen_text( + install.tui_view(state, 80, 24))) + self.assertEqual(press(state, "enter"), [("run", "install")]) + argv = install.tui_argv(state, "install", "py", "install.py") + self.assertIn("--system=nintendo-gba", argv) + self.assertNotIn("--all-files", argv) + + def test_no_takes_the_whole_list_and_yes_brings_it_back(self): + state = record_state() + state.focus["plan"] = install.tui_rows(state).index(self.row(state, "record")) + with mock.patch.object(install, "games_probe", return_value=None): + effects = press(state, "enter") + self.assertEqual(effects, [("run", "check")]) + flags = install.tui_flags(state) + self.assertIn(("--all-files", None), flags) + self.assertFalse([flag for flag in flags if flag[0] == "--system"]) + self.assertFalse(install.tui_record_kept(state)) + for event in [*FILES, *SYSTEMS, CHECK]: + install.tui_receive(state, ("event", state.run_id, event)) + with mock.patch.object(install, "_tui_folder_facts", return_value={ + "free": 50 * 1024 ** 3, "writable": True}): + install.tui_receive(state, ("ended", state.run_id, 1, [])) + self.assertEqual(self.row(state, "record").label, "Same choice as on 3 Nov: no") + self.assertEqual(install.tui_rows(state)[state.focus["plan"]].action, "record") + self.assertEqual(press(state, "enter"), [("run", "check")]) + self.assertIn(("--system", "nintendo-gba"), install.tui_flags(state)) + self.assertTrue(install.tui_record_kept(state)) + + def test_the_command_line_choice_wins(self): + with mock.patch.object(install, "setup_record", + return_value=(record_setup(), SYSTEM_DIR, "")), \ + mock.patch.object(install.Path, "exists", return_value=True): + state, _effects = install.tui_start( + _args(platform="retroarch", dest=str(SYSTEM_DIR), system=["sony-playstation"]), + "linux", detect=False) + self.assertEqual(install._tui_system_flags(state), [("--system", "sony-playstation")]) + self.assertFalse(install.tui_record_kept(state)) + self.assertEqual(install._record_rows(state), []) + + def test_a_record_that_cannot_be_read_is_said_once(self): + problem = "The install record /x/system.install.json is damaged" + install._RECORD_SAID.discard("/x/system.install.json") + with mock.patch.object(install, "setup_record", + return_value=(None, Path("/x/system.install.json"), problem)), \ + mock.patch.object(install.Path, "exists", return_value=True): + state, _effects = install.tui_start( + _args(platform="retroarch", dest=str(SYSTEM_DIR)), "linux", detect=False) + self.assertEqual(str(state.status), f"{problem}, so it is ignored.") + state.status, state.record_for = "", None + install._tui_record_load(state) + self.assertEqual(state.status, "") + self.assertIsNone(state.record) + self.assertNotIn("--all-files", str(install.tui_flags(state))) + + def test_the_install_row_says_the_size_the_headline_says(self): + """14930 files to download, 6.8 GB stood above Install 6.6 GB: the + data files counted in one, the copies in the other.""" + big = 2 * 1024 ** 3 + state = checked_state() + state.files[0] = {**state.files[0], "size": big} + state.check = {**state.check, "bytes_needed": state.check["bytes_needed"] + big, + "data_missing": 3, "data_bytes": 300 * 1024 ** 2} + headline = str(install._plan_lines(state.check, state.dest, state)[0]) + size = headline.rsplit(", ", 1)[1] + self.assertEqual(install.tui_rows(state)[0].label, f"Install {size}") + + class Views(unittest.TestCase): """Every screen as text, at the sizes a handheld and a desktop show.""" @@ -2387,6 +2569,9 @@ class CardsSpaceAndConfirm(unittest.TestCase): mock.patch.object(install, "detect_platforms", return_value=[]), \ mock.patch.object(install, "detect_frontends", return_value=[]): state, effects = install.tui_start(_args(), "linux") + self.assertEqual(effects, [("fetch", "retroarch")]) + self.assertEqual(state.dest, root / "MUOS" / "bios") + listed(state) label = f"muOS SD card ({root.name})" self.assertEqual(state.setups, [("retroarch", root / "MUOS" / "bios", label)]) text = screen_text(install.tui_view(state, 120, 40)) @@ -4527,6 +4712,14 @@ class WindowsHasNoPosixModules(unittest.TestCase): # --- End to end, on a pseudo-terminal ------------------------------------- +def on_setups(term: Pty) -> str: + """The setups found, one Back from the plan the screen opens on when it + found one setup: the first screen as it was before.""" + term.wait("> Install") + term.send("\x1b") + return term.wait("Where to install?") + + class Pty: """install.py on a pseudo-terminal, its screen read back through pyte.""" @@ -4925,14 +5118,13 @@ class EndToEnd(unittest.TestCase): self.assertAppOnCard(root, "KNULLI") self.assertIn("--add-app", term.after()) - def test_three_presses_install_everything(self): + def test_two_presses_install_everything(self): repo = self.repo() term = self.start() - screen = term.wait("Where to install?") - self.assertIn("> RetroArch", screen) - term.send("\r") + # The one setup found here: its plan first. screen = term.wait("files to download") self.assertIn("5 files to download, 30 B", screen) + self.assertIn("Change where", screen) self.assertIn("> Install", screen) term.send("\r") term.wait("5 files installed.") @@ -4979,8 +5171,6 @@ class EndToEnd(unittest.TestCase): self.assertIn("6 files installed", proc.stdout) else: term = self.start() - term.wait("Where to install?") - term.send("\r") screen = term.wait("files to download") self.assertIn("6 files to download", screen) # The second folder the run writes to, named on the @@ -5159,7 +5349,7 @@ class EndToEnd(unittest.TestCase): def test_ctrl_c_on_the_first_screen(self): self.repo() term = self.start() - term.wait("Where to install?") + term.wait("> Install") term.send("\x03") self.assertEqual(term.close(), 130) self.assertIn(b"\x1b[?2004l\x1b[0m\x1b[?25h\x1b[?1049l", term.raw) @@ -5169,7 +5359,7 @@ class EndToEnd(unittest.TestCase): self.repo() term = self.start() - term.wait("Where to install?") + term.wait("> Install") mark = len(term.raw) os.kill(term.pid, signal.SIGTSTP) end = time.monotonic() + 10 @@ -5182,7 +5372,7 @@ class EndToEnd(unittest.TestCase): self.assertIn(b"\x1b[?25h\x1b[?1049l", term.raw[mark:]) mark = len(term.raw) os.kill(term.pid, signal.SIGCONT) - term.wait("A/Enter Continue") + term.wait("A/Enter Install") end = time.monotonic() + 5 while b"\x1b[?1049h" not in term.raw[mark:] and time.monotonic() < end: term._pump(0.05) @@ -5194,11 +5384,11 @@ class EndToEnd(unittest.TestCase): """The terminal gone, the farewell has nowhere to go: the code is 129 (SIGHUP), not 120 from the flush failing at exit.""" self.repo() - for screen_name, keys_first in (("Where to install?", ()), - ("files to download", ("\r",))): + for screen_name, keys_first in (("files to download", ()), + ("Where to install?", ("\x1b",))): with self.subTest(screen=screen_name): term = self.start() - term.wait("Where to install?") + term.wait("files to download") term.send(*keys_first) term.wait(screen_name) os.close(term.fd) @@ -5247,12 +5437,12 @@ class EndToEnd(unittest.TestCase): self.repo() self.env[install.TUI_IDLE_ENV] = "1" term = self.start() - term.wait("Where to install?") + term.wait("> Install") self.assertEqual(term.close(), install.QUIT_CODE) self.assertIn("No button pressed, closing.", term.after()) # A key pressed, the screen stays: the person is there. term = self.start() - term.wait("Where to install?") + term.wait("> Install") term.send(DOWN) end = time.monotonic() + 2.5 while time.monotonic() < end: @@ -5282,7 +5472,7 @@ class EndToEnd(unittest.TestCase): with self.subTest(env=env): self.env.update(env) term = self.start(cols=40, rows=12) - term.wait("Where to install?") + on_setups(term) screen = term.wait(bar) self.assertEqual(screen.splitlines()[-1].strip(), bar) term.send("q") @@ -5293,7 +5483,7 @@ class EndToEnd(unittest.TestCase): def test_resize_redraws(self): self.repo() term = self.start(cols=40, rows=15) - term.wait("Where to install?") + on_setups(term) term.resize(100, 30) term.send(DOWN) screen = term.wait("> Another setup or folder") @@ -5379,7 +5569,7 @@ class EndToEnd(unittest.TestCase): self.env.pop("NO_COLOR", None) self.env.update({"TERM": "xterm", **env}) term = self.start() - screen = term.wait("Where to install?") + screen = on_setups(term) self.assertIn("> RetroArch", screen) screens[name] = screen # pyte reads the colours drawn: RetroBIOS in the accent. @@ -5612,7 +5802,7 @@ class TheTerminalComesBack(unittest.TestCase): def ended(self, how) -> Pty: self.repo() term = self.start() - term.wait("Where to install?") + term.wait("> Install") # Every way out below starts with the clicks asked for, written # right after the frame. term.settle() @@ -5672,7 +5862,7 @@ class TheTerminalComesBack(unittest.TestCase): job control it stops, and fg brings the screen back.""" term = self.shell() self.typed(term, f"{shlex.quote(sys.executable)} {REPO_ROOT / 'install.py'} --tui") - term.wait("Where to install?") + term.wait("> Install") mark = len(term.raw) term.send("\x1a") end = time.monotonic() + 10 @@ -5687,7 +5877,7 @@ class TheTerminalComesBack(unittest.TestCase): while b"\x1b[?1049h" not in term.raw[mark:] and time.monotonic() < end: term._pump(0.05) self.assertIn(b"\x1b[?1049h", term.raw[mark:]) - term.wait("Where to install?") + term.wait("> Install") self.assertFalse(termios.tcgetattr(term.fd)[3] & termios.ECHO) # The clicks come back with the screen. term.settle() @@ -5709,7 +5899,7 @@ class TheTerminalComesBack(unittest.TestCase): term = Pty(["--tui"], self.env, program=program) self.addCleanup(lambda term=term: term.status is None and os.killpg(term.pid, 9)) - term.wait("Where to install?") + term.wait("> Install") term.send("\x1a") screen = term.wait("Ctrl-Z is ignored") self.assertIn("no shell started this installer", " ".join(screen.split())) @@ -5765,8 +5955,6 @@ class TheTerminalComesBack(unittest.TestCase): self.addCleanup(lambda: term.status is None and os.killpg(term.pid, 9)) term.wait("PROMPT$") self.typed(term, f"sh {REPO_ROOT / 'install.sh'} --jobs 1") - term.wait("Where to install?") - term.send("\r") term.wait("to download") term.send("\r") term.wait("Installing") @@ -10803,7 +10991,7 @@ class MouseOnAPty(unittest.TestCase): def test_taps_choose_a_row_and_press_the_bar(self): self.repo() term = self.start() - term.wait("Where to install?") + on_setups(term) term.settle() self.assertIn(install._MOUSE_ON.encode(), term.raw) row = self.line_of(term, "Another setup") @@ -11455,8 +11643,6 @@ class TapsOnAPty(unittest.TestCase): def test_taps_on_systems_tick_and_write_nothing(self): repo = self.repo() term = self.start() - term.wait("Where to install?") - term.send("\r") term.wait("> Install") row = self.line_of(term, "Choose systems") self.click(term, 20, row) @@ -11482,8 +11668,6 @@ class TapsOnAPty(unittest.TestCase): def test_the_result_is_left_by_a_tap_on_exit(self): self.repo() term = self.start() - term.wait("Where to install?") - term.send("\r") term.wait("files to download") row = self.line_of(term, "> Install") self.click(term, 20, row) @@ -12365,8 +12549,6 @@ class TapsOnAPtySeeFirst(unittest.TestCase): def test_one_tap_on_the_bar_install_installs_nothing(self): repo = self.repo() term = self.start() - term.wait("Where to install?") - term.send("\r") term.wait("> Install") term.settle() self.click(term, *self.bar_entry(term, "Install")) @@ -12380,7 +12562,7 @@ class TapsOnAPtySeeFirst(unittest.TestCase): def test_a_double_tap_on_continue_neither_quits_nor_installs(self): repo = self.repo() term = self.start() - term.wait("Where to install?") + on_setups(term) term.settle() col, line = self.bar_entry(term, "Continue") report = sgr(col + 1, line + 1) + sgr(col + 1, line + 1, release=True) @@ -12402,7 +12584,7 @@ class TapsOnAPtySeeFirst(unittest.TestCase): with self.subTest(gap=gap): repo = self.repo() term = self.start() - term.wait("Where to install?") + on_setups(term) term.settle() time.sleep(install.TUI_TAP_SETTLE) col, line = self.bar_entry(term, "Continue") @@ -12422,8 +12604,6 @@ class TapsOnAPtySeeFirst(unittest.TestCase): def test_continue_then_install_in_one_cell_stops_on_confirm(self): repo = self.repo() term = self.start() - term.wait("Where to install?") - term.send("\r") term.wait("> Install") row = self.line_of(term, "Choose systems") self.click(term, 20, row) @@ -12465,8 +12645,6 @@ class TapsOnAPtySeeFirst(unittest.TestCase): repo = self.repo(GameDataRepository) term = self.start() - term.wait("Where to install?") - term.send("\r") term.wait("> Install") for _step in range(8): if "> Choose systems" in term.settle(): @@ -12508,8 +12686,6 @@ class TapsOnAPtySeeFirst(unittest.TestCase): def test_two_taps_on_a_region_untick_it_where_it_was(self): self.repo() term = self.start() - term.wait("Where to install?") - term.send("\r") term.wait("> Install") term.send("o") term.wait("Preferred regions") @@ -12662,8 +12838,10 @@ class ChoiceStateInBothModes(unittest.TestCase): mock.patch.object(install, "detect_frontends", return_value=[]), \ mock.patch.object(install.Path, "home", return_value=home): state, _effects = install.tui_start(_args(), "linux") - text = self.screen(state) - # Named on the first screen, its plan one Enter away. + # Its plan first, the setups one Back away. + self.assertEqual(state.dest, card / "bios") + text = self.screen(listed(state)) + # Named on the setups, its plan one Enter away. self.assertIn(f"> ArkOS SD card ({name}", text) self.assertNotIn("Nothing was found here", text) install.tui_update(state, Key("enter")) @@ -12977,7 +13155,7 @@ class DriveIsACardByItsFiles(unittest.TestCase): mock.patch.object(install, "detect_frontends", return_value=[]), \ mock.patch.object(install.Path, "home", return_value=self.root / "home"): state, _effects = install.tui_start(_args(), "linux") - return state + return listed(state) def folder_screen(self, mounts: list) -> tuple[install.TuiState, str, list]: """RetroArch chosen on the first screen: (state, Which folder? at @@ -13176,8 +13354,6 @@ class TheSettleRunsFromTheFrameShown(unittest.TestCase): env = {**self.env, "SLOW": "0.5"} term = Pty([], env, 80, 24, program=[sys.executable, str(wrapper)]) self.addCleanup(lambda: term.status is None and os.kill(term.pid, 9)) - term.wait("Where to install?") - term.send("\r") term.wait("> Install") term.settle() term.send("o") @@ -14949,6 +15125,10 @@ SCREEN_WORDS = { "quit": (14, 14, 12, 12), "help": (24, 26, 26, 26), "plan-games": (36, 46, 54, 54), "games": (36, 45, 44, 44), "plan-details": (30, 30, 29, 29), "result-details": (30, 30, 19, 19), + # The plan's words, plus the recorded choice by name, its row (the one + # press back to the whole list) and Change where (back to the setups, + # the plan having opened on the one found). + "plan-record": (38, 42, 42, 42), } diff --git a/wiki/first-install.md b/wiki/first-install.md index 6b41c84a4..7093ed7ec 100644 --- a/wiki/first-install.md +++ b/wiki/first-install.md @@ -68,7 +68,8 @@ On Batocera, Recalbox and similar setups, see ## 2. Check the setup it found -It opens a menu: +With one setup found, it opens on step 3, its folder on the second line. +With none or several, it asks: ``` RetroBIOS: Where to install? @@ -80,8 +81,8 @@ It opens a menu: - **Right folder?** Press Enter. - **Wrong folder, or a handheld's SD card?** Choose - **Another setup or folder**. Press / and type the device (Miyoo Mini), - or browse. + **Another setup or folder** (**Change where** from step 3). + Press / and type the device (Miyoo Mini), or browse. Arrows move, Enter chooses, Esc goes back (A and B on a handheld). On a touch screen, tap a row twice to open it, once to tick a box. @@ -89,7 +90,7 @@ touch screen, tap a row twice to open it, once to tick a box. ## 3. Install Press Enter on **Install**. **Choose systems** picks the consoles to -keep. +keep. Run it again later: it keeps that choice, one press. **Game data** (only a game reads it) has its own row. diff --git a/wiki/installer.md b/wiki/installer.md index 7dcd94386..a5e69c33e 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -169,8 +169,14 @@ Enter checks the BIOS folder when nothing can be copied. ## The full-screen interface { #the-full-screen-interface } Run at a terminal, the installer opens a full screen that a keyboard or a -controller drives. Three presses of A or Enter install everything the setup -reads: the setup found here, the plan, then the result. The plan screen says +controller drives. With one setup found here, it opens on that setup's plan, +and two presses of A or Enter install everything it reads: the plan, then the +result. **Change where** (or B) goes back to the setups, which open first when +none or several are found. What that screen would have noted (ES-DE found, the +setup's name) is under **Details**, one plan line saying so. After an install, the plan of the same folder opens +on the same choice (**Same choice as on 10 Nov: yes**, the +[install record](#the-install-record)), and **no** takes the whole list, or the +games of the card when it holds some. The plan screen says what it installs, its size and where, before anything is written. **Details** (`d`) adds the rest: how many files are in place, the free space, the files left out and why. @@ -371,6 +377,8 @@ file list is downloaded once and handed to each run. that archive in use and downloads its own copy. A file already there with other contents is moved aside first, see [Files already in the folder](#files-you-already-had). 8. Copies into standalone emulator directories, only with `--standalone-copies`. +9. Writes the [install record](#the-install-record): each file it placed, with + its SHA-1, and the choice it made. The file list names the version of the platform it was built for (Batocera 43.1, RetroBat 8.3.1). Where the system writes its own version in a file, the @@ -444,6 +452,61 @@ ones, grouped by system with the emulators that read them, and point at the emulator pages. They do not change the exit code of `--check`, since no run of the installer can fetch them. +## The install record { #the-install-record } + +An install that places files writes what it placed in a record beside the BIOS +folder: `retrobios-backup/.install.json`, in the same holder as +the [backups](#files-you-already-had). A folder with nothing writable beside it +(a drive's root) keeps it in `retrobios-backup` inside the folder. It is never +loose in the BIOS folder, which DuckStation, SwanStation and Rustation read +whole. On a card it travels with the card. + +The record is JSON, one entry per setup: + +```json +{"schema": 1, "installer": "48e57b2f26e2", + "folder": "/home/me/.config/retroarch/system", + "setups": {"retroarch": { + "written": "2026-11-10T09:48:36Z", + "list": {"version": "v1.22.2", "generated": "2026-11-10T01:52:59Z"}, + "choice": ["--system", "nintendo-gba"], + "files": [{"dest": "gba_bios.bin", "root": "", "sha1": "4176d5fc...", + "size": 16384, "from": "download"}]}}} +``` + +`files` holds each file this run and the earlier ones wrote, with the SHA-1 +checked before it was moved into place: `root` is empty for the BIOS folder, +`save` for the save directory and `data` for a data tree's member, and `from` +says `download`, `copy` (this machine, `--from`) or `pack`. A file that was +already in place is not added: the record names only what the installer wrote. +`choice` is the options of the run that decided what to install, `--all-files` +for the whole list. + +The next run for the same setup and folder takes that choice when nothing on +the command line chose: line mode installs the same systems +(`Installing 1 file (16.0 KB) for RetroArch, same choice as on 10 Nov.`), and +the full screen opens on it (**Same choice as on 10 Nov: yes**). Any option of +the choice (`--system`, `--core`, `--region`, `--for-games`, `--no-game-data`, +`--target`, `--every-board`, `--emulator`, `--installed-cores`, `--declared-only`) +replaces it, `--all-files` installs the whole list, and a run over several +setups or from a file list (`--manifest`) never reads it. `--check` and +`--dry-run` read it too, and say so. To forget it, delete the file. + +A record is read only when it is a regular file of at most 16 MiB whose +choice holds nothing but the options above and their values. A games folder +(`--for-games FOLDER`) is kept only under the folder that holds the BIOS +folder, so a record never sends the games scan to another drive or a network +share: any other folder is recorded as `--for-games` alone, the card's own +games. A record that breaks any of this, or cannot be read, is said once and +ignored, and the next install writes a new one, keeping the other setups' +entries that read. One written by a newer installer is left as it is. A +recorded option the list can no longer apply (a region, an emulator, a board, +the installed cores, game data on a list that marks none) is left out in one +line, `Left out of the recorded choice: --target (...)`, and the run goes on: +typed on the command line, the same option is refused as before. A record kept +for a folder of another name (copied or renamed with it) is not applied. With `--events` the record +gives a `record` event, see [Machine-readable events](#machine-readable-events). + ## Reading a run { #reading-a-run } @@ -1884,11 +1947,12 @@ other schemas under `api/v1/schemas/`. | `downloaded` | `dest`, `ok`, `reason`, `size`, `files_done`, `files_total`, `bytes_done`, `bytes_total` | Each finished file, downloaded or copied from where a `found` event named it. `reason` says why one failed, and is empty otherwise | | `summary` | `mode`, `installed`, `up_to_date`, `pending`, `failed`, `skipped`, `left_out`, `kept`, `interrupted` | The outcome. `pending` counts what a next run would still fetch, `failed` lists `{dest, reason}`, `skipped` lists `{reason, label, files, required}` for the files the collection cannot supply, `left_out` lists `{category, label, files, dests}` for what `--no-game-data` left out, and is empty without it. `kept` lists `{folder, files, bytes}`, one per backup folder, for the files in place the run moved aside before replacing them (with `--dry-run`, would move), and is empty when none. Not written for a listing | | `app` | `system`, `folder`, `ok`, `reason`, `dests`, `written`, `kept` | After `summary`, with `--add-app` or a yes to its question: the RetroBIOS app on the card of `system` whose root is `folder`. `dests` lists the app's files below that root, `written` the ones this run wrote (empty when each was in place), `kept` the backup folders as in `summary`. `ok` is false, with `reason`, when the card takes no app (before anything is written, code 2), when the install did not finish, or when a file could not be fetched, verified or written (code 1) | +| `record` | `platform`, `folder`, `path`, `status`, `choice`, `date`, `files`, `message` | The [install record](#the-install-record) of `folder`, kept at `path`. `status` is `applied` before `choices` when the run took its choice, nothing on the command line having chosen, `written` before `summary` once an install placed files, `unreadable` when it cannot be read (said once, ignored), or `not_written` when it could not be saved, the install itself unharmed. `choice` is the recorded options as words, `date` when it was written (UTC), `files` how many files it holds for the platform | | `end` | `code` | Last line, with the exit status the process returns (see [Exit status](#exit-status)) | -Schema 1 can gain a kind (`found` came with `--from`, `declared` with `--declared-only`, `unsearched` with the search that goes on past a closed folder, `app` with `--add-app`, `data` with the size of the data trees, `version` with the list's version), never a key of an +Schema 1 can gain a kind (`found` came with `--from`, `declared` with `--declared-only`, `unsearched` with the search that goes on past a closed folder, `app` with `--add-app`, `data` with the size of the data trees, `version` with the list's version, `record` with the install record), never a key of an existing kind: a reader skips a kind it does not know. An option refused before the run starts (`install.py: error:`, code 2) writes no event at all. A program reads a missing `end` as that case, or as a