From 8f2018e7c2be56056306bda3e61b58eefa651793 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:46:57 +0100 Subject: [PATCH 1/2] feat: remember the install and open on the plan An install writes retrobios-backup/.install.json beside the BIOS folder: each file it placed with its sha1, size and source, and the choice it made. The next run for the same setup and folder takes that choice when the command line chose nothing, in line mode and on the full screen, and says so. A record that cannot be read is said once and ignored. A record event reports it. With one setup found, the full screen opens on its plan, Change where one row away. The plan's Install row says the size its headline says. --- install.ps1 | 2 +- install.py | 640 ++++++++++++++++-- install.sh | 2 +- install/launchers/index.json | 16 +- schemas/install-events.schema.json | 57 ++ scripts/generate_event_schema.py | 5 +- tests/fixtures/tui/colour-120x40.txt | 41 ++ tests/fixtures/tui/colour-40x12.txt | 13 + tests/fixtures/tui/colour-40x15.txt | 16 + tests/fixtures/tui/colour-80x24.txt | 25 + tests/fixtures/tui/plan-record-120x40.txt | 40 ++ tests/fixtures/tui/plan-record-40x12.txt | 12 + tests/fixtures/tui/plan-record-40x15.txt | 15 + tests/fixtures/tui/plan-record-80x24.txt | 24 + tests/fixtures/tui/route-evmapy.txt | 54 ++ tests/fixtures/tui/route-gptokeyb.txt | 54 ++ tests/fixtures/tui/route-gptokeyb_muxterm.txt | 54 ++ tests/fixtures/tui/route-keys.txt | 54 ++ tests/fixtures/tui/route-miyoo.txt | 54 ++ tests/fixtures/tui/route-muxterm.txt | 54 ++ tests/test_install.py | 18 +- tests/test_install_journeys.py | 209 +++++- tests/test_install_tui.py | 238 +++++-- wiki/first-install.md | 9 +- wiki/installer.md | 58 +- 25 files changed, 1621 insertions(+), 143 deletions(-) create mode 100644 tests/fixtures/tui/plan-record-120x40.txt create mode 100644 tests/fixtures/tui/plan-record-40x12.txt create mode 100644 tests/fixtures/tui/plan-record-40x15.txt create mode 100644 tests/fixtures/tui/plan-record-80x24.txt diff --git a/install.ps1 b/install.ps1 index 75033ba8b..561f5f9d0 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 = "70e71268db3c09fd3e3c9bd823bab905de73b01e95affcdae9db344a9f4087ca" # 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..0b12df28a 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,270 @@ 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)] + + +def _valid_setup(setup: object) -> 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 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)) + + +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.""" + for path in record_paths(folder): + try: + text = path.read_text(encoding="utf-8") + except (FileNotFoundError, NotADirectoryError): + continue + except (OSError, UnicodeDecodeError) as exc: + reason = getattr(exc, "strerror", None) or str(exc) + return None, path, f"The install record {path} cannot be read ({reason})" + try: + data = json.loads(text) + except ValueError: + return None, path, f"The install record {path} is damaged" + if not isinstance(data, dict) or not isinstance(data.get("setups"), dict): + return None, path, f"The install record {path} is damaged" + if data.get("schema") != RECORD_SCHEMA: + return None, path, (f"The install record {path} was written by another " + "version of the installer") + if not all(_valid_setup(setup) for setup in data["setups"].values()): + return None, path, f"The install record {path} is damaged" + return data, path, "" + return None, None, "" + + +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.""" + data, path, problem = read_record(folder) + if data is None: + return None, path, problem + written_for = str(data.get("folder") or "") + if Path(written_for).name != Path(os.path.abspath(folder)).name: + return None, path, "" + return data["setups"].get(plat), 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")) + except (KeyError, ValueError): + return "" + local = when.astimezone() + 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)) + data, found, _problem = read_record(folder) + if data is None: + data = {"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 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"] + 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 +7042,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 +7057,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 +8233,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 +8278,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 +9875,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 +9899,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 +9954,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 +10494,20 @@ 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: + detail(f" {value} is no longer on the list: left out of the recorded choice") + continue + kept.append(value) + return kept + + def _requested_narrowing( args: argparse.Namespace, plat: str, files: list[dict], omitted: list[dict], manifest: dict | None = None, @@ -10234,6 +10536,14 @@ def _requested_narrowing( print(_narrowing_advice(plat, axis, flag, recorded, pack_alone), file=sys.stderr) sys.exit(1) choices = available_choices(files, omitted) + if getattr(args, "from_record", False): + # A name the list no longer holds is dropped from a recorded choice, + # never refused: nobody typed it. + args.system = _known_names(args.system, choices["systems"], "system", + system_aliases(manifest)) or None + args.core = _known_names(args.core, choices["cores"], "core") or None + if not (args.system or args.core or args.region): + return None try: systems = resolve_choices( args.system or [], choices["systems"], "system", system_aliases(manifest), @@ -16101,6 +16411,42 @@ 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 + options = _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): + if apply_record(args, *platforms[0]) is not None and args.target: + known = fetch_targets(platforms[0][0]) + if known is not None and args.target not in known: + # A board the list no longer names: every board's files. + args.target = None + # --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. @@ -16409,6 +16755,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. + recorded = _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 +16765,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 +16808,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 recorded is not None: + # Enter on the recorded choice: still the record's options. + _INVOCATION.chosen[plat_name] = recorded + 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 +16925,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 +17031,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 +17073,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 +17150,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 +17246,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 +17257,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 +17304,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 +17347,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") @@ -18019,6 +18398,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 +18864,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)): @@ -18960,14 +19355,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 +19399,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 +19412,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 +19917,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 +20021,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 +20623,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 +22000,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 +22500,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() @@ -22603,6 +23129,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 +23184,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: @@ -24065,6 +24608,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 +24681,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 +25284,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: diff --git a/install.sh b/install.sh index a606cca06..c206e9c2a 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="70e71268db3c09fd3e3c9bd823bab905de73b01e95affcdae9db344a9f4087ca" # 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..30a747d52 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": 1189759, + "sha256": "70e71268db3c09fd3e3c9bd823bab905de73b01e95affcdae9db344a9f4087ca", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1216077, - "sha256": "b9e623839a8cf71d929a860b53a6c4c82cbfde551151912e1aba0347532be965" + "size": 1243384, + "sha256": "ae0d36d5f2dda70c39bfe8be474108b92f0a5a80457bc8c07d36736b09b0310d" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 1162452, - "sha256": "456a32e79668e125f2ebe573b549247210c59fed6389e78dc1482b57d3a36712", + "size": 1189759, + "sha256": "70e71268db3c09fd3e3c9bd823bab905de73b01e95affcdae9db344a9f4087ca", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 1162452, - "sha256": "456a32e79668e125f2ebe573b549247210c59fed6389e78dc1482b57d3a36712", + "size": 1189759, + "sha256": "70e71268db3c09fd3e3c9bd823bab905de73b01e95affcdae9db344a9f4087ca", "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..e98a2fc58 100644 --- a/tests/test_install_journeys.py +++ b/tests/test_install_journeys.py @@ -41,15 +41,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 +60,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 +79,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 +227,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 +246,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 +268,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 +279,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 +297,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 +332,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 +481,143 @@ 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"]) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index eae7a1ed3..cabc3dc56 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,102 @@ 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_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 +2539,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 +4682,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 +5088,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 +5141,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 +5319,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 +5329,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 +5342,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 +5354,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 +5407,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 +5442,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 +5453,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 +5539,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 +5772,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 +5832,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 +5847,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 +5869,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 +5925,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 +10961,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 +11613,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 +11638,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 +12519,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 +12532,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 +12554,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 +12574,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 +12615,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 +12656,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 +12808,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 +13125,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 +13324,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 +15095,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 0b42087a7..fd0269e33 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -169,8 +169,13 @@ 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. 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 +376,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 +451,50 @@ 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 +that cannot be read, or that another version wrote, is said once and ignored, +and the next install writes a new one. 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 } @@ -1885,11 +1936,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 From 5f3f4cff384f2c1add98c01e683545eaeff915ac Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:17:08 +0100 Subject: [PATCH 2/2] fix: check the install record before using it A record's choice holds only the options of the choice and their values, or the whole record is said once and ignored: it no longer reaches the commands printed for the user. A recorded games folder is kept only under the folder that holds the BIOS folder. A recorded option the list can no longer apply is left out in one line, never refused. The record is read only when it is a regular file of at most 16 MiB, its day is unknown when no clock can show it, one a newer installer wrote is left as it is, and a setup that cannot be used is dropped alone. The plan opened on the one setup found keeps that screen's notes under Details. --- install.ps1 | 2 +- install.py | 341 ++++++++++++++++++++++++++++----- install.sh | 2 +- install/launchers/index.json | 16 +- tests/test_install_journeys.py | 140 ++++++++++++++ tests/test_install_tui.py | 30 +++ wiki/installer.md | 22 ++- 7 files changed, 489 insertions(+), 64 deletions(-) diff --git a/install.ps1 b/install.ps1 index 561f5f9d0..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 = "70e71268db3c09fd3e3c9bd823bab905de73b01e95affcdae9db344a9f4087ca" + $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 0b12df28a..0d0031846 100644 --- a/install.py +++ b/install.py @@ -6136,63 +6136,165 @@ def record_paths(folder: Path) -> list[Path]: return [holder / name for holder, _prefix in KeptFiles._holders(folder)] -def _valid_setup(setup: object) -> bool: +# 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 isinstance(files, 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)) -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.""" +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 = path.read_text(encoding="utf-8") + 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 None, path, f"The install record {path} cannot be read ({reason})" + return RecordRead(None, path, f"The install record {path} cannot be read ({reason})") try: data = json.loads(text) except ValueError: - return None, path, f"The install record {path} is damaged" - if not isinstance(data, dict) or not isinstance(data.get("setups"), dict): - return None, path, f"The install record {path} is damaged" - if data.get("schema") != RECORD_SCHEMA: - return None, path, (f"The install record {path} was written by another " - "version of the installer") - if not all(_valid_setup(setup) for setup in data["setups"].values()): - return None, path, f"The install record {path} is damaged" - return data, path, "" - return None, None, "" + 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.""" - data, path, problem = read_record(folder) - if data is None: - return None, path, problem - written_for = str(data.get("folder") or "") + 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, path, "" - return data["setups"].get(plat), path, "" + 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")) - except (KeyError, ValueError): + # Year 1 or 9999 cannot be moved to local time: an unknown day. + local = when.astimezone() + except (KeyError, TypeError, AttributeError, ValueError, OverflowError, OSError): return "" - local = when.astimezone() day = f"{local.day} {_MONTHS[local.month - 1]}" return day if local.year == datetime.datetime.now().year else f"{day} {local.year}" @@ -6265,9 +6367,18 @@ def write_record( 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)) - data, found, _problem = read_record(folder) - if data is None: - data = {"setups": {}} + 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 []} @@ -6339,6 +6450,29 @@ def say_record_problem(plat: str, folder: Path, path: Path | None, problem: str) 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, @@ -6365,6 +6499,12 @@ def apply_record(args: argparse.Namespace, plat: str, folder: Path) -> dict | No # 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"], @@ -10502,12 +10642,47 @@ def _known_names(values: list[str] | None, known: list[str], label: str, try: resolve_choices([value], known, label, aliases) except ValueError: - detail(f" {value} is no longer on the list: left out of the recorded choice") 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, @@ -10524,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 @@ -10536,14 +10727,6 @@ def _requested_narrowing( print(_narrowing_advice(plat, axis, flag, recorded, pack_alone), file=sys.stderr) sys.exit(1) choices = available_choices(files, omitted) - if getattr(args, "from_record", False): - # A name the list no longer holds is dropped from a recorded choice, - # never refused: nobody typed it. - args.system = _known_names(args.system, choices["systems"], "system", - system_aliases(manifest)) or None - args.core = _known_names(args.core, choices["cores"], "core") or None - if not (args.system or args.core or args.region): - return None try: systems = resolve_choices( args.system or [], choices["systems"], "system", system_aliases(manifest), @@ -14577,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. @@ -16424,7 +16609,12 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: if not written and setup_record(plat, folder)[0] is None: # Nothing of ours is there to record, and no choice to keep. return - options = _INVOCATION.flags(plat) + # 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 [])] @@ -16441,16 +16631,23 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: f"Recorded {_plural(count, 'file')} and this choice in {path}.") if len(platforms) == 1 and not listing and not choice_given(args): - if apply_record(args, *platforms[0]) is not None and args.target: - known = fetch_targets(platforms[0][0]) - if known is not None and args.target not in known: - # A board the list no longer names: every board's files. - args.target = None + 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) @@ -16485,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. " @@ -16493,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: @@ -16536,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. @@ -16619,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( @@ -16644,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 @@ -16756,7 +16966,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: return _INVOCATION.command(plat, folder) # The record's options, which the menu's commands overwrite. - recorded = _INVOCATION.chosen.get(plat_name) + 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, @@ -16808,9 +17018,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: _INVOCATION.chosen[plat_name] = [] else: _INVOCATION.chosen.pop(plat_name, None) - if kept_record and recorded is not 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] = recorded + _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 @@ -17465,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 @@ -18900,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.""" @@ -22675,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. @@ -23722,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 @@ -25303,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 c206e9c2a..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="70e71268db3c09fd3e3c9bd823bab905de73b01e95affcdae9db344a9f4087ca" +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 30a747d52..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": 1189759, - "sha256": "70e71268db3c09fd3e3c9bd823bab905de73b01e95affcdae9db344a9f4087ca", + "size": 1201450, + "sha256": "37442a7c0d307b0b95a40d7630cf57025a6498fea6223076892371b2d6363147", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1243384, - "sha256": "ae0d36d5f2dda70c39bfe8be474108b92f0a5a80457bc8c07d36736b09b0310d" + "size": 1255075, + "sha256": "23393eb6b88f7a7875bd034e79c02d81e80409c9403290bb96a75166bf7a60b6" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 1189759, - "sha256": "70e71268db3c09fd3e3c9bd823bab905de73b01e95affcdae9db344a9f4087ca", + "size": 1201450, + "sha256": "37442a7c0d307b0b95a40d7630cf57025a6498fea6223076892371b2d6363147", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 1189759, - "sha256": "70e71268db3c09fd3e3c9bd823bab905de73b01e95affcdae9db344a9f4087ca", + "size": 1201450, + "sha256": "37442a7c0d307b0b95a40d7630cf57025a6498fea6223076892371b2d6363147", "executable": false }, { diff --git a/tests/test_install_journeys.py b/tests/test_install_journeys.py index e98a2fc58..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 @@ -619,5 +620,144 @@ class InstallRecord(unittest.TestCase): 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 cabc3dc56..e8e69cf95 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -1148,6 +1148,36 @@ class RecordedPlan(unittest.TestCase): 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())), \ diff --git a/wiki/installer.md b/wiki/installer.md index fd0269e33..6d348f2aa 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -172,7 +172,8 @@ Run at a terminal, the installer opens a full screen that a keyboard or a 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. After an install, the plan of the same folder opens +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 @@ -489,10 +490,21 @@ 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 -that cannot be read, or that another version wrote, is said once and ignored, -and the next install writes a new one. A record kept for a folder of another -name (copied or renamed with it) is not applied. With `--events` the record +`--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 }